PK       ! ]s      PDFEmbedHooks.phpnu [        <?php

use MediaWiki\MediaWikiServices;

class PDFEmbedHooks {
	/**
	 * Sets up this extensions parser functions.
	 *
	 * @param Parser $parser
	 */
	public static function onParserFirstCallInit( Parser $parser ) {
		$parser->setHook( 'pdf', [ __CLASS__, 'generateTag' ] );
	}

	/**
	 * Generates the PDF object tag.
	 *
	 * @param string $file Namespace prefixed article of the PDF file to display.
	 * @param array	$args Arguments on the tag.
	 * @param Parser $parser
	 * @param PPFrame $frame
	 * @return string HTML
	 */
	public static function generateTag( $file, $args, Parser $parser, PPFrame $frame ) {
		global $wgPdfEmbed;
		$parser->getOutput()->updateCacheExpiry( 0 );

		if ( strstr( $file, '{{{' ) !== false ) {
			$file = $parser->recursiveTagParse( $file, $frame );
		}

		$context = RequestContext::getMain();
		$request = $context->getRequest();

		$services = MediaWikiServices::getInstance();
		if ( $request->getVal( 'action' ) == 'edit' || $request->getVal( 'action' ) == 'submit' ) {
			$user = $context->getUser();
		} else {
			$user = $services->getUserFactory()->newFromName(
				$parser->getRevisionUser() ?? 'Unknown user'
			);
		}

		if ( !$user ) {
			return self::error( 'embed_pdf_invalid_user' );
		}

		if ( !$user->isAllowed( 'embed_pdf' ) ) {
			return self::error( 'embed_pdf_no_permission' );
		}

		if ( !$file || !preg_match( '#(.+?)\.pdf#is', $file ) ) {
			return self::error( 'embed_pdf_blank_file' );
		}

		$title = $services->getTitleFactory()->newFromText( $file );
		if ( !$title ) {
			return self::error( 'embed_pdf_blank_file' );
		}

		$file = $services->getRepoGroup()->findFile( $title );
		if ( array_key_exists( 'width', $args ) ) {
			$width = intval( $parser->recursiveTagParse( $args['width'], $frame ) );
		} else {
			$width = intval( $wgPdfEmbed['width'] );
		}

		if ( array_key_exists( 'height', $args ) ) {
			$height = intval( $parser->recursiveTagParse( $args['height'], $frame ) );
		} else {
			$height = intval( $wgPdfEmbed['height'] );
		}

		if ( array_key_exists( 'page', $args ) ) {
			$page = intval( $parser->recursiveTagParse( $args['page'], $frame ) );
		} else {
			$page = 1;
		}

		if ( $file !== false ) {
			return self::embed( $file, $width, $height, $page );
		} else {
			return self::error( 'embed_pdf_invalid_file' );
		}
	}

	/**
	 * Returns a HTML object as string.
	 *
	 * @param File $file
	 * @param int $width Width of the object.
	 * @param int $height Height of the object.
	 * @param int $page
	 * @return string HTML object.
	 */
	private static function embed( File $file, $width, $height, $page ) {
		return Html::rawElement(
			'iframe',
			[
				'width' => $width,
				'height' => $height,
				'src' => $file->getFullUrl() . '#page=' . $page,
				'style' => 'max-width: 100%;',
				'loading' => 'lazy',
			]
		);
	}

	/**
	 * Returns a standard error message.
	 *
	 * @param string $messageKey Error message key to display.
	 * @return string HTML error message.
	 */
	private static function error( $messageKey ) {
		return Html::errorBox( wfMessage( $messageKey )->escaped() );
	}
}
PK       ! ZYE    	  Hooks.phpnu [        <?php

namespace CookieWarning;

use Config;
use ConfigException;
use Html;
use MediaWiki;
use MediaWiki\Hook\BeforeInitializeHook;
use MediaWiki\Hook\BeforePageDisplayHook;
use MediaWiki\Hook\SkinAfterContentHook;
use MediaWiki\Preferences\Hook\GetPreferencesHook;
use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
use MediaWiki\User\Options\UserOptionsManager;
use MWException;
use OOUI\ButtonInputWidget;
use OOUI\ButtonWidget;
use OOUI\HorizontalLayout;
use OutputPage;
use Skin;
use Title;
use User;
use WebRequest;

class Hooks implements
	SkinAfterContentHook,
	GetPreferencesHook,
	BeforeInitializeHook,
	BeforePageDisplayHook,
	ResourceLoaderGetConfigVarsHook
{
	private Config $config;
	private Decisions $decisions;
	private UserOptionsManager $userOptionsManager;

	public function __construct(
		Config $config,
		Decisions $decisions,
		UserOptionsManager $userOptionsManager
	) {
		$this->config = $config;
		$this->decisions = $decisions;
		$this->userOptionsManager = $userOptionsManager;
	}

	/**
	 * BeforeInitialize hook handler.
	 *
	 * If the disablecookiewarning POST data is send, disables the cookiewarning bar with a
	 * cookie or a user preference, if the user is logged in.
	 *
	 * @param Title $title
	 * @param null $unused
	 * @param OutputPage $output
	 * @param User $user
	 * @param WebRequest $request
	 * @param MediaWiki $mediawiki
	 * @throws MWException
	 */
	public function onBeforeInitialize( $title, $unused, $output, $user, $request, $mediawiki ) {
		if ( !$request->wasPosted() || !$request->getVal( 'disablecookiewarning' ) ) {
			return;
		}

		if ( $user->isRegistered() ) {
			$this->userOptionsManager->setOption( $user, 'cookiewarning_dismissed', 1 );
			$this->userOptionsManager->saveOptions( $user );
		} else {
			$request->response()->setCookie( 'cookiewarning_dismissed', true );
		}
		$output->redirect( $request->getRequestURL() );
	}

	/**
	 * SkinAfterContent hook handler.
	 *
	 * Adds the CookieWarning information bar to the output html.
	 *
	 * @param string &$data
	 * @param Skin $skin
	 *
	 * @throws MWException
	 */
	public function onSkinAfterContent( &$data, $skin ) {
		if ( !$this->decisions->shouldShowCookieWarning( $skin->getContext() ) ) {
			return;
		}

		$data .= $this->generateElements( $skin );
	}

	/**
	 * Generates the elements for the banner.
	 *
	 * @param Skin $skin
	 * @return string|null The html for cookie notice.
	 */
	private function generateElements( Skin $skin ): ?string {
		$moreLink = $this->getMoreLink();

		$buttons = [];
		if ( $moreLink ) {
			$buttons[] = new ButtonWidget( [
				'href' => $moreLink,
				'label' => $skin->msg( 'cookiewarning-moreinfo-label' )->text(),
				'flags' => [ 'progressive' ]
			] );
		}
		$buttons[] = new ButtonInputWidget( [
			'type' => 'submit',
			'label' => $skin->msg( 'cookiewarning-ok-label' )->text(),
			'name' => 'disablecookiewarning',
			'value' => 'OK',
			'flags' => [ 'primary', 'progressive' ]
		] );

		$form = Html::rawElement(
			'form',
			[ 'method' => 'POST' ],
			new HorizontalLayout( [ 'items' => $buttons ] )
		);

		return Html::openElement(
				'div',
				[ 'class' => 'mw-cookiewarning-container' ]
			) .
			Html::openElement(
				'div',
				[ 'class' => 'mw-cookiewarning-text' ]
			) .
			Html::element(
				'span',
				[],
				$skin->msg( 'cookiewarning-info' )->text()
			) .
			Html::closeElement( 'div' ) .
			$form .
			Html::closeElement( 'div' );
	}

	/**
	 * Returns the target for the "More information" link of the cookie warning bar, if one is set.
	 * The link can be set by either (checked in this order):
	 *  - the configuration variable $wgCookieWarningMoreUrl
	 *  - the interface message MediaWiki:Cookiewarning-more-link
	 *  - the interface message MediaWiki:Cookie-policy-link (bc T145781)
	 *
	 * @return string|null The url or null if none set
	 * @throws ConfigException
	 */
	private function getMoreLink(): ?string {
		if ( $this->config->get( 'CookieWarningMoreUrl' ) ) {
			return $this->config->get( 'CookieWarningMoreUrl' );
		}

		$cookieWarningMessage = wfMessage( 'cookiewarning-more-link' );
		if ( $cookieWarningMessage->exists() && !$cookieWarningMessage->isDisabled() ) {
			return $cookieWarningMessage->text();
		}

		$cookiePolicyMessage = wfMessage( 'cookie-policy-link' );
		if ( $cookiePolicyMessage->exists() && !$cookiePolicyMessage->isDisabled() ) {
			return $cookiePolicyMessage->text();
		}

		return null;
	}

	/**
	 * BeforePageDisplay hook handler.
	 *
	 * Adds the required style and JS module, if cookiewarning is enabled.
	 *
	 * @param OutputPage $out
	 * @param Skin $skin
	 * @throws ConfigException
	 * @throws MWException
	 */
	public function onBeforePageDisplay( $out, $skin ): void {
		if ( !$this->decisions->shouldShowCookieWarning( $out->getContext() ) ) {
			return;
		}

		$modules = [ 'ext.CookieWarning' ];
		$moduleStyles = [ 'ext.CookieWarning.styles' ];

		if ( $this->decisions->shouldAddResourceLoaderComponents() ) {
			$modules[] = 'ext.CookieWarning.geolocation';
			$moduleStyles[] = 'ext.CookieWarning.geolocation.styles';
		}
		$out->addModules( $modules );
		$out->addModuleStyles( $moduleStyles );
		$out->enableOOUI();
	}

	/**
	 * ResourceLoaderGetConfigVars hook handler.
	 *
	 * @param array &$vars
	 * @param string $skin
	 * @param Config $config
	 *
	 * @throws ConfigException
	 */
	public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void {
		if ( $this->decisions->shouldAddResourceLoaderComponents() ) {
			$vars += [
				'wgCookieWarningGeoIPServiceURL' => $this->config->get( 'CookieWarningGeoIPServiceURL' ),
				'wgCookieWarningForCountryCodes' => $this->config->get( 'CookieWarningForCountryCodes' ),
			];
		}
	}

	/**
	 * GetPreferences hook handler
	 *
	 * @see https://www.mediawiki.org/wiki/Manual:Hooks/GetPreferences
	 *
	 * @param User $user
	 * @param array &$defaultPreferences
	 * @return bool
	 */
	public function onGetPreferences( $user, &$defaultPreferences ): bool {
		$defaultPreferences['cookiewarning_dismissed'] = [
			'type' => 'api',
			'default' => '0',
		];
		return true;
	}
}
PK       ! LV  V    SpecialNuke.phpnu Iw        <?php

namespace MediaWiki\Extension\Nuke;

use DeletePageJob;
use ErrorPageError;
use JobQueueGroup;
use MediaWiki\CheckUser\Services\CheckUserTemporaryAccountsByIPLookup;
use MediaWiki\CommentStore\CommentStore;
use MediaWiki\Extension\Nuke\Hooks\NukeHookRunner;
use MediaWiki\Html\Html;
use MediaWiki\Html\ListToggle;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Language\Language;
use MediaWiki\Page\File\FileDeleteForm;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Request\WebRequest;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\Title\Title;
use MediaWiki\User\Options\UserOptionsLookup;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserNamePrefixSearch;
use MediaWiki\User\UserNameUtils;
use MediaWiki\Xml\Xml;
use OOUI\DropdownInputWidget;
use OOUI\FieldLayout;
use OOUI\TextInputWidget;
use PermissionsError;
use RepoGroup;
use UserBlockedError;
use Wikimedia\IPUtils;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Rdbms\IExpression;
use Wikimedia\Rdbms\LikeMatch;
use Wikimedia\Rdbms\LikeValue;
use Wikimedia\Rdbms\SelectQueryBuilder;

class SpecialNuke extends SpecialPage {

	/** @var NukeHookRunner|null */
	private $hookRunner;

	private JobQueueGroup $jobQueueGroup;
	private IConnectionProvider $dbProvider;
	private PermissionManager $permissionManager;
	private RepoGroup $repoGroup;
	private UserFactory $userFactory;
	private UserOptionsLookup $userOptionsLookup;
	private UserNamePrefixSearch $userNamePrefixSearch;
	private UserNameUtils $userNameUtils;
	private NamespaceInfo $namespaceInfo;
	private Language $contentLanguage;
	/** @var CheckUserTemporaryAccountsByIPLookup|null */
	private $checkUserTemporaryAccountsByIPLookup = null;

	/**
	 * @inheritDoc
	 */
	public function __construct(
		JobQueueGroup $jobQueueGroup,
		IConnectionProvider $dbProvider,
		PermissionManager $permissionManager,
		RepoGroup $repoGroup,
		UserFactory $userFactory,
		UserOptionsLookup $userOptionsLookup,
		UserNamePrefixSearch $userNamePrefixSearch,
		UserNameUtils $userNameUtils,
		NamespaceInfo $namespaceInfo,
		Language $contentLanguage,
		$checkUserTemporaryAccountsByIPLookup = null
	) {
		parent::__construct( 'Nuke', 'nuke' );
		$this->jobQueueGroup = $jobQueueGroup;
		$this->dbProvider = $dbProvider;
		$this->permissionManager = $permissionManager;
		$this->repoGroup = $repoGroup;
		$this->userFactory = $userFactory;
		$this->userOptionsLookup = $userOptionsLookup;
		$this->userNamePrefixSearch = $userNamePrefixSearch;
		$this->userNameUtils = $userNameUtils;
		$this->namespaceInfo = $namespaceInfo;
		$this->contentLanguage = $contentLanguage;
		$this->checkUserTemporaryAccountsByIPLookup = $checkUserTemporaryAccountsByIPLookup;
	}

	/**
	 * @inheritDoc
	 * @codeCoverageIgnore
	 */
	public function doesWrites() {
		return true;
	}

	/**
	 * @param null|string $par
	 */
	public function execute( $par ) {
		$this->setHeaders();
		$this->checkPermissions();
		$this->checkReadOnly();
		$this->outputHeader();
		$this->addHelpLink( 'Help:Extension:Nuke' );

		$currentUser = $this->getUser();
		$block = $currentUser->getBlock();

		// appliesToRight is presently a no-op, since there is no handling for `delete`,
		// and so will return `null`. `true` will be returned if the block actively
		// applies to `delete`, and both `null` and `true` should result in an error
		if ( $block && ( $block->isSitewide() ||
			( $block->appliesToRight( 'delete' ) !== false ) )
		) {
			throw new UserBlockedError( $block );
		}

		$req = $this->getRequest();
		$target = trim( $req->getText( 'target', $par ?? '' ) );

		// Normalise name
		if ( $target !== '' ) {
			$user = $this->userFactory->newFromName( $target );
			if ( $user ) {
				$target = $user->getName();
			}
		}

		$reason = $this->getDeleteReason( $this->getRequest(), $target );

		$limit = $req->getInt( 'limit', 500 );
		$namespace = $req->getIntOrNull( 'namespace' );

		if ( $req->wasPosted()
			&& $currentUser->matchEditToken( $req->getVal( 'wpEditToken' ) )
		) {
			if ( $req->getRawVal( 'action' ) === 'delete' ) {
				$pages = $req->getArray( 'pages' );

				if ( $pages ) {
					$this->doDelete( $pages, $reason );
					return;
				}
			} elseif ( $req->getRawVal( 'action' ) === 'submit' ) {
				// if the target is an ip addresss and temp account lookup is available,
				// list pages created by the ip user or by temp accounts associated with the ip address
				if (
					$this->checkUserTemporaryAccountsByIPLookup &&
					IPUtils::isValid( $target )
				) {
					$this->assertUserCanAccessTemporaryAccounts( $currentUser );
					$tempnames = $this->getTempAccountData( $target );
					$reason = $this->getDeleteReason( $this->getRequest(), $target, true );
					$this->listForm( $target, $reason, $limit, $namespace, $tempnames );
				} else {
					// otherwise just list pages normally
					$this->listForm( $target, $reason, $limit, $namespace );
				}
			} else {
				$this->promptForm();
			}
		} elseif ( $target === '' ) {
			$this->promptForm();
		} else {
			$this->listForm( $target, $reason, $limit, $namespace );
		}
	}

	/**
	 * Does the user have the appropriate permissions and have they enabled in preferences?
	 * Adapted from MediaWiki\CheckUser\Api\Rest\Handler\AbstractTemporaryAccountHandler::checkPermissions
	 *
	 * @param User $currentUser
	 *
	 * @throws PermissionsError if the user does not have the 'checkuser-temporary-account' right
	 * @throws ErrorPageError if the user has not enabled the 'checkuser-temporary-account-enabled' preference
	 */
	private function assertUserCanAccessTemporaryAccounts( User $currentUser ) {
		if (
			!$currentUser->isAllowed( 'checkuser-temporary-account-no-preference' )
		) {
			if (
				!$currentUser->isAllowed( 'checkuser-temporary-account' )
			) {
				throw new PermissionsError( 'checkuser-temporary-account' );
			}
			if (
				!$this->userOptionsLookup->getOption(
					$currentUser,
					'checkuser-temporary-account-enable'
				)
			) {
				throw new ErrorPageError(
					$this->msg( 'checkuser-ip-contributions-permission-error-title' ),
					$this->msg( 'checkuser-ip-contributions-permission-error-description' )
				);
			}
		}
	}

	/**
	 * Given an IP address, return a list of temporary accounts that are known to have edited from the IP.
	 *
	 * Calls to this method result in a log entry being generated for the logged-in user account making the request.
	 * @param string $ip The IP address used for looking up temporary account names.
	 * The address will be normalized in the IP lookup service.
	 * @return string[] A list of temporary account usernames associated with the IP address
	 */
	private function getTempAccountData( string $ip ): array {
		// Requires CheckUserTemporaryAccountsByIPLookup service
		if ( !$this->checkUserTemporaryAccountsByIPLookup ) {
			return [];
		}
		$status = $this->checkUserTemporaryAccountsByIPLookup->get(
			$ip,
			$this->getAuthority(),
			true
		);
		if ( $status->isGood() ) {
			return $status->getValue();
		}
		return [];
	}

	/**
	 * Prompt for a username or IP address.
	 *
	 * @param string $userName
	 */
	protected function promptForm( string $userName = '' ): void {
		$out = $this->getOutput();

		if ( $this->checkUserTemporaryAccountsByIPLookup ) {
			$out->addWikiMsg( 'nuke-tools-tempaccount' );
		} else {
			$out->addWikiMsg( 'nuke-tools' );
		}

		$formDescriptor = [
			'nuke-target' => [
				'id' => 'nuke-target',
				'default' => $userName,
				'label' => $this->msg( 'nuke-userorip' )->text(),
				'type' => 'user',
				'name' => 'target',
				'autofocus' => true
			],
			'nuke-pattern' => [
				'id' => 'nuke-pattern',
				'label' => $this->msg( 'nuke-pattern' )->text(),
				'maxLength' => 40,
				'type' => 'text',
				'name' => 'pattern'
			],
			'namespace' => [
				'id' => 'nuke-namespace',
				'type' => 'namespaceselect',
				'label' => $this->msg( 'nuke-namespace' )->text(),
				'all' => 'all',
				'name' => 'namespace'
			],
			'limit' => [
				'id' => 'nuke-limit',
				'maxLength' => 7,
				'default' => 500,
				'label' => $this->msg( 'nuke-maxpages' )->text(),
				'type' => 'int',
				'name' => 'limit'
			]
		];

		HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
			->setName( 'massdelete' )
			->setFormIdentifier( 'massdelete' )
			->setWrapperLegendMsg( 'nuke' )
			->setSubmitTextMsg( 'nuke-submit-user' )
			->setSubmitName( 'nuke-submit-user' )
			->setAction( $this->getPageTitle()->getLocalURL( 'action=submit' ) )
			->prepareForm()
			->displayForm( false );
	}

	/**
	 * Display list of pages to delete.
	 *
	 * @param string $username
	 * @param string $reason
	 * @param int $limit
	 * @param int|null $namespace
	 * @param string[] $tempnames
	 */
	protected function listForm( $username, $reason, $limit, $namespace = null, $tempnames = [] ): void {
		$out = $this->getOutput();

		$pages = $this->getNewPages( $username, $limit, $namespace, $tempnames );

		if ( !$pages ) {
			if ( $username === '' ) {
				$out->addWikiMsg( 'nuke-nopages-global' );
			} else {
				$out->addWikiMsg( 'nuke-nopages', $username );
			}

			$this->promptForm( $username );
			return;
		}

		$out->addModules( 'ext.nuke.confirm' );
		$out->addModuleStyles( [ 'ext.nuke.styles', 'mediawiki.interface.helpers.styles' ] );

		if ( $username === '' ) {
			$out->addWikiMsg( 'nuke-list-multiple' );
		} elseif ( $tempnames ) {
			$out->addWikiMsg( 'nuke-list-tempaccount', $username );
		} else {
			$out->addWikiMsg( 'nuke-list', $username );
		}

		$nuke = $this->getPageTitle();

		$options = Xml::listDropdownOptions(
			$this->msg( 'deletereason-dropdown' )->inContentLanguage()->text(),
			[ 'other' => $this->msg( 'deletereasonotherlist' )->inContentLanguage()->text() ]
		);

		$dropdown = new FieldLayout(
			new DropdownInputWidget( [
				'name' => 'wpDeleteReasonList',
				'inputId' => 'wpDeleteReasonList',
				'tabIndex' => 1,
				'infusable' => true,
				'value' => '',
				'options' => Xml::listDropdownOptionsOoui( $options ),
			] ),
			[
				'label' => $this->msg( 'deletecomment' )->text(),
				'align' => 'top',
			]
		);
		$reasonField = new FieldLayout(
			new TextInputWidget( [
				'name' => 'wpReason',
				'inputId' => 'wpReason',
				'tabIndex' => 2,
				'maxLength' => CommentStore::COMMENT_CHARACTER_LIMIT,
				'infusable' => true,
				'value' => $reason,
				'autofocus' => true,
			] ),
			[
				'label' => $this->msg( 'deleteotherreason' )->text(),
				'align' => 'top',
			]
		);

		$out->enableOOUI();
		$out->addHTML(
			Html::openElement( 'form', [
					'action' => $nuke->getLocalURL( 'action=delete' ),
					'method' => 'post',
					'name' => 'nukelist' ]
			) .
			Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) .
			$dropdown .
			$reasonField .
			// Select: All, None, Invert
			( new ListToggle( $this->getOutput() ) )->getHTML() .
			'<ul>'
		);

		$wordSeparator = $this->msg( 'word-separator' )->escaped();
		$commaSeparator = $this->msg( 'comma-separator' )->escaped();
		$pipeSeparator = $this->msg( 'pipe-separator' )->escaped();

		$linkRenderer = $this->getLinkRenderer();
		$localRepo = $this->repoGroup->getLocalRepo();
		foreach ( $pages as [ $title, $userName ] ) {
			/**
			 * @var $title Title
			 */

			$image = $title->inNamespace( NS_FILE ) ? $localRepo->newFile( $title ) : false;
			$thumb = $image && $image->exists() ?
				$image->transform( [ 'width' => 120, 'height' => 120 ], 0 ) :
				false;

			$userNameText = $userName ?
				' <span class="mw-changeslist-separator"></span> ' . $this->msg( 'nuke-editby', $userName )->parse() :
				'';
			$changesLink = $linkRenderer->makeKnownLink(
				$title,
				$this->msg( 'nuke-viewchanges' )->text(),
				[],
				[ 'action' => 'history' ]
			);

			$talkPageText = $this->namespaceInfo->isTalk( $title->getNamespace() ) ?
				'' :
				$linkRenderer->makeLink(
					$this->namespaceInfo->getTalkPage( $title ),
					$this->msg( 'sp-contributions-talk' )->text(),
					[],
					[],
				) . $wordSeparator . $pipeSeparator;

			$query = $title->isRedirect() ? [ 'redirect' => 'no' ] : [];
			$attributes = $title->isRedirect() ? [ 'class' => 'ext-nuke-italicize' ] : [];
			$out->addHTML( '<li>' .
				Html::check(
					'pages[]',
					true,
					[ 'value' => $title->getPrefixedDBkey() ]
				) . "\u{00A0}" .
				( $thumb ? $thumb->toHtml( [ 'desc-link' => true ] ) : '' ) .
				$linkRenderer->makeKnownLink( $title, null, $attributes, $query ) . $wordSeparator .
				$this->msg( 'parentheses' )->rawParams( $talkPageText . $changesLink )->escaped() . $wordSeparator .
				"<span class='ext-nuke-italicize'>" . $userNameText . "</span>" .
				"</li>\n" );
		}

		$out->addHTML(
			"</ul>\n" .
			Html::submitButton( $this->msg( 'nuke-submit-delete' )->text() ) .
			'</form>'
		);
	}

	/**
	 * Gets a list of new pages by the specified user or everyone when none is specified.
	 *
	 * @param string $username
	 * @param int $limit
	 * @param int|null $namespace
	 * @param string[] $tempnames
	 *
	 * @return array{0:Title,1:string|false}[]
	 */
	protected function getNewPages( $username, $limit, $namespace = null, $tempnames = [] ): array {
		$dbr = $this->dbProvider->getReplicaDatabase();
		$queryBuilder = $dbr->newSelectQueryBuilder()
			->select( [ 'page_title', 'page_namespace' ] )
			->from( 'recentchanges' )
			->join( 'actor', null, 'actor_id=rc_actor' )
			->join( 'page', null, 'page_id=rc_cur_id' )
			->where(
				$dbr->expr( 'rc_source', '=', 'mw.new' )->orExpr(
					$dbr->expr( 'rc_log_type', '=', 'upload' )
						->and( 'rc_log_action', '=', 'upload' )
				)
			)
			->orderBy( 'rc_timestamp', SelectQueryBuilder::SORT_DESC )
			->limit( $limit );

		if ( $username === '' ) {
			$queryBuilder->field( 'actor_name', 'rc_user_text' );
		} else {
			$actornames = array_filter( [ $username, ...$tempnames ] );
			if ( $actornames ) {
				$queryBuilder->andWhere( [ 'actor_name' => $actornames ] );
			}
		}

		if ( $namespace !== null ) {
			$queryBuilder->andWhere( [ 'page_namespace' => $namespace ] );
		}

		$pattern = $this->getRequest()->getText( 'pattern' );
		if ( $pattern !== null && trim( $pattern ) !== '' ) {
			$addedWhere = false;

			$pattern = trim( $pattern );
			$pattern = preg_replace( '/ +/', '`_', $pattern );
			$pattern = preg_replace( '/\\\\([%_])/', '`$1', $pattern );

			if ( $namespace !== null ) {
				// Custom namespace requested
				// If that namespace capitalizes titles, capitalize the first character
				// to match the DB title.
				$pattern = $this->namespaceInfo->isCapitalized( $namespace ) ?
					$this->contentLanguage->ucfirst( $pattern ) : $pattern;
			} else {
				// All namespaces requested

				$overriddenNamespaces = [];
				$capitalLinks = $this->getConfig()->get( 'CapitalLinks' );
				$capitalLinkOverrides = $this->getConfig()->get( 'CapitalLinkOverrides' );
				// If there are any capital-overridden namespaces, keep track of them. "overridden"
				// here means the namespace-specific value is not equal to $wgCapitalLinks.
				foreach ( $capitalLinkOverrides as $k => $v ) {
					if ( $v !== $capitalLinks ) {
						$overriddenNamespaces[] = $k;
					}
				}

				if ( count( $overriddenNamespaces ) ) {
					// If there are overridden namespaces, they have to be converted
					// on a case-by-case basis.

					$validNamespaces = $this->namespaceInfo->getValidNamespaces();
					$nonOverriddenNamespaces = [];
					foreach ( $validNamespaces as $ns ) {
						if ( !in_array( $ns, $overriddenNamespaces ) ) {
							// Put all namespaces that aren't overridden in $nonOverriddenNamespaces
							$nonOverriddenNamespaces[] = $ns;
						}
					}

					$patternSpecific = $this->namespaceInfo->isCapitalized( $overriddenNamespaces[0] ) ?
						$this->contentLanguage->ucfirst( $pattern ) : $pattern;
					$orConditions = [
						$dbr->expr(
							'page_title', IExpression::LIKE, new LikeValue(
								new LikeMatch( $patternSpecific )
							)
						)->and(
							// IN condition
							'page_namespace', '=', $overriddenNamespaces
						)
					];
					if ( count( $nonOverriddenNamespaces ) ) {
						$patternStandard = $this->namespaceInfo->isCapitalized( $nonOverriddenNamespaces[0] ) ?
							$this->contentLanguage->ucfirst( $pattern ) : $pattern;
						$orConditions[] = $dbr->expr(
							'page_title', IExpression::LIKE, new LikeValue(
								new LikeMatch( $patternStandard )
							)
						)->and(
							// IN condition, with the non-overridden namespaces.
							// If the default is case-sensitive namespaces, $pattern's first
							// character is turned lowercase. Otherwise, it is turned uppercase.
							'page_namespace', '=', $nonOverriddenNamespaces
						);
					}
					$queryBuilder->andWhere( $dbr->orExpr( $orConditions ) );
					$addedWhere = true;
				} else {
					// No overridden namespaces; just convert all titles.
					$pattern = $this->namespaceInfo->isCapitalized( NS_MAIN ) ?
						$this->contentLanguage->ucfirst( $pattern ) : $pattern;
				}
			}

			if ( !$addedWhere ) {
				$queryBuilder->andWhere(
					$dbr->expr(
						'page_title',
						IExpression::LIKE,
						new LikeValue(
							new LikeMatch( $pattern )
						)
					)
				);
			}
		}

		$result = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
		/** @var array{0:Title,1:string|false}[] $pages */
		$pages = [];
		foreach ( $result as $row ) {
			$pages[] = [
				Title::makeTitle( $row->page_namespace, $row->page_title ),
				$username === '' ? $row->rc_user_text : false
			];
		}

		// Allows other extensions to provide pages to be nuked that don't use
		// the recentchanges table the way mediawiki-core does
		$this->getNukeHookRunner()->onNukeGetNewPages( $username, $pattern, $namespace, $limit, $pages );

		// Re-enforcing the limit *after* the hook because other extensions
		// may add and/or remove pages. We need to make sure we don't end up
		// with more pages than $limit.
		if ( count( $pages ) > $limit ) {
			$pages = array_slice( $pages, 0, $limit );
		}

		return $pages;
	}

	/**
	 * Does the actual deletion of the pages.
	 *
	 * @param array $pages The pages to delete
	 * @param string $reason
	 * @throws PermissionsError
	 */
	protected function doDelete( array $pages, $reason ): void {
		$res = [];
		$jobs = [];
		$user = $this->getUser();

		$localRepo = $this->repoGroup->getLocalRepo();
		foreach ( $pages as $page ) {
			$title = Title::newFromText( $page );

			$deletionResult = false;
			if ( !$this->getNukeHookRunner()->onNukeDeletePage( $title, $reason, $deletionResult ) ) {
				$res[] = $this->msg(
					$deletionResult ? 'nuke-deleted' : 'nuke-not-deleted',
					wfEscapeWikiText( $title->getPrefixedText() )
				)->parse();
				continue;
			}

			$permission_errors = $this->permissionManager->getPermissionErrors( 'delete', $user, $title );

			if ( $permission_errors !== [] ) {
				throw new PermissionsError( 'delete', $permission_errors );
			}

			$file = $title->getNamespace() === NS_FILE ? $localRepo->newFile( $title ) : false;
			if ( $file ) {
				// Must be passed by reference
				$oldimage = null;
				$status = FileDeleteForm::doDelete(
					$title,
					$file,
					$oldimage,
					$reason,
					false,
					$user
				);
			} else {
				$job = new DeletePageJob( [
					'namespace' => $title->getNamespace(),
					'title' => $title->getDBKey(),
					'reason' => $reason,
					'userId' => $user->getId(),
					'wikiPageId' => $title->getId(),
					'suppress' => false,
					'tags' => '["Nuke"]',
					'logsubtype' => 'delete',
				] );
				$jobs[] = $job;
				$status = 'job';
			}

			if ( $status === 'job' ) {
				$res[] = $this->msg(
					'nuke-deletion-queued',
					wfEscapeWikiText( $title->getPrefixedText() )
				)->parse();
			} else {
				$res[] = $this->msg(
					$status->isOK() ? 'nuke-deleted' : 'nuke-not-deleted',
					wfEscapeWikiText( $title->getPrefixedText() )
				)->parse();
			}
		}

		if ( $jobs ) {
			$this->jobQueueGroup->push( $jobs );
		}

		$this->getOutput()->addHTML(
			"<ul>\n<li>" .
			implode( "</li>\n<li>", $res ) .
			"</li>\n</ul>\n"
		);
		$this->getOutput()->addWikiMsg( 'nuke-delete-more' );
	}

	/**
	 * Return an array of subpages beginning with $search that this special page will accept.
	 *
	 * @param string $search Prefix to search for
	 * @param int $limit Maximum number of results to return (usually 10)
	 * @param int $offset Number of results to skip (usually 0)
	 * @return string[] Matching subpages
	 */
	public function prefixSearchSubpages( $search, $limit, $offset ) {
		$search = $this->userNameUtils->getCanonical( $search );
		if ( !$search ) {
			// No prefix suggestion for invalid user
			return [];
		}

		// Autocomplete subpage as user list - public to allow caching
		return $this->userNamePrefixSearch
			->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset );
	}

	/**
	 * Group Special:Nuke with pagetools
	 *
	 * @codeCoverageIgnore
	 * @return string
	 */
	protected function getGroupName() {
		return 'pagetools';
	}

	private function getDeleteReason( WebRequest $request, string $target, bool $tempaccount = false ): string {
		if ( $tempaccount ) {
			$defaultReason = $this->msg( 'nuke-defaultreason-tempaccount' );
		} else {
			$defaultReason = $target === ''
				? $this->msg( 'nuke-multiplepeople' )->inContentLanguage()->text()
				: $this->msg( 'nuke-defaultreason', $target )->inContentLanguage()->text();
		}

		$dropdownSelection = $request->getText( 'wpDeleteReasonList', 'other' );
		$reasonInput = $request->getText( 'wpReason', $defaultReason );

		if ( $dropdownSelection === 'other' ) {
			return $reasonInput;
		} elseif ( $reasonInput !== '' ) {
			// Entry from drop down menu + additional comment
			$separator = $this->msg( 'colon-separator' )->inContentLanguage()->text();
			return $dropdownSelection . $separator . $reasonInput;
		} else {
			return $dropdownSelection;
		}
	}

	private function getNukeHookRunner(): NukeHookRunner {
		$this->hookRunner ??= new NukeHookRunner( $this->getHookContainer() );
		return $this->hookRunner;
	}
}
PK       ! oH      Hooks/NukeGetNewPagesHook.phpnu Iw        <?php

namespace MediaWiki\Extension\Nuke\Hooks;

interface NukeGetNewPagesHook {

	/**
	 * Hook runner for the `NukeGetNewPages` hook
	 *
	 * After searching for pages to delete. Can be used to add and remove pages.
	 *
	 * @param string $username username filter applied
	 * @param ?string $pattern pattern filter applied
	 * @param ?int $namespace namespace filter applied
	 * @param int $limit limit filter applied
	 * @param array &$pages page titles already retrieved
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onNukeGetNewPages(
		string $username,
		?string $pattern,
		?int $namespace,
		int $limit,
		array &$pages
	);

}
PK       ! L       Hooks/NukeHookRunner.phpnu Iw        <?php

namespace MediaWiki\Extension\Nuke\Hooks;

use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Title\Title;

/**
 * Handle running Nuke's hooks
 * @author DannyS712
 */
class NukeHookRunner implements NukeDeletePageHook, NukeGetNewPagesHook {

	private HookContainer $hookContainer;

	public function __construct( HookContainer $hookContainer ) {
		$this->hookContainer = $hookContainer;
	}

	/**
	 * @inheritDoc
	 */
	public function onNukeDeletePage( Title $title, string $reason, bool &$deletionResult ) {
		return $this->hookContainer->run(
			'NukeDeletePage',
			[ $title, $reason, &$deletionResult ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onNukeGetNewPages(
		string $username,
		?string $pattern,
		?int $namespace,
		int $limit,
		array &$pages
	) {
		return $this->hookContainer->run(
			'NukeGetNewPages',
			[ $username, $pattern, $namespace, $limit, &$pages ]
		);
	}

}
PK       ! WL4l  l    Hooks/NukeDeletePageHook.phpnu Iw        <?php

namespace MediaWiki\Extension\Nuke\Hooks;

use MediaWiki\Title\Title;

interface NukeDeletePageHook {

	/**
	 * Hook runner for the `NukeDeletePage` hook
	 *
	 * Allows other extensions to handle the deletion of titles
	 *
	 * @param Title $title title to delete
	 * @param string $reason reason for deletion
	 * @param bool &$deletionResult Whether the deletion was successful or not
	 * @return bool|void True or no return value to let Nuke handle the deletion or
	 *  false if it was already handled in the hook.
	 */
	public function onNukeDeletePage( Title $title, string $reason, bool &$deletionResult );
}
PK       ! 9      ServiceWiring.phpnu [        <?php

use MediaWiki\Extension\EnhancedUpload\AttachmentTagModifier;
use MediaWiki\MediaWikiServices;

// PHP unit does not understand code coverage for this file
// as the @covers annotation cannot cover a specific file
// This is fully tested in ServiceWiringTest.php
// @codeCoverageIgnoreStart

return [
	'EnhancedUploadAttachmentTagModifier' => static function ( MediaWikiServices $services ) {
		return new AttachmentTagModifier( $services->getTitleFactory() );
	},
];

// @codeCoverageIgnoreEnd
PK       ! >_i      specials/SpecialLiveChat.phpnu [        <?php

namespace LiveChat;

use SpecialPage;

class SpecialLiveChat extends SpecialPage {

	public function __construct() {
		parent::__construct( 'LiveChat' );
	}

	/**
	 * @param string|null $subPage
	 */
	public function execute( $subPage ) {
		$output = $this->getOutput();
		$this->setHeaders();
		$htmlTitle = $this->msg( 'livechat' )->text();
		$output->setPageTitle( $htmlTitle );
		$output->setHTMLTitle( $htmlTitle );

		$output->addModules( 'ext.LiveChat.special.LiveChat' );
	}

}
PK       !       specials/SpecialLiveStatus.phpnu [        <?php

namespace LiveChat;

use SpecialPage;

class SpecialLiveStatus extends SpecialPage {

	public function __construct() {
		parent::__construct( 'LiveStatus', 'LiveChatManager' );
	}

	/**
	 * @param string|null $subPage
	 */
	public function execute( $subPage ) {
		$output = $this->getOutput();
		$this->setHeaders();
		$htmlTitle = $this->msg( 'livestatus' )->text();
		$output->setPageTitle( $htmlTitle );
		$output->setHTMLTitle( $htmlTitle );

		$output->addModules( 'ext.LiveChat.special.LiveStatus' );
	}

}
PK       ! "2  "2    Room.phpnu [        <?php
namespace LiveChat;

use ConfigException;
use Exception;
use FormatJson;
use MWDebug;
use RequestContext;
use Workerman\Connection\AsyncTcpConnection;
use Workerman\Connection\ConnectionInterface;

class Room {
	const O_ROOM_RESTRICTION = 'roomRestriction';
	const MANAGER_ACTION_SEND_TO_ALL = 'SendToAll';
	const MANAGER_ACTION_SYNC = 'sync';
	const TARGET_CONNECTION = 'cId';
	// const TARGET_USER = 'uId';

	/**
	 * @var int
	 */
	protected $roomId;

	const ROOM_TYPE = 0;

	/**
	 * @var array
	 */
	protected $options;

	/**
	 * @var Connection[]
	 */
	protected $connections = [];

	/**
	 * @var AsyncTcpConnection
	 */
	protected $managerConnection;

	/**
	 * @var array
	 */
	protected $users = [];

	/**
	 * @var array
	 */
	protected $managerTimers = [];

	/**
	 * Room constructor.
	 * @param int $id
	 * @param array $options
	 */
	public function __construct( int $id = 0, array $options = [] ) {
		$this->roomId = $id;
		$this->options = $options;
		$this->connectToManager();
	}

	/**
	 * @param Connection $connection
	 */
	public function addConnection( Connection $connection ) {
		$user = $connection->getUser();

		$restriction = $this->options[self::O_ROOM_RESTRICTION] ?? null;
		if ( $restriction ) {
			if ( !$user->isAllowed( $restriction ) ) {
				$this->debugLog( __FUNCTION__, 'Room is not allowed for user', static::class, $restriction, $user->getName() );
				$connection->sendErrorMessage( 'You are not allowed to connect to room ' . static::class );
				return;
			}
		}
		$this->debugLog( __FUNCTION__, $connection->getId() );
		$this->connections[$connection->getId()] = $connection;

		$userKey = $connection->getUserKey();
		if ( empty( $this->users[$userKey] ) ) {
			$this->users[$userKey] = [
				'count' => 1,
				'name' => $user->getName(),
			];
			if ( !$user->isAnon() ) {
				$this->users[$userKey]['id'] = $user->getId();
				$this->users[$userKey]['realName'] = $user->getRealName();
			}
			$this->onUserJoin( $connection, $userKey );
		} else {
			$this->users[$userKey]['count']++;
		}
	}

	/**
	 * @param array|null $target
	 * @return Connection[]
	 */
	public function getConnections( ?array $target = null ): array {
		if ( !$target ) {
			return $this->connections;
		}

		$return = [];

		$toConn = $target[self::TARGET_CONNECTION] ?? null;
		if ( $toConn ) {
			$c = $this->connections[$toConn] ?? null;
			if ( $c ) {
				$return[] = $c;
			}
		}

		// $toUser = $target[self::TARGET_USER] ?? null;
		// if ( $toUser ) {
			// $this->users[$toUser]
		// }

		return $return;
	}

	/**
	 * @param Connection $connection
	 */
	public function removeConnection( Connection $connection ) {
		unset( $this->connections[$connection->getId()] );

		$userKey = $connection->getUserKey();
		if ( isset( $this->users[$userKey] ) ) {
			$this->users[$userKey]['count']--;
			if ( !$this->users[$userKey]['count'] ) {
				$this->onUserLeft( $connection, $userKey );
			}
		}
	}

	/**
	 * @param Connection $connection
	 * @param string $event
	 * @param array $data
	 */
	public function onEvent( Connection $connection, string $event, array $data ) {
		$this->debugLog( __FUNCTION__, $connection->getId(), 'UNHANDLED EVENT', $event );
	}

	/**
	 * @param Connection $connection
	 * @param string $userKey
	 */
	protected function onUserJoin( Connection $connection, string $userKey ) {
	}

	/**
	 * @param Connection $connection
	 * @param string $userKey
	 */
	protected function onUserLeft( Connection $connection, string $userKey ) {
	}

	/**
	 * @return array
	 */
	public function getOptions(): array {
		return $this->options;
	}

	/**
	 * @return int
	 */
	public function getOnlineUsersCount() {
		return count( $this->users );
	}

	/**
	 * @return int
	 */
	public function getConnectionsCount() {
		return count( $this->connections );
	}

	/**
	 * @param string $event
	 * @param array $data
	 * @param string|null $time
	 */
	public function sendToAllInRoom( string $event, array $data = [], ?string $time = null ) {
		$buffer = Connection::makeSendBuffer( $event, $data, $time );
		$this->sendBufferToAllInRoom( $buffer );
	}

	/**
	 * @param string|null $buffer
	 */
	protected function sendBufferToAllInRoom( ?string $buffer ) {
		if ( !$buffer ) {
			return;
		}
		foreach ( $this->getConnections() as $connection ) {
			$connection->sendBuffer( $buffer );
		}
	}

	/**
	 * @param int $roomId
	 * @param string $event
	 * @param array $data
	 * @param array|null $target
	 * @param string|null $time
	 * @return bool
	 */
	public static function sendToAllByRoomId( int $roomId, string $event, array $data = [], ?array $target = null, ?string $time = null ) {
		$message = [
			'action' => self::MANAGER_ACTION_SEND_TO_ALL,
			'buffer' => Connection::makeSendBuffer( $event, $data, $time ),
		];
		$data = [
			'command' => 'send',
			'roomType' => static::ROOM_TYPE,
			'roomId' => $roomId,
			'message' => $message,
		];

		if ( $target ) {
			$data['target'] = $target;
		}

		return Manager::sendDataToItself( $data );
	}

	/**
	 * @param string $event
	 * @param array $data
	 * @param string|null $time
	 */
	public function sendToAll( string $event, array $data = [], ?string $time = null ) {
		$buffer = Connection::makeSendBuffer( $event, $data, $time );
		$message = [
			'action' => self::MANAGER_ACTION_SEND_TO_ALL,
			'buffer' => $buffer,
		];
		$this->sendToManager( 'send', $message );
	}

	/**
	 * @param string $command
	 * @param array|null $message
	 * @param array|null $target
	 */
	protected function sendToManager( string $command, ?array $message = null, ?array $target = null ) {
		if ( !$this->managerConnection ) {
			$this->debugLog( __FUNCTION__, 'ERROR: managerConnection is undefined' );
			return;
		}

		$data = [
			'command' => $command,
			'roomType' => static::ROOM_TYPE,
			'roomId' => $this->roomId,
			'key' => $this->getManagerKey(),
		];
		if ( $target ) {
			$data['target'] = $target;
		}
		if ( $message ) {
			$data['message'] = $message;
		}
		$buffer = FormatJson::encode( $data );
		$this->debugLog( __FUNCTION__, $buffer );
		$this->managerConnection->send( $buffer );
	}

	// /**
	//  * @param string $name
	//  * @param mixed $value
	//  */
	// protected function syncManagerData( string $name, $value ) {
		// $this->sendToManager(
			// 'sync',
			// [
				// 'name' => $name,
				// 'value' => $value,
			// ]
		// );
	// }

	/**
	 * @param string $command
	 * @param string $providerName
	 * @param mixed $value
	 * @param bool $sync
	 * @param array|null $target
	 */
	protected function sendDatabaseCommand( string $command, string $providerName, $value, bool $sync = false, ?array $target = null ) {
		$this->debugLog( __FUNCTION__, $command, $providerName );
		$this->sendToManager(
			$command,
			[
				'providerName' => $providerName,
				'value' => $value,
				'sync' => $sync,
			],
			$target
		);
	}

	/**
	 * @param string $name
	 * @param mixed $value
	 * @param bool $sync
	 */
	protected function setManagerData( string $name, $value, bool $sync = false ) {
		$this->sendToManager(
			'set',
			[
				'name' => $name,
				'value' => $value,
				'sync' => $sync,
			]
		);
	}

	/**
	 * @param string $name
	 * @param array|null $target
	 */
	protected function getManagerData( string $name, ?array $target = null ) {
		$this->sendToManager(
			'get',
			[ 'name' => $name ],
			$target
		);
	}

	/**
	 * @param string $name
	 * @param Connection $connection
	 */
	protected function getManagerDataForConnection( string $name, Connection $connection ) {
		$this->getManagerData( $name, [ self::TARGET_CONNECTION => $connection->getId() ] );
	}

	/**
	 * @param string $action
	 * @param array $data
	 * @param array|null $target
	 */
	protected function onManagerAction( string $action, array $data, ?array $target = null ) {
		$this->debugLog( __FUNCTION__, $action );
		switch ( $action ) {
			case self::MANAGER_ACTION_SEND_TO_ALL:
				$this->sendBufferToAllInRoom( $data['buffer'] ?? null );
				break;
			case self::MANAGER_ACTION_SYNC:
				$this->onManagerSyncAction(
					$data['sync'] ?? null,
					$data['name'] ?? null,
					$data['value'] ?? null
				);
				break;
		}
	}

	/**
	 * @param string $info
	 * @param array|null $answer
	 * @param array|null $target
	 */
	protected function onManagerInfo( string $info, ?array $answer, ?array $target = null ) {
		if ( $target === null ) {
			$this->onManagerDataReceived( $info, $answer );
			return;
		}

		if ( $answer === null ) {
			$this->debugLog( __FUNCTION__, 'ERROR: answer is NULL' );
			return;
		}

		$connections = $this->getConnections( $target );
		foreach ( $connections as $c ) {
			$this->debugLog( __FUNCTION__, 'SEND to client', $c->getId(), $info );
			$c->send( $info, [ 'answer' => $answer ] );
		}
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param string $buffer
	 */
	public function onManagerMessage( ConnectionInterface $connection, string $buffer ) {
		$exploded = explode( '}{', $buffer );
		$lastKey = count( $exploded ) - 1;
		foreach ( $exploded as $k => $v ) {
			if ( $lastKey > 0 ) {
				if ( $k !== 0 ) {
					$v = '{' . $v;
				}
				if ( $k !== $lastKey ) {
					$v .= '}';
				}
			}
			$this->onManagerAnswer( $connection, $v );
		}
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param string $buffer
	 */
	protected function onManagerAnswer( ConnectionInterface $connection, string $buffer ) {
		$this->debugLog( __FUNCTION__, $buffer );
		$data = FormatJson::decode( $buffer, true );

		$timerName = $data[Manager::EVENT_TIMER] ?? null;
		if ( $timerName ) {
			$this->onManagerTimer( $timerName, $data );
			return;
		}

		$action = $data['action'] ?? null;
		$target = $data['target'] ?? null;
		if ( $action ) {
			$this->onManagerAction( $action, $data, $target );
			return;
		}

		$info = $data['info'] ?? null;
		$answer = $data['answer'] ?? null;
		if ( $info ) {
			$this->onManagerInfo( $info, $answer, $target );
			return;
		}

		$this->debugLog( __FUNCTION__, 'ERROR: Unhandled answer', var_export( $data, true ) );
	}

	/**
	 * @param string $name
	 * @return mixed|null
	 */
	public static function getConfigValue( string $name ) {
		$config = RequestContext::getMain()->getConfig();
		try {
			return $config->get( $name );
		} catch ( ConfigException $e ) {
			MWDebug::warning( $e->getMessage() );
		}
		return null;
	}

	/**
	 * @param array $message
	 */
	protected function connectToManager( array $message = [] ) {
		$managerSocketName = self::getConfigValue( 'LiveChatManagerSocketName' );
		$this->debugLog( __FUNCTION__, $managerSocketName );
		try {
			$this->managerConnection = new AsyncTcpConnection( $managerSocketName );
			$this->managerConnection->onMessage = [ $this, 'onManagerMessage' ];
			$this->managerConnection->onClose = [ $this, 'onManagerClose' ];
			$this->managerConnection->onError = [ $this, 'onManagerError' ];
			$this->managerConnection->connect();

			$message += [
				'roomClass' => static::class,
				'roomType' => static::ROOM_TYPE,
				'roomId' => $this->roomId,
				'key' => $this->getManagerKey(),
			];

			if ( $this->managerTimers ) {
				$message[Manager::ADD_TIMERS] = array_keys( $this->managerTimers );
			}
			$this->sendToManager( 'subscribe', $message );
		} catch ( Exception $e ) {
			$this->debugLog( __FUNCTION__, 'ERROR', $e->getMessage() );
			wfLogWarning( $e->getMessage() );
		}
	}

	/**
	 * @param ConnectionInterface $connection
	 */
	public function onManagerClose( ConnectionInterface $connection ) {
		$this->debugLog( __FUNCTION__, $connection->id ?? 'undefined' );
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param mixed $code
	 * @param string $msg
	 */
	public function onManagerError( ConnectionInterface $connection, $code, $msg ) {
		$this->debugLog( __FUNCTION__, $connection->id ?? 'undefined', $code, $msg );
	}

	/**
	 * @return string
	 */
	protected function getManagerKey(): string {
		return static::ROOM_TYPE . '#' . $this->roomId;
	}

	/**
	 * @param string ...$args
	 */
	protected function debugLog( ...$args ) {
		$roomType = static::ROOM_TYPE;
		$roomId = $this->roomId;
		wfDebugLog(
			__CLASS__,
			"$roomType $roomId " . static::class . '::' . implode( '; ', $args ),
			false
		);
	}

	/**
	 * @param string $timerName
	 * @param array $data
	 */
	protected function onManagerTimer( string $timerName, array $data ) {
		$this->debugLog( __FUNCTION__, $timerName, $data[Manager::TIMER_ITERATION], $data[Manager::TIMER_NUMBER] );
	}

	/**
	 * @param string|null $command
	 * @param string|null $name
	 * @param mixed $value
	 */
	protected function onManagerSyncAction( ?string $command, ?string $name, $value ) {
		$this->debugLog( __FUNCTION__, $command, $name );
	}

	/**
	 * @param string $info
	 * @param array|null $answer
	 */
	protected function onManagerDataReceived( string $info, ?array $answer ) {
		$this->debugLog( __FUNCTION__, $info );
	}
}
PK       ! a3  3    ChatRoom.phpnu [        <?php

namespace LiveChat;

use MediaWiki\MediaWikiServices;
use User;
use wAvatar;
use Wikimedia\Rdbms\Database;
use Wikimedia\Timestamp\ConvertibleTimestamp;

class ChatRoom extends Room {

	const ROOM_TYPE = 10;

	// const
	const O_PERM_CAN_POST_MESSAGE = 'canPostMessagePermission';
	const O_PERM_CAN_POST_REACTION = 'canPostReactionPermission';

	const I_CAN_POST = 'canPost';

	const TABLE = 'lch_messages';
	const C_ID = 'lchm_id';
	const C_PARENT = 'lchm_parent_id';
	const C_ROOM_TYPE = 'lchm_room_type';
	const C_ROOM_ID = 'lchm_room_id';
	const C_USER_ID = 'lchm_user_id';
	const C_USER_TEXT = 'lchm_user_text';
	const C_MESSAGE = 'lchm_message';
	const C_TIMESTAMP = 'lchm_timestamp';

	const MAX_MESSAGE_TEXT_SIZE = 1000;

	const EVENT_MESSAGE = 'LiveChatMessage';
	const EVENT_REACTION = 'LiveChatReaction';
	const EVENT_GET_HISTORY = 'getLiveChatHistory';
	const EVENT_GET_PERMISSIONS = 'getPermissions';
	const EVENT_GET_ROOM_STATISTICS = 'getRoomStatistics';
	const EVENT_SEND_HISTORY = 'LiveChatHistory';
	const EVENT_SEND_MESSAGE = 'LiveChatMessage';
	const EVENT_SEND_REACTION = 'LiveChatReaction';
	const EVENT_SEND_USER_STATUS = 'LiveChatUserStatus';
	const EVENT_SEND_MESSAGE_CONFIRM = 'LiveChatMessageConfirm';
	const EVENT_SEND_REACTION_CONFIRM = 'LiveChatReactionConfirm';
	const EVENT_SEND_ROOM_STATISTICS = 'LiveChatRoomStatistics';
	const EVENT_SEND_PERMISSIONS = 'permissions';

	/**
	 * @var Database|null
	 */
	private static $dbw;

	/**
	 * @var Database|null
	 */
	private static $dbr;

	/**
	 * @var array
	 */
	private $messages = [];

	/**
	 * @var array
	 */
	private $parents = [];

	/**
	 * @var Reactions
	 */
	private $reactions;

	/**
	 * @var int
	 */
	private $cacheSize = 100;

	/**
	 * Room constructor.
	 * @param int $id
	 * @param array $options
	 */
	public function __construct( int $id = 0, array $options = [] ) {
		parent::__construct( $id, $options );

		$this->loadMessagesToCache();
		$this->reactions = new Reactions( $this->messages, $this );
		$this->reactions->loadForMessages();
	}

	/**
	 * @param array &$message
	 * @param string $userName
	 */
	private function addUserReaction( array &$message, string $userName ) {
		$userReaction = $this->reactions->getUserReaction( $message['id'], $userName, true );
		if ( $userReaction ) {
			$message['userReaction'] = $userReaction;
		}
	}

	/**
	 * @param Connection $connection
	 */
	public function addConnection( Connection $connection ) {
		parent::addConnection( $connection );

		$onlineCount = $this->getOnlineUsersCount();
		$statistics = [ 'online' => $onlineCount ];
		$connection->send( self::EVENT_SEND_ROOM_STATISTICS, [ 'statistics' => $statistics ] );
	}

	// protected function onUserJoin( Connection $connection, string $userKey ) {
		// parent::onUserJoin( $connection, $userKey );
		//
		// $msg = [
			// 'status' => 'join',
			// 'data' => array_intersect_key( $this->users[$userKey], [ 'name' => 1, 'realName' => 1 ] ),
		// ];
		// foreach ( $this->connections as $c ) {
			// if ( $c === $connection ) {
				// continue;
			// }
			// $c->send( self::EVENT_SEND_USER_STATUS, $msg );
		// }
	// }
	//
	// protected function onUserLeft( Connection $connection, string $userKey ) {
		// parent::onUserLeft( $connection, $userKey );
		//
		// $msg = [
			// 'status' => 'left',
			// 'data' => array_intersect_key( $this->users[$userKey], ['name' => 1, 'realName' => 1] ),
		// ];
		// unset( $this->users[$userKey] );
		// foreach ( $this->connections as $c ) {
			// if ( $c === $connection ) {
				// continue;
			// }
			// $c->send( self::EVENT_SEND_USER_STATUS, $msg );
		// }
	// }

	/**
	 * @param Connection $connection
	 * @param array $data
	 */
	public function onReaction( Connection $connection, array $data ) {
		$this->debugLog( __FUNCTION__ );
		$user = $connection->getUser();
		$confirm = [ 'clientTime' => $data['time'] ?? null ];
		if ( !$this->canUserPostReaction( $user ) ) {
			$confirm['error'] = Tools::getMessage( $user, 'ext-livechat-error-user-cannot-post-reactions' )->text();
			$connection->send( self::EVENT_SEND_REACTION_CONFIRM, $confirm );
			return;
		}

		if ( empty( $data['message'] ) ) {
			$confirm['error'] = 'No message id provided';
			$connection->send( self::EVENT_SEND_REACTION_CONFIRM, $confirm );
			return;
		}

		$reaction = $data['reaction'] ?? null;
		if ( !$reaction || empty( ChatData::REACTIONS_BY_NAME[$reaction] ) ) {
			$confirm['error'] = "Reaction $reaction is not allowed";
			$connection->send( self::EVENT_SEND_REACTION_CONFIRM, $confirm );
			return;
		}

		$connection->send( self::EVENT_SEND_REACTION_CONFIRM, $confirm );

		$data['userId'] = $user->getId();
		$data['userName'] = $user->getName();

		$this->sendDatabaseCommand( 'update', ChatData::class, $data, true );
	}

	/**
	 * @param Connection $connection
	 * @param array $data
	 */
	public function onMessage( Connection $connection, array $data ) {
		$user = $connection->getUser();
		$parentId = $data['parentId'] ?? null;
		$confirm = [ 'clientTime' => $data['time'] ?? null ];
		if ( $parentId ) {
			$confirm['parentId'] = $parentId;
		}
		if ( !$this->canUserPostMessages( $user ) ) {
			$errorMessage = Tools::getMessage( $user, 'ext-livechat-error-user-cannot-post-comments' )->text();
			$confirm['error'] = $errorMessage;
			$connection->send( self::EVENT_SEND_MESSAGE_CONFIRM, $confirm );
			return;
		}

		$text = trim( $data['message'] ?? '' );
		if ( !$text ) {
			$errorMessage = 'empty message';
			$confirm['error'] = $errorMessage;
			$connection->send( self::EVENT_SEND_MESSAGE_CONFIRM, $confirm );
			return;
		} elseif ( strlen( $text > self::MAX_MESSAGE_TEXT_SIZE ) ) {
			$text = substr( $text, 0, self::MAX_MESSAGE_TEXT_SIZE );
			$data['message'] = $text;
		}
		$connection->send( self::EVENT_SEND_MESSAGE_CONFIRM, $confirm );

		$data['userId'] = $user->getId();
		$data['userName'] = $user->getName();

		$this->sendDatabaseCommand( 'insert', ChatData::class, $data, true );

		// $parentId = $data['parent'] ?? null;
		// if ( $parentId ) {
			// if ( empty( $this->parents[$parentId] ) ) {
				// if ( empty( $this->messages[$parentId] ) ) {
					// $parent = $this->loadMessage( $parentId, true );
				// } else {
					// $parent =& $this->messages[$parentId];
				// }
			// if ( !$parent || isset( $parent['parent'] ) ) {
				// $parentId = null; // don't allow wrong parent and parent of children
			// } else {
				// $this->parents[$parentId] =& $parent;
			// }
		// } else {
			// $parent =& $this->parents[$parentId];
		// }
		// }
		//
		// $time = Connection::getTime();
		// $text = substr( $data['message'] ?? '', 0, self::MAX_MESSAGE_TEXT_SIZE );
		// $msg = self::makeMessage( $text, $user->getName(), $user->getId(), $time, $parentId );
		// $id = $this->saveMessage( $connection, $msg, $text, $time );
		// $msg['id'] = $id;
		// if ( !$id ) {
			// $msg['clientTime'] = $data['time'] ?? null;
			// $connection->send( self::EVENT_SEND_MESSAGE_CONFIRM, $msg, $time );
			// return;
		// }
		//
		// $this->messages[$id] = $msg;
		// if ( $parentId ) {
			// $parent['children'][] =& $this->messages[$id];
		// }
		//
		// if ( count( $this->messages ) > $this->cacheSize ) {
			// $min = min( array_keys( $this->messages ) );
			// unset( $this->messages[$min] );
		// }
		//
		// foreach ( $this->connections as $c ) {
			// if ( $c === $connection ) {
				// $tmp = $msg;
				// $tmp['clientTime'] = $data['time'] ?? null;
				// $c->send( self::EVENT_SEND_MESSAGE_CONFIRM, $tmp, $time );
			// } else {
				// $tmp = $msg;
				// $this->addUserReaction( $tmp );
				// $c->send( self::EVENT_SEND_MESSAGE, $tmp, $time );
			// }
		// }
	}

	/**
	 * @param string|null $text
	 * @param string $userName
	 * @param int|null $userId
	 * @param string $time
	 * @param int|null $parentId
	 * @return array
	 */
	private static function makeMessage( $text, $userName, $userId, $time, $parentId = null ) {
		$msg = [
			'message' => MessageParser::parse( $text ),
			'userName' => $userName,
			'timestamp' => ConvertibleTimestamp::convert( TS_MW, $time ),
		];
		if ( $userId ) {
			$msg['userId'] = $userId;
		}
		if ( $parentId ) {
			$msg['parent'] = $parentId;
		}
		$userAvatar = self::getUserAvatar( $userId );
		if ( $userAvatar ) {
			$msg['userAvatar'] = $userAvatar;
		}
		return $msg;
	}

	/**
	 * @param Connection $connection
	 * @param array $data
	 */
	public function onGetHistory( Connection $connection, array $data ) {
		$target = [ self::TARGET_CONNECTION => $connection->getId() ];
		$this->sendDatabaseCommand( 'select', ChatData::class, $data, false, $target );
	}

	/**
	 * @param Connection $connection
	 * @param array $data
	 */
	public function onGetPermissions( Connection $connection, array $data ) {
		$user = $connection->getUser();
		$list = $data['list'] ?? [];

		$permissions = [];
		foreach ( $list as $value ) {
			switch ( $value ) {
				case self::I_CAN_POST:
					$permissions[self::I_CAN_POST] = $this->canUserPostMessages( $user );
					break;
				default:
					$permissions[$value] = null;
			}
		}
		$connection->send( self::EVENT_SEND_PERMISSIONS, [ 'permissions' => $permissions ] );
	}

	/**
	 * @param int|null $userId
	 * @param string $size
	 * @return string|null
	 */
	public static function getUserAvatar( $userId, $size = 'm' ) {
		if ( !$userId ) {
			return null;
		}
		$avatar = new wAvatar( $userId, $size );
		if ( $avatar->isDefault() ) {
			return null;
		}

		global $wgUploadBaseUrl, $wgUploadPath;

		$avatarImg = $avatar->getAvatarImage();
		return "{$wgUploadBaseUrl}{$wgUploadPath}/avatars/{$avatarImg}";
	}

	/**
	 * @param User $user
	 * @return bool
	 */
	public function canUserPostMessages( User $user ) {
		if ( empty( $this->options[self::O_PERM_CAN_POST_MESSAGE] ) ) {
			return true;
		}
		return $user->isAllowed( $this->options[self::O_PERM_CAN_POST_MESSAGE] );
	}

	private function loadMessagesToCache() {
		$dbr = $this->getDBR();
		$conds = [
			self::C_ROOM_TYPE => static::ROOM_TYPE,
			self::C_ROOM_ID => $this->roomId,
		];
		$options = [
			'LIMIT' => $this->cacheSize,
			'ORDER BY' => self::C_ID,
		];
		// TODO get user name from user table
		$res = $dbr->select( self::TABLE, '*', $conds, __METHOD__, $options );
		if ( !$res ) {
			return;
		}

		foreach ( $res as $row ) {
			$msg = self::messageFromRow( (array)$row );
			$id = $msg['id'];
			$this->messages[$id] = $msg;
		}
	}

	// /**
	//  * @param $messageId
	//  * @param bool $withChildren
	//  * @return array
	//  */
	// public function loadMessage( $messageId, $withChildren = false ) {
		// $dbr = $this->getDBR();
		// $conds = [
			// self::C_ROOM_TYPE => static::ROOM_TYPE,
			// self::C_ROOM_ID => $this->roomId,
		// ];
		// if ( $withChildren ) {
			// $ids = $dbr->makeList( [
				// self::C_ID => $messageId,
				// self::C_PARENT => $messageId,
			// ], LIST_OR );
			// $conds[] = 	$ids;
		// } else {
			// $conds[self::C_ID] = $messageId;
		// }
		//
		// // TODO get user name from user table
		// $res = $dbr->select( self::TABLE, '*', $conds,  __METHOD__ );
		// if ( !$res ) {
			// return [];
		// }
		//
		// $parent = [];
		// $children = [];
		// foreach ( $res as $row ) {
			// $msg = self::messageFromRow( (array)$row );
			// $id = $msg['id'];
			// if ( $id == $messageId ) {
				// $parent = $msg;
			// } else {
				// $children[$id] = $msg;
			// }
		// }
		// if ( $children ) {
			// $parent['children'] = $children;
		// }
		//
		// if ( $parent[self::C_ROOM_TYPE] != static::ROOM_TYPE ||
			// $parent[self::C_ROOM_ID] != $this->roomId
		// ) {
			// return [];
		// }
		// return $parent;
	// }

	/**
	 * @param array $row
	 * @return array
	 */
	private static function messageFromRow( array $row ) {
		$msg = self::makeMessage(
			$row[self::C_MESSAGE],
			$row[self::C_USER_TEXT],
			$row[self::C_USER_ID],
			$row[self::C_TIMESTAMP],
			$row[self::C_PARENT]
		);
		$id = $row[self::C_ID];
		$msg['id'] = $id;
		return $msg;
	}

	/**
	 * @param Connection $connection
	 * @param string $event
	 * @param array $data
	 */
	public function onEvent( Connection $connection, string $event, array $data ) {
		switch ( $event ) {
			case self::EVENT_MESSAGE:
				$this->onMessage( $connection, $data );
				break;
			case self::EVENT_GET_HISTORY:
				$this->onGetHistory( $connection, $data );
				break;
			case self::EVENT_GET_PERMISSIONS:
				$this->onGetPermissions( $connection, $data );
				break;
			case self::EVENT_REACTION:
				$this->onReaction( $connection, $data );
				break;
			default:
				parent::onEvent( $connection, $event, $data );
		}
	}

	/**
	 * @return Database
	 */
	public function getDBW() {
		if ( !self::$dbw ) {
			self::$dbw = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_PRIMARY );
		}
		return self::$dbw;
	}

	/**
	 * @return Database
	 */
	public function getDBR() {
		if ( !self::$dbr ) {
			self::$dbr = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_REPLICA );
		}
		return self::$dbr;
	}

	/**
	 * @param User $user
	 * @return bool
	 */
	protected function canUserPostReaction( User $user ) {
		$options = $this->getOptions();
		if ( empty( $options[self::O_PERM_CAN_POST_REACTION] ) ) {
			return true;
		}
		return $user->isAllowed( $options[self::O_PERM_CAN_POST_REACTION] );
	}
}
PK       ! ;  ;    Manager.phpnu [        <?php
namespace LiveChat;

use Exception;
use FormatJson;
use MWDebug;
use Workerman\Connection\AsyncTcpConnection;
use Workerman\Connection\ConnectionInterface;
use Workerman\Lib\Timer;

class Manager extends Worker {
	const TIMER_ONE_SECOND = 'OneSecondTimer';
	const TIMER_FIVE_SECONDS = 'FiveSecondsTimer';
	const TIMER_TEN_SECONDS = 'TenSecondsTimer';
	const TIMER_ONE_MINUTE = 'OneMinuteTimer';
	const EVENT_TIMER = 'ManagerTimer';
	const TIMER_ITERATION = 'TimerIteration';
	const TIMER_NUMBER = 'TimerNumber';
	const ADD_TIMERS = 'ManagerAddTimers';
	const TARGET_ROOM_CONNECTION = 'trcId';

	/**
	 * @var callable[][]
	 */
	protected $commandCallbacks = [];

	/**
	 * @var ConnectionInterface[]
	 */
	protected $subscribed = [];

	/**
	 * @var ConnectionInterface[][][]
	 */
	protected $rooms = [];

	/**
	 * @var array
	 */
	protected $roomClasses = [];

	/**
	 * @var ConnectionInterface[][]
	 */
	protected $keys = [];

	/**
	 * @var array
	 */
	protected $timers = [];

	/**
	 * @var ConnectionInterface[][]
	 */
	protected $timerConnections = [];

	/**
	 * @var AsyncTcpConnection
	 */
	protected $storageConnection;

	/**
	 * @var int[]
	 */
	protected $timerIterations = [
		self::TIMER_ONE_SECOND => 0,
		self::TIMER_FIVE_SECONDS => 0,
		self::TIMER_TEN_SECONDS => 0,
		self::TIMER_ONE_MINUTE => 0,
	];

	/**
	 * @var array
	 */
	protected $data = [];

	/** @inheritDoc */
	public function __construct( string $socket_name = '', array $context_option = [] ) {
		if ( !$socket_name ) {
			$socket_name = self::getConfigValue( 'LiveChatManagerSocketName' );
		}
		parent::__construct( $socket_name, $context_option );
		$this->name = 'LiveChat Manager';
	}

	/** @inheritDoc */
	public function run() {
		$this->addCommandCallbacks( 'subscribe', [ $this, 'onSubscribeCommand' ] );
		$this->addCommandCallbacks( 'unsubscribe', [ $this, 'onUnsubscribeCommand' ] );
		$this->addCommandCallbacks( 'send', [ $this, 'onSendCommand' ] );
		$this->addCommandCallbacks( 'status', [ $this, 'onStatusCommand' ] );
		$this->addCommandCallbacks( 'get', [ $this, 'onGetCommand' ] );
		$this->addCommandCallbacks( 'set', [ $this, 'onSetCommand' ] );
		$this->addCommandCallbacks( 'insert', [ $this, 'onDatabaseCommand' ] );
		$this->addCommandCallbacks( 'update', [ $this, 'onDatabaseCommand' ] );
		$this->addCommandCallbacks( 'select', [ $this, 'onDatabaseCommand' ] );

		parent::run();
	}

	public function onWorkerStart() {
		parent::onWorkerStart();

		$this->connectToStorage();

		$timerIntervals = [
			self::TIMER_ONE_SECOND => 1,
			self::TIMER_FIVE_SECONDS => 5.2,
			self::TIMER_TEN_SECONDS => 10.4,
			self::TIMER_ONE_MINUTE => 60.6,
		];
		foreach ( $timerIntervals as $timer => $interval ) {
			$timerId = Timer::add( $interval, [ $this, 'onTimer' ], [ $timer ] );
			if ( $timerId ) {
				$this->timers[] = $timerId;
				self::debugLog( __FUNCTION__, 'added timer', $timer, (string)$interval );
			} else {
				self::debugLog( __FUNCTION__, 'ERROR: cannot add timer', $timer, (string)$interval );
				MWDebug::warning( 'Cannot add timer ' . $timer . ' interval ' . $interval );
			}
		}
	}

	/**
	 * @param string $timerName
	 */
	public function onTimer( string $timerName ) {
		$data = [
			self::EVENT_TIMER => $timerName,
			self::TIMER_ITERATION => $this->timerIterations[$timerName]++,
		];
		$timerNumber = 0;

		/** @var ConnectionInterface $connection */
		foreach ( $this->timerConnections[$timerName] ?? [] as $connection ) {
			$data[self::TIMER_NUMBER] = $timerNumber++;
			$buffer = FormatJson::encode( $data );
			$connection->send( $buffer );
		}
	}

	public function onWorkerStop() {
		parent::onWorkerStop();

		foreach ( $this->timers as $timerId ) {
			Timer::del( $timerId );
		}
	}

	/** @inheritDoc */
	public function onConnect( ConnectionInterface $connection ) {
		parent::onConnect( $connection );

		self::sendAnswer( $connection, 'connected' );
	}

	/** @inheritDoc */
	public function onClose( ConnectionInterface $connection ) {
		parent::onClose( $connection );

		$this->onCommand(
			$connection,
			'unsubscribe',
			[ 'command' => 'unsubscribe' ]
		);
	}

	/**
	 * @param string $command
	 * @param callable $callback
	 */
	public function addCommandCallbacks( string $command, callable $callback ) {
		self::debugLog( __FUNCTION__, $command );
		$this->commandCallbacks[$command][] = $callback;
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param string $command
	 * @param array $data
	 */
	protected function onCommand( ConnectionInterface $connection, string $command, array $data ) {
		parent::onCommand( $connection, $command, $data );

		if ( !empty( $this->commandCallbacks[$command] ) ) {
			foreach ( $this->commandCallbacks[$command] as $callback ) {
				call_user_func( $callback, $connection, $data );
			}
		} else {
			self::debugLog( __FUNCTION__, "no callback for command: $command" );
		}
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param array $data
	 */
	public function onSubscribeCommand( ConnectionInterface $connection, array $data ) {
		$id = $connection->id ?? null;
		self::debugLog( __FUNCTION__, $id );
		if ( $id !== null ) {
			$this->subscribed[$id] = $connection;
		} else {
			$this->subscribed[] = $connection;
		}

		$message = $data['message'] ?? null;
		if ( $message && is_array( $message ) ) {
			$roomType = $message['roomType'] ?? 0;
			$roomId = $message['roomId'] ?? 0;
			$connection->roomType = $roomType;
			$connection->roomId = $roomId;
			if ( $id !== null ) {
				$this->rooms[$roomType][$roomId][$id] = $connection;
			} else {
				$this->rooms[$roomType][$roomId][] = $connection;
			}
			if ( !isset( $this->roomClasses[$roomType] ) ) {
				$this->roomClasses[$roomType] = $message['roomClass'] ?? null;
			}

			$key = $message['key'] ?? null;
			if ( $key ) {
				$connection->key = $key;
				if ( $id !== null ) {
					$this->keys[$key][$id] = $connection;
				} else {
					$this->keys[$key][] = $connection;
				}
			}
		}
		foreach ( $message[self::ADD_TIMERS] ?? [] as $timerName ) {
			self::debugLog( __FUNCTION__, $id, "ADD $timerName TIMER" );
			if ( $id !== null ) {
				$this->timerConnections[$timerName][$id] = $connection;
			} else {
				$this->timerConnections[$timerName][] = $connection;
			}
			if ( $connection->timers ?? false ) {
				$connection->timers = [];
			}
			$connection->timers[] = $timerName;
		}
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param array $data
	 */
	public function onUnsubscribeCommand( ConnectionInterface $connection, array $data ) {
		$id = $connection->id ?? null;
		self::debugLog( __FUNCTION__, $id );
		if ( $id !== null ) {
			unset( $this->subscribed[$id] );
		} else {
			$key = array_search( $connection, $this->subscribed, true );
			if ( $key !== false ) {
				unset( $this->subscribed[$key] );
			}
		}

		foreach ( $connection->timers ?? [] as $timerName ) {
			self::debugLog( __FUNCTION__, $id, 'REMOVE TIMER', $timerName );
			if ( $id !== null ) {
				unset( $this->timerConnections[$timerName][$id] );
			} else {
				$key = array_search( $connection, $this->timerConnections[$timerName], true );
				if ( $key !== false ) {
					unset( $this->timerConnections[$timerName][$key] );
				}
			}
		}

		if ( isset( $connection->roomType ) && isset( $connection->roomId ) ) {
			$roomType = $connection->roomType;
			$roomId = $connection->roomId;
			if ( $id !== null ) {
				unset( $this->rooms[$roomType][$roomId][$id] );
			} else {
				$key = array_search( $connection, $this->rooms[$roomType][$roomId], true );
				if ( $key !== false ) {
					unset( $this->rooms[$roomType][$roomId][$key] );
				}
			}
		}

		$key = $connection->key ?? null;
		if ( $key ) {
			if ( $id !== null ) {
				unset( $this->keys[$key][$id] );
			} else {
				$k = array_search( $connection, $this->keys[$key], true );
				if ( $k !== false ) {
					unset( $this->keys[$key][$k] );
				}
			}
		}
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param array $data
	 */
	public function onSendCommand( ConnectionInterface $connection, array $data ) {
		self::debugLog( __FUNCTION__, $connection->id ?? 'undefined', var_export( $data, true ) );

		$message = $data['message'] ?? null;
		if ( !$message ) {
			return;
		}
		if ( is_string( $message ) ) {
			$sendBuffer = $message;
		} else {
			$sendBuffer = FormatJson::encode( $message );
		}

		$trcId = $message['target'][self::TARGET_ROOM_CONNECTION] ?? null;
		if ( $trcId ) {
			self::debugLog( __FUNCTION__, "Send by target room connection: $trcId", $sendBuffer );
			$roomConnection = $this->connections[$trcId] ?? null;
			if ( $roomConnection ) {
				self::sendBuffer( $sendBuffer, [ $roomConnection ] );
			}
			return;
		}

		$key = $data['key'] ?? null;
		if ( $key ) {
			self::debugLog( __FUNCTION__, "Send by key: $key", $sendBuffer );
			foreach ( (array)$key as $k ) {
				$array = $this->keys[$k] ?? [];
				if ( $array ) {
					self::sendBuffer( $sendBuffer, $array, $connection );
				}
			}
			return;
		}

		$roomType = $data['roomType'] ?? null;
		$roomId = $data['roomId'] ?? null;
		if ( $roomType !== null ) {
			foreach ( (array)$roomType as $rt ) {
				if ( $roomId !== null ) {
					foreach ( (array)$roomId as $rid ) {
						self::debugLog( __FUNCTION__, "Send to room type: $rt, id: $rid", $sendBuffer );
						$array = $this->rooms[$rt][$rid] ?? [];
						if ( $array ) {
							self::sendBuffer( $sendBuffer, $array, $connection );
						}
					}
					return;
				}

				self::debugLog( __FUNCTION__, "Send to all rooms by type: $rt", $sendBuffer );
				foreach ( $this->rooms[$rt] ?? [] as $rooms ) {
					foreach ( $rooms as $array ) {
						if ( $array ) {
							self::sendBuffer( $sendBuffer, $array, $connection );
						}
					}
				}
			}
			return;
		}

		self::debugLog( __FUNCTION__, "Send to ALL subscribed connections", $sendBuffer );
		self::sendBuffer( $sendBuffer, $this->subscribed, $connection );
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param array $data
	 */
	public function onStatusCommand( ConnectionInterface $connection, array $data ) {
		$message = $data['message'] ?? [];
		$info = $message['info'] ?? null;
		$target = $message['target'] ?? null;
		self::debugLog( __FUNCTION__, $info );
		switch ( $info ) {
			case 'rooms':
				$return = [];
				foreach ( $this->rooms as $roomType => $rooms ) {
					$return[$roomType] = $this->roomClasses[$roomType] ?? null;
				}
				self::sendAnswer( $connection, 'LiveChatManagerListRooms', $return, $target );
				break;
		}
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param array $data
	 */
	public function onGetCommand( ConnectionInterface $connection, array $data ) {
		$name = $data['message']['name'] ?? null;
		$target = $data['target'] ?? null;
		if ( !$name ) {
			self::debugLog( __FUNCTION__, $connection->id ?? 'undefined', 'ERROR: name is null' );
			$value = null;
		} else {
			// self::debugLog( __FUNCTION__, var_export( $this->data, true ) );
			$value = $this->data[$name] ?? null;
			self::debugLog( __FUNCTION__, $name, var_export( $value, true ) );
		}
		self::sendAnswer( $connection, $name, $value, $target );
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param array $data
	 */
	public function onSetCommand( ConnectionInterface $connection, array $data ) {
		$cId = $connection->id ?? 'undefined';
		$message = $data['message'] ?? [];
		$name = $message['name'] ?? null;
		if ( !$name ) {
			self::debugLog( __FUNCTION__, $cId, 'ERROR: name is null' );
			return;
		}

		$value = $message['value'] ?? null;
		$this->data[$name] = $value;
		self::debugLog( __FUNCTION__, $cId, $name, print_r( $value, true ) );

		if ( $message['sync'] ?? false ) {
			self::debugLog( __FUNCTION__, $cId, 'SYNCHRONIZE', $name );
			$data['message']['action'] = 'sync';
			$data['message']['sync'] = 'set';
			$this->onSendCommand( $connection, $data );
		}
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param array $data
	 */
	public function onDatabaseCommand( ConnectionInterface $connection, array $data ) {
		if ( !empty( $data['target'][Room::TARGET_CONNECTION] ) ) {
			$data['target'][self::TARGET_ROOM_CONNECTION] = $connection->id;
		}
		$buffer = FormatJson::encode( $data );
		$this->storageConnection->send( $buffer );
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param string $info
	 * @param array|null $answer
	 * @param array|null $target
	 */
	protected static function sendAnswer( ConnectionInterface $connection, string $info, ?array $answer = null, ?array $target = null ) {
		$data = [ 'info' => $info ];
		$data['answer'] = $answer;
		if ( $target ) {
			$data['target'] = $target;
		}
		self::sendData( $data, [ $connection ] );
	}

	/**
	 * @param array $data
	 * @param array $connections
	 * @param ConnectionInterface|null $except
	 */
	protected static function sendData( array $data, array $connections, ?ConnectionInterface $except = null ) {
		$buffer = FormatJson::encode( $data );
		self::sendBuffer( $buffer, $connections, $except );
	}

	/**
	 * @param string $buffer
	 * @param ConnectionInterface[] $connections
	 * @param ConnectionInterface|null $except
	 */
	protected static function sendBuffer( string $buffer, array $connections, ?ConnectionInterface $except = null ) {
		self::debugLog( __FUNCTION__, $buffer );
		foreach ( $connections as $c ) {
			if ( $c === $except ) {
				continue;
			}
			$c->send( $buffer );
		}
	}

	// /**
	// * @param string $name
	// * @param mixed $value
	// * @return bool
	// */
	// public static function setData( string $name, $value ): bool {
		// $data = [
			// 'command' => 'set',
			// 'message' => [
				// 'name' => $name,
				// 'value' => $value,
			// ],
		// ];
		// return self::sendDataToItself( $data );
	// }

	/**
	 * @param array $data
	 * @return bool
	 */
	public static function sendDataToItself( array $data ) {
		$buffer = FormatJson::encode( $data );
		return self::sendBufferToItself( $buffer );
	}

	/**
	 * @param string $buffer
	 * @return bool
	 */
	public static function sendBufferToItself( string $buffer ) {
		self::debugLog( __FUNCTION__, $buffer );
		$managerSocketName = self::getConfigValue( 'LiveChatManagerSocketName' );
		$instance = stream_socket_client( $managerSocketName );
		fwrite( $instance, $buffer );
		fclose( $instance );
		return true;
	}

	protected function connectToStorage() {
		$socketName = self::getConfigValue( 'LiveChatStorageSocketName' );
		$this->debugLog( __FUNCTION__, $socketName );
		try {
			$this->storageConnection = new AsyncTcpConnection( $socketName );
			$this->storageConnection->onMessage = [ $this, 'onStorageMessage' ];
			$this->storageConnection->onClose = [ $this, 'onStorageClose' ];
			$this->storageConnection->onError = [ $this, 'onStorageError' ];
			$this->storageConnection->connect();
		} catch ( Exception $e ) {
			$this->debugLog( __FUNCTION__, 'ERROR', $e->getMessage() );
			wfLogWarning( $e->getMessage() );
		}
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param string $value
	 */
	public function onStorageMessage( ConnectionInterface $connection, string $value ) {
		$this->debugLog( __FUNCTION__ );
		$this->onMessage( $connection, $value );
	}

	public function onStorageClose() {
		$this->debugLog( __FUNCTION__ );
	}

	public function onStorageError() {
		$this->debugLog( __FUNCTION__ );
	}
}
PK       ! 7n    	  Tools.phpnu [        <?php

namespace LiveChat;

use MediaWiki\MediaWikiServices;
use Message;
use MWException;
use User;

class Tools {
	/**
	 * @param User $user
	 * @param string $key
	 * @param string|string[] ...$params Normal message parameters
	 * @return Message
	 */
	public static function getMessage( $user, $key, ...$params ) {
		$langCode = MediaWikiServices::getInstance()->getUserOptionsManager()
			->getOption( $user, 'language' );
		try {
			$message = wfMessage( $key )->inLanguage( $langCode );
		} catch ( MWException $e ) {
			$message = wfMessage( $key );
		}
		if ( $params ) {
			$message->params( ...$params );
		}
		return $message;
	}
}
PK       ! lP?      Reactions.phpnu [        <?php
namespace LiveChat;

use User;

class Reactions {
	const TABLE = 'lch_msg_reactions';

	const C_MESSAGE_ID = 'lchmr_msg_id';
	const C_USER_ID = 'lchmr_user_id';
	const C_USER_TEXT = 'lchmr_user_text';
	const C_TYPE = 'lchmr_type';
	const C_TIMESTAMP = 'lchmr_timestamp';

	/**
	 * @var array
	 */
	private $messages;

	/**
	 * @var ChatRoom
	 */
	private $room;

	/**
	 * @var array
	 */
	private $reactions = [];

	private const REACTIONS_BY_ID = [
		1 => 'like',
	];

	/**
	 * Reactions constructor.
	 * @param array &$messages
	 * @param ChatRoom $room
	 */
	public function __construct( array &$messages, ChatRoom $room ) {
		$this->messages = &$messages;
		$this->room = $room;
	}

	/**
	 * @param User $user
	 * @return bool
	 */
	public function canUserPostReaction( User $user ) {
		$options = $this->room->getOptions();
		if ( empty( $options[ChatRoom::O_PERM_CAN_POST_REACTION] ) ) {
			return true;
		}
		return $user->isAllowed( $options[ChatRoom::O_PERM_CAN_POST_REACTION] );
	}

	public function loadForMessages() {
		$this->loadForMessagesInternal( $this->messages, $this->reactions );
	}

	/**
	 * @param array &$messages
	 * @param string[][] &$reactions
	 */
	private function loadForMessagesInternal( &$messages = [], &$reactions = [] ) {
		if ( !$messages ) {
			return;
		}

		$dbr = $this->room->getDBR();
		$vars = [
			self::C_MESSAGE_ID,
			self::C_USER_ID,
			self::C_USER_TEXT,
			self::C_TYPE,
		];
		$cond = [
			self::C_MESSAGE_ID => array_keys( $messages ),
		];
		$res = $dbr->select( self::TABLE, $vars, $cond, __METHOD__ );

		foreach ( $res as $row ) {
			$a = (array)$row;
			$id = $a[self::C_MESSAGE_ID];
			$userText = $a[self::C_USER_ID] ? User::newFromId( $a[self::C_USER_ID] )->getName() : $a[self::C_USER_TEXT];
			$reactionName = self::REACTIONS_BY_ID[ $a[self::C_TYPE] ] ?? 'undefined';
			self::updateReaction( $messages, $reactions, $id, $userText, $reactionName );
		}
	}

	/**
	 * @param array &$messages
	 * @param array &$reactions
	 * @param string $id
	 * @param string $userName
	 * @param string $newReaction
	 * @return bool
	 */
	private static function updateReaction( &$messages, &$reactions, $id, $userName, $newReaction ) {
		if ( empty( $messages[$id] ) ) {
			// echo 'empty( $messages[$id] )', "\n";
			return false;
		}

		if ( !isset( $messages[$id]['reactions'] ) ) {
			// echo 'empty( $messages[$id][\'reactions\'] )', "\n";
			$messages[$id]['reactions'] = [];
		}
		if ( !isset( $reactions[$id] ) ) {
			// echo 'empty( $reactions[$id] )', "\n";
			$reactions[$id] = [];
		}
		$messageReactions =& $messages[$id]['reactions'];
		if ( isset( $reactions[$id][$userName] ) ) {
			// echo 'isset( $reactions[$id][$userName] )', "\n";
			$oldReaction = $reactions[$id][$userName];
			// echo '$oldReaction=', $oldReaction, "\n";
			if ( $oldReaction === $newReaction ) {
				// echo '$oldReaction === $newReaction', "\n";
				return true;
			}
			if ( ( $messageReactions[$oldReaction] ?? 0 ) > 0 ) {
				$messageReactions[$oldReaction]--;
				// echo '$messageReactions[ $oldReaction ]--', "\n";
			}
		}
		if ( empty( $messageReactions[$newReaction] ) ) {
			$messageReactions[$newReaction] = 1;
			// echo '$messageReactions[$newReaction] = 1;', "\n";
		} else {
			$messageReactions[$newReaction]++;
			// echo '$messageReactions[$newReaction]++;', "\n";
		}
		$reactions[$id][$userName] = $newReaction;

		return true;
	}

	/**
	 * @param int $id
	 * @param User $userName
	 * @param bool $fromCache
	 * @return string|null|void
	 */
	public function getUserReaction( $id, $userName, $fromCache ) {
		if ( isset( $this->reactions[$id] ) ) {
			return $this->reactions[$id][$userName] ?? null;
		} elseif ( $fromCache ) {
			return null;
		}
	}

}
PK       ! gb}1F  1F    ChatData.phpnu [        <?php
namespace LiveChat;

use FormatJson;
use InvalidArgumentException;
use MediaWiki\MediaWikiServices;
use User;
use wAvatar;
use Wikimedia\Rdbms\Database;
use Wikimedia\Timestamp\ConvertibleTimestamp;
use Workerman\Connection\ConnectionInterface;

class ChatData {

	const TABLE = 'lch_messages';
	const C_ID = 'lchm_id';
	const C_PARENT = 'lchm_parent_id';
	const C_ROOM_TYPE = 'lchm_room_type';
	const C_ROOM_ID = 'lchm_room_id';
	const C_USER_ID = 'lchm_user_id';
	const C_USER_TEXT = 'lchm_user_text';
	const C_MESSAGE = 'lchm_message';
	const C_HAS_CHILDREN = 'lchm_has_children';
	const C_TIMESTAMP = 'lchm_timestamp';

	const TABLE_REACTION = 'lch_msg_reactions';

	const C_REACTION_MESSAGE_ID = 'lchmr_msg_id';
	const C_REACTION_USER_ID = 'lchmr_user_id';
	const C_REACTION_USER_TEXT = 'lchmr_user_text';
	const C_REACTION_TYPE = 'lchmr_type';
	const C_REACTION_TIMESTAMP = 'lchmr_timestamp';

	const CACHE_SIZE = 100;

	/**
	 * @var array
	 */
	protected static $cache = [];

	const REACTIONS_BY_NAME = [
		'like' => 1,
	];

	const REACTIONS_BY_ID = [
		1 => 'like',
	];

	/**
	 * @var Database
	 */
	private static $dbw;
	/**
	 * @var Database
	 */
	private static $dbr;

	/**
	 * @param ConnectionInterface $connection
	 * @param string $name
	 * @param string $command
	 * @param array $data
	 */
	public static function onCommand( ConnectionInterface $connection, string $name, string $command, array $data ) {
		self::debugLog( __FUNCTION__, $name, $command, var_export( $data, true ) );
		switch ( $command ) {
			case 'insert':
				self::onInsertCommand( $connection, $data );
				break;
			case 'select':
				self::onSelectCommand( $connection, $data );
				break;
			case 'update':
				self::onUpdateCommand( $connection, $data );
				break;
			default:
				self::debugLog( __FUNCTION__, 'ERROR: Unknown command' );
		}
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param array $data
	 */
	protected static function onSelectCommand( ConnectionInterface $connection, array $data ) {
		$roomId = $data['roomId'];
		$roomType = $data['roomType'];
		$target = $data['target'] ?? null;

		$value = $data['message']['value'] ?? [];
		// $userId = $value['userId'] ?? 0;
		$userName = $value['userName'] ?? 'Undefined';

		$fromId = $value['fromId'] ?? null;
		$parentId = $value['parentId'] ?? null;
		$roomCache = &self::getCache( [ $roomType, $roomId ] );
		if ( !$roomCache ) {
			self::loadMessagesToCache( $roomType, $roomId );
		}
		$reactions = &$roomCache['reactions'];
		$messagesCache = &$roomCache['messages'];
		$return = [];
		if ( $parentId ) {
			$parent = self::getParent( $roomType, $roomId, $parentId );
			if ( $parent && !$fromId ) {
				// Add parent message if this is not a history request for disconnect events
				$return[] = $parent;
			}
			if ( $parent && isset( $roomCache['children'][$parentId] ) ) {
				$messagesCache = &$roomCache['children'][$parentId];
			} else {
				$emptyArray = [];
				$messagesCache = &$emptyArray;
			}
		}

		if ( !$fromId || isset( $messagesCache[$fromId] ) ) { // TODO send reaction also when fromId provided
			foreach ( $messagesCache as $msgId => $message ) { // TODO optimize me
				if ( !$fromId || $msgId > $fromId ) {
					// Add user reaction
					$userReaction = $reactions[$msgId][$userName] ?? null;
					if ( $userReaction ) {
						$message['userReaction'] = $userReaction;
					}
					$return[] = $message;
				}
			}
		} else {
			// TODO load from database
		}

		$bufferData = [
			'event' => ChatRoom::EVENT_SEND_HISTORY,
			'messages' => $return,
		];
		if ( $parentId ) {
			$bufferData['parentId'] = $parentId;
		}

		self::sendDataToManager(
			$connection,
			[
				'command' => 'send',
				'message' => [
					'action' => Room::MANAGER_ACTION_SEND_TO_ALL,
					'buffer' => FormatJson::encode( $bufferData ),
				],
				'key' => $data['key'] ?? null,
				'roomId' => $roomId,
				'roomType' => $roomType,
			],
			$target
		);
	}

	/**
	 * Updates reactions
	 * @param ConnectionInterface $connection
	 * @param array $data
	 */
	protected static function onUpdateCommand( ConnectionInterface $connection, array $data ) {
		$target = $data['target'] ?? null;
		$message = $data['message'] ?? [];
		$value = $message['value'] ?? [];
		$id = $value['message'] ?? null;
		if ( !$id ) {
			self::debugLog( __FUNCTION__, 'ERROR: No message id provided' );
			return;
		}

		$newReaction = $value['reaction'] ?? null;
		$newReactionId = self::REACTIONS_BY_NAME[$newReaction] ?? null;
		if ( !$newReactionId ) {
			self::debugLog( __FUNCTION__, "ERROR: Reaction $newReaction is not allowed" );
			return;
		}

		$userId = $value['userId'];
		$userName = $value['userName'];

		$roomId = $data['roomId'];
		$roomType = $data['roomType'];
		$roomCache = &self::getCache( [ $roomType, $roomId ] );
		if ( !$roomCache ) {
			self::loadMessagesToCache( $roomType, $roomId );
		}
		$messagesCache = &$roomCache['messages'];
		$reactionsCache = &$roomCache['reactions'];

		$msgInCache = self::updateReaction( $messagesCache, $reactionsCache, $id, $userName, $newReaction );
		if ( !$msgInCache ) {
			self::debugLog( __FUNCTION__, "ERROR: Message does not exist $roomType $roomId $id" );
			return;
		}
		$messageReactions = $messagesCache[$id]['reactions'];

		$dbw = self::getDBW();
		$time = Connection::getTime();
		$timestamp = ConvertibleTimestamp::convert( TS_MW, $time );
		$index = [
			self::C_REACTION_MESSAGE_ID => $id,
			self::C_REACTION_USER_ID => $userId,
			self::C_REACTION_USER_TEXT => $userName,
			self::C_REACTION_TIMESTAMP => $timestamp,
		];
		$set = [
			self::C_REACTION_TYPE => $newReactionId,
		];
		$dbw->upsert(
			self::TABLE_REACTION,
			[ $index + $set ],
			[ self::C_REACTION_MESSAGE_ID, self::C_REACTION_USER_TEXT ],
			$set,
			__METHOD__
		);

		$msg = [
			'event' => ChatRoom::EVENT_SEND_REACTION,
			'id' => $id,
			'reaction' => $newReaction,
			'messageReactions' => $messageReactions,
			'time' => $timestamp,
		];

		self::sendDataToManager(
			$connection,
			[
				'command' => 'send',
				'message' => [
					'action' => Room::MANAGER_ACTION_SEND_TO_ALL,
					'buffer' => FormatJson::encode( $msg ),
				],
				'key' => $data['key'] ?? null,
				'roomId' => $roomId,
				'roomType' => $roomType,
			],
			$target
		);
	}

	/**
	 * @param array &$messages
	 * @param array &$reactions
	 */
	protected static function loadReactionsForMessages( &$messages = [], &$reactions = [] ) {
		if ( !$messages ) {
			return;
		}

		$dbr = self::getDBR();
		$vars = [
			self::C_REACTION_MESSAGE_ID,
			self::C_REACTION_USER_ID,
			self::C_REACTION_USER_TEXT,
			self::C_REACTION_TYPE,
		];
		$cond = [
			self::C_REACTION_MESSAGE_ID => array_keys( $messages ),
		];
		$res = $dbr->select( self::TABLE_REACTION, $vars, $cond, __METHOD__ );

		foreach ( $res as $row ) {
			$a = (array)$row;
			$id = $a[self::C_REACTION_MESSAGE_ID];
			$userText = $a[self::C_REACTION_USER_ID] ? User::newFromId( $a[self::C_REACTION_USER_ID] )->getName() : $a[self::C_REACTION_USER_TEXT];
			$reactionName = self::REACTIONS_BY_ID[ $a[self::C_REACTION_TYPE] ] ?? 'undefined';
			self::updateReaction( $messages, $reactions, $id, $userText, $reactionName );
		}
	}

	/**
	 * @param array &$messages
	 * @param array &$reactions
	 * @param string $id
	 * @param string $userName
	 * @param string $newReaction
	 * @return bool
	 */
	private static function updateReaction( &$messages, &$reactions, $id, $userName, $newReaction ) {
		if ( empty( $messages[$id] ) ) {
			self::debugLog( __FUNCTION__, 'empty( $messages[$id] )', $id );
			return false;
		}

		if ( !isset( $messages[$id]['reactions'] ) ) {
			self::debugLog( __FUNCTION__, 'empty( $messages[$id]["reactions"] )', $id );
			$messages[$id]['reactions'] = [];
		}
		if ( !isset( $reactions[$id] ) ) {
			self::debugLog( __FUNCTION__, 'empty( $reactions[$id] )', $id );
			$reactions[$id] = [];
		}
		$messageReactions =& $messages[$id]['reactions'];
		$oldReaction = $reactions[$id][$userName] ?? null;
		if ( $oldReaction ) {
			if ( $oldReaction === $newReaction ) {
				self::debugLog( __FUNCTION__, '$oldReaction === $newReaction', $id, $userName, $newReaction );
				return true;
			}
			self::debugLog( __FUNCTION__, '$oldReaction', $oldReaction, '$newReaction', $newReaction );
			if ( ( $messageReactions[$oldReaction] ?? 0 ) > 0 ) {
				$messageReactions[$oldReaction]--;
				// echo '$messageReactions[ $oldReaction ]--', "\n";
			}
		}
		if ( empty( $messageReactions[$newReaction] ) ) {
			$messageReactions[$newReaction] = 1;
			// echo '$messageReactions[$newReaction] = 1;', "\n";
		} else {
			$messageReactions[$newReaction]++;
			// echo '$messageReactions[$newReaction]++;', "\n";
		}
		$reactions[$id][$userName] = $newReaction;

		return true;
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param array $data
	 */
	protected static function onInsertCommand( ConnectionInterface $connection, array $data ) {
		$roomId = $data['roomId'];
		$roomType = $data['roomType'];
		$target = $data['target'] ?? null;

		$roomCache = &self::getCache( [ $roomType, $roomId ] );
		$messagesCache = &$roomCache[ 'messages' ];

		$value = $data['message']['value'] ?? [];
		$parentId = $value['parentId'] ?? null;
		$userId = $value['userId'] ?? 0;
		$userName = $value['userName'] ?? 'Undefined';

		if ( $parentId ) {
			$parent = &self::getParent( $roomType, $roomId, $parentId, true );
			if ( !$parent ) {
				$parentId = null;
			}
		}

		$text = $value['message'] ?? '';
		if ( !$text ) {
			self::debugLog( __FUNCTION__, 'ERROR: Message is empty' );
		}
		// Save to the database
		$time = Connection::getTime();
		$row = [
			self::C_ROOM_TYPE => $roomType,
			self::C_ROOM_ID => $roomId,
			self::C_PARENT => $parentId,
			self::C_USER_ID => $userId,
			self::C_USER_TEXT => $userName,
			self::C_MESSAGE => $text,
			self::C_HAS_CHILDREN => false,
			self::C_TIMESTAMP => ConvertibleTimestamp::convert( TS_MW, $time ),
		];
		$dbw = self::getDBW();
		$dbw->insert(
			self::TABLE,
			$row,
			__METHOD__
		);
		$id = $dbw->insertId();

		// Make Message
		$msg = self::makeMessageData( $roomType, $roomId, $id, $text, $userName, $userId, $time, $parentId, false );
		if ( $id ) {
			if ( $parentId ) {
				$roomCache['children'][$parentId][$id] = &$messagesCache[$id];
				if ( empty( $parent['hasChildren'] ) ) {
					$parent['hasChildren'] = 1;
					$dbw->update(
						self::TABLE,
						[ self::C_HAS_CHILDREN => true ],
						[ self::C_ID => $parentId ],
						__METHOD__
					);
				}
			}
			$messagesCache[$id] = $msg;

			// TODO need to develop more smarter cleaner (should work with parents and reactions)
			// if ( count( $messagesCache ) > static::CACHE_SIZE ) {
				// $min = min( array_keys( $messagesCache ) );
				// unset( $messagesCache[$min] );
			// }
		} else {
			self::debugLog( __FUNCTION__, 'ERROR: Cannot insert row' );
		}

		self::sendDataToManager(
			$connection,
			[
				'command' => 'send',
				'message' => [
					'action' => Room::MANAGER_ACTION_SEND_TO_ALL,
					'buffer' => FormatJson::encode( [
						'event' => ChatRoom::EVENT_SEND_MESSAGE,
						'messageData' => $msg,
					] ),
				],
				'key' => $data['key'] ?? null,
				'roomId' => $roomId,
				'roomType' => $roomType,
			],
			$target
		);
	}

	/**
	 * @param int $roomType
	 * @param int $roomId
	 * @param int $id
	 * @param string|null $text
	 * @param string $userName
	 * @param int|null $userId
	 * @param string $time
	 * @param int|null $parentId
	 * @param bool $hasChildren
	 * @return array
	 */
	protected static function makeMessageData( int $roomType, int $roomId, $id, $text, $userName, $userId, $time, $parentId, $hasChildren ) {
		$msg = [
			'id' => $id,
			'message' => MessageParser::parse( $text ),
			'userName' => $userName,
			'timestamp' => ConvertibleTimestamp::convert( TS_MW, $time ),
		];
		if ( $hasChildren ) {
			$msg['hasChildren'] = 1;
		}
		if ( $userId ) {
			$msg['userId'] = $userId;
		}
		if ( $parentId ) {
			$parent = self::getParent( $roomType, $roomId, $parentId, false );
			if ( $parent ) {
				$msg['parentId'] = $parentId;
				$msg['parentUserName'] = $parent['userName'];
			}
		}
		$userAvatar = self::getUserAvatar( $userId );
		if ( $userAvatar ) {
			$msg['userAvatar'] = $userAvatar;
		}
		return $msg;
	}

	/**
	 * @param int|null $userId
	 * @param string $size
	 * @return string|null
	 */
	public static function getUserAvatar( $userId, $size = 'm' ) {
		if ( !$userId ) {
			return null;
		}
		$avatar = new wAvatar( $userId, $size );
		if ( $avatar->isDefault() ) {
			return null;
		}

		global $wgUploadBaseUrl, $wgUploadPath;

		$avatarImg = $avatar->getAvatarImage();
		return "{$wgUploadBaseUrl}{$wgUploadPath}/avatars/{$avatarImg}";
	}

	/**
	 * @param int $roomType
	 * @param int $roomId
	 * @param int $messageId
	 * @param bool $withChildren
	 * @return array
	 */
	protected static function loadMessage( int $roomType, int $roomId, int $messageId, bool $withChildren = false ) {
		$dbr = self::getDBR();
		$conds = [
			self::C_ROOM_TYPE => $roomType,
			self::C_ROOM_ID => $roomId,
		];
		if ( $withChildren ) {
			$ids = $dbr->makeList( [
				self::C_ID => $messageId,
				self::C_PARENT => $messageId,
			], LIST_OR );
			$conds[] = $ids;
		} else {
			$conds[self::C_ID] = $messageId;
		}

		// TODO get user name from user table
		$res = $dbr->select( self::TABLE, '*', $conds, __METHOD__ );
		if ( !$res ) {
			return [];
		}

		$parent = [];
		$children = [];
		foreach ( $res as $row ) {
			$msg = self::messageDataFromRow( $roomType, $roomId, (array)$row );
			$id = $msg['id'];
			if ( $id == $messageId ) {
				$parent = $msg;
			} else {
				$children[$id] = $msg;
			}
		}
		if ( $children ) {
			$parentId = $parent['id'];
			$roomCache['children'][$parentId] = $children;
		}

		if ( $parent[self::C_ROOM_TYPE] != $roomType ||
			$parent[self::C_ROOM_ID] != $roomId
		) {
			return [];
		}
		return $parent;
	}

	/**
	 * @param int $roomType
	 * @param int $roomId
	 * @param array $row
	 * @return array
	 */
	protected static function messageDataFromRow( int $roomType, int $roomId, array $row ) {
		$msg = self::makeMessageData(
			$roomType,
			$roomId,
			$row[self::C_ID],
			$row[self::C_MESSAGE],
			$row[self::C_USER_TEXT],
			$row[self::C_USER_ID],
			$row[self::C_TIMESTAMP],
			$row[self::C_PARENT],
			$row[self::C_HAS_CHILDREN]
		);
		return $msg;
	}

	/**
	 * @param mixed ...$args
	 */
	protected static function debugLog( ...$args ) {
		wfDebugLog(
			__CLASS__,
			static::class . '::' . implode( '; ', $args ),
			false
		);
	}

	/**
	 * @param array $path
	 * @return array
	 */
	protected static function &getCache( array $path ) {
		$ret = &self::$cache;
		foreach ( $path as $i => $k ) {
			if ( !isset( $ret[$k] ) ) {
				$ret[$k] = [];
			}
			if ( !is_array( $ret[$k] ) ) {
				$fail = implode( '.', array_slice( $path, 0, $i + 1 ) );
				throw new InvalidArgumentException( "Path $fail is not an array" );
			}
			$ret = &$ret[$k];
		}
		return $ret;
	}

	/**
	 * @return Database
	 */
	protected static function getDBW() {
		if ( !self::$dbw ) {
			self::$dbw = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_PRIMARY );
		}
		return self::$dbw;
	}

	/**
	 * @return Database
	 */
	protected static function getDBR() {
		if ( !self::$dbr ) {
			self::$dbr = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_REPLICA );
		}
		return self::$dbr;
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param array $data
	 * @param array|null $target
	 */
	protected static function sendDataToManager( ConnectionInterface $connection, array $data, ?array $target = null ) {
		if ( $target ) {
			$data['message']['target'] = $target;
		}
		$buffer = FormatJson::encode( $data );
		self::debugLog( __FUNCTION__, $buffer );
		$connection->send( $buffer );
	}

	/**
	 * @param int $roomType
	 * @param int $roomId
	 */
	protected static function loadMessagesToCache( int $roomType, int $roomId ) {
		self::debugLog( __FUNCTION__, $roomType, $roomId );
		$roomCache = &self::getCache( [ $roomType, $roomId ] );
		// if ( !$roomCache ) { …
		$roomCache = [
			'messages' => [],
			'parents' => [],
			'children' => [],
			'reactions' => [],
		];
		// … }

		$dbr = self::getDBR();
		$conds = [
			self::C_ROOM_TYPE => $roomType,
			self::C_ROOM_ID => $roomId,
		];
		$options = [
			'LIMIT' => static::CACHE_SIZE,
			'ORDER BY' => self::C_ID,
		];
		// TODO get user name from user table
		$res = $dbr->select( self::TABLE, '*', $conds, __METHOD__, $options );
		if ( !$res ) {
			return;
		}

		$messageCache = &$roomCache['messages'];
		foreach ( $res as $row ) {
			$a = (array)$row;
			$parentId = $a[self::C_PARENT];
			$msg = self::messageDataFromRow( $roomType, $roomId, $a );
			$id = $msg['id'];
			$messageCache[$id] = $msg;
			if ( $parentId ) {
				$parent = &self::getParent( $roomType, $roomId, $parentId, true );
				if ( $parent ) {
					$roomCache['children'][$parentId][$id] = &$messageCache[$id];
				}
			}
		}
		self::loadReactionsForMessages( $messageCache, $roomCache['reactions'] );
	}

	/**
	 * @param int $roomType
	 * @param int $roomId
	 * @param int $parentId
	 * @param bool $withChildren
	 * @return array|null
	 */
	protected static function &getParent(
		int $roomType, int $roomId, int $parentId, bool $withChildren = false
	): ?array {
		$parentsCache = &self::getCache( [ $roomType, $roomId, 'parents' ] );
		if ( empty( $parentsCache[$parentId] ) ) {
			$messagesCache = &self::getCache( [ $roomType, $roomId, 'messages' ] );
			if ( empty( $messagesCache[$parentId] ) ) {
				$parent = self::loadMessage( $roomType, $roomId, $parentId, $withChildren );
			} else {
				$parent = &$messagesCache[$parentId];
			}
			if ( !$parent || isset( $parent['parentId'] ) ) {
				$parent = null; // don't allow wrong parent and parent of children
			} else {
				$parentsCache[$parentId] =& $parent;
			}
		} else {
			$parent = &$parentsCache[$parentId];
		}
		return $parent;
	}
}
PK       ! zm  m  
  Worker.phpnu [        <?php
namespace LiveChat;

use ConfigException;
use FormatJson;
use MWDebug;
use RequestContext;
use Workerman\Connection\ConnectionInterface;

class Worker extends \Workerman\Worker {
	/**
	 * Manager constructor.
	 * @param string $socket_name
	 * @param array $context_option
	 */
	public function __construct( string $socket_name = '', array $context_option = [] ) {
		self::debugLog( __FUNCTION__, $socket_name );
		parent::__construct( $socket_name, $context_option );
	}

	/**
	 * Run manager instance.
	 *
	 * @see Workerman.Worker::run()
	 */
	public function run() {
		self::debugLog( __FUNCTION__ );

		$this->onWorkerStart = [ $this, 'onWorkerStart' ];
		$this->onWorkerStop = [ $this, 'onWorkerStop' ];
		$this->onMessage = [ $this, 'onMessage' ];
		$this->onClose = [ $this, 'onClose' ];
		$this->onConnect = [ $this, 'onConnect' ];

		parent::run();
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param string $value
	 */
	public function onMessage( ConnectionInterface $connection, string $value ) {
		$exploded = explode( '}{', $value );
		$lastKey = count( $exploded ) - 1;
		foreach ( $exploded as $k => $v ) {
			if ( $lastKey > 0 ) {
				if ( $k !== 0 ) {
					$v = '{' . $v;
				}
				if ( $k !== $lastKey ) {
					$v .= '}';
				}
			}
			$this->onExplodedMessage( $connection, $v );
		}
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param string $value
	 */
	protected function onExplodedMessage( ConnectionInterface $connection, string $value ) {
		self::debugLog( __FUNCTION__, $connection->id ?? 'undefined', $value );

		$data = FormatJson::decode( $value, true ) ?? [];
		if ( $data === null ) {
			self::debugLog( __FUNCTION__, $connection->id ?? 'undefined', 'ERROR: CANNOT DECODE JSON', $value );
			return;
		}

		$command = $data['command'] ?? null;
		if ( !$command ) {
			self::debugLog( __FUNCTION__, $connection->id ?? 'undefined', 'ERROR: EMPTY COMMAND', print_r( $data, true ) );
			return;
		}

		foreach ( (array)$command as $cmd ) {
			$this->onCommand( $connection, $cmd, $data );
		}
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param string $command
	 * @param array $data
	 */
	protected function onCommand( ConnectionInterface $connection, string $command, array $data ) {
		self::debugLog( __FUNCTION__, $connection->id ?? 'undefined', $command );
	}

	/**
	 * @param ConnectionInterface $connection
	 */
	public function onConnect( ConnectionInterface $connection ) {
		self::debugLog( __FUNCTION__, $connection->id ?? 'undefined' );
	}

	public function onWorkerStart() {
		self::debugLog( __FUNCTION__ );
	}

	public function onWorkerStop() {
		self::debugLog( __FUNCTION__ );

		/** @var ConnectionInterface $connection */
		foreach ( $this->connections as $connection ) {
			$connection->close();
		}
	}

	/**
	 * @param ConnectionInterface $connection
	 */
	public function onClose( ConnectionInterface $connection ) {
		self::debugLog( __FUNCTION__ );
	}

	/**
	 * @param string $name
	 * @return mixed|null
	 */
	protected static function getConfigValue( string $name ) {
		$config = RequestContext::getMain()->getConfig();
		try {
			return $config->get( $name );
		} catch ( ConfigException $e ) {
			MWDebug::warning( $e->getMessage() );
		}
		return null;
	}

	/**
	 * @param mixed ...$args
	 */
	protected static function debugLog( ...$args ) {
		wfDebugLog(
			__CLASS__,
			static::class . '::' . implode( '; ', $args ),
			false
		);
	}
}
PK       ! 
CO      Storage.phpnu [        <?php
namespace LiveChat;

use MediaWiki\MediaWikiServices;
use Workerman\Connection\ConnectionInterface;

class Storage extends Worker {

	/**
	 * @var array
	 */
	protected $providers = [];

	/** @inheritDoc */
	public function __construct( string $socket_name = '', array $context_option = [] ) {
		if ( !$socket_name ) {
			$socket_name = self::getConfigValue( 'LiveChatStorageSocketName' );
		}
		parent::__construct( $socket_name, $context_option );
		$this->name = 'LiveChat Storage';
	}

	public function run() {
		MediaWikiServices::getInstance()->getHookContainer()->run( 'LiveChatStorageInit', [ &$this->providers ] );

		parent::run();
	}

	/**
	 * @param ConnectionInterface $connection
	 * @param string $command
	 * @param array $data
	 */
	protected function onCommand( ConnectionInterface $connection, string $command, array $data ) {
		parent::onCommand( $connection, $command, $data );

		$message = $data['message'] ?? null;
		if ( !$message ) {
			self::debugLog( __FUNCTION__, 'ERROR: Message is empty' );
			return;
		}

		$providerName = $message['providerName'] ?? null;
		if ( !$providerName ) {
			self::debugLog( __FUNCTION__, 'ERROR: Provider name is empty' );
			return;
		}

		$className = $this->providers[$providerName] ?? null;
		if ( !$className ) {
			self::debugLog( __FUNCTION__, 'ERROR', 'Unknown provider for name', $providerName );
			return;
		}

		call_user_func( [ $className, 'onCommand' ], $connection, $providerName, $command, $data );
	}
}
PK       ! 8  8    Connection.phpnu [        <?php

namespace LiveChat;

use Exception;
use FormatJson;
use MediaWiki\MediaWikiServices;
use MWDebug;
use Title;
use User;
use WebRequest;
use Wikimedia\Timestamp\ConvertibleTimestamp;
use Workerman\Connection\ConnectionInterface;

class Connection {

	/**
	 * @var ConnectionInterface
	 */
	private $connection;

	/**
	 * @var User
	 */
	private $user;

	/**
	 * @var string
	 */
	private $userSession;

	/**
	 * @var Title|null
	 */
	private $title;

	/**
	 * @var Room[]
	 */
	private $rooms = [];

	/**
	 * @var User[]
	 */
	private $lpUsers;

	/**
	 * @var array
	 */
	private $data = [];

	/**
	 * @var Connection[][]
	 */
	private static $anonymous = [];

	/**
	 * @var Connection[][]
	 */
	private static $registered = [];

	const COUNT_ALL = 1;
	const COUNT_REGISTERED = 2;
	const COUNT_ANONYMOUS = 3;

	const EVENT_CONNECT = 'connect';
	const EVENT_PING = 'ping';
	const EVENT_PONG = 'pong';
	const EVENT_SEND_WRONG_ROOM = 'LiveChatWrongRoom';

	/**
	 * Connection constructor.
	 * @param ConnectionInterface $connection
	 * @param User $user
	 */
	public function __construct( ConnectionInterface $connection, User $user ) {
		$this->connection = $connection;
		$this->user = $user;

		if ( $user->isAnon() ) {
			if ( !isset( self::$anonymous[$user->getName()] ) ) {
				self::$anonymous[$user->getName()] = [];
			}
			$this->lpUsers =& self::$anonymous[$user->getName()];
		} else {
			if ( !isset( self::$registered[$user->getName()] ) ) {
				self::$registered[$user->getName()] = [];
			}
			$this->lpUsers =& self::$registered[$user->getName()];
		}

		$this->lpUsers[] = $this;
	}

	/**
	 * @param ConnectionInterface $connection
	 * @return Connection
	 */
	public static function factory( ConnectionInterface $connection ) {
		$_SERVER['REQUEST_TIME_FLOAT'] = microtime( true );
		$_SERVER['REMOTE_ADDR'] = $connection->getRemoteIp();
		$request = new WebRequest();
		$user = User::newFromSession( $request );

		return new self( $connection, $user );
	}

	/**
	 * @return User
	 */
	public function getUser(): User {
		return $this->user;
	}

	/**
	 * @param int $count
	 * @return int|null
	 */
	public function getUsersCount( $count = self::COUNT_ALL ) {
		switch ( $count ) {
			case self::COUNT_ALL:
				return self::getUsersCount( self::COUNT_REGISTERED ) + self::getUsersCount( self::COUNT_ANONYMOUS );
			case self::COUNT_REGISTERED:
				return count( self::$registered );
			case self::COUNT_ANONYMOUS:
				return count( self::$anonymous );
		}
		return null;
	}

	/**
	 * @param string $value
	 */
	public function onMessage( $value ) {
		$data = FormatJson::decode( $value, true ) ?? [];
		$event = $data['event'] ?? null;

		if ( $event === self::EVENT_CONNECT ) {
			$this->onConnectEvent( $data );
			return;
		} elseif ( $event === self::EVENT_PING ) {
			$this->send(
				self::EVENT_PONG,
				[ 'clientTime' => $data['time'] ?? null ]
			);
			return;
		}

		foreach ( $this->rooms as $room ) {
			$room->onEvent( $this, $event, $data );
		}
	}

	/**
	 * @param string $event
	 * @param array $data
	 * @param string|null $time
	 * @return bool|void
	 */
	public function send( string $event, array $data = [], ?string $time = null ) {
		$buffer = self::makeSendBuffer( $event, $data, $time );
		return $this->sendBuffer( $buffer );
	}

	/**
	 * @param string $buffer
	 * @return bool|void
	 */
	public function sendBuffer( string $buffer ) {
		return $this->connection->send( $buffer );
	}

	/**
	 * @param string $event
	 * @param array $data
	 * @param string|null $time
	 * @return string
	 */
	public static function makeSendBuffer( string $event, array $data = [], ?string $time = null ): string {
		if ( !$time ) {
			$time = self::getTime();
		}
		$data['event'] = $event;
		$data['time'] = $time;

		return FormatJson::encode( $data );
	}

	/**
	 * @return string
	 */
	public static function getTime() {
		return ConvertibleTimestamp::now( TS_UNIX );
	}

	/**
	 * @param array $data
	 */
	private function onConnectEvent( array $data ) {
		if ( empty( $this->userSession ) ) {
			$this->userSession = $data['session'] ?? null;
		}

		$pageName = $data['pageName'] ?? null;
		if ( $pageName ) {
			$title = Title::newFromText( $pageName );
			if ( $title ) {
				$this->title = $title;
			}
		}

		try {
			MediaWikiServices::getInstance()->getHookContainer()->run( 'LiveChatConnected', [ $this, $data ] );
		} catch ( Exception $e ) {
			MWDebug::warning( $e->getMessage() );
		}
	}

	public function onClose() {
		foreach ( $this->rooms as $room ) {
			$room->removeConnection( $this );
		}

		$key = array_search( $this, $this->lpUsers );
		if ( $key !== false ) {
			unset( $this->lpUsers[$key] );
			if ( !$this->lpUsers ) { // It is the last connection for the user
				$user = $this->getUser();
				if ( $user->isAnon() ) {
					unset( self::$anonymous[$user->getName()] );
				} else {
					unset( self::$registered[$user->getName()] );
				}
			}
		}
	}

	/**
	 * @param Room $room
	 * @param string|null $name
	 */
	public function addRoom( Room $room, ?string $name = null ) {
		if ( !$name ) {
			$name = get_class( $room );
		}

		if ( !empty( $this->rooms[$name] ) ) {
			$this->rooms[$name]->removeConnection( $this );
		}
		$this->rooms[$name] = $room;
		if ( $room ) {
			$room->addConnection( $this );
		}
	}

	/**
	 * @param string $name
	 * @return Room|null
	 */
	public function getRoom( string $name ): ?Room {
		return $this->rooms[$name] ?? null;
	}

	/**
	 * @return Title|null
	 */
	public function getTitle() {
		return $this->title;
	}

	/**
	 * @return int|string
	 */
	public function getUserKey() {
		$user = $this->user;
		return $user->isAnon() ? $user->getName() : $user->getId();
	}

	/**
	 * @param string $name
	 * @return mixed|null
	 */
	public function getData( $name ) {
		return $this->data[$name] ?? null;
	}

	/**
	 * @param string $name
	 * @param mixed $value
	 */
	public function setData( $name, $value ) {
		$this->data[$name] = $value;
	}

	/**
	 * @param string $error
	 */
	public function sendErrorMessage( string $error ) {
		$this->send(
			'ErrorMessage',
			[ 'error' => $error ]
		);
	}

	/**
	 * @return int|null
	 */
	public function getId() {
		return $this->connection->id ?? null;
	}
}
PK       ! I^      LiveChatHooks.phpnu [        <?php

use LiveChat\ChatData;
use LiveChat\ChatRoom;
use LiveChat\Connection;
use LiveChat\ManagerRoom;
use MediaWiki\MediaWikiServices;

class LiveChatHooks {

	/**
	 * @var ChatRoom[]
	 */
	private static $chatRooms = [];

	/**
	 * @var ManagerRoom
	 */
	private static $managerRoom;

	/**
	 * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforePageDisplay
	 * @param OutputPage &$out
	 * @param Skin &$skin
	 */
	public static function onBeforePageDisplay( OutputPage &$out, Skin &$skin ) {
		// $out->addModules( 'ext.LiveChat.client' );
	}

	/**
	 * @param Connection $connection
	 */
	public static function onLiveChatConnected( Connection $connection ) {
		static $specialLiveChatText, $specialLiveStatusText;

		$title = $connection->getTitle();
		if ( $title && $title->getNamespace() === NS_SPECIAL ) {
			if ( !$specialLiveChatText ) {
				$specialLiveChatText = SpecialPage::getTitleFor( 'LiveChat' )->getText();
				$specialLiveStatusText = SpecialPage::getTitleFor( 'LiveStatus' )->getText();
			}
			$rootText = $title->getRootText();
			echo "####################### $rootText\n";
			echo "$ $specialLiveChatText $$$ $specialLiveStatusText\n";
			if ( $rootText === $specialLiveChatText ) {
				$subpageText = $title->getSubpageText();
				if ( empty( self::$chatRooms[$subpageText] ) ) {
					self::$chatRooms[$subpageText] = new ChatRoom();
				}
				$connection->addRoom( self::$chatRooms[$subpageText] );
			} elseif ( $rootText === $specialLiveStatusText ) {
				echo '$connection->addRoom( self::getManagerRoom() );';
				$connection->addRoom( self::getManagerRoom() );
			}
		}
	}

	/**
	 * @param array &$providers
	 */
	public static function onLiveChatStorageInit( &$providers ) {
		$providers[ChatData::class] = ChatData::class;
	}

	/**
	 * @see https://www.mediawiki.org/wiki/Manual:Hooks/MakeGlobalVariablesScript
	 * @param array &$vars
	 * @param OutputPage $out
	 * @throws ConfigException
	 */
	public static function onMakeGlobalVariablesScript( &$vars, OutputPage $out ) {
		$config = MediaWikiServices::getInstance()->getConfigFactory()
			->makeConfig( 'main' );

		$domain = $config->get( 'LiveChatClientDomain' ) ?: self::getDomain();
		$port = $config->get( 'LiveChatClientPort' );
		$path = $config->get( 'LiveChatClientPath' );
		$protocol = $config->get( 'LiveChatClientTLS' ) ? 'wss' : 'ws';

		$vars['LiveChatClientURL'] = "$protocol://$domain:$port/$path";
	}

	/**
	 * @return string
	 */
	private static function getDomain() {
		global $wgServer, $wgServerName;

		$serverParts = wfParseUrl( $wgServer );
		return $serverParts && isset( $serverParts['host'] ) ? $serverParts['host'] : $wgServerName;
	}

	/**
	 * @return ManagerRoom
	 */
	public static function getManagerRoom() {
		if ( !self::$managerRoom ) {
			self::$managerRoom = new ManagerRoom(
				0,
				[
					ManagerRoom::O_ROOM_RESTRICTION => 'LiveChatManager',
				]
			);
		}
		return self::$managerRoom;
	}

	/**
	 * This is attached to the MediaWiki 'LoadExtensionSchemaUpdates' hook.
	 * Fired when MediaWiki is updated to allow extensions to update the database
	 * @param DatabaseUpdater $updater
	 */
	public static function onLoadExtensionSchemaUpdates( DatabaseUpdater $updater ) {
		$updater->addExtensionTable( 'lch_messages', __DIR__ . '/../sql/messages.sql' );
		$updater->addExtensionField( 'lch_messages', 'lchm_has_children', __DIR__ . '/../sql/patch_messages_add_has_children.sql' );
		$updater->addExtensionTable( 'lch_msg_reactions', __DIR__ . '/../sql/msg_reactions.sql' );
	}
}
PK       ! ba      ManagerRoom.phpnu [        <?php
namespace LiveChat;

class ManagerRoom extends Room {

	const ROOM_TYPE = 1;

	/**
	 * @param Connection $connection
	 * @param string $event
	 * @param array $data
	 */
	public function onEvent( Connection $connection, string $event, array $data ) {
		switch ( $event ) {
			case 'getRoomList':
				$this->onGetRoomList( $connection, $data );
				break;
			default:
				parent::onEvent( $connection, $event, $data );
		}
	}

	/**
	 * @param Connection $connection
	 * @param array $data
	 */
	private function onGetRoomList( Connection $connection, array $data ) {
		$this->debugLog( static::class, $connection->getId() );
		$this->sendToManager(
			'status',
			[ 'info' => 'rooms' ],
			[ self::TARGET_CONNECTION => $connection->getId() ]
		);
	}
}
PK       ! ҭA      MessageParser.phpnu [        <?php
namespace LiveChat;

use Language;
use Linker;
use MediaWiki\MediaWikiServices;
use Parser;
use Sanitizer;
use Title;

class MessageParser {
	const TYPE_TEXT = 'text';
	const TYPE_INTERNAL_LINK = 'internalLink';
	const TYPE_EXTERNAL_LINK = 'externalLink';

	/**
	 * @var array
	 */
	private $result = [];

	/**
	 * @var string
	 */
	private $mUrlProtocols;

	/**
	 * @var string
	 */
	private $mAbsoluteUrlProtocols;

	/**
	 * @var string
	 */
	private $mExtLinkBracketedRegex;

	public function __construct() {
		$urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
		$this->mUrlProtocols = $urlUtils->validProtocols();
		$this->mAbsoluteUrlProtocols = $urlUtils->validAbsoluteProtocols();
		$this->mExtLinkBracketedRegex = '/\[(((?i)' . $this->mUrlProtocols . ')' .
			Parser::EXT_LINK_ADDR .
			Parser::EXT_LINK_URL_CLASS . '*)\p{Zs}*([^\]\\x00-\\x08\\x0a-\\x1F\\x{FFFD}]*?)\]/Su';
	}

	/**
	 * @param string $text
	 * @return array
	 */
	public static function parse( $text ) {
		$parser = new self();
		$parser->parseInternalLinks( $text );
		$parser->parseExternalLinks();
		$parser->parseFreeExternalLinks();
		return $parser->getResult();
	}

	/**
	 * @param string $text
	 */
	private function parseInternalLinks( $text ) {
		static $tc = false, $e1;

		// Parse Internal links
		if ( !$tc ) {
			$tc = Title::legalChars() . '#%';
			# Match a link having the form [[namespace:link|alternate]]trail
			$e1 = "/^(.*?)\[\[([{$tc}]+)(?:\\|(.+?))?]](.*)\$/sD";
		}

		while ( preg_match( $e1, $text, $matches ) ) {
			if ( $matches[1] ) {
				$this->result[] = self::makeText( $matches[1] );
			}
			$this->result[] = self::makeInternalLink( $matches[2], $matches[3] );
			// var_dump( $matches );
			$text = $matches[4];
		}

		if ( $text ) {
			$this->result[] = self::makeText( $text );
		}
	}

	private function parseExternalLinks() {
		/** @var Language $wgLang */
		global $wgLang;
		$langConverter = MediaWikiServices::getInstance()->getLanguageConverterFactory()->getLanguageConverter( $wgLang );

		$key = 0;
		while ( isset( $this->result[$key] ) ) {
			$result = $this->result[$key];
			if ( $result['type'] !== self::TYPE_TEXT || empty( $result['text'] ) ) {
				$key++;
				continue;
			}

			$text = $result['text'];
			$newResult = [];

			$bits = preg_split( $this->mExtLinkBracketedRegex, $text, -1, PREG_SPLIT_DELIM_CAPTURE );
			$tmp = array_shift( $bits );
			if ( $tmp ) {
				$newResult[] = self::makeText( $tmp );
			}

			$i = 0;
			while ( $i < count( $bits ) ) {
				$url = $bits[$i++];
				$i++; // protocol
				$text = $bits[$i++];
				$trail = $bits[$i++];

				# The characters '<' and '>' (which were escaped by
				# removeHTMLtags()) should not be included in
				# URLs, per RFC 2396.
				$m2 = [];
				if ( preg_match( '/&(lt|gt);/', $url, $m2, PREG_OFFSET_CAPTURE ) ) {
					$text = substr( $url, $m2[0][1] ) . ' ' . $text;
					$url = substr( $url, 0, $m2[0][1] );
				}

				$dtrail = '';

				# No link text, e.g. [http://domain.tld/some.link]
				if ( $text !== '' ) {
					# Have link text, e.g. [http://domain.tld/some.link text]s
					# Check for trail
					[ $dtrail, $trail ] = Linker::splitTrail( $trail );

					// Excluding protocol-relative URLs may avoid many false positives.
					if ( preg_match( '/^(?:' . $this->mAbsoluteUrlProtocols . ')/', $text ) ) {
						$text = $langConverter->markNoConversion( $text );
					}
				}

				$newResult[] = self::makeExternalLink( $url, $text );
				$newResult[] = self::makeText( $dtrail . $trail );
			}

			$newCount = count( $newResult );
			if ( $newCount === 1 ) {
				$this->result[$key] = $newResult[0];
			} elseif ( $newResult ) {
				array_splice( $this->result, $key, 1, $newResult );
				$key += $newCount;
				continue;
			}
			$key++;
		}
	}

	public function parseFreeExternalLinks() {
		$prots = $this->mAbsoluteUrlProtocols;
		$urlChar = Parser::EXT_LINK_URL_CLASS;
		$addr = Parser::EXT_LINK_ADDR;

		$key = 0;
		while ( isset( $this->result[$key] ) ) {
			$result = $this->result[$key];
			if ( $result['type'] !== self::TYPE_TEXT || empty( $result['text'] ) ) {
				$key++;
				continue;
			}

			$text = $result['text'];
			$newResult = [];

			while ( preg_match( "!(.*?)(\b(?i:$prots)($addr$urlChar*))(.*)!xu", $text, $matches ) ) {
				// var_dump( $matches );
				if ( $matches[1] ) {
					$newResult[] = self::makeText( $matches[1] );
				}
				$newResult[] = self::makeExternalLink( $matches[2] );
				$text = $matches[4];
			}

			if ( $text === $result['text'] ) {
				$key++;
				continue;
			} elseif ( $text ) {
				$newResult[] = self::makeText( $text );
			}

			$newCount = count( $newResult );
			if ( $newCount === 1 ) {
				$this->result[$key] = $newResult[0];
			} elseif ( $newResult ) {
				array_splice( $this->result, $key, 1, $newResult );
				$key += $newCount;
				continue;
			}
			$key++;
		}
	}

	/**
	 * @param string $text
	 * @return string[]
	 */
	private static function makeText( $text ) {
		return [
			'type' => self::TYPE_TEXT,
			'text' => $text,
		];
	}

	/**
	 * @param string $titleText
	 * @param string $text
	 * @return string[]
	 */
	private static function makeInternalLink( $titleText, $text ) {
		$title = Title::newFromText( $titleText );
		if ( !$title ) {
			return self::makeText( '[[' . $titleText . ( $text ? "|$text" : '' ) . ']]' );
		}

		return [
			'type' => self::TYPE_INTERNAL_LINK,
			'url' => $title->getCanonicalURL(),
			'text' => $text ?: $titleText,
		];
	}

	/**
	 * @param string $url
	 * @param string|null $text
	 * @return array
	 */
	private static function makeExternalLink( $url, $text = null ) {
		return [
			'type' => self::TYPE_EXTERNAL_LINK,
			'url' => Sanitizer::cleanUrl( $url ),
			'text' => $text ?: $url,
			'free' => !$text
		];
	}

	/**
	 * @return array
	 */
	public function getResult(): array {
		return $this->result;
	}

}
PK       ! \	  \	    specials/SpecialWhosOnline.phpnu [        <?php

use MediaWiki\MediaWikiServices;

/**
 * WhosOnline extension - creates a list of logged-in users & anons currently online
 * The list can be viewed at Special:WhosOnline
 *
 * @file
 * @ingroup Extensions
 * @author Maciej Brencz <macbre(at)-spam-wikia.com> - minor fixes and improvements
 * @author ChekMate Security Group - original code
 * @see http://www.chekmate.org/wiki/index.php/MW:_Whos_Online_Extension
 * @license GPL-2.0-or-later
 */

class SpecialWhosOnline extends IncludableSpecialPage {
	public function __construct() {
		parent::__construct( 'WhosOnline' );
	}

	/**
	 * Get the list of anonymous users being online
	 *
	 * @return int
	 */
	protected function getAnonsOnline() {
		$dbr = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_REPLICA );

		$row = $dbr->selectRow(
			'online',
			'COUNT(*) AS cnt',
			'userid = 0',
			__METHOD__,
			'GROUP BY username'
		);
		$guests = (int)$row->cnt;

		return $guests;
	}

	/** @inheritDoc */
	public function execute( $para ) {
		global $wgWhosOnlineTimeout;

		$timeout = 3600;
		if ( is_numeric( $wgWhosOnlineTimeout ) ) {
			$timeout = $wgWhosOnlineTimeout;
		}

		$db = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_PRIMARY );
		$old = wfTimestamp( TS_MW, time() - $timeout );
		$db->delete( 'online', [ 'timestamp < "' . $old . '"' ], __METHOD__ );

		$this->setHeaders();

		$pager = new PagerWhosOnline();

		$showNavigation = !$this->including();
		if ( $para ) {
			$bits = preg_split( '/\s*,\s*/', trim( $para ) );
			foreach ( $bits as $bit ) {
				if ( $bit == 'shownav' ) {
					$showNavigation = true;
				}
				if ( is_numeric( $bit ) ) {
					$pager->mLimit = $bit;
				}

				$m = [];
				if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) {
					$pager->mLimit = intval( $m[1] );
				}
			}
		}

		$body = $pager->getBody();

		// Checking for both to ensure that we don't show the useless navigation
		// stuff when $body is empty, i.e. no registered users are online
		if ( $showNavigation && $body ) {
			$this->getOutput()->addHTML( $pager->getNavigationBar() );
		}
		if ( $body ) {
			$this->getOutput()->addHTML( '<ul>' . $body . '</ul>' );
		} else {
			// Nothing to display, hmm? Well, no point in continuing further, then...
			// Just get us out of here.
			$this->getOutput()->addHTML( $this->msg( 'specialpage-empty' )->parse() );
		}
	}
}
PK       ! ~X4$0	  0	    WhosOnlineHooks.phpnu [        <?php
/**
 * @file
 */

use MediaWiki\MediaWikiServices;

class WhosOnlineHooks {

	/**
	 * Update online data.
	 *
	 * @param OutputPage &$out
	 * @param Skin &$skin
	 * @throws \ConfigException
	 * @return true|void
	 */
	public static function onBeforePageDisplay( OutputPage &$out, Skin &$skin ) {
		global $wgWhosOnlineTimeout;

		$services = MediaWikiServices::getInstance();
		// don't write to the DB if the DB is read-only
		if ( $services->getReadOnlyMode()->isReadOnly() ) {
			return true;
		}

		$user = $out->getUser();
		$userOptionsManager = $services->getUserOptionsManager();
		$lastVisit = $userOptionsManager->getOption( $user, 'LastVisit' );
		$currentTime = wfTimestamp( TS_UNIX );
		if ( empty( $lastVisit ) || $currentTime - $lastVisit > $wgWhosOnlineTimeout ) {

			if ( !$user->isAnon() ) {
				$userOptionsManager->setOption( $user, 'LastVisit', $currentTime );
			}

			// write to DB (use master)
			$dbw = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_PRIMARY );
			$now = gmdate( 'YmdHis', time() );

			// row to insert to table
			$row = [
				'userid' => $user->getId(),
				'username' => $user->getName(),
				'timestamp' => $now,
				'wikiid' => $out->getConfig()->get( 'DBname' )
			];

			$method = __METHOD__;
			$dbw->onTransactionCommitOrIdle( static function () use ( $dbw, $method, $row, $services ) {
				$dbw->upsert(
					'online',
					$row,
					[ [ 'userid', 'username', 'wikiid' ] ],
					[ 'timestamp' => $row['timestamp'] ],
					$method
				);

				// Per ApiQueryWhosOnline.php:
				// Not using $cache->makeKey() on $key intentionally to keep the key a
				// global one; makeKey() automagically adds the current DB name to it as
				// a prefix
				$key = 'whosonline:data';
				$services->getMainWANObjectCache()->delete( $key );
			} );
		}
	}

	/**
	 * Apply database schema changes when MediaWiki core updater script (update.php) is re-run
	 *
	 * @param DatabaseUpdater $updater
	 */
	public static function onLoadExtensionSchemaUpdates( $updater ) {
		$updater->addExtensionTable( 'online', __DIR__ . '/../sql/whosonline.sql' );
		// wikiid field since WhosOnline version 1.8.0
		if ( !$updater->getDB()->fieldExists( 'whosonline', 'wikiid' ) ) {
			$updater->addExtensionField( 'online', 'wikiid',
				__DIR__ . '/../sql/patch-add-wikiid-field.sql' );
		}
	}

}
PK       ! Γi	  	    PagerWhosOnline.phpnu [        <?php
/**
 * WhosOnline extension - creates a list of logged-in users & anons currently online
 * The list can be viewed at Special:WhosOnline
 *
 * @file
 * @ingroup Extensions
 * @author Maciej Brencz <macbre(at)-spam-wikia.com> - minor fixes and improvements
 * @author ChekMate Security Group - original code
 * @see http://www.chekmate.org/wiki/index.php/MW:_Whos_Online_Extension
 * @license GPL-2.0-or-later
 */

class PagerWhosOnline extends IndexPager {
	function __construct() {
		parent::__construct();
		$this->mLimit = $this->mDefaultLimit;
	}

	/** @inheritDoc */
	function getQueryInfo() {
		global $wgWhosOnlineShowAnons;

		return [
			'tables'  => [ 'online' ],
			'fields'  => [ 'username' ],
			'options' => [
				'ORDER BY' => 'timestamp DESC',
				'GROUP BY' => 'username'
			],
			'conds'   => $wgWhosOnlineShowAnons
					? []
					: [ 'userid != 0' ]
		];
	}

	/**
	 * use classical LIMIT/OFFSET instead of sorting by table key
	 * @inheritDoc
	 */
	function reallyDoQuery( $offset, $limit, $descending ) {
		$info = $this->getQueryInfo();
		$tables = $info['tables'];
		$fields = $info['fields'];
		$conds = isset( $info['conds'] ) ? $info['conds'] : [];
		$options = isset( $info['options'] ) ? $info['options'] : [];

		$options['LIMIT']  = intval( $limit );
		$options['OFFSET'] = intval( $offset );

		return $this->mDb->select( $tables, $fields, $conds, __METHOD__, $options );
	}

	/** @inheritDoc */
	function getIndexField() {
		// dummy
		return 'username';
	}

	/** @inheritDoc */
	function formatRow( $row ) {
		global $wgWhosOnlineShowRealName;

		$userPageLink = Title::makeTitle( NS_USER, $row->username )->getFullURL();
		$name = $row->username;
		if ( $wgWhosOnlineShowRealName ) {
			$user = User::newFromName( $name );
			if ( $user ) {
				$realName = $user->getRealName();
				if ( $realName !== '' ) {
					$name = $realName;
				}
			}
		}
		return '<li><a href="' . htmlspecialchars( $userPageLink, ENT_QUOTES ) . '">' .
			htmlspecialchars( $name, ENT_QUOTES ) . '</a></li>';
	}

	/**
	 * @return int
	 */
	function countUsersOnline() {
		$row = $this->mDb->selectRow(
			'online',
			'COUNT(*) AS cnt',
			'userid != 0',
			__METHOD__,
			'GROUP BY username'
		);
		$users = (int)$row->cnt;

		return $users;
	}

	/** @inheritDoc */
	function getNavigationBar() {
		return $this->buildPrevNextNavigation(
			SpecialPage::getTitleFor( 'WhosOnline' ),
			$this->mOffset,
			$this->mLimit,
			[],
			// show next link
			$this->countUsersOnline() < ( $this->mLimit + (int)$this->mOffset )
		);
	}
}
PK       ! 8T      api/ApiQueryWhosOnline.phpnu [        <?php
/**
 * API for WhosOnline extension
 *
 * @file
 * @ingroup API
 * @author Maciej Brencz <macbre@wikia-inc.com>
 * @author Maciej Błaszkowski <marooned@wikia-inc.com> - optimization
 */

class ApiQueryWhosOnline extends ApiQueryBase {

	/** @var WANObjectCache Injected via services magic and set up in the extension.json file */
	private $cache;

	/**
	 * @param ApiQuery $query
	 * @param string $action
	 * @param WANObjectCache $wanCache
	 */
	public function __construct(
		ApiQuery $query,
		$action,
		WANObjectCache $wanCache
	) {
		parent::__construct( $query, $action );
		$this->cache = $wanCache;
	}

	/**
	 * Main function
	 */
	public function execute() {
		$config = $this->getConfig();
		// Not using $cache->makeKey() on $key intentionally to keep the key a
		// global one; makeKey() automagically adds the current DB name to it as
		// a prefix
		$key = 'whosonline:data';
		$memcData = $this->cache->get( $key );

		if ( !is_array( $memcData ) ) {
			// database instance
			$dbr = $this->getDB();

			// build query
			$this->addTables( [ 'online' ] );
			$this->addFields( [ 'userid', 'username', 'timestamp', 'wikiid' ] );

			$this->addOption( 'ORDER BY', 'timestamp DESC' );

			$maxAge = wfTimestamp( TS_UNIX ) - $config->get( 'WhosOnlineTimeout' );
			$this->addWhere( "timestamp >= '$maxAge'" );
			if ( !$config->get( 'WhosOnlineShowAnons' ) ) {
				$this->addWhere( 'userid != 0' );
			}

			// build results
			$data = [];
			$res = $this->select( __METHOD__ );

			$i = $countUsers = $countAnons = 0;

			foreach ( $res as $row ) {
				// count both anons and logged-in
				if ( $row->userid != 0 ) {
					// add only logged-in
					$data[$i] = [
						'userid' => $row->userid,
						'user' => $row->username,
						'time' => $row->timestamp,
						'wikiid' => $row->wikiid
					];
					$countUsers++;
				} else {
					$countAnons++;
				}
				$i++;
			}

			$memcData = [
				'data' => $data,
				'countUsers' => $countUsers,
				'countAnons' => $countAnons
			];
			$this->cache->set( $key, $memcData, $config->get( 'WhosOnlineTimeout' ) );
		} else {
			$data = $memcData['data'];
			$countUsers = (int)$memcData['countUsers'];
			$countAnons = (int)$memcData['countAnons'];
		}

		$params = $this->extractRequestParams();
		$limit  = is_numeric( $params['limit'] ) ? $params['limit'] : 50;
		$offset = is_numeric( $params['offset'] ) ? $params['offset'] : 0;

		if ( !$config->get( 'WhosOnlinePerWiki' ) ) {
			// Look on every wiki and display only one record for one user (the newest)
			$tmpUsers = [];
			for ( $i = count( $data ) - 1; $i >= 0; $i-- ) {
				if ( empty( $tmpUsers[$data[$i]['user']] ) ) {
					$tmpUsers[$data[$i]['user']] = 1;
				} else {
					$data[$i]['userid'] == 0 ? $countAnons-- : $countUsers--;
					unset( $data[$i] );
				}
			}
		} else {
			// Look only on current wiki
			for ( $i = count( $data ) - 1; $i >= 0; $i-- ) {
				if ( $data[$i]['wikiid'] != $config->get( 'DBname' ) ) {
					$data[$i]['userid'] == 0 ? $countAnons-- : $countUsers--;
					unset( $data[$i] );
				}
			}
		}

		// limit results
		$data = array_slice( $data, $offset, $limit );

		$result = $this->getResult();
		ApiResult::setIndexedTagName( $data, 'online' );
		$result->addValue( [ 'query', $this->getModuleName() ], null, $data );
		$result->addValue( [ 'query', 'users' ], null, intval( $countUsers ) );
		$result->addValue( [ 'query', 'anons' ], null, intval( $countAnons ) );
	}

	/** @inheritDoc */
	public function getAllowedParams() {
		return [
			'limit' => [
				ApiBase::PARAM_TYPE => 'integer'
			],
			'offset' => [
				ApiBase::PARAM_TYPE => 'integer'
			]
		];
	}

	/** @inheritDoc */
	protected function getExamplesMessages() {
		return [
			'action=query&list=whosonline' =>
				'apihelp-query+whosonline-example-1',
			'action=query&list=whosonline&limit=5' =>
				'apihelp-query+whosonline-example-2',
			'action=query&list=whosonline&limit=5&offset=15' =>
				'apihelp-query+whosonline-example-3',
		];
	}

}
PK       ! 	0  0    specials/SpecialPatroller.phpnu [        <?php
/**
 * Patroller
 * Patroller MediaWiki hooks
 *
 * @author: Rob Church <robchur@gmail.com>, Kris Blair (Developaws)
 * @copyright: 2006-2008 Rob Church, 2015-2017 Kris Blair
 * @license: GPL General Public Licence 2.0
 * @package: Patroller
 * @link: https://mediawiki.org/wiki/Extension:Patroller
 */

use MediaWiki\MediaWikiServices;
use MediaWiki\Revision\SlotRecord;

class SpecialPatroller extends SpecialPage {
	/**
	 * Constructor
	 *
	 * @return void
	 */
	public function __construct() {
		parent::__construct( 'Patrol', 'patroller' );
	}

	public function doesWrites() {
		return true;
	}

	/**
	 * Execution
	 *
	 * @param array $par Parameters passed to the page
	 * @return void
	 */
	public function execute( $par ) {
		$out = $this->getOutput();
		$user = $this->getUser();
		$request = $this->getRequest();
		$this->setHeaders();

		// Check permissions
		if ( !$user->isAllowed( 'patroller' ) ) {
			throw new PermissionsError( 'patroller' );
		}

		// Keep out blocked users
		if ( $user->getBlock() ) {
			throw new UserBlockedError( $user->getBlock() );
		}

		// Prune old assignments if needed
		if ( mt_rand( 0, 499 ) == 0 ) {
			$this->pruneAssignments();
		}

		// See if something needs to be done
		if ( $request->wasPosted() && $user->matchEditToken( $request->getText( 'wpToken' ) ) ) {
			$rcid = $request->getIntOrNull( 'wpRcId' );
			if ( $rcid ) {
				if ( $request->getCheck( 'wpPatrolEndorse' ) ) {
					// Mark the change patrolled
					if ( !$user->getBlock( false ) ) {
						$rc = RecentChange::newFromId( $rcid );
						if ( $rc !== null ) {
							$rc->doMarkPatrolled( $user );
						}
						$out->setSubtitle( wfMessage( 'patrol-endorsed-ok' )->escaped() );
					} else {
						$out->setSubtitle( wfMessage( 'patrol-endorsed-failed' )->escaped() );
					}
				} elseif ( $request->getCheck( 'wpPatrolRevert' ) ) {
					// Revert the change
					$edit = $this->loadChange( $rcid );
					$msg = $this->revert( $edit, $this->revertReason( $request ) ) ? 'ok' : 'failed';
					$out->setSubtitle( wfMessage( 'patrol-reverted-' . $msg )->escaped() );
				} elseif ( $request->getCheck( 'wpPatrolSkip' ) ) {
					// Do nothing
					$out->setSubtitle( wfMessage( 'patrol-skipped-ok' )->escaped() );
				}
			}
		}

		// If a token was passed, but the check box value was not, then the user
		// wants to pause or stop patrolling
		if ( $request->getCheck( 'wpToken' ) && !$request->getCheck( 'wpAnother' ) ) {
			$skin = $this->getSkin();
			$self = SpecialPage::getTitleFor( 'Patrol' );
			$link = Linker::link(
				$self,
				wfMessage( 'patrol-resume' )->escaped(),
				[],
				[],
				[ 'known' ]
			);
			$out->addHTML( wfMessage( 'patrol-stopped', $link )->escaped() );
			return;
		}

		// Pop an edit off recentchanges
		$haveEdit = false;
		while ( !$haveEdit ) {
			$edit = $this->fetchChange( $user );
			if ( $edit ) {
				// Attempt to assign it
				if ( $this->assignChange( $edit ) ) {
					$haveEdit = true;
					$this->showDiffDetails( $edit );
					$out->addHTML( '<br><hr>' );
					$this->showDiff( $edit );
					$out->addHTML( '<br><hr>' );
					$this->showControls( $edit );
				}
			} else {
				// Can't find a suitable edit
				// Don't keep going, there's nothing to find
				$haveEdit = true;
				$out->addWikiTextAsInterface( wfMessage( 'patrol-nonefound' )->text() );
			}
		}
	}

	/**
	 * Produce a stub recent changes listing for a single diff.
	 *
	 * @param RecentChange &$edit Diff. to show the listing for
	 */
	private function showDiffDetails( &$edit ) {
		$out = $this->getOutput();
		$edit->counter = 1;
		$editAttribs = $edit->getAttributes();
		$editAttribs['rc_patrolled'] = 1;
		$edit->setAttribs( $editAttribs );
		$list = ChangesList::newFromContext( RequestContext::GetMain() );
		$out->addHTML(
			$list->beginRecentChangesList() .
			$list->recentChangesLine( $edit ) .
			$list->endRecentChangesList()
		);
	}

	/**
	 * Output a trimmed down diff view corresponding to a particular change
	 *
	 * @param RecentChange &$edit Recent change to produce a diff for
	 */
	private function showDiff( &$edit ) {
		$diff = new DifferenceEngine(
			$edit->getTitle(),
			$edit->getAttribute( 'rc_last_oldid' ),
			$edit->getAttribute( 'rc_this_oldid' )
		);
		$diff->showDiff( '', '' );
	}

	/**
	 * Output a bunch of controls to let the user endorse, revert and skip changes
	 *
	 * @param RecentChange &$edit RecentChange being dealt with
	 */
	private function showControls( &$edit ) {
		$user = $this->getUser();
		$out = $this->getOutput();
		$self = SpecialPage::getTitleFor( 'Patrol' );
		$form = Html::openElement( 'form', [
			'method' => 'post',
			'action' => $self->getLocalUrl()
		] );
		$form .= Html::openElement( 'table' );
		$form .= Html::openElement( 'tr' );
		$form .= Html::openElement( 'td', [
			'align' => 'right'
		] );
		$form .= Html::submitButton( wfMessage( 'patrol-endorse' )->escaped(), [
			'name' => 'wpPatrolEndorse'
		] );
		$form .= Html::closeElement( 'td' );
		$form .= Html::openElement( 'td' ) . Html::closeElement( 'td' );
		$form .= Html::closeElement( 'tr' );
		$form .= Html::openElement( 'tr' );
		$form .= Html::openElement( 'td', [
			'align' => 'right'
		] );
		$form .= Html::submitButton( wfMessage( 'patrol-revert' )->escaped(), [
			'name' => 'wpPatrolRevert'
		] );
		$form .= Html::closeElement( 'td' );
		$form .= Html::openElement( 'td' );
		$form .= Html::label( wfMessage( 'patrol-revert-reason' )->escaped(), 'reason' ) . '&#160;';
		$form .= $this->revertReasonsDropdown() . ' / ' . Html::input( 'wpPatrolRevertReason' );
		$form .= Html::closeElement( 'td' );
		$form .= Html::closeElement( 'tr' );
		$form .= Html::openElement( 'tr' );
		$form .= Html::openElement( 'td', [
			'align' => 'right'
		] );
		$form .= Html::submitButton( wfMessage( 'patrol-skip' )->escaped(), [
			'name' => 'wpPatrolSkip'
		] );
		$form .= Html::closeElement( 'td' );
		$form .= Html::closeElement( 'tr' );
		$form .= Html::openElement( 'tr' );
		$form .= Html::openElement( 'td' );
		$form .= Html::check( 'wpAnother', true );
		$form .= Html::closeElement( 'td' );
		$form .= Html::openElement( 'td' );
		$form .= wfMessage( 'patrol-another' )->escaped();
		$form .= Html::closeElement( 'td' );
		$form .= Html::closeElement( 'tr' );
		$form .= Html::closeElement( 'table' );
		$form .= Html::Hidden( 'wpRcId', $edit->getAttribute( 'rc_id' ) );
		$form .= Html::Hidden( 'wpToken', $user->getEditToken() );
		$form .= Html::closeElement( 'form' );
		$out->addHTML( $form );
	}

	/**
	 * Fetch a recent change which
	 *   - the user doing the patrolling didn't cause
	 *   - wasn't due to a bot
	 *   - hasn't been patrolled
	 *   - isn't assigned to a user
	 *
	 * @param User &$user User to suppress edits for
	 * @return false|RecentChange
	 */
	private function fetchChange( &$user ) {
		$dbr = wfGetDB( DB_REPLICA );
		$aid = $user->getActorId();
		$res = $dbr->select(
			[ 'page', 'recentchanges', 'patrollers' ],
			'*',
			[
				'ptr_timestamp IS NULL',
				'rc_namespace = page_namespace',
				'rc_title = page_title',
				'rc_this_oldid = page_latest',
				'rc_actor != ' . $aid,
				'rc_bot'		=> '0',
				'rc_patrolled'	=> '0',
				'rc_type'		=> '0'
			],
			__METHOD__,
			[
				'LIMIT'	=> 1
			],
			[
				'patrollers' => [
					'LEFT JOIN',
					[
						'rc_id = ptr_change'
					]
				]
			]
		);
		if ( $res->numRows() > 0 ) {
			$row = $res->fetchObject();
			return RecentChange::newFromRow( $row, $row->rc_last_oldid );
		}
		return false;
	}

	/**
	 * Fetch a particular recent change given the rc_id value
	 *
	 * @param int $rcid rc_id value of the row to fetch
	 * @return bool|RecentChange
	 */
	private function loadChange( $rcid ) {
		$dbr = wfGetDB( DB_REPLICA );
		$row = $dbr->selectRow(
			'recentchanges',
			'*',
			[
				'rc_id' => $rcid
			],
			'Patroller::loadChange'
		);
		if ( $row ) {
			return RecentChange::newFromRow( $row );
		}
		return false;
	}

	/**
	 * Assign the patrolling of a particular change, so other users don't pull
	 * it up, duplicating effort
	 *
	 * @param RecentChange &$edit RecentChange item to assign
	 * @return bool If rows were changed
	 */
	private function assignChange( &$edit ) {
		$dbw = wfGetDB( DB_PRIMARY );
		$res = $dbw->insert(
			'patrollers',
			[
				'ptr_change'	=> $edit->getAttribute( 'rc_id' ),
				'ptr_timestamp'	=> $dbw->timestamp()
			],
			__METHOD__,
			'IGNORE'
		);
		return (bool)$dbw->affectedRows();
	}

	/**
	 * Remove the assignment for a particular change, to let another user handle it
	 *
	 * @param int $rcid rc_id value
	 *
	 * @todo Use it or lose it
	 */
	private function unassignChange( $rcid ) {
		$dbw = wfGetDB( DB_PRIMARY );
		$dbw->delete(
			'patrollers',
			[
				'ptr_change' => $rcid
			],
			__METHOD__
		);
	}

	/**
	 * Prune old assignments from the table so edits aren't
	 * hidden forever because a user wandered off, and to
	 * keep the table size down as regards old assignments
	 */
	private function pruneAssignments() {
		$dbw = wfGetDB( DB_PRIMARY );
		$dbw->delete(
			'patrollers',
			[
				'ptr_timestamp < ' . $dbw->timestamp( time() - 120 )
			],
			__METHOD__
		);
	}

	/**
	 * Revert a change, setting the page back to the "old" version
	 *
	 * @param RecentChange &$edit RecentChange to revert
	 * @param string $comment Comment to use when reverting
	 * @return bool Change was reverted
	 */
	private function revert( &$edit, $comment = '' ) {
		$user = $this->getUser();
		if ( !$user->getBlock( false ) ) {
			// Check block against master
			$dbw = wfGetDB( DB_PRIMARY );
			$title = $edit->getTitle();
			// Prepare the comment
			$comment = wfMessage( 'patrol-reverting', $comment )->inContentLanguage()->text();
			// Be certain we're not overwriting a more recent change
			// If we would, ignore it, and silently consider this change patrolled
			$latest = (int)$dbw->selectField(
				'page',
				'page_latest',
				[
					'page_id' => $title->getArticleID()
				],
				__METHOD__
			);
			if ( $edit->getAttribute( 'rc_this_oldid' ) == $latest ) {
				// Find the old revision
				$oldRevisionRecord = MediaWikiServices::getInstance()
					->getRevisionLookup()
					->getRevisionById( $edit->getAttribute( 'rc_last_oldid' ) );

				// Revert the edit; keep the reversion itself out of recent changes
				wfDebugLog(
					'patroller',
					'Reverting "' .
						$title->getPrefixedText() .
						'" to r' .
						$oldRevisionRecord->getId()
				);
				if ( method_exists( MediaWikiServices::class, 'getWikiPageFactory' ) ) {
					// MW 1.36+
					$page = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title );
				} else {
					$page = WikiPage::factory( $title );
				}
				if ( method_exists( $page, 'doUserEditContent' ) ) {
					// MW 1.36+
					$page->doUserEditContent(
						$oldRevisionRecord->getContent( SlotRecord::MAIN ),
						$user,
						$comment,
						EDIT_UPDATE & EDIT_MINOR & EDIT_SUPPRESS_RC
					);
				} else {
					$page->doEditContent(
						$oldRevisionRecord->getContent( SlotRecord::MAIN ),
						$comment,
						EDIT_UPDATE & EDIT_MINOR & EDIT_SUPPRESS_RC
					);
				}
			}
			// Mark the edit patrolled so it doesn't bother us again
			if ( $edit !== null ) {
				$edit->doMarkPatrolled( $user );
			}
			return true;
		}
		return false;
	}

	/**
	 * Make a nice little drop-down box containing all the pre-defined revert
	 * reasons for simplified selection
	 *
	 * @return string Reasons
	 */
	private function revertReasonsDropdown() {
		$msg = wfMessage( 'patrol-reasons' )->inContentLanguage()->text();
		if ( $msg == '-' || $msg == '&lt;patrol-reasons&gt;' ) {
			return '';
		}
		$reasons = [];
		$lines = explode( "\n", $msg );
		foreach ( $lines as $line ) {
			if ( substr( $line, 0, 1 ) == '*' ) {
				$reasons[] = trim( $line, '* ' );
			}
		}
		if ( count( $reasons ) > 0 ) {
			$box = Html::openElement( 'select', [
				'name' => 'wpPatrolRevertReasonCommon'
			] );
			foreach ( $reasons as $reason ) {
				$box .= Html::element( 'option', [
					'value' => $reason
				], $reason );
			}
			$box .= Html::closeElement( 'select' );
			return $box;
		}
		return '';
	}

	/**
	 * Determine which of the two "revert reason" form fields to use;
	 * the pre-defined reasons, or the nice custom text box
	 *
	 * @param WebRequest &$request WebRequest object to test
	 * @return string Revert reason
	 */
	private function revertReason( &$request ) {
		$custom = $request->getText( 'wpPatrolRevertReason' );
		return trim( $custom ) != '' ? $custom : $request->getText( 'wpPatrolRevertReasonCommon' );
	}
}
PK       ! n
M  M    PatrollerHooks.phpnu [        <?php
/**
 * Patroller
 * Patroller MediaWiki main hooks
 *
 * @author: Rob Church <robchur@gmail.com>, Kris Blair (Developaws)
 * @copyright: 2006-2008 Rob Church, 2015-2017 Kris Blair
 * @license: GPL General Public Licence 2.0
 * @package: Patroller
 * @link: https://mediawiki.org/wiki/Extension:Patroller
 */

class PatrollerHooks {
	/**
	 * Setup the database tables
	 *
	 * @param DatabaseUpdater $updater The updater
	 */
	public static function onLoadExtensionSchemaUpdates( $updater ) {
		$updater->addExtensionTable( 'patrollers', __DIR__ . '/../sql/add-patrollers.sql' );
	}
}
PK       ! O      TimelessVariablesModule.phpnu Iw        <?php

namespace MediaWiki\Skin\Timeless;

use MediaWiki\ResourceLoader\Context;
use MediaWiki\ResourceLoader\SkinModule;

/**
 * ResourceLoader module to set some LESS variables for the skin
 */
class TimelessVariablesModule extends SkinModule {
	/**
	 * Add our LESS variables
	 *
	 * @param Context $context
	 * @return array LESS variables
	 */
	protected function getLessVars( Context $context ) {
		$vars = parent::getLessVars( $context );
		$config = $this->getConfig();

		// Backdrop image
		$backdrop = $config->get( 'TimelessBackdropImage' );

		if ( $backdrop === 'cat.svg' ) {
			// expand default
			$backdrop = 'images/cat.svg';
		}

		return array_merge(
			$vars,
			[
				'backdrop-image' => "url($backdrop)",
				// 'logo-image' => ''
				// 'wordmark-image' => ''
				// +width cutoffs ...
			]
		);
	}

	/**
	 * Register the config var with the caching stuff so it properly updates the cache
	 *
	 * @param Context $context
	 * @return array
	 */
	public function getDefinitionSummary( Context $context ) {
		$summary = parent::getDefinitionSummary( $context );
		$summary[] = [
			'TimelessBackdropImage' => $this->getConfig()->get( 'TimelessBackdropImage' )
		];
		return $summary;
	}
}
PK       ! *w  w    TimelessTemplate.phpnu Iw        <?php
/**
 * BaseTemplate class for the Timeless skin
 *
 * @ingroup Skins
 */

namespace MediaWiki\Skin\Timeless;

use BaseTemplate;
use File;
use MediaWiki\Html\Html;
use MediaWiki\Linker\Linker;
use MediaWiki\MediaWikiServices;
use MediaWiki\Parser\Sanitizer;
use MediaWiki\ResourceLoader\SkinModule;
use MediaWiki\SpecialPage\SpecialPage;

class TimelessTemplate extends BaseTemplate {

	/** @var array */
	protected $pileOfTools;

	/** @var (array|false)[] */
	protected $sidebar;

	/** @var array|null */
	protected $otherProjects;

	/** @var array|null */
	protected $collectionPortlet;

	/** @var array[] */
	protected $languages;

	/** @var string */
	protected $afterLangPortlet;

	/**
	 * Outputs the entire contents of the page
	 */
	public function execute() {
		$this->sidebar = $this->data['sidebar'];
		$this->languages = $this->sidebar['LANGUAGES'];

		// WikiBase sidebar thing
		if ( isset( $this->sidebar['wikibase-otherprojects'] ) ) {
			$this->otherProjects = $this->sidebar['wikibase-otherprojects'];
			unset( $this->sidebar['wikibase-otherprojects'] );
		}
		// Collection sidebar thing
		if ( isset( $this->sidebar['coll-print_export'] ) ) {
			$this->collectionPortlet = $this->sidebar['coll-print_export'];
			unset( $this->sidebar['coll-print_export'] );
		}

		$this->pileOfTools = $this->getPageTools();
		$userLinks = $this->getUserLinks();

		$html = Html::openElement( 'div', [ 'id' => 'mw-wrapper', 'class' => $userLinks['class'] ] );

		$html .= Html::rawElement( 'div', [ 'id' => 'mw-header-container', 'class' => 'ts-container' ],
			Html::rawElement( 'div', [ 'id' => 'mw-header', 'class' => 'ts-inner' ],
				$userLinks['html'] .
				$this->getLogo( 'p-logo-text', 'text' ) .
				$this->getSearch()
			) .
			$this->getClear()
		);
		$html .= $this->getHeaderHack();

		// For mobile
		$html .= Html::element( 'div', [ 'id' => 'menus-cover' ] );

		$html .= Html::rawElement( 'div', [ 'id' => 'mw-content-container', 'class' => 'ts-container' ],
			Html::rawElement( 'div', [ 'id' => 'mw-content-block', 'class' => 'ts-inner' ],
				Html::rawElement( 'div', [ 'id' => 'mw-content-wrapper' ],
					$this->getContentBlock() .
					$this->getAfterContent()
				) .
				Html::rawElement( 'div', [ 'id' => 'mw-site-navigation' ],
					$this->getLogo( 'p-logo', 'image' ) .
					$this->getMainNavigation() .
					$this->getSidebarChunk(
						'site-tools',
						'timeless-sitetools',
						$this->getPortlet(
							'tb',
							$this->pileOfTools['general'],
							'timeless-sitetools'
						)
					)
				) .
				Html::rawElement( 'div', [ 'id' => 'mw-related-navigation' ],
					$this->getPageToolSidebar() .
					$this->getInterwikiLinks() .
					$this->getCategories()
				) .
				$this->getClear()
			)
		);

		$html .= Html::rawElement( 'div',
			[ 'id' => 'mw-footer-container', 'class' => 'mw-footer-container ts-container' ],
			$this->getFooterBlock( [ 'class' => [ 'mw-footer', 'ts-inner' ], 'id' => 'mw-footer' ] )
		);

		$html .= Html::closeElement( 'div' );

		// The unholy echo
		echo $html;
	}

	/**
	 * Generate the page content block
	 * Broken out here due to the excessive indenting, or stuff.
	 *
	 * @return string html
	 */
	protected function getContentBlock() {
		$templateData = $this->getSkin()->getTemplateData();
		$html = Html::rawElement(
			'div',
			[ 'id' => 'content', 'class' => 'mw-body', 'role' => 'main' ],
			$this->getSiteNotices() .
			$this->getIndicators() .
			$templateData[ 'html-title-heading' ] .
			Html::rawElement( 'div', [ 'id' => 'bodyContentOuter' ],
				Html::rawElement( 'div', [ 'id' => 'siteSub' ], $this->getMsg( 'tagline' )->parse() ) .
				Html::rawElement( 'div', [ 'id' => 'mw-page-header-links' ],
					$this->getPortlet(
						'namespaces',
						$this->pileOfTools['namespaces'],
						'timeless-namespaces',
						[ 'extra-classes' => 'tools-inline' ]
					) .
					$this->getPortlet(
						'more',
						$this->pileOfTools['more'],
						'timeless-more',
						[ 'extra-classes' => 'tools-inline' ]
					) .
					$this->getVariants() .
					$this->getPortlet(
						'views',
						$this->pileOfTools['page-primary'],
						'timeless-pagetools',
						[ 'extra-classes' => 'tools-inline' ]
					)
				) .
				$this->getClear() .
				Html::rawElement( 'div', [ 'id' => 'bodyContent' ],
					$this->getContentSub() .
					$this->get( 'bodytext' ) .
					$this->getClear()
				)
			)
		);

		return Html::rawElement( 'div', [ 'id' => 'mw-content' ], $html );
	}

	/**
	 * Generates a block of navigation links with a header
	 * This is some random fork of some random fork of what was supposed to be in core. Latest
	 * version copied out of MonoBook, probably. (20190719)
	 *
	 * @param string $name
	 * @param array|string $content array of links for use with makeListItem, or a block of text
	 *        Expected array format:
	 * 	[
	 * 		$name => [
	 * 			'links' => [ '0' =>
	 * 				[
	 * 					'href' => ...,
	 * 					'single-id' => ...,
	 * 					'text' => ...
	 * 				]
	 * 			],
	 * 			'id' => ...,
	 * 			'active' => ...
	 * 		],
	 * 		...
	 * 	]
	 * @param null|string|array|bool $msg
	 * @param array $setOptions miscellaneous overrides, see below
	 *
	 * @return string html
	 * @suppress PhanTypeMismatchArgumentNullable
	 */
	protected function getPortlet( $name, $content, $msg = null, $setOptions = [] ) {
		$skin = $this->getSkin();
		// random stuff to override with any provided options
		$options = array_merge( [
			'role' => 'navigation',
			// extra classes/ids
			'id' => 'p-' . $name,
			'class' => [ 'mw-portlet', 'emptyPortlet' => !$content ],
			'extra-classes' => '',
			'body-id' => null,
			'body-class' => 'mw-portlet-body',
			'body-extra-classes' => '',
			// wrapper for individual list items
			'text-wrapper' => [ 'tag' => 'span' ],
			// option to stick arbitrary stuff at the beginning of the ul
			'list-prepend' => ''
		], $setOptions );

		// Handle the different $msg possibilities
		if ( $msg === null ) {
			$msg = $name;
			$msgParams = [];
		} elseif ( is_array( $msg ) ) {
			$msgString = array_shift( $msg );
			$msgParams = $msg;
			$msg = $msgString;
		} else {
			$msgParams = [];
		}
		$msgObj = $this->getMsg( $msg, $msgParams );
		if ( $msgObj->exists() ) {
			$msgString = $msgObj->parse();
		} else {
			$msgString = htmlspecialchars( $msg );
		}

		$labelId = Sanitizer::escapeIdForAttribute( "p-$name-label" );

		if ( is_array( $content ) ) {
			$contentText = Html::openElement( 'ul',
				[ 'lang' => $this->get( 'userlang' ), 'dir' => $this->get( 'dir' ) ]
			);
			$contentText .= $options['list-prepend'];
			foreach ( $content as $key => $item ) {
				if ( is_array( $options['text-wrapper'] ) ) {
					$contentText .= $skin->makeListItem(
						$key,
						$item,
						[ 'text-wrapper' => $options['text-wrapper'] ]
					);
				} else {
					$contentText .= $skin->makeListItem(
						$key,
						$item
					);
				}
			}
			$contentText .= Html::closeElement( 'ul' );
		} else {
			$contentText = $content;
		}

		$divOptions = [
			'role' => $options['role'],
			'class' => $this->mergeClasses( $options['class'], $options['extra-classes'] ),
			'id' => Sanitizer::escapeIdForAttribute( $options['id'] ),
			'title' => Linker::titleAttrib( $options['id'] ),
			'aria-labelledby' => $labelId
		];
		$labelOptions = [
			'id' => $labelId,
			'lang' => $this->get( 'userlang' ),
			'dir' => $this->get( 'dir' )
		];

		$bodyDivOptions = [
			'class' => $this->mergeClasses( $options['body-class'], $options['body-extra-classes'] )
		];
		if ( is_string( $options['body-id'] ) ) {
			$bodyDivOptions['id'] = $options['body-id'];
		}

		$afterPortlet = '';
		$content = $this->getSkin()->getAfterPortlet( $name );
		if ( $content !== '' ) {
			$afterPortlet = Html::rawElement(
				'div',
				[ 'class' => [ 'after-portlet', 'after-portlet-' . $name ] ],
				$content
			);
		}

		if ( $name === 'lang' ) {
			$this->afterLangPortlet = $afterPortlet;
		}

		$html = Html::rawElement( 'div', $divOptions,
			Html::rawElement( 'h3', $labelOptions, $msgString ) .
			Html::rawElement( 'div', $bodyDivOptions,
				$contentText .
				$afterPortlet
			)
		);

		return $html;
	}

	/**
	 * Helper function for getPortlet
	 *
	 * Merge all provided css classes into a single array
	 * Account for possible different input methods matching what Html::element stuff takes
	 *
	 * @param string|array $class base portlet/body class
	 * @param string|array $extraClasses any extra classes to also include
	 *
	 * @return array all classes to apply
	 */
	protected function mergeClasses( $class, $extraClasses ) {
		if ( !is_array( $class ) ) {
			$class = [ $class ];
		}
		if ( !is_array( $extraClasses ) ) {
			$extraClasses = [ $extraClasses ];
		}

		return array_merge( $class, $extraClasses );
	}

	/**
	 * Better renderer for getFooterIcons and getFooterLinks, based on Vector's HTML output
	 * (as of 2016)
	 *
	 * @param array $setOptions Miscellaneous other options
	 * * 'id' for footer id
	 * * 'class' for footer class
	 * * 'order' to determine whether icons or links appear first: 'iconsfirst' or links, though in
	 *   practice we currently only check if it is or isn't 'iconsfirst'
	 * * 'link-prefix' to set the prefix for all link and block ids; most skins use 'f' or 'footer',
	 *   as in id='f-whatever' vs id='footer-whatever'
	 * * 'link-style' to pass to getFooterLinks: "flat" to disable categorisation of links in a
	 *   nested array
	 *
	 * @return string html
	 */
	protected function getFooterBlock( $setOptions = [] ) {
		// Set options and fill in defaults
		$options = $setOptions + [
			'id' => 'footer',
			'class' => 'mw-footer',
			'order' => 'iconsfirst',
			'link-prefix' => 'footer',
			'link-style' => null
		];

		// phpcs:ignore Generic.Files.LineLength.TooLong
		'@phan-var array{id:string,class:string,order:string,link-prefix:string,link-style:?string} $options';
		$validFooterIcons = $this->get( 'footericons' );
		$validFooterLinks = $this->getFooterLinks( $options['link-style'] );

		$html = '';

		$html .= Html::openElement( 'div', [
			'id' => $options['id'],
			'class' => $options['class'],
			'role' => 'contentinfo',
			'lang' => $this->get( 'userlang' ),
			'dir' => $this->get( 'dir' )
		] );

		$iconsHTML = '';
		if ( count( $validFooterIcons ) > 0 ) {
			$iconsHTML .= Html::openElement( 'ul', [ 'id' => "{$options['link-prefix']}-icons" ] );
			foreach ( $validFooterIcons as $blockName => $footerIcons ) {
				$iconsHTML .= Html::openElement( 'li', [
					'id' => Sanitizer::escapeIdForAttribute(
						"{$options['link-prefix']}-{$blockName}ico"
					),
					'class' => 'footer-icons'
				] );
				foreach ( $footerIcons as $icon ) {
					$iconsHTML .= $this->getSkin()->makeFooterIcon( $icon );
				}
				$iconsHTML .= Html::closeElement( 'li' );
			}
			$iconsHTML .= Html::closeElement( 'ul' );
		}

		$linksHTML = '';
		if ( count( $validFooterLinks ) > 0 ) {
			if ( $options['link-style'] === 'flat' ) {
				$linksHTML .= Html::openElement( 'ul', [
					'id' => "{$options['link-prefix']}-list",
					'class' => 'footer-places'
				] );
				foreach ( $validFooterLinks as $link ) {
					$linksHTML .= Html::rawElement(
						'li',
						[ 'id' => Sanitizer::escapeIdForAttribute( $link ) ],
						$this->get( $link )
					);
				}
				$linksHTML .= Html::closeElement( 'ul' );
			} else {
				$linksHTML .= Html::openElement( 'div', [ 'id' => "{$options['link-prefix']}-list" ] );
				foreach ( $validFooterLinks as $category => $links ) {
					$linksHTML .= Html::openElement( 'ul',
						[ 'id' => Sanitizer::escapeIdForAttribute(
							"{$options['link-prefix']}-{$category}"
						) ]
					);
					foreach ( $links as $link ) {
						$linksHTML .= Html::rawElement(
							'li',
							[ 'id' => Sanitizer::escapeIdForAttribute(
								"{$options['link-prefix']}-{$category}-{$link}"
							) ],
							$this->get( $link )
						);
					}
					$linksHTML .= Html::closeElement( 'ul' );
				}
				$linksHTML .= Html::closeElement( 'div' );
			}
		}

		if ( $options['order'] === 'iconsfirst' ) {
			$html .= $iconsHTML . $linksHTML;
		} else {
			$html .= $linksHTML . $iconsHTML;
		}

		$html .= $this->getClear() . Html::closeElement( 'div' );

		return $html;
	}

	/**
	 * Sidebar chunk containing one or more portlets
	 *
	 * @param string $id
	 * @param string $headerMessage
	 * @param string $content
	 * @param array $classes
	 *
	 * @return string html
	 */
	protected function getSidebarChunk( $id, $headerMessage, $content, $classes = [] ) {
		$html = '';

		$html .= Html::rawElement(
			'div',
			[
				'id' => Sanitizer::escapeIdForAttribute( $id ),
				'class' => array_merge( [ 'sidebar-chunk' ], $classes )
			],
			Html::rawElement( 'h2', [],
				Html::element( 'span', [],
					$this->getMsg( $headerMessage )->text()
				)
			) .
			Html::rawElement( 'div', [ 'class' => 'sidebar-inner' ], $content )
		);

		return $html;
	}

	/**
	 * The logo and (optionally) site title
	 *
	 * @param string $id
	 * @param string $part whether it's only image, only text, or both
	 *
	 * @return string html
	 */
	protected function getLogo( $id = 'p-logo', $part = 'both' ) {
		$html = '';
		$config = $this->getSkin()->getContext()->getConfig();

		$html .= Html::openElement(
			'div',
			[
				'id' => Sanitizer::escapeIdForAttribute( $id ),
				'class' => 'mw-portlet',
				'role' => 'banner'
			]
		);
		$logos = SkinModule::getAvailableLogos( $config, $this->getSkin()->getLanguage()->getCode() );
		if ( $part !== 'image' ) {
			$wordmarkImage = $this->getLogoImage( $config->get( 'TimelessWordmark' ), true );
			if ( !$wordmarkImage && isset( $logos['wordmark'] ) ) {
				$wordmarkData = $logos['wordmark'];
				$wordmarkImage = Html::element( 'img', [
					'src' => $wordmarkData['src'],
					'height' => $wordmarkData['height'] ?? null,
					'width' => $wordmarkData['width'] ?? null,
				] );
			}

			$titleClass = '';
			$siteTitle = '';
			if ( !$wordmarkImage ) {
				$langConv = MediaWikiServices::getInstance()->getLanguageConverterFactory()
					->getLanguageConverter( $this->getSkin()->getLanguage() );
				if ( $langConv->hasVariants() ) {
					$siteTitle = $langConv->convert( $this->getMsg( 'timeless-sitetitle' )->escaped() );
				} else {
					$siteTitle = $this->getMsg( 'timeless-sitetitle' )->escaped();
				}
				// width is 11em; 13 characters will probably fit?
				if ( mb_strlen( $siteTitle ) > 13 ) {
					$titleClass = 'long';
				}
			} else {
				$titleClass = 'wordmark';
			}
			$html .= Html::rawElement( 'a', [
					'id' => 'p-banner',
					'class' => [ 'mw-wiki-title', $titleClass ],
					'href' => $this->data['nav_urls']['mainpage']['href']
				],
				$wordmarkImage ?: $siteTitle
			);

		}
		if ( $part !== 'text' ) {
			$logoImage = $this->getLogoImage( $config->get( 'TimelessLogo' ) );
			if ( $logoImage === false ) {
				$logoSrc = $logos['svg'] ?? $logos['icon'] ?? '';
				if ( $logoSrc !== '' ) {
					$logoImage = Html::element( 'img', [
						'src' => $logoSrc,
					] );
				}
			}

			$html .= Html::rawElement(
				'a',
				array_merge(
					[
						'class' => [ 'mw-wiki-logo', !$logoImage ? 'fallback' : 'timeless-logo' ],
						'href' => $this->data['nav_urls']['mainpage']['href']
					],
					Linker::tooltipAndAccesskeyAttribs( 'p-logo' )
				),
				$logoImage ?: ''
			);
		}
		$html .= Html::closeElement( 'div' );

		return $html;
	}

	/**
	 * The search box at the top
	 *
	 * @return string html
	 */
	protected function getSearch() {
		$skin = $this->getSkin();
		$html = Html::openElement( 'div', [ 'class' => 'mw-portlet', 'id' => 'p-search' ] );

		$html .= Html::rawElement(
			'h3',
			[ 'lang' => $this->get( 'userlang' ), 'dir' => $this->get( 'dir' ) ],
			Html::rawElement( 'label', [ 'for' => 'searchInput' ], $this->getMsg( 'search' )->escaped() )
		);

		$html .= Html::rawElement( 'form', [ 'action' => $this->get( 'wgScript' ), 'id' => 'searchform' ],
			Html::rawElement( 'div', [ 'id' => 'simpleSearch' ],
				Html::rawElement( 'div', [ 'id' => 'searchInput-container' ],
					$skin->makeSearchInput( [
						'id' => 'searchInput'
					] )
				) .
				Html::hidden( 'title', $this->get( 'searchtitle' ) ) .
				$skin->makeSearchButton(
					'fulltext',
					[ 'id' => 'mw-searchButton', 'class' => 'searchButton mw-fallbackSearchButton' ]
				) .
				$skin->makeSearchButton(
					'go',
					[ 'id' => 'searchButton', 'class' => 'searchButton' ]
				)
			)
		);

		return $html . Html::closeElement( 'div' );
	}

	/**
	 * Left sidebar navigation, usually
	 *
	 * @return string html
	 */
	protected function getMainNavigation() {
		$html = '';

		// Already hardcoded into header
		$this->sidebar['SEARCH'] = false;
		// Parsed as part of pageTools
		$this->sidebar['TOOLBOX'] = false;
		// Forcibly removed to separate chunk
		$this->sidebar['LANGUAGES'] = false;
		foreach ( $this->sidebar as $name => $content ) {
			if ( $content === false ) {
				continue;
			}
			// Numeric strings gets an integer when set as key, cast back - T73639
			$name = (string)$name;
			$html .= $this->getPortlet( $name, $content );
		}

		return $this->getSidebarChunk( 'site-navigation', 'navigation', $html );
	}

	/**
	 * The colour bars
	 * Split this out so we don't have to look at it/can easily kill it later
	 *
	 * @return string html
	 */
	protected function getHeaderHack() {
		$html = '';

		// These are almost exactly the same and this is stupid.
		$html .= Html::rawElement( 'div', [ 'id' => 'mw-header-hack', 'class' => 'color-bar' ],
			Html::rawElement( 'div', [ 'class' => 'color-middle-container' ],
				Html::element( 'div', [ 'class' => 'color-middle' ] )
			) .
			Html::element( 'div', [ 'class' => 'color-left' ] ) .
			Html::element( 'div', [ 'class' => 'color-right' ] )
		);
		$html .= Html::rawElement( 'div', [ 'id' => 'mw-header-nav-hack' ],
			Html::rawElement( 'div', [ 'class' => 'color-bar' ],
				Html::rawElement( 'div', [ 'class' => 'color-middle-container' ],
					Html::element( 'div', [ 'class' => 'color-middle' ] )
				) .
				Html::element( 'div', [ 'class' => 'color-left' ] ) .
				Html::element( 'div', [ 'class' => 'color-right' ] )
			)
		);

		return $html;
	}

	/**
	 * Page tools in sidebar
	 *
	 * @return string html
	 */
	protected function getPageToolSidebar() {
		$pageTools = $this->getPortlet(
			'cactions',
			$this->pileOfTools['page-secondary'],
			'timeless-pageactions'
		);
		$pageTools .= $this->getPortlet(
			'userpagetools',
			$this->pileOfTools['user'],
			'timeless-userpagetools'
		);
		$pageTools .= $this->getPortlet(
			'pagemisc',
			$this->pileOfTools['page-tertiary'],
			'timeless-pagemisc'
		);
		if ( isset( $this->collectionPortlet ) ) {
			$pageTools .= $this->getPortlet(
				'coll-print_export',
				$this->collectionPortlet
			);
		}

		return $this->getSidebarChunk( 'page-tools', 'timeless-pageactions', $pageTools );
	}

	/**
	 * Personal/user links portlet for header
	 *
	 * @return array [ html, class ], where class is an extra class to apply to surrounding objects
	 * (for width adjustments)
	 */
	protected function getUserLinks() {
		$skin = $this->getSkin();
		$user = $skin->getUser();
		$personalTools = $skin->getPersonalToolsForMakeListItem( $this->get( 'personal_urls' ) );
		// Preserve standard username label to allow customisation (T215822)
		$userName = $personalTools['userpage']['links'][0]['text'] ?? $user->getName();

		$extraTools = [];

		// Remove anon placeholder
		if ( isset( $personalTools['anonuserpage'] ) ) {
			unset( $personalTools['anonuserpage'] );
		}
		// Remove temp user placeholder, as we display the user name in the dropdown header instead.
		// Removing the use of .mw-userpage-tmp class also prevents the anchored popup from appearing,
		// which is good, because there's no reasonable place to put it.
		if (
			isset( $personalTools['userpage'] ) &&
			in_array( 'mw-userpage-tmp', $personalTools['userpage']['links'][0]['class'] ?? [] )
		) {
			unset( $personalTools['userpage'] );
		}

		// Remove Echo badges
		if ( isset( $personalTools['notifications-alert'] ) ) {
			$extraTools['notifications-alert'] = $personalTools['notifications-alert'];
			unset( $personalTools['notifications-alert'] );
		}
		if ( isset( $personalTools['notifications-notice'] ) ) {
			$extraTools['notifications-notice'] = $personalTools['notifications-notice'];
			unset( $personalTools['notifications-notice'] );
		}
		$class = $extraTools === [] ? '' : 'extension-icons';

		// Re-label some messages
		if ( isset( $personalTools['userpage'] ) ) {
			$personalTools['userpage']['links'][0]['text'] = $this->getMsg( 'timeless-userpage' )->text();
		}
		if ( isset( $personalTools['mytalk'] ) ) {
			$personalTools['mytalk']['links'][0]['text'] = $this->getMsg( 'timeless-talkpage' )->text();
		}

		// Labels
		if ( $user->isNamed() ) {
			$dropdownHeader = $userName;
			$headerMsg = [ 'timeless-loggedinas', $userName ];
		} elseif ( $user->isTemp() ) {
			$dropdownHeader = $user->getName();
			$headerMsg = 'timeless-notloggedin';
		} else {
			$dropdownHeader = $this->getMsg( 'timeless-anonymous' )->text();
			$headerMsg = 'timeless-notloggedin';
		}
		$html = Html::openElement( 'div', [ 'id' => 'user-tools' ] );

		$html .= Html::rawElement( 'div', [ 'id' => 'personal' ],
			Html::rawElement( 'h2', [],
				Html::element( 'span', [], $dropdownHeader )
			) .
			Html::rawElement( 'div', [ 'id' => 'personal-inner', 'class' => 'dropdown' ],
				$this->getPortlet( 'personal', $personalTools, $headerMsg )
			)
		);

		// Extra icon stuff (echo etc)
		if ( $extraTools !== [] ) {
			$iconList = '';
			foreach ( $extraTools as $key => $item ) {
				$iconList .= $skin->makeListItem( $key, $item );
			}

			$html .= Html::rawElement(
				'div',
				[ 'id' => 'personal-extra', 'class' => 'p-body' ],
				Html::rawElement( 'ul', [], $iconList )
			);
		}

		$html .= Html::closeElement( 'div' );

		return [
			'html' => $html,
			'class' => $class
		];
	}

	/**
	 * Notices that may appear above the firstHeading
	 *
	 * @return string html
	 */
	protected function getSiteNotices() {
		$html = '';

		if ( $this->data['sitenotice'] ) {
			$html .= Html::rawElement( 'div', [ 'id' => 'siteNotice' ], $this->get( 'sitenotice' ) );
		}
		if ( $this->data['newtalk'] ) {
			$html .= Html::rawElement( 'div', [ 'class' => 'usermessage' ], $this->get( 'newtalk' ) );
		}

		return $html;
	}

	/**
	 * Links and information that may appear below the firstHeading
	 *
	 * @return string html
	 */
	protected function getContentSub() {
		$html = Html::openElement( 'div', [ 'id' => 'contentSub' ] );
		if ( $this->data['subtitle'] ) {
			$html .= $this->get( 'subtitle' );
		}
		if ( $this->data['undelete'] ) {
			$html .= $this->get( 'undelete' );
		}
		return $html . Html::closeElement( 'div' );
	}

	/**
	 * The data after content, catlinks, and potential other stuff that may appear within
	 * the content block but after the main content
	 *
	 * @return string html
	 */
	protected function getAfterContent() {
		$html = '';

		if ( $this->data['catlinks'] || $this->data['dataAfterContent'] ) {
			$html .= Html::openElement( 'div', [ 'id' => 'content-bottom-stuff' ] );
			if ( $this->data['catlinks'] ) {
				$html .= $this->get( 'catlinks' );
			}
			if ( $this->data['dataAfterContent'] ) {
				$html .= $this->get( 'dataAfterContent' );
			}
			$html .= Html::closeElement( 'div' );
		}

		return $html;
	}

	/**
	 * Generate pile of all the tools
	 *
	 * We can make a few assumptions based on where a tool started out:
	 *     If it's in the cactions region, it's a page tool, probably primary or secondary
	 *     ...that's all I can think of
	 *
	 * @return array of array of tools information (portlet formatting)
	 */
	protected function getPageTools() {
		$title = $this->getSkin()->getTitle();
		$namespace = $title->getNamespace();

		$sortedPileOfTools = [
			'namespaces' => [],
			'page-primary' => [],
			'page-secondary' => [],
			'user' => [],
			'page-tertiary' => [],
			'more' => [],
			'general' => []
		];

		// Tools specific to the page
		$pileOfEditTools = [];
		$contentNavigation = $this->data['content_navigation'];

		foreach ( $contentNavigation as $navKey => $navBlock ) {
			// Just use namespaces items as they are
			if ( $navKey == 'namespaces' ) {
				if ( $namespace < 0 && count( $navBlock ) < 2 ) {
					// Put special page ns_pages in the more pile so they're not so lonely
					$sortedPileOfTools['page-tertiary'] = $navBlock;
				} else {
					$sortedPileOfTools['namespaces'] = $navBlock;
				}
			} elseif ( $navKey == 'variants' ) {
				// wat
				$sortedPileOfTools['variants'] = $navBlock;
			} else {
				$pileOfEditTools = array_merge( $pileOfEditTools, $navBlock );
			}
		}

		// Tools that may be general or page-related (typically the toolbox)
		$pileOfTools = $this->sidebar['TOOLBOX'];
		if ( $namespace >= 0 ) {
			$pileOfTools['pagelog'] = [
				'text' => $this->getMsg( 'timeless-pagelog' )->text(),
				'href' => SpecialPage::getTitleFor( 'Log' )->getLocalURL(
					[ 'page' => $title->getPrefixedText() ]
				),
				'id' => 't-pagelog'
			];
		}

		// Mobile toggles
		$pileOfTools['more'] = [
			'text' => $this->getMsg( 'timeless-more' )->text(),
			'id' => 'ca-more',
			'class' => 'dropdown-toggle'
		];
		// @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
		if ( !empty( $this->sidebar['LANGUAGES'] ) || $sortedPileOfTools['variants']
			|| isset( $this->otherProjects ) ) {
			$pileOfTools['languages'] = [
				'text' => $this->getMsg( 'timeless-languages' )->escaped(),
				'id' => 'ca-languages',
				'class' => 'dropdown-toggle'
			];
		}

		// This is really dumb, and you're an idiot for doing it this way.
		// Obviously if you're not the idiot who did this, I don't mean you.
		foreach ( $pileOfEditTools as $navKey => $navBlock ) {
			if ( in_array( $navKey, [
				'watch',
				'unwatch'
			] ) ) {
				$currentSet = 'namespaces';
			} elseif ( in_array( $navKey, [
				'edit',
				'view',
				'history',
				'addsection',
				'viewsource'
			] ) ) {
				$currentSet = 'page-primary';
			} elseif ( in_array( $navKey, [
				'delete',
				'rename',
				'protect',
				'unprotect',
				'move'
			] ) ) {
				$currentSet = 'page-secondary';
			} else {
				// Catch random extension ones?
				$currentSet = 'page-primary';
			}
			$sortedPileOfTools[$currentSet][$navKey] = $navBlock;
		}
		foreach ( $pileOfTools as $navKey => $navBlock ) {
			$currentSet = null;

			if ( $navKey === 'contributions' ) {
				$currentSet = 'page-primary';
			} elseif ( in_array( $navKey, [
				'blockip',
				'userrights',
				'log',
				'emailuser'

			] ) ) {
				$currentSet = 'user';
			} elseif ( in_array( $navKey, [
				'whatlinkshere',
				'print',
				'info',
				'pagelog',
				'recentchangeslinked',
				'permalink',
				'wikibase',
				'cite'
			] ) ) {
				$currentSet = 'page-tertiary';
			} elseif ( in_array( $navKey, [
				'more',
				'languages'
			] ) ) {
				$currentSet = 'more';
			} else {
				$currentSet = 'general';
			}
			$sortedPileOfTools[$currentSet][$navKey] = $navBlock;
		}

		// Extra sorting for Extension:ProofreadPage namespace items
		$tabs = [
			// This is the order we want them in...
			'proofreadPageScanLink',
			'proofreadPageIndexLink',
			'proofreadPageNextLink',
		];
		foreach ( $tabs as $tab ) {
			if ( isset( $sortedPileOfTools['namespaces'][$tab] ) ) {
				$toMove = $sortedPileOfTools['namespaces'][$tab];
				unset( $sortedPileOfTools['namespaces'][$tab] );

				// move to end!
				$sortedPileOfTools['namespaces'][$tab] = $toMove;
			}
		}

		return $sortedPileOfTools;
	}

	/**
	 * Categories for the sidebar
	 *
	 * Assemble an array of categories. This doesn't show any categories for the
	 * action=history view, but that behaviour is consistent with other skins.
	 *
	 * @return string html
	 */
	protected function getCategories() {
		$skin = $this->getSkin();
		$catHeader = 'categories';
		$catList = '';

		$allCats = $skin->getOutput()->getCategoryLinks();
		if ( $allCats !== [] ) {
			if ( isset( $allCats['normal'] ) && $allCats['normal'] !== [] ) {
				$catList .= $this->getCatList(
					$allCats['normal'],
					'normal-catlinks',
					'mw-normal-catlinks',
					'categories'
				);
			} else {
				$catHeader = 'hidden-categories';
			}

			if ( isset( $allCats['hidden'] ) ) {
				$hiddenCatClass = [ 'mw-hidden-catlinks' ];
				if ( MediaWikiServices::getInstance()
					->getUserOptionsLookup()
					->getBoolOption( $skin->getUser(), 'showhiddencats' )
				) {
					$hiddenCatClass[] = 'mw-hidden-cats-user-shown';
				} elseif ( $skin->getTitle()->getNamespace() === NS_CATEGORY ) {
					$hiddenCatClass[] = 'mw-hidden-cats-ns-shown';
				} else {
					$hiddenCatClass[] = 'mw-hidden-cats-hidden';
				}
				$catList .= $this->getCatList(
					$allCats['hidden'],
					'hidden-catlinks',
					$hiddenCatClass,
					[ 'hidden-categories', count( $allCats['hidden'] ) ]
				);
			}
		}

		if ( $catList !== '' ) {
			return $this->getSidebarChunk( 'catlinks-sidebar', $catHeader, $catList );
		}

		return '';
	}

	/**
	 * List of categories
	 *
	 * @param array $list
	 * @param string $id
	 * @param string|array $class
	 * @param string|array $message i18n message name or an array of [ message name, params ]
	 *
	 * @return string html
	 */
	protected function getCatList( $list, $id, $class, $message ) {
		$html = Html::openElement( 'div', [ 'id' => "sidebar-{$id}", 'class' => $class ] );

		$makeLinkItem = static function ( $linkHtml ) {
			return Html::rawElement( 'li', [], $linkHtml );
		};

		$categoryItems = array_map( $makeLinkItem, $list );

		$categoriesHtml = Html::rawElement( 'ul',
			[],
			implode( '', $categoryItems )
		);

		$html .= $this->getPortlet( $id, $categoriesHtml, $message );

		return $html . Html::closeElement( 'div' );
	}

	/**
	 * Interlanguage links block, with variants if applicable
	 * Layout sort of assumes we're using ULS compact language handling
	 * if there's a lot of languages.
	 *
	 * @return string html
	 */
	protected function getVariants() {
		$html = '';

		if ( $this->pileOfTools['variants'] ) {
			$html .= $this->getPortlet(
				'variants-desktop',
				$this->pileOfTools['variants'],
				'variants',
				[ 'body-extra-classes' => 'dropdown' ]
			);
		}

		return $html;
	}

	/**
	 * Interwiki links block
	 *
	 * @return string html
	 */
	protected function getInterwikiLinks() {
		$html = '';
		$variants = '';
		$otherprojects = '';
		$show = false;
		$variantsOnly = false;

		if ( $this->pileOfTools['variants'] ) {
			$variants = $this->getPortlet(
				'variants',
				$this->pileOfTools['variants']
			);
			$show = true;
			$variantsOnly = true;
		}

		$languages = $this->getPortlet( 'lang', $this->languages, 'otherlanguages' );

		// Force rendering of this section if there are languages or when the 'lang'
		// portlet has been modified by hook even if there are no language items.
		if ( count( $this->languages ) || $this->afterLangPortlet !== '' ) {
			$show = true;
			$variantsOnly = false;
		} else {
			$languages = '';
		}

		// if using wikibase for 'in other projects'
		if ( isset( $this->otherProjects ) ) {
			$otherprojects = $this->getPortlet(
				'wikibase-otherprojects',
				$this->otherProjects
			);
			$show = true;
			$variantsOnly = false;
		}

		if ( $show ) {
			$html .= $this->getSidebarChunk(
				'other-languages',
				'timeless-projects',
				$variants . $languages . $otherprojects,
				$variantsOnly ? [ 'variants-only' ] : []
			);
		}

		return $html;
	}

	/**
	 * Generate img-based logos for proper HiDPI support
	 *
	 * @param string|array|null $logo
	 * @param bool $doLarge Render extra-large HiDPI logos for mobile devices?
	 *
	 * @return string|false html|we're not doing this
	 */
	protected function getLogoImage( $logo, $doLarge = false ) {
		if ( $logo === null ) {
			// not set, fall back to generic methods
			return false;
		}

		// Generate $logoData from a file upload
		if ( is_string( $logo ) ) {
			$file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $logo );

			if ( !$file || !$file->canRender() ) {
				// eeeeeh bail, scary
				return false;
			}
			$logoData = [];

			// Calculate intended sizes
			$width = $file->getWidth();
			$height = $file->getHeight();
			$bound = $width > $height ? $width : $height;
			$svg = File::normalizeExtension( $file->getExtension() ) === 'svg';

			// Mobile stuff is generally a lot more than just 2ppp. Let's go with 4x?
			// Currently we're just doing this for wordmarks, which shouldn't get that
			// big in practice, so this is probably safe enough. And no need to use
			// this for desktop logos, so fall back to 2x for 2x as default...
			$large = $doLarge ? 4 : 2;

			if ( $bound <= 165 ) {
				// It's a 1x image
				$logoData['width'] = $width;
				$logoData['height'] = $height;

				if ( $svg ) {
					$logoData['1x'] = $file->createThumb( $logoData['width'] );
					$logoData['1.5x'] = $file->createThumb( (int)( $logoData['width'] * 1.5 ) );
					$logoData['2x'] = $file->createThumb( $logoData['width'] * $large );
				} elseif ( $file->mustRender() ) {
					$logoData['1x'] = $file->createThumb( $logoData['width'] );
				} else {
					$logoData['1x'] = $file->getUrl();
				}

			} elseif ( $bound >= 230 && $bound <= 330 ) {
				// It's a 2x image
				$logoData['width'] = (int)( $width / 2 );
				$logoData['height'] = (int)( $height / 2 );

				$logoData['1x'] = $file->createThumb( $logoData['width'] );
				$logoData['1.5x'] = $file->createThumb( (int)( $logoData['width'] * 1.5 ) );

				if ( $svg || $file->mustRender() ) {
					$logoData['2x'] = $file->createThumb( $logoData['width'] * 2 );
				} else {
					$logoData['2x'] = $file->getUrl();
				}
			} else {
				// Okay, whatever, we get to pick something random
				// Yes I am aware this means they might have arbitrarily tall logos,
				// and you know what, let 'em, I don't care
				$logoData['width'] = 155;
				$logoData['height'] = File::scaleHeight( $width, $height, $logoData['width'] );

				$logoData['1x'] = $file->createThumb( $logoData['width'] );
				if ( $svg || $logoData['width'] * 1.5 <= $width ) {
					$logoData['1.5x'] = $file->createThumb( (int)( $logoData['width'] * 1.5 ) );
				}
				if ( $svg || $logoData['width'] * 2 <= $width ) {
					$logoData['2x'] = $file->createThumb( $logoData['width'] * $large );
				}
			}
		} elseif ( is_array( $logo ) ) {
			// manually set logo data for non-file-uploads
			$logoData = $logo;
		} else {
			// nope
			return false;
		}

		// Render the html output!
		$attribs = [
			'alt' => $this->getMsg( 'sitetitle' )->text(),
			// Should we care? It's just a logo...
			'decoding' => 'auto',
			'width' => $logoData['width'],
			'height' => $logoData['height'],
		];

		if ( !isset( $logoData['1x'] ) && isset( $logoData['2x'] ) ) {
			// We'll allow it...
			$attribs['src'] = $logoData['2x'];
		} else {
			// Okay, we really do want a 1x otherwise. If this throws an error or
			// something because there's nothing here, GOOD.
			// @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
			$attribs['src'] = $logoData['1x'];

			// Throw the rest in a srcset
			unset( $logoData['1x'], $logoData['width'], $logoData['height'] );
			$srcset = '';
			foreach ( $logoData as $res => $path ) {
				if ( $srcset != '' ) {
					$srcset .= ', ';
				}
				$srcset .= $path . ' ' . $res;
			}

			if ( $srcset !== '' ) {
				$attribs['srcset'] = $srcset;
			}
		}

		return Html::element( 'img', $attribs );
	}
}
PK       ! 77      SkinTimeless.phpnu Iw        <?php

namespace MediaWiki\Skin\Timeless;

use SkinTemplate;

/**
 * SkinTemplate class for the Timeless skin
 *
 * @ingroup Skins
 */
class SkinTimeless extends SkinTemplate {
	/**
	 * @inheritDoc
	 */
	public function __construct(
		array $options = []
	) {
		$out = $this->getOutput();

		// Basic IE support without flexbox
		$out->addStyle( 'Timeless/resources/IE9fixes.css', 'screen', 'IE' );

		parent::__construct( $options );
	}
}
PK       ! u`      BenchmarkerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Maintenance\Includes;

use MediaWiki\Maintenance\Benchmarker;
use MediaWikiCoversValidator;
use PHPUnit\Framework\TestCase;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Maintenance\Benchmarker
 */
class BenchmarkerTest extends TestCase {

	use MediaWikiCoversValidator;

	public function testBenchSimple() {
		$bench = $this->getMockBuilder( Benchmarker::class )
			->onlyMethods( [ 'execute', 'output' ] )
			->getMock();
		$benchProxy = TestingAccessWrapper::newFromObject( $bench );
		$benchProxy->defaultCount = 3;

		$count = 0;
		$bench->bench( [
			'test' => static function () use ( &$count ) {
				$count++;
			}
		] );

		$this->assertSame( 3, $count );
	}

	public function testBenchSetup() {
		$bench = $this->getMockBuilder( Benchmarker::class )
			->onlyMethods( [ 'execute', 'output' ] )
			->getMock();
		$benchProxy = TestingAccessWrapper::newFromObject( $bench );
		$benchProxy->defaultCount = 2;

		$buffer = [];
		$bench->bench( [
			'test' => [
				'setup' => static function () use ( &$buffer ) {
					$buffer[] = 'setup';
				},
				'function' => static function () use ( &$buffer ) {
					$buffer[] = 'run';
				}
			]
		] );

		$this->assertSame( [ 'setup', 'run', 'run' ], $buffer );
	}

	public function testBenchVerbose() {
		$bench = $this->getMockBuilder( Benchmarker::class )
			->onlyMethods( [ 'execute', 'output', 'hasOption', 'verboseRun' ] )
			->getMock();
		$benchProxy = TestingAccessWrapper::newFromObject( $bench );
		$benchProxy->defaultCount = 1;

		$bench->expects( $this->once() )->method( 'hasOption' )
			->willReturnMap( [
				[ 'verbose', true ],
			] );

		$bench->expects( $this->once() )->method( 'verboseRun' )
			->with( 0 )
			->willReturn( null );

		$bench->bench( [
			'test' => static function () {
			}
		] );
	}

	public function noop() {
	}

	public function testBenchName_method() {
		$bench = $this->getMockBuilder( Benchmarker::class )
			->onlyMethods( [ 'execute', 'output', 'addResult' ] )
			->getMock();
		$benchProxy = TestingAccessWrapper::newFromObject( $bench );
		$benchProxy->defaultCount = 1;

		$bench->expects( $this->once() )->method( 'addResult' )
			->with( $this->callback( static function ( $res ) {
				return isset( $res['name'] ) && $res['name'] === ( __CLASS__ . '::noop()' );
			} ) );

		$bench->bench( [
			[ 'function' => [ $this, 'noop' ] ]
		] );
	}

	public function testBenchName_string() {
		$bench = $this->getMockBuilder( Benchmarker::class )
			->onlyMethods( [ 'execute', 'output', 'addResult' ] )
			->getMock();
		$benchProxy = TestingAccessWrapper::newFromObject( $bench );
		$benchProxy->defaultCount = 1;

		$bench->expects( $this->once() )->method( 'addResult' )
			->with( $this->callback( static function ( $res ) {
				return $res['name'] === "strtolower('A')";
			} ) );

		$bench->bench( [ [
			'function' => 'strtolower',
			'args' => [ 'A' ],
		] ] );
	}

	public function testVerboseRun() {
		$bench = $this->getMockBuilder( Benchmarker::class )
			->onlyMethods( [ 'execute', 'output', 'hasOption', 'startBench', 'addResult' ] )
			->getMock();
		$benchProxy = TestingAccessWrapper::newFromObject( $bench );
		$benchProxy->defaultCount = 1;

		$bench->expects( $this->once() )->method( 'hasOption' )
			->willReturnMap( [
				[ 'verbose', true ],
			] );

		$bench->expects( $this->once() )->method( 'output' )
			->with( $this->callback( static function ( $out ) {
				return preg_match( '/memory.+ peak/', $out ) === 1;
			} ) );

		$bench->bench( [
			'test' => static function () {
			}
		] );
	}

	public function testNaming() {
		$bench = $this->getMockBuilder( Benchmarker::class )
			->onlyMethods( [ 'execute', 'output', 'startBench' ] )
			->getMock();
		$benchProxy = TestingAccessWrapper::newFromObject( $bench );
		$benchProxy->defaultCount = 1;

		$out = '';
		$bench->expects( $this->any() )->method( 'output' )
			->willReturnCallback( static function ( $str ) use ( &$out ) {
				$out .= $str;
				return null;
			} );

		$bench->bench( [
			[
				'function' => 'in_array',
				'args' => [ 'A', [ 'X', 'Y' ] ],
			],
			[
				'function' => 'in_array',
				'args' => [ 'A', [ 'X', 'Y', str_repeat( 'z', 900 ) ] ],
			],
			[
				'function' => 'strtolower',
				'args' => [ 'A' ],
			],
			[
				'function' => 'strtolower',
				'args' => [ str_repeat( 'x', 900 ) ],
			],
			[
				'function' => 'in_array',
				'args' => [ str_repeat( 'y', 900 ), [] ],
			],
			[
				'function' => 'in_array',
				'args' => [ str_repeat( 'z', 900 ), [] ],
			],
		] );

		$out = preg_replace( '/^.*(: |memory).*\n/m', '', $out );
		$out = trim( str_replace( "\n\n", "\n", $out ) );
		$this->assertEquals(
			<<<TEXT
			in_array@1
			in_array@2
			strtolower('A')
			strtolower@2
			in_array@3
			in_array@4
			TEXT,
			$out
		);
	}
}
PK       ! ٓ      LoggedUpdateMaintenanceTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Maintenance\Includes;

use MediaWiki\Maintenance\LoggedUpdateMaintenance;
use MediaWiki\Tests\Maintenance\MaintenanceBaseTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Maintenance\LoggedUpdateMaintenance
 * @author Dreamy Jazz
 * @group Database
 */
class LoggedUpdateMaintenanceTest extends MaintenanceBaseTestCase {

	/** @var LoggedUpdateMaintenance|MockObject */
	protected $maintenance;

	protected function getMaintenanceClass() {
		return LoggedUpdateMaintenance::class;
	}

	protected function createMaintenance() {
		$obj = $this->getMockBuilder( $this->getMaintenanceClass() )
			->onlyMethods( [ 'getUpdateKey', 'doDBUpdates' ] )
			->getMockForAbstractClass();

		// We use TestingAccessWrapper in order to access protected internals
		// such as `output()`.
		return TestingAccessWrapper::newFromObject( $obj );
	}

	/** @dataProvider provideForcedValues */
	public function testSetForce( $value ) {
		$this->maintenance->setForce( $value );
		$this->assertSame( $value, $this->maintenance->getParameters()->getOption( 'force' ) );
	}

	public static function provideForcedValues() {
		return [
			'--force set' => [ true ],
			'--force not set' => [ null ],
		];
	}

	/** @dataProvider provideExecute */
	public function testExecute(
		$doDBUpdatesReturnValue, $markedAsCompleteBeforeRun, $shouldBeMarkedAsCompleteAfterExecution, $force,
		$expectedReturnValueFromExecute, $expectedOutputRegex = false
	) {
		// If marked as complete before the run, manually add the key into the updatelog table
		if ( $markedAsCompleteBeforeRun ) {
			$this->getDb()->newInsertQueryBuilder()
				->insertInto( 'updatelog' )
				->row( [ 'ul_key' => 'test' ] )
				->execute();
		}
		// Set if --force is specified and also mock the return value of ::doDBUpdates
		$this->maintenance->setForce( $force );
		$this->maintenance->method( 'doDBUpdates' )
			->willReturn( $doDBUpdatesReturnValue );
		$this->maintenance->method( 'getUpdateKey' )
			->willReturn( 'test' );
		// Run the maintenance script and then assert that the updatelog table is as expected
		$this->assertSame( $expectedReturnValueFromExecute, $this->maintenance->execute() );
		$this->newSelectQueryBuilder()
			->select( 'COUNT(*)' )
			->from( 'updatelog' )
			->where( [ 'ul_key' => 'test' ] )
			->assertFieldValue( (int)$shouldBeMarkedAsCompleteAfterExecution );
		if ( $expectedOutputRegex ) {
			$this->expectOutputRegex( $expectedOutputRegex );
		} else {
			$this->expectOutputString( '' );
		}
	}

	public static function provideExecute() {
		return [
			'Update has been run before' => [
				'doDBUpdatesReturnValue' => false,
				'markedAsCompleteBeforeRun' => true,
				'shouldBeMarkedAsCompleteAfterExecution' => true,
				'force' => null,
				'expectedReturnValueFromExecute' => true,
				'expectedOutputRegex' => "/Update 'test' already logged as completed/"
			],
			'Update has been run before, but force provided and update fails' => [
				'doDBUpdatesReturnValue' => false,
				'markedAsCompleteBeforeRun' => true,
				'shouldBeMarkedAsCompleteAfterExecution' => true,
				'force' => true,
				'expectedReturnValueFromExecute' => false,
			],
			'Update has never been run before and update succeeds' => [
				'doDBUpdatesReturnValue' => true,
				'markedAsCompleteBeforeRun' => false,
				'shouldBeMarkedAsCompleteAfterExecution' => true,
				'force' => null,
				'expectedReturnValueFromExecute' => true,
			],
			'Update has never been run before and update fails' => [
				'doDBUpdatesReturnValue' => false,
				'markedAsCompleteBeforeRun' => false,
				'shouldBeMarkedAsCompleteAfterExecution' => false,
				'force' => false,
				'expectedReturnValueFromExecute' => false,
			],
		];
	}
}
PK       ! #L5-nV  nV    MaintenanceTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Maintenance\Includes;

use MediaWiki\Config\Config;
use MediaWiki\Config\HashConfig;
use MediaWiki\Maintenance\Maintenance;
use MediaWiki\MediaWikiServices;
use MediaWiki\Tests\Maintenance\MaintenanceBaseTestCase;
use PHPUnit\Framework\Assert;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Maintenance\MaintenanceFatalError
 * @covers \MediaWiki\Maintenance\Maintenance
 * @group Database
 */
class MaintenanceTest extends MaintenanceBaseTestCase {

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

	/**
	 * @see MaintenanceBaseTestCase::createMaintenance
	 *
	 * Note to extension authors looking for a model to follow: This function
	 * is normally not needed in a maintenance test, it's only overridden here
	 * because Maintenance is abstract.
	 * @inheritDoc
	 */
	protected function createMaintenance() {
		$className = $this->getMaintenanceClass();
		$obj = $this->getMockForAbstractClass( $className );

		return TestingAccessWrapper::newFromObject( $obj );
	}

	// Although the following tests do not seem to be too consistent (compare for
	// example the newlines within the test.*StringString tests, or the
	// test.*Intermittent.* tests), the objective of these tests is not to describe
	// consistent behavior, but rather currently existing behavior.

	/**
	 * @dataProvider provideOutputData
	 */
	public function testOutput( $outputs, $expected, $extraNL ) {
		foreach ( $outputs as $data ) {
			if ( is_array( $data ) ) {
				[ $msg, $channel ] = $data;
			} else {
				$msg = $data;
				$channel = null;
			}
			$this->maintenance->output( $msg, $channel );
		}
		$this->assertOutputPrePostShutdown( $expected, $extraNL );
	}

	public static function provideOutputData() {
		return [
			[ [ "" ], "", false ],
			[ [ "foo" ], "foo", false ],
			[ [ "foo", "bar" ], "foobar", false ],
			[ [ "foo\n" ], "foo\n", false ],
			[ [ "foo\n\n" ], "foo\n\n", false ],
			[ [ "foo\nbar" ], "foo\nbar", false ],
			[ [ "foo\nbar\n" ], "foo\nbar\n", false ],
			[ [ "foo\n", "bar\n" ], "foo\nbar\n", false ],
			[ [ "", "foo", "", "\n", "ba", "", "r\n" ], "foo\nbar\n", false ],
			[ [ "", "foo", "", "\nb", "a", "", "r\n" ], "foo\nbar\n", false ],
			[ [ [ "foo", "bazChannel" ] ], "foo", true ],
			[ [ [ "foo\n", "bazChannel" ] ], "foo", true ],

			// If this test fails, note that output takes strings with double line
			// endings (although output's implementation in this situation calls
			// outputChanneled with a string ending in a nl ... which is not allowed
			// according to the documentation of outputChanneled)
			[ [ [ "foo\n\n", "bazChannel" ] ], "foo\n", true ],
			[ [ [ "foo\nbar", "bazChannel" ] ], "foo\nbar", true ],
			[ [ [ "foo\nbar\n", "bazChannel" ] ], "foo\nbar", true ],
			[
				[
					[ "foo\n", "bazChannel" ],
					[ "bar\n", "bazChannel" ],
				],
				"foobar",
				true
			],
			[
				[
					[ "", "bazChannel" ],
					[ "foo", "bazChannel" ],
					[ "", "bazChannel" ],
					[ "\n", "bazChannel" ],
					[ "ba", "bazChannel" ],
					[ "", "bazChannel" ],
					[ "r\n", "bazChannel" ],
				],
				"foobar",
				true
			],
			[
				[
					[ "", "bazChannel" ],
					[ "foo", "bazChannel" ],
					[ "", "bazChannel" ],
					[ "\nb", "bazChannel" ],
					[ "a", "bazChannel" ],
					[ "", "bazChannel" ],
					[ "r\n", "bazChannel" ],
				],
				"foo\nbar",
				true
			],
			[
				[
					[ "foo", "bazChannel" ],
					[ "bar", "bazChannel" ],
					[ "qux", "quuxChannel" ],
					[ "corge", "bazChannel" ],
				],
				"foobar\nqux\ncorge",
				true
			],
			[
				[
					[ "foo", "bazChannel" ],
					[ "bar\n", "bazChannel" ],
					[ "qux\n", "quuxChannel" ],
					[ "corge", "bazChannel" ],
				],
				"foobar\nqux\ncorge",
				true
			],
			[
				[
					[ "foo", null ],
					[ "bar", "bazChannel" ],
					[ "qux", null ],
					[ "quux", "bazChannel" ],
				],
				"foobar\nquxquux",
				true
			],
			[
				[
					[ "foo", "bazChannel" ],
					[ "bar", null ],
					[ "qux", "bazChannel" ],
					[ "quux", null ],
				],
				"foo\nbarqux\nquux",
				false
			],
			[
				[
					[ "foo", 1 ],
					[ "bar", 1.0 ],
				],
				"foo\nbar",
				true
			],
			[ [ "foo", "", "bar" ], "foobar", false ],
			[ [ "foo", false, "bar" ], "foobar", false ],
			[
				[
					[ "qux", "quuxChannel" ],
					"foo",
					false,
					"bar"
				],
				"qux\nfoobar",
				false
			],
			[
				[
					[ "foo", "bazChannel" ],
					[ "", "bazChannel" ],
					[ "bar", "bazChannel" ],
				],
				"foobar",
				true
			],
			[
				[
					[ "foo", "bazChannel" ],
					[ false, "bazChannel" ],
					[ "bar", "bazChannel" ],
				],
				"foobar",
				true
			],
		];
	}

	/**
	 * @dataProvider provideOutputChanneledData
	 */
	public function testOutputChanneled( $outputs, $expected, $extraNL ) {
		foreach ( $outputs as $data ) {
			if ( is_array( $data ) ) {
				[ $msg, $channel ] = $data;
			} else {
				$msg = $data;
				$channel = null;
			}
			$this->maintenance->outputChanneled( $msg, $channel );
		}
		$this->assertOutputPrePostShutdown( $expected, $extraNL );
	}

	public static function provideOutputChanneledData() {
		return [
			[ [ "" ], "\n", false ],
			[ [ "foo" ], "foo\n", false ],
			[ [ "foo", "bar" ], "foo\nbar\n", false ],
			[ [ "foo\nbar" ], "foo\nbar\n", false ],
			[ [ "", "foo", "", "\nb", "a", "", "r" ], "\nfoo\n\n\nb\na\n\nr\n", false ],
			[ [ [ "foo", "bazChannel" ] ], "foo", true ],
			[
				[
					[ "foo\nbar", "bazChannel" ]
				],
				"foo\nbar",
				true
			],
			[
				[
					[ "foo", "bazChannel" ],
					[ "bar", "bazChannel" ],
				],
				"foobar",
				true
			],
			[
				[
					[ "", "bazChannel" ],
					[ "foo", "bazChannel" ],
					[ "", "bazChannel" ],
					[ "\nb", "bazChannel" ],
					[ "a", "bazChannel" ],
					[ "", "bazChannel" ],
					[ "r", "bazChannel" ],
				],
				"foo\nbar",
				true
			],
			[
				[
					[ "foo", "bazChannel" ],
					[ "bar", "bazChannel" ],
					[ "qux", "quuxChannel" ],
					[ "corge", "bazChannel" ],
				],
				"foobar\nqux\ncorge",
				true
			],
			[
				[
					[ "foo", "bazChannel" ],
					[ "bar", "bazChannel" ],
					[ "qux", "quuxChannel" ],
					[ "corge", "bazChannel" ],
				],
				"foobar\nqux\ncorge",
				true
			],
			[
				[
					[ "foo", "bazChannel" ],
					[ "bar", null ],
					[ "qux", null ],
					[ "corge", "bazChannel" ],
				],
				"foo\nbar\nqux\ncorge",
				true
			],
			[
				[
					[ "foo", null ],
					[ "bar", "bazChannel" ],
					[ "qux", null ],
					[ "quux", "bazChannel" ],
				],
				"foo\nbar\nqux\nquux",
				true
			],
			[
				[
					[ "foo", "bazChannel" ],
					[ "bar", null ],
					[ "qux", "bazChannel" ],
					[ "quux", null ],
				],
				"foo\nbar\nqux\nquux\n",
				false
			],
			[
				[
					[ "foo", 1 ],
					[ "bar", 1.0 ],
				],
				"foo\nbar",
				true
			],
			[ [ "foo", "", "bar" ], "foo\n\nbar\n", false ],
			[ [ "foo", false, "bar" ], "foo\nbar\n", false ],
		];
	}

	public function testCleanupChanneledClean() {
		$this->maintenance->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "", false );
	}

	public function testCleanupChanneledAfterOutput() {
		$this->maintenance->output( "foo" );
		$this->maintenance->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foo", false );
	}

	public function testCleanupChanneledAfterOutputWNullChannel() {
		$this->maintenance->output( "foo", null );
		$this->maintenance->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foo", false );
	}

	public function testCleanupChanneledAfterOutputWChannel() {
		$this->maintenance->output( "foo", "bazChannel" );
		$this->maintenance->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foo\n", false );
	}

	public function testCleanupChanneledAfterNLOutput() {
		$this->maintenance->output( "foo\n" );
		$this->maintenance->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foo\n", false );
	}

	public function testCleanupChanneledAfterNLOutputWNullChannel() {
		$this->maintenance->output( "foo\n", null );
		$this->maintenance->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foo\n", false );
	}

	public function testCleanupChanneledAfterNLOutputWChannel() {
		$this->maintenance->output( "foo\n", "bazChannel" );
		$this->maintenance->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foo\n", false );
	}

	public function testCleanupChanneledAfterOutputChanneledWOChannel() {
		$this->maintenance->outputChanneled( "foo" );
		$this->maintenance->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foo\n", false );
	}

	public function testCleanupChanneledAfterOutputChanneledWNullChannel() {
		$this->maintenance->outputChanneled( "foo", null );
		$this->maintenance->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foo\n", false );
	}

	public function testCleanupChanneledAfterOutputChanneledWChannel() {
		$this->maintenance->outputChanneled( "foo", "bazChannel" );
		$this->maintenance->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foo\n", false );
	}

	public function testMultipleMaintenanceObjectsInteractionOutput() {
		$m2 = $this->createMaintenance();

		$this->maintenance->output( "foo" );
		$m2->output( "bar" );

		$this->assertEquals( "foobar", $this->getActualOutput(),
			"Output before shutdown simulation (m2)" );
		$m2->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foobar", false );
	}

	public function testMultipleMaintenanceObjectsInteractionOutputWNullChannel() {
		$m2 = $this->createMaintenance();

		$this->maintenance->output( "foo", null );
		$m2->output( "bar", null );

		$this->assertEquals( "foobar", $this->getActualOutput(),
			"Output before shutdown simulation (m2)" );
		$m2->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foobar", false );
	}

	public function testMultipleMaintenanceObjectsInteractionOutputWChannel() {
		$m2 = $this->createMaintenance();

		$this->maintenance->output( "foo", "bazChannel" );
		$m2->output( "bar", "bazChannel" );

		$this->assertEquals( "foobar", $this->getActualOutput(),
			"Output before shutdown simulation (m2)" );
		$m2->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foobar\n", true );
	}

	public function testMultipleMaintenanceObjectsInteractionOutputWNullChannelNL() {
		$m2 = $this->createMaintenance();

		$this->maintenance->output( "foo\n", null );
		$m2->output( "bar\n", null );

		$this->assertEquals( "foo\nbar\n", $this->getActualOutput(),
			"Output before shutdown simulation (m2)" );
		$m2->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foo\nbar\n", false );
	}

	public function testMultipleMaintenanceObjectsInteractionOutputWChannelNL() {
		$m2 = $this->createMaintenance();

		$this->maintenance->output( "foo\n", "bazChannel" );
		$m2->output( "bar\n", "bazChannel" );

		$this->assertEquals( "foobar", $this->getActualOutput(),
			"Output before shutdown simulation (m2)" );
		$m2->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foobar\n", true );
	}

	public function testMultipleMaintenanceObjectsInteractionOutputChanneled() {
		$m2 = $this->createMaintenance();

		$this->maintenance->outputChanneled( "foo" );
		$m2->outputChanneled( "bar" );

		$this->assertEquals( "foo\nbar\n", $this->getActualOutput(),
			"Output before shutdown simulation (m2)" );
		$m2->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foo\nbar\n", false );
	}

	public function testMultipleMaintenanceObjectsInteractionOutputChanneledWNullChannel() {
		$m2 = $this->createMaintenance();

		$this->maintenance->outputChanneled( "foo", null );
		$m2->outputChanneled( "bar", null );

		$this->assertEquals( "foo\nbar\n", $this->getActualOutput(),
			"Output before shutdown simulation (m2)" );
		$m2->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foo\nbar\n", false );
	}

	public function testMultipleMaintenanceObjectsInteractionOutputChanneledWChannel() {
		$m2 = $this->createMaintenance();

		$this->maintenance->outputChanneled( "foo", "bazChannel" );
		$m2->outputChanneled( "bar", "bazChannel" );

		$this->assertEquals( "foobar", $this->getActualOutput(),
			"Output before shutdown simulation (m2)" );
		$m2->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foobar\n", true );
	}

	public function testMultipleMaintenanceObjectsInteractionCleanupChanneledWChannel() {
		$m2 = $this->createMaintenance();

		$this->maintenance->outputChanneled( "foo", "bazChannel" );
		$m2->outputChanneled( "bar", "bazChannel" );

		$this->assertEquals( "foobar", $this->getActualOutput(),
			"Output before first cleanup" );
		$this->maintenance->cleanupChanneled();
		$this->assertEquals( "foobar\n", $this->getActualOutput(),
			"Output after first cleanup" );
		$m2->cleanupChanneled();
		$this->assertEquals( "foobar\n\n", $this->getActualOutput(),
			"Output after second cleanup" );

		$m2->cleanupChanneled();
		$this->assertOutputPrePostShutdown( "foobar\n\n", false );
	}

	/**
	 * @covers \MediaWiki\Maintenance\Maintenance::getConfig
	 */
	public function testGetConfig() {
		$this->assertInstanceOf( Config::class, $this->maintenance->getConfig() );
		$this->assertSame(
			MediaWikiServices::getInstance()->getMainConfig(),
			$this->maintenance->getConfig()
		);
	}

	/**
	 * @covers \MediaWiki\Maintenance\Maintenance::setConfig
	 */
	public function testSetConfig() {
		$conf = new HashConfig();
		$this->maintenance->setConfig( $conf );
		$this->assertSame( $conf, $this->maintenance->getConfig() );
	}

	public function testParseWithMultiArgs() {
		// Create an option with an argument allowed to be specified multiple times
		$this->maintenance->addOption( 'multi', 'This option does stuff', false, true, false, true );
		$this->maintenance->loadWithArgv( [ '--multi', 'this1', '--multi', 'this2' ] );

		$this->assertEquals( [ 'this1', 'this2' ], $this->maintenance->getOption( 'multi' ) );
		$this->assertEquals( [ [ 'multi', 'this1' ], [ 'multi', 'this2' ] ],
			$this->maintenance->orderedOptions );
	}

	public function testParseMultiOption() {
		$this->maintenance->addOption( 'multi', 'This option does stuff', false, false, false, true );
		$this->maintenance->loadWithArgv( [ '--multi', '--multi' ] );

		$this->assertEquals( [ 1, 1 ], $this->maintenance->getOption( 'multi' ) );
		$this->assertEquals( [ [ 'multi', 1 ], [ 'multi', 1 ] ], $this->maintenance->orderedOptions );
	}

	public function testParseArgs() {
		$this->maintenance->addOption( 'multi', 'This option doesn\'t actually support multiple occurrences' );
		$this->maintenance->loadWithArgv( [ '--multi=yo' ] );

		$this->assertEquals( 'yo', $this->maintenance->getOption( 'multi' ) );
		$this->assertEquals( [ [ 'multi', 'yo' ] ], $this->maintenance->orderedOptions );
	}

	public function testOptionGetters() {
		$this->assertSame(
			false,
			$this->maintenance->hasOption( 'somearg' ),
			'Non existent option not found'
		);
		$this->assertSame(
			'default',
			$this->maintenance->getOption( 'somearg', 'default' ),
			'Non existent option falls back to default'
		);
		$this->assertSame(
			false,
			$this->maintenance->hasOption( 'somearg' ),
			'Non existent option not found after getting'
		);
		$this->assertSame(
			'newdefault',
			$this->maintenance->getOption( 'somearg', 'newdefault' ),
			'Non existent option falls back to a new default'
		);
	}

	public function testLegacyOptionsAccess() {
		$maintenance = new class () extends Maintenance {
			/**
			 * Tests need to be inside the class in order to have access to protected members.
			 * Setting fields in protected arrays doesn't work via TestingAccessWrapper, triggering
			 * an PHP warning ("Indirect modification of overloaded property").
			 */
			public function execute() {
				$this->setOption( 'test', 'foo' );
				Assert::assertSame( 'foo', $this->getOption( 'test' ) );
				Assert::assertSame( 'foo', $this->mOptions['test'] );

				$this->mOptions['test'] = 'bar';
				Assert::assertSame( 'bar', $this->getOption( 'test' ) );

				$this->setArg( 1, 'foo' );
				Assert::assertSame( 'foo', $this->getArg( 1 ) );
				Assert::assertSame( 'foo', $this->mArgs[1] );

				$this->mArgs[1] = 'bar';
				Assert::assertSame( 'bar', $this->getArg( 1 ) );
			}
		};

		$maintenance->execute();
	}

	/** @dataProvider provideFatalError */
	public function testFatalError( $msg, $errorCode ) {
		$this->expectCallToFatalError( $errorCode );
		$this->expectOutputString( $msg . "\n" );
		$this->maintenance->fatalError( $msg, $errorCode );
	}

	public static function provideFatalError() {
		return [
			'No error message, code as 1' => [ '', 1 ],
			'Defined error message, code as 3' => [ 'Testing error message', 3 ],
		];
	}

	/** @dataProvider provideRequiredButMissingExtensions */
	public function testCheckRequiredExtensionForMissingExtension( $requiredExtensions, $expectedOutputRegex ) {
		$this->expectCallToFatalError();
		$this->expectOutputRegex( $expectedOutputRegex );
		foreach ( $requiredExtensions as $extension ) {
			$this->maintenance->requireExtension( $extension );
		}
		$this->maintenance->checkRequiredExtensions();
	}

	public static function provideRequiredButMissingExtensions() {
		return [
			'One missing extension' => [
				[ 'FakeExtensionForMaintenanceTest' ],
				'/The "FakeExtensionForMaintenanceTest" extension must be installed.*' .
				'Please enable it and then try again/'
			],
			'Two missing extensions' => [
				[ 'FakeExtensionForMaintenanceTest', 'MissingExtensionTest2' ],
				'/The following extensions must be installed.*FakeExtensionForMaintenanceTest.*MissingExtensionTest2' .
				'.*Please enable them and then try again/'
			],
		];
	}

	public function testValidateUserOptionForMissingArguments() {
		$this->expectCallToFatalError();
		$this->expectOutputRegex( '/Test error message/' );
		$this->maintenance->validateUserOption( 'Test error message' );
	}

	/** @dataProvider provideInvalidUserOptions */
	public function testValidateUserOptionForInvalidUserOption( $options, $expectedOutputRegex ) {
		$this->expectCallToFatalError();
		$this->expectOutputRegex( $expectedOutputRegex );
		foreach ( $options as $name => $value ) {
			$this->maintenance->setOption( $name, $value );
		}
		$this->maintenance->validateUserOption( 'unused' );
	}

	public static function provideInvalidUserOptions() {
		return [
			'Invalid --user option' => [
				[ 'user' => 'Non-existent-test-user' ], '/No such user.*Non-existent-test-user/',
			],
			'Invalid --userid option' => [ [ 'userid' => 0 ], '/No such user id.*0/' ],
		];
	}

	public function testValidateUserOptionForValidUser() {
		$testUser = $this->getTestUser()->getUserIdentity();
		$this->maintenance->setOption( 'userid', $testUser->getId() );
		$this->assertTrue( $testUser->equals( $this->maintenance->validateUserOption( "unused" ) ) );
	}

	public function testRunChildForNonExistentClass() {
		$this->expectCallToFatalError();
		$this->expectOutputRegex( '/Cannot spawn child.*NonExistingTestClassForMaintenanceTest/' );
		$this->maintenance->runChild( 'NonExistingTestClassForMaintenanceTest' );
	}

	public function testSetAllowUnregisteredOptions() {
		$this->maintenance->setOption( 'abcdef', 'abc' );
		$this->maintenance->setAllowUnregisteredOptions( true );
		$this->assertTrue( $this->maintenance->getParameters()->validate() );
		$this->maintenance->setAllowUnregisteredOptions( false );
		$this->assertFalse( $this->maintenance->getParameters()->validate() );
	}

	/** @dataProvider provideSetBatchSize */
	public function testSetBatchSize( $batchSize, $shouldHaveBatchSizeOption ) {
		$this->maintenance->setBatchSize( $batchSize );
		$this->assertSame( $batchSize, $this->maintenance->getBatchSize() );
		$this->assertSame(
			$shouldHaveBatchSizeOption,
			$this->maintenance->supportsOption( 'batch-size' )
		);
	}

	public static function provideSetBatchSize() {
		return [
			'Batch size as 0' => [ 0, false ],
			'Batch size as 150' => [ 150, true ],
		];
	}

	public function testPurgeRedundantTextWhenNoPagesExist() {
		// Regression test for the method breaking if no rows exist in the content_address table.
		$this->maintenance->purgeRedundantText();
		$this->expectOutputRegex( '/0 inactive items found[\s\S]*(?!Deleting)/' );
	}

	public function testDeleteOptionLoop() {
		$this->maintenance->addOption( 'test-for-deletion', 'testing' );
		$this->assertTrue( $this->maintenance->getParameters()->supportsOption( 'test-for-deletion' ) );
		$this->maintenance->deleteOption( 'test-for-deletion' );
		$this->assertFalse( $this->maintenance->getParameters()->supportsOption( 'test-for-deletion' ) );
	}

	public function testAddDescription() {
		$this->maintenance->addDescription( 'testing-description abcdef' );
		$this->expectCallToFatalError();
		$this->expectOutputRegex( '/testing-description abcdef/' );
		$this->maintenance->getParameters()->setName( 'test.php' );
		$this->maintenance->maybeHelp( true );
	}

	public function testGetArgName() {
		$this->maintenance->addArg( 'testing', 'test' );
		$this->assertSame( 'testing', $this->maintenance->getArgName( 0 ) );
	}

	public function testHasArg() {
		$this->maintenance->addArg( 'testing', 'test' );
		$this->maintenance->setArg( 'testing', 'abc' );
		$this->assertTrue( $this->maintenance->hasArg( 0 ) );
		$this->assertTrue( $this->maintenance->hasArg( 'testing' ) );
		$this->assertSame( [ 'abc' ], $this->maintenance->getArgs() );
	}

	public function testSetName() {
		$this->maintenance->setName( 'test.php' );
		$this->assertSame( 'test.php', $this->maintenance->getName() );
		$this->assertSame( 'test.php', $this->maintenance->getParameters()->getName() );
	}

	public function testGetDir() {
		$this->assertSame( realpath( MW_INSTALL_PATH . '/maintenance' ), realpath( $this->maintenance->getDir() ) );
	}

	/** @dataProvider provideParseIntList */
	public function testParseIntList( $text, $expected ) {
		$this->assertArrayEquals( $expected, $this->maintenance->parseIntList( $text ) );
	}

	public static function provideParseIntList() {
		return [
			'Integers separated by ","' => [ '1,2,3,3', [ 1, 2, 3, 3 ] ],
			'Integers separated by "|"' => [ '1|2|3|4|4', [ 1, 2, 3, 4, 4 ] ],
		];
	}
}
PK       ! n";I      defines.phpnu [        <?php

/**
 * @package    Joomla.API
 *
 * @copyright  (C) 2019 Open Source Matters, Inc. <https://www.joomla.org>
 * @license    GNU General Public License version 2 or later; see LICENSE.txt
 */

\defined('_JEXEC') or die;

// Define JPATH constants if not defined yet
\defined('JPATH_BASE') || \define('JPATH_BASE', \dirname(__DIR__));

// Global definitions
$parts = explode(DIRECTORY_SEPARATOR, JPATH_BASE);
array_pop($parts);

// Defines
\defined('JPATH_ROOT') || \define('JPATH_ROOT', implode(DIRECTORY_SEPARATOR, $parts));
\defined('JPATH_SITE') || \define('JPATH_SITE', JPATH_ROOT);
\defined('JPATH_PUBLIC') || \define('JPATH_PUBLIC', JPATH_ROOT);
\defined('JPATH_CONFIGURATION') || \define('JPATH_CONFIGURATION', JPATH_ROOT);
\defined('JPATH_ADMINISTRATOR') || \define('JPATH_ADMINISTRATOR', JPATH_ROOT . DIRECTORY_SEPARATOR . 'administrator');
\defined('JPATH_LIBRARIES') || \define('JPATH_LIBRARIES', JPATH_ROOT . DIRECTORY_SEPARATOR . 'libraries');
\defined('JPATH_PLUGINS') || \define('JPATH_PLUGINS', JPATH_ROOT . DIRECTORY_SEPARATOR . 'plugins');
\defined('JPATH_INSTALLATION') || \define('JPATH_INSTALLATION', JPATH_ROOT . DIRECTORY_SEPARATOR . 'installation');
\defined('JPATH_THEMES') || \define('JPATH_THEMES', JPATH_BASE . DIRECTORY_SEPARATOR . 'templates');
\defined('JPATH_CACHE') || \define('JPATH_CACHE', JPATH_ADMINISTRATOR . DIRECTORY_SEPARATOR . 'cache');
\defined('JPATH_MANIFESTS') || \define('JPATH_MANIFESTS', JPATH_ADMINISTRATOR . DIRECTORY_SEPARATOR . 'manifests');
\defined('JPATH_API') || \define('JPATH_API', JPATH_ROOT . DIRECTORY_SEPARATOR . 'api');
\defined('JPATH_CLI') || \define('JPATH_CLI', JPATH_ROOT . DIRECTORY_SEPARATOR . 'cli');
PK       ! i'v      app.phpnu [        <?php

/**
 * @package    Joomla.API
 *
 * @copyright  (C) 2019 Open Source Matters, Inc. <https://www.joomla.org>
 * @license    GNU General Public License version 2 or later; see LICENSE.txt
 */

\defined('_JEXEC') or die;

// Saves the start time and memory usage.
$startTime = microtime(1);
$startMem  = memory_get_usage();

if (file_exists(\dirname(__DIR__) . '/defines.php')) {
    include_once \dirname(__DIR__) . '/defines.php';
}

require_once __DIR__ . '/defines.php';

require_once JPATH_BASE . '/includes/framework.php';

// Set profiler start time and memory usage and mark afterLoad in the profiler.
JDEBUG && \Joomla\CMS\Profiler\Profiler::getInstance('Application')->setStart($startTime, $startMem)->mark('afterLoad');

// Boot the DI container
$container = \Joomla\CMS\Factory::getContainer();

/*
 * Alias the session service keys to the web session service as that is the primary session backend for this application
 *
 * In addition to aliasing "common" service keys, we also create aliases for the PHP classes to ensure autowiring objects
 * is supported.  This includes aliases for aliased class names, and the keys for aliased class names should be considered
 * deprecated to be removed when the class name alias is removed as well.
 */
$container->alias('session', 'session.cli')
    ->alias('JSession', 'session.cli')
    ->alias(\Joomla\CMS\Session\Session::class, 'session.cli')
    ->alias(\Joomla\Session\Session::class, 'session.cli')
    ->alias(\Joomla\Session\SessionInterface::class, 'session.cli');

// Instantiate the application.
$app = $container->get(\Joomla\CMS\Application\ApiApplication::class);

// Set the application as global app
\Joomla\CMS\Factory::$application = $app;

// Execute the application.
$app->execute();
PK       !       framework.phpnu [        <?php

/**
 * @package    Joomla.API
 *
 * @copyright  (C) 2019 Open Source Matters, Inc. <https://www.joomla.org>
 * @license    GNU General Public License version 2 or later; see LICENSE.txt
 */

\defined('_JEXEC') or die;

use Joomla\CMS\Version;
use Joomla\Utilities\IpHelper;

// System includes
require_once JPATH_LIBRARIES . '/bootstrap.php';

// Installation check, and check on removal of the install directory.
if (
    !file_exists(JPATH_CONFIGURATION . '/configuration.php')
    || (filesize(JPATH_CONFIGURATION . '/configuration.php') < 10)
    || (file_exists(JPATH_INSTALLATION . '/index.php') && (false === (new Version())->isInDevelopmentState()))
) {
    if (file_exists(JPATH_INSTALLATION . '/index.php')) {
        header('HTTP/1.1 500 Internal Server Error');
        echo json_encode(
            ['error' => 'You must install Joomla to use the API']
        );

        exit();
    }

    header('HTTP/1.1 500 Internal Server Error');
    echo json_encode(
        ['error' => 'No configuration file found and no installation code available. Exiting...']
    );

    exit;
}

// Pre-Load configuration. Don't remove the Output Buffering due to BOM issues, see JCode 26026
ob_start();
require_once JPATH_CONFIGURATION . '/configuration.php';
ob_end_clean();

// System configuration.
$config = new JConfig();

// Set the error_reporting
switch ($config->error_reporting) {
    case 'default':
    case '-1':
        break;

    case 'none':
    case '0':
        error_reporting(0);

        break;

    case 'simple':
        error_reporting(E_ERROR | E_WARNING | E_PARSE);
        ini_set('display_errors', 1);

        break;

    case 'maximum':
        error_reporting(E_ALL);
        ini_set('display_errors', 1);

        break;

    default:
        error_reporting($config->error_reporting);
        ini_set('display_errors', 1);

        break;
}

\define('JDEBUG', $config->debug);

// Check deprecation logging
if (empty($config->log_deprecated)) {
    // Reset handler for E_USER_DEPRECATED
    set_error_handler(null, E_USER_DEPRECATED);
} else {
    // Make sure handler for E_USER_DEPRECATED is registered
    set_error_handler(['Joomla\CMS\Exception\ExceptionHandler', 'handleUserDeprecatedErrors'], E_USER_DEPRECATED);
}

if (JDEBUG || $config->error_reporting === 'maximum') {
    // Set new Exception handler with debug enabled
    $errorHandler->setExceptionHandler(
        [
            new \Symfony\Component\ErrorHandler\ErrorHandler(null, true),
            'renderException',
        ]
    );
}

/**
 * Correctly set the allowing of IP Overrides if behind a trusted proxy/load balancer.
 *
 * We need to do this as high up the stack as we can, as the default in \Joomla\Utilities\IpHelper is to
 * $allowIpOverride = true which is the wrong default for a generic site NOT behind a trusted proxy/load balancer.
 */
if (property_exists($config, 'behind_loadbalancer') && $config->behind_loadbalancer == 1) {
    // If Joomla is configured to be behind a trusted proxy/load balancer, allow HTTP Headers to override the REMOTE_ADDR
    IpHelper::setAllowIpOverrides(true);
} else {
    // We disable the allowing of IP overriding using headers by default.
    IpHelper::setAllowIpOverrides(false);
}

unset($config);
PK       ! Ex/',  ',    PdfHandler.phpnu Iw        <?php

namespace MediaWiki\Extension\PdfHandler;

use File;
use ImageHandler;
use MediaTransformError;
use MediaTransformOutput;
use MediaWiki\Context\IContextSource;
use MediaWiki\MediaWikiServices;
use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
use ThumbnailImage;
use TransformParameterError;

/**
 * Copyright © 2007 Martin Seidel (Xarax) <jodeldi@gmx.de>
 *
 * Inspired by djvuhandler from Tim Starling
 * Modified and written by Xarax
 *
 * 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
 */

class PdfHandler extends ImageHandler {
	/**
	 * Keep in sync with pdfhandler.messages in extension.json
	 *
	 * @see getWarningConfig
	 */
	private const MESSAGES = [
		'main' => 'pdf-file-page-warning',
		'header' => 'pdf-file-page-warning-header',
		'info' => 'pdf-file-page-warning-info',
		'footer' => 'pdf-file-page-warning-footer',
	];

	/**
	 * 10MB is considered a large file
	 */
	private const LARGE_FILE = 1e7;

	/**
	 * Key for getHandlerState for value of type PdfImage
	 */
	private const STATE_PDF_IMAGE = 'pdfImage';

	/**
	 * Key for getHandlerState for dimension info
	 */
	private const STATE_DIMENSION_INFO = 'pdfDimensionInfo';

	/**
	 * @param File $file
	 * @return bool
	 */
	public function mustRender( $file ) {
		return true;
	}

	/**
	 * @param File $file
	 * @return bool
	 */
	public function isMultiPage( $file ) {
		return true;
	}

	/**
	 * @param string $name
	 * @param string $value
	 * @return bool
	 */
	public function validateParam( $name, $value ) {
		if ( $name === 'page' && trim( $value ) !== (string)intval( $value ) ) {
			// Extra junk on the end of page, probably actually a caption
			// e.g. [[File:Foo.pdf|thumb|Page 3 of the document shows foo]]
			return false;
		}
		if ( in_array( $name, [ 'width', 'height', 'page' ] ) ) {
			return ( $value > 0 );
		}
		return false;
	}

	/**
	 * @param array $params
	 * @return bool|string
	 */
	public function makeParamString( $params ) {
		$page = $params['page'] ?? 1;
		if ( !isset( $params['width'] ) ) {
			return false;
		}
		return "page{$page}-{$params['width']}px";
	}

	/**
	 * @param string $str
	 * @return array|bool
	 */
	public function parseParamString( $str ) {
		$m = [];

		if ( preg_match( '/^page(\d+)-(\d+)px$/', $str, $m ) ) {
			return [ 'width' => $m[2], 'page' => $m[1] ];
		}

		return false;
	}

	/**
	 * @param array $params
	 * @return array
	 */
	public function getScriptParams( $params ) {
		return [
			'width' => $params['width'],
			'page' => $params['page'],
		];
	}

	/**
	 * @return array
	 */
	public function getParamMap() {
		return [
			'img_width' => 'width',
			'img_page' => 'page',
		];
	}

	/**
	 * @param int $width
	 * @param int $height
	 * @param string $msg
	 * @return MediaTransformError
	 */
	protected function doThumbError( $width, $height, $msg ) {
		return new MediaTransformError( 'thumbnail_error',
			$width, $height, wfMessage( $msg )->inContentLanguage()->text() );
	}

	/**
	 * @param File $image
	 * @param string $dstPath
	 * @param string $dstUrl
	 * @param array $params
	 * @param int $flags
	 * @return MediaTransformError|MediaTransformOutput|ThumbnailImage|TransformParameterError
	 */
	public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
		global $wgPdfProcessor, $wgPdfPostProcessor, $wgPdfHandlerDpi, $wgPdfHandlerJpegQuality;

		if ( !$this->normaliseParams( $image, $params ) ) {
			return new TransformParameterError( $params );
		}

		$width = (int)$params['width'];
		$height = (int)$params['height'];
		$page = (int)$params['page'];

		if ( $page > $this->pageCount( $image ) ) {
			return $this->doThumbError( $width, $height, 'pdf_page_error' );
		}

		if ( $flags & self::TRANSFORM_LATER ) {
			return new ThumbnailImage( $image, $dstUrl, false, [
				'width' => $width,
				'height' => $height,
				'page' => $page,
			] );
		}

		if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
			return $this->doThumbError( $width, $height, 'thumbnail_dest_directory' );
		}

		// Thumbnail extraction is very inefficient for large files.
		// Provide a way to pool count limit the number of downloaders.
		if ( $image->getSize() >= self::LARGE_FILE ) {
			$work = new PoolCounterWorkViaCallback( 'GetLocalFileCopy', sha1( $image->getName() ),
				[
					'doWork' => static function () use ( $image ) {
						return $image->getLocalRefPath();
					}
				]
			);
			$srcPath = $work->execute();
		} else {
			$srcPath = $image->getLocalRefPath();
		}

		if ( $srcPath === false ) {
			// could not download original
			return $this->doThumbError( $width, $height, 'filemissing' );
		}

		$cmd = '(' . wfEscapeShellArg(
			$wgPdfProcessor,
			"-sDEVICE=jpeg",
			"-sOutputFile=-",
			"-sstdout=%stderr",
			"-dFirstPage={$page}",
			"-dLastPage={$page}",
			"-dSAFER",
			"-r{$wgPdfHandlerDpi}",
			// CropBox defines the region that the PDF viewer application is expected to display or print.
			"-dUseCropBox",
			"-dBATCH",
			"-dNOPAUSE",
			"-q",
			$srcPath
		);
		$cmd .= " | " . wfEscapeShellArg(
			$wgPdfPostProcessor,
			"-depth",
			"8",
			"-quality",
			$wgPdfHandlerJpegQuality,
			"-resize",
			(string)$width,
			"-",
			$dstPath
		);
		$cmd .= ")";

		wfDebug( __METHOD__ . ": $cmd\n" );
		$retval = '';
		$err = wfShellExecWithStderr( $cmd, $retval );

		$removed = $this->removeBadFile( $dstPath, $retval );

		if ( $retval != 0 || $removed ) {
			wfDebugLog( 'thumbnail',
				sprintf( 'thumbnail failed on %s: error %d "%s" from "%s"',
				wfHostname(), $retval, trim( $err ), $cmd ) );
			return new MediaTransformError( 'thumbnail_error', $width, $height, $err );
		}

		return new ThumbnailImage( $image, $dstUrl, $dstPath, [
			'width' => $width,
			'height' => $height,
			'page' => $page,
		] );
	}

	/**
	 * @param \MediaHandlerState $state
	 * @param string $path
	 * @return PdfImage
	 */
	private function getPdfImage( $state, $path ) {
		$pdfImg = $state->getHandlerState( self::STATE_PDF_IMAGE );
		if ( !$pdfImg ) {
			$pdfImg = new PdfImage( $path );
			$state->setHandlerState( self::STATE_PDF_IMAGE, $pdfImg );
		}
		return $pdfImg;
	}

	/**
	 * @param \MediaHandlerState $state
	 * @param string $path
	 * @return array|bool
	 */
	public function getSizeAndMetadata( $state, $path ) {
		$metadata = $this->getPdfImage( $state, $path )->retrieveMetaData();
		$sizes = PdfImage::getPageSize( $metadata, 1 );
		if ( $sizes ) {
			return $sizes + [ 'metadata' => $metadata ];
		}

		return [ 'metadata' => $metadata ];
	}

	/**
	 * @param string $ext
	 * @param string $mime
	 * @param null $params
	 * @return array
	 */
	public function getThumbType( $ext, $mime, $params = null ) {
		global $wgPdfOutputExtension;
		static $mime;

		if ( !isset( $mime ) ) {
			$magic = MediaWikiServices::getInstance()->getMimeAnalyzer();
			$mime = $magic->guessTypesForExtension( $wgPdfOutputExtension );
		}
		return [ $wgPdfOutputExtension, $mime ];
	}

	/**
	 * @param File $file
	 * @return bool|int
	 */
	public function isFileMetadataValid( $file ) {
		$data = $file->getMetadataItems( [ 'mergedMetadata', 'pages' ] );
		if ( !isset( $data['pages'] ) ) {
			return self::METADATA_BAD;
		}

		if ( !isset( $data['mergedMetadata'] ) ) {
			return self::METADATA_COMPATIBLE;
		}

		return self::METADATA_GOOD;
	}

	/**
	 * @param File $image
	 * @param bool|IContextSource $context Context to use (optional)
	 * @return bool|array
	 */
	public function formatMetadata( $image, $context = false ) {
		$mergedMetadata = $image->getMetadataItem( 'mergedMetadata' );

		if ( !is_array( $mergedMetadata ) || !count( $mergedMetadata ) ) {
			return false;
		}

		// Inherited from MediaHandler.
		return $this->formatMetadataHelper( $mergedMetadata, $context );
	}

	/** @inheritDoc */
	protected function formatTag( string $key, $vals, $context = false ) {
		switch ( $key ) {
			case 'pdf-Producer':
			case 'pdf-Version':
				return htmlspecialchars( $vals );
			case 'pdf-PageSize':
				foreach ( $vals as &$val ) {
					$val = htmlspecialchars( $val );
				}
				return $vals;
			case 'pdf-Encrypted':
				// @todo: The value isn't i18n-ised; should be done here.
				// For reference, if encrypted this field's value looks like:
				// "yes (print:yes copy:no change:no addNotes:no)"
				return htmlspecialchars( $vals );
			default:
				break;
		}
		// Use default formatting
		return false;
	}

	/**
	 * @param File $image
	 * @return bool|int
	 */
	public function pageCount( File $image ) {
		$info = $this->getDimensionInfo( $image );

		return $info ? $info['pageCount'] : false;
	}

	/**
	 * @param File $image
	 * @param int $page
	 * @return array|bool
	 */
	public function getPageDimensions( File $image, $page ) {
		// MW starts pages at 1, as they are stored here
		$index = $page;

		$info = $this->getDimensionInfo( $image );
		if ( $info && isset( $info['dimensionsByPage'][$index] ) ) {
			return $info['dimensionsByPage'][$index];
		}

		return false;
	}

	/**
	 * @param File $file
	 * @return bool|mixed
	 */
	protected function getDimensionInfo( File $file ) {
		$info = $file->getHandlerState( self::STATE_DIMENSION_INFO );
		if ( !$info ) {
			$cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
			$info = $cache->getWithSetCallback(
				$cache->makeKey( 'file-pdf-dimensions', $file->getSha1() ),
				$cache::TTL_MONTH,
				static function () use ( $file ) {
					$data = $file->getMetadataItems( PdfImage::ITEMS_FOR_PAGE_SIZE );
					if ( !$data || !isset( $data['Pages'] ) ) {
						return false;
					}

					$dimsByPage = [];
					$count = intval( $data['Pages'] );
					for ( $i = 1; $i <= $count; $i++ ) {
						$dimsByPage[$i] = PdfImage::getPageSize( $data, $i );
					}

					return [ 'pageCount' => $count, 'dimensionsByPage' => $dimsByPage ];
				}
			);
		}
		$file->setHandlerState( self::STATE_DIMENSION_INFO, $info );
		return $info;
	}

	/**
	 * @param File $image
	 * @param int $page
	 * @return bool
	 */
	public function getPageText( File $image, $page ) {
		$pageTexts = $image->getMetadataItem( 'text' );
		if ( !is_array( $pageTexts ) || !isset( $pageTexts[$page - 1] ) ) {
			return false;
		}
		return $pageTexts[$page - 1];
	}

	/**
	 * Adds a warning about PDFs being potentially dangerous to the file
	 * page. Multiple messages with this base will be used.
	 * @param File $file
	 * @return array
	 */
	public function getWarningConfig( $file ) {
		return [
			'messages' => self::MESSAGES,
			'link' => '//www.mediawiki.org/wiki/Special:MyLanguage/Help:Security/PDF_files',
			'module' => 'pdfhandler.messages',
		];
	}

	public function useSplitMetadata() {
		return true;
	}
}
PK       ! zj\%  \%    PdfImage.phpnu Iw        <?php
/**
 *
 * Copyright © 2007 Xarax <jodeldi@gmx.de>
 *
 * 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
 */

namespace MediaWiki\Extension\PdfHandler;

use BitmapMetadataHandler;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
use UtfNormal\Validator;
use Wikimedia\XMPReader\Reader as XMPReader;

/**
 * inspired by djvuimage from Brion Vibber
 * modified and written by xarax
 */

class PdfImage {

	/**
	 * @var string
	 */
	private $mFilename;

	public const ITEMS_FOR_PAGE_SIZE = [ 'Pages', 'pages', 'Page size', 'Page rot' ];

	/**
	 * @param string $filename
	 */
	public function __construct( $filename ) {
		$this->mFilename = $filename;
	}

	/**
	 * @return bool
	 */
	public function isValid() {
		return true;
	}

	/**
	 * @param array $data
	 * @param int $page
	 * @return array|bool
	 */
	public static function getPageSize( $data, $page ) {
		global $wgPdfHandlerDpi;

		if ( isset( $data['pages'][$page]['Page size'] ) ) {
			$pageSize = $data['pages'][$page]['Page size'];
		} elseif ( isset( $data['Page size'] ) ) {
			$pageSize = $data['Page size'];
		} else {
			$pageSize = false;
		}

		if ( $pageSize ) {
			if ( isset( $data['pages'][$page]['Page rot'] ) ) {
				$pageRotation = $data['pages'][$page]['Page rot'];
			} elseif ( isset( $data['Page rot'] ) ) {
				$pageRotation = $data['Page rot'];
			} else {
				$pageRotation = 0;
			}
			$size = explode( 'x', $pageSize, 2 );

			$width  = intval( (int)trim( $size[0] ) / 72 * $wgPdfHandlerDpi );
			$height = explode( ' ', trim( $size[1] ), 2 );
			$height = intval( (int)trim( $height[0] ) / 72 * $wgPdfHandlerDpi );
			if ( ( $pageRotation / 90 ) & 1 ) {
				// Swap width and height for landscape pages
				$temp = $width;
				$width = $height;
				$height = $temp;
			}

			return [
				'width' => $width,
				'height' => $height
			];
		}

		return false;
	}

	/**
	 * @return array
	 */
	public function retrieveMetaData(): array {
		global $wgPdfInfo, $wgPdftoText, $wgShellboxShell;

		$command = MediaWikiServices::getInstance()->getShellCommandFactory()
			->createBoxed( 'pdfhandler' )
			->disableNetwork()
			->firejailDefaultSeccomp()
			->routeName( 'pdfhandler-metadata' );

		$result = $command
			->params( $wgShellboxShell, 'scripts/retrieveMetaData.sh' )
			->inputFileFromFile(
				'scripts/retrieveMetaData.sh',
				__DIR__ . '/../scripts/retrieveMetaData.sh' )
			->inputFileFromFile( 'file.pdf', $this->mFilename )
			->outputFileToString( 'meta' )
			->outputFileToString( 'pages' )
			->outputFileToString( 'text' )
			->outputFileToString( 'text_exit_code' )
			->environment( [
				'PDFHANDLER_INFO' => $wgPdfInfo,
				'PDFHANDLER_TOTEXT' => $wgPdftoText,
			] )
			->execute();

		// Record in statsd
		MediaWikiServices::getInstance()->getStatsFactory()
			->getCounter( 'pdfhandler_shell_retrievemetadata_total' )
			->copyToStatsdAt( 'pdfhandler.shell.retrieve_meta_data' )
			->increment();

		// Metadata retrieval is allowed to fail, but we'd like to know why
		if ( $result->getExitCode() != 0 ) {
			wfDebug( __METHOD__ . ': retrieveMetaData.sh' .
			"\n\nExitcode: " . $result->getExitCode() . "\n\n"
			. $result->getStderr() );
		}

		$resultMeta = $result->getFileContents( 'meta' );
		$resultPages = $result->getFileContents( 'pages' );
		if ( $resultMeta !== null || $resultPages !== null ) {
			$data = $this->convertDumpToArray(
				$resultMeta ?? '',
				$resultPages ?? ''
			);
		} else {
			$data = [];
		}

		// Read text layer
		$retval = $result->wasReceived( 'text_exit_code' )
			? (int)trim( $result->getFileContents( 'text_exit_code' ) )
			: 1;
		$txt = $result->getFileContents( 'text' );
		if ( $retval == 0 && strlen( $txt ) ) {
			$txt = str_replace( "\r\n", "\n", $txt );
			$pages = explode( "\f", $txt );
			foreach ( $pages as $page => $pageText ) {
				// Get rid of invalid UTF-8, strip control characters
				// Note we need to do this per page, as \f page feed would be stripped.
				$pages[$page] = Validator::cleanUp( $pageText );
			}
			$data['text'] = $pages;
		}

		return $data;
	}

	/**
	 * @param string $metaDump
	 * @param string $infoDump
	 * @return array
	 */
	protected function convertDumpToArray( $metaDump, $infoDump ): array {
		if ( strval( $infoDump ) === '' ) {
			return [];
		}

		$lines = explode( "\n", $infoDump );
		$data = [];

		// Metadata is always the last item, and spans multiple lines.
		$inMetadata = false;

		// Basically this loop will go through each line, splitting key value
		// pairs on the colon, until it gets to a "Metadata:\n" at which point
		// it will gather all remaining lines into the xmp key.
		foreach ( $lines as $line ) {
			if ( $inMetadata ) {
				// Handle XMP differently due to difference in line break
				$data['xmp'] .= "\n$line";
				continue;
			}
			$bits = explode( ':', $line, 2 );
			if ( count( $bits ) > 1 ) {
				$key = trim( $bits[0] );
				if ( $key === 'Metadata' ) {
					$inMetadata = true;
					$data['xmp'] = '';
					continue;
				}
				$value = trim( $bits[1] );
				$matches = [];
				// "Page xx rot" will be in poppler 0.20's pdfinfo output
				// See https://bugs.freedesktop.org/show_bug.cgi?id=41867
				if ( preg_match( '/^Page +(\d+) (size|rot)$/', $key, $matches ) ) {
					$data['pages'][$matches[1]][$matches[2] == 'size' ? 'Page size' : 'Page rot'] = $value;
				} else {
					$data[$key] = $value;
				}
			}
		}
		$metaDump = trim( $metaDump );
		if ( $metaDump !== '' ) {
			$data['xmp'] = $metaDump;
		}

		return $this->postProcessDump( $data );
	}

	/**
	 * Postprocess the metadata (convert xmp into useful form, etc)
	 *
	 * This is used to generate the metadata table at the bottom
	 * of the image description page.
	 *
	 * @param array $data metadata
	 * @return array post-processed metadata
	 */
	protected function postProcessDump( array $data ) {
		$meta = new BitmapMetadataHandler();
		$items = [];
		foreach ( $data as $key => $val ) {
			switch ( $key ) {
				case 'Title':
					$items['ObjectName'] = $val;
					break;
				case 'Subject':
					$items['ImageDescription'] = $val;
					break;
				case 'Keywords':
					// Sometimes we have empty keywords. This seems
					// to be a product of how pdfinfo deals with keywords
					// with spaces in them. Filter such empty keywords
					$keyList = array_filter( explode( ' ', $val ) );
					if ( count( $keyList ) > 0 ) {
						$items['Keywords'] = $keyList;
					}
					break;
				case 'Author':
					$items['Artist'] = $val;
					break;
				case 'Creator':
					// Program used to create file.
					// Different from program used to convert to pdf.
					$items['Software'] = $val;
					break;
				case 'Producer':
					// Conversion program
					$items['pdf-Producer'] = $val;
					break;
				case 'ModTime':
					$timestamp = wfTimestamp( TS_EXIF, $val );
					if ( $timestamp ) {
						// 'if' is just paranoia
						$items['DateTime'] = $timestamp;
					}
					break;
				case 'CreationTime':
					$timestamp = wfTimestamp( TS_EXIF, $val );
					if ( $timestamp ) {
						$items['DateTimeDigitized'] = $timestamp;
					}
					break;
				// These last two (version and encryption) I was unsure
				// if we should include in the table, since they aren't
				// all that useful to editors. I leaned on the side
				// of including. However not including if file
				// is optimized/linearized since that is really useless
				// to an editor.
				case 'PDF version':
					$items['pdf-Version'] = $val;
					break;
				case 'Encrypted':
					$items['pdf-Encrypted'] = $val;
					break;
				// Note 'pages' and 'Pages' are different keys (!)
				case 'pages':
					// A pdf document can have multiple sized pages in it.
					// (However 95% of the time, all pages are the same size)
					// get a list of all the unique page sizes in document.
					// This doesn't do anything with rotation as of yet,
					// mostly because I am unsure of what a good way to
					// present that information to the user would be.
					$pageSizes = [];
					foreach ( $val as $page ) {
						if ( isset( $page['Page size'] ) ) {
							$pageSizes[$page['Page size']] = true;
						}
					}

					$pageSizeArray = array_keys( $pageSizes );
					if ( count( $pageSizeArray ) > 0 ) {
						$items['pdf-PageSize'] = $pageSizeArray;
					}
					break;
			}

		}
		$meta->addMetadata( $items, 'native' );

		if ( isset( $data['xmp'] ) && XMPReader::isSupported() ) {
			// @todo: This only handles generic xmp properties. Would be improved
			// by handling pdf xmp properties (pdf and pdfx) via a hook.
			$xmp = new XMPReader( LoggerFactory::getInstance( 'XMP' ) );
			$xmp->parse( $data['xmp'] );
			$xmpRes = $xmp->getResults();
			foreach ( $xmpRes as $type => $xmpSection ) {
				$meta->addMetadata( $xmpSection, $type );
			}
		}
		unset( $data['xmp'] );
		$data['mergedMetadata'] = $meta->getMetadataArray();
		return $data;
	}
}
PK       ! us
  
    Poem.phpnu Iw        <?php

namespace MediaWiki\Extension\Poem;

use MediaWiki\Hook\ParserFirstCallInitHook;
use MediaWiki\Html\Html;
use MediaWiki\Parser\Parser;
use MediaWiki\Parser\PPFrame;
use MediaWiki\Parser\Sanitizer;

/**
 * This class handles formatting poems in WikiText, specifically anything within
 * <poem></poem> tags.
 *
 * @license CC0-1.0
 * @author Nikola Smolenski <smolensk@eunet.yu>
 */
class Poem implements ParserFirstCallInitHook {
	/**
	 * Bind the renderPoem function to the <poem> tag
	 * @param Parser $parser
	 */
	public function onParserFirstCallInit( $parser ) {
		$parser->setHook( 'poem', [ $this, 'renderPoem' ] );
	}

	/**
	 * Parse the text into proper poem format
	 * @param string|null $in The text inside the poem tag
	 * @param string[] $param
	 * @param Parser $parser
	 * @param PPFrame $frame
	 * @return string
	 */
	public function renderPoem( $in, array $param, Parser $parser, PPFrame $frame ) {
		// using newlines in the text will cause the parser to add <p> tags,
		// which may not be desired in some cases
		$newline = isset( $param['compact'] ) ? '' : "\n";

		$tag = $parser->insertStripItem( "<br />" );

		// replace colons with indented spans
		$text = preg_replace_callback(
			'/^(:++)(.+)$/m',
			static function ( array $matches ) {
				$indentation = strlen( $matches[1] ) . 'em';
				return Html::rawElement(
					'span',
					[
						'class' => 'mw-poem-indented',
						'style' => 'display: inline-block; ' .
							"margin-inline-start: $indentation;",
					],
					$matches[2]
				);
			},
			$in ?? ''
		);

		// replace newlines with <br /> tags unless they are at the beginning or end
		// of the poem, or would directly follow exactly 4 dashes. See Parser::internalParse() for
		// the exact syntax for horizontal rules.
		$text = preg_replace(
			[ '/^\n/', '/\n$/D', '/(?<!^----)\n/m' ],
			[ "", "", "$tag\n" ],
			$text
		);

		// replace spaces at the beginning of a line with non-breaking spaces
		$text = preg_replace_callback(
			'/^ +/m',
			static function ( array $matches ) {
				return str_repeat( '&#160;', strlen( $matches[0] ) );
			},
			$text
		);

		$text = $parser->recursiveTagParse( $text, $frame );

		// Because of limitations of the regular expression above, horizontal rules with more than 4
		// dashes still need special handling.
		$text = str_replace( '<hr />' . $tag, '<hr />', $text );

		$attribs = Sanitizer::validateTagAttributes( $param, 'div' );

		// Wrap output in a <div> with "poem" class.
		if ( isset( $attribs['class'] ) ) {
			$attribs['class'] = 'poem ' . $attribs['class'];
		} else {
			$attribs['class'] = 'poem';
		}

		return Html::rawElement( 'div', $attribs, $newline . trim( $text ) . $newline );
	}
}
PK       ! QؗN  N    Parsoid/PoemProcessor.phpnu Iw        <?php
declare( strict_types = 1 );

namespace MediaWiki\Extension\Poem\Parsoid;

use Wikimedia\Parsoid\DOM\Element;
use Wikimedia\Parsoid\DOM\Node;
use Wikimedia\Parsoid\DOM\Text;
use Wikimedia\Parsoid\Ext\DOMProcessor;
use Wikimedia\Parsoid\Ext\DOMUtils;
use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;

class PoemProcessor extends DOMProcessor {

	/**
	 * @inheritDoc
	 */
	public function wtPostprocess(
		ParsoidExtensionAPI $extApi, Node $node, array $options
	): void {
		$c = $node->firstChild;
		while ( $c ) {
			if ( $c instanceof Element ) {
				if ( DOMUtils::hasTypeOf( $c, 'mw:Extension/poem' ) ) {
					// Replace newlines found in <nowiki> fragment with <br/>s
					self::processNowikis( $c );
				} else {
					$this->wtPostprocess( $extApi, $c, $options );
				}
			}
			$c = $c->nextSibling;
		}
	}

	private function processNowikis( Element $node ): void {
		$doc = $node->ownerDocument;
		$c = $node->firstChild;
		while ( $c ) {
			if ( !$c instanceof Element ) {
				$c = $c->nextSibling;
				continue;
			}

			if ( !DOMUtils::hasTypeOf( $c, 'mw:Nowiki' ) ) {
				self::processNowikis( $c );
				$c = $c->nextSibling;
				continue;
			}

			// Replace the nowiki's text node with a combination
			// of content and <br/>s. Take care to deal with
			// entities that are still entity-wrapped (!!).
			$cc = $c->firstChild;
			while ( $cc ) {
				$next = $cc->nextSibling;
				if ( $cc instanceof Text ) {
					$pieces = preg_split( '/\n/', $cc->nodeValue ?? '' );
					$n = count( $pieces );
					$nl = '';
					for ( $i = 0;  $i < $n;  $i++ ) {
						$p = $pieces[$i];
						$c->insertBefore( $doc->createTextNode( $nl . $p ), $cc );
						if ( $i < $n - 1 ) {
							$c->insertBefore( $doc->createElement( 'br' ), $cc );
							$nl = "\n";
						}
					}
					$c->removeChild( $cc );
				}
				$cc = $next;
			}
			$c = $c->nextSibling;
		}
	}
}
PK       ! %%  %    Parsoid/Poem.phpnu Iw        <?php
declare( strict_types = 1 );

namespace MediaWiki\Extension\Poem\Parsoid;

use Wikimedia\Parsoid\DOM\DocumentFragment;
use Wikimedia\Parsoid\Ext\ExtensionTagHandler;
use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
use Wikimedia\Parsoid\Ext\PHPUtils;
use Wikimedia\Parsoid\Utils\DOMCompat;

class Poem extends ExtensionTagHandler {
	/** @inheritDoc */
	public function sourceToDom(
		ParsoidExtensionAPI $extApi, string $content, array $extArgs
	): DocumentFragment {
		/*
		 * Transform wikitext found in <poem>...</poem>
		 * 1. Strip leading & trailing newlines
		 * 2. Suppress indent-pre by replacing leading space with &nbsp;
		 * 3. Replace colons with <span class='...' style='...'>...</span>
		 * 4. Add <br/> for newlines except (a) in nowikis (b) after ----
		 */

		if ( strlen( $content ) > 0 ) {
			// 1. above
			$content = PHPUtils::stripPrefix( $content, "\n" );
			$content = PHPUtils::stripSuffix( $content, "\n" );

			// 2. above
			$content = preg_replace( '/^ /m', '&nbsp;', $content );

			// 3. above
			$contentArray = explode( "\n", $content );
			$contentMap = array_map( static function ( $line ) use ( $extApi ) {
				$i = 0;
				$lineLength = strlen( $line );
				while ( $i < $lineLength && $line[$i] === ':' ) {
					$i++;
				}
				if ( $i > 0 && $i < $lineLength ) {
					$domFragment = $extApi->htmlToDom( '' );
					$doc = $domFragment->ownerDocument;
					$span = $doc->createElement( 'span' );
					$span->setAttribute( 'class', 'mw-poem-indented' );
					$span->setAttribute( 'style', 'display: inline-block; margin-inline-start: ' . $i . 'em;' );
					// $line isn't an HTML text node, it's wikitext that will be passed to extTagToDOM
					return substr( DOMCompat::getOuterHTML( $span ), 0, -7 ) .
						ltrim( $line, ':' ) . '</span>';
				} else {
					return $line;
				}
			}, $contentArray );
			// TODO: Use faster? preg_replace
			$content = implode( "\n", $contentMap );

			// 4. above
			// Split on <nowiki>..</nowiki> fragments.
			// Process newlines inside nowikis in a post-processing pass.
			// If <br/>s are added here, Parsoid will escape them to plaintext.
			$splitContent = preg_split( '/(<nowiki>[\s\S]*?<\/nowiki>)/', $content,
				-1, PREG_SPLIT_DELIM_CAPTURE );
			$content = implode( '',
				array_map( static function ( $p, $i ) {
					if ( $i % 2 === 1 ) {
						return $p;
					}

					// This is a hack that exploits the fact that </poem>
					// cannot show up in the extension's content.
					return preg_replace( '/^(-+)<\/poem>/m', "\$1\n",
						preg_replace( '/\n/m', "<br/>\n",
							preg_replace( '/(^----+)\n/m', '$1</poem>', $p ) ) );
				},
				$splitContent,
				range( 0, count( $splitContent ) - 1 ) )
			);

		}

		// Add the 'poem' class to the 'class' attribute, or if not found, add it
		$value = $extApi->findAndUpdateArg( $extArgs, 'class', static function ( string $value ) {
			return strlen( $value ) ? "poem {$value}" : 'poem';
		} );

		if ( !$value ) {
			$extApi->addNewArg( $extArgs, 'class', 'poem' );
		}

		return $extApi->extTagToDOM( $extArgs, $content, [
				'wrapperTag' => 'div',
				'parseOpts' => [ 'extTag' => 'poem' ],
				// Create new frame, because $content doesn't literally appear in
				// the parent frame's sourceText (our copy has been munged)
				'processInNewFrame' => true,
				// We've shifted the content around quite a bit when we preprocessed
				// it.  In the future if we wanted to enable selser inside the <poem>
				// body we should create a proper offset map and then apply it to the
				// result after the parse, like we do in the Gallery extension.
				// But for now, since we don't selser the contents, just strip the
				// DSR info so it doesn't cause problems/confusion with unicode
				// offset conversion (and so it's clear you can't selser what we're
				// currently emitting).
				'clearDSROffsets' => true
			]
		);
	}
}
PK       ! H      SpamBlacklist.phpnu Iw        <?php

namespace MediaWiki\Extension\SpamBlacklist;

use LogPage;
use ManualLogEntry;
use MediaWiki\CheckUser\Hooks as CUHooks;
use MediaWiki\Context\RequestContext;
use MediaWiki\ExternalLinks\ExternalLinksLookup;
use MediaWiki\MediaWikiServices;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use Wikimedia\AtEase\AtEase;
use Wikimedia\Rdbms\Database;

class SpamBlacklist extends BaseBlacklist {
	private const STASH_TTL = 180;
	private const STASH_AGE_DYING = 150;

	/**
	 * Returns the code for the blacklist implementation
	 *
	 * @return string
	 */
	protected function getBlacklistType() {
		return 'spam';
	}

	/**
	 * Apply some basic anti-spoofing to the links before they get filtered,
	 * see @bug 12896
	 *
	 * @param string $text
	 *
	 * @return string
	 */
	protected function antiSpoof( $text ) {
		$text = str_replace( '．', '.', $text );
		return $text;
	}

	/**
	 * @param string[] $links An array of links to check against the blacklist
	 * @param ?Title $title The title of the page to which the filter shall be applied.
	 *               This is used to load the old links already on the page, so
	 *               the filter is only applied to links that got added. If not given,
	 *               the filter is applied to all $links.
	 * @param User $user Relevant user
	 * @param bool $preventLog Whether to prevent logging of hits. Set to true when
	 *               the action is testing the links rather than attempting to save them
	 *               (e.g. the API spamblacklist action)
	 * @param string $mode Either 'check' or 'stash'
	 *
	 * @return string[]|bool Matched text(s) if the edit should not be allowed; false otherwise
	 */
	public function filter(
		array $links,
		?Title $title,
		User $user,
		$preventLog = false,
		$mode = 'check'
	) {
		$services = MediaWikiServices::getInstance();
		$statsd = $services->getStatsdDataFactory();
		$cache = $services->getObjectCacheFactory()->getLocalClusterInstance();

		if ( !$links ) {
			return false;
		}

		sort( $links );
		$key = $cache->makeKey(
			'blacklist',
			$this->getBlacklistType(),
			'pass',
			sha1( implode( "\n", $links ) ),
			md5( (string)$title )
		);
		// Skip blacklist checks if nothing matched during edit stashing...
		$knownNonMatchAsOf = $cache->get( $key );
		if ( $mode === 'check' ) {
			if ( $knownNonMatchAsOf ) {
				$statsd->increment( 'spamblacklist.check-stash.hit' );

				return false;
			} else {
				$statsd->increment( 'spamblacklist.check-stash.miss' );
			}
		} elseif ( $mode === 'stash' ) {
			if ( $knownNonMatchAsOf && ( time() - $knownNonMatchAsOf ) < self::STASH_AGE_DYING ) {
				// OK; not about to expire soon
				return false;
			}
		}

		$blacklists = $this->getBlacklists();
		$whitelists = $this->getWhitelists();

		if ( count( $blacklists ) ) {
			// poor man's anti-spoof, see bug 12896
			$newLinks = array_map( [ $this, 'antiSpoof' ], $links );

			$oldLinks = [];
			if ( $title !== null ) {
				$oldLinks = $this->getCurrentLinks( $title );
				$addedLinks = array_diff( $newLinks, $oldLinks );
			} else {
				// can't load old links, so treat all links as added.
				$addedLinks = $newLinks;
			}

			wfDebugLog( 'SpamBlacklist', "Old URLs: " . implode( ', ', $oldLinks ) );
			wfDebugLog( 'SpamBlacklist', "New URLs: " . implode( ', ', $newLinks ) );
			wfDebugLog( 'SpamBlacklist', "Added URLs: " . implode( ', ', $addedLinks ) );

			$links = implode( "\n", $addedLinks );

			# Strip whitelisted URLs from the match
			if ( is_array( $whitelists ) ) {
				wfDebugLog( 'SpamBlacklist', "Excluding whitelisted URLs from " . count( $whitelists ) .
					" regexes: " . implode( ', ', $whitelists ) . "\n" );
				foreach ( $whitelists as $regex ) {
					AtEase::suppressWarnings();
					$newLinks = preg_replace( $regex, '', $links );
					AtEase::restoreWarnings();
					if ( is_string( $newLinks ) ) {
						// If there wasn't a regex error, strip the matching URLs
						$links = $newLinks;
					}
				}
			}

			# Do the match
			wfDebugLog( 'SpamBlacklist', "Checking text against " . count( $blacklists ) .
				" regexes: " . implode( ', ', $blacklists ) . "\n" );
			$retVal = false;
			foreach ( $blacklists as $regex ) {
				AtEase::suppressWarnings();
				$matches = [];
				$check = ( preg_match_all( $regex, $links, $matches ) > 0 );
				AtEase::restoreWarnings();
				if ( $check ) {
					wfDebugLog( 'SpamBlacklist', "Match!\n" );
					$ip = RequestContext::getMain()->getRequest()->getIP();
					$fullUrls = [];
					$fullLineRegex = substr( $regex, 0, strrpos( $regex, '/' ) ) . '.*/Sim';
					preg_match_all( $fullLineRegex, $links, $fullUrls );
					$imploded = implode( ' ', $fullUrls[0] );
					wfDebugLog( 'SpamBlacklistHit', "$ip caught submitting spam: $imploded\n" );
					if ( !$preventLog && $title ) {
						$this->logFilterHit( $user, $title, $imploded );
					}
					if ( $retVal === false ) {
						$retVal = [];
					}
					$retVal = array_merge( $retVal, $fullUrls[1] );
				}
			}
			if ( is_array( $retVal ) ) {
				$retVal = array_unique( $retVal );
			}
		} else {
			$retVal = false;
		}

		if ( $retVal === false ) {
			// Cache the typical negative results
			$cache->set( $key, time(), self::STASH_TTL );
			if ( $mode === 'stash' ) {
				$statsd->increment( 'spamblacklist.check-stash.store' );
			}
		}

		return $retVal;
	}

	/**
	 * Look up the links currently in the article, so we can
	 * ignore them on a second run.
	 *
	 * WARNING: I can add more *of the same link* with no problem here.
	 * @param Title $title
	 * @return array
	 */
	public function getCurrentLinks( Title $title ) {
		$cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
		$fname = __METHOD__;
		return $cache->getWithSetCallback(
			// Key is warmed via warmCachesForFilter() from ApiStashEdit
			$cache->makeKey( 'external-link-list', $title->getLatestRevID() ),
			$cache::TTL_MINUTE,
			static function ( $oldValue, &$ttl, array &$setOpts ) use ( $title, $fname ) {
				$dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
				$setOpts += Database::getCacheSetOptions( $dbr );
				return ExternalLinksLookup::getExternalLinksForPage(
					$title->getArticleID(),
					$dbr,
					$fname
				);
			}
		);
	}

	public function warmCachesForFilter( Title $title, array $entries, User $user ) {
		$this->filter(
			$entries,
			$title,
			$user,
			// no logging
			true,
			'stash'
		);
	}

	/**
	 * Returns the start of the regex for matches
	 *
	 * @return string
	 */
	public function getRegexStart() {
		return '/(?:https?:)?\/\/+[a-z0-9_\-.]*(';
	}

	/**
	 * Returns the end of the regex for matches
	 *
	 * @param int $batchSize
	 * @return string
	 */
	public function getRegexEnd( $batchSize ) {
		return ')' . parent::getRegexEnd( $batchSize );
	}

	/**
	 * Logs the filter hit to Special:Log if
	 * $wgLogSpamBlacklistHits is enabled.
	 *
	 * @param User $user
	 * @param Title $title
	 * @param string $url URL that the user attempted to add
	 */
	public function logFilterHit( User $user, $title, $url ) {
		global $wgLogSpamBlacklistHits;
		if ( $wgLogSpamBlacklistHits ) {
			$logEntry = new ManualLogEntry( 'spamblacklist', 'hit' );
			$logEntry->setPerformer( $user );
			$logEntry->setTarget( $title );
			$logEntry->setParameters( [
				'4::url' => $url,
			] );
			$logid = $logEntry->insert();
			$log = new LogPage( 'spamblacklist' );
			if ( $log->isRestricted() ) {
				// Make sure checkusers can see this action if the log is restricted
				// (which is the default)
				if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) ) {
					$rc = $logEntry->getRecentChange( $logid );
					CUHooks::updateCheckUserData( $rc );
				}
			} else {
				// If the log is unrestricted, publish normally to RC,
				// which will also update checkuser
				$logEntry->publish( $logid, "rc" );
			}
		}
	}
}
PK       ! J	  	    ApiSpamBlacklist.phpnu Iw        <?php
/**
 * SpamBlacklist extension API
 *
 * Copyright © 2013 Wikimedia Foundation
 * Based on code by Ian Baker, Victor Vasiliev, Bryan Tong Minh, Roan Kattouw,
 * Alex Z., and Jackmcbarn
 *
 * 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
 */

namespace MediaWiki\Extension\SpamBlacklist;

use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiResult;
use Wikimedia\ParamValidator\ParamValidator;

/**
 * Query module check a URL against the blacklist
 *
 * @ingroup API
 * @ingroup Extensions
 */
class ApiSpamBlacklist extends ApiBase {
	public function execute() {
		$params = $this->extractRequestParams();
		$matches = BaseBlacklist::getSpamBlacklist()->filter(
			$params['url'],
			null,
			$this->getUser(),
			true
		);
		$res = $this->getResult();

		if ( $matches !== false ) {
			// this url is blacklisted.
			$res->addValue( 'spamblacklist', 'result', 'blacklisted' );
			ApiResult::setIndexedTagName( $matches, 'match' );
			$res->addValue( 'spamblacklist', 'matches', $matches );
		} else {
			// not blacklisted
			$res->addValue( 'spamblacklist', 'result', 'ok' );
		}
	}

	public function getAllowedParams() {
		return [
			'url' => [
				ParamValidator::PARAM_REQUIRED => true,
				ParamValidator::PARAM_ISMULTI => true,
			]
		];
	}

	/**
	 * @see ApiBase::getExamplesMessages()
	 * @return array
	 */
	protected function getExamplesMessages() {
		return [
			'action=spamblacklist&url=http://www.example.com/|http://www.example.org/'
				=> 'apihelp-spamblacklist-example-1',
		];
	}

	public function getHelpUrls() {
		return [ 'https://www.mediawiki.org/wiki/Extension:SpamBlacklist/API' ];
	}
}
PK       ! E  E    EmailBlacklist.phpnu Iw        <?php

namespace MediaWiki\Extension\SpamBlacklist;

use LogicException;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use Wikimedia\AtEase\AtEase;

/**
 * Email Blacklisting
 */
class EmailBlacklist extends BaseBlacklist {
	/**
	 * @inheritDoc
	 * @suppress PhanPluginNeverReturnMethod LSP/ISP violation
	 */
	public function filter( array $links, ?Title $title, User $user, $preventLog = false ) {
		throw new LogicException( __CLASS__ . ' cannot be used to filter links.' );
	}

	/**
	 * Returns the code for the blacklist implementation
	 *
	 * @return string
	 */
	protected function getBlacklistType() {
		return 'email';
	}

	/**
	 * Checks a User object for a blacklisted email address
	 *
	 * @param User $user
	 * @return bool True on valid email
	 */
	public function checkUser( User $user ) {
		$blacklists = $this->getBlacklists();
		$whitelists = $this->getWhitelists();

		// The email to check
		$email = $user->getEmail();

		if ( !count( $blacklists ) ) {
			// Nothing to check
			return true;
		}

		// Check for whitelisted email addresses
		if ( is_array( $whitelists ) ) {
			wfDebugLog( 'SpamBlacklist', "Excluding whitelisted email addresses from " .
				count( $whitelists ) . " regexes: " . implode( ', ', $whitelists ) . "\n" );
			foreach ( $whitelists as $regex ) {
				AtEase::suppressWarnings();
				$match = preg_match( $regex, $email );
				AtEase::restoreWarnings();
				if ( $match ) {
					// Whitelisted email
					return true;
				}
			}
		}

		# Do the match
		wfDebugLog( 'SpamBlacklist', "Checking e-mail address against " . count( $blacklists ) .
			" regexes: " . implode( ', ', $blacklists ) . "\n" );
		foreach ( $blacklists as $regex ) {
			AtEase::suppressWarnings();
			$match = preg_match( $regex, $email );
			AtEase::restoreWarnings();
			if ( $match ) {
				return false;
			}
		}

		return true;
	}
}
PK       ! +,      SpamBlacklistLogFormatter.phpnu Iw        <?php

namespace MediaWiki\Extension\SpamBlacklist;

use LogFormatter;
use MediaWiki\Message\Message;

class SpamBlacklistLogFormatter extends LogFormatter {
	/**
	 * @return array
	 * @suppress SecurityCheck-DoubleEscaped Known taint-check bug
	 */
	protected function getMessageParameters() {
		$params = parent::getMessageParameters();
		$params[3] = Message::rawParam( htmlspecialchars( $params[3] ) );
		return $params;
	}

}
PK       !     *  SpamBlacklistPreAuthenticationProvider.phpnu Iw        <?php

namespace MediaWiki\Extension\SpamBlacklist;

use MediaWiki\Auth\AbstractPreAuthenticationProvider;
use StatusValue;

class SpamBlacklistPreAuthenticationProvider extends AbstractPreAuthenticationProvider {
	public function testForAccountCreation( $user, $creator, array $reqs ) {
		$blacklist = BaseBlacklist::getEmailBlacklist();
		if ( $blacklist->checkUser( $user ) ) {
			return StatusValue::newGood();
		}

		return StatusValue::newFatal( 'spam-blacklisted-email-signup' );
	}
}
PK       ! cVJ/  J/    BaseBlacklist.phpnu Iw        <?php

namespace MediaWiki\Extension\SpamBlacklist;

use InvalidArgumentException;
use MediaWiki\Content\TextContent;
use MediaWiki\MediaWikiServices;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Title\Title;
use MediaWiki\User\User;

/**
 * Base class for different kinds of blacklists
 */
abstract class BaseBlacklist {
	/**
	 * Array of blacklist sources
	 *
	 * @var string[]
	 */
	public $files = [];

	/**
	 * Array containing regexes to test against
	 *
	 * @var string[]|false
	 */
	protected $regexes = false;

	/**
	 * Chance of receiving a warning when the filter is hit
	 *
	 * @var int
	 */
	public $warningChance = 100;

	/**
	 * @var int
	 */
	public $warningTime = 600;

	/**
	 * @var int
	 */
	public $expiryTime = 900;

	/**
	 * Array containing blacklists that extend BaseBlacklist
	 *
	 * @var string[]
	 */
	private static $blacklistTypes = [
		'spam' => SpamBlacklist::class,
		'email' => EmailBlacklist::class,
	];

	/**
	 * Array of blacklist instances
	 *
	 * @var self[]
	 */
	private static $instances = [];

	/**
	 * @param array $settings
	 */
	public function __construct( $settings = [] ) {
		foreach ( $settings as $name => $value ) {
			$this->$name = $value;
		}
	}

	/**
	 * @param array $links
	 * @param ?Title $title
	 * @param User $user
	 * @param bool $preventLog
	 * @return mixed
	 */
	abstract public function filter(
		array $links,
		?Title $title,
		User $user,
		$preventLog = false
	);

	/**
	 * Adds a blacklist class to the registry
	 *
	 * @param string $type
	 * @param string $class
	 */
	public static function addBlacklistType( $type, $class ) {
		self::$blacklistTypes[$type] = $class;
	}

	/**
	 * Return the array of blacklist types currently defined
	 *
	 * @return string[]
	 */
	public static function getBlacklistTypes() {
		return self::$blacklistTypes;
	}

	/**
	 * @return SpamBlacklist
	 */
	public static function getSpamBlacklist() {
		// @phan-suppress-next-line PhanTypeMismatchReturnSuperType
		return self::getInstance( 'spam' );
	}

	/**
	 * @return EmailBlacklist
	 */
	public static function getEmailBlacklist() {
		// @phan-suppress-next-line PhanTypeMismatchReturnSuperType
		return self::getInstance( 'email' );
	}

	/**
	 * Returns an instance of the given blacklist
	 *
	 * @deprecated Use getSpamBlacklist() or getEmailBlacklist() instead
	 * @param string $type Code for the blacklist
	 * @return BaseBlacklist
	 */
	public static function getInstance( $type ) {
		if ( !isset( self::$blacklistTypes[$type] ) ) {
			throw new InvalidArgumentException( "Invalid blacklist type '$type' passed to " . __METHOD__ );
		}

		if ( !isset( self::$instances[$type] ) ) {
			global $wgBlacklistSettings;

			// Prevent notices
			if ( !isset( $wgBlacklistSettings[$type] ) ) {
				$wgBlacklistSettings[$type] = [];
			}

			$class = self::$blacklistTypes[$type];
			self::$instances[$type] = new $class( $wgBlacklistSettings[$type] );
		}

		return self::$instances[$type];
	}

	/**
	 * Clear instance cache. For use during testing.
	 */
	public static function clearInstanceCache() {
		self::$instances = [];
	}

	/**
	 * Returns the code for the blacklist implementation
	 *
	 * @return string
	 */
	abstract protected function getBlacklistType();

	/**
	 * Check if the given local page title is a spam regex source.
	 *
	 * @param Title $title
	 * @return bool
	 */
	public static function isLocalSource( Title $title ) {
		global $wgDBname, $wgBlacklistSettings;

		if ( $title->inNamespace( NS_MEDIAWIKI ) ) {
			$sources = [];
			foreach ( self::$blacklistTypes as $type => $class ) {
				// For the built in types, this results in the use of:
				// spam-blacklist, spam-whitelist
				// email-blacklist, email-whitelist
				$type = ucfirst( $type );
				$sources[] = "$type-blacklist";
				$sources[] = "$type-whitelist";
			}

			if ( in_array( $title->getDBkey(), $sources ) ) {
				return true;
			}
		}

		$thisHttp = wfExpandUrl( $title->getFullUrl( 'action=raw' ), PROTO_HTTP );
		$thisHttpRegex = '/^' . preg_quote( $thisHttp, '/' ) . '(?:&.*)?$/';

		$files = [];
		foreach ( self::$blacklistTypes as $type => $class ) {
			if ( isset( $wgBlacklistSettings[$type]['files'] ) ) {
				$files += $wgBlacklistSettings[$type]['files'];
			}
		}

		foreach ( $files as $fileName ) {
			$matches = [];
			if ( preg_match( '/^DB: (\w*) (.*)$/', $fileName, $matches ) ) {
				if ( $wgDBname === $matches[1] && $matches[2] === $title->getPrefixedDbKey() ) {
					// Local DB fetch of this page...
					return true;
				}
			} elseif ( preg_match( $thisHttpRegex, $fileName ) ) {
				// Raw view of this page
				return true;
			}
		}

		return false;
	}

	/**
	 * Returns the type of blacklist from the given title
	 *
	 * @todo building a regex for this is pretty overkill
	 * @param Title $title
	 * @return bool|string
	 */
	public static function getTypeFromTitle( Title $title ) {
		$contLang = MediaWikiServices::getInstance()->getContentLanguage();

		$types = array_map( [ $contLang, 'ucfirst' ], array_keys( self::$blacklistTypes ) );
		$regex = '/(' . implode( '|', $types ) . ')-(?:blacklist|whitelist)/';

		if ( preg_match( $regex, $title->getDBkey(), $m ) ) {
			return strtolower( $m[1] );
		}

		return false;
	}

	/**
	 * Fetch local and (possibly cached) remote blacklists.
	 * Will be cached locally across multiple invocations.
	 * @return string[] set of regular expressions, potentially empty.
	 */
	public function getBlacklists() {
		if ( $this->regexes === false ) {
			$this->regexes = array_merge(
				$this->getLocalBlacklists(),
				$this->getSharedBlacklists()
			);
		}
		return $this->regexes;
	}

	/**
	 * Returns the local blacklist
	 *
	 * @return string[] Regular expressions
	 */
	public function getLocalBlacklists() {
		$type = $this->getBlacklistType();
		$cache = MediaWikiServices::getInstance()->getMainWANObjectCache();

		return $cache->getWithSetCallback(
			$cache->makeKey( 'spamblacklist', $type, 'blacklist-regex' ),
			$this->expiryTime,
			function () use ( $type ) {
				return SpamRegexBatch::regexesFromMessage( "{$type}-blacklist", $this );
			}
		);
	}

	/**
	 * Returns the (local) whitelist
	 *
	 * @return string[] Regular expressions
	 */
	public function getWhitelists() {
		$type = $this->getBlacklistType();
		$cache = MediaWikiServices::getInstance()->getMainWANObjectCache();

		return $cache->getWithSetCallback(
			$cache->makeKey( 'spamblacklist', $type, 'whitelist-regex' ),
			$this->expiryTime,
			function () use ( $type ) {
				return SpamRegexBatch::regexesFromMessage( "{$type}-whitelist", $this );
			}
		);
	}

	/**
	 * Fetch (possibly cached) remote blacklists.
	 * @return array
	 */
	private function getSharedBlacklists() {
		$listType = $this->getBlacklistType();

		wfDebugLog( 'SpamBlacklist', "Loading $listType regex..." );

		if ( !$this->files ) {
			# No lists
			wfDebugLog( 'SpamBlacklist', "no files specified\n" );
			return [];
		}

		if ( defined( 'MW_PHPUNIT_TEST' ) ) {
			wfDebugLog( 'SpamBlacklist', 'remote loading disabled during PHPUnit test' );
			return [];
		}

		$miss = false;
		$cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
		$regexes = $cache->getWithSetCallback(
			// This used to be cached per-site, but that could be bad on a shared
			// server where not all wikis have the same configuration.
			$cache->makeKey( 'spamblacklist', $listType, 'shared-blacklist-regex' ),
			$this->expiryTime,
			function () use ( &$miss ) {
				$miss = true;
				return $this->buildSharedBlacklists();
			}
		);

		if ( !$miss ) {
			wfDebugLog( 'SpamBlacklist', "Got shared spam regexes from cache\n" );
		}

		return $regexes;
	}

	/**
	 * Clear all primary blacklist cache keys
	 */
	public function clearCache() {
		$listType = $this->getBlacklistType();

		$cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
		$cache->delete( $cache->makeKey( 'spamblacklist', $listType, 'shared-blacklist-regex' ) );
		$cache->delete( $cache->makeKey( 'spamblacklist', $listType, 'blacklist-regex' ) );
		$cache->delete( $cache->makeKey( 'spamblacklist', $listType, 'whitelist-regex' ) );

		wfDebugLog( 'SpamBlacklist', "$listType blacklist local cache cleared.\n" );
	}

	private function buildSharedBlacklists() {
		$regexes = [];
		$listType = $this->getBlacklistType();
		# Load lists
		wfDebugLog( 'SpamBlacklist', "Constructing $listType blacklist\n" );
		foreach ( $this->files as $fileName ) {
			$matches = [];
			if ( preg_match( '/^DB: ([\w-]*) (.*)$/', $fileName, $matches ) ) {
				$text = $this->getArticleText( $matches[1], $matches[2] );
			} elseif ( preg_match( '/^(https?:)?\/\//', $fileName ) ) {
				$text = $this->getHttpText( $fileName );
			} else {
				$text = file_get_contents( $fileName );
				wfDebugLog( 'SpamBlacklist', "got from file $fileName\n" );
			}

			if ( $text ) {
				// Build a separate batch of regexes from each source.
				// While in theory we could squeeze a little efficiency
				// out of combining multiple sources in one regex, if
				// there's a bad line in one of them we'll gain more
				// from only having to break that set into smaller pieces.
				$regexes = array_merge(
					$regexes,
					SpamRegexBatch::regexesFromText( $text, $this, $fileName )
				);
			}
		}

		return $regexes;
	}

	private function getHttpText( $fileName ) {
		global $wgMessageCacheType;
		// FIXME: This is a hack to use Memcached where possible (incl. WMF),
		// but have CACHE_DB as fallback (instead of no cache).
		// This might be a good candidate for T248005.
		$services = MediaWikiServices::getInstance()->getObjectCacheFactory();
		$cache = $services->getInstance( $wgMessageCacheType );

		$listType = $this->getBlacklistType();
		// There are two keys, when the warning key expires, a random thread will refresh
		// the real key. This reduces the chance of multiple requests under high traffic
		// conditions.
		$key = $cache->makeGlobalKey( "blacklist_file_{$listType}", $fileName );
		$warningKey = $cache->makeKey( "filewarning_{$listType}", $fileName );
		$httpText = $cache->get( $key );
		$warning = $cache->get( $warningKey );

		if ( !is_string( $httpText ) || ( !$warning && !mt_rand( 0, $this->warningChance ) ) ) {
			wfDebugLog( 'SpamBlacklist', "Loading $listType blacklist from $fileName\n" );
			$httpText = MediaWikiServices::getInstance()->getHttpRequestFactory()
				->get( $fileName, [], __METHOD__ );
			if ( $httpText === false ) {
				wfDebugLog( 'SpamBlacklist', "Error loading $listType blacklist from $fileName\n" );
			}
			$cache->set( $warningKey, 1, $this->warningTime );
			$cache->set( $key, $httpText, $this->expiryTime );
		} else {
			wfDebugLog( 'SpamBlacklist', "Got $listType blacklist from HTTP cache for $fileName\n" );
		}
		return $httpText;
	}

	/**
	 * Fetch an article from this or another local MediaWiki database.
	 *
	 * @param string $wiki
	 * @param string $pagename
	 * @return bool|string|null
	 */
	private function getArticleText( $wiki, $pagename ) {
		wfDebugLog( 'SpamBlacklist',
			"Fetching {$this->getBlacklistType()} blacklist from '$pagename' on '$wiki'...\n" );

		$services = MediaWikiServices::getInstance();

		// XXX: We do not know about custom namespaces on the target wiki here!
		$title = $services->getTitleParser()->parseTitle( $pagename );
		$store = $services->getRevisionStoreFactory()->getRevisionStore( $wiki );
		$rev = $store->getRevisionByTitle( $title );

		$content = $rev ? $rev->getContent( SlotRecord::MAIN ) : null;

		if ( !( $content instanceof TextContent ) ) {
			return false;
		}

		return $content->getText();
	}

	/**
	 * Returns the start of the regex for matches
	 *
	 * @return string
	 */
	public function getRegexStart() {
		return '/[a-z0-9_\-.]*';
	}

	/**
	 * Returns the end of the regex for matches
	 *
	 * @param int $batchSize
	 * @return string
	 */
	public function getRegexEnd( $batchSize ) {
		return ( $batchSize > 0 ) ? '/Sim' : '/im';
	}

	/**
	 * @param Title $title
	 * @param string[] $entries
	 * @param User $user
	 */
	public function warmCachesForFilter( Title $title, array $entries, User $user ) {
		// subclass this
	}
}
PK       ! l!      SpamRegexBatch.phpnu Iw        <?php

namespace MediaWiki\Extension\SpamBlacklist;

use Wikimedia\AtEase\AtEase;

/**
 * Utility class for working with blacklists
 */
class SpamRegexBatch {
	/**
	 * Build a set of regular expressions matching URLs with the list of regex fragments.
	 * Returns an empty list if the input list is empty.
	 *
	 * @param string[] $lines list of fragments which will match in URLs
	 * @param BaseBlacklist $blacklist
	 * @param int $batchSize largest allowed batch regex;
	 *                       if 0, will produce one regex per line
	 * @return string[]
	 */
	private static function buildRegexes( array $lines, BaseBlacklist $blacklist, $batchSize = 4096 ) {
		# Make regex
		# It's faster using the S modifier even though it will usually only be run once
		// $regex = 'https?://+[a-z0-9_\-.]*(' . implode( '|', $lines ) . ')';
		// return '/' . str_replace( '/', '\/', preg_replace('|\\\*/|', '/', $regex) ) . '/Sim';
		$regexes = [];
		$regexStart = $blacklist->getRegexStart();
		$regexEnd = $blacklist->getRegexEnd( $batchSize );
		$build = false;
		foreach ( $lines as $line ) {
			if ( substr( $line, -1, 1 ) == "\\" ) {
				// Final \ will break silently on the batched regexes.
				// Skip it here to avoid breaking the next line;
				// warnings from getBadLines() will still trigger on
				// edit to keep new ones from floating in.
				continue;
			}
			// FIXME: not very robust size check, but should work. :)
			if ( $build === false ) {
				$build = $line;
			} elseif ( strlen( $build ) + strlen( $line ) > $batchSize ) {
				$regexes[] = $regexStart .
					str_replace( '/', '\/', preg_replace( '|\\\*/|u', '/', $build ) ) .
					$regexEnd;
				$build = $line;
			} else {
				$build .= '|';
				$build .= $line;
			}
		}
		if ( $build !== false ) {
			$regexes[] = $regexStart .
				str_replace( '/', '\/', preg_replace( '|\\\*/|u', '/', $build ) ) .
				$regexEnd;
		}
		return $regexes;
	}

	/**
	 * Confirm that a set of regexes is either empty or valid.
	 *
	 * @param string[] $regexes set of regexes
	 * @return bool true if ok, false if contains invalid lines
	 */
	private static function validateRegexes( $regexes ) {
		foreach ( $regexes as $regex ) {
			AtEase::suppressWarnings();
			// @phan-suppress-next-line PhanParamSuspiciousOrder False positive
			$ok = preg_match( $regex, '' );
			AtEase::restoreWarnings();

			if ( $ok === false ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Strip comments and whitespace, then remove blanks
	 *
	 * @param string[] $lines
	 * @return string[]
	 */
	private static function stripLines( array $lines ) {
		return array_filter(
			array_map( 'trim',
				preg_replace( '/#.*$/', '',
					$lines )
			)
		);
	}

	/**
	 * Do a sanity check on the batch regex.
	 *
	 * @param string[] $lines unsanitized input lines
	 * @param BaseBlacklist $blacklist
	 * @param bool|string $fileName optional for debug reporting
	 * @return string[] of regexes
	 */
	private static function buildSafeRegexes( array $lines, BaseBlacklist $blacklist, $fileName = false ) {
		$lines = self::stripLines( $lines );
		$regexes = self::buildRegexes( $lines, $blacklist );
		if ( self::validateRegexes( $regexes ) ) {
			return $regexes;
		} else {
			// _Something_ broke... rebuild line-by-line; it'll be
			// slower if there's a lot of blacklist lines, but one
			// broken line won't take out hundreds of its brothers.
			if ( $fileName ) {
				wfDebugLog( 'SpamBlacklist', "Spam blacklist warning: bogus line in $fileName\n" );
			}
			return self::buildRegexes( $lines, $blacklist, 0 );
		}
	}

	/**
	 * Returns an array of invalid lines
	 *
	 * @param string[] $lines
	 * @param BaseBlacklist $blacklist
	 * @return string[] of input lines which produce invalid input, or empty array if no problems
	 */
	public static function getBadLines( $lines, BaseBlacklist $blacklist ) {
		$lines = self::stripLines( $lines );

		$badLines = [];
		foreach ( $lines as $line ) {
			if ( substr( $line, -1, 1 ) == "\\" ) {
				// Final \ will break silently on the batched regexes.
				$badLines[] = $line;
			}
		}

		$regexes = self::buildRegexes( $lines, $blacklist );
		if ( self::validateRegexes( $regexes ) ) {
			// No other problems!
			return $badLines;
		}

		// Something failed in the batch, so check them one by one.
		foreach ( $lines as $line ) {
			$regexes = self::buildRegexes( [ $line ], $blacklist );
			if ( !self::validateRegexes( $regexes ) ) {
				$badLines[] = $line;
			}
		}
		return $badLines;
	}

	/**
	 * Build a set of regular expressions from the given multiline input text,
	 * with empty lines and comments stripped.
	 *
	 * @param string $source
	 * @param BaseBlacklist $blacklist
	 * @param bool|string $fileName optional, for reporting of bad files
	 * @return string[] of regular expressions, potentially empty
	 */
	public static function regexesFromText( $source, BaseBlacklist $blacklist, $fileName = false ) {
		$lines = explode( "\n", $source );
		return self::buildSafeRegexes( $lines, $blacklist, $fileName );
	}

	/**
	 * Build a set of regular expressions from a MediaWiki message.
	 * Will be correctly empty if the message isn't present.
	 *
	 * @param string $message
	 * @param BaseBlacklist $blacklist
	 * @return string[] of regular expressions, potentially empty
	 */
	public static function regexesFromMessage( $message, BaseBlacklist $blacklist ) {
		$source = wfMessage( $message )->inContentLanguage();
		if ( !$source->isDisabled() ) {
			return self::regexesFromText( $source->plain(), $blacklist );
		} else {
			return [];
		}
	}
}
PK       ! .  .    jobqueue/JobQueueTest.phpnu Iw        <?php

use MediaWiki\MediaWikiServices;
use MediaWiki\WikiMap\WikiMap;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;

/**
 * @group JobQueue
 * @group medium
 * @group Database
 * @covers \JobQueue
 */
class JobQueueTest extends MediaWikiIntegrationTestCase {
	protected ?JobQueue $queueRand;
	protected ?JobQueue $queueRandTTL;
	protected ?JobQueue $queueTimestamp;
	protected ?JobQueue $queueTimestampTTL;
	protected ?JobQueue $queueFifo;
	protected ?JobQueue $queueFifoTTL;

	protected function setUp(): void {
		global $wgJobTypeConf;
		parent::setUp();

		$services = $this->getServiceContainer();
		if ( $this->getCliArg( 'use-jobqueue' ) ) {
			$name = $this->getCliArg( 'use-jobqueue' );
			if ( !isset( $wgJobTypeConf[$name] ) ) {
				throw new RuntimeException( "No \$wgJobTypeConf entry for '$name'." );
			}
			$baseConfig = $wgJobTypeConf[$name];
		} else {
			$baseConfig = [ 'class' => JobQueueDBSingle::class ];
		}
		$baseConfig['type'] = 'null';
		$baseConfig['domain'] = WikiMap::getCurrentWikiDbDomain()->getId();
		$baseConfig['stash'] = new HashBagOStuff();
		$baseConfig['wanCache'] = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
		$baseConfig['idGenerator'] = $services->getGlobalIdGenerator();
		$variants = [
			'queueRand' => [ 'order' => 'random', 'claimTTL' => 0 ],
			'queueRandTTL' => [ 'order' => 'random', 'claimTTL' => 10 ],
			'queueTimestamp' => [ 'order' => 'timestamp', 'claimTTL' => 0 ],
			'queueTimestampTTL' => [ 'order' => 'timestamp', 'claimTTL' => 10 ],
			'queueFifo' => [ 'order' => 'fifo', 'claimTTL' => 0 ],
			'queueFifoTTL' => [ 'order' => 'fifo', 'claimTTL' => 10 ],
		];
		foreach ( $variants as $q => $settings ) {
			$this->$q = JobQueue::factory( $settings + $baseConfig );
		}
	}

	protected function tearDown(): void {
		foreach (
			[
				'queueRand', 'queueRandTTL', 'queueTimestamp', 'queueTimestampTTL',
				'queueFifo', 'queueFifoTTL'
			] as $q
		) {
			if ( $this->$q ) {
				$this->$q->delete();
			}
			$this->$q = null;
		}
		parent::tearDown();
	}

	/**
	 * @dataProvider provider_queueLists
	 */
	public function testGetType( $queue, $recycles, $desc ) {
		$queue = $this->$queue;
		if ( !$queue ) {
			$this->markTestSkipped( $desc );
		}
		$this->assertEquals( 'null', $queue->getType(), "Proper job type ($desc)" );
	}

	/**
	 * @dataProvider provider_queueLists
	 */
	public function testBasicOperations( $queue, $recycles, $desc ) {
		$queue = $this->$queue;
		if ( !$queue ) {
			$this->markTestSkipped( $desc );
		}

		$this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" );

		$queue->flushCaches();
		$this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" );
		$this->assertSame( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" );

		$this->assertNull( $queue->push( $this->newJob() ), "Push worked ($desc)" );
		$this->assertNull( $queue->batchPush( [ $this->newJob() ] ), "Push worked ($desc)" );

		$this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" );

		$queue->flushCaches();
		$this->assertEquals( 2, $queue->getSize(), "Queue size is correct ($desc)" );
		$this->assertSame( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" );
		$jobs = iterator_to_array( $queue->getAllQueuedJobs() );
		$this->assertCount( 2, $jobs, "Queue iterator size is correct ($desc)" );

		$job1 = $queue->pop();
		$this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" );

		$queue->flushCaches();
		$this->assertSame( 1, $queue->getSize(), "Queue size is correct ($desc)" );

		$queue->flushCaches();
		if ( $recycles ) {
			$this->assertSame( 1, $queue->getAcquiredCount(), "Active job count ($desc)" );
		}

		$job2 = $queue->pop();
		$this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" );
		$this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" );

		$queue->flushCaches();
		if ( $recycles ) {
			$this->assertEquals( 2, $queue->getAcquiredCount(), "Active job count ($desc)" );
		}

		$queue->ack( $job1 );

		$queue->flushCaches();
		if ( $recycles ) {
			$this->assertSame( 1, $queue->getAcquiredCount(), "Active job count ($desc)" );
		}

		$queue->ack( $job2 );

		$queue->flushCaches();
		$this->assertSame( 0, $queue->getAcquiredCount(), "Active job count ($desc)" );

		$this->assertNull( $queue->batchPush( [ $this->newJob(), $this->newJob() ] ),
			"Push worked ($desc)" );
		$this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" );

		$queue->delete();
		$queue->flushCaches();
		$this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" );
		$this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" );
	}

	/**
	 * @dataProvider provider_queueLists
	 */
	public function testBasicDeduplication( $queue, $recycles, $desc ) {
		$queue = $this->$queue;
		if ( !$queue ) {
			$this->markTestSkipped( $desc );
		}

		$this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" );

		$queue->flushCaches();
		$this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" );
		$this->assertSame( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" );

		$this->assertNull(
			$queue->batchPush(
				[ $this->newDedupedJob(), $this->newDedupedJob(), $this->newDedupedJob() ]
			),
			"Push worked ($desc)" );

		$this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" );

		$queue->flushCaches();
		$this->assertSame( 1, $queue->getSize(), "Queue size is correct ($desc)" );
		$this->assertSame( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" );

		$this->assertNull(
			$queue->batchPush(
				[ $this->newDedupedJob(), $this->newDedupedJob(), $this->newDedupedJob() ]
			),
			"Push worked ($desc)"
		);

		$this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" );

		$queue->flushCaches();
		$this->assertSame( 1, $queue->getSize(), "Queue size is correct ($desc)" );
		$this->assertSame( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" );

		$job1 = $queue->pop();
		$this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" );

		$queue->flushCaches();
		$this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" );
		if ( $recycles ) {
			$this->assertSame( 1, $queue->getAcquiredCount(), "Active job count ($desc)" );
		}

		$queue->ack( $job1 );

		$queue->flushCaches();
		$this->assertSame( 0, $queue->getAcquiredCount(), "Active job count ($desc)" );
	}

	/**
	 * @dataProvider provider_queueLists
	 */
	public function testDeduplicationWhileClaimed( $queue, $recycles, $desc ) {
		$queue = $this->$queue;
		if ( !$queue ) {
			$this->markTestSkipped( $desc );
		}

		$job = $this->newDedupedJob();
		$queue->push( $job );

		// De-duplication does not apply to already-claimed jobs
		$j = $queue->pop();
		$queue->push( $job );
		$queue->ack( $j );

		$j = $queue->pop();
		// Make sure ack() of the twin did not delete the sibling data
		$this->assertInstanceOf( NullJob::class, $j );
	}

	/**
	 * @dataProvider provider_queueLists
	 */
	public function testRootDeduplication( $queue, $recycles, $desc ) {
		$queue = $this->$queue;
		if ( !$queue ) {
			$this->markTestSkipped( $desc );
		}

		$this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" );

		$queue->flushCaches();
		$this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" );
		$this->assertSame( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" );

		$root1 = Job::newRootJobParams( "nulljobspam:testId" ); // task ID/timestamp
		for ( $i = 0; $i < 5; ++$i ) {
			$this->assertNull( $queue->push( $this->newJob( 0, $root1 ) ), "Push worked ($desc)" );
		}
		$queue->deduplicateRootJob( $this->newJob( 0, $root1 ) );

		$root2 = $root1;
		# Add a second to UNIX epoch and format back to TS_MW
		$root2_ts = strtotime( $root2['rootJobTimestamp'] );
		$root2_ts++;
		$root2['rootJobTimestamp'] = wfTimestamp( TS_MW, $root2_ts );

		$this->assertNotEquals( $root1['rootJobTimestamp'], $root2['rootJobTimestamp'],
			"Root job signatures have different timestamps." );
		for ( $i = 0; $i < 5; ++$i ) {
			$this->assertNull( $queue->push( $this->newJob( 0, $root2 ) ), "Push worked ($desc)" );
		}
		$queue->deduplicateRootJob( $this->newJob( 0, $root2 ) );

		$this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" );

		$queue->flushCaches();
		$this->assertEquals( 10, $queue->getSize(), "Queue size is correct ($desc)" );
		$this->assertSame( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" );

		$dupcount = 0;
		$jobs = [];
		do {
			$job = $queue->pop();
			if ( $job ) {
				$jobs[] = $job;
				$queue->ack( $job );
			}
			if ( $job instanceof DuplicateJob ) {
				++$dupcount;
			}
		} while ( $job );

		$this->assertCount( 10, $jobs, "Correct number of jobs popped ($desc)" );
		$this->assertEquals( 5, $dupcount, "Correct number of duplicate jobs popped ($desc)" );
	}

	/**
	 * @dataProvider provider_fifoQueueLists
	 */
	public function testJobOrder( $queue, $recycles, $desc ) {
		$queue = $this->$queue;
		if ( !$queue ) {
			$this->markTestSkipped( $desc );
		}

		$this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" );

		$queue->flushCaches();
		$this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" );
		$this->assertSame( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" );

		for ( $i = 0; $i < 10; ++$i ) {
			$this->assertNull( $queue->push( $this->newJob( $i ) ), "Push worked ($desc)" );
		}

		for ( $i = 0; $i < 10; ++$i ) {
			$job = $queue->pop();
			$this->assertTrue( $job instanceof Job, "Jobs popped from queue ($desc)" );
			$params = $job->getParams();
			$this->assertEquals( $i, $params['i'], "Job popped from queue is FIFO ($desc)" );
			$queue->ack( $job );
		}

		$this->assertFalse( $queue->pop(), "Queue is not empty ($desc)" );

		$queue->flushCaches();
		$this->assertSame( 0, $queue->getSize(), "Queue is empty ($desc)" );
		$this->assertSame( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" );
	}

	public function testQueueAggregateTable() {
		$this->hideDeprecated( 'JobQueue::getWiki' );

		$queue = $this->queueFifo;
		if ( !$queue || !method_exists( $queue, 'getServerQueuesWithJobs' ) ) {
			$this->markTestSkipped();
		}

		$this->assertNotContains(
			[ $queue->getType(), $queue->getWiki() ],
			$queue->getServerQueuesWithJobs(),
			"Null queue not in listing"
		);

		$queue->push( $this->newJob( 0 ) );

		$this->assertContains(
			[ $queue->getType(), $queue->getWiki() ],
			$queue->getServerQueuesWithJobs(),
			"Null queue in listing"
		);
	}

	public static function provider_queueLists() {
		return [
			[ 'queueRand', false, 'Random queue without ack()' ],
			[ 'queueRandTTL', true, 'Random queue with ack()' ],
			[ 'queueTimestamp', false, 'Time ordered queue without ack()' ],
			[ 'queueTimestampTTL', true, 'Time ordered queue with ack()' ],
			[ 'queueFifo', false, 'FIFO ordered queue without ack()' ],
			[ 'queueFifoTTL', true, 'FIFO ordered queue with ack()' ]
		];
	}

	public static function provider_fifoQueueLists() {
		return [
			[ 'queueFifo', false, 'Ordered queue without ack()' ],
			[ 'queueFifoTTL', true, 'Ordered queue with ack()' ]
		];
	}

	protected function newJob( $i = 0, $rootJob = [] ) {
		$params = [
			'namespace' => NS_MAIN,
			'title' => 'Main_Page',
			'lives' => 0,
			'usleep' => 0,
			'removeDuplicates' => 0,
			'i' => $i
		] + $rootJob;

		return $this->getServiceContainer()->getJobFactory()->newJob( 'null', $params );
	}

	protected function newDedupedJob( $i = 0, $rootJob = [] ) {
		$params = [
				'namespace' => NS_MAIN,
				'title' => 'Main_Page',
				'lives' => 0,
				'usleep' => 0,
				'removeDuplicates' => 1,
				'i' => $i
			] + $rootJob;

		return $this->getServiceContainer()->getJobFactory()->newJob( 'null', $params );
	}
}

class JobQueueDBSingle extends JobQueueDB {
	protected function getDB( $index ) {
		$lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
		// Override to not use CONN_TRX_AUTOCOMMIT so that we see the same temporary `job` table
		return $lb->getConnection( $index, [], $this->domain );
	}
}
PK       !     *  jobqueue/jobs/UserEditCountInitJobTest.phpnu Iw        <?php

/**
 * @group JobQueue
 * @group Database
 */
class UserEditCountInitJobTest extends MediaWikiIntegrationTestCase {

	public static function provideTestCases() {
		// $startingEditCount, $setCount, $finalCount
		yield 'Initiate count if not yet set' => [ false, 2, 2 ];
		yield 'Update count when increasing' => [ 2, 3, 3 ];
		yield 'Never decrease count' => [ 10, 3, 10 ];
	}

	/**
	 * @covers \UserEditCountInitJob
	 * @dataProvider provideTestCases
	 */
	public function testUserEditCountInitJob( $startingEditCount, $setCount, $finalCount ) {
		$user = $this->getMutableTestUser()->getUser();

		if ( $startingEditCount !== false ) {
			$this->getServiceContainer()->getConnectionProvider()->getPrimaryDatabase()
				->newUpdateQueryBuilder()
				->update( 'user' )
				->set( [ 'user_editcount' => $startingEditCount ] )
				->where( [ 'user_id' => $user->getId() ] )
				->caller( __METHOD__ )
				->execute();
		}

		$job = new UserEditCountInitJob( [
			'userId' => $user->getId(),
			'editCount' => $setCount
		] );

		$result = $job->run();

		$this->assertTrue( $result );
		$this->assertEquals( $finalCount, $user->getEditCount() );
	}
}
PK       ! %    %  jobqueue/jobs/RefreshLinksJobTest.phpnu Iw        <?php

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\Content;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Page\PageAssertionException;
use MediaWiki\Title\Title;
use Wikimedia\Rdbms\Platform\ISQLPlatform;
use Wikimedia\Stats\StatsFactory;

/**
 * @covers \RefreshLinksJob
 *
 * @group JobQueue
 * @group Database
 *
 * @license GPL-2.0-or-later
 * @author Addshore
 */
class RefreshLinksJobTest extends MediaWikiIntegrationTestCase {
	/** @var StatsFactory */
	private $statsFactory;

	protected function setUp(): void {
		parent::setUp();
		$this->statsFactory = StatsFactory::newNull();
		$this->setService( 'StatsFactory', $this->statsFactory );
	}

	/**
	 * @param string $name
	 * @param Content[] $content
	 *
	 * @return WikiPage
	 */
	private function createPage( $name, array $content ) {
		$title = Title::makeTitle( $this->getDefaultWikitextNS(), $name );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$updater = $page->newPageUpdater( $this->getTestUser()->getUser() );

		foreach ( $content as $slot => $cnt ) {
			$updater->setContent( $slot, $cnt );
		}

		$updater->saveRevision( CommentStoreComment::newUnsavedComment( 'Test' ) );

		return $page;
	}

	// TODO: test multi-page
	// TODO: test recursive
	// TODO: test partition

	public function testBadTitle() {
		$specialBlankPage = Title::makeTitle( NS_SPECIAL, 'Blankpage' );

		$this->expectException( PageAssertionException::class );
		new RefreshLinksJob( $specialBlankPage, [] );
	}

	public function testRunForNonexistentPage() {
		$nonexistentPage = $this->getNonexistingTestPage();
		$job = new RefreshLinksJob( $nonexistentPage, [] );
		$totalFailuresCounter = $this->statsFactory->getCounter( 'refreshlinks_failures_total' );

		$result = $job->run();

		$this->assertFalse( $result );
		$this->assertSame( 1, $totalFailuresCounter->getSampleCount() );
	}

	public function testUpdateSuperseded() {
		$page = $this->getExistingTestPage();
		$job = new RefreshLinksJob( $page->getTitle(), [ 'rootJobTimestamp' => '20240101000000' ] );
		$supersededUpdatesCounter = $this->statsFactory->getCounter( 'refreshlinks_superseded_updates_total' );

		$result = $job->run();

		$this->assertTrue( $result );
		$this->assertSame( 1, $supersededUpdatesCounter->getSampleCount() );
	}

	public function testStaleRevision() {
		$page = $this->getExistingTestPage();
		$prevRev = $page->getRevisionRecord();
		$this->editPage( $page, 'New content' );

		$job = new RefreshLinksJob( $page->getTitle(), [ 'triggeringRevisionId' => $prevRev->getId() ] );
		$totalFailuresCounter = $this->statsFactory->getCounter( 'refreshlinks_failures_total' );

		$result = $job->run();

		// We don't want to retry the job so it is returned with true.
		$this->assertTrue( $result );
		$this->assertSame( 1, $totalFailuresCounter->getSampleCount() );
		$this->assertSame( "Revision {$prevRev->getId()} is not current", $job->getLastError() );
	}

	public function testRunForSinglePage() {
		$this->getServiceContainer()->getSlotRoleRegistry()->defineRoleWithModel(
			'aux',
			CONTENT_MODEL_WIKITEXT
		);

		$cacheOpsCounter = $this->statsFactory->getCounter( 'refreshlinks_parsercache_operations_total' );

		$mainContent = new WikitextContent( 'MAIN [[Kittens]]' );
		$auxContent = new WikitextContent( 'AUX [[Category:Goats]]' );
		$page = $this->createPage( __METHOD__, [ 'main' => $mainContent, 'aux' => $auxContent ] );

		// clear state
		$parserCache = $this->getServiceContainer()->getParserCache();
		$parserCache->deleteOptionsKey( $page );

		$this->getDb()->newDeleteQueryBuilder()
			->deleteFrom( 'pagelinks' )
			->where( ISQLPlatform::ALL_ROWS )
			->caller( __METHOD__ )
			->execute();
		$this->getDb()->newDeleteQueryBuilder()
			->deleteFrom( 'categorylinks' )
			->where( ISQLPlatform::ALL_ROWS )
			->caller( __METHOD__ )
			->execute();

		// run job
		$job = new RefreshLinksJob( $page->getTitle(), [ 'parseThreshold' => 0 ] );
		$result = $job->run();

		$this->newSelectQueryBuilder()
			->select( 'lt_title' )
			->from( 'pagelinks' )
			->join( 'linktarget', null, 'pl_target_id=lt_id' )
			->where( [ 'pl_from' => $page->getId() ] )
			->assertFieldValue( 'Kittens' );
		$this->newSelectQueryBuilder()
			->select( 'cl_to' )
			->from( 'categorylinks' )
			->where( [ 'cl_from' => $page->getId() ] )
			->assertFieldValue( 'Goats' );

		$this->assertTrue( $result );
		$this->assertSame( 1, $cacheOpsCounter->getSampleCount() );
	}

	public function testRunForMultiPage() {
		$this->getServiceContainer()->getSlotRoleRegistry()->defineRoleWithModel(
			'aux',
			CONTENT_MODEL_WIKITEXT
		);

		$fname = __METHOD__;

		$mainContent = new WikitextContent( 'MAIN [[Kittens]]' );
		$auxContent = new WikitextContent( 'AUX [[Category:Goats]]' );
		$page1 = $this->createPage( "$fname-1", [ 'main' => $mainContent, 'aux' => $auxContent ] );

		$mainContent = new WikitextContent( 'MAIN [[Dogs]]' );
		$auxContent = new WikitextContent( 'AUX [[Category:Hamsters]]' );
		$page2 = $this->createPage( "$fname-2", [ 'main' => $mainContent, 'aux' => $auxContent ] );

		// clear state
		$parserCache = $this->getServiceContainer()->getParserCache();
		$parserCache->deleteOptionsKey( $page1 );
		$parserCache->deleteOptionsKey( $page2 );

		$this->getDb()->newDeleteQueryBuilder()
			->deleteFrom( 'pagelinks' )
			->where( ISQLPlatform::ALL_ROWS )
			->caller( __METHOD__ )
			->execute();
		$this->getDb()->newDeleteQueryBuilder()
			->deleteFrom( 'categorylinks' )
			->where( ISQLPlatform::ALL_ROWS )
			->caller( __METHOD__ )
			->execute();

		// run job
		$job = new RefreshLinksJob(
			Title::makeTitle( NS_SPECIAL, 'Blankpage' ),
			[ 'pages' => [ [ 0, "$fname-1" ], [ 0, "$fname-2" ] ] ]
		);
		$job->run();

		$this->newSelectQueryBuilder()
			->select( 'lt_title' )
			->from( 'pagelinks' )
			->join( 'linktarget', null, 'pl_target_id=lt_id' )
			->where( [ 'pl_from' => $page1->getId() ] )
			->assertFieldValue( 'Kittens' );
		$this->newSelectQueryBuilder()
			->select( 'cl_to' )
			->from( 'categorylinks' )
			->where( [ 'cl_from' => $page1->getId() ] )
			->assertFieldValue( 'Goats' );
		$this->newSelectQueryBuilder()
			->select( 'lt_title' )
			->from( 'pagelinks' )
			->join( 'linktarget', null, 'pl_target_id=lt_id' )
			->where( [ 'pl_from' => $page2->getId() ] )
			->assertFieldValue( 'Dogs' );
		$this->newSelectQueryBuilder()
			->select( 'cl_to' )
			->from( 'categorylinks' )
			->where( [ 'cl_from' => $page2->getId() ] )
			->assertFieldValue( 'Hamsters' );
	}
}
PK       ! A  A  1  jobqueue/jobs/CategoryMembershipChangeJobTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Title\Title;
use MediaWiki\Utils\MWTimestamp;

/**
 * @covers \CategoryMembershipChangeJob
 *
 * @group JobQueue
 * @group Database
 *
 * @license GPL-2.0-or-later
 * @author Addshore
 */
class CategoryMembershipChangeJobTest extends MediaWikiIntegrationTestCase {

	private const TITLE_STRING = 'UTCatChangeJobPage';

	/**
	 * @var Title
	 */
	private $title;

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValue( MainConfigNames::RCWatchCategoryMembership, true );
		$this->setContentLang( 'qqx' );
	}

	public function addDBData() {
		parent::addDBData();
		$insertResult = $this->insertPage( self::TITLE_STRING, 'UT Content' );
		$this->title = $insertResult['title'];
	}

	/**
	 * @param string $text new page text
	 *
	 * @return int|null
	 */
	private function editPageText( $text ) {
		$editResult = $this->editPage(
			$this->title,
			$text,
			__METHOD__,
			NS_MAIN,
			$this->getTestSysop()->getAuthority()
		);
		/** @var RevisionRecord $revisionRecord */
		$revisionRecord = $editResult->getNewRevision();
		$this->runJobs();

		return $revisionRecord->getId();
	}

	/**
	 * @param int $revId
	 *
	 * @return RecentChange|null
	 */
	private function getCategorizeRecentChangeForRevId( $revId ) {
		$rc = RecentChange::newFromConds(
			[
				'rc_type' => RC_CATEGORIZE,
				'rc_this_oldid' => $revId,
			],
			__METHOD__
		);

		$this->assertNotNull( $rc, 'rev_id = ' . $revId );
		return $rc;
	}

	public function testRun_normalCategoryAddedAndRemoved() {
		$addedRevId = $this->editPageText( '[[Category:Normal]]' );
		$removedRevId = $this->editPageText( 'Blank' );

		$this->assertEquals(
			'(recentchanges-page-added-to-category: ' . self::TITLE_STRING . ')',
			$this->getCategorizeRecentChangeForRevId( $addedRevId )->getAttribute( 'rc_comment' )
		);
		$this->assertEquals(
			'(recentchanges-page-removed-from-category: ' . self::TITLE_STRING . ')',
			$this->getCategorizeRecentChangeForRevId( $removedRevId )->getAttribute( 'rc_comment' )
		);
	}

	public function testJobSpecRemovesDuplicates() {
		$jobSpec = CategoryMembershipChangeJob::newSpec( $this->title, MWTimestamp::now(), false );
		$job = new CategoryMembershipChangeJob(
			$this->title,
			$jobSpec->getParams()
		);
		$this->assertTrue( $job->ignoreDuplicates() );
		$this->assertTrue( $jobSpec->ignoreDuplicates() );
		$this->assertEquals( $job->getDeduplicationInfo(), $jobSpec->getDeduplicationInfo() );
	}

	public function testJobSpecDeduplicationIgnoresRevTimestamp() {
		$jobSpec1 = CategoryMembershipChangeJob::newSpec( $this->title, '20191008204617', false );
		$jobSpec2 = CategoryMembershipChangeJob::newSpec( $this->title, '20201008204617', false );
		$this->assertArrayEquals( $jobSpec1->getDeduplicationInfo(), $jobSpec2->getDeduplicationInfo() );
	}
}
PK       ! ]b/    ,  jobqueue/jobs/ParsoidCachePrewarmJobTest.phpnu Iw        <?php

use MediaWiki\Config\ServiceOptions;
use MediaWiki\MediaWikiServices;
use MediaWiki\OutputTransform\Stages\RenderDebugInfo;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageRecord;
use MediaWiki\Parser\ParserOptions;
use Psr\Log\NullLogger;
use Wikimedia\TestingAccessWrapper;

/**
 * @group JobQueue
 * @group Database
 *
 * @license GPL-2.0-or-later
 */
class ParsoidCachePrewarmJobTest extends MediaWikiIntegrationTestCase {

	private const NON_JOB_QUEUE_EDIT = 'parsoid edit not executed by job queue';
	private const JOB_QUEUE_EDIT = 'parsoid edit executed by job queue';

	private function getPageIdentity( PageRecord $page ): PageIdentityValue {
		return PageIdentityValue::localIdentity(
			$page->getId(),
			$page->getNamespace(),
			$page->getDBkey()
		);
	}

	private function getPageRecord( PageIdentity $page ): PageRecord {
		return $this->getServiceContainer()->getPageStore()
			->getPageByReference( $page );
	}

	/**
	 * @covers \ParsoidCachePrewarmJob::doParsoidCacheUpdate
	 * @covers \ParsoidCachePrewarmJob::newSpec
	 * @covers \ParsoidCachePrewarmJob::run
	 */
	public function testRun() {
		$page = $this->getExistingTestPage( 'ParsoidPrewarmJob' )->toPageRecord();
		$rev1 = $this->editPage( $page, self::NON_JOB_QUEUE_EDIT )->getNewRevision();

		$parsoidPrewarmJob = new ParsoidCachePrewarmJob(
			[ 'revId' => $rev1->getId(), 'pageId' => $page->getId() ],
			$this->getServiceContainer()->getParserOutputAccess(),
			$this->getServiceContainer()->getPageStore(),
			$this->getServiceContainer()->getRevisionLookup(),
			$this->getServiceContainer()->getParsoidSiteConfig()
		);

		// NOTE: calling ->run() will not run the job scheduled in the queue but will
		//       instead call doParsoidCacheUpdate() directly. Will run the job and assert
		//       below.
		$execStatus = $parsoidPrewarmJob->run();
		$this->assertTrue( $execStatus );

		$popts = ParserOptions::newFromAnon();
		$popts->setUseParsoid();
		$parsoidOutput = $this->getServiceContainer()->getParserOutputAccess()->getCachedParserOutput(
			$this->getPageRecord( $this->getPageIdentity( $page ) ),
			$popts,
			$rev1
		);

		// Ensure we have the parsoid output in parser cache as an HTML document
		$this->assertStringContainsString( '<html', $parsoidOutput->getRawText() );
		$this->assertStringContainsString( self::NON_JOB_QUEUE_EDIT, $parsoidOutput->getRawText() );

		$rev2 = $this->editPage( $page, self::JOB_QUEUE_EDIT )->getNewRevision();
		// Post-edit, reset all services!
		// ParserOutputAccess has a localCache which can incorrectly return stale
		// content for the previous revision! Resetting ensures that ParsoidCachePrewarmJob
		// gets a fresh copy of ParserOutputAccess.
		$this->resetServices();

		$parsoidPrewarmJob = new ParsoidCachePrewarmJob(
			[ 'revId' => $rev2->getId(), 'pageId' => $page->getId(), 'causeAction' => 'just for testing' ],
			$this->getServiceContainer()->getParserOutputAccess(),
			$this->getServiceContainer()->getPageStore(),
			$this->getServiceContainer()->getRevisionLookup(),
			$this->getServiceContainer()->getParsoidSiteConfig()
		);

		$jobQueueGroup = $this->getServiceContainer()->getJobQueueGroup();
		$jobQueueGroup->get( $parsoidPrewarmJob->getType() )->delete();
		$jobQueueGroup->push( $parsoidPrewarmJob );

		// At this point, we have 1 job scheduled for this job type.
		$this->assertSame( 1, $jobQueueGroup->getQueueSizes()['parsoidCachePrewarm'] );

		// doParsoidCacheUpdate() now with a job queue instead of calling directly.
		$this->runJobs( [ 'maxJobs' => 1 ], [ 'type' => 'parsoidCachePrewarm' ] );

		// At this point, we have 0 jobs scheduled for this job type.
		$this->assertSame( 0, $jobQueueGroup->getQueueSizes()['parsoidCachePrewarm'] );

		$parsoidOutput = $this->getServiceContainer()->getParserOutputAccess()->getCachedParserOutput(
			$this->getPageRecord( $this->getPageIdentity( $page ) ),
			$popts,
			$rev2
		);

		// Ensure we have the parsoid output in parser cache as an HTML document
		$this->assertStringContainsString( '<html', $parsoidOutput->getRawText() );
		$this->assertStringContainsString( self::JOB_QUEUE_EDIT, $parsoidOutput->getRawText() );

		$services = MediaWikiServices::getInstance();
		$servicesOptions = new ServiceOptions(
			RenderDebugInfo::CONSTRUCTOR_OPTIONS, $services->getMainConfig()
		);
		$rdi = TestingAccessWrapper::newFromObject(
			new RenderDebugInfo( $servicesOptions, new NullLogger(), $services->getHookContainer() )
		);
		// Check that the causeAction was looped through as the render reason
		$this->assertStringContainsString(
			'triggered because: just for testing',
			$rdi->debugInfo( $parsoidOutput )
		);
	}

	/**
	 * @covers \ParsoidCachePrewarmJob::newSpec
	 */
	public function testEnqueueSpec() {
		$page = $this->getExistingTestPage( 'ParsoidPrewarmJob' )->toPageRecord();
		$rev1 = $this->editPage( $page, self::NON_JOB_QUEUE_EDIT )->getNewRevision();

		$parsoidPrewarmSpec = ParsoidCachePrewarmJob::newSpec(
			$rev1->getId(), $page,
		);

		$this->assertSame( 'parsoidCachePrewarm', $parsoidPrewarmSpec->getType(), 'getType' );

		$dedupeInfo = $parsoidPrewarmSpec->getDeduplicationInfo();
		$this->assertTrue( $dedupeInfo['params']['rootJobIsSelf'] );
		$this->assertSame( $page->getTouched(), $dedupeInfo['params']['page_touched'] );
		$this->assertSame( $rev1->getId(), $dedupeInfo['params']['revId'] );
		$this->assertSame( $page->getId(), $dedupeInfo['params']['pageId'] );

		$jobQueueGroup = $this->getServiceContainer()->getJobQueueGroup();
		$jobQueueGroup->get( $parsoidPrewarmSpec->getType() )->delete();

		$jobQueueGroup->push( $parsoidPrewarmSpec );

		// At this point, we have 1 job scheduled for this job type.
		$this->assertSame( 1, $jobQueueGroup->getQueueSizes()['parsoidCachePrewarm'] );

		// Push again times, deduplication should apply!
		$jobQueueGroup->push( $parsoidPrewarmSpec );
		$jobQueueGroup->push( $parsoidPrewarmSpec );

		// We should still have just 1 job scheduled for this job type.
		$this->assertSame( 1, $jobQueueGroup->getQueueSizes()['parsoidCachePrewarm'] );
	}

}
PK       !       jobqueue/JobQueueMemoryTest.phpnu Iw        <?php

use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;
use MediaWiki\WikiMap\WikiMap;

/**
 * @covers \JobQueueMemory
 *
 * @group JobQueue
 *
 * @license GPL-2.0-or-later
 * @author Thiemo Kreuz
 */
class JobQueueMemoryTest extends PHPUnit\Framework\TestCase {

	use MediaWikiCoversValidator;

	/**
	 * @return JobQueueMemory
	 */
	private function newJobQueue() {
		$services = MediaWikiServices::getInstance();

		return JobQueue::factory( [
			'class' => JobQueueMemory::class,
			'domain' => WikiMap::getCurrentWikiDbDomain()->getId(),
			'type' => 'null',
			'idGenerator' => $services->getGlobalIdGenerator(),
		] );
	}

	private function newJobSpecification() {
		return new JobSpecification(
			'null',
			[ 'customParameter' => null ],
			[],
			Title::makeTitle( NS_MAIN, 'Custom title' )
		);
	}

	public function testGetAllQueuedJobs() {
		$queue = $this->newJobQueue();
		$this->assertCount( 0, $queue->getAllQueuedJobs() );

		$queue->push( $this->newJobSpecification() );
		$this->assertCount( 1, $queue->getAllQueuedJobs() );
	}

	public function testGetAllAcquiredJobs() {
		$queue = $this->newJobQueue();
		$this->assertCount( 0, $queue->getAllAcquiredJobs() );

		$queue->push( $this->newJobSpecification() );
		$this->assertCount( 0, $queue->getAllAcquiredJobs() );

		$queue->pop();
		$this->assertCount( 1, $queue->getAllAcquiredJobs() );
	}

	public function testJobFromSpecInternal() {
		$queue = $this->newJobQueue();
		$job = $queue->jobFromSpecInternal( $this->newJobSpecification() );
		$this->assertInstanceOf( Job::class, $job );
		$this->assertSame( 'null', $job->getType() );
		$this->assertArrayHasKey( 'customParameter', $job->getParams() );
		$this->assertSame( 'Custom title', $job->getTitle()->getText() );
	}

}
PK       ! 
      jobqueue/JobFactoryTest.phpnu Iw        <?php

use MediaWiki\JobQueue\JobFactory;
use MediaWiki\Title\Title;

/**
 * @author Addshore
 * @covers \MediaWiki\JobQueue\JobFactory
 */
class JobFactoryTest extends MediaWikiIntegrationTestCase {

	/**
	 * @dataProvider provideTestNewJob
	 */
	public function testNewJob( $handler, $expectedClass ) {
		$specs = [
			'testdummy' => $handler
		];

		$factory = new JobFactory(
			$this->getServiceContainer()->getObjectFactory(),
			$specs
		);

		$job = $factory->newJob( 'testdummy', Title::newMainPage(), [] );
		$this->assertInstanceOf( $expectedClass, $job );

		$job2 = $factory->newJob( 'testdummy', [] );
		$this->assertInstanceOf( $expectedClass, $job2 );
		$this->assertNotSame( $job, $job2, 'should not reuse instance' );

		$job3 = $factory->newJob( 'testdummy', [ 'namespace' => NS_MAIN, 'title' => 'JobTestTitle' ] );
		$this->assertInstanceOf( $expectedClass, $job3 );
		$this->assertNotSame( $job, $job3, 'should not reuse instance' );
	}

	public function provideTestNewJob() {
		return [
			'class name, no title' => [ 'NullJob', NullJob::class ],
			'class name with title' => [ DeleteLinksJob::class, DeleteLinksJob::class ],
			'closure' => [ static function ( Title $title, array $params ) {
				return new NullJob( $params );
			}, NullJob::class ],
			'function' => [ [ $this, 'newNullJob' ], NullJob::class ],
			'object spec, no title' => [ [ 'class' => 'NullJob' ], NullJob::class ],
			'object spec with title' => [ [ 'class' => DeleteLinksJob::class ], DeleteLinksJob::class ],
			'object spec with no title and not subclass of GenericParameterJob' => [
				[
					'class' => ParsoidCachePrewarmJob::class,
					'services' => [
						'ParserOutputAccess',
						'PageStore',
						'RevisionLookup',
						'ParsoidSiteConfig',
					],
					'needsPage' => false
				],
				ParsoidCachePrewarmJob::class
			]
		];
	}

	public function newNullJob( Title $title, array $params ) {
		return new NullJob( $params );
	}
}
PK       ! a܄      jobqueue/JobTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Request\WebRequest;
use MediaWiki\Title\Title;

/**
 * @author Addshore
 * @covers \Job
 */
class JobTest extends MediaWikiIntegrationTestCase {

	/**
	 * @dataProvider provideTestToString
	 *
	 * @param Job $job
	 * @param string $expected
	 */
	public function testToString( $job, $expected ) {
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'en' );
		$this->assertEquals( $expected, $job->toString() );
	}

	public function provideTestToString() {
		$mockToStringObj = $this->getMockBuilder( stdClass::class )
			->addMethods( [ '__toString' ] )->getMock();
		$mockToStringObj->method( '__toString' )
			->willReturn( '{STRING_OBJ_VAL}' );

		$requestId = 'requestId=' . WebRequest::getRequestId();

		return [
			[
				$this->getMockJob( [ 'key' => 'val' ] ),
				'someCommand Special: key=val ' . $requestId
			],
			[
				$this->getMockJob( [ 'key' => [ 'inkey' => 'inval' ] ] ),
				'someCommand Special: key={"inkey":"inval"} ' . $requestId
			],
			[
				$this->getMockJob( [ 'val1' ] ),
				'someCommand Special: 0=val1 ' . $requestId
			],
			[
				$this->getMockJob( [ 'val1', 'val2' ] ),
				'someCommand Special: 0=val1 1=val2 ' . $requestId
			],
			[
				$this->getMockJob( [ (object)[] ] ),
				'someCommand Special: 0=stdClass ' . $requestId
			],
			[
				$this->getMockJob( [ $mockToStringObj ] ),
				'someCommand Special: 0={STRING_OBJ_VAL} ' . $requestId
			],
			[
				$this->getMockJob( [
					"pages" => [
						"932737" => [
							0,
							"Robert_James_Waller"
						]
					],
					"rootJobSignature" => "45868e99bba89064e4483743ebb9b682ef95c1a7",
					"rootJobTimestamp" => "20160309110158",
					"masterPos" => [
						"file" => "db1023-bin.001288",
						"pos" => "308257743",
						"asOfTime" => 1457521464.3814
					],
					"triggeredRecursive" => true
				] ),
				'someCommand Special: pages={"932737":[0,"Robert_James_Waller"]} ' .
				'rootJobSignature=45868e99bba89064e4483743ebb9b682ef95c1a7 ' .
				'rootJobTimestamp=20160309110158 masterPos=' .
				'{"file":"db1023-bin.001288","pos":"308257743",' .
				'"asOfTime":1457521464.3814} triggeredRecursive=1 ' .
				$requestId
			],
		];
	}

	public function getMockJob( $params ) {
		$mock = $this->getMockForAbstractClass(
			Job::class,
			[ 'someCommand', $params ],
			'SomeJob'
		);

		return $mock;
	}

	public function testInvalidParamsArgument() {
		$params = false;
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( '$params must be an array' );
		$job = $this->getMockJob( $params );
	}

	/**
	 * @dataProvider provideTestJobFactory
	 */
	public function testJobFactory( $handler, $expectedClass ) {
		$this->overrideConfigValue( MainConfigNames::JobClasses, [ 'testdummy' => $handler ] );

		$job = Job::factory( 'testdummy', Title::newMainPage(), [] );
		$this->assertInstanceOf( $expectedClass, $job );

		$job2 = Job::factory( 'testdummy', [] );
		$this->assertInstanceOf( $expectedClass, $job2 );
		$this->assertNotSame( $job, $job2, 'should not reuse instance' );

		$job3 = Job::factory( 'testdummy', [ 'namespace' => NS_MAIN, 'title' => 'JobTestTitle' ] );
		$this->assertInstanceOf( $expectedClass, $job3 );
		$this->assertNotSame( $job, $job3, 'should not reuse instance' );
	}

	public function provideTestJobFactory() {
		return [
			'class name, no title' => [ 'NullJob', NullJob::class ],
			'class name with title' => [ DeleteLinksJob::class, DeleteLinksJob::class ],
			'closure' => [ static function ( Title $title, array $params ) {
				return new NullJob( $params );
			}, NullJob::class ],
			'function' => [ [ $this, 'newNullJob' ], NullJob::class ],
			'object spec, no title' => [ [ 'class' => 'NullJob' ], NullJob::class ],
			'object spec with title' => [ [ 'class' => DeleteLinksJob::class ], DeleteLinksJob::class ],
			'object spec with no title and not subclass of GenericParameterJob' => [
				[
					'class' => ParsoidCachePrewarmJob::class,
					'services' => [
						'ParserOutputAccess',
						'PageStore',
						'RevisionLookup',
						'ParsoidSiteConfig',
					],
					'needsPage' => false
				],
				ParsoidCachePrewarmJob::class
			]
		];
	}

	public function newNullJob( Title $title, array $params ) {
		return new NullJob( $params );
	}

	public function testJobSignatureGeneric() {
		$testPage = Title::makeTitle( NS_PROJECT, 'x' );
		$blankTitle = Title::makeTitle( NS_SPECIAL, '' );
		$params = [ 'z' => 1, 'lives' => 1, 'usleep' => 0 ];
		$paramsWithTitle = $params + [ 'namespace' => NS_PROJECT, 'title' => 'x' ];

		$job = new NullJob( [ 'namespace' => NS_PROJECT, 'title' => 'x' ] + $params );
		$this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
		$this->assertJobParamsMatch( $job, $paramsWithTitle );

		$job = Job::factory( 'null', $testPage, $params );
		$this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() );
		$this->assertJobParamsMatch( $job, $params );

		$job = Job::factory( 'null', $paramsWithTitle );
		$this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
		$this->assertJobParamsMatch( $job, $paramsWithTitle );

		$job = Job::factory( 'null', $params );
		$this->assertTrue( $blankTitle->equals( $job->getTitle() ) );
		$this->assertJobParamsMatch( $job, $params );
	}

	public function testJobSignatureTitleBased() {
		$testPage = Title::makeTitle( NS_PROJECT, 'X' );
		$blankPage = Title::makeTitle( NS_SPECIAL, 'Blankpage' );
		$params = [ 'z' => 1, 'causeAction' => 'unknown', 'causeAgent' => 'unknown' ];
		$paramsWithTitle = $params + [ 'namespace' => NS_PROJECT, 'title' => 'X' ];
		$paramsWithBlankpage = $params + [ 'namespace' => NS_SPECIAL, 'title' => 'Blankpage' ];

		$job = new RefreshLinksJob( $testPage, $params );
		$this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
		$this->assertTrue( $testPage->equals( $job->getTitle() ) );
		$this->assertJobParamsMatch( $job, $paramsWithTitle );

		$job = Job::factory( 'htmlCacheUpdate', $testPage, $params );
		$this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
		$this->assertJobParamsMatch( $job, $paramsWithTitle );

		$job = Job::factory( 'htmlCacheUpdate', $paramsWithTitle );
		$this->assertEquals( $testPage->getPrefixedText(), $job->getTitle()->getPrefixedText() );
		$this->assertJobParamsMatch( $job, $paramsWithTitle );

		$job = Job::factory( 'htmlCacheUpdate', $params );
		$this->assertTrue( $blankPage->equals( $job->getTitle() ) );
		$this->assertJobParamsMatch( $job, $paramsWithBlankpage );
	}

	public function testJobSignatureTitleBasedIncomplete() {
		$testPage = Title::makeTitle( NS_PROJECT, 'X' );
		$blankTitle = Title::makeTitle( NS_SPECIAL, '' );
		$params = [ 'z' => 1, 'causeAction' => 'unknown', 'causeAgent' => 'unknown' ];

		$job = new RefreshLinksJob( $testPage, $params + [ 'namespace' => 0 ] );
		$this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() );
		$this->assertJobParamsMatch( $job, $params + [ 'namespace' => 0 ] );

		$job = new RefreshLinksJob( $testPage, $params + [ 'title' => 'x' ] );
		$this->assertEquals( $blankTitle->getPrefixedText(), $job->getTitle()->getPrefixedText() );
		$this->assertJobParamsMatch( $job, $params + [ 'title' => 'x' ] );
	}

	private function assertJobParamsMatch( IJobSpecification $job, array $params ) {
		$actual = $job->getParams();
		unset( $actual['requestId'] );

		$this->assertEquals( $actual, $params );
	}
}
PK       ! C    &  jobqueue/RefreshLinksPartitionTest.phpnu Iw        <?php

use MediaWiki\Title\Title;

/**
 * @group JobQueue
 * @group medium
 * @group Database
 */
class RefreshLinksPartitionTest extends MediaWikiIntegrationTestCase {

	/**
	 * @dataProvider provideBacklinks
	 * @covers \BacklinkJobUtils
	 */
	public function testRefreshLinks( $ns, $dbKey, $pages ) {
		$title = Title::makeTitle( $ns, $dbKey );

		$user = $this->getTestSysop()->getAuthority();
		foreach ( $pages as [ $bns, $bdbkey ] ) {
			$this->editPage(
				Title::makeTitle( $bns, $bdbkey ),
				"[[{$title->getPrefixedText()}]]",
				'test',
				NS_MAIN,
				$user
			);
		}

		$backlinkCache = $this->getServiceContainer()->getBacklinkCacheFactory()
			->getBacklinkCache( $title );
		$this->assertEquals(
			20,
			$backlinkCache->getNumLinks( 'pagelinks' ),
			'Correct number of backlinks'
		);

		$job = new RefreshLinksJob( $title, [ 'recursive' => true, 'table' => 'pagelinks' ]
			+ Job::newRootJobParams( 'refreshlinks:pagelinks:' . $title->getPrefixedText() ) );
		$extraParams = $job->getRootJobParams();
		$jobs = BacklinkJobUtils::partitionBacklinkJob( $job, 9, 1, [ 'params' => $extraParams ] );

		$this->assertCount( 10, $jobs, 'Correct number of sub-jobs' );
		$this->assertEquals( $pages[0], current( $jobs[0]->params['pages'] ),
			'First job is leaf job with proper title' );
		$this->assertEquals( $pages[8], current( $jobs[8]->params['pages'] ),
			'Last leaf job is leaf job with proper title' );
		$this->assertTrue( isset( $jobs[9]->params['recursive'] ),
			'Last job is recursive sub-job' );
		$this->assertTrue( $jobs[9]->params['recursive'],
			'Last job is recursive sub-job' );
		$this->assertIsArray( $jobs[9]->params['range'],
			'Last job is recursive sub-job' );
		$this->assertEquals( $title->getPrefixedText(), $jobs[0]->getTitle()->getPrefixedText(),
			'Base job title retainend in leaf job' );
		$this->assertEquals( $title->getPrefixedText(), $jobs[9]->getTitle()->getPrefixedText(),
			'Base job title retainend recursive sub-job' );
		$this->assertEquals( $extraParams['rootJobSignature'], $jobs[0]->params['rootJobSignature'],
			'Leaf job has root params' );
		$this->assertEquals( $extraParams['rootJobSignature'], $jobs[9]->params['rootJobSignature'],
			'Recursive sub-job has root params' );

		$jobs2 = BacklinkJobUtils::partitionBacklinkJob(
			$jobs[9],
			9,
			1,
			[ 'params' => $extraParams ]
		);

		$this->assertCount( 10, $jobs2, 'Correct number of sub-jobs' );
		$this->assertEquals( $pages[9], current( $jobs2[0]->params['pages'] ),
			'First job is leaf job with proper title' );
		$this->assertEquals( $pages[17], current( $jobs2[8]->params['pages'] ),
			'Last leaf job is leaf job with proper title' );
		$this->assertTrue( isset( $jobs2[9]->params['recursive'] ),
			'Last job is recursive sub-job' );
		$this->assertTrue( $jobs2[9]->params['recursive'],
			'Last job is recursive sub-job' );
		$this->assertIsArray( $jobs2[9]->params['range'],
			'Last job is recursive sub-job' );
		$this->assertEquals( $extraParams['rootJobSignature'], $jobs2[0]->params['rootJobSignature'],
			'Leaf job has root params' );
		$this->assertEquals( $extraParams['rootJobSignature'], $jobs2[9]->params['rootJobSignature'],
			'Recursive sub-job has root params' );

		$jobs3 = BacklinkJobUtils::partitionBacklinkJob(
			$jobs2[9],
			9,
			1,
			[ 'params' => $extraParams ]
		);

		$this->assertCount( 2, $jobs3, 'Correct number of sub-jobs' );
		$this->assertEquals( $pages[18], current( $jobs3[0]->params['pages'] ),
			'First job is leaf job with proper title' );
		$this->assertEquals( $extraParams['rootJobSignature'], $jobs3[0]->params['rootJobSignature'],
			'Leaf job has root params' );
		$this->assertEquals( $pages[19], current( $jobs3[1]->params['pages'] ),
			'Last job is leaf job with proper title' );
		$this->assertEquals( $extraParams['rootJobSignature'], $jobs3[1]->params['rootJobSignature'],
			'Last leaf job has root params' );
	}

	public static function provideBacklinks() {
		$pages = [];
		for ( $i = 0; $i < 20; ++$i ) {
			$pages[] = [ 0, "Page-$i" ];
		}
		return [
			[ 10, 'Bang', $pages ]
		];
	}
}
PK       ! 窲s      jobqueue/JobRunnerTest.phpnu Iw        <?php

use MediaWiki\Page\DeletePage;
use MediaWiki\Request\WebRequest;
use MediaWiki\Title\Title;

/**
 * @group Database
 * @covers \JobRunner
 */
class JobRunnerTest extends MediaWikiIntegrationTestCase {

	/**
	 * @var Title
	 */
	private $page;

	/**
	 * @var JobRunner
	 */
	private $jobRunner;

	/**
	 * @var DeletePageJob
	 */
	private $deletePageJob;

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

		$str = wfRandomString( 10 );
		$this->page = $this->insertPage( $str )['title'];

		$this->assertTrue( $this->page->exists(), 'The created page exists' );

		$this->jobRunner = $this->getServiceContainer()->getJobRunner();
		$jobParams = [
			'namespace' => $this->page->getNamespace(),
			'title' => $this->page->getDBkey(),
			'wikiPageId' => $this->page->getArticleID(),
			'requestId' => WebRequest::getRequestId(),
			'reason' => 'Testing delete job',
			'suppress' => false,
			'userId' => $this->getTestUser()->getUser()->getId(),
			'tags' => json_encode( [] ),
			'logsubtype' => 'delete',
			'pageRole' => DeletePage::PAGE_BASE,
		];
		$this->deletePageJob = new DeletePageJob( $jobParams );
	}

	/**
	 * @dataProvider provideTestRun
	 */
	public function testRun( $options, $expectedVal ) {
		$this->getServiceContainer()->getJobQueueGroup()->push( $this->deletePageJob );

		$results = $this->jobRunner->run( $options );

		$this->assertEquals( $expectedVal, $results['reached'] );
	}

	public static function provideTestRun() {
		return [
			[ [], 'none-ready' ],
			[ [ 'type' => true ], 'none-possible' ],
			[ [ 'maxJobs' => 1 ], 'job-limit' ],
			[ [ 'maxTime' => -1 ], 'time-limit' ],
			[ [ 'type' => 'deletePage', 'throttle' => false ], 'none-ready' ]
		];
	}

	public function testExecuteJob() {
		$results = $this->jobRunner->executeJob( $this->deletePageJob );

		$this->assertIsInt( $results['timeMs'] );
		$this->assertTrue( $results['status'] );
		$this->assertIsArray( $results['caught'] );
		$this->assertNull( $results['error'] );

		$this->assertTrue( $this->page->hasDeletedEdits() );
	}
}
PK       ! pڷm      SiteStats/SiteStatsTest.phpnu Iw        <?php

use MediaWiki\SiteStats\SiteStats;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Rdbms\Platform\ISQLPlatform;

/**
 * @group Database
 */
class SiteStatsTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers \MediaWiki\SiteStats\SiteStats::jobs
	 */
	public function testJobsCountGetCached() {
		$cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
		$this->setService( 'MainWANObjectCache', $cache );
		$jobq = $this->getServiceContainer()->getJobQueueGroup();

		$jobq->push( new NullJob( [] ) );
		$this->assertSame( 1, SiteStats::jobs(),
			'A single job enqueued bumps jobscount stat to 1' );

		$jobq->push( new NullJob( [] ) );
		$this->assertSame( 1, SiteStats::jobs(),
			'SiteStats::jobs() count does not reflect addition ' .
			'of a second job (cached)'
		);

		$jobq->get( 'null' )->delete();  // clear jobqueue
		$this->assertSame( 0, $jobq->get( 'null' )->getSize(),
			'Job queue for NullJob has been cleaned' );

		$cache->delete( $cache->makeKey( 'SiteStats', 'jobscount' ) );
		$this->assertSame( 1, SiteStats::jobs(),
			'jobs count is kept in process cache' );

		$cache->clearProcessCache();
		$this->assertSame( 0, SiteStats::jobs() );
	}

	/**
	 * @covers \MediaWiki\SiteStats\SiteStats
	 */
	public function testInit() {
		$this->getDb()->newDeleteQueryBuilder()
			->deleteFrom( 'site_stats' )
			->where( ISQLPlatform::ALL_ROWS )
			->caller( __METHOD__ )
			->execute();
		SiteStats::unload();

		SiteStats::edits();
		$row = $this->getDb()->newSelectQueryBuilder()
			->select( '1' )
			->from( 'site_stats' )
			->caller( __METHOD__ )->fetchRow();

		$this->assertNotFalse( $row );
	}
}
PK       ! ً    *  auth/UsernameAuthenticationRequestTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\UsernameAuthenticationRequest;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\UsernameAuthenticationRequest
 */
class UsernameAuthenticationRequestTest extends AuthenticationRequestTestCase {

	protected function getInstance( array $args = [] ) {
		return new UsernameAuthenticationRequest();
	}

	public static function provideLoadFromSubmission() {
		return [
			'Empty request' => [
				[],
				[],
				false
			],
			'Username' => [
				[],
				$data = [ 'username' => 'User' ],
				$data,
			],
			'Username empty' => [
				[],
				[ 'username' => '' ],
				false
			],
		];
	}
}
PK       ! Qz  z  *  auth/PasswordAuthenticationRequestTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\PasswordAuthenticationRequest;
use MediaWiki\Message\Message;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\PasswordAuthenticationRequest
 */
class PasswordAuthenticationRequestTest extends AuthenticationRequestTestCase {

	protected function getInstance( array $args = [] ) {
		$ret = new PasswordAuthenticationRequest();
		$ret->action = $args[0];
		return $ret;
	}

	public static function provideGetFieldInfo() {
		return [
			[ [ AuthManager::ACTION_LOGIN ] ],
			[ [ AuthManager::ACTION_CREATE ] ],
			[ [ AuthManager::ACTION_CHANGE ] ],
			[ [ AuthManager::ACTION_REMOVE ] ],
		];
	}

	public function testGetFieldInfo2() {
		$info = [];
		foreach ( [
			AuthManager::ACTION_LOGIN,
			AuthManager::ACTION_CREATE,
			AuthManager::ACTION_CHANGE,
			AuthManager::ACTION_REMOVE,
		] as $action ) {
			$req = new PasswordAuthenticationRequest();
			$req->action = $action;
			$info[$action] = $req->getFieldInfo();
		}

		$this->assertSame( [], $info[AuthManager::ACTION_REMOVE], 'No data needed to remove' );

		$this->assertArrayNotHasKey( 'retype', $info[AuthManager::ACTION_LOGIN],
			'No need to retype password on login' );
		$this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CREATE],
			'Need to retype when creating new password' );
		$this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CHANGE],
			'Need to retype when changing password' );

		$this->assertNotEquals(
			$info[AuthManager::ACTION_LOGIN]['password']['label'],
			$info[AuthManager::ACTION_CHANGE]['password']['label'],
			'Password field for change is differentiated from login'
		);
		$this->assertNotEquals(
			$info[AuthManager::ACTION_CREATE]['password']['label'],
			$info[AuthManager::ACTION_CHANGE]['password']['label'],
			'Password field for change is differentiated from create'
		);
		$this->assertNotEquals(
			$info[AuthManager::ACTION_CREATE]['retype']['label'],
			$info[AuthManager::ACTION_CHANGE]['retype']['label'],
			'Retype field for change is differentiated from create'
		);
	}

	public static function provideLoadFromSubmission() {
		return [
			'Empty request, login' => [
				[ AuthManager::ACTION_LOGIN ],
				[],
				false,
			],
			'Empty request, change' => [
				[ AuthManager::ACTION_CHANGE ],
				[],
				false,
			],
			'Empty request, remove' => [
				[ AuthManager::ACTION_REMOVE ],
				[],
				false,
			],
			'Username + password, login' => [
				[ AuthManager::ACTION_LOGIN ],
				$data = [ 'username' => 'User', 'password' => 'Bar' ],
				$data + [ 'action' => AuthManager::ACTION_LOGIN ],
			],
			'Username + password, change' => [
				[ AuthManager::ACTION_CHANGE ],
				[ 'username' => 'User', 'password' => 'Bar' ],
				false,
			],
			'Username + password + retype' => [
				[ AuthManager::ACTION_CHANGE ],
				[ 'username' => 'User', 'password' => 'Bar', 'retype' => 'baz' ],
				[ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ],
			],
			'Username empty, login' => [
				[ AuthManager::ACTION_LOGIN ],
				[ 'username' => '', 'password' => 'Bar' ],
				false,
			],
			'Username empty, change' => [
				[ AuthManager::ACTION_CHANGE ],
				[ 'username' => '', 'password' => 'Bar', 'retype' => 'baz' ],
				[ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ],
			],
			'Password empty, login' => [
				[ AuthManager::ACTION_LOGIN ],
				[ 'username' => 'User', 'password' => '' ],
				false,
			],
			'Password empty, login, with retype' => [
				[ AuthManager::ACTION_LOGIN ],
				[ 'username' => 'User', 'password' => '', 'retype' => 'baz' ],
				false,
			],
			'Retype empty' => [
				[ AuthManager::ACTION_CHANGE ],
				[ 'username' => 'User', 'password' => 'Bar', 'retype' => '' ],
				false,
			],
		];
	}

	public function testDescribeCredentials() {
		$username = 'TestDescribeCredentials';
		$req = new PasswordAuthenticationRequest;
		$req->action = AuthManager::ACTION_LOGIN;
		$req->username = $username;
		$ret = $req->describeCredentials();
		$this->assertIsArray( $ret );
		$this->assertArrayHasKey( 'provider', $ret );
		$this->assertInstanceOf( Message::class, $ret['provider'] );
		$this->assertSame( 'authmanager-provider-password', $ret['provider']->getKey() );
		$this->assertArrayHasKey( 'account', $ret );
		$this->assertInstanceOf( Message::class, $ret['account'] );
		$this->assertSame( [ $username ], $ret['account']->getParams() );
	}
}
PK       ! 8,  ,  -  auth/ConfirmLinkAuthenticationRequestTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use InvalidArgumentException;
use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Auth\ConfirmLinkAuthenticationRequest;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\ConfirmLinkAuthenticationRequest
 */
class ConfirmLinkAuthenticationRequestTest extends AuthenticationRequestTestCase {

	protected function getInstance( array $args = [] ) {
		return new ConfirmLinkAuthenticationRequest( self::getLinkRequests() );
	}

	public function testConstructorException() {
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( '$linkRequests must not be empty' );
		new ConfirmLinkAuthenticationRequest( [] );
	}

	/**
	 * Get requests for testing
	 * @return AuthenticationRequest[]
	 */
	private static function getLinkRequests() {
		$reqs = [];

		for ( $i = 1; $i <= 3; $i++ ) {
			$req = new class( "Request$i" ) extends AuthenticationRequest {
				private $uniqueId;

				public function __construct( $uniqueId ) {
					$this->uniqueId = $uniqueId;
				}

				public function getFieldInfo() {
					return [];
				}

				public function getUniqueId() {
					return $this->uniqueId;
				}
			};

			$reqs[$req->getUniqueId()] = $req;
		}

		return $reqs;
	}

	public static function provideLoadFromSubmission() {
		$reqs = self::getLinkRequests();

		return [
			'Empty request' => [
				[],
				[],
				[ 'linkRequests' => $reqs ],
			],
			'Some confirmed' => [
				[],
				[ 'confirmedLinkIDs' => [ 'Request1', 'Request3' ] ],
				[ 'confirmedLinkIDs' => [ 'Request1', 'Request3' ], 'linkRequests' => $reqs ],
			],
		];
	}

	public function testGetUniqueId() {
		$req = new ConfirmLinkAuthenticationRequest( self::getLinkRequests() );
		$this->assertSame(
			get_class( $req ) . ':Request1|Request2|Request3',
			$req->getUniqueId()
		);
	}
}
PK       ! ۞_  _  7  auth/LocalPasswordPrimaryAuthenticationProviderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use BadMethodCallException;
use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider;
use MediaWiki\Auth\PasswordAuthenticationRequest;
use MediaWiki\Auth\PasswordDomainAuthenticationRequest;
use MediaWiki\Auth\PrimaryAuthenticationProvider;
use MediaWiki\Config\HashConfig;
use MediaWiki\Config\MultiConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Password\PasswordFactory;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Unit\Auth\AuthenticationProviderTestTrait;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\User\User;
use MediaWiki\User\UserNameUtils;
use MediaWikiIntegrationTestCase;
use StatusValue;
use Wikimedia\TestingAccessWrapper;

/**
 * @group AuthManager
 * @group Database
 * @covers \MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider
 */
class LocalPasswordPrimaryAuthenticationProviderTest extends MediaWikiIntegrationTestCase {
	use AuthenticationProviderTestTrait;
	use DummyServicesTrait;

	/** @var AuthManager|null */
	private $manager = null;
	/** @var HashConfig|null */
	private $config = null;
	/** @var Status|null */
	private $validity = null;

	/**
	 * Get an instance of the provider
	 *
	 * $provider->checkPasswordValidity is mocked to return $this->validity,
	 * because we don't need to test that here.
	 *
	 * @param bool $loginOnly
	 * @return LocalPasswordPrimaryAuthenticationProvider
	 */
	protected function getProvider( $loginOnly = false ) {
		$mwServices = $this->getServiceContainer();
		if ( !$this->config ) {
			$this->config = new HashConfig();
		}
		$config = new MultiConfig( [
			$this->config,
			$mwServices->getMainConfig()
		] );

		// We need a real HookContainer since testProviderChangeAuthenticationData()
		// modifies $wgHooks
		$hookContainer = $mwServices->getHookContainer();

		if ( !$this->manager ) {
			$userNameUtils = $this->createNoOpMock( UserNameUtils::class );

			$this->manager = new AuthManager(
				new FauxRequest(),
				$config,
				$this->getDummyObjectFactory(),
				$hookContainer,
				$mwServices->getReadOnlyMode(),
				$userNameUtils,
				$mwServices->getBlockManager(),
				$mwServices->getWatchlistManager(),
				$mwServices->getDBLoadBalancer(),
				$mwServices->getContentLanguage(),
				$mwServices->getLanguageConverterFactory(),
				$mwServices->getBotPasswordStore(),
				$mwServices->getUserFactory(),
				$mwServices->getUserIdentityLookup(),
				$mwServices->getUserOptionsManager()
			);
		}
		$this->validity = Status::newGood();
		$provider = $this->getMockBuilder( LocalPasswordPrimaryAuthenticationProvider::class )
			->onlyMethods( [ 'checkPasswordValidity' ] )
			->setConstructorArgs( [
				$mwServices->getConnectionProvider(),
				[ 'loginOnly' => $loginOnly ]
			] )
			->getMock();

		$provider->method( 'checkPasswordValidity' )
			->willReturnCallback( function () {
				return $this->validity;
			} );
		$this->initProvider(
			$provider, $config, null, $this->manager, $hookContainer, $this->getServiceContainer()->getUserNameUtils()
		);

		return $provider;
	}

	public function testBasics() {
		$user = $this->getMutableTestUser()->getUser();
		$userName = $user->getName();
		$lowerInitialUserName = mb_strtolower( $userName[0] ) . substr( $userName, 1 );

		$provider = $this->getProvider();

		$this->assertSame(
			PrimaryAuthenticationProvider::TYPE_CREATE,
			$provider->accountCreationType()
		);

		$this->assertTrue( $provider->testUserExists( $userName ) );
		$this->assertTrue( $provider->testUserExists( $lowerInitialUserName ) );
		$this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) );
		$this->assertFalse( $provider->testUserExists( '<invalid>' ) );

		$provider = $this->getProvider( [ 'loginOnly' => true ] );

		$this->assertSame(
			PrimaryAuthenticationProvider::TYPE_NONE,
			$provider->accountCreationType()
		);

		$this->assertTrue( $provider->testUserExists( $userName ) );
		$this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) );

		$req = new PasswordAuthenticationRequest;
		$req->action = AuthManager::ACTION_CHANGE;
		$req->username = '<invalid>';
		$provider->providerChangeAuthenticationData( $req );
	}

	public function testTestUserCanAuthenticate() {
		$user = $this->getMutableTestUser()->getUser();
		$userName = $user->getName();
		$dbw = $this->getDb();

		$provider = $this->getProvider();

		$this->assertFalse( $provider->testUserCanAuthenticate( '<invalid>' ) );

		$this->assertFalse( $provider->testUserCanAuthenticate( 'DoesNotExist' ) );

		$this->assertTrue( $provider->testUserCanAuthenticate( $userName ) );
		$lowerInitialUserName = mb_strtolower( $userName[0] ) . substr( $userName, 1 );
		$this->assertTrue( $provider->testUserCanAuthenticate( $lowerInitialUserName ) );

		$dbw->newUpdateQueryBuilder()
			->update( 'user' )
			->set( [ 'user_password' => PasswordFactory::newInvalidPassword()->toString() ] )
			->where( [ 'user_name' => $userName ] )
			->caller( __METHOD__ )
			->execute();
		$this->assertFalse( $provider->testUserCanAuthenticate( $userName ) );

		// Really old format
		$dbw->newUpdateQueryBuilder()
			->update( 'user' )
			->set( [ 'user_password' => '0123456789abcdef0123456789abcdef' ] )
			->where( [ 'user_name' => $userName ] )
			->caller( __METHOD__ )
			->execute();
		$this->assertTrue( $provider->testUserCanAuthenticate( $userName ) );
	}

	public function testSetPasswordResetFlag() {
		// Set instance vars
		$this->getProvider();

		// @todo: Because we're currently using User, which uses the global config...
		$this->overrideConfigValue( MainConfigNames::PasswordExpireGrace, 100 );

		$this->config->set( MainConfigNames::PasswordExpireGrace, 100 );
		$this->config->set( MainConfigNames::InvalidPasswordReset, true );

		$provider = new LocalPasswordPrimaryAuthenticationProvider(
			$this->getServiceContainer()->getConnectionProvider()
		);
		$this->initProvider( $provider, $this->config, null, $this->manager );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );

		$user = $this->getMutableTestUser()->getUser();
		$userName = $user->getName();
		$row = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'user' )
			->where( [ 'user_name' => $userName ] )
			->caller( __METHOD__ )->fetchRow();

		$this->manager->removeAuthenticationSessionData( null );
		$row->user_password_expires = wfTimestamp( TS_MW, time() + 200 );
		$providerPriv->setPasswordResetFlag( $userName, Status::newGood(), $row );
		$this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );

		$this->manager->removeAuthenticationSessionData( null );
		$row->user_password_expires = wfTimestamp( TS_MW, time() - 200 );
		$providerPriv->setPasswordResetFlag( $userName, Status::newGood(), $row );
		$ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
		$this->assertNotNull( $ret );
		$this->assertSame( 'resetpass-expired', $ret->msg->getKey() );
		$this->assertTrue( $ret->hard );

		$this->manager->removeAuthenticationSessionData( null );
		$row->user_password_expires = wfTimestamp( TS_MW, time() - 1 );
		$providerPriv->setPasswordResetFlag( $userName, Status::newGood(), $row );
		$ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
		$this->assertNotNull( $ret );
		$this->assertSame( 'resetpass-expired-soft', $ret->msg->getKey() );
		$this->assertFalse( $ret->hard );

		$this->manager->removeAuthenticationSessionData( null );
		$row->user_password_expires = null;
		$status = Status::newGood( [ 'suggestChangeOnLogin' => true ] );
		$status->error( 'testing' );
		$providerPriv->setPasswordResetFlag( $userName, $status, $row );
		$ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
		$this->assertNotNull( $ret );
		$this->assertSame( 'resetpass-validity-soft', $ret->msg->getKey() );
		$this->assertFalse( $ret->hard );

		$this->manager->removeAuthenticationSessionData( null );
		$row->user_password_expires = null;
		$status = Status::newGood( [ 'forceChange' => true ] );
		$status->error( 'testing' );
		$providerPriv->setPasswordResetFlag( $userName, $status, $row );
		$ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
		$this->assertNotNull( $ret );
		$this->assertSame( 'resetpass-validity', $ret->msg->getKey() );
		$this->assertTrue( $ret->hard );

		$this->manager->removeAuthenticationSessionData( null );
		$row->user_password_expires = null;
		$status = Status::newGood( [ 'suggestChangeOnLogin' => false, ] );
		$status->error( 'testing' );
		$providerPriv->setPasswordResetFlag( $userName, $status, $row );
		$ret = $this->manager->getAuthenticationSessionData( 'reset-pass' );
		$this->assertNull( $ret );
	}

	public function testAuthentication() {
		$testUser = $this->getMutableTestUser();
		$userName = $testUser->getUser()->getName();

		$dbw = $this->getDb();
		$id = $testUser->getUser()->getId();

		$req = new PasswordAuthenticationRequest();
		$req->action = AuthManager::ACTION_LOGIN;
		$reqs = [ PasswordAuthenticationRequest::class => $req ];

		$provider = $this->getProvider();

		// General failures
		$this->assertEquals(
			AuthenticationResponse::newAbstain(),
			$provider->beginPrimaryAuthentication( [] )
		);

		$req->username = 'foo';
		$req->password = null;
		$this->assertEquals(
			AuthenticationResponse::newAbstain(),
			$provider->beginPrimaryAuthentication( $reqs )
		);

		$req->username = null;
		$req->password = 'bar';
		$this->assertEquals(
			AuthenticationResponse::newAbstain(),
			$provider->beginPrimaryAuthentication( $reqs )
		);

		$req->username = '<invalid>';
		$req->password = 'WhoCares';
		$ret = $provider->beginPrimaryAuthentication( $reqs );
		$this->assertEquals(
			AuthenticationResponse::newAbstain(),
			$provider->beginPrimaryAuthentication( $reqs )
		);

		$req->username = 'DoesNotExist';
		$req->password = 'DoesNotExist';
		$ret = $provider->beginPrimaryAuthentication( $reqs );
		$this->assertEquals(
			AuthenticationResponse::FAIL,
			$ret->status
		);
		$this->assertEquals(
			'wrongpassword',
			$ret->message->getKey()
		);

		// Validation failure
		$req->username = $userName;
		$req->password = $testUser->getPassword();
		$this->validity = Status::newFatal( 'arbitrary-failure' );
		$ret = $provider->beginPrimaryAuthentication( $reqs );
		$this->assertEquals(
			AuthenticationResponse::FAIL,
			$ret->status
		);
		// AbstractPasswordPrimaryAuthenticationProvider::getFatalPasswordErrorResponse() will
		// wrap the original message in 'fatalpassworderror'
		$this->assertEquals(
			'fatalpassworderror',
			$ret->message->getKey()
		);

		// Successful auth
		$this->manager->removeAuthenticationSessionData( null );
		$this->validity = Status::newGood();
		$this->assertEquals(
			AuthenticationResponse::newPass( $userName ),
			$provider->beginPrimaryAuthentication( $reqs )
		);
		$this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );

		// Successful auth after normalizing name
		$this->manager->removeAuthenticationSessionData( null );
		$this->validity = Status::newGood();
		$req->username = mb_strtolower( $userName[0] ) . substr( $userName, 1 );
		$this->assertEquals(
			AuthenticationResponse::newPass( $userName ),
			$provider->beginPrimaryAuthentication( $reqs )
		);
		$this->assertNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
		$req->username = $userName;

		// Successful auth with reset
		$this->manager->removeAuthenticationSessionData( null );
		$this->validity = Status::newGood( [ 'suggestChangeOnLogin' => true ] );
		$this->validity->error( 'arbitrary-warning' );
		$this->assertEquals(
			AuthenticationResponse::newPass( $userName ),
			$provider->beginPrimaryAuthentication( $reqs )
		);
		$this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );

		// Wrong password
		$this->validity = Status::newGood();
		$req->password = 'Wrong';
		$ret = $provider->beginPrimaryAuthentication( $reqs );
		$this->assertEquals(
			AuthenticationResponse::FAIL,
			$ret->status
		);
		$this->assertEquals(
			'wrongpassword',
			$ret->message->getKey()
		);

		// Correct handling of legacy encodings
		$password = ':B:salt:' . md5( 'salt-' . md5( "\xe1\xe9\xed\xf3\xfa" ) );
		$dbw->newUpdateQueryBuilder()
			->update( 'user' )
			->set( [ 'user_password' => $password ] )
			->where( [ 'user_name' => $userName ] )
			->caller( __METHOD__ )
			->execute();
		$req->password = 'áéíóú';
		$ret = $provider->beginPrimaryAuthentication( $reqs );
		$this->assertEquals(
			AuthenticationResponse::FAIL,
			$ret->status
		);
		$this->assertEquals(
			'wrongpassword',
			$ret->message->getKey()
		);

		$this->config->set( MainConfigNames::LegacyEncoding, true );
		$this->assertEquals(
			AuthenticationResponse::newPass( $userName ),
			$provider->beginPrimaryAuthentication( $reqs )
		);

		$req->password = 'áéíóú Wrong';
		$ret = $provider->beginPrimaryAuthentication( $reqs );
		$this->assertEquals(
			AuthenticationResponse::FAIL,
			$ret->status
		);
		$this->assertEquals(
			'wrongpassword',
			$ret->message->getKey()
		);

		// Correct handling of really old password hashes
		$password = md5( "$id-" . md5( 'FooBar' ) );
		$dbw->newUpdateQueryBuilder()
			->update( 'user' )
			->set( [ 'user_password' => $password ] )
			->where( [ 'user_name' => $userName ] )
			->caller( __METHOD__ )
			->execute();
		$req->password = 'FooBar';
		$this->assertEquals(
			AuthenticationResponse::newPass( $userName ),
			$provider->beginPrimaryAuthentication( $reqs )
		);
	}

	/**
	 * @dataProvider provideProviderAllowsAuthenticationDataChange
	 *
	 * @param string $type
	 * @param callable $usernameGetter Function that takes the username of a sysop user and returns the username to
	 * use for testing.
	 * @param Status $validity Result of the password validity check
	 * @param StatusValue $expect1 Expected result with $checkData = false
	 * @param StatusValue $expect2 Expected result with $checkData = true
	 */
	public function testProviderAllowsAuthenticationDataChange( $type, callable $usernameGetter, Status $validity,
		StatusValue $expect1,
		StatusValue $expect2
	) {
		$user = $usernameGetter( $this->getTestSysop()->getUserIdentity()->getName() );
		if ( $type === PasswordAuthenticationRequest::class ) {
			$req = new $type();
			$req->password = 'NewPassword';
			$req->retype = 'NewPassword';
		} elseif ( $type === PasswordDomainAuthenticationRequest::class ) {
			$req = new $type( [] );
		} else {
			$req = $this->createMock( $type );
		}
		$req->action = AuthManager::ACTION_CHANGE;
		$req->username = $user;

		$provider = $this->getProvider();
		$this->validity = $validity;
		$this->assertEquals( $expect1, $provider->providerAllowsAuthenticationDataChange( $req, false ) );
		$this->assertEquals( $expect2, $provider->providerAllowsAuthenticationDataChange( $req, true ) );

		if ( $req instanceof PasswordAuthenticationRequest ) {
			$req->retype = 'BadRetype';
		}
		$this->assertEquals(
			$expect1,
			$provider->providerAllowsAuthenticationDataChange( $req, false )
		);
		$this->assertEquals(
			$expect2->getValue() === 'ignored' ? $expect2 : StatusValue::newFatal( 'badretype' ),
			$provider->providerAllowsAuthenticationDataChange( $req, true )
		);

		$provider = $this->getProvider( true );
		$this->assertEquals(
			StatusValue::newGood( 'ignored' ),
			$provider->providerAllowsAuthenticationDataChange( $req, true ),
			'loginOnly mode should claim to ignore all changes'
		);
	}

	public static function provideProviderAllowsAuthenticationDataChange() {
		$err = StatusValue::newGood();
		$err->error( 'arbitrary-warning' );

		return [
			[
				AuthenticationRequest::class,
				static fn ( $sysopUsername ) => $sysopUsername,
				Status::newGood(),
				StatusValue::newGood( 'ignored' ),
				StatusValue::newGood( 'ignored' )
			],
			[
				PasswordAuthenticationRequest::class,
				static fn ( $sysopUsername ) => $sysopUsername,
				Status::newGood(),
				StatusValue::newGood(),
				StatusValue::newGood()
			],
			[
				PasswordAuthenticationRequest::class,
				'lcfirst',
				Status::newGood(),
				StatusValue::newGood(),
				StatusValue::newGood()
			],
			[
				PasswordAuthenticationRequest::class,
				static fn ( $sysopUsername ) => $sysopUsername,
				Status::wrap( $err ),
				StatusValue::newGood(),
				$err
			],
			[
				PasswordAuthenticationRequest::class,
				static fn ( $sysopUsername ) => $sysopUsername,
				Status::newFatal( 'arbitrary-error' ),
				StatusValue::newGood(),
				StatusValue::newFatal( 'arbitrary-error' )
			],
			[
				PasswordAuthenticationRequest::class,
				static fn () => 'DoesNotExist',
				Status::newGood(),
				StatusValue::newGood(),
				StatusValue::newGood( 'ignored' )
			],
			[
				PasswordDomainAuthenticationRequest::class,
				static fn ( $sysopUsername ) => $sysopUsername,
				Status::newGood(),
				StatusValue::newGood( 'ignored' ),
				StatusValue::newGood( 'ignored' )
			],
		];
	}

	/**
	 * @dataProvider provideProviderChangeAuthenticationData
	 * @param callable|false $usernameTransform
	 * @param string $type
	 * @param bool $loginOnly
	 * @param bool $changed
	 */
	public function testProviderChangeAuthenticationData(
			$usernameTransform, $type, $loginOnly, $changed ) {
		$testUser = $this->getMutableTestUser();
		$user = $testUser->getUser()->getName();
		if ( is_callable( $usernameTransform ) ) {
			$user = $usernameTransform( $user );
		}
		$cuser = ucfirst( $user );
		$oldpass = $testUser->getPassword();
		$newpass = 'NewPassword';

		$dbw = $this->getDb();
		$oldExpiry = $dbw->newSelectQueryBuilder()
			->select( 'user_password_expires' )
			->from( 'user' )
			->where( [ 'user_name' => $cuser ] )
			->fetchField();

		$this->mergeMwGlobalArrayValue( 'wgHooks', [
			'ResetPasswordExpiration' => [ static function ( $user, &$expires ) {
				$expires = '30001231235959';
			} ]
		] );

		$provider = $this->getProvider( $loginOnly );

		$loginReq = new PasswordAuthenticationRequest();
		$loginReq->action = AuthManager::ACTION_LOGIN;
		$loginReq->username = $user;
		$loginReq->password = $oldpass;
		$loginReqs = [ PasswordAuthenticationRequest::class => $loginReq ];
		$this->assertEquals(
			AuthenticationResponse::newPass( $cuser ),
			$provider->beginPrimaryAuthentication( $loginReqs )
		);

		if ( $type === PasswordAuthenticationRequest::class ) {
			$changeReq = new $type();
			$changeReq->password = $newpass;
		} else {
			$changeReq = $this->createMock( $type );
		}
		$changeReq->action = AuthManager::ACTION_CHANGE;
		$changeReq->username = $user;
		$provider->providerChangeAuthenticationData( $changeReq );

		if ( $loginOnly && $changed ) {
			$old = 'fail';
			$new = 'fail';
			$expectExpiry = null;
		} elseif ( $changed ) {
			$old = 'fail';
			$new = 'pass';
			$expectExpiry = '30001231235959';
		} else {
			$old = 'pass';
			$new = 'fail';
			$expectExpiry = $oldExpiry;
		}

		$loginReq->password = $oldpass;
		$ret = $provider->beginPrimaryAuthentication( $loginReqs );
		if ( $old === 'pass' ) {
			$this->assertEquals(
				AuthenticationResponse::newPass( $cuser ),
				$ret,
				'old password should pass'
			);
		} else {
			$this->assertEquals(
				AuthenticationResponse::FAIL,
				$ret->status,
				'old password should fail'
			);
			$this->assertEquals(
				'wrongpassword',
				$ret->message->getKey(),
				'old password should fail'
			);
		}

		$loginReq->password = $newpass;
		$ret = $provider->beginPrimaryAuthentication( $loginReqs );
		if ( $new === 'pass' ) {
			$this->assertEquals(
				AuthenticationResponse::newPass( $cuser ),
				$ret,
				'new password should pass'
			);
		} else {
			$this->assertEquals(
				AuthenticationResponse::FAIL,
				$ret->status,
				'new password should fail'
			);
			$this->assertEquals(
				'wrongpassword',
				$ret->message->getKey(),
				'new password should fail'
			);
		}

		$this->assertSame(
			$expectExpiry,
			wfTimestampOrNull(
				TS_MW,
				$dbw->newSelectQueryBuilder()
					->select( 'user_password_expires' )
					->from( 'user' )
					->where( [ 'user_name' => $cuser ] )
					->fetchField()
			)
		);
	}

	public static function provideProviderChangeAuthenticationData() {
		return [
			[ false, AuthenticationRequest::class, false, false ],
			[ false, PasswordAuthenticationRequest::class, false, true ],
			[ false, AuthenticationRequest::class, true, false ],
			[ false, PasswordAuthenticationRequest::class, true, true ],
			[ 'ucfirst', PasswordAuthenticationRequest::class, false, true ],
			[ 'ucfirst', PasswordAuthenticationRequest::class, true, true ],
		];
	}

	public function testTestForAccountCreation() {
		$user = User::newFromName( 'foo' );
		$req = new PasswordAuthenticationRequest();
		$req->action = AuthManager::ACTION_CREATE;
		$req->username = 'Foo';
		$req->password = 'Bar';
		$req->retype = 'Bar';
		$reqs = [ PasswordAuthenticationRequest::class => $req ];

		$provider = $this->getProvider();
		$this->assertEquals(
			StatusValue::newGood(),
			$provider->testForAccountCreation( $user, $user, [] ),
			'No password request'
		);

		$this->assertEquals(
			StatusValue::newGood(),
			$provider->testForAccountCreation( $user, $user, $reqs ),
			'Password request, validated'
		);

		$req->retype = 'Baz';
		$this->assertEquals(
			StatusValue::newFatal( 'badretype' ),
			$provider->testForAccountCreation( $user, $user, $reqs ),
			'Password request, bad retype'
		);
		$req->retype = 'Bar';

		$this->validity->error( 'arbitrary warning' );
		$expect = StatusValue::newGood();
		$expect->error( 'arbitrary warning' );
		$this->assertEquals(
			$expect,
			$provider->testForAccountCreation( $user, $user, $reqs ),
			'Password request, not validated'
		);

		$provider = $this->getProvider( true );
		$this->validity->error( 'arbitrary warning' );
		$this->assertEquals(
			StatusValue::newGood(),
			$provider->testForAccountCreation( $user, $user, $reqs ),
			'Password request, not validated, loginOnly'
		);
	}

	public function testAccountCreation() {
		$user = User::newFromName( 'Foo' );

		$req = new PasswordAuthenticationRequest();
		$req->action = AuthManager::ACTION_CREATE;
		$reqs = [ PasswordAuthenticationRequest::class => $req ];

		$provider = $this->getProvider( true );
		try {
			$provider->beginPrimaryAccountCreation( $user, $user, [] );
			$this->fail( 'Expected exception was not thrown' );
		} catch ( BadMethodCallException $ex ) {
			$this->assertSame(
				'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage()
			);
		}

		try {
			$provider->finishAccountCreation( $user, $user, AuthenticationResponse::newPass() );
			$this->fail( 'Expected exception was not thrown' );
		} catch ( BadMethodCallException $ex ) {
			$this->assertSame(
				'Shouldn\'t call this when accountCreationType() is NONE', $ex->getMessage()
			);
		}

		$provider = $this->getProvider( false );

		$this->assertEquals(
			AuthenticationResponse::newAbstain(),
			$provider->beginPrimaryAccountCreation( $user, $user, [] )
		);

		$req->username = 'foo';
		$req->password = null;
		$this->assertEquals(
			AuthenticationResponse::newAbstain(),
			$provider->beginPrimaryAccountCreation( $user, $user, $reqs )
		);

		$req->username = null;
		$req->password = 'bar';
		$this->assertEquals(
			AuthenticationResponse::newAbstain(),
			$provider->beginPrimaryAccountCreation( $user, $user, $reqs )
		);

		$req->username = 'foo';
		$req->password = 'bar';

		$expect = AuthenticationResponse::newPass( 'Foo' );
		$expect->createRequest = clone $req;
		$expect->createRequest->username = 'Foo';
		$this->assertEquals( $expect, $provider->beginPrimaryAccountCreation( $user, $user, $reqs ) );

		$user = $this->getTestSysop()->getUser();
		$req->username = $user->getName();
		$req->password = 'NewPassword';
		$expect = AuthenticationResponse::newPass( $user->getName() );
		$expect->createRequest = $req;

		$res2 = $provider->beginPrimaryAccountCreation( $user, $user, $reqs );
		$this->assertEquals( $expect, $res2 );

		$ret = $provider->beginPrimaryAuthentication( $reqs );
		$this->assertEquals( AuthenticationResponse::FAIL, $ret->status );

		$this->assertNull( $provider->finishAccountCreation( $user, $user, $res2 ) );
		$ret = $provider->beginPrimaryAuthentication( $reqs );
		$this->assertEquals( AuthenticationResponse::PASS, $ret->status, 'new password is set' );
	}
}
PK       ! ]	  ]	  (  auth/ButtonAuthenticationRequestTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\ButtonAuthenticationRequest;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\ButtonAuthenticationRequest
 */
class ButtonAuthenticationRequestTest extends AuthenticationRequestTestCase {

	protected function getInstance( array $args = [] ) {
		$data = array_intersect_key( $args, [ 'name' => 1, 'label' => 1, 'help' => 1 ] );
		if ( $args['name'] === 'foo' ) {
			return ButtonAuthenticationRequestForLoadFromSubmission::__set_state( $data );
		}
		return ButtonAuthenticationRequest::__set_state( $data );
	}

	public static function provideGetFieldInfo() {
		return [
			[ [ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ] ]
		];
	}

	public static function provideLoadFromSubmission() {
		return [
			'Empty request' => [
				[ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ],
				[],
				false
			],
			'Button present' => [
				[ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz' ],
				[ 'foo' => 'Foobar' ],
				[ 'name' => 'foo', 'label' => 'bar', 'help' => 'baz', 'foo' => true ]
			],
		];
	}

	public function testGetUniqueId() {
		$req = new ButtonAuthenticationRequest( 'foo', wfMessage( 'bar' ), wfMessage( 'baz' ) );
		$this->assertSame(
			'MediaWiki\\Auth\\ButtonAuthenticationRequest:foo', $req->getUniqueId()
		);
	}

	public function testGetRequestByName() {
		$reqs = [];
		$reqs['testOne'] = new ButtonAuthenticationRequest(
			'foo', wfMessage( 'msg' ), wfMessage( 'help' )
		);
		$reqs[] = new ButtonAuthenticationRequest( 'bar', wfMessage( 'msg1' ), wfMessage( 'help1' ) );
		$reqs[] = new ButtonAuthenticationRequest( 'bar', wfMessage( 'msg2' ), wfMessage( 'help2' ) );
		$reqs['testSub'] =
			new ButtonAuthenticationRequest( 'subclass', wfMessage( 'msg3' ), wfMessage( 'help3' ) );

		$this->assertNull( ButtonAuthenticationRequest::getRequestByName( $reqs, 'missing' ) );
		$this->assertSame(
			$reqs['testOne'], ButtonAuthenticationRequest::getRequestByName( $reqs, 'foo' )
		);
		$this->assertNull( ButtonAuthenticationRequest::getRequestByName( $reqs, 'bar' ) );
		$this->assertSame(
			$reqs['testSub'], ButtonAuthenticationRequest::getRequestByName( $reqs, 'subclass' )
		);
	}
}

// Dynamic properties from the testLoadFromSubmission not working in php8.2
class ButtonAuthenticationRequestForLoadFromSubmission extends ButtonAuthenticationRequest {
	/** @var string */
	public $foo;
}
PK       ! bs  s  &  auth/AuthenticationRequestTestCase.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Message\Message;
use MediaWikiIntegrationTestCase;
use ReflectionMethod;
use const E_USER_DEPRECATED;

/**
 * @group AuthManager
 */
abstract class AuthenticationRequestTestCase extends MediaWikiIntegrationTestCase {

	/**
	 * @param array $args
	 *
	 * @return AuthenticationRequest
	 */
	abstract protected function getInstance( array $args = [] );

	/**
	 * @dataProvider provideGetFieldInfo
	 */
	public function testGetFieldInfo( array $args ) {
		$info = $this->getInstance( $args )->getFieldInfo();
		$this->assertIsArray( $info );

		foreach ( $info as $field => $data ) {
			$this->assertIsArray( $data, "Field $field" );
			$this->assertArrayHasKey( 'type', $data, "Field $field" );
			$this->assertArrayHasKey( 'label', $data, "Field $field" );
			$this->assertInstanceOf( Message::class, $data['label'], "Field $field, label" );

			if ( $data['type'] !== 'null' ) {
				$this->assertArrayHasKey( 'help', $data, "Field $field" );
				$this->assertInstanceOf( Message::class, $data['help'], "Field $field, help" );
			}

			if ( isset( $data['optional'] ) ) {
				$this->assertIsBool( $data['optional'], "Field $field, optional" );
			}
			if ( isset( $data['image'] ) ) {
				$this->assertIsString( $data['image'], "Field $field, image" );
			}
			if ( isset( $data['sensitive'] ) ) {
				$this->assertIsBool( $data['sensitive'], "Field $field, sensitive" );
			}
			if ( $data['type'] === 'password' ) {
				$this->assertTrue( !empty( $data['sensitive'] ),
					"Field $field, password field must be sensitive" );
			}

			switch ( $data['type'] ) {
				case 'string':
				case 'password':
				case 'hidden':
					break;
				case 'select':
				case 'multiselect':
					$this->assertArrayHasKey( 'options', $data, "Field $field" );
					$this->assertIsArray( $data['options'], "Field $field, options" );
					foreach ( $data['options'] as $val => $msg ) {
						$this->assertInstanceOf( Message::class, $msg, "Field $field, option $val" );
					}
					break;
				case 'checkbox':
					break;
				case 'button':
					break;
				case 'null':
					break;
				default:
					$this->fail( "Field $field, unknown type " . $data['type'] );
					break;
			}
		}
	}

	public static function provideGetFieldInfo() {
		return [
			[ [] ]
		];
	}

	/**
	 * @dataProvider provideLoadFromSubmissionStatically
	 * @param array $args
	 * @param array $data
	 * @param array|bool $expectState
	 */
	public function testLoadFromSubmission( array $args, array $data, $expectState ) {
		$instance = $this->getInstance( $args );
		$ret = $instance->loadFromSubmission( $data );
		if ( is_array( $expectState ) ) {
			$this->assertTrue( $ret );
			$expect = $instance::__set_state( $expectState );
			$this->assertEquals( $expect, $instance );
		} else {
			$this->assertFalse( $ret );
		}
	}

	// abstract public static function provideLoadFromSubmission();

	/**
	 * Tempory override to make provideLoadFromSubmission static.
	 * See T332865.
	 */
	final public static function provideLoadFromSubmissionStatically() {
		$reflectionMethod = new ReflectionMethod( static::class, 'provideLoadFromSubmission' );
		if ( $reflectionMethod->isStatic() ) {
			return $reflectionMethod->invoke( null );
		}

		trigger_error(
			'overriding provideLoadFromSubmission as an instance method is deprecated. (' .
			$reflectionMethod->getFileName() . ':' . $reflectionMethod->getEndLine() . ')',
			E_USER_DEPRECATED
		);

		return $reflectionMethod->invoke( new static() );
	}
}

/** @deprecated class alias since 1.42 */
class_alias( AuthenticationRequestTestCase::class, 'MediaWiki\\Auth\\AuthenticationRequestTestCase' );
PK       ! r[  [  ;  auth/TemporaryPasswordPrimaryAuthenticationProviderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\PasswordAuthenticationRequest;
use MediaWiki\Auth\PrimaryAuthenticationProvider;
use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest;
use MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider;
use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Password\PasswordFactory;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Unit\Auth\AuthenticationProviderTestTrait;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserNameUtils;
use MediaWikiIntegrationTestCase;
use StatusValue;
use Wikimedia\Message\MessageSpecifier;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;

/**
 * TODO clean up and reduce duplication
 *
 * @group AuthManager
 * @group Database
 * @covers \MediaWiki\Auth\AbstractTemporaryPasswordPrimaryAuthenticationProvider
 * @covers \MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider
 */
class TemporaryPasswordPrimaryAuthenticationProviderTest extends MediaWikiIntegrationTestCase {
	use AuthenticationProviderTestTrait;
	use DummyServicesTrait;

	private AuthManager $manager;
	private Status $validity;

	private PasswordFactory $testPasswordFactory;

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

		$mwServices = $this->getServiceContainer();

		$hookContainer = $this->createHookContainer();

		$this->manager = new AuthManager(
			new FauxRequest(),
			$mwServices->getMainConfig(),
			$this->getDummyObjectFactory(),
			$hookContainer,
			$mwServices->getReadOnlyMode(),
			$this->createNoOpMock( UserNameUtils::class ),
			$mwServices->getBlockManager(),
			$mwServices->getWatchlistManager(),
			$mwServices->getDBLoadBalancer(),
			$mwServices->getContentLanguage(),
			$mwServices->getLanguageConverterFactory(),
			$mwServices->getBotPasswordStore(),
			$mwServices->getUserFactory(),
			$mwServices->getUserIdentityLookup(),
			$mwServices->getUserOptionsManager()
		);

		$this->validity = Status::newGood();

		// A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
		$this->testPasswordFactory = new PasswordFactory(
			$this->getConfVar( MainConfigNames::PasswordConfig ),
			'A'
		);
	}

	/**
	 * Get an instance of the provider
	 *
	 * $provider->checkPasswordValidity is mocked to return $this->validity,
	 * because we don't need to test that here.
	 *
	 * @param array $params
	 * @param UserNameUtils|null $userNameUtils
	 * @return TemporaryPasswordPrimaryAuthenticationProvider
	 */
	protected function getProvider( array $params = [], ?UserNameUtils $userNameUtils = null ) {
		$userNameUtils ??= $this->getServiceContainer()->getUserNameUtils();
		$mwServices = $this->getServiceContainer();

		$mockedMethods[] = 'checkPasswordValidity';
		$provider = $this->getMockBuilder( TemporaryPasswordPrimaryAuthenticationProvider::class )
			->onlyMethods( $mockedMethods )
			->setConstructorArgs( [
				$mwServices->getConnectionProvider(),
				$mwServices->getUserOptionsLookup(),
				$params,
			] )
			->getMock();
		$provider->method( 'checkPasswordValidity' )
			->willReturnCallback( function () {
				return $this->validity;
			} );
		$this->initProvider(
			$provider, $mwServices->getMainConfig(), null, $this->manager, null, $userNameUtils
		);

		return $provider;
	}

	protected function hookMailer( $func = null ) {
		$hookContainer = $this->getServiceContainer()->getHookContainer();

		$this->clearHook( 'AlternateUserMailer' );

		if ( $func ) {
			$reset = $hookContainer->scopedRegister( 'AlternateUserMailer', $func );
		} else {
			$reset = $hookContainer->scopedRegister( 'AlternateUserMailer', function () {
				$this->fail( 'AlternateUserMailer hook called unexpectedly' );
				return false;
			} );
		}
		return $reset;
	}

	/**
	 * Set the new password (i.e. single use temporary password)
	 * hash for the given user, with an optional expiry time.
	 *
	 * @param UserIdentity $user The user to update the new password for.
	 * @param string $hash Password hash to store.
	 * @param int|null $expiry UNIX timestamp at which the new password expires, or `null` for no expiry.
	 */
	private function setNewPassword(
		UserIdentity $user,
		string $hash,
		?int $expiry = null
	): void {
		$dbw = $this->getDb();
		$dbw->newUpdateQueryBuilder()
			->update( 'user' )
			->set( [
				'user_newpassword' => $hash,
				'user_newpass_time' => $expiry ? $dbw->timestamp( $expiry ) : null
			] )
			->where( [ 'user_id' => $user->getId() ] )
			->execute();
	}

	public function testBasics() {
		$provider = $this->getProvider();

		$this->assertSame(
			PrimaryAuthenticationProvider::TYPE_CREATE,
			$provider->accountCreationType()
		);

		$existingUserName = $this->getTestUser()->getUserIdentity()->getName();
		$this->assertTrue( $provider->testUserExists( $existingUserName ) );
		$this->assertTrue( $provider->testUserExists( lcfirst( $existingUserName ) ) );
		$this->assertFalse( $provider->testUserExists( 'DoesNotExist' ) );
		$this->assertFalse( $provider->testUserExists( '<invalid>' ) );

		$req = new PasswordAuthenticationRequest;
		$req->action = AuthManager::ACTION_CHANGE;
		$req->username = '<invalid>';
		$provider->providerChangeAuthenticationData( $req );
	}

	public function testConfig() {
		$config = new HashConfig( [
			MainConfigNames::EnableEmail => false,
			MainConfigNames::NewPasswordExpiry => 100,
			MainConfigNames::PasswordReminderResendTime => 101,
		] );

		$provider = new TemporaryPasswordPrimaryAuthenticationProvider(
			$this->getServiceContainer()->getConnectionProvider(),
			$this->getServiceContainer()->getUserOptionsLookup()
		);
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$this->initProvider( $provider, $config );
		$this->assertSame( false, $providerPriv->emailEnabled );
		$this->assertSame( 100, $providerPriv->newPasswordExpiry );
		$this->assertSame( 101, $providerPriv->passwordReminderResendTime );

		$provider = new TemporaryPasswordPrimaryAuthenticationProvider(
			$this->getServiceContainer()->getConnectionProvider(),
			$this->getServiceContainer()->getUserOptionsLookup(),
			[
				'emailEnabled' => true,
				'newPasswordExpiry' => 42,
				'passwordReminderResendTime' => 43,
			]
		);
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$this->initProvider( $provider, $config );
		$this->assertSame( true, $providerPriv->emailEnabled );
		$this->assertSame( 42, $providerPriv->newPasswordExpiry );
		$this->assertSame( 43, $providerPriv->passwordReminderResendTime );
	}

	/**
	 * @dataProvider provideTestUserCanAuthenticateErrorCases
	 *
	 * @param string|null $userName The user name to check, or `null` to use the user name of the test user
	 * @param callable|null $passwordProvider Optional callable that takes a `PasswordFactory` and produces
	 * a password hash override to set for the test user
	 * @param int|null $passwordExpiry Expiry to set for the password returned by `$passwordProvider`, or
	 * `null` to set no expiry.
	 * @return void
	 */
	public function testTestUserCanAuthenticateErrorCases(
		?string $userName = null,
		?callable $passwordProvider = null,
		?int $passwordExpiry = null
	): void {
		$user = self::getMutableTestUser()->getUser();

		if ( $passwordProvider !== null ) {
			$this->setNewPassword(
				$user,
				$passwordProvider( $this->testPasswordFactory ),
				$passwordExpiry
			);
		}

		$userName ??= $user->getName();

		$result = $this->getProvider( [ 'newPasswordExpiry' => 100 ] )->testUserCanAuthenticate( $userName );

		$this->assertFalse( $result );
	}

	public function provideTestUserCanAuthenticateErrorCases(): iterable {
		yield 'invalid user name' => [ '<invalid>' ];
		yield 'nonexistent user' => [ 'DoesNotExist' ];
		yield 'user with invalid password' => [
			null,
			fn () => PasswordFactory::newInvalidPassword()->toString()
		];
		yield 'user with expired password' => [
			null,
			fn ( PasswordFactory $passwordFactory ) => $passwordFactory->newFromPlaintext( 'password' )->toString(),
			time() - 3_600
		];
	}

	public function testTestUserCanAuthenticateSimple(): void {
		$user = self::getMutableTestUser()->getUser();

		$this->setNewPassword(
			$user,
			$this->testPasswordFactory->newFromPlaintext( 'password' )->toString()
		);

		$result = $this->getProvider()->testUserCanAuthenticate( $user->getName() );

		$this->assertTrue( $result );
	}

	public function testTestUserCanAuthenticateCaseInsensitive(): void {
		$user = self::getMutableTestUser()->getUser();

		$this->setNewPassword(
			$user,
			$this->testPasswordFactory->newFromPlaintext( 'password' )->toString()
		);

		$result = $this->getProvider()->testUserCanAuthenticate( lcfirst( $user->getName() ) );

		$this->assertTrue( $result );
	}

	public function testTestUserCanAuthenticateWithNonExpiredTemporaryPassword(): void {
		$user = self::getMutableTestUser()->getUser();

		$this->setNewPassword(
			$user,
			$this->testPasswordFactory->newFromPlaintext( 'password' )->toString(),
			time() - 100
		);

		$result = $this->getProvider( [ 'newPasswordExpiry' => 3600 ] )->testUserCanAuthenticate( $user->getName() );

		$this->assertTrue( $result );
	}

	/**
	 * @dataProvider provideGetAuthenticationRequests
	 * @param string $action
	 * @param bool $registered
	 * @param bool $temporary
	 * @param AuthenticationRequest[] $expected
	 */
	public function testGetAuthenticationRequests(
		string $action,
		bool $registered,
		bool $temporary,
		array $expected
	) {
		$username = $registered ? 'TestGetAuthenticationRequests' : null;
		$options = [ 'username' => $username ];

		$userNameUtils = $this->createMock( UserNameUtils::class );
		$userNameUtils->method( 'isTemp' )
			->with( $username )
			->willReturn( $temporary );

		$actual = $this->getProvider( [ 'emailEnabled' => true ], $userNameUtils )
			->getAuthenticationRequests( $action, $options );
		foreach ( $actual as $req ) {
			if ( $req instanceof TemporaryPasswordAuthenticationRequest && $req->password !== null ) {
				$req->password = 'random';
			}
		}
		$this->assertEquals( $expected, $actual );
	}

	public static function provideGetAuthenticationRequests(): iterable {
		yield 'login attempt as anonymous user' => [
			AuthManager::ACTION_LOGIN, false, false, [ new PasswordAuthenticationRequest ]
		];

		yield 'login attempt as named user' => [
			AuthManager::ACTION_LOGIN, true, false, [ new PasswordAuthenticationRequest ]
		];

		yield 'login attempt as temporary user' => [
			AuthManager::ACTION_LOGIN, true, true, [ new PasswordAuthenticationRequest ]
		];

		yield 'signup attempt as anonymous user' => [
			AuthManager::ACTION_CREATE, false, false, []
		];

		yield 'signup attempt as named user' => [
			AuthManager::ACTION_CREATE, true, false, [ new TemporaryPasswordAuthenticationRequest( 'random' ) ]
		];

		yield 'signup attempt as temporary user' => [
			AuthManager::ACTION_CREATE, true, true, []
		];

		yield 'account linking attempt as anonymous user' => [
			AuthManager::ACTION_LINK, false, false, []
		];

		yield 'account linking attempt as named user' => [
			AuthManager::ACTION_LINK, true, false, []
		];

		yield 'account linking attempt as temporary user' => [
			AuthManager::ACTION_LINK, true, true, []
		];

		yield 'credential change attempt as anonymous user' => [
			AuthManager::ACTION_CHANGE, false, false, [ new TemporaryPasswordAuthenticationRequest( 'random' ) ]
		];

		yield 'credential change attempt as named user' => [
			AuthManager::ACTION_CHANGE, true, false, [ new TemporaryPasswordAuthenticationRequest( 'random' ) ]
		];

		yield 'credential change attempt as temporary user' => [
			AuthManager::ACTION_CHANGE, true, true, [ new TemporaryPasswordAuthenticationRequest( 'random' ) ]
		];

		yield 'credential remove attempt as anonymous user' => [
			AuthManager::ACTION_REMOVE, false, false, [ new TemporaryPasswordAuthenticationRequest() ]
		];

		yield 'credential remove attempt as named user' => [
			AuthManager::ACTION_REMOVE, true, false, [ new TemporaryPasswordAuthenticationRequest() ]
		];

		yield 'credential remove attempt as temporary user' => [
			AuthManager::ACTION_REMOVE, true, true, [ new TemporaryPasswordAuthenticationRequest() ]
		];
	}

	/**
	 * @dataProvider provideAuthenticationErrorCases
	 * @param string $password
	 * @param string $expectedErrorMessage
	 * @param int $newPasswordExpiry
	 * @param StatusValue|null $validationError
	 * @return void
	 */
	public function testAuthenticationErrorCases(
		string $password,
		string $expectedErrorMessage,
		int $newPasswordExpiry = 100,
		?StatusValue $validationError = null
	) {
		$user = self::getMutableTestUser()->getUser();

		$validPassword = 'TemporaryPassword';
		$hash = ':A:' . md5( $validPassword );

		$this->setNewPassword( $user, $hash, time() - 10 );

		$req = self::makePasswordAuthenticationRequest( $user->getName(), $password );

		$reqs = [ PasswordAuthenticationRequest::class => $req ];

		$provider = $this->getProvider( [ 'newPasswordExpiry' => $newPasswordExpiry ] );

		$this->validity = $validationError ?? Status::newGood();

		$response = $provider->beginPrimaryAuthentication( $reqs );

		$this->assertSame( AuthenticationResponse::FAIL, $response->status );
		if ( $validationError !== null ) {
			$this->assertSame(
				$validationError->getMessages()[0]->getKey(),
				$response->message->getParams()[0]->getKey()
			);
		}
	}

	public static function provideAuthenticationErrorCases(): iterable {
		yield 'validation failure' => [
			'TemporaryPassword',
			'fatalpassworderror',
			100,
			Status::newFatal( 'arbitrary-failure' )
		];

		yield 'expired password' => [
			'TemporaryPassword',
			'wrongpassword',
			1
		];

		yield 'wrong password' => [
			'Wrong',
			'wrongpassword'
		];
	}

	/**
	 * @dataProvider provideAuthenticationAbstainCases
	 * @param PasswordAuthenticationRequest|null $req The authentication request to send,
	 * or `null` to send no requests
	 * @return void
	 */
	public function testAuthenticationAbstainCases( ?PasswordAuthenticationRequest $req ): void {
		$reqs = $req ? [ PasswordAuthenticationRequest::class => $req ] : [];

		$response = $this->getProvider()->beginPrimaryAuthentication( $reqs );

		$this->assertEquals( AuthenticationResponse::newAbstain(), $response );
	}

	public static function provideAuthenticationAbstainCases(): iterable {
		yield 'no requests' => [ null ];
		yield 'no user name' => [ self::makePasswordAuthenticationRequest( null, 'bar' ) ];
		yield 'no password' => [ self::makePasswordAuthenticationRequest( 'foo' ) ];
		yield 'invalid user name' => [ self::makePasswordAuthenticationRequest( '<invalid>', 'bar' ) ];
		yield 'nonexistent user' => [ self::makePasswordAuthenticationRequest( 'DoesNotExist', 'bar' ) ];
	}

	private static function makePasswordAuthenticationRequest(
		?string $userName = null,
		?string $password = null
	): PasswordAuthenticationRequest {
		$req = new PasswordAuthenticationRequest();
		$req->action = AuthManager::ACTION_LOGIN;
		$req->username = $userName;
		$req->password = $password;
		return $req;
	}

	public function testAuthenticationSuccess(): void {
		$user = self::getMutableTestUser()->getUser();

		$password = 'TemporaryPassword';
		$hash = ':A:' . md5( $password );

		$this->setNewPassword( $user, $hash, time() - 10 );

		$req = self::makePasswordAuthenticationRequest( $user->getName(), $password );
		$reqs = [ PasswordAuthenticationRequest::class => $req ];

		$provider = $this->getProvider();

		$this->manager->removeAuthenticationSessionData( null );
		$this->validity = Status::newGood();

		$this->assertEquals(
			AuthenticationResponse::newPass( $user->getName() ),
			$provider->beginPrimaryAuthentication( $reqs )
		);

		$this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
	}

	public function testAuthenticationSuccessCaseInsensitive(): void {
		$user = self::getMutableTestUser()->getUser();

		$password = 'TemporaryPassword';
		$hash = ':A:' . md5( $password );

		$this->setNewPassword( $user, $hash, time() - 10 );

		$req = self::makePasswordAuthenticationRequest( lcfirst( $user->getName() ), $password );
		$reqs = [ PasswordAuthenticationRequest::class => $req ];

		$provider = $this->getProvider();

		$this->manager->removeAuthenticationSessionData( null );
		$this->validity = Status::newGood();

		$this->assertEquals(
			AuthenticationResponse::newPass( $user->getName() ),
			$provider->beginPrimaryAuthentication( $reqs )
		);

		$this->assertNotNull( $this->manager->getAuthenticationSessionData( 'reset-pass' ) );
	}

	/**
	 * @dataProvider provideProviderAllowsAuthenticationDataChange
	 *
	 * @param string $type
	 * @param callable $usernameGetter Function that takes the username of a sysop user and returns the username to
	 *  use for testing.
	 * @param Status $validity Result of the password validity check
	 * @param StatusValue $expect1 Expected result with $checkData = false
	 * @param StatusValue $expect2 Expected result with $checkData = true
	 */
	public function testProviderAllowsAuthenticationDataChange( $type, callable $usernameGetter,
		Status $validity,
		StatusValue $expect1, StatusValue $expect2
	) {
		$user = $usernameGetter( $this->getTestSysop()->getUserIdentity()->getName() );
		if ( $type === PasswordAuthenticationRequest::class ||
			$type === TemporaryPasswordAuthenticationRequest::class
		) {
			$req = new $type();
			$req->password = 'NewPassword';
		} else {
			$req = $this->createMock( $type );
		}
		$req->action = AuthManager::ACTION_CHANGE;
		$req->username = $user;

		$provider = $this->getProvider();
		$this->validity = $validity;
		$this->assertEquals( $expect1, $provider->providerAllowsAuthenticationDataChange( $req, false ) );
		$this->assertEquals( $expect2, $provider->providerAllowsAuthenticationDataChange( $req, true ) );
	}

	public static function provideProviderAllowsAuthenticationDataChange() {
		$err = StatusValue::newGood();
		$err->error( 'arbitrary-warning' );

		return [
			[
				AuthenticationRequest::class,
				static fn ( $sysopUsername ) => $sysopUsername,
				Status::newGood(),
				StatusValue::newGood( 'ignored' ),
				StatusValue::newGood( 'ignored' ),
			],
			[
				PasswordAuthenticationRequest::class,
				static fn ( $sysopUsername ) => $sysopUsername,
				Status::newGood(),
				StatusValue::newGood( 'ignored' ),
				StatusValue::newGood( 'ignored' ),
			],
			[
				TemporaryPasswordAuthenticationRequest::class,
				static fn ( $sysopUsername ) => $sysopUsername,
				Status::newGood(),
				StatusValue::newGood(),
				StatusValue::newGood(),
			],
			[
				TemporaryPasswordAuthenticationRequest::class,
				'lcfirst',
				Status::newGood(),
				StatusValue::newGood(),
				StatusValue::newGood(),
			],
			[
				TemporaryPasswordAuthenticationRequest::class,
				static fn ( $sysopUsername ) => $sysopUsername,
				Status::wrap( $err ),
				StatusValue::newGood(),
				$err,
			],
			[
				TemporaryPasswordAuthenticationRequest::class,
				static fn ( $sysopUsername ) => $sysopUsername,
				Status::newFatal( 'arbitrary-error' ),
				StatusValue::newGood(),
				StatusValue::newFatal( 'arbitrary-error' ),
			],
			[
				TemporaryPasswordAuthenticationRequest::class,
				static fn () => 'DoesNotExist',
				Status::newGood(),
				StatusValue::newGood(),
				StatusValue::newGood( 'ignored' ),
			],
			[
				TemporaryPasswordAuthenticationRequest::class,
				static fn () => '<invalid>',
				Status::newGood(),
				StatusValue::newGood(),
				StatusValue::newGood( 'ignored' ),
			],
		];
	}

	/**
	 * @dataProvider provideProviderChangeAuthenticationData
	 * @param string $type
	 * @param bool $changed
	 */
	public function testProviderChangeAuthenticationData( $type, $changed ) {
		$user = $this->getTestSysop()->getUserIdentity()->getName();
		$oldpass = 'OldTempPassword';
		$newpass = 'NewTempPassword';

		$dbw = $this->getDb();
		$oldHash = $dbw->newSelectQueryBuilder()
			->select( 'user_newpassword' )
			->from( 'user' )
			->where( [ 'user_name' => $user ] )
			->fetchField();
		$cb = new ScopedCallback( static function () use ( $dbw, $user, $oldHash ) {
			$dbw->newUpdateQueryBuilder()
				->update( 'user' )
				->set( [ 'user_newpassword' => $oldHash ] )
				->where( [ 'user_name' => $user ] )
				->execute();
		} );

		$hash = ':A:' . md5( $oldpass );
		$dbw->newUpdateQueryBuilder()
			->update( 'user' )
			->set( [ 'user_newpassword' => $hash, 'user_newpass_time' => $dbw->timestamp( time() + 1000 ) ] )
			->where( [ 'user_name' => $user ] )
			->execute();

		$provider = $this->getProvider();

		$loginReq = new PasswordAuthenticationRequest();
		$loginReq->action = AuthManager::ACTION_CHANGE;
		$loginReq->username = $user;
		$loginReq->password = $oldpass;
		$loginReqs = [ PasswordAuthenticationRequest::class => $loginReq ];
		$this->assertEquals(
			AuthenticationResponse::newPass( $user ),
			$provider->beginPrimaryAuthentication( $loginReqs )
		);

		if ( $type === PasswordAuthenticationRequest::class ||
			$type === TemporaryPasswordAuthenticationRequest::class
		) {
			$changeReq = new $type();
			$changeReq->password = $newpass;
		} else {
			$changeReq = $this->createMock( $type );
		}
		$changeReq->action = AuthManager::ACTION_CHANGE;
		$changeReq->username = $user;
		$resetMailer = $this->hookMailer();
		$provider->providerChangeAuthenticationData( $changeReq );
		ScopedCallback::consume( $resetMailer );

		$loginReq->password = $oldpass;
		$ret = $provider->beginPrimaryAuthentication( $loginReqs );
		$this->assertEquals(
			AuthenticationResponse::FAIL,
			$ret->status,
			'old password should fail'
		);
		$this->assertEquals(
			'wrongpassword',
			$ret->message->getKey(),
			'old password should fail'
		);

		$loginReq->password = $newpass;
		$ret = $provider->beginPrimaryAuthentication( $loginReqs );
		if ( $changed ) {
			$this->assertEquals(
				AuthenticationResponse::newPass( $user ),
				$ret,
				'new password should pass'
			);
			$this->assertNotNull(
				$dbw->newSelectQueryBuilder()
					->select( 'user_newpass_time' )
					->from( 'user' )
					->where( [ 'user_name' => $user ] )
					->fetchField()
			);
		} else {
			$this->assertEquals(
				AuthenticationResponse::FAIL,
				$ret->status,
				'new password should fail'
			);
			$this->assertEquals(
				'wrongpassword',
				$ret->message->getKey(),
				'new password should fail'
			);
			$this->assertNull(
				$dbw->newSelectQueryBuilder()
					->select( 'user_newpass_time' )
					->from( 'user' )
					->where( [ 'user_name' => $user ] )
					->fetchField()
			);
		}
	}

	public static function provideProviderChangeAuthenticationData() {
		return [
			[ AuthenticationRequest::class, false ],
			[ PasswordAuthenticationRequest::class, false ],
			[ TemporaryPasswordAuthenticationRequest::class, true ],
		];
	}

	/**
	 * @dataProvider provideChangeAuthenticationDataEmailErrorCases
	 *
	 * @param array $providerConfig Configuration to pass on to the auth provider
	 * @param string|null $caller Caller on behalf of which the request is sent
	 * @param string $expectedError Expected error message key
	 */
	public function testProviderChangeAuthenticationDataEmailError(
		array $providerConfig,
		?string $caller,
		string $expectedError
	): void {
		$user = self::getMutableTestUser()->getUser();

		$dbw = $this->getDb();
		$dbw->newUpdateQueryBuilder()
			->update( 'user' )
			->set( [ 'user_newpass_time' => $dbw->timestamp( time() - 5 * 3600 ) ] )
			->where( [ 'user_id' => $user->getId() ] )
			->execute();

		$req = TemporaryPasswordAuthenticationRequest::newRandom();
		$req->username = $user->getName();
		$req->mailpassword = true;
		$req->caller = $caller;

		$provider = $this->getProvider( $providerConfig );
		$status = $provider->providerAllowsAuthenticationDataChange( $req );

		$this->assertFalse( $status->isGood() );
		$this->assertSame(
			[ $expectedError ],
			array_map( fn ( MessageSpecifier $spec ) => $spec->getKey(), $status->getMessages() )
		);
	}

	public static function provideChangeAuthenticationDataEmailErrorCases(): iterable {
		yield 'email disabled' => [
			[ 'emailEnabled' => false ],
			'127.0.0.1',
			'passwordreset-emaildisabled'
		];

		yield 'password reset rate limited' => [
			[ 'emailEnabled' => true, 'passwordReminderResendTime' => 10 ],
			'127.0.0.1',
			'throttled-mailpassword'
		];

		yield 'missing caller' => [
			[ 'emailEnabled' => true, 'passwordReminderResendTime' => 0 ],
			null,
			'passwordreset-nocaller'
		];

		yield 'invalid IP caller' => [
			[ 'emailEnabled' => true, 'passwordReminderResendTime' => 0 ],
			'127.0.0.256',
			'passwordreset-nosuchcaller'
		];

		yield 'invalid registered caller' => [
			[ 'emailEnabled' => true, 'passwordReminderResendTime' => 0 ],
			'<Invalid>',
			'passwordreset-nosuchcaller'
		];
	}

	/**
	 * @dataProvider provideChangeAuthenticationDataEmailSuccessCases
	 * @param string $caller Caller on behalf of which the request is sent
	 */
	public function testProviderChangeAuthenticationDataEmailSuccess( string $caller ) {
		$user = self::getMutableTestUser()->getUser();

		$dbw = $this->getDb();
		$dbw->newUpdateQueryBuilder()
			->update( 'user' )
			->set( [ 'user_newpass_time' => $dbw->timestamp( time() + 5 * 3600 ) ] )
			->where( [ 'user_id' => $user->getId() ] )
			->execute();

		$req = TemporaryPasswordAuthenticationRequest::newRandom();
		$req->username = $user->getName();
		$req->mailpassword = true;
		$req->caller = $caller;

		$provider = $this->getProvider( [ 'emailEnabled' => true, 'passwordReminderResendTime' => 0 ] );

		$status = $provider->providerAllowsAuthenticationDataChange( $req, true );
		$this->assertEquals( StatusValue::newGood(), $status );

		$mailed = false;
		$resetMailer = $this->hookMailer( function ( $headers, $to, $from, $subject, $body )
			use ( &$mailed, $req, $user )
		{
			$mailed = true;
			$this->assertSame( $user->getEmail(), $to[0]->address );
			$this->assertStringContainsString( $req->password, $body );
			return false;
		} );
		$provider->providerChangeAuthenticationData( $req );
		ScopedCallback::consume( $resetMailer );
		$this->assertTrue( $mailed );
	}

	public static function provideChangeAuthenticationDataEmailSuccessCases(): iterable {
		yield 'anonymous caller' => [ '127.0.0.1' ];
		yield 'registered caller' => [ 'TestUser' ];
	}

	/**
	 * @dataProvider provideAccountCreationSuccessCases
	 * @param AuthenticationRequest[] $reqs
	 */
	public function testTestForAccountCreationSuccess( array $reqs ) {
		$user = $this->getServiceContainer()->getUserFactory()->newFromName( 'foo' );

		$status = $this->getProvider()->testForAccountCreation( $user, $user, $reqs );

		$this->assertTrue( $status->isGood() );
	}

	public static function provideAccountCreationSuccessCases(): iterable {
		$req = new TemporaryPasswordAuthenticationRequest();
		$req->username = 'Foo';
		$req->password = 'Bar';

		yield 'no password request' => [
			[],
		];

		yield 'validated password request' => [
			[ TemporaryPasswordAuthenticationRequest::class => $req ],
		];
	}

	public function testTestForAccountCreationError(): void {
		$req = new TemporaryPasswordAuthenticationRequest();
		$req->username = 'Foo';
		$req->password = 'Bar';

		$user = $this->getServiceContainer()->getUserFactory()->newFromName( 'foo' );
		$provider = $this->getProvider();
		$this->validity->error( 'arbitrary warning' );

		$status = $provider->testForAccountCreation(
			$user, $user, [ TemporaryPasswordAuthenticationRequest::class => $req ]
		);

		$this->assertFalse( $status->isGood() );
		$this->assertTrue( $status->hasMessage( 'arbitrary warning' ) );
	}

	/**
	 * @dataProvider provideAccountCreationAbstainCases
	 * @param TemporaryPasswordAuthenticationRequest|null $req
	 * @return void
	 */
	public function testAccountCreationAbstain( ?TemporaryPasswordAuthenticationRequest $req ) {
		$resetMailer = $this->hookMailer();

		$user = $this->getServiceContainer()->getUserFactory()->newFromName( 'Foo' );

		$reqs = $req ? [ TemporaryPasswordAuthenticationRequest::class => $req ] : [];

		$provider = $this->getProvider();
		$response = $provider->beginPrimaryAccountCreation( $user, $user, $reqs );

		$this->assertSame( AuthenticationResponse::ABSTAIN, $response->status );
	}

	public static function provideAccountCreationAbstainCases(): iterable {
		yield 'no authentication requests' => [
			null,
		];

		yield 'request without password' => [
			self::makeTemporaryPasswordAuthenticationRequest( 'foo' ),
		];

		yield 'request without username' => [
			self::makeTemporaryPasswordAuthenticationRequest( null, 'bar' ),
		];
	}

	public function testAccountCreationPassForUserNameWithDifferentCase(): void {
		$user = $this->getServiceContainer()->getUserFactory()->newFromName( 'Foo' );
		$pass = 'NewPassword';

		$req = self::makeTemporaryPasswordAuthenticationRequest( 'foo', $pass );
		$reqs = [ TemporaryPasswordAuthenticationRequest::class => $req ];

		$provider = $this->getProvider();
		$response = $provider->beginPrimaryAccountCreation( $user, $user, $reqs );

		$this->assertSame( AuthenticationResponse::PASS, $response->status );
		$this->assertSame( $response->username, $user->getName() );
		$this->assertSame(
			$response->createRequest->username,
			$user->getName()
		);
	}

	public function testAccountCreationPass(): void {
		$resetMailer = $this->hookMailer();

		$user = self::getMutableTestUser()->getUser();
		$pass = 'NewPassword';

		$req = self::makeTemporaryPasswordAuthenticationRequest( $user->getName(), $pass );
		$reqs = [ TemporaryPasswordAuthenticationRequest::class => $req ];

		$provider = $this->getProvider();
		$response = $provider->beginPrimaryAccountCreation( $user, $user, $reqs );

		$this->assertSame( AuthenticationResponse::PASS, $response->status );
		$this->assertSame( $response->username, $user->getName() );
		$this->assertSame(
			$response->createRequest->username,
			$user->getName()
		);
		$this->assertNull( $this->manager->getAuthenticationSessionData( 'no-email' ) );

		$authreq = new PasswordAuthenticationRequest();
		$authreq->action = AuthManager::ACTION_CREATE;
		$authreq->username = $user->getName();
		$authreq->password = $pass;

		$authreqs = [ PasswordAuthenticationRequest::class => $authreq ];

		$failedAttemptResponse = $provider->beginPrimaryAuthentication( $authreqs );
		$this->assertSame( AuthenticationResponse::FAIL, $failedAttemptResponse->status, 'account creation not finished yet' );

		$this->assertSame( null, $provider->finishAccountCreation( $user, $user, $response ) );

		$response = $provider->beginPrimaryAuthentication( $authreqs );
		$this->assertSame( AuthenticationResponse::PASS, $response->status, 'new password is set' );
	}

	private static function makeTemporaryPasswordAuthenticationRequest(
		?string $userName = null,
		?string $password = null
	): TemporaryPasswordAuthenticationRequest {
		$req = new TemporaryPasswordAuthenticationRequest();
		$req->username = $userName;
		$req->password = $password;
		return $req;
	}

	/**
	 * @dataProvider provideAccountCreationEmailErrorCases
	 *
	 * @param array $providerConfig Configuration to pass on to the auth provider
	 * @param string $userEmail Email to set for the user being tested
	 * @param string $expectedError Expected error message key
	 */
	public function testAccountCreationEmailErrorCases(
		array $providerConfig,
		string $userEmail,
		string $expectedError
	): void {
		$creator = $this->getServiceContainer()->getUserFactory()->newFromName( 'Foo' );

		$user = self::getMutableTestUser()->getUser();
		$user->setEmail( $userEmail );

		$req = TemporaryPasswordAuthenticationRequest::newRandom();
		$req->username = $user->getName();
		$req->mailpassword = true;

		$provider = $this->getProvider( $providerConfig );
		$status = $provider->testForAccountCreation( $user, $creator, [ $req ] );
		$this->assertEquals( StatusValue::newFatal( $expectedError ), $status );
	}

	public static function provideAccountCreationEmailErrorCases(): iterable {
		yield 'email disabled' => [
			[ 'emailEnabled' => false ],
			'test@localhost.localdomain',
			'emaildisabled'
		];

		yield 'missing user email' => [
			[ 'emailEnabled' => true ],
			'',
			'noemailcreate'
		];
	}

	public function testAccountCreationEmailSuccess(): void {
		$creator = $this->getServiceContainer()->getUserFactory()->newFromName( 'Foo' );

		$user = self::getMutableTestUser()->getUser();
		$user->setEmail( 'test@localhost.localdomain' );

		$req = TemporaryPasswordAuthenticationRequest::newRandom();
		$req->username = $user->getName();
		$req->mailpassword = true;

		$provider = $this->getProvider( [ 'emailEnabled' => true ] );
		$status = $provider->testForAccountCreation( $user, $creator, [ $req ] );
		$this->assertEquals( StatusValue::newGood(), $status );

		$mailed = false;
		$resetMailer = $this->hookMailer( function ( $headers, $to, $from, $subject, $body )
			use ( &$mailed, $req )
		{
			$mailed = true;
			$this->assertSame( 'test@localhost.localdomain', $to[0]->address );
			$this->assertStringContainsString( $req->password, $body );
			return false;
		} );

		$expect = AuthenticationResponse::newPass( $user->getName() );
		$expect->createRequest = clone $req;
		$expect->createRequest->username = $user->getName();
		$res = $provider->beginPrimaryAccountCreation( $user, $creator, [ $req ] );
		$this->assertEquals( $expect, $res );
		$this->assertTrue( $this->manager->getAuthenticationSessionData( 'no-email' ) );
		$this->assertFalse( $mailed );

		$this->assertSame( 'byemail', $provider->finishAccountCreation( $user, $creator, $res ) );
		$this->assertTrue( $mailed );

		ScopedCallback::consume( $resetMailer );
		$this->assertTrue( $mailed );
	}

}
PK       ! }    =  auth/EmailNotificationSecondaryAuthenticationProviderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\EmailNotificationSecondaryAuthenticationProvider;
use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Unit\Auth\AuthenticationProviderTestTrait;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\User\User;
use MediaWiki\User\UserNameUtils;
use MediaWikiIntegrationTestCase;
use Psr\Log\LoggerInterface;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Auth\EmailNotificationSecondaryAuthenticationProvider
 * @group Database
 */
class EmailNotificationSecondaryAuthenticationProviderTest extends MediaWikiIntegrationTestCase {
	use AuthenticationProviderTestTrait;
	use DummyServicesTrait;

	/**
	 * @param array $options
	 * @return EmailNotificationSecondaryAuthenticationProvider
	 */
	private function getProvider( array $options = [] ): EmailNotificationSecondaryAuthenticationProvider {
		$services = $this->getServiceContainer();
		$provider = new EmailNotificationSecondaryAuthenticationProvider(
			$options['dbProvider'] ?? $services->getConnectionProvider(),
			$options // make things easier for tests by using the same options
		);
		$this->initProvider(
			$provider,
			$options['config'] ?? null,
			$options['logger'] ?? null,
			$options['authManager'] ?? null,
			$options['hookContainer'] ?? null,
			$options['userNameUtils'] ?? null
		);
		return $provider;
	}

	public function testConstructor() {
		$config = new HashConfig( [
			MainConfigNames::EnableEmail => true,
			MainConfigNames::EmailAuthentication => true,
		] );

		$provider = $this->getProvider( [
			'config' => $config,
		] );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$this->assertTrue( $providerPriv->sendConfirmationEmail );

		$provider = $this->getProvider( [
			'config' => $config,
			'sendConfirmationEmail' => false,
		] );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$this->assertFalse( $providerPriv->sendConfirmationEmail );
	}

	/**
	 * @dataProvider provideGetAuthenticationRequests
	 * @param string $action
	 * @param AuthenticationRequest[] $expected
	 */
	public function testGetAuthenticationRequests( $action, $expected ) {
		$provider = $this->getProvider( [
			'sendConfirmationEmail' => true,
		] );
		$this->assertSame( $expected, $provider->getAuthenticationRequests( $action, [] ) );
	}

	public static function provideGetAuthenticationRequests() {
		return [
			[ AuthManager::ACTION_LOGIN, [] ],
			[ AuthManager::ACTION_CREATE, [] ],
			[ AuthManager::ACTION_LINK, [] ],
			[ AuthManager::ACTION_CHANGE, [] ],
			[ AuthManager::ACTION_REMOVE, [] ],
		];
	}

	public function testBeginSecondaryAuthentication() {
		$provider = $this->getProvider( [
			'sendConfirmationEmail' => true,
		] );
		$this->assertEquals( AuthenticationResponse::newAbstain(),
			$provider->beginSecondaryAuthentication( User::newFromName( 'Foo' ), [] ) );
	}

	public function testBeginSecondaryAccountCreation() {
		$mwServices = $this->getServiceContainer();
		$hookContainer = $this->createHookContainer();
		$userNameUtils = $this->createNoOpMock( UserNameUtils::class );
		$authManager = new AuthManager(
			new FauxRequest(),
			new HashConfig(),
			$this->getDummyObjectFactory(),
			$hookContainer,
			$mwServices->getReadOnlyMode(),
			$userNameUtils,
			$mwServices->getBlockManager(),
			$mwServices->getWatchlistManager(),
			$mwServices->getDBLoadBalancer(),
			$mwServices->getContentLanguage(),
			$mwServices->getLanguageConverterFactory(),
			$mwServices->getBotPasswordStore(),
			$mwServices->getUserFactory(),
			$mwServices->getUserIdentityLookup(),
			$mwServices->getUserOptionsManager()
		);

		$creator = $this->createMock( User::class );
		$userWithoutEmail = $this->createMock( User::class );
		$userWithoutEmail->method( 'getEmail' )->willReturn( '' );
		$userWithoutEmail->method( 'getInstanceForUpdate' )->willReturnSelf();
		$userWithoutEmail->expects( $this->never() )->method( 'sendConfirmationMail' );
		$userWithEmailError = $this->createMock( User::class );
		$userWithEmailError->method( 'getEmail' )->willReturn( 'foo@bar.baz' );
		$userWithEmailError->method( 'getInstanceForUpdate' )->willReturnSelf();
		$userWithEmailError->method( 'sendConfirmationMail' )
			->willReturn( Status::newFatal( 'fail' ) );
		$userExpectsConfirmation = $this->createMock( User::class );
		$userExpectsConfirmation->method( 'getEmail' )
			->willReturn( 'foo@bar.baz' );
		$userExpectsConfirmation->method( 'getInstanceForUpdate' )
			->willReturnSelf();
		$userExpectsConfirmation->expects( $this->once() )->method( 'sendConfirmationMail' )
			->willReturn( Status::newGood() );
		$userNotExpectsConfirmation = $this->createMock( User::class );
		$userNotExpectsConfirmation->method( 'getEmail' )
			->willReturn( 'foo@bar.baz' );
		$userNotExpectsConfirmation->method( 'getInstanceForUpdate' )
			->willReturnSelf();
		$userNotExpectsConfirmation->expects( $this->never() )->method( 'sendConfirmationMail' );

		$provider = $this->getProvider( [
			'sendConfirmationEmail' => false,
			'authManager' => $authManager,
			'hookContainer' => $hookContainer,
			'userNameUtils' => $userNameUtils
		] );
		$provider->beginSecondaryAccountCreation( $userNotExpectsConfirmation, $creator, [] );

		$provider = $this->getProvider( [
			'sendConfirmationEmail' => true,
			'authManager' => $authManager,
			'userNameUtils' => $userNameUtils
		] );
		$provider->beginSecondaryAccountCreation( $userWithoutEmail, $creator, [] );
		$provider->beginSecondaryAccountCreation( $userExpectsConfirmation, $creator, [] );

		// test logging of email errors
		$logger = $this->getMockForAbstractClass( LoggerInterface::class );
		$logger->expects( $this->once() )->method( 'warning' );
		$this->initProvider( $provider, null, $logger, $authManager );
		$provider->beginSecondaryAccountCreation( $userWithEmailError, $creator, [] );

		// test disable flag used by other providers
		$authManager->setAuthenticationSessionData( 'no-email', true );
		$this->initProvider( $provider, null, null, $authManager );
		$provider->beginSecondaryAccountCreation( $userNotExpectsConfirmation, $creator, [] );
	}
}
PK       ! 16f"  f"  .  auth/ThrottlePreAuthenticationProviderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\ThrottlePreAuthenticationProvider;
use MediaWiki\Auth\UsernameAuthenticationRequest;
use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Tests\Unit\Auth\AuthenticationProviderTestTrait;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use Psr\Log\LogLevel;
use StatusValue;
use stdClass;
use TestLogger;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\TestingAccessWrapper;

/**
 * @group AuthManager
 * @group Database
 * @covers \MediaWiki\Auth\ThrottlePreAuthenticationProvider
 */
class ThrottlePreAuthenticationProviderTest extends MediaWikiIntegrationTestCase {
	use AuthenticationProviderTestTrait;

	public function testConstructor() {
		$provider = new ThrottlePreAuthenticationProvider();
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$config = new HashConfig( [
			MainConfigNames::AccountCreationThrottle => [ [
				'count' => 123,
				'seconds' => 86400,
			] ],
			MainConfigNames::PasswordAttemptThrottle => [ [
				'count' => 5,
				'seconds' => 300,
			] ],
		] );
		$this->initProvider( $provider, $config );
		$this->assertSame( [
			'accountCreationThrottle' => [ [ 'count' => 123, 'seconds' => 86400 ] ],
			'passwordAttemptThrottle' => [ [ 'count' => 5, 'seconds' => 300 ] ]
		], $providerPriv->throttleSettings );
		$accountCreationThrottle = TestingAccessWrapper::newFromObject(
			$providerPriv->accountCreationThrottle );
		$this->assertSame( [ [ 'count' => 123, 'seconds' => 86400 ] ],
			$accountCreationThrottle->conditions );
		$passwordAttemptThrottle = TestingAccessWrapper::newFromObject(
			$providerPriv->passwordAttemptThrottle );
		$this->assertSame( [ [ 'count' => 5, 'seconds' => 300 ] ],
			$passwordAttemptThrottle->conditions );

		$provider = new ThrottlePreAuthenticationProvider( [
			'accountCreationThrottle' => [ [ 'count' => 43, 'seconds' => 10000 ] ],
			'passwordAttemptThrottle' => [ [ 'count' => 11, 'seconds' => 100 ] ],
		] );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$config = new HashConfig( [
			MainConfigNames::AccountCreationThrottle => [ [
				'count' => 123,
				'seconds' => 86400,
			] ],
			MainConfigNames::PasswordAttemptThrottle => [ [
				'count' => 5,
				'seconds' => 300,
			] ],
		] );
		$this->initProvider( $provider, $config );
		$this->assertSame( [
			'accountCreationThrottle' => [ [ 'count' => 43, 'seconds' => 10000 ] ],
			'passwordAttemptThrottle' => [ [ 'count' => 11, 'seconds' => 100 ] ],
		], $providerPriv->throttleSettings );

		$cache = new HashBagOStuff();
		$provider = new ThrottlePreAuthenticationProvider( [ 'cache' => $cache ] );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$config = new HashConfig( [
			MainConfigNames::AccountCreationThrottle => [ [ 'count' => 1, 'seconds' => 1 ] ],
			MainConfigNames::PasswordAttemptThrottle => [ [ 'count' => 1, 'seconds' => 1 ] ],
		] );
		$this->initProvider( $provider, $config );
		$accountCreationThrottle = TestingAccessWrapper::newFromObject(
			$providerPriv->accountCreationThrottle );
		$this->assertSame( $cache, $accountCreationThrottle->cache );
		$passwordAttemptThrottle = TestingAccessWrapper::newFromObject(
			$providerPriv->passwordAttemptThrottle );
		$this->assertSame( $cache, $passwordAttemptThrottle->cache );
	}

	public function testDisabled() {
		$provider = new ThrottlePreAuthenticationProvider( [
			'accountCreationThrottle' => [],
			'passwordAttemptThrottle' => [],
			'cache' => new HashBagOStuff(),
		] );
		$this->initProvider(
			$provider,
			new HashConfig( [
				MainConfigNames::AccountCreationThrottle => null,
				MainConfigNames::PasswordAttemptThrottle => null,
			] ),
			null,
			$this->getServiceContainer()->getAuthManager()
		);

		$this->assertEquals(
			StatusValue::newGood(),
			$provider->testForAccountCreation(
				User::newFromName( 'Created' ),
				User::newFromName( 'Creator' ),
				[]
			)
		);
		$this->assertEquals(
			StatusValue::newGood(),
			$provider->testForAuthentication( [] )
		);
	}

	/**
	 * @dataProvider provideTestForAccountCreation
	 * @param bool $creatorIsSysop
	 * @param bool $succeed
	 * @param bool $hook
	 */
	public function testTestForAccountCreation( bool $creatorIsSysop, $succeed, $hook ) {
		if ( $hook ) {
			$mock = $this->getMockBuilder( stdClass::class )
				->addMethods( [ 'onExemptFromAccountCreationThrottle' ] )
				->getMock();
			$mock->method( 'onExemptFromAccountCreationThrottle' )
				->willReturn( false );
			$this->setTemporaryHook( 'ExemptFromAccountCreationThrottle', $mock );
		}

		$provider = new ThrottlePreAuthenticationProvider( [
			'accountCreationThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ],
			'cache' => new HashBagOStuff(),
		] );
		$this->initProvider(
			$provider,
			new HashConfig( [
				MainConfigNames::AccountCreationThrottle => null,
				MainConfigNames::PasswordAttemptThrottle => null,
			] ),
			null,
			$this->getServiceContainer()->getAuthManager(),
			$this->getServiceContainer()->getHookContainer()
		);

		$user = User::newFromName( 'RandomUser' );
		$creator = $creatorIsSysop ? $this->getTestSysop()->getUser() : $this->getTestUser()->getUser();

		$this->assertTrue(

			$provider->testForAccountCreation( $user, $creator, [] )->isOK(),
			'attempt #1'
		);
		$this->assertTrue(

			$provider->testForAccountCreation( $user, $creator, [] )->isOK(),
			'attempt #2'
		);
		$this->assertEquals(
			(bool)$succeed,
			$provider->testForAccountCreation( $user, $creator, [] )->isOK(),
			'attempt #3'
		);
	}

	public static function provideTestForAccountCreation() {
		return [
			'Normal user' => [ false, false, false ],
			'Sysop' => [ true, true, false ],
			'Normal user with hook' => [ false, true, true ],
		];
	}

	public function testTestForAuthentication() {
		$provider = new ThrottlePreAuthenticationProvider( [
			'passwordAttemptThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ],
			'cache' => new HashBagOStuff(),
		] );
		$this->initProvider(
			$provider,
			new HashConfig( [
				MainConfigNames::AccountCreationThrottle => null,
				MainConfigNames::PasswordAttemptThrottle => null,
			] ),
			null,
			$this->getServiceContainer()->getAuthManager()
		);

		$req = new UsernameAuthenticationRequest;
		$req->username = 'SomeUser';
		for ( $i = 1; $i <= 3; $i++ ) {
			$status = $provider->testForAuthentication( [ $req ] );
			$this->assertEquals( $i < 3, $status->isGood(), "attempt #$i" );
		}
		$this->assertStatusError( 'login-throttled', $status );

		$provider->postAuthentication( User::newFromName( 'SomeUser' ),
			AuthenticationResponse::newFail( wfMessage( 'foo' ) ) );
		$this->assertStatusNotOk( $provider->testForAuthentication( [ $req ] ), 'after FAIL' );

		$provider->postAuthentication( User::newFromName( 'SomeUser' ),
			AuthenticationResponse::newPass() );
		$this->assertStatusGood( $provider->testForAuthentication( [ $req ] ), 'after PASS' );

		$req1 = new UsernameAuthenticationRequest;
		$req1->username = 'foo';
		$req2 = new UsernameAuthenticationRequest;
		$req2->username = 'bar';
		$this->assertStatusGood( $provider->testForAuthentication( [ $req1, $req2 ] ) );

		$req = new UsernameAuthenticationRequest;
		$req->username = 'Some user';
		$provider->testForAuthentication( [ $req ] );
		$req->username = 'Some_user';
		$provider->testForAuthentication( [ $req ] );
		$req->username = 'some user';
		$status = $provider->testForAuthentication( [ $req ] );
		$this->assertStatusNotOk( $status, 'denormalized usernames are normalized' );
	}

	public function testPostAuthentication() {
		$provider = new ThrottlePreAuthenticationProvider( [
			'passwordAttemptThrottle' => [],
			'cache' => new HashBagOStuff(),
		] );
		$this->initProvider(
			$provider,
			new HashConfig( [
				MainConfigNames::AccountCreationThrottle => null,
				MainConfigNames::PasswordAttemptThrottle => null,
			] ),
			null,
			$this->getServiceContainer()->getAuthManager()
		);
		$provider->postAuthentication( User::newFromName( 'SomeUser' ),
			AuthenticationResponse::newPass() );

		$provider = new ThrottlePreAuthenticationProvider( [
			'passwordAttemptThrottle' => [ [ 'count' => 2, 'seconds' => 86400 ] ],
			'cache' => new HashBagOStuff(),
		] );
		$logger = new TestLogger( true );
		$this->initProvider(
			$provider,
			new HashConfig( [
				MainConfigNames::AccountCreationThrottle => null,
				MainConfigNames::PasswordAttemptThrottle => null,
			] ),
			$logger,
			$this->getServiceContainer()->getAuthManager()
		);
		$provider->postAuthentication( User::newFromName( 'SomeUser' ),
			AuthenticationResponse::newPass() );
		$this->assertSame( [
			[ LogLevel::INFO, 'throttler data not found for {user}' ],
		], $logger->getBuffer() );
	}
}
PK       ! 9Ǆ Ǆ   auth/AuthManagerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use Closure;
use DatabaseLogEntry;
use DomainException;
use DummySessionProvider;
use DynamicPropertyTestHelper;
use Exception;
use InvalidArgumentException;
use LogicException;
use MediaWiki\Auth\AbstractPreAuthenticationProvider;
use MediaWiki\Auth\AbstractPrimaryAuthenticationProvider;
use MediaWiki\Auth\AbstractSecondaryAuthenticationProvider;
use MediaWiki\Auth\AuthenticationProvider;
use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\ConfirmLinkSecondaryAuthenticationProvider;
use MediaWiki\Auth\CreatedAccountAuthenticationRequest;
use MediaWiki\Auth\CreateFromLoginAuthenticationRequest;
use MediaWiki\Auth\CreationReasonAuthenticationRequest;
use MediaWiki\Auth\Hook\AuthManagerLoginAuthenticateAuditHook;
use MediaWiki\Auth\Hook\AuthManagerVerifyAuthenticationHook;
use MediaWiki\Auth\Hook\LocalUserCreatedHook;
use MediaWiki\Auth\Hook\SecuritySensitiveOperationStatusHook;
use MediaWiki\Auth\Hook\UserLoggedInHook;
use MediaWiki\Auth\PasswordAuthenticationRequest;
use MediaWiki\Auth\PrimaryAuthenticationProvider;
use MediaWiki\Auth\RememberMeAuthenticationRequest;
use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest;
use MediaWiki\Auth\UserDataAuthenticationRequest;
use MediaWiki\Auth\UsernameAuthenticationRequest;
use MediaWiki\Block\BlockManager;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\Restriction\PageRestriction;
use MediaWiki\Block\SystemBlock;
use MediaWiki\Config\Config;
use MediaWiki\Config\HashConfig;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Context\RequestContext;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\StaticHookRegistry;
use MediaWiki\Language\Language;
use MediaWiki\Languages\LanguageConverterFactory;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Message\Message;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\WebRequest;
use MediaWiki\Session\SessionInfo;
use MediaWiki\Session\SessionManager;
use MediaWiki\Session\UserInfo;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Session\TestUtils;
use MediaWiki\User\BotPasswordStore;
use MediaWiki\User\Options\UserOptionsManager;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentityLookup;
use MediaWiki\User\UserNameUtils;
use MediaWiki\Watchlist\WatchlistManager;
use MediaWikiIntegrationTestCase;
use ObjectCacheFactory;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\MockObject\Builder\InvocationMocker;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Rule\InvocationOrder;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Psr\Log\NullLogger;
use ReflectionClass;
use RuntimeException;
use StatusValue;
use TestLogger;
use TestUser;
use UnexpectedValueException;
use Wikimedia\Message\MessageSpecifier;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectFactory\ObjectFactory;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Rdbms\ReadOnlyMode;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;

/**
 * @group AuthManager
 * @group Database
 * @covers \MediaWiki\Auth\AuthManager
 */
class AuthManagerTest extends MediaWikiIntegrationTestCase {
	/** @var WebRequest */
	protected $request;
	/** @var Config */
	protected $config;
	/** @var ObjectFactory */
	protected $objectFactory;
	/** @var ReadOnlyMode */
	protected $readOnlyMode;

	/** @var HookContainer */
	private $hookContainer;

	/** @var UserNameUtils */
	protected $userNameUtils;

	/** @var LoggerInterface */
	protected $logger;

	/** @var AbstractPreAuthenticationProvider&MockObject[] */
	protected $preauthMocks = [];
	/** @var AbstractPrimaryAuthenticationProvider&MockObject[] */
	protected $primaryauthMocks = [];
	/** @var AbstractSecondaryAuthenticationProvider&MockObject[] */
	protected $secondaryauthMocks = [];

	/** @var AuthManager */
	protected $manager;
	/** @var TestingAccessWrapper|AuthManager */
	protected $managerPriv;

	/** @var BlockManager */
	private $blockManager;

	/** @var WatchlistManager */
	private $watchlistManager;

	/** @var ILoadBalancer */
	private $loadBalancer;

	/** @var Language */
	private $contentLanguage;

	/** @var LanguageConverterFactory */
	private $languageConverterFactory;

	/** @var BotPasswordStore */
	private $botPasswordStore;

	/** @var UserFactory */
	private $userFactory;

	/** @var UserIdentityLookup */
	private $userIdentityLookup;

	/** @var UserOptionsManager */
	private $userOptionsManager;

	/** @var ObjectCacheFactory */
	private $objectCacheFactory;

	/**
	 * Registers a mock hook.
	 * Note this should be called after initializeManager( true ) as that removes mock hooks.
	 * @param string $hook
	 * @param string $hookInterface
	 * @param InvocationOrder $expect From $this->once(), $this->never(), etc.
	 * @return InvocationMocker $mock->expects( $expect )->method( ... ).
	 */
	protected function hook( $hook, $hookInterface, $expect ) {
		$mock = $this->getMockBuilder( $hookInterface )
			->onlyMethods( [ "on$hook" ] )
			->getMock();
		$this->hookContainer->register( $hook, $mock );
		return $mock->expects( $expect )->method( "on$hook" );
	}

	/**
	 * Unsets a hook
	 * @param string $hook
	 */
	protected function unhook( $hook ) {
		$this->hookContainer->clear( $hook );
	}

	/**
	 * Ensure a value is a clean Message object
	 *
	 * @param string|Message $key
	 * @param array $params
	 *
	 * @return Message
	 */
	protected function message( $key, $params = [] ) {
		if ( $key === null ) {
			return null;
		}
		if ( $key instanceof MessageSpecifier ) {
			$params = $key->getParams();
			$key = $key->getKey();
		}
		return new Message( $key, $params,
			MediaWikiServices::getInstance()->getLanguageFactory()->getLanguage( 'en' ) );
	}

	/**
	 * Test two AuthenticationResponses for equality.  We don't want to use regular assertEquals
	 * because that recursively compares members, which leads to false negatives if e.g. Language
	 * caches are reset.
	 *
	 * @param AuthenticationResponse $expected
	 * @param AuthenticationResponse $actual
	 * @param string $msg
	 */
	private function assertResponseEquals(
		AuthenticationResponse $expected, AuthenticationResponse $actual, $msg = ''
	) {
		foreach ( ( new ReflectionClass( $expected ) )->getProperties() as $prop ) {
			$name = $prop->getName();
			$usedMsg = ltrim( "$msg ($name)" );
			if ( $name === 'message' && $expected->message ) {
				$this->assertSame( $expected->message->__serialize(), $actual->message->__serialize(),
					$usedMsg );
			} else {
				$this->assertEquals( $expected->$name, $actual->$name, $usedMsg );
			}
		}
	}

	/**
	 * Initialize the AuthManagerConfig variable in $this->config
	 *
	 * Uses data from the various 'mocks' fields.
	 */
	protected function initializeConfig() {
		$config = [
			'preauth' => [
			],
			'primaryauth' => [
			],
			'secondaryauth' => [
			],
		];

		foreach ( [ 'preauth', 'primaryauth', 'secondaryauth' ] as $type ) {
			$key = $type . 'Mocks';
			foreach ( $this->$key as $mock ) {
				$config[$type][$mock->getUniqueId()] = [ 'factory' => static function () use ( $mock ) {
					return $mock;
				} ];
			}
		}

		$this->config->set( MainConfigNames::AuthManagerConfig, $config );
		$this->config->set( MainConfigNames::LanguageCode, 'en' );
		$this->config->set( MainConfigNames::NewUserLog, false );
		$this->config->set( MainConfigNames::RememberMe, RememberMeAuthenticationRequest::CHOOSE_REMEMBER );
	}

	/**
	 * Initialize $this->manager
	 * @param bool $regen Force a call to $this->initializeConfig()
	 */
	protected function initializeManager( $regen = false ) {
		// TODO clean this up, don't need to re fetch the services each time
		if ( $regen || !$this->config ) {
			$this->config = new HashConfig();
		}
		if ( $regen || !$this->request ) {
			$this->request = new FauxRequest();
		}
		if ( $regen || !$this->objectFactory ) {
			$services = $this->createNoOpAbstractMock( ContainerInterface::class );
			$this->objectFactory = new ObjectFactory( $services );
		}
		if ( $regen || !$this->readOnlyMode ) {
			$this->readOnlyMode = $this->getServiceContainer()->getReadOnlyMode();
		}
		if ( $regen || !$this->blockManager ) {
			// Override BlockManager::checkHost. Formerly testAuthorizeCreateAccount_DNSBlacklist
			// required *.localhost to resolve as 127.0.0.1, but that is system-dependent.
			$this->blockManager = new class(
				new ServiceOptions(
					BlockManager::CONSTRUCTOR_OPTIONS,
					$this->getServiceContainer()->getMainConfig()
				),
				$this->getServiceContainer()->getUserFactory(),
				$this->getServiceContainer()->getUserIdentityUtils(),
				LoggerFactory::getInstance( 'BlockManager' ),
				$this->getServiceContainer()->getHookContainer(),
				$this->getServiceContainer()->getDatabaseBlockStore(),
				$this->getServiceContainer()->getProxyLookup()
			) extends BlockManager {
				protected function checkHost( $hostname ) {
					return '127.0.0.1';
				}
			};
		}
		if ( $regen || !$this->watchlistManager ) {
			$this->watchlistManager = $this->getServiceContainer()->getWatchlistManager();
		}
		if ( $regen || !$this->hookContainer ) {
			// Set up a HookContainer we control
			$this->hookContainer = new HookContainer(
				new StaticHookRegistry( [], [], [] ),
				$this->objectFactory
			);
		}
		if ( $regen || !$this->userNameUtils ) {
			$this->userNameUtils = $this->getServiceContainer()->getUserNameUtils();
		}
		if ( $regen || !$this->loadBalancer ) {
			$this->loadBalancer = $this->getServiceContainer()->getDBLoadBalancer();
		}
		if ( $regen || !$this->contentLanguage ) {
			$this->contentLanguage = $this->getServiceContainer()->getContentLanguage();
		}
		if ( $regen || !$this->languageConverterFactory ) {
			$this->languageConverterFactory = $this->getServiceContainer()->getLanguageConverterFactory();
		}
		if ( $regen || !$this->botPasswordStore ) {
			$this->botPasswordStore = $this->getServiceContainer()->getBotPasswordStore();
		}
		if ( $regen || !$this->userFactory ) {
			$this->userFactory = $this->getServiceContainer()->getUserFactory();
		}
		if ( $regen || !$this->userIdentityLookup ) {
			$this->userIdentityLookup = $this->getServiceContainer()->getUserIdentityLookup();
		}
		if ( $regen || !$this->userOptionsManager ) {
			$this->userOptionsManager = $this->getServiceContainer()->getUserOptionsManager();
		}
		if ( $regen || !$this->objectCacheFactory ) {
			$this->objectCacheFactory = $this->getServiceContainer()->getObjectCacheFactory();
		}
		if ( !$this->logger ) {
			$this->logger = new TestLogger();
		}

		if ( $regen || !$this->config->has( MainConfigNames::AuthManagerConfig ) ) {
			$this->initializeConfig();
		}
		$this->manager = new AuthManager(
			$this->request,
			$this->config,
			$this->objectFactory,
			$this->hookContainer,
			$this->readOnlyMode,
			$this->userNameUtils,
			$this->blockManager,
			$this->watchlistManager,
			$this->loadBalancer,
			$this->contentLanguage,
			$this->languageConverterFactory,
			$this->botPasswordStore,
			$this->userFactory,
			$this->userIdentityLookup,
			$this->userOptionsManager
		);
		$this->manager->setLogger( $this->logger );
		$this->managerPriv = TestingAccessWrapper::newFromObject( $this->manager );
	}

	/**
	 * Setup SessionManager with a mock session provider
	 * @param bool|null $canChangeUser If non-null, canChangeUser will be mocked to return this
	 * @param array $methods Additional methods to mock
	 * @return array (MediaWiki\Session\SessionProvider, ScopedCallback)
	 */
	protected function getMockSessionProvider( $canChangeUser = null, array $methods = [] ) {
		if ( !$this->config ) {
			$this->config = new HashConfig();
			$this->initializeConfig();
		}
		$this->config->set( MainConfigNames::ObjectCacheSessionExpiry, 100 );

		$methods[] = '__toString';
		$methods[] = 'describe';
		if ( $canChangeUser !== null ) {
			$methods[] = 'canChangeUser';
		}
		$provider = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( $methods )
			->getMock();
		$provider->method( '__toString' )
			->willReturn( 'MockSessionProvider' );
		$provider->method( 'describe' )
			->willReturn( 'MockSessionProvider sessions' );
		if ( $canChangeUser !== null ) {
			$provider->method( 'canChangeUser' )
				->willReturn( $canChangeUser );
		}
		$this->config->set( MainConfigNames::SessionProviders, [
			[ 'factory' => static function () use ( $provider ) {
				return $provider;
			} ],
		] );

		$manager = new SessionManager( [
			'config' => $this->config,
			'logger' => new NullLogger(),
			'store' => new HashBagOStuff(),
		] );
		TestingAccessWrapper::newFromObject( $manager )->getProvider( (string)$provider );

		$reset = TestUtils::setSessionManagerSingleton( $manager );

		if ( $this->request ) {
			$manager->getSessionForRequest( $this->request );
		}

		return [ $provider, $reset ];
	}

	public function testCanAuthenticateNow() {
		$this->initializeManager();

		[ $provider, $reset ] = $this->getMockSessionProvider( false );
		$this->assertFalse( $this->manager->canAuthenticateNow() );
		ScopedCallback::consume( $reset );

		[ $provider, $reset ] = $this->getMockSessionProvider( true );
		$this->assertTrue( $this->manager->canAuthenticateNow() );
		ScopedCallback::consume( $reset );
	}

	public function testNormalizeUsername() {
		$mocks = [
			$this->createMock( AbstractPrimaryAuthenticationProvider::class ),
			$this->createMock( AbstractPrimaryAuthenticationProvider::class ),
			$this->createMock( AbstractPrimaryAuthenticationProvider::class ),
			$this->createMock( AbstractPrimaryAuthenticationProvider::class ),
		];
		foreach ( $mocks as $key => $mock ) {
			$mock->method( 'getUniqueId' )->willReturn( $key );
		}
		$mocks[0]->expects( $this->once() )->method( 'providerNormalizeUsername' )
			->with( $this->identicalTo( 'XYZ' ) )
			->willReturn( 'Foo' );
		$mocks[1]->expects( $this->once() )->method( 'providerNormalizeUsername' )
			->with( $this->identicalTo( 'XYZ' ) )
			->willReturn( 'Foo' );
		$mocks[2]->expects( $this->once() )->method( 'providerNormalizeUsername' )
			->with( $this->identicalTo( 'XYZ' ) )
			->willReturn( null );
		$mocks[3]->expects( $this->once() )->method( 'providerNormalizeUsername' )
			->with( $this->identicalTo( 'XYZ' ) )
			->willReturn( 'Bar!' );

		$this->primaryauthMocks = $mocks;

		$this->initializeManager();

		$this->assertSame( [ 'Foo', 'Bar!' ], $this->manager->normalizeUsername( 'XYZ' ) );
	}

	/**
	 * @dataProvider provideSecuritySensitiveOperationStatus
	 * @param bool $mutableSession
	 */
	public function testSecuritySensitiveOperationStatus( $mutableSession ) {
		$this->logger = new NullLogger();
		$user = $this->getTestSysop()->getUser();
		$provideUser = null;
		$reauth = $mutableSession ? AuthManager::SEC_REAUTH : AuthManager::SEC_FAIL;

		[ $provider, $reset ] = $this->getMockSessionProvider(
			$mutableSession, [ 'provideSessionInfo' ]
		);
		$provider->method( 'provideSessionInfo' )
			->willReturnCallback( static function () use ( $provider, &$provideUser ) {
				return new SessionInfo( SessionInfo::MIN_PRIORITY, [
					'provider' => $provider,
					'id' => DummySessionProvider::ID,
					'persisted' => true,
					'userInfo' => UserInfo::newFromUser( $provideUser, true )
				] );
			} );
		$this->initializeManager();

		$this->config->set( MainConfigNames::ReauthenticateTime, [] );
		$this->config->set( MainConfigNames::AllowSecuritySensitiveOperationIfCannotReauthenticate, [] );
		$provideUser = new User;
		$session = $provider->getManager()->getSessionForRequest( $this->request );
		$this->assertSame( 0, $session->getUser()->getId() );

		// Anonymous user => reauth
		$session->set( 'AuthManager:lastAuthId', 0 );
		$session->set( 'AuthManager:lastAuthTimestamp', time() - 5 );
		$this->assertSame( $reauth, $this->manager->securitySensitiveOperationStatus( 'foo' ) );

		$provideUser = $user;
		$session = $provider->getManager()->getSessionForRequest( $this->request );
		$this->assertSame( $user->getId(), $session->getUser()->getId() );

		// Error for no default (only gets thrown for non-anonymous user)
		$session->set( 'AuthManager:lastAuthId', $user->getId() + 1 );
		$session->set( 'AuthManager:lastAuthTimestamp', time() - 5 );
		try {
			$this->manager->securitySensitiveOperationStatus( 'foo' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame(
				$mutableSession
					? '$wgReauthenticateTime lacks a default'
					: '$wgAllowSecuritySensitiveOperationIfCannotReauthenticate lacks a default',
				$ex->getMessage()
			);
		}

		if ( $mutableSession ) {
			$this->config->set( MainConfigNames::ReauthenticateTime, [
				'test' => 100,
				'test2' => -1,
				'default' => 10,
			] );

			// Mismatched user ID
			$session->set( 'AuthManager:lastAuthId', $user->getId() + 1 );
			$session->set( 'AuthManager:lastAuthTimestamp', time() - 5 );
			$this->assertSame(
				AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' )
			);
			$this->assertSame(
				AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'test' )
			);
			$this->assertSame(
				AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test2' )
			);

			// Missing time
			$session->set( 'AuthManager:lastAuthId', $user->getId() );
			$session->set( 'AuthManager:lastAuthTimestamp', null );
			$this->assertSame(
				AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' )
			);
			$this->assertSame(
				AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'test' )
			);
			$this->assertSame(
				AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test2' )
			);

			// Recent enough to pass
			$session->set( 'AuthManager:lastAuthTimestamp', time() - 5 );
			$this->assertSame(
				AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'foo' )
			);

			// Not recent enough to pass
			$session->set( 'AuthManager:lastAuthTimestamp', time() - 20 );
			$this->assertSame(
				AuthManager::SEC_REAUTH, $this->manager->securitySensitiveOperationStatus( 'foo' )
			);
			// But recent enough for the 'test' operation
			$this->assertSame(
				AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'test' )
			);
		} else {
			$this->config->set( MainConfigNames::AllowSecuritySensitiveOperationIfCannotReauthenticate, [
				'test' => false,
				'default' => true,
			] );

			$this->assertEquals(
				AuthManager::SEC_OK, $this->manager->securitySensitiveOperationStatus( 'foo' )
			);

			$this->assertEquals(
				AuthManager::SEC_FAIL, $this->manager->securitySensitiveOperationStatus( 'test' )
			);
		}

		// Test hook, all three possible values
		foreach ( [
			AuthManager::SEC_OK => AuthManager::SEC_OK,
			AuthManager::SEC_REAUTH => $reauth,
			AuthManager::SEC_FAIL => AuthManager::SEC_FAIL,
		] as $hook => $expect ) {
			$this->hook( 'SecuritySensitiveOperationStatus',
				SecuritySensitiveOperationStatusHook::class,
				$this->exactly( 2 )
			)
				->with(
					/* $status */ $this->anything(),
					/* $operation */ $this->anything(),
					/* $session */ $this->callback( static function ( $s ) use ( $session ) {
						return $s->getId() === $session->getId();
					} ),
					/* $timeSinceAuth*/ $mutableSession
						? $this->equalToWithDelta( 500, 2 )
						: -1
				)
				->willReturnCallback( static function ( &$v ) use ( $hook ) {
					$v = $hook;
					return true;
				} );
			$session->set( 'AuthManager:lastAuthTimestamp', time() - 500 );
			$this->assertEquals(
				$expect, $this->manager->securitySensitiveOperationStatus( 'test' ), "hook $hook"
			);
			$this->assertEquals(
				$expect, $this->manager->securitySensitiveOperationStatus( 'test2' ), "hook $hook"
			);
			$this->unhook( 'SecuritySensitiveOperationStatus' );
		}

		ScopedCallback::consume( $reset );
	}

	public static function provideSecuritySensitiveOperationStatus() {
		return [
			[ true ],
			[ false ],
		];
	}

	/**
	 * @dataProvider provideUserCanAuthenticate
	 * @param bool $primary1Can
	 * @param bool $primary2Can
	 * @param bool $expect
	 */
	public function testUserCanAuthenticate( $primary1Can, $primary2Can, $expect ) {
		$userName = 'TestUserCanAuthenticate';
		$mock1 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock1->method( 'getUniqueId' )
			->willReturn( 'primary1' );
		$mock1->method( 'testUserCanAuthenticate' )
			->with( $userName )
			->willReturn( $primary1Can );
		$mock2 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock2->method( 'getUniqueId' )
			->willReturn( 'primary2' );
		$mock2->method( 'testUserCanAuthenticate' )
			->with( $userName )
			->willReturn( $primary2Can );
		$this->primaryauthMocks = [ $mock1, $mock2 ];

		$this->initializeManager( true );
		$this->assertSame( $expect, $this->manager->userCanAuthenticate( $userName ) );
	}

	public static function provideUserCanAuthenticate() {
		return [
			[ false, false, false ],
			[ true, false, true ],
			[ false, true, true ],
			[ true, true, true ],
		];
	}

	public function testRevokeAccessForUser() {
		$userName = 'TestRevokeAccessForUser';
		$this->initializeManager();

		$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock->method( 'getUniqueId' )
			->willReturn( 'primary' );
		$mock->expects( $this->once() )->method( 'providerRevokeAccessForUser' )
			->with( $userName );
		$this->primaryauthMocks = [ $mock ];

		$this->initializeManager( true );
		$this->logger->setCollect( true );

		$this->manager->revokeAccessForUser( $userName );

		$this->assertSame( [
			[ LogLevel::INFO, 'Revoking access for {user}' ],
		], $this->logger->getBuffer() );
	}

	public function testProviderCreation() {
		$mocks = [
			'pre' => $this->createMock( AbstractPreAuthenticationProvider::class ),
			'primary' => $this->createMock( AbstractPrimaryAuthenticationProvider::class ),
			'secondary' => $this->createMock( AbstractSecondaryAuthenticationProvider::class ),
		];
		foreach ( $mocks as $key => $mock ) {
			$mock->method( 'getUniqueId' )->willReturn( $key );
			$mock->expects( $this->once() )->method( 'init' );
		}
		$this->preauthMocks = [ $mocks['pre'] ];
		$this->primaryauthMocks = [ $mocks['primary'] ];
		$this->secondaryauthMocks = [ $mocks['secondary'] ];

		// Normal operation
		$this->initializeManager();
		$this->assertSame(
			$mocks['primary'],
			$this->managerPriv->getAuthenticationProvider( 'primary' )
		);
		$this->assertSame(
			$mocks['secondary'],
			$this->managerPriv->getAuthenticationProvider( 'secondary' )
		);
		$this->assertSame(
			$mocks['pre'],
			$this->managerPriv->getAuthenticationProvider( 'pre' )
		);
		$this->assertSame(
			[ 'pre' => $mocks['pre'] ],
			$this->managerPriv->getPreAuthenticationProviders()
		);
		$this->assertSame(
			[ 'primary' => $mocks['primary'] ],
			$this->managerPriv->getPrimaryAuthenticationProviders()
		);
		$this->assertSame(
			[ 'secondary' => $mocks['secondary'] ],
			$this->managerPriv->getSecondaryAuthenticationProviders()
		);

		// Duplicate IDs
		$mock1 = $this->createMock( AbstractPreAuthenticationProvider::class );
		$mock2 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock1->method( 'getUniqueId' )->willReturn( 'X' );
		$mock2->method( 'getUniqueId' )->willReturn( 'X' );
		$this->preauthMocks = [ $mock1 ];
		$this->primaryauthMocks = [ $mock2 ];
		$this->secondaryauthMocks = [];
		$this->initializeManager( true );
		try {
			$this->managerPriv->getAuthenticationProvider( 'Y' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( RuntimeException $ex ) {
			$class1 = get_class( $mock1 );
			$class2 = get_class( $mock2 );
			$this->assertSame(
				"Duplicate specifications for id X (classes $class2 and $class1)", $ex->getMessage()
			);
		}

		// Wrong classes
		$mock = $this->getMockForAbstractClass( AuthenticationProvider::class );
		$mock->method( 'getUniqueId' )->willReturn( 'X' );
		$class = get_class( $mock );
		$this->preauthMocks = [ $mock ];
		$this->primaryauthMocks = [];
		$this->secondaryauthMocks = [];
		$this->initializeManager( true );
		try {
			$this->managerPriv->getPreAuthenticationProviders();
			$this->fail( 'Expected exception not thrown' );
		} catch ( RuntimeException $ex ) {
			$this->assertSame(
				"Expected instance of MediaWiki\\Auth\\PreAuthenticationProvider, got $class",
				$ex->getMessage()
			);
		}
		$this->preauthMocks = [];
		$this->primaryauthMocks = [ $mock ];
		$this->secondaryauthMocks = [];
		$this->initializeManager( true );
		try {
			$this->managerPriv->getPrimaryAuthenticationProviders();
			$this->fail( 'Expected exception not thrown' );
		} catch ( RuntimeException $ex ) {
			$this->assertSame(
				"Expected instance of MediaWiki\\Auth\\PrimaryAuthenticationProvider, got $class",
				$ex->getMessage()
			);
		}
		$this->preauthMocks = [];
		$this->primaryauthMocks = [];
		$this->secondaryauthMocks = [ $mock ];
		$this->initializeManager( true );
		try {
			$this->managerPriv->getSecondaryAuthenticationProviders();
			$this->fail( 'Expected exception not thrown' );
		} catch ( RuntimeException $ex ) {
			$this->assertSame(
				"Expected instance of MediaWiki\\Auth\\SecondaryAuthenticationProvider, got $class",
				$ex->getMessage()
			);
		}

		// Sorting
		$mock1 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock2 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock3 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock1->method( 'getUniqueId' )->willReturn( 'A' );
		$mock2->method( 'getUniqueId' )->willReturn( 'B' );
		$mock3->method( 'getUniqueId' )->willReturn( 'C' );
		$this->preauthMocks = [];
		$this->primaryauthMocks = [ $mock1, $mock2, $mock3 ];
		$this->secondaryauthMocks = [];
		$this->initializeConfig();
		$config = $this->config->get( MainConfigNames::AuthManagerConfig );

		$this->initializeManager( false );
		$this->assertSame(
			[ 'A' => $mock1, 'B' => $mock2, 'C' => $mock3 ],
			$this->managerPriv->getPrimaryAuthenticationProviders()
		);

		$config['primaryauth']['A']['sort'] = 100;
		$config['primaryauth']['C']['sort'] = -1;
		$this->config->set( MainConfigNames::AuthManagerConfig, $config );
		$this->initializeManager( false );
		$this->assertSame(
			[ 'C' => $mock3, 'B' => $mock2, 'A' => $mock1 ],
			$this->managerPriv->getPrimaryAuthenticationProviders()
		);

		// filtering
		$mockPreAuth1 = $this->createMock( AbstractPreAuthenticationProvider::class );
		$mockPreAuth2 = $this->createMock( AbstractPreAuthenticationProvider::class );
		$mockPrimary1 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mockPrimary2 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mockSecondary1 = $this->createMock( AbstractSecondaryAuthenticationProvider::class );
		$mockSecondary2 = $this->createMock( AbstractSecondaryAuthenticationProvider::class );
		$mockPreAuth1->method( 'getUniqueId' )->willReturn( 'pre1' );
		$mockPreAuth2->method( 'getUniqueId' )->willReturn( 'pre2' );
		$mockPrimary1->method( 'getUniqueId' )->willReturn( 'primary1' );
		$mockPrimary2->method( 'getUniqueId' )->willReturn( 'primary2' );
		$mockSecondary1->method( 'getUniqueId' )->willReturn( 'secondary1' );
		$mockSecondary2->method( 'getUniqueId' )->willReturn( 'secondary2' );
		$this->preauthMocks = [ $mockPreAuth1, $mockPreAuth2 ];
		$this->primaryauthMocks = [ $mockPrimary1, $mockPrimary2 ];
		$this->secondaryauthMocks = [ $mockSecondary1, $mockSecondary2 ];
		$this->initializeConfig();
		$this->initializeManager( true );
		$this->hookContainer->register( 'AuthManagerFilterProviders', static function ( &$providers ) {
			unset( $providers['preauth']['pre1'] );
			$providers['primaryauth']['primary2'] = false;
			$providers['secondaryauth'] = [ 'secondary2' => true ];
		} );
		$this->assertSame( [ 'pre2' => $mockPreAuth2 ], $this->managerPriv->getPreAuthenticationProviders() );
		$this->assertSame( [ 'primary1' => $mockPrimary1 ], $this->managerPriv->getPrimaryAuthenticationProviders() );
		$this->assertSame( [ 'secondary2' => $mockSecondary2 ], $this->managerPriv->getSecondaryAuthenticationProviders() );
	}

	/**
	 * @dataProvider provideSetDefaultUserOptions
	 */
	public function testSetDefaultUserOptions(
		$contLang, $useContextLang, $expectedLang, $expectedVariant
	) {
		$this->setContentLang( $contLang );
		$this->initializeManager( true );
		$context = RequestContext::getMain();
		$reset = new ScopedCallback( [ $context, 'setLanguage' ], [ $context->getLanguage() ] );
		$context->setLanguage( 'de' );

		$user = User::newFromName( self::usernameForCreation() );
		$user->addToDatabase();
		$oldToken = $user->getToken();
		$this->managerPriv->setDefaultUserOptions( $user, $useContextLang );
		$user->saveSettings();
		$this->assertNotEquals( $oldToken, $user->getToken() );
		$this->assertSame(
			$expectedLang,
			$this->userOptionsManager->getOption( $user, 'language' )
		);
		$this->assertSame(
			$expectedVariant,
			$this->userOptionsManager->getOption( $user, 'variant' )
		);
	}

	public static function provideSetDefaultUserOptions() {
		return [
			[ 'zh', false, 'zh', 'zh' ],
			[ 'zh', true, 'de', 'zh' ],
			[ 'fr', true, 'de', 'fr' ],
		];
	}

	public function testForcePrimaryAuthenticationProviders() {
		$mockA = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mockB = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mockB2 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mockA->method( 'getUniqueId' )->willReturn( 'A' );
		$mockB->method( 'getUniqueId' )->willReturn( 'B' );
		$mockB2->method( 'getUniqueId' )->willReturn( 'B' );
		$this->primaryauthMocks = [ $mockA ];

		$this->logger = new TestLogger( true );

		// Test without first initializing the configured providers
		$this->initializeManager();
		$this->expectDeprecationAndContinue( '/AuthManager::forcePrimaryAuthenticationProviders/' );
		$this->manager->forcePrimaryAuthenticationProviders( [ $mockB ], 'testing' );
		$this->assertSame(
			[ 'B' => $mockB ], $this->managerPriv->getPrimaryAuthenticationProviders()
		);
		$this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'A' ) );
		$this->assertSame( $mockB, $this->managerPriv->getAuthenticationProvider( 'B' ) );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Overriding AuthManager primary authn because testing' ],
		], $this->logger->getBuffer() );
		$this->logger->clearBuffer();

		// Test with first initializing the configured providers
		$this->initializeManager();
		$this->assertSame( $mockA, $this->managerPriv->getAuthenticationProvider( 'A' ) );
		$this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'B' ) );
		$this->request->getSession()->setSecret( AuthManager::AUTHN_STATE, 'test' );
		$this->request->getSession()->setSecret( AuthManager::ACCOUNT_CREATION_STATE, 'test' );
		$this->expectDeprecationAndContinue( '/AuthManager::forcePrimaryAuthenticationProviders/' );
		$this->manager->forcePrimaryAuthenticationProviders( [ $mockB ], 'testing' );
		$this->assertSame(
			[ 'B' => $mockB ], $this->managerPriv->getPrimaryAuthenticationProviders()
		);
		$this->assertSame( null, $this->managerPriv->getAuthenticationProvider( 'A' ) );
		$this->assertSame( $mockB, $this->managerPriv->getAuthenticationProvider( 'B' ) );
		$this->assertNull( $this->request->getSession()->getSecret( AuthManager::AUTHN_STATE ) );
		$this->assertNull(
			$this->request->getSession()->getSecret( AuthManager::ACCOUNT_CREATION_STATE )
		);
		$this->assertSame( [
			[ LogLevel::WARNING, 'Overriding AuthManager primary authn because testing' ],
			[
				LogLevel::WARNING,
				'PrimaryAuthenticationProviders have already been accessed! I hope nothing breaks.'
			],
		], $this->logger->getBuffer() );
		$this->logger->clearBuffer();

		// Test duplicate IDs
		$this->initializeManager();
		try {
			$this->expectDeprecationAndContinue( '/AuthManager::forcePrimaryAuthenticationProviders/' );
			$this->manager->forcePrimaryAuthenticationProviders( [ $mockB, $mockB2 ], 'testing' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( RuntimeException $ex ) {
			$class1 = get_class( $mockB );
			$class2 = get_class( $mockB2 );
			$this->assertSame(
				"Duplicate specifications for id B (classes $class2 and $class1)", $ex->getMessage()
			);
		}

		// Wrong classes
		$mock = $this->getMockForAbstractClass( AuthenticationProvider::class );
		$mock->method( 'getUniqueId' )->willReturn( 'X' );
		$class = get_class( $mock );
		try {
			$this->manager->forcePrimaryAuthenticationProviders( [ $mock ], 'testing' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( RuntimeException $ex ) {
			$this->assertSame(
				"Expected instance of MediaWiki\\Auth\\AbstractPrimaryAuthenticationProvider, got $class",
				$ex->getMessage()
			);
		}
	}

	public function testBeginAuthentication() {
		$this->initializeManager();

		// Immutable session
		[ $provider, $reset ] = $this->getMockSessionProvider( false );
		$this->hook( 'UserLoggedIn', UserLoggedInHook::class, $this->never() );
		$this->request->getSession()->setSecret( AuthManager::AUTHN_STATE, 'test' );
		try {
			$this->manager->beginAuthentication( [], 'http://localhost/' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( LogicException $ex ) {
			$this->assertSame( 'Authentication is not possible now', $ex->getMessage() );
		}
		$this->unhook( 'UserLoggedIn' );
		$this->assertNull( $this->request->getSession()->getSecret( AuthManager::AUTHN_STATE ) );
		ScopedCallback::consume( $reset );
		$this->initializeManager( true );

		// CreatedAccountAuthenticationRequest
		$user = $this->getTestSysop()->getUser();
		$reqs = [
			new CreatedAccountAuthenticationRequest( $user->getId(), $user->getName() )
		];
		$this->hook( 'UserLoggedIn', UserLoggedInHook::class, $this->never() );
		try {
			$this->manager->beginAuthentication( $reqs, 'http://localhost/' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( LogicException $ex ) {
			$this->assertSame(
				'CreatedAccountAuthenticationRequests are only valid on the same AuthManager ' .
					'that created the account',
				$ex->getMessage()
			);
		}
		$this->unhook( 'UserLoggedIn' );

		$this->request->getSession()->clear();
		$this->request->getSession()->setSecret( AuthManager::AUTHN_STATE, 'test' );
		$this->managerPriv->createdAccountAuthenticationRequests = [ $reqs[0] ];
		$this->hook( 'UserLoggedIn', UserLoggedInHook::class, $this->once() )
			->with( $this->callback( static function ( $u ) use ( $user ) {
				return $user->getId() === $u->getId() && $user->getName() === $u->getName();
			} ) );
		$this->hook( 'AuthManagerLoginAuthenticateAudit',
			AuthManagerLoginAuthenticateAuditHook::class, $this->once() );
		$this->logger->setCollect( true );
		$ret = $this->manager->beginAuthentication( $reqs, 'http://localhost/' );
		$this->logger->setCollect( false );
		$this->unhook( 'UserLoggedIn' );
		$this->unhook( 'AuthManagerLoginAuthenticateAudit' );
		$this->assertSame( AuthenticationResponse::PASS, $ret->status );
		$this->assertSame( $user->getName(), $ret->username );
		$this->assertSame( $user->getId(), $this->request->getSessionData( 'AuthManager:lastAuthId' ) );
		// FIXME: Avoid relying on implicit amounts of time elapsing.
		$this->assertEqualsWithDelta(
			time(),
			$this->request->getSessionData( 'AuthManager:lastAuthTimestamp' ),
			1,
			'timestamp ±1'
		);
		$this->assertNull( $this->request->getSession()->getSecret( AuthManager::AUTHN_STATE ) );
		$this->assertSame( $user->getId(), $this->request->getSession()->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::INFO, 'Logging in {user} after account creation' ],
		], $this->logger->getBuffer() );
	}

	public function testCreateFromLogin() {
		$user = $this->getTestSysop()->getUser();
		$req1 = $this->createMock( AuthenticationRequest::class );
		$req2 = $this->createMock( AuthenticationRequest::class );
		$req3 = $this->createMock( AuthenticationRequest::class );
		$userReq = new UsernameAuthenticationRequest;
		$userReq->username = 'UTDummy';

		$req1->returnToUrl = 'http://localhost/';
		$req2->returnToUrl = 'http://localhost/';
		$req3->returnToUrl = 'http://localhost/';
		$req3->username = 'UTDummy';
		$userReq->returnToUrl = 'http://localhost/';

		// Passing one into beginAuthentication(), and an immediate FAIL
		$primary = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class );
		$this->primaryauthMocks = [ $primary ];
		$this->initializeManager( true );
		$res = AuthenticationResponse::newFail( wfMessage( 'foo' ) );
		$res->createRequest = $req1;
		$primary->method( 'beginPrimaryAuthentication' )
			->willReturn( $res );
		$createReq = new CreateFromLoginAuthenticationRequest(
			null, [ $req2->getUniqueId() => $req2 ]
		);
		$this->logger->setCollect( true );
		$ret = $this->manager->beginAuthentication( [ $createReq ], 'http://localhost/' );
		$this->logger->setCollect( false );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertInstanceOf( CreateFromLoginAuthenticationRequest::class, $ret->createRequest );
		$this->assertSame( $req1, $ret->createRequest->createRequest );
		$this->assertEquals( [ $req2->getUniqueId() => $req2 ], $ret->createRequest->maybeLink );

		// UI, then FAIL in beginAuthentication()
		$primary = $this->getMockBuilder( AbstractPrimaryAuthenticationProvider::class )
			->onlyMethods( [ 'continuePrimaryAuthentication' ] )
			->getMockForAbstractClass();
		$this->primaryauthMocks = [ $primary ];
		$this->initializeManager( true );
		$primary->method( 'beginPrimaryAuthentication' )
			->willReturn( AuthenticationResponse::newUI( [ $req1 ], wfMessage( 'foo' ) ) );
		$res = AuthenticationResponse::newFail( wfMessage( 'foo' ) );
		$res->createRequest = $req2;
		$primary->method( 'continuePrimaryAuthentication' )
			->willReturn( $res );
		$this->logger->setCollect( true );
		$ret = $this->manager->beginAuthentication( [], 'http://localhost/' );
		$this->assertSame( AuthenticationResponse::UI, $ret->status );
		$ret = $this->manager->continueAuthentication( [] );
		$this->logger->setCollect( false );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertInstanceOf( CreateFromLoginAuthenticationRequest::class, $ret->createRequest );
		$this->assertSame( $req2, $ret->createRequest->createRequest );
		$this->assertEquals( [], $ret->createRequest->maybeLink );

		// Pass into beginAccountCreation(), see that maybeLink and createRequest get copied
		$primary = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class );
		$this->primaryauthMocks = [ $primary ];
		$this->initializeManager( true );
		$createReq = new CreateFromLoginAuthenticationRequest( $req3, [ $req2 ] );
		$createReq->returnToUrl = 'http://localhost/';
		$createReq->username = 'UTDummy';
		$res = AuthenticationResponse::newUI( [ $req1 ], wfMessage( 'foo' ) );
		$primary->method( 'beginPrimaryAccountCreation' )
			->with( $this->anything(), $this->anything(), [ $userReq, $createReq, $req3 ] )
			->willReturn( $res );
		$primary->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$this->logger->setCollect( true );
		$ret = $this->manager->beginAccountCreation(
			$user, [ $userReq, $createReq ], 'http://localhost/'
		);
		$this->logger->setCollect( false );
		$this->assertSame( AuthenticationResponse::UI, $ret->status );
		$state = $this->request->getSession()->getSecret( AuthManager::ACCOUNT_CREATION_STATE );
		$this->assertNotNull( $state );
		$this->assertEquals( [ $userReq, $createReq, $req3 ], $state['reqs'] );
		$this->assertEquals( [ $req2 ], $state['maybeLink'] );
	}

	/**
	 * @dataProvider provideAuthentication
	 * @param StatusValue $preResponse
	 * @param array<AuthenticationResponse|Exception> $primaryResponses
	 * @param array<AuthenticationResponse|Exception> $secondaryResponses
	 * @param array<AuthenticationResponse|Exception> $managerResponses
	 * @param bool $link Whether the primary authentication provider is a "link" provider
	 */
	public function testAuthentication(
		StatusValue $preResponse, array $primaryResponses, array $secondaryResponses,
		array $managerResponses, $link = false
	) {
		$this->initializeManager();
		$user = $this->getTestSysop()->getUser();
		$id = $user->getId();
		$name = $user->getName();
		// Hack: replace placeholder usernames with that of the test user. A better solution would be to instantiate
		// all responses here, only providing constructor arguments (like the status) from the data provider.
		$responseArrays = [ $primaryResponses, $secondaryResponses, $managerResponses ];
		foreach ( $responseArrays as $respArray ) {
			foreach ( $respArray as $resp ) {
				if ( $resp instanceof AuthenticationResponse && $resp->username === 'PLACEHOLDER' ) {
					$resp->username = $name;
				}
			}
		}

		// Set up lots of mocks...
		$req = new RememberMeAuthenticationRequest;
		$req->rememberMe = (bool)rand( 0, 1 );
		$mocks = [];
		foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) {
			$class = ucfirst( $key ) . 'AuthenticationProvider';
			$mocks[$key] = $this->getMockBuilder( "MediaWiki\\Auth\\Abstract$class" )
				->setMockClassName( "MockAbstract$class" )
				->getMock();
			$mocks[$key]->method( 'getUniqueId' )
				->willReturn( $key );
			$mocks[$key . '2'] = $this->createMock( "MediaWiki\\Auth\\Abstract$class" );
			$mocks[$key . '2']->method( 'getUniqueId' )
				->willReturn( $key . '2' );
			$mocks[$key . '3'] = $this->createMock( "MediaWiki\\Auth\\Abstract$class" );
			$mocks[$key . '3']->method( 'getUniqueId' )
				->willReturn( $key . '3' );
		}
		foreach ( $mocks as $mock ) {
			$mock->method( 'getAuthenticationRequests' )
				->willReturn( [] );
		}

		$mocks['pre']->expects( $this->once() )->method( 'testForAuthentication' )
			->willReturnCallback( function ( $reqs ) use ( $req, $preResponse ) {
				$this->assertContains( $req, $reqs );
				return $preResponse;
			} );

		$ct = count( $primaryResponses );
		$callback = $this->returnCallback( function ( $reqs ) use ( $req, &$primaryResponses ) {
			$this->assertContains( $req, $reqs );
			return array_shift( $primaryResponses );
		} );
		$mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) )
			->method( 'beginPrimaryAuthentication' )
			->will( $callback );
		$mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) )
			->method( 'continuePrimaryAuthentication' )
			->will( $callback );
		if ( $link ) {
			$mocks['primary']->method( 'accountCreationType' )
				->willReturn( PrimaryAuthenticationProvider::TYPE_LINK );
		}

		$ct = count( $secondaryResponses );
		$callback = $this->returnCallback( function ( $user, $reqs ) use ( $id, $name, $req, &$secondaryResponses ) {
			$this->assertSame( $id, $user->getId() );
			$this->assertSame( $name, $user->getName() );
			$this->assertContains( $req, $reqs );
			return array_shift( $secondaryResponses );
		} );
		$mocks['secondary']->expects( $this->exactly( min( 1, $ct ) ) )
			->method( 'beginSecondaryAuthentication' )
			->will( $callback );
		$mocks['secondary']->expects( $this->exactly( max( 0, $ct - 1 ) ) )
			->method( 'continueSecondaryAuthentication' )
			->will( $callback );

		$abstain = AuthenticationResponse::newAbstain();
		$mocks['pre2']->expects( $this->atMost( 1 ) )->method( 'testForAuthentication' )
			->willReturn( StatusValue::newGood() );
		$mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAuthentication' )
				->willReturn( $abstain );
		$mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAuthentication' );
		$mocks['secondary2']->expects( $this->atMost( 1 ) )->method( 'beginSecondaryAuthentication' )
				->willReturn( $abstain );
		$mocks['secondary2']->expects( $this->never() )->method( 'continueSecondaryAuthentication' );
		$mocks['secondary3']->expects( $this->atMost( 1 ) )->method( 'beginSecondaryAuthentication' )
				->willReturn( $abstain );
		$mocks['secondary3']->expects( $this->never() )->method( 'continueSecondaryAuthentication' );

		$this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ];
		$this->primaryauthMocks = [ $mocks['primary'], $mocks['primary2'] ];
		$this->secondaryauthMocks = [
			$mocks['secondary3'], $mocks['secondary'], $mocks['secondary2'],
			// So linking happens
			new ConfirmLinkSecondaryAuthenticationProvider,
		];
		$this->initializeManager( true );
		$this->logger->setCollect( true );

		$constraint = Assert::logicalOr(
			$this->equalTo( AuthenticationResponse::PASS ),
			$this->equalTo( AuthenticationResponse::FAIL )
		);
		$providers = array_filter(
			array_merge(
				$this->preauthMocks, $this->primaryauthMocks, $this->secondaryauthMocks
			),
			static function ( $p ) {
				return is_callable( [ $p, 'expects' ] );
			}
		);
		foreach ( $providers as $p ) {
			DynamicPropertyTestHelper::setDynamicProperty( $p, 'postCalled', false );
			$p->expects( $this->atMost( 1 ) )->method( 'postAuthentication' )
				->willReturnCallback( function ( $userArg, $response ) use ( $user, $constraint, $p ) {
					if ( $userArg !== null ) {
						$this->assertInstanceOf( User::class, $userArg );
						$this->assertSame( $user->getName(), $userArg->getName() );
					}
					$this->assertInstanceOf( AuthenticationResponse::class, $response );
					$this->assertThat( $response->status, $constraint );
					DynamicPropertyTestHelper::setDynamicProperty( $p, 'postCalled', $response->status );
				} );
		}

		$session = $this->request->getSession();
		$session->setRememberUser( !$req->rememberMe );

		foreach ( $managerResponses as $i => $response ) {
			$success = $response instanceof AuthenticationResponse &&
				$response->status === AuthenticationResponse::PASS;
			if ( $success ) {
				$this->hook( 'UserLoggedIn', UserLoggedInHook::class, $this->once() )
					->with( $this->callback( static function ( $user ) use ( $id, $name ) {
						return $user->getId() === $id && $user->getName() === $name;
					} ) );
			} else {
				$this->hook( 'UserLoggedIn', UserLoggedInHook::class, $this->never() );
			}
			if ( $success || (
					$response instanceof AuthenticationResponse &&
					$response->status === AuthenticationResponse::FAIL &&
					$response->message->getKey() !== 'authmanager-authn-not-in-progress' &&
					$response->message->getKey() !== 'authmanager-authn-no-primary'
				)
			) {
				$this->hook( 'AuthManagerLoginAuthenticateAudit',
					AuthManagerLoginAuthenticateAuditHook::class, $this->once() );
			} else {
				$this->hook( 'AuthManagerLoginAuthenticateAudit',
					AuthManagerLoginAuthenticateAuditHook::class, $this->never() );
			}

			try {
				if ( !$i ) {
					$ret = $this->manager->beginAuthentication( [ $req ], 'http://localhost/' );
				} else {
					$ret = $this->manager->continueAuthentication( [ $req ] );
				}
				if ( $response instanceof Exception ) {
					$this->fail( 'Expected exception not thrown', "Response $i" );
				}
			} catch ( Exception $ex ) {
				if ( !$response instanceof Exception ) {
					throw $ex;
				}
				$this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" );
				$this->assertNull( $session->getSecret( AuthManager::AUTHN_STATE ),
					"Response $i, exception, session state" );
				$this->unhook( 'UserLoggedIn' );
				$this->unhook( 'AuthManagerLoginAuthenticateAudit' );
				return;
			}

			$this->unhook( 'UserLoggedIn' );
			$this->unhook( 'AuthManagerLoginAuthenticateAudit' );

			$this->assertSame( 'http://localhost/', $req->returnToUrl );

			$ret->message = $this->message( $ret->message );
			$this->assertResponseEquals( $response, $ret, "Response $i, response" );
			if ( $success ) {
				$this->assertSame( $id, $session->getUser()->getId(),
					"Response $i, authn" );
			} else {
				$this->assertSame( 0, $session->getUser()->getId(),
					"Response $i, authn" );
			}
			if ( $success || $response->status === AuthenticationResponse::FAIL ) {
				$this->assertNull( $session->getSecret( AuthManager::AUTHN_STATE ),
					"Response $i, session state" );
				foreach ( $providers as $p ) {
					$this->assertSame( $response->status, DynamicPropertyTestHelper::getDynamicProperty( $p, 'postCalled' ),
						"Response $i, post-auth callback called" );
				}
			} else {
				$this->assertNotNull( $session->getSecret( AuthManager::AUTHN_STATE ),
					"Response $i, session state" );
				foreach ( $ret->neededRequests as $neededReq ) {
					$this->assertEquals( AuthManager::ACTION_LOGIN, $neededReq->action,
						"Response $i, neededRequest action" );
				}
				$this->assertEquals(
					$ret->neededRequests,
					$this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN_CONTINUE ),
					"Response $i, continuation check"
				);
				foreach ( $providers as $p ) {
					$this->assertFalse( DynamicPropertyTestHelper::getDynamicProperty( $p, 'postCalled' ), "Response $i, post-auth callback not called" );
				}
			}

			$state = $session->getSecret( AuthManager::AUTHN_STATE );
			$maybeLink = $state['maybeLink'] ?? [];
			if ( $link && $response->status === AuthenticationResponse::RESTART ) {
				$this->assertEquals(
					$response->createRequest->maybeLink,
					$maybeLink,
					"Response $i, maybeLink"
				);
			} else {
				$this->assertEquals( [], $maybeLink, "Response $i, maybeLink" );
			}
		}

		if ( $success ) {
			$this->assertSame( $req->rememberMe, $session->shouldRememberUser(),
				'rememberMe checkbox had effect' );
		} else {
			$this->assertNotSame( $req->rememberMe, $session->shouldRememberUser(),
				'rememberMe checkbox wasn\'t applied' );
		}
	}

	public function provideAuthentication() {
		$rememberReq = new RememberMeAuthenticationRequest;
		$rememberReq->action = AuthManager::ACTION_LOGIN;

		$req = $this->getMockForAbstractClass( AuthenticationRequest::class );
		$restartResponse = AuthenticationResponse::newRestart(
			$this->message( 'authmanager-authn-no-local-user' )
		);
		$restartResponse->neededRequests = [ $rememberReq ];

		$restartResponse2Pass = AuthenticationResponse::newPass( null );
		$restartResponse2Pass->linkRequest = $req;
		$restartResponse2 = AuthenticationResponse::newRestart(
			$this->message( 'authmanager-authn-no-local-user-link' )
		);
		$restartResponse2->createRequest = new CreateFromLoginAuthenticationRequest(
			null, [ $req->getUniqueId() => $req ]
		);
		$restartResponse2->createRequest->action = AuthManager::ACTION_LOGIN;
		$restartResponse2->neededRequests = [ $rememberReq, $restartResponse2->createRequest ];

		// Hack: use a placeholder that will be replaced with the actual username in the test method.
		$userNamePlaceholder = 'PLACEHOLDER';

		return [
			'Failure in pre-auth' => [
				StatusValue::newFatal( 'fail-from-pre' ),
				[],
				[],
				[
					AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ),
					AuthenticationResponse::newFail(
						$this->message( 'authmanager-authn-not-in-progress' )
					),
				]
			],
			'Failure in primary' => [
				StatusValue::newGood(),
				$tmp = [
					AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ),
				],
				[],
				$tmp
			],
			'All primary abstain' => [
				StatusValue::newGood(),
				[
					AuthenticationResponse::newAbstain(),
				],
				[],
				[
					AuthenticationResponse::newFail( $this->message( 'authmanager-authn-no-primary' ) )
				]
			],
			'Primary UI, then redirect, then fail' => [
				StatusValue::newGood(),
				$tmp = [
					AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
					AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ),
					AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ),
				],
				[],
				$tmp
			],
			'Primary redirect, then abstain' => [
				StatusValue::newGood(),
				[
					$tmp = AuthenticationResponse::newRedirect(
						[ $req ], '/foo.html', [ 'foo' => 'bar' ]
					),
					AuthenticationResponse::newAbstain(),
				],
				[],
				[
					$tmp,
					new DomainException(
						'MockAbstractPrimaryAuthenticationProvider::continuePrimaryAuthentication() returned ABSTAIN'
					)
				]
			],
			'Primary UI, then pass with no local user' => [
				StatusValue::newGood(),
				[
					$tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
					AuthenticationResponse::newPass( null ),
				],
				[],
				[
					$tmp,
					$restartResponse,
				]
			],
			'Primary UI, then pass with no local user (link type)' => [
				StatusValue::newGood(),
				[
					$tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
					$restartResponse2Pass,
				],
				[],
				[
					$tmp,
					$restartResponse2,
				],
				true
			],
			'Primary pass with invalid username' => [
				StatusValue::newGood(),
				[
					AuthenticationResponse::newPass( '<>' ),
				],
				[],
				[
					new DomainException(
						'MockAbstractPrimaryAuthenticationProvider returned an invalid username: <>'
					),
				]
			],
			'Secondary fail' => [
				StatusValue::newGood(),
				[
					AuthenticationResponse::newPass( $userNamePlaceholder ),
				],
				$tmp = [
					AuthenticationResponse::newFail( $this->message( 'fail-in-secondary' ) ),
				],
				$tmp
			],
			'Secondary UI, then abstain' => [
				StatusValue::newGood(),
				[
					AuthenticationResponse::newPass( $userNamePlaceholder ),
				],
				[
					$tmp = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
					AuthenticationResponse::newAbstain()
				],
				[
					$tmp,
					AuthenticationResponse::newPass( $userNamePlaceholder ),
				]
			],
			'Secondary pass' => [
				StatusValue::newGood(),
				[
					AuthenticationResponse::newPass( $userNamePlaceholder ),
				],
				[
					AuthenticationResponse::newPass()
				],
				[
					AuthenticationResponse::newPass( $userNamePlaceholder ),
				]
			],
		];
	}

	public function testAuthentication_AuthManagerVerifyAuthentication() {
		$this->logger = new NullLogger();
		$this->initializeManager();

		$primaryConfig = [
			'getUniqueId' => 'primary',
			'testUserForCreation' => StatusValue::newGood(),
			'getAuthenticationRequests' => [],
			'beginPrimaryAuthentication' => AuthenticationResponse::newPass( 'UTDummy' ),
		];
		$secondaryConfig = [
			'getUniqueId' => 'secondary',
			'testUserForCreation' => StatusValue::newGood(),
			'getAuthenticationRequests' => [],
			'beginSecondaryAuthentication' => AuthenticationResponse::newAbstain(),
		];
		$updateManager = function () use ( &$primaryConfig, &$secondaryConfig ) {
			$primaryMock = $this->createConfiguredMock( AbstractPrimaryAuthenticationProvider::class, $primaryConfig );
			foreach ( [ 'beginPrimaryAuthentication', 'continuePrimaryAuthentication' ] as $method ) {
				$primaryMock->expects(
					array_key_exists( $method, $primaryConfig ) ? $this->once() : $this->never()
				)->method( $method );
			}
			$secondaryMock = $this->createConfiguredMock( AbstractSecondaryAuthenticationProvider::class, $secondaryConfig );
			foreach ( [ 'beginSecondaryAuthentication', 'continueSecondaryAuthentication' ] as $method ) {
				$secondaryMock->expects(
					array_key_exists( $method, $secondaryConfig ) ? $this->once() : $this->never()
				)->method( $method );
			}
			$this->primaryauthMocks = [ $primaryMock ];
			$this->secondaryauthMocks = [ $secondaryMock ];
			$this->initializeManager( true );
		};
		$req = new RememberMeAuthenticationRequest();
		$req->rememberMe = true;

		// Gets expected data
		$updateManager();
		$hook = $this->hook( 'AuthManagerVerifyAuthentication', AuthManagerVerifyAuthenticationHook::class, $this->once() );
		$hook->willReturnCallback( function ( $user, &$response, $authManager, $info ) {
			$this->assertSame( 'UTDummy', $user->getName() );
			$this->assertSame( AuthenticationResponse::PASS, $response->status );
			$this->assertSame( $this->manager, $authManager );
			$this->assertSame( AuthManager::ACTION_LOGIN, $info['action'] );
			$this->assertSame( 'primary', $info['primaryId'] );
		} );
		$response = $this->manager->beginAuthentication( [ $req ], 'http://localhost/' );
		$this->assertEquals( AuthenticationResponse::newPass( 'UTDummy' ), $response );
		$this->assertNotNull( $this->manager->getRequest()->getSession()->getUser() );
		$this->assertSame( 'UTDummy', $this->manager->getRequest()->getSession()->getUser()->getName() );

		// Will prevent login
		$updateManager();
		$hook = $this->hook( 'AuthManagerVerifyAuthentication', AuthManagerVerifyAuthenticationHook::class, $this->once() );
		$hook->willReturnCallback( static function ( $user, &$response, $authManager, $info ) {
			$response = AuthenticationResponse::newFail( wfMessage( 'hook-fail' ) );
			return false;
		} );
		$response = $this->manager->beginAuthentication( [ $req ], 'http://localhost/' );
		$this->assertEquals( AuthenticationResponse::newFail( wfMessage( 'hook-fail' ) ), $response );
		$this->assertTrue( $this->manager->getRequest()->getSession()->getUser()->isAnon() );

		// Will not allow invalid responses
		$updateManager();
		$hook = $this->hook( 'AuthManagerVerifyAuthentication', AuthManagerVerifyAuthenticationHook::class, $this->once() );
		$hook->willReturnCallback( static function ( $user, &$response, $authManager, $info ) {
			$response = 'invalid';
			return false;
		} );
		try {
			$this->manager->beginAuthentication( [ $req ], 'http://localhost/' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( LogicException $ex ) {
			$this->assertSame( '$response must be an AuthenticationResponse', $ex->getMessage() );
		}

		$updateManager();
		$hook = $this->hook( 'AuthManagerVerifyAuthentication', AuthManagerVerifyAuthenticationHook::class, $this->once() );
		$hook->willReturnCallback( static function ( $user, &$response, $authManager, $info ) {
			$response = AuthenticationResponse::newPass( 'UTDummy' );
		} );
		try {
			$this->manager->beginAuthentication( [ $req ], 'http://localhost/' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( LogicException $ex ) {
			$this->assertSame( 'AuthManagerVerifyAuthenticationHook must not modify the response unless it returns false', $ex->getMessage() );
		}

		$updateManager();
		$hook = $this->hook( 'AuthManagerVerifyAuthentication', AuthManagerVerifyAuthenticationHook::class, $this->once() );
		$hook->willReturnCallback( static function ( $user, &$response, $authManager, $info ) {
			return false;
		} );
		try {
			$this->manager->beginAuthentication( [ $req ], 'http://localhost/' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( LogicException $ex ) {
			$this->assertSame( 'AuthManagerVerifyAuthenticationHook must set the response to FAIL if it returns false', $ex->getMessage() );
		}

		// Will prevent restart
		$primaryConfig['beginPrimaryAuthentication'] = AuthenticationResponse::newPass( null );
		unset( $secondaryConfig['beginSecondaryAuthentication'] );
		$updateManager();
		$hook = $this->hook( 'AuthManagerVerifyAuthentication', AuthManagerVerifyAuthenticationHook::class, $this->once() );
		$hook->willReturnCallback( function ( $user, &$response, $authManager, $info ) {
			$this->assertSame( AuthenticationResponse::RESTART, $response->status );
			$response = AuthenticationResponse::newFail( wfMessage( 'hook-fail' ) );
			return false;
		} );
		$response = $this->manager->beginAuthentication( [ $req ], 'http://localhost/' );
		$this->assertEquals( AuthenticationResponse::newFail( wfMessage( 'hook-fail' ) ), $response );
		$this->assertTrue( $this->manager->getRequest()->getSession()->getUser()->isAnon() );
		$this->assertNull( $this->manager->getRequest()->getSession()->get( AuthManager::AUTHN_STATE ) );
	}

	/**
	 * @dataProvider provideUserExists
	 * @param bool $primary1Exists
	 * @param bool $primary2Exists
	 * @param bool $expect
	 */
	public function testUserExists( $primary1Exists, $primary2Exists, $expect ) {
		$userName = 'TestUserExists';
		$mock1 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock1->method( 'getUniqueId' )
			->willReturn( 'primary1' );
		$mock1->method( 'testUserExists' )
			->with( $userName )
			->willReturn( $primary1Exists );
		$mock2 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock2->method( 'getUniqueId' )
			->willReturn( 'primary2' );
		$mock2->method( 'testUserExists' )
			->with( $userName )
			->willReturn( $primary2Exists );
		$this->primaryauthMocks = [ $mock1, $mock2 ];

		$this->initializeManager( true );
		$this->assertSame( $expect, $this->manager->userExists( $userName ) );
	}

	public static function provideUserExists() {
		return [
			[ false, false, false ],
			[ true, false, true ],
			[ false, true, true ],
			[ true, true, true ],
		];
	}

	/**
	 * @dataProvider provideAllowsAuthenticationDataChange
	 * @param StatusValue $primaryReturn
	 * @param StatusValue $secondaryReturn
	 * @param Status $expect
	 */
	public function testAllowsAuthenticationDataChange( $primaryReturn, $secondaryReturn, $expect ) {
		$req = $this->getMockForAbstractClass( AuthenticationRequest::class );

		$mock1 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock1->method( 'getUniqueId' )->willReturn( '1' );
		$mock1->method( 'providerAllowsAuthenticationDataChange' )
			->with( $req )
			->willReturn( $primaryReturn );
		$mock2 = $this->createMock( AbstractSecondaryAuthenticationProvider::class );
		$mock2->method( 'getUniqueId' )->willReturn( '2' );
		$mock2->method( 'providerAllowsAuthenticationDataChange' )
			->with( $req )
			->willReturn( $secondaryReturn );

		$this->primaryauthMocks = [ $mock1 ];
		$this->secondaryauthMocks = [ $mock2 ];
		$this->initializeManager( true );
		$this->assertEquals( $expect, $this->manager->allowsAuthenticationDataChange( $req ) );
	}

	public static function provideAllowsAuthenticationDataChange() {
		$ignored = Status::newGood( 'ignored' );
		$ignored->warning( 'authmanager-change-not-supported' );

		$okFromPrimary = StatusValue::newGood();
		$okFromPrimary->warning( 'warning-from-primary' );
		$okFromSecondary = StatusValue::newGood();
		$okFromSecondary->warning( 'warning-from-secondary' );

		$throttledMailPassword = StatusValue::newFatal( 'throttled-mailpassword' );

		return [
			[
				StatusValue::newGood(),
				StatusValue::newGood(),
				Status::newGood(),
			],
			[
				StatusValue::newGood(),
				StatusValue::newGood( 'ignore' ),
				Status::newGood(),
			],
			[
				StatusValue::newGood( 'ignored' ),
				StatusValue::newGood(),
				Status::newGood(),
			],
			[
				StatusValue::newGood( 'ignored' ),
				StatusValue::newGood( 'ignored' ),
				$ignored,
			],
			[
				StatusValue::newFatal( 'fail from primary' ),
				StatusValue::newGood(),
				Status::newFatal( 'fail from primary' ),
			],
			[
				$okFromPrimary,
				StatusValue::newGood(),
				Status::wrap( $okFromPrimary ),
			],
			[
				StatusValue::newGood(),
				StatusValue::newFatal( 'fail from secondary' ),
				Status::newFatal( 'fail from secondary' ),
			],
			[
				StatusValue::newGood(),
				$okFromSecondary,
				Status::wrap( $okFromSecondary ),
			],
			[
				StatusValue::newGood(),
				$throttledMailPassword,
				Status::newGood( 'throttled-mailpassword' ),
			]
		];
	}

	public function testChangeAuthenticationData() {
		$req = $this->getMockForAbstractClass( AuthenticationRequest::class );
		$req->username = 'TestChangeAuthenticationData';

		$mock1 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock1->method( 'getUniqueId' )->willReturn( '1' );
		$mock1->expects( $this->once() )->method( 'providerChangeAuthenticationData' )
			->with( $req );
		$mock2 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock2->method( 'getUniqueId' )->willReturn( '2' );
		$mock2->expects( $this->once() )->method( 'providerChangeAuthenticationData' )
			->with( $req );

		$this->primaryauthMocks = [ $mock1, $mock2 ];
		$this->initializeManager( true );
		$this->logger->setCollect( true );
		$this->manager->changeAuthenticationData( $req );
		$this->assertSame( [
			[ LogLevel::INFO, 'Changing authentication data for {user} class {what}' ],
		], $this->logger->getBuffer() );
	}

	public function testCanCreateAccounts() {
		$types = [
			PrimaryAuthenticationProvider::TYPE_CREATE => true,
			PrimaryAuthenticationProvider::TYPE_LINK => true,
			PrimaryAuthenticationProvider::TYPE_NONE => false,
		];

		foreach ( $types as $type => $can ) {
			$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
			$mock->method( 'getUniqueId' )->willReturn( $type );
			$mock->method( 'accountCreationType' )
				->willReturn( $type );
			$this->primaryauthMocks = [ $mock ];
			$this->initializeManager( true );
			$this->assertSame( $can, $this->manager->canCreateAccounts(), $type );
		}
	}

	/**
	 * @covers \MediaWiki\Auth\AuthManager::probablyCanCreateAccount()
	 */
	public function testProbablyCanCreateAccount() {
		$this->setGroupPermissions( '*', 'createaccount', true );
		$this->initializeManager( true );
		$this->assertEquals(
			StatusValue::newGood(),
			$this->manager->probablyCanCreateAccount( new User )
		);
	}

	/**
	 * @covers \MediaWiki\Auth\AuthManager::authorizeCreateAccount()
	 */
	public function testAuthorizeCreateAccount_anon() {
		$this->setGroupPermissions( '*', 'createaccount', true );
		$this->initializeManager( true );
		$this->assertEquals(
			StatusValue::newGood(),
			$this->manager->authorizeCreateAccount( new User )
		);
	}

	/**
	 * @covers \MediaWiki\Auth\AuthManager::authorizeCreateAccount()
	 */
	public function testAuthorizeCreateAccount_anonNotAllowed() {
		$this->setGroupPermissions( '*', 'createaccount', false );
		$this->initializeManager( true );
		$status = $this->manager->authorizeCreateAccount( new User );
		$this->assertStatusError( 'badaccess-groups', $status );
	}

	/**
	 * @covers \MediaWiki\Auth\AuthManager::authorizeCreateAccount()
	 */
	public function testAuthorizeCreateAccount_readOnly() {
		$this->initializeManager( true );
		$readOnlyMode = $this->getServiceContainer()->getReadOnlyMode();
		$readOnlyMode->setReason( 'Because' );
		$this->assertEquals(
			StatusValue::newFatal( wfMessage( 'readonlytext', 'Because' ) ),
			$this->manager->authorizeCreateAccount( new User )
		);
		$readOnlyMode->setReason( false );
	}

	/**
	 * @covers \MediaWiki\Auth\AuthManager::authorizeCreateAccount()
	 * @covers \MediaWiki\Permissions\PermissionManager::checkUserBlock()
	 */
	public function testAuthorizeCreateAccount_blocked() {
		$this->initializeManager( true );

		$user = User::newFromName( 'UTBlockee' );
		if ( $user->getId() == 0 ) {
			$user->addToDatabase();
			TestUser::setPasswordForUser( $user, 'UTBlockeePassword' );
			$user->saveSettings();
		}
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockOptions = [
			'address' => $user,
			'by' => $this->getTestSysop()->getUser(),
			'reason' => __METHOD__,
			'expiry' => time() + 100500,
			'createAccount' => true,
		];
		$block = new DatabaseBlock( $blockOptions );
		$blockStore->insertBlock( $block );
		$this->resetServices();
		$this->initializeManager( true );
		$status = $this->manager->authorizeCreateAccount( $user );
		$this->assertStatusError( 'blockedtext', $status );
	}

	/**
	 * @covers \MediaWiki\Auth\AuthManager::authorizeCreateAccount()
	 * @covers \MediaWiki\Permissions\PermissionManager::checkUserBlock()
	 */
	public function testAuthorizeCreateAccount_ipBlocked() {
		$this->setGroupPermissions( '*', 'createaccount', true );
		$this->initializeManager( true );
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockOptions = [
			'address' => '127.0.0.0/24',
			'by' => $this->getTestSysop()->getUser(),
			'reason' => __METHOD__,
			'expiry' => time() + 100500,
			'createAccount' => true,
			'sitewide' => false,
		];
		$block = new DatabaseBlock( $blockOptions );
		$blockStore->insertBlock( $block );
		$status = $this->manager->authorizeCreateAccount( new User );
		$this->assertStatusError( 'blockedtext-partial', $status );
	}

	/**
	 * @covers \MediaWiki\Auth\AuthManager::authorizeCreateAccount()
	 */
	public function testAuthorizeCreateAccount_DNSBlacklist() {
		$this->overrideConfigValues( [
			MainConfigNames::EnableDnsBlacklist => true,
			MainConfigNames::DnsBlacklistUrls => [
				'localhost',
			],
			MainConfigNames::ProxyWhitelist => [],
		] );
		$this->initializeManager( true );

		// For User::getBlockedStatus()
		$this->setService( 'BlockManager', $this->blockManager );

		$status = $this->manager->authorizeCreateAccount( new User );
		$this->assertStatusError( 'sorbs_create_account_reason', $status );

		$this->overrideConfigValue( MainConfigNames::ProxyWhitelist, [ '127.0.0.1' ] );
		$this->initializeManager( true );
		$this->setService( 'BlockManager', $this->blockManager );
		$status = $this->manager->authorizeCreateAccount( new User );
		$this->assertStatusGood( $status );
	}

	/**
	 * @covers \MediaWiki\Auth\AuthManager::authorizeCreateAccount()
	 * @covers \MediaWiki\Permissions\PermissionManager::checkUserBlock()
	 */
	public function testAuthorizeCreateAccount_ipIsBlockedByUserNot() {
		$this->initializeManager( true );

		$user = User::newFromName( 'UTBlockee' );
		if ( $user->getId() == 0 ) {
			$user->addToDatabase();
			TestUser::setPasswordForUser( $user, 'UTBlockeePassword' );
			$user->saveSettings();
		}
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockOptions = [
			'address' => $user,
			'by' => $this->getTestSysop()->getUser(),
			'reason' => __METHOD__,
			'expiry' => time() + 100500,
			'createAccount' => false,
		];
		$block = new DatabaseBlock( $blockOptions );
		$blockStore->insertBlock( $block );

		$blockOptions = [
			'address' => '127.0.0.0/24',
			'by' => $this->getTestSysop()->getUser(),
			'reason' => __METHOD__,
			'expiry' => time() + 100500,
			'createAccount' => true,
			'sitewide' => false,
		];
		$block = new DatabaseBlock( $blockOptions );
		$blockStore->insertBlock( $block );

		$this->resetServices();
		$this->initializeManager( true );
		$status = $this->manager->authorizeCreateAccount( $user );
		$this->assertStatusError( 'blockedtext-partial', $status );
	}

	/**
	 * @param string $uniq
	 * @return string
	 */
	private static function usernameForCreation( $uniq = '' ) {
		$i = 0;
		do {
			$username = "UTAuthManagerTestAccountCreation" . $uniq . ++$i;
		} while ( User::newFromName( $username )->getId() !== 0 );
		return $username;
	}

	public function testCanCreateAccount() {
		$username = self::usernameForCreation();
		$this->initializeManager();

		$this->assertEquals(
			Status::newFatal( 'authmanager-create-disabled' ),
			$this->manager->canCreateAccount( $username )
		);

		$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock->method( 'getUniqueId' )->willReturn( 'X' );
		$mock->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$mock->method( 'testUserExists' )->willReturn( true );
		$mock->method( 'testUserForCreation' )
			->willReturn( StatusValue::newGood() );
		$this->primaryauthMocks = [ $mock ];
		$this->initializeManager( true );

		$this->assertEquals(
			Status::newFatal( 'userexists' ),
			$this->manager->canCreateAccount( $username )
		);

		$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock->method( 'getUniqueId' )->willReturn( 'X' );
		$mock->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$mock->method( 'testUserExists' )->willReturn( false );
		$mock->method( 'testUserForCreation' )
			->willReturn( StatusValue::newGood() );
		$this->primaryauthMocks = [ $mock ];
		$this->initializeManager( true );

		$this->assertEquals(
			Status::newFatal( 'noname' ),
			$this->manager->canCreateAccount( $username . '<>' )
		);

		$existingUserName = $this->getTestSysop()->getUserIdentity()->getName();
		$this->assertEquals(
			Status::newFatal( 'userexists' ),
			$this->manager->canCreateAccount( $existingUserName )
		);

		$this->assertEquals(
			Status::newGood(),
			$this->manager->canCreateAccount( $username )
		);

		$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock->method( 'getUniqueId' )->willReturn( 'X' );
		$mock->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$mock->method( 'testUserExists' )->willReturn( false );
		$mock->method( 'testUserForCreation' )
			->willReturn( StatusValue::newFatal( 'fail' ) );
		$this->primaryauthMocks = [ $mock ];
		$this->initializeManager( true );

		$this->assertEquals(
			Status::newFatal( 'fail' ),
			$this->manager->canCreateAccount( $username )
		);
	}

	public function testBeginAccountCreation() {
		$creator = $this->getTestSysop()->getUser();
		$userReq = new UsernameAuthenticationRequest;
		$this->logger = new TestLogger( false, static function ( $message, $level ) {
			return $level === LogLevel::DEBUG ? null : $message;
		} );
		$this->initializeManager();

		$this->request->getSession()->setSecret( AuthManager::ACCOUNT_CREATION_STATE, 'test' );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		try {
			$this->manager->beginAccountCreation(
				$creator, [], 'http://localhost/'
			);
			$this->fail( 'Expected exception not thrown' );
		} catch ( LogicException $ex ) {
			$this->assertEquals( 'Account creation is not possible', $ex->getMessage() );
		}
		$this->unhook( 'LocalUserCreated' );
		$this->assertNull(
			$this->request->getSession()->getSecret( AuthManager::ACCOUNT_CREATION_STATE )
		);

		$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock->method( 'getUniqueId' )->willReturn( 'X' );
		$mock->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$mock->method( 'testUserExists' )->willReturn( true );
		$mock->method( 'testUserForCreation' )
			->willReturn( StatusValue::newGood() );
		$this->primaryauthMocks = [ $mock ];
		$this->initializeManager( true );

		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->beginAccountCreation( $creator, [], 'http://localhost/' );
		$this->unhook( 'LocalUserCreated' );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'noname', $ret->message->getKey() );

		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$userReq->username = self::usernameForCreation();
		$userReq2 = new UsernameAuthenticationRequest;
		$userReq2->username = $userReq->username . 'X';
		$ret = $this->manager->beginAccountCreation(
			$creator, [ $userReq, $userReq2 ], 'http://localhost/'
		);
		$this->unhook( 'LocalUserCreated' );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'noname', $ret->message->getKey() );

		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$readOnlyMode = $this->getServiceContainer()->getReadOnlyMode();
		$readOnlyMode->setReason( 'Because' );
		$userReq->username = self::usernameForCreation();
		$ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' );
		$this->unhook( 'LocalUserCreated' );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'readonlytext', $ret->message->getKey() );
		$this->assertSame( [ 'Because' ], $ret->message->getParams() );
		$readOnlyMode->setReason( false );

		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$userReq->username = self::usernameForCreation();
		$ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' );
		$this->unhook( 'LocalUserCreated' );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'userexists', $ret->message->getKey() );

		$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock->method( 'getUniqueId' )->willReturn( 'X' );
		$mock->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$mock->method( 'testUserExists' )->willReturn( false );
		$mock->method( 'testUserForCreation' )
			->willReturn( StatusValue::newFatal( 'fail' ) );
		$this->primaryauthMocks = [ $mock ];
		$this->initializeManager( true );

		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$userReq->username = self::usernameForCreation();
		$ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' );
		$this->unhook( 'LocalUserCreated' );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'fail', $ret->message->getKey() );

		$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock->method( 'getUniqueId' )->willReturn( 'X' );
		$mock->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$mock->method( 'testUserExists' )->willReturn( false );
		$mock->method( 'testUserForCreation' )
			->willReturn( StatusValue::newGood() );
		$this->primaryauthMocks = [ $mock ];
		$this->initializeManager( true );

		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$userReq->username = self::usernameForCreation() . '<>';
		$ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' );
		$this->unhook( 'LocalUserCreated' );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'noname', $ret->message->getKey() );

		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$userReq->username = $creator->getName();
		$ret = $this->manager->beginAccountCreation( $creator, [ $userReq ], 'http://localhost/' );
		$this->unhook( 'LocalUserCreated' );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'userexists', $ret->message->getKey() );

		$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock->method( 'getUniqueId' )->willReturn( 'X' );
		$mock->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$mock->method( 'testUserExists' )->willReturn( false );
		$mock->method( 'testUserForCreation' )
			->willReturn( StatusValue::newGood() );
		$mock->method( 'testForAccountCreation' )
			->willReturn( StatusValue::newFatal( 'fail' ) );
		$this->primaryauthMocks = [ $mock ];
		$this->initializeManager( true );

		$req = $this->getMockBuilder( UserDataAuthenticationRequest::class )
			->onlyMethods( [ 'populateUser' ] )
			->getMock();
		$req->method( 'populateUser' )
			->willReturn( StatusValue::newFatal( 'populatefail' ) );
		$userReq->username = self::usernameForCreation();
		$ret = $this->manager->beginAccountCreation(
			$creator, [ $userReq, $req ], 'http://localhost/'
		);
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'populatefail', $ret->message->getKey() );

		$req = new UserDataAuthenticationRequest;
		$userReq->username = self::usernameForCreation();

		$ret = $this->manager->beginAccountCreation(
			$creator, [ $userReq, $req ], 'http://localhost/'
		);
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'fail', $ret->message->getKey() );

		$this->manager->beginAccountCreation(
			User::newFromName( $userReq->username ), [ $userReq, $req ], 'http://localhost/'
		);
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'fail', $ret->message->getKey() );
	}

	public function testContinueAccountCreation() {
		$creator = $this->getTestSysop()->getUser();
		$username = self::usernameForCreation();
		$this->logger = new TestLogger( false, static function ( $message, $level ) {
			return $level === LogLevel::DEBUG ? null : $message;
		} );
		$this->initializeManager();

		$session = [
			'userid' => 0,
			'username' => $username,
			'creatorid' => 0,
			'creatorname' => $username,
			'reqs' => [],
			'providerIds' => [ 'preauth' => [], 'primaryauth' => [], 'secondaryauth' => [] ],
			'primary' => null,
			'primaryResponse' => null,
			'secondary' => [],
			'ranPreTests' => true,
		];

		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		try {
			$this->manager->continueAccountCreation( [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( LogicException $ex ) {
			$this->assertEquals( 'Account creation is not possible', $ex->getMessage() );
		}
		$this->unhook( 'LocalUserCreated' );

		$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock->method( 'getUniqueId' )->willReturn( 'X' );
		$mock->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$mock->method( 'testUserExists' )->willReturn( false );
		$mock->method( 'beginPrimaryAccountCreation' )
			->willReturn( AuthenticationResponse::newFail( $this->message( 'fail' ) ) );
		$this->primaryauthMocks = [ $mock ];
		$session['providerIds']['primaryauth'][] = 'X';
		$this->initializeManager( true );

		$this->request->getSession()->setSecret( AuthManager::ACCOUNT_CREATION_STATE, null );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->continueAccountCreation( [] );
		$this->unhook( 'LocalUserCreated' );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'authmanager-create-not-in-progress', $ret->message->getKey() );

		$this->request->getSession()->setSecret( AuthManager::ACCOUNT_CREATION_STATE,
			[ 'username' => "$username<>" ] + $session );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->continueAccountCreation( [] );
		$this->unhook( 'LocalUserCreated' );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'noname', $ret->message->getKey() );
		$this->assertNull(
			$this->request->getSession()->getSecret( AuthManager::ACCOUNT_CREATION_STATE )
		);

		$this->request->getSession()->setSecret( AuthManager::ACCOUNT_CREATION_STATE, $session );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$cache = $this->objectCacheFactory->getLocalClusterInstance();
		$lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) );
		$ret = $this->manager->continueAccountCreation( [] );
		unset( $lock );
		$this->unhook( 'LocalUserCreated' );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'usernameinprogress', $ret->message->getKey() );
		// This error shouldn't remove the existing session, because the
		// raced-with process "owns" it.
		$this->assertSame(
			$session, $this->request->getSession()->getSecret( AuthManager::ACCOUNT_CREATION_STATE )
		);

		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$this->request->getSession()->setSecret( AuthManager::ACCOUNT_CREATION_STATE,
			[ 'username' => $creator->getName() ] + $session );
		$readOnlyMode = $this->getServiceContainer()->getReadOnlyMode();
		$readOnlyMode->setReason( 'Because' );
		$ret = $this->manager->continueAccountCreation( [] );
		$this->unhook( 'LocalUserCreated' );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'readonlytext', $ret->message->getKey() );
		$this->assertSame( [ 'Because' ], $ret->message->getParams() );
		$readOnlyMode->setReason( false );

		$this->request->getSession()->setSecret( AuthManager::ACCOUNT_CREATION_STATE,
			[ 'username' => $creator->getName() ] + $session );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->continueAccountCreation( [] );
		$this->unhook( 'LocalUserCreated' );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'userexists', $ret->message->getKey() );
		$this->assertNull(
			$this->request->getSession()->getSecret( AuthManager::ACCOUNT_CREATION_STATE )
		);

		$this->request->getSession()->setSecret( AuthManager::ACCOUNT_CREATION_STATE,
			[ 'userid' => $creator->getId() ] + $session );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		try {
			$ret = $this->manager->continueAccountCreation( [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertEquals( "User \"{$username}\" should exist now, but doesn't!", $ex->getMessage() );
		}
		$this->unhook( 'LocalUserCreated' );
		$this->assertNull(
			$this->request->getSession()->getSecret( AuthManager::ACCOUNT_CREATION_STATE )
		);

		$id = $creator->getId();
		$name = $creator->getName();
		$this->request->getSession()->setSecret( AuthManager::ACCOUNT_CREATION_STATE,
			[ 'username' => $name, 'userid' => $id + 1 ] + $session );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		try {
			$ret = $this->manager->continueAccountCreation( [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertEquals(
				"User \"{$name}\" exists, but ID $id !== " . ( $id + 1 ) . '!', $ex->getMessage()
			);
		}
		$this->unhook( 'LocalUserCreated' );
		$this->assertNull(
			$this->request->getSession()->getSecret( AuthManager::ACCOUNT_CREATION_STATE )
		);

		$req = $this->getMockBuilder( UserDataAuthenticationRequest::class )
			->onlyMethods( [ 'populateUser' ] )
			->getMock();
		$req->method( 'populateUser' )
			->willReturn( StatusValue::newFatal( 'populatefail' ) );
		$this->request->getSession()->setSecret( AuthManager::ACCOUNT_CREATION_STATE,
			[ 'reqs' => [ $req ] ] + $session );
		$ret = $this->manager->continueAccountCreation( [] );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'populatefail', $ret->message->getKey() );
		$this->assertNull(
			$this->request->getSession()->getSecret( AuthManager::ACCOUNT_CREATION_STATE )
		);
	}

	/**
	 * @dataProvider provideAccountCreation
	 * @param StatusValue $preTest
	 * @param StatusValue $primaryTest
	 * @param StatusValue $secondaryTest
	 * @param array $primaryResponses
	 * @param array $secondaryResponses
	 * @param array $managerResponses
	 */
	public function testAccountCreation(
		StatusValue $preTest, $primaryTest, $secondaryTest,
		array $primaryResponses, array $secondaryResponses, array $managerResponses
	) {
		$creator = $this->getTestSysop()->getUser();
		$username = self::usernameForCreation();

		$this->initializeManager();

		// Set up lots of mocks...
		$req = $this->getMockForAbstractClass( AuthenticationRequest::class );
		$mocks = [];
		foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) {
			$class = ucfirst( $key ) . 'AuthenticationProvider';
			$mocks[$key] = $this->getMockBuilder( "MediaWiki\\Auth\\Abstract$class" )
				->setMockClassName( "MockAbstract$class" )
				->getMock();
			$mocks[$key]->method( 'getUniqueId' )
				->willReturn( $key );
			$mocks[$key]->method( 'testUserForCreation' )
				->willReturn( StatusValue::newGood() );
			$mocks[$key]->method( 'testForAccountCreation' )
				->willReturnCallback(
					function ( $user, $creatorIn, $reqs )
						use ( $username, $creator, $req, $key, $preTest, $primaryTest, $secondaryTest )
					{
						$this->assertSame( $username, $user->getName() );
						$this->assertSame( $creator->getId(), $creatorIn->getId() );
						$this->assertSame( $creator->getName(), $creatorIn->getName() );
						$foundReq = false;
						foreach ( $reqs as $r ) {
							$this->assertSame( $username, $r->username );
							$foundReq = $foundReq || get_class( $r ) === get_class( $req );
						}
						$this->assertTrue( $foundReq, '$reqs contains $req' );
						if ( $key === 'pre' ) {
							return $preTest;
						}
						if ( $key === 'primary' ) {
							return $primaryTest;
						}
						return $secondaryTest;
					}
				);

			for ( $i = 2; $i <= 3; $i++ ) {
				$mocks[$key . $i] = $this->createMock( "MediaWiki\\Auth\\Abstract$class" );
				$mocks[$key . $i]->method( 'getUniqueId' )
					->willReturn( $key . $i );
				$mocks[$key . $i]->method( 'testUserForCreation' )
					->willReturn( StatusValue::newGood() );
				$mocks[$key . $i]->expects( $this->atMost( 1 ) )->method( 'testForAccountCreation' )
					->willReturn( StatusValue::newGood() );
			}
		}

		$mocks['primary']->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$mocks['primary']->method( 'testUserExists' )
			->willReturn( false );
		$ct = count( $primaryResponses );
		$callback = $this->returnCallback( function ( $user, $creatorArg, $reqs ) use ( $creator, $username, $req, &$primaryResponses ) {
			$this->assertSame( $username, $user->getName() );
			$this->assertSame( $creator->getName(), $creatorArg->getName() );
			$foundReq = false;
			foreach ( $reqs as $r ) {
				$this->assertSame( $username, $r->username );
				$foundReq = $foundReq || get_class( $r ) === get_class( $req );
			}
			$this->assertTrue( $foundReq, '$reqs contains $req' );
			return array_shift( $primaryResponses );
		} );
		$mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) )
			->method( 'beginPrimaryAccountCreation' )
			->will( $callback );
		$mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) )
			->method( 'continuePrimaryAccountCreation' )
			->will( $callback );

		$ct = count( $secondaryResponses );
		$callback = $this->returnCallback( function ( $user, $creatorArg, $reqs ) use ( $creator, $username, $req, &$secondaryResponses ) {
			$this->assertSame( $username, $user->getName() );
			$this->assertSame( $creator->getName(), $creatorArg->getName() );
			$foundReq = false;
			foreach ( $reqs as $r ) {
				$this->assertSame( $username, $r->username );
				$foundReq = $foundReq || get_class( $r ) === get_class( $req );
			}
			$this->assertTrue( $foundReq, '$reqs contains $req' );
			return array_shift( $secondaryResponses );
		} );
		$mocks['secondary']->expects( $this->exactly( min( 1, $ct ) ) )
			->method( 'beginSecondaryAccountCreation' )
			->will( $callback );
		$mocks['secondary']->expects( $this->exactly( max( 0, $ct - 1 ) ) )
			->method( 'continueSecondaryAccountCreation' )
			->will( $callback );

		$abstain = AuthenticationResponse::newAbstain();
		$mocks['primary2']->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_LINK );
		$mocks['primary2']->method( 'testUserExists' )
			->willReturn( false );
		$mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAccountCreation' )
			->willReturn( $abstain );
		$mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAccountCreation' );
		$mocks['primary3']->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_NONE );
		$mocks['primary3']->method( 'testUserExists' )
			->willReturn( false );
		$mocks['primary3']->expects( $this->never() )->method( 'beginPrimaryAccountCreation' );
		$mocks['primary3']->expects( $this->never() )->method( 'continuePrimaryAccountCreation' );
		$mocks['secondary2']->expects( $this->atMost( 1 ) )
			->method( 'beginSecondaryAccountCreation' )
			->willReturn( $abstain );
		$mocks['secondary2']->expects( $this->never() )->method( 'continueSecondaryAccountCreation' );
		$mocks['secondary3']->expects( $this->atMost( 1 ) )
			->method( 'beginSecondaryAccountCreation' )
			->willReturn( $abstain );
		$mocks['secondary3']->expects( $this->never() )->method( 'continueSecondaryAccountCreation' );

		$this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ];
		$this->primaryauthMocks = [ $mocks['primary3'], $mocks['primary'], $mocks['primary2'] ];
		$this->secondaryauthMocks = [
			$mocks['secondary3'], $mocks['secondary'], $mocks['secondary2']
		];

		$this->logger = new TestLogger( true, static function ( $message, $level ) {
			return $level === LogLevel::DEBUG ? null : $message;
		} );
		$expectLog = [];
		$this->initializeManager( true );

		$constraint = Assert::logicalOr(
			$this->equalTo( AuthenticationResponse::PASS ),
			$this->equalTo( AuthenticationResponse::FAIL )
		);
		$providers = array_merge(
			$this->preauthMocks, $this->primaryauthMocks, $this->secondaryauthMocks
		);
		foreach ( $providers as $p ) {
			DynamicPropertyTestHelper::setDynamicProperty( $p, 'postCalled', false );
			$p->expects( $this->atMost( 1 ) )->method( 'postAccountCreation' )
				->willReturnCallback( function ( $user, $creatorArg, $response )
					use ( $creator, $constraint, $p, $username )
				{
					$this->assertInstanceOf( User::class, $user );
					$this->assertSame( $username, $user->getName() );
					$this->assertSame( $creator->getName(), $creatorArg->getName() );
					$this->assertInstanceOf( AuthenticationResponse::class, $response );
					$this->assertThat( $response->status, $constraint );
					DynamicPropertyTestHelper::setDynamicProperty( $p, 'postCalled', $response->status );
				} );
		}

		// We're testing with $wgNewUserLog = false, so assert that it worked
		$dbw = $this->getDb();
		$maxLogId = $dbw->newSelectQueryBuilder()
			->select( 'MAX(log_id)' )
			->from( 'logging' )
			->where( [ 'log_type' => 'newusers' ] )
			->fetchField();

		$first = true;
		$created = false;
		foreach ( $managerResponses as $i => $response ) {
			$success = $response instanceof AuthenticationResponse &&
				$response->status === AuthenticationResponse::PASS;
			if ( $i === 'created' ) {
				$created = true;
				$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->once() )
					->with(
						$this->callback( static function ( $user ) use ( $username ) {
							return $user->getName() === $username;
						} ),
						false
					);
				$expectLog[] = [ LogLevel::INFO, "Creating user {user} during account creation" ];
			} else {
				$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
			}

			try {
				if ( $first ) {
					$userReq = new UsernameAuthenticationRequest;
					$userReq->username = $username;
					$ret = $this->manager->beginAccountCreation(
						$creator, [ $userReq, $req ], 'http://localhost/'
					);
				} else {
					$ret = $this->manager->continueAccountCreation( [ $req ] );
				}
				if ( $response instanceof Exception ) {
					$this->fail( 'Expected exception not thrown', "Response $i" );
				}
			} catch ( Exception $ex ) {
				if ( !$response instanceof Exception ) {
					throw $ex;
				}
				$this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" );
				$this->assertNull(
					$this->request->getSession()->getSecret( AuthManager::ACCOUNT_CREATION_STATE ),
					"Response $i, exception, session state"
				);
				$this->unhook( 'LocalUserCreated' );
				return;
			}

			$this->unhook( 'LocalUserCreated' );

			$this->assertSame( 'http://localhost/', $req->returnToUrl );

			if ( $success ) {
				$this->assertNotNull( $ret->loginRequest, "Response $i, login marker" );
				$this->assertContains(
					$ret->loginRequest, $this->managerPriv->createdAccountAuthenticationRequests,
					"Response $i, login marker"
				);

				$expectLog[] = [
					LogLevel::INFO,
					"MediaWiki\Auth\AuthManager::continueAccountCreation: Account creation succeeded for {user}"
				];

				// Set some fields in the expected $response that we couldn't
				// know in provideAccountCreation().
				$response->username = $username;
				$response->loginRequest = $ret->loginRequest;
			} else {
				$this->assertNull( $ret->loginRequest, "Response $i, login marker" );
				$this->assertSame( [], $this->managerPriv->createdAccountAuthenticationRequests,
					"Response $i, login marker" );
			}
			$ret->message = $this->message( $ret->message );
			$this->assertResponseEquals( $response, $ret, "Response $i, response" );
			if ( $success || $response->status === AuthenticationResponse::FAIL ) {
				$this->assertNull(
					$this->request->getSession()->getSecret( AuthManager::ACCOUNT_CREATION_STATE ),
					"Response $i, session state"
				);
				foreach ( $providers as $p ) {
					$this->assertSame( $response->status, DynamicPropertyTestHelper::getDynamicProperty( $p, 'postCalled' ),
						"Response $i, post-auth callback called" );
				}
			} else {
				$this->assertNotNull(
					$this->request->getSession()->getSecret( AuthManager::ACCOUNT_CREATION_STATE ),
					"Response $i, session state"
				);
				foreach ( $ret->neededRequests as $neededReq ) {
					$this->assertEquals( AuthManager::ACTION_CREATE, $neededReq->action,
						"Response $i, neededRequest action" );
				}
				$this->assertEquals(
					$ret->neededRequests,
					$this->manager->getAuthenticationRequests( AuthManager::ACTION_CREATE_CONTINUE ),
					"Response $i, continuation check"
				);
				foreach ( $providers as $p ) {
					$this->assertFalse( DynamicPropertyTestHelper::getDynamicProperty( $p, 'postCalled' ), "Response $i, post-auth callback not called" );
				}
			}

			$userIdentity = $this->userIdentityLookup->getUserIdentityByName( $username );
			$this->assertSame( $created, $userIdentity && $userIdentity->isRegistered() );

			$first = false;
		}

		$this->assertSame( $expectLog, $this->logger->getBuffer() );

		$this->assertSame(
			$maxLogId,
			$dbw->newSelectQueryBuilder()
				->select( 'MAX(log_id)' )
				->from( 'logging' )
				->where( [ 'log_type' => 'newusers' ] )
				->fetchField() );
	}

	public function provideAccountCreation() {
		$req = $this->getMockForAbstractClass( AuthenticationRequest::class );
		$good = StatusValue::newGood();

		return [
			'Pre-creation test fail in pre' => [
				StatusValue::newFatal( 'fail-from-pre' ), $good, $good,
				[],
				[],
				[
					AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ),
				]
			],
			'Pre-creation test fail in primary' => [
				$good, StatusValue::newFatal( 'fail-from-primary' ), $good,
				[],
				[],
				[
					AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ),
				]
			],
			'Pre-creation test fail in secondary' => [
				$good, $good, StatusValue::newFatal( 'fail-from-secondary' ),
				[],
				[],
				[
					AuthenticationResponse::newFail( $this->message( 'fail-from-secondary' ) ),
				]
			],
			'Failure in primary' => [
				$good, $good, $good,
				$tmp = [
					AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ),
				],
				[],
				$tmp
			],
			'All primary abstain' => [
				$good, $good, $good,
				[
					AuthenticationResponse::newAbstain(),
				],
				[],
				[
					AuthenticationResponse::newFail( $this->message( 'authmanager-create-no-primary' ) )
				]
			],
			'Primary UI, then redirect, then fail' => [
				$good, $good, $good,
				$tmp = [
					AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
					AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ),
					AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ),
				],
				[],
				$tmp
			],
			'Primary redirect, then abstain' => [
				$good, $good, $good,
				[
					$tmp = AuthenticationResponse::newRedirect(
						[ $req ], '/foo.html', [ 'foo' => 'bar' ]
					),
					AuthenticationResponse::newAbstain(),
				],
				[],
				[
					$tmp,
					new DomainException(
						'MockAbstractPrimaryAuthenticationProvider::continuePrimaryAccountCreation() returned ABSTAIN'
					)
				]
			],
			'Primary UI, then pass; secondary abstain' => [
				$good, $good, $good,
				[
					$tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
					AuthenticationResponse::newPass(),
				],
				[
					AuthenticationResponse::newAbstain(),
				],
				[
					$tmp1,
					'created' => AuthenticationResponse::newPass( '' ),
				]
			],
			'Primary pass; secondary UI then pass' => [
				$good, $good, $good,
				[
					AuthenticationResponse::newPass( '' ),
				],
				[
					$tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
					AuthenticationResponse::newPass( '' ),
				],
				[
					'created' => $tmp1,
					AuthenticationResponse::newPass( '' ),
				]
			],
			'Primary pass; secondary fail' => [
				$good, $good, $good,
				[
					AuthenticationResponse::newPass(),
				],
				[
					AuthenticationResponse::newFail( $this->message( '...' ) ),
				],
				[
					'created' => new DomainException(
						'MockAbstractSecondaryAuthenticationProvider::beginSecondaryAccountCreation() returned FAIL. ' .
							'Secondary providers are not allowed to fail account creation, ' .
							'that should have been done via testForAccountCreation().'
					)
				]
			],
		];
	}

	/**
	 * @dataProvider provideAccountCreationLogging
	 * @param bool $isAnon
	 * @param string|null $logSubtype
	 */
	public function testAccountCreationLogging( $isAnon, $logSubtype ) {
		$creator = $isAnon ? new User : $this->getTestSysop()->getUser();
		$username = self::usernameForCreation();

		$this->initializeManager();

		// Set up lots of mocks...
		$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock->method( 'getUniqueId' )
			->willReturn( 'primary' );
		$mock->method( 'testUserForCreation' )
			->willReturn( StatusValue::newGood() );
		$mock->method( 'testForAccountCreation' )
			->willReturn( StatusValue::newGood() );
		$mock->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$mock->method( 'testUserExists' )
			->willReturn( false );
		$mock->method( 'beginPrimaryAccountCreation' )
			->willReturn( AuthenticationResponse::newPass( $username ) );
		$mock->method( 'finishAccountCreation' )
			->willReturn( $logSubtype );

		$this->primaryauthMocks = [ $mock ];
		$this->initializeManager( true );
		$this->logger->setCollect( true );

		$this->config->set( MainConfigNames::NewUserLog, true );

		$dbw = $this->getDb();
		$maxLogId = $dbw->newSelectQueryBuilder()
			->select( 'MAX(log_id)' )
			->from( 'logging' )
			->where( [ 'log_type' => 'newusers' ] )
			->fetchField();

		$userReq = new UsernameAuthenticationRequest;
		$userReq->username = $username;
		$reasonReq = new CreationReasonAuthenticationRequest;
		$reasonReq->reason = $this->toString();
		$ret = $this->manager->beginAccountCreation(
			$creator, [ $userReq, $reasonReq ], 'http://localhost/'
		);

		$this->assertSame( AuthenticationResponse::PASS, $ret->status );

		$user = User::newFromName( $username );
		$this->assertNotEquals( 0, $user->getId() );
		$this->assertNotEquals( $creator->getId(), $user->getId() );

		$queryBuilder = DatabaseLogEntry::newSelectQueryBuilder( $dbw )
			->where( [ 'log_id > ' . (int)$maxLogId, 'log_type' => 'newusers' ] );
		$rows = iterator_to_array( $queryBuilder->caller( __METHOD__ )->fetchResultSet() );
		$this->assertCount( 1, $rows );
		$entry = DatabaseLogEntry::newFromRow( reset( $rows ) );

		$this->assertSame( $logSubtype ?: ( $isAnon ? 'create' : 'create2' ), $entry->getSubtype() );
		$this->assertSame(
			$isAnon ? $user->getId() : $creator->getId(),
			$entry->getPerformerIdentity()->getId()
		);
		$this->assertSame(
			$isAnon ? $user->getName() : $creator->getName(),
			$entry->getPerformerIdentity()->getName()
		);
		$this->assertSame( $user->getUserPage()->getFullText(), $entry->getTarget()->getFullText() );
		$this->assertSame( [ '4::userid' => $user->getId() ], $entry->getParameters() );
		$this->assertSame( $this->toString(), $entry->getComment() );
	}

	public static function provideAccountCreationLogging() {
		return [
			[ true, null ],
			[ true, 'foobar' ],
			[ false, null ],
			[ false, 'byemail' ],
		];
	}

	public function testAccountCreation_AuthManagerVerifyAuthentication() {
		$this->logger = new NullLogger();
		$this->initializeManager();

		$primaryConfig = [
			'getUniqueId' => 'primary',
			'accountCreationType' => PrimaryAuthenticationProvider::TYPE_CREATE,
			'testUserForCreation' => StatusValue::newGood(),
			'testForAccountCreation' => StatusValue::newGood(),
			'getAuthenticationRequests' => [],
			'beginPrimaryAccountCreation' => AuthenticationResponse::newPass(),
		];
		$secondaryConfig = [
			'getUniqueId' => 'secondary',
			'testUserForCreation' => StatusValue::newGood(),
			'testForAccountCreation' => StatusValue::newGood(),
			'getAuthenticationRequests' => [],
			'beginSecondaryAccountCreation' => AuthenticationResponse::newAbstain(),
		];
		$updateManager = function () use ( &$primaryConfig, &$secondaryConfig ) {
			$primaryMock = $this->createConfiguredMock( AbstractPrimaryAuthenticationProvider::class, $primaryConfig );
			foreach ( [ 'beginPrimaryAccountCreation', 'continuePrimaryAccountCreation' ] as $method ) {
				$primaryMock->expects(
					array_key_exists( $method, $primaryConfig ) ? $this->once() : $this->never()
				)->method( $method );
			}
			$secondaryMock = $this->createConfiguredMock( AbstractSecondaryAuthenticationProvider::class, $secondaryConfig );
			foreach ( [ 'beginSecondaryAccountCreation', 'continueSecondaryAccountCreation' ] as $method ) {
				$secondaryMock->expects(
					array_key_exists( $method, $secondaryConfig ) ? $this->once() : $this->never()
				)->method( $method );
			}
			$this->primaryauthMocks = [ $primaryMock ];
			$this->secondaryauthMocks = [ $secondaryMock ];
			$this->initializeManager( true );
		};
		$req = new UsernameAuthenticationRequest();
		$req->username = 'UTDummy';

		// Gets expected data
		$updateManager();
		$hook = $this->hook( 'AuthManagerVerifyAuthentication', AuthManagerVerifyAuthenticationHook::class, $this->once() );
		$hook->willReturnCallback( function ( $user, &$response, $authManager, $info ) {
			$this->assertSame( 'UTDummy', $user->getName() );
			$this->assertSame( AuthenticationResponse::PASS, $response->status );
			$this->assertSame( $this->manager, $authManager );
			$this->assertSame( AuthManager::ACTION_CREATE, $info['action'] );
			$this->assertSame( 'primary', $info['primaryId'] );
		} );
		$response = $this->manager->beginAccountCreation( new User(), [ $req ], 'http://localhost/' );
		// Simplify verifying $response, loginRequest would include the user ID
		$response->loginRequest = null;
		$this->assertEquals( AuthenticationResponse::newPass( 'UTDummy' ), $response );
		$this->assertNotNull( $this->manager->getRequest()->getSession()->getUser() );
		$this->assertTrue( $this->getServiceContainer()->getUserFactory()->newFromName( 'UTDummy' )->isRegistered() );

		// Will prevent login
		unset( $secondaryConfig['beginSecondaryAccountCreation'] );
		$updateManager();
		$req = new UsernameAuthenticationRequest();
		$req->username = 'UTDummy2';
		$hook = $this->hook( 'AuthManagerVerifyAuthentication', AuthManagerVerifyAuthenticationHook::class, $this->once() );
		$hook->willReturnCallback( static function ( $user, &$response, $authManager, $info ) {
			$response = AuthenticationResponse::newFail( wfMessage( 'hook-fail' ) );
			return false;
		} );
		$response = $this->manager->beginAccountCreation( new User(), [ $req ], 'http://localhost/' );
		$this->assertEquals( AuthenticationResponse::newFail( wfMessage( 'hook-fail' ) ), $response );
		$this->assertFalse( $this->getServiceContainer()->getUserFactory()->newFromName( 'UTDummy2' )->isRegistered() );

		// the LogicError paths are already tested under testAuthentication_AuthManagerVerifyAuthentication
	}

	public function testAutoAccountCreation() {
		// PHPUnit seems to have a bug where it will call the ->with()
		// callbacks for our hooks again after the test is run (WTF?), which
		// breaks here because $username no longer matches $user by the end of
		// the testing.
		$workaroundPHPUnitBug = false;

		$username = self::usernameForCreation();
		$expectedSource = AuthManager::AUTOCREATE_SOURCE_SESSION;

		$this->setGroupPermissions( '*', 'createaccount', true );
		$this->setGroupPermissions( '*', 'autocreateaccount', false );
		$this->initializeManager( true );

		// Set up lots of mocks...
		$mocks = [];
		foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) {
			$class = ucfirst( $key ) . 'AuthenticationProvider';
			$mocks[$key] = $this->createMock( "MediaWiki\\Auth\\Abstract$class" );
			$mocks[$key]->method( 'getUniqueId' )
				->willReturn( $key );
		}

		$good = StatusValue::newGood();
		$ok = StatusValue::newFatal( 'ok' );
		$callback = $this->callback( static function ( $user ) use ( &$username, &$workaroundPHPUnitBug ) {
			return $workaroundPHPUnitBug || $user->getName() === $username;
		} );
		$callback2 = $this->callback(
			static function ( $source ) use ( &$expectedSource, &$workaroundPHPUnitBug ) {
				return $workaroundPHPUnitBug || $source === $expectedSource;
			}
		);

		$mocks['pre']->expects( $this->exactly( 13 ) )->method( 'testUserForCreation' )
			->with( $callback, $callback2 )
			->willReturnOnConsecutiveCalls(
				$ok, $ok, $ok, // For testing permissions
				StatusValue::newFatal( 'fail-in-pre' ), $good, $good,
				$good, // backoff test
				$good, // addToDatabase fails test
				$good, // addToDatabase throws test
				$good, // addToDatabase exists test
				$good, $good, $good // success
			);

		$mocks['primary']->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$mocks['primary']->method( 'testUserExists' )
			->willReturn( true );
		$mocks['primary']->expects( $this->exactly( 9 ) )->method( 'testUserForCreation' )
			->with( $callback, $callback2 )
			->willReturnOnConsecutiveCalls(
				StatusValue::newFatal( 'fail-in-primary' ), $good,
				$good, // backoff test
				$good, // addToDatabase fails test
				$good, // addToDatabase throws test
				$good, // addToDatabase exists test
				$good, $good, $good
			);
		$mocks['primary']->expects( $this->exactly( 3 ) )->method( 'autoCreatedAccount' )
			->with( $callback, $callback2 );

		$mocks['secondary']->expects( $this->exactly( 8 ) )->method( 'testUserForCreation' )
			->with( $callback, $callback2 )
			->willReturnOnConsecutiveCalls(
				StatusValue::newFatal( 'fail-in-secondary' ),
				$good, // backoff test
				$good, // addToDatabase fails test
				$good, // addToDatabase throws test
				$good, // addToDatabase exists test
				$good, $good, $good
			);
		$mocks['secondary']->expects( $this->exactly( 3 ) )->method( 'autoCreatedAccount' )
			->with( $callback, $callback2 );

		$this->preauthMocks = [ $mocks['pre'] ];
		$this->primaryauthMocks = [ $mocks['primary'] ];
		$this->secondaryauthMocks = [ $mocks['secondary'] ];
		$this->initializeManager( true );
		$session = $this->request->getSession();

		$logger = new TestLogger( true, static function ( $m ) {
			$m = str_replace( 'MediaWiki\\Auth\\AuthManager::autoCreateUser: ', '', $m );
			return $m;
		} );
		$this->logger = $logger;
		$this->manager->setLogger( $logger );

		try {
			$userMock = $this->createMock( User::class );
			$this->manager->autoCreateUser( $userMock, 'InvalidSource', true, true );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Unknown auto-creation source: InvalidSource', $ex->getMessage() );
		}

		// First, check an existing user
		$session->clear();
		$existingUser = $this->getTestSysop()->getUser();
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->autoCreateUser( $existingUser, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$this->unhook( 'LocalUserCreated' );
		$expect = Status::newGood();
		$expect->warning( 'userexists' );
		$this->assertEquals( $expect, $ret );
		$this->assertNotEquals( 0, $existingUser->getId() );
		$this->assertEquals( $existingUser->getId(), $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::DEBUG, '{username} already exists locally' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		$session->clear();
		$user = $this->getTestSysop()->getUser();
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false, true );
		$this->unhook( 'LocalUserCreated' );
		$expect = Status::newGood();
		$expect->warning( 'userexists' );
		$this->assertEquals( $expect, $ret );
		$this->assertNotEquals( 0, $user->getId() );
		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::DEBUG, '{username} already exists locally' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// Wiki is read-only
		$session->clear();
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$readOnlyMode = $this->getServiceContainer()->getReadOnlyMode();
		$readOnlyMode->setReason( 'Because' );
		$user = User::newFromName( $username );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$this->unhook( 'LocalUserCreated' );
		$this->assertEquals( Status::newFatal( wfMessage( 'readonlytext', 'Because' ) ), $ret );
		$this->assertSame( 0, $user->getId() );
		$this->assertNotEquals( $username, $user->getName() );
		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::DEBUG, 'denied because of read only mode: {reason}' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();
		$readOnlyMode->setReason( false );

		// Session blacklisted
		$session->clear();
		$session->set( AuthManager::AUTOCREATE_BLOCKLIST, 'test' );
		$user = User::newFromName( $username );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$this->unhook( 'LocalUserCreated' );
		$this->assertEquals( Status::newFatal( 'test' ), $ret );
		$this->assertSame( 0, $user->getId() );
		$this->assertNotEquals( $username, $user->getName() );
		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::DEBUG, 'blacklisted in session {sessionid}' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		$session->clear();
		$session->set( AuthManager::AUTOCREATE_BLOCKLIST, StatusValue::newFatal( 'test2' ) );
		$user = User::newFromName( $username );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$this->unhook( 'LocalUserCreated' );
		$this->assertEquals( Status::newFatal( 'test2' ), $ret );
		$this->assertSame( 0, $user->getId() );
		$this->assertNotEquals( $username, $user->getName() );
		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::DEBUG, 'blacklisted in session {sessionid}' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// Invalid name
		$session->clear();
		$user = User::newFromName( $username . "\u{0080}", false );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$this->unhook( 'LocalUserCreated' );
		$this->assertEquals( Status::newFatal( 'noname' ), $ret );
		$this->assertSame( 0, $user->getId() );
		$this->assertNotEquals( $username . "\u{0080}", $user->getId() );
		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::DEBUG, 'name "{username}" is not usable' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();
		$this->assertSame( 'noname', $session->get( AuthManager::AUTOCREATE_BLOCKLIST ) );

		// IP unable to create accounts
		$this->setGroupPermissions( '*', 'createaccount', false );
		$this->setGroupPermissions( '*', 'autocreateaccount', false );
		$this->initializeManager( true );
		$session = $this->request->getSession();
		$user = User::newFromName( $username );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$this->unhook( 'LocalUserCreated' );
		$this->assertTrue( $ret->hasMessage( 'badaccess-group0' ) );
		$this->assertSame( 0, $user->getId() );
		$this->assertNotEquals( $username, $user->getName() );
		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::DEBUG, 'cannot create or autocreate accounts' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();
		$this->assertEquals(
			(string)$ret, (string)$session->get( AuthManager::AUTOCREATE_BLOCKLIST )
		);

		// maintenance scripts always work
		$expectedSource = AuthManager::AUTOCREATE_SOURCE_MAINT;
		$this->setGroupPermissions( '*', 'createaccount', false );
		$this->setGroupPermissions( '*', 'autocreateaccount', false );
		$this->initializeManager( true );
		$session = $this->request->getSession();
		$user = User::newFromName( $username );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_MAINT, true, false );
		$this->unhook( 'LocalUserCreated' );
		$this->assertStatusError( 'ok', $ret );

		// Test that both permutations of permissions are allowed
		// (this hits the two "ok" entries in $mocks['pre'])
		$expectedSource = AuthManager::AUTOCREATE_SOURCE_SESSION;
		$this->setGroupPermissions( '*', 'createaccount', false );
		$this->setGroupPermissions( '*', 'autocreateaccount', true );
		$this->initializeManager( true );
		$session = $this->request->getSession();
		$user = User::newFromName( $username );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$this->unhook( 'LocalUserCreated' );
		$this->assertStatusError( 'ok', $ret );

		$this->setGroupPermissions( '*', 'createaccount', true );
		$this->setGroupPermissions( '*', 'autocreateaccount', false );
		$this->initializeManager( true );
		$session = $this->request->getSession();
		$user = User::newFromName( $username );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$this->unhook( 'LocalUserCreated' );
		$this->assertStatusError( 'ok', $ret );
		$logger->clearBuffer();

		// Test lock fail
		$session->clear();
		$user = User::newFromName( $username );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$cache = $this->objectCacheFactory->getLocalClusterInstance();
		$lock = $cache->getScopedLock( $cache->makeGlobalKey( 'account', md5( $username ) ) );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		unset( $lock );
		$this->unhook( 'LocalUserCreated' );
		$this->assertStatusError( 'usernameinprogress', $ret );
		$this->assertSame( 0, $user->getId() );
		$this->assertNotEquals( $username, $user->getName() );
		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::DEBUG, 'Could not acquire account creation lock' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// Test pre-authentication provider fail
		$session->clear();
		$user = User::newFromName( $username );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$this->unhook( 'LocalUserCreated' );
		$this->assertStatusError( 'fail-in-pre', $ret );
		$this->assertSame( 0, $user->getId() );
		$this->assertNotEquals( $username, $user->getName() );
		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();
		$this->assertEquals(
			StatusValue::newFatal( 'fail-in-pre' ), $session->get( AuthManager::AUTOCREATE_BLOCKLIST )
		);

		$session->clear();
		$user = User::newFromName( $username );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$this->unhook( 'LocalUserCreated' );
		$this->assertStatusError( 'fail-in-primary', $ret );
		$this->assertSame( 0, $user->getId() );
		$this->assertNotEquals( $username, $user->getName() );
		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();
		$this->assertEquals(
			StatusValue::newFatal( 'fail-in-primary' ), $session->get( AuthManager::AUTOCREATE_BLOCKLIST )
		);

		$session->clear();
		$user = User::newFromName( $username );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$this->unhook( 'LocalUserCreated' );
		$this->assertStatusError( 'fail-in-secondary', $ret );
		$this->assertSame( 0, $user->getId() );
		$this->assertNotEquals( $username, $user->getName() );
		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::DEBUG, 'Provider denied creation of {username}: {reason}' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();
		$this->assertEquals(
			StatusValue::newFatal( 'fail-in-secondary' ), $session->get( AuthManager::AUTOCREATE_BLOCKLIST )
		);

		// Test backoff
		$cache = $this->objectCacheFactory->getLocalClusterInstance();
		$backoffKey = $cache->makeKey( 'AuthManager', 'autocreate-failed', md5( $username ) );
		$cache->set( $backoffKey, true );
		$session->clear();
		$user = User::newFromName( $username );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$this->unhook( 'LocalUserCreated' );
		$this->assertStatusError( 'authmanager-autocreate-exception', $ret );
		$this->assertSame( 0, $user->getId() );
		$this->assertNotEquals( $username, $user->getName() );
		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::DEBUG, '{username} denied by prior creation attempt failures' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();
		$this->assertSame( null, $session->get( AuthManager::AUTOCREATE_BLOCKLIST ) );
		$cache->delete( $backoffKey );

		// Test addToDatabase fails
		$session->clear();
		$user = $this->getMockBuilder( User::class )
			->onlyMethods( [ 'addToDatabase' ] )->getMock();
		$user->expects( $this->once() )->method( 'addToDatabase' )
			->willReturn( Status::newFatal( 'because' ) );
		$user->setName( $username );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$this->assertStatusError( 'because', $ret );
		$this->assertSame( 0, $user->getId() );
		$this->assertNotEquals( $username, $user->getName() );
		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ],
			[ LogLevel::ERROR, '{username} failed with message {msg}' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();
		$this->assertSame( null, $session->get( AuthManager::AUTOCREATE_BLOCKLIST ) );

		// Test addToDatabase throws an exception
		$cache = $this->objectCacheFactory->getLocalClusterInstance();
		$backoffKey = $cache->makeKey( 'AuthManager', 'autocreate-failed', md5( $username ) );
		$this->assertFalse( $cache->get( $backoffKey ) );
		$session->clear();
		$user = $this->getMockBuilder( User::class )
			->onlyMethods( [ 'addToDatabase' ] )->getMock();
		$user->expects( $this->once() )->method( 'addToDatabase' )
			->willThrowException( new Exception( 'Excepted' ) );
		$user->setName( $username );
		try {
			$this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
			$this->fail( 'Expected exception not thrown' );
		} catch ( Exception $ex ) {
			$this->assertSame( 'Excepted', $ex->getMessage() );
		}
		$this->assertSame( 0, $user->getId() );
		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ],
			[ LogLevel::ERROR, '{username} failed with exception {exception}' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();
		$this->assertSame( null, $session->get( AuthManager::AUTOCREATE_BLOCKLIST ) );
		$this->assertNotFalse( $cache->get( $backoffKey ) );
		$cache->delete( $backoffKey );

		// Test addToDatabase fails because the user already exists.
		$session->clear();
		$user = $this->getMockBuilder( User::class )
			->onlyMethods( [ 'addToDatabase' ] )->getMock();
		$user->expects( $this->once() )->method( 'addToDatabase' )
			->willReturnCallback( function () use ( $username, &$user ) {
				$oldUser = User::newFromName( $username );
				$status = $oldUser->addToDatabase();
				$this->assertStatusOK( $status );
				$user->setId( $oldUser->getId() );
				return Status::newFatal( 'userexists' );
			} );
		$user->setName( $username );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$expect = Status::newGood();
		$expect->warning( 'userexists' );
		$this->assertEquals( $expect, $ret );
		$this->assertNotEquals( 0, $user->getId() );
		$this->assertEquals( $username, $user->getName() );
		$this->assertEquals( $user->getId(), $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ],
			[ LogLevel::INFO, '{username} already exists locally (race)' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();
		$this->assertSame( null, $session->get( AuthManager::AUTOCREATE_BLOCKLIST ) );

		// Success!
		$session->clear();
		$username = self::usernameForCreation();
		$user = User::newFromName( $username );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->once() )
			->with( $callback, true );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, true, true );
		$this->unhook( 'LocalUserCreated' );
		$this->assertEquals( Status::newGood(), $ret );
		$this->assertNotEquals( 0, $user->getId() );
		$this->assertEquals( $username, $user->getName() );
		$this->assertEquals( $user->getId(), $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		$dbw = $this->getDb();
		$maxLogId = $dbw->newSelectQueryBuilder()
			->select( 'MAX(log_id)' )
			->from( 'logging' )
			->where( [ 'log_type' => 'newusers' ] )
			->fetchField();
		$session->clear();
		$username = self::usernameForCreation();
		$user = User::newFromName( $username );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->once() )
			->with( $callback, true );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false, true );
		$this->unhook( 'LocalUserCreated' );
		$this->assertEquals( Status::newGood(), $ret );
		$this->assertNotEquals( 0, $user->getId() );
		$this->assertEquals( $username, $user->getName() );
		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( [
			[ LogLevel::INFO, 'creating new user ({username}) - from: {from}' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();
		$this->assertSame(
			$maxLogId,
			$dbw->newSelectQueryBuilder()
				->select( 'MAX(log_id)' )
				->from( 'logging' )
				->where( [ 'log_type' => 'newusers' ] )
				->fetchField() );

		$this->config->set( MainConfigNames::NewUserLog, true );
		$session->clear();
		$username = self::usernameForCreation();
		$user = User::newFromName( $username );
		$ret = $this->manager->autoCreateUser( $user, AuthManager::AUTOCREATE_SOURCE_SESSION, false, true );
		$this->assertEquals( Status::newGood(), $ret );
		$logger->clearBuffer();

		$queryBuilder = DatabaseLogEntry::newSelectQueryBuilder( $dbw )
			->where( [ 'log_id > ' . (int)$maxLogId, 'log_type' => 'newusers' ] );
		$rows = iterator_to_array( $queryBuilder->caller( __METHOD__ )->fetchResultSet() );
		$this->assertCount( 1, $rows );
		$entry = DatabaseLogEntry::newFromRow( reset( $rows ) );

		$this->assertSame( 'autocreate', $entry->getSubtype() );
		$this->assertSame( $user->getId(), $entry->getPerformerIdentity()->getId() );
		$this->assertSame( $user->getName(), $entry->getPerformerIdentity()->getName() );
		$this->assertSame( $user->getUserPage()->getFullText(), $entry->getTarget()->getFullText() );
		$this->assertSame( [ '4::userid' => $user->getId() ], $entry->getParameters() );

		$workaroundPHPUnitBug = true;
	}

	/**
	 * @dataProvider provideAutoCreateUserBlocks
	 */
	public function testAutoCreateUserBlocks(
		string $blockType,
		array $blockOptions,
		string $performerType,
		bool $expectedStatus

	) {
		if ( $blockType === 'ip' ) {
			$blockOptions['address'] = '127.0.0.0/24';
		} elseif ( $blockType === 'global-ip' ) {
			$this->setTemporaryHook( 'GetUserBlock',
				static function ( $user, $ip, &$block ) use ( $blockOptions ) {
					$block = new SystemBlock( $blockOptions );
					$block->isCreateAccountBlocked( true );
				}
			);
			$blockOptions = null;
		} elseif ( $blockType === 'none' ) {
			$blockOptions = null;
		} else {
			$this->fail( "Unknown block type \"$blockType\"" );
		}

		if ( $blockOptions !== null ) {
			$blockOptions += [
				'by' => $this->getTestSysop()->getUser(),
				'reason' => __METHOD__,
				'expiry' => time() + 100500,
			];
			$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
			$block = new DatabaseBlock( $blockOptions );
			$blockStore->insertBlock( $block );
		}

		if ( $performerType === 'sysop' ) {
			$performer = $this->getTestSysop()->getUser();
		} elseif ( $performerType === 'anon' ) {
			$performer = null;
		} else {
			$this->fail( "Unknown performer type \"$performerType\"" );
		}

		$this->logger = LoggerFactory::getInstance( 'AuthManagerTest' );
		$this->initializeManager( true );

		$user = $this->userFactory->newFromName( 'NewUser' );
		$status = $this->manager->autoCreateUser( $user,
			AuthManager::AUTOCREATE_SOURCE_SESSION, true, true, $performer );
		$this->assertSame( $expectedStatus, $status->isGood() );
	}

	public static function provideAutoCreateUserBlocks() {
		return [
			// block type (ip/global/none), block options, performer, expected status
			'not blocked' => [ 'none', [], 'anon', true ],
			'ip-blocked' => [ 'ip', [], 'anon', true ],
			'ip-blocked with createAccount' => [
				'ip',
				[ 'createAccount' => true ],
				'anon',
				false
			],
			'partially ip-blocked' => [
				'ip',
				[ 'restrictions' => [ new PageRestriction( 0, 1 ) ] ],
				'anon',
				true
			],
			'ip-blocked with sysop performer' => [
				'ip',
				[ 'createAccount' => true ],
				'sysop',
				true
			],
			'globally blocked' => [
				'global-ip',
				[ 'systemBlock' => 'test-systemBlock' ],
				'anon',
				false
			],
		];
	}

	/**
	 * @dataProvider provideGetAuthenticationRequests
	 * @param string $action
	 * @param array $expect
	 * @param array $state
	 */
	public function testGetAuthenticationRequests( $action, $expect, $state = [] ) {
		$makeReq = function ( $key ) use ( $action ) {
			$req = $this->createMock( AuthenticationRequest::class );
			$req->method( 'getUniqueId' )
				->willReturn( $key );
			$req->action = $action === AuthManager::ACTION_UNLINK ? AuthManager::ACTION_REMOVE : $action;
			return $req;
		};
		$cmpReqs = static function ( $a, $b ) {
			$ret = strcmp( get_class( $a ), get_class( $b ) );
			if ( !$ret ) {
				$ret = strcmp( $a->getUniqueId(), $b->getUniqueId() );
			}
			return $ret;
		};

		$good = StatusValue::newGood();

		$mocks = [];
		$mocks['pre'] = $this->createMock( AbstractPreAuthenticationProvider::class );
		$mocks['pre']->method( 'getUniqueId' )
			->willReturn( 'pre' );
		$mocks['pre']->method( 'getAuthenticationRequests' )
			->willReturnCallback( static function ( $action ) use ( $makeReq ) {
				return [ $makeReq( "pre-$action" ), $makeReq( 'generic' ) ];
			} );
		foreach ( [ 'primary', 'secondary' ] as $key ) {
			$class = ucfirst( $key ) . 'AuthenticationProvider';
			$mocks[$key] = $this->createMock( "MediaWiki\\Auth\\Abstract$class" );
			$mocks[$key]->method( 'getUniqueId' )
				->willReturn( $key );
			$mocks[$key]->method( 'getAuthenticationRequests' )
				->willReturnCallback( static function ( $action ) use ( $key, $makeReq ) {
					return [ $makeReq( "$key-$action" ), $makeReq( 'generic' ) ];
				} );
			$mocks[$key]->method( 'providerAllowsAuthenticationDataChange' )
				->willReturn( $good );
		}

		foreach ( [
			PrimaryAuthenticationProvider::TYPE_NONE,
			PrimaryAuthenticationProvider::TYPE_CREATE,
			PrimaryAuthenticationProvider::TYPE_LINK
		] as $type ) {
			$class = 'PrimaryAuthenticationProvider';
			$mocks["primary-$type"] = $this->createMock( "MediaWiki\\Auth\\Abstract$class" );
			$mocks["primary-$type"]->method( 'getUniqueId' )
				->willReturn( "primary-$type" );
			$mocks["primary-$type"]->method( 'accountCreationType' )
				->willReturn( $type );
			$mocks["primary-$type"]->method( 'getAuthenticationRequests' )
				->willReturnCallback( static function ( $action ) use ( $type, $makeReq ) {
					return [ $makeReq( "primary-$type-$action" ), $makeReq( 'generic' ) ];
				} );
			$mocks["primary-$type"]->method( 'providerAllowsAuthenticationDataChange' )
				->willReturn( $good );
			$this->primaryauthMocks[] = $mocks["primary-$type"];
		}

		$mocks['primary2'] = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mocks['primary2']->method( 'getUniqueId' )
			->willReturn( 'primary2' );
		$mocks['primary2']->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_LINK );
		$mocks['primary2']->method( 'getAuthenticationRequests' )
			->willReturn( [] );
		$mocks['primary2']->method( 'providerAllowsAuthenticationDataChange' )
			->willReturnCallback( static function ( $req ) use ( $good ) {
				return $req->getUniqueId() === 'generic' ? StatusValue::newFatal( 'no' ) : $good;
			} );
		$this->primaryauthMocks[] = $mocks['primary2'];

		$this->preauthMocks = [ $mocks['pre'] ];
		$this->secondaryauthMocks = [ $mocks['secondary'] ];
		$this->initializeManager( true );

		if ( $state ) {
			if ( isset( $state['continueRequests'] ) ) {
				$state['continueRequests'] = array_map( $makeReq, $state['continueRequests'] );
			}
			if ( $action === AuthManager::ACTION_LOGIN_CONTINUE ) {
				$this->request->getSession()->setSecret( AuthManager::AUTHN_STATE, $state );
			} elseif ( $action === AuthManager::ACTION_CREATE_CONTINUE ) {
				$this->request->getSession()->setSecret( AuthManager::ACCOUNT_CREATION_STATE, $state );
			} elseif ( $action === AuthManager::ACTION_LINK_CONTINUE ) {
				$this->request->getSession()->setSecret( AuthManager::ACCOUNT_LINK_STATE, $state );
			}
		}

		$expectReqs = array_map( $makeReq, $expect );
		if ( $action === AuthManager::ACTION_LOGIN ) {
			$req = new RememberMeAuthenticationRequest;
			$req->action = $action;
			$req->required = AuthenticationRequest::REQUIRED;
			$expectReqs[] = $req;
		} elseif ( $action === AuthManager::ACTION_CREATE ) {
			$req = new UsernameAuthenticationRequest;
			$req->action = $action;
			$expectReqs[] = $req;
			$req = new UserDataAuthenticationRequest;
			$req->action = $action;
			$req->required = AuthenticationRequest::REQUIRED;
			$expectReqs[] = $req;
		}
		usort( $expectReqs, $cmpReqs );

		$actual = $this->manager->getAuthenticationRequests( $action );
		foreach ( $actual as $req ) {
			// Don't test this here.
			$req->required = AuthenticationRequest::REQUIRED;
		}
		usort( $actual, $cmpReqs );

		$this->assertEquals( $expectReqs, $actual );

		// Test CreationReasonAuthenticationRequest gets returned
		if ( $action === AuthManager::ACTION_CREATE ) {
			$req = new CreationReasonAuthenticationRequest;
			$req->action = $action;
			$req->required = AuthenticationRequest::REQUIRED;
			$expectReqs[] = $req;
			usort( $expectReqs, $cmpReqs );

			$user = $this->getTestSysop()->getUser();
			$actual = $this->manager->getAuthenticationRequests( $action, $user );
			foreach ( $actual as $req ) {
				// Don't test this here.
				$req->required = AuthenticationRequest::REQUIRED;
			}
			usort( $actual, $cmpReqs );

			$this->assertEquals( $expectReqs, $actual );
		}
	}

	public static function provideGetAuthenticationRequests() {
		return [
			[
				AuthManager::ACTION_LOGIN,
				[ 'pre-login', 'primary-none-login', 'primary-create-login',
					'primary-link-login', 'secondary-login', 'generic' ],
			],
			[
				AuthManager::ACTION_CREATE,
				[ 'pre-create', 'primary-none-create', 'primary-create-create',
					'primary-link-create', 'secondary-create', 'generic' ],
			],
			[
				AuthManager::ACTION_LINK,
				[ 'primary-link-link', 'generic' ],
			],
			[
				AuthManager::ACTION_CHANGE,
				[ 'primary-none-change', 'primary-create-change', 'primary-link-change',
					'secondary-change' ],
			],
			[
				AuthManager::ACTION_REMOVE,
				[ 'primary-none-remove', 'primary-create-remove', 'primary-link-remove',
					'secondary-remove' ],
			],
			[
				AuthManager::ACTION_UNLINK,
				[ 'primary-link-remove' ],
			],
			[
				AuthManager::ACTION_LOGIN_CONTINUE,
				[],
			],
			[
				AuthManager::ACTION_LOGIN_CONTINUE,
				$reqs = [ 'continue-login', 'foo', 'bar' ],
				[
					'continueRequests' => $reqs,
				],
			],
			[
				AuthManager::ACTION_CREATE_CONTINUE,
				[],
			],
			[
				AuthManager::ACTION_CREATE_CONTINUE,
				$reqs = [ 'continue-create', 'foo', 'bar' ],
				[
					'continueRequests' => $reqs,
				],
			],
			[
				AuthManager::ACTION_LINK_CONTINUE,
				[],
			],
			[
				AuthManager::ACTION_LINK_CONTINUE,
				$reqs = [ 'continue-link', 'foo', 'bar' ],
				[
					'continueRequests' => $reqs,
				],
			],
		];
	}

	public function testGetAuthenticationRequestsRequired() {
		$makeReq = function ( $key, $required ) {
			$req = $this->createMock( AuthenticationRequest::class );
			$req->method( 'getUniqueId' )
				->willReturn( $key );
			$req->action = AuthManager::ACTION_LOGIN;
			$req->required = $required;
			return $req;
		};
		$cmpReqs = static function ( $a, $b ) {
			$ret = strcmp( get_class( $a ), get_class( $b ) );
			if ( !$ret ) {
				$ret = strcmp( $a->getUniqueId(), $b->getUniqueId() );
			}
			return $ret;
		};

		$primary1 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$primary1->method( 'getUniqueId' )
			->willReturn( 'primary1' );
		$primary1->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$primary1->method( 'getAuthenticationRequests' )
			->willReturnCallback( static function ( $action ) use ( $makeReq ) {
				return [
					$makeReq( "primary-shared", AuthenticationRequest::REQUIRED ),
					$makeReq( "required", AuthenticationRequest::REQUIRED ),
					$makeReq( "optional", AuthenticationRequest::OPTIONAL ),
					$makeReq( "foo", AuthenticationRequest::REQUIRED ),
					$makeReq( "bar", AuthenticationRequest::REQUIRED ),
					$makeReq( "baz", AuthenticationRequest::OPTIONAL ),
				];
			} );

		$primary2 = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$primary2->method( 'getUniqueId' )
			->willReturn( 'primary2' );
		$primary2->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$primary2->method( 'getAuthenticationRequests' )
			->willReturnCallback( static function ( $action ) use ( $makeReq ) {
				return [
					$makeReq( "primary-shared", AuthenticationRequest::REQUIRED ),
					$makeReq( "required2", AuthenticationRequest::REQUIRED ),
					$makeReq( "optional2", AuthenticationRequest::OPTIONAL ),
				];
			} );

		$secondary = $this->createMock( AbstractSecondaryAuthenticationProvider::class );
		$secondary->method( 'getUniqueId' )
			->willReturn( 'secondary' );
		$secondary->method( 'getAuthenticationRequests' )
			->willReturnCallback( static function ( $action ) use ( $makeReq ) {
				return [
					$makeReq( "foo", AuthenticationRequest::OPTIONAL ),
					$makeReq( "bar", AuthenticationRequest::REQUIRED ),
					$makeReq( "baz", AuthenticationRequest::REQUIRED ),
				];
			} );

		$rememberReq = new RememberMeAuthenticationRequest;
		$rememberReq->action = AuthManager::ACTION_LOGIN;

		$this->primaryauthMocks = [ $primary1, $primary2 ];
		$this->secondaryauthMocks = [ $secondary ];
		$this->initializeManager( true );

		$actual = $this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN );
		$expected = [
			$rememberReq,
			$makeReq( "primary-shared", AuthenticationRequest::PRIMARY_REQUIRED ),
			$makeReq( "required", AuthenticationRequest::PRIMARY_REQUIRED ),
			$makeReq( "required2", AuthenticationRequest::PRIMARY_REQUIRED ),
			$makeReq( "optional", AuthenticationRequest::OPTIONAL ),
			$makeReq( "optional2", AuthenticationRequest::OPTIONAL ),
			$makeReq( "foo", AuthenticationRequest::PRIMARY_REQUIRED ),
			$makeReq( "bar", AuthenticationRequest::REQUIRED ),
			$makeReq( "baz", AuthenticationRequest::REQUIRED ),
		];
		usort( $actual, $cmpReqs );
		usort( $expected, $cmpReqs );
		$this->assertEquals( $expected, $actual );

		$this->primaryauthMocks = [ $primary1 ];
		$this->secondaryauthMocks = [ $secondary ];
		$this->initializeManager( true );

		$actual = $this->manager->getAuthenticationRequests( AuthManager::ACTION_LOGIN );
		$expected = [
			$rememberReq,
			$makeReq( "primary-shared", AuthenticationRequest::PRIMARY_REQUIRED ),
			$makeReq( "required", AuthenticationRequest::PRIMARY_REQUIRED ),
			$makeReq( "optional", AuthenticationRequest::OPTIONAL ),
			$makeReq( "foo", AuthenticationRequest::PRIMARY_REQUIRED ),
			$makeReq( "bar", AuthenticationRequest::REQUIRED ),
			$makeReq( "baz", AuthenticationRequest::REQUIRED ),
		];
		usort( $actual, $cmpReqs );
		usort( $expected, $cmpReqs );
		$this->assertEquals( $expected, $actual );
	}

	public function testAllowsPropertyChange() {
		$mocks = [];
		foreach ( [ 'primary', 'secondary' ] as $key ) {
			$class = ucfirst( $key ) . 'AuthenticationProvider';
			$mocks[$key] = $this->createMock( "MediaWiki\\Auth\\Abstract$class" );
			$mocks[$key]->method( 'getUniqueId' )
				->willReturn( $key );
			$mocks[$key]->method( 'providerAllowsPropertyChange' )
				->willReturnCallback( static function ( $prop ) use ( $key ) {
					return $prop !== $key;
				} );
		}

		$this->primaryauthMocks = [ $mocks['primary'] ];
		$this->secondaryauthMocks = [ $mocks['secondary'] ];
		$this->initializeManager( true );

		$this->assertTrue( $this->manager->allowsPropertyChange( 'foo' ) );
		$this->assertFalse( $this->manager->allowsPropertyChange( 'primary' ) );
		$this->assertFalse( $this->manager->allowsPropertyChange( 'secondary' ) );
	}

	public function testAutoCreateOnLogin() {
		$username = self::usernameForCreation();

		$req = $this->createMock( AuthenticationRequest::class );

		$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock->method( 'getUniqueId' )->willReturn( 'primary' );
		$mock->method( 'beginPrimaryAuthentication' )
			->willReturn( AuthenticationResponse::newPass( $username ) );
		$mock->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$mock->method( 'testUserExists' )->willReturn( true );
		$mock->method( 'testUserForCreation' )
			->willReturn( StatusValue::newGood() );

		$mock2 = $this->createMock( AbstractSecondaryAuthenticationProvider::class );
		$mock2->method( 'getUniqueId' )
			->willReturn( 'secondary' );
		$mock2->method( 'beginSecondaryAuthentication' )
			->willReturn( AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ) );
		$mock2->method( 'continueSecondaryAuthentication' )
			->willReturn( AuthenticationResponse::newAbstain() );
		$mock2->method( 'testUserForCreation' )
			->willReturn( StatusValue::newGood() );

		$this->primaryauthMocks = [ $mock ];
		$this->secondaryauthMocks = [ $mock2 ];
		$this->initializeManager( true );
		$this->manager->setLogger( new NullLogger() );
		$session = $this->request->getSession();
		$session->clear();

		$this->assertSame( 0, User::newFromName( $username )->getId() );

		$callback = $this->callback( static function ( $user ) use ( $username ) {
			return $user->getName() === $username;
		} );

		$this->hook( 'UserLoggedIn', UserLoggedInHook::class, $this->never() );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->once() )
			->with( $callback, true );
		$ret = $this->manager->beginAuthentication( [], 'http://localhost/' );
		$this->unhook( 'LocalUserCreated' );
		$this->unhook( 'UserLoggedIn' );
		$this->assertSame( AuthenticationResponse::UI, $ret->status );

		$id = (int)User::newFromName( $username )->getId();
		$this->assertNotSame( 0, User::newFromName( $username )->getId() );
		$this->assertSame( 0, $session->getUser()->getId() );

		$this->hook( 'UserLoggedIn', UserLoggedInHook::class, $this->once() )
			->with( $callback );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->continueAuthentication( [] );
		$this->unhook( 'LocalUserCreated' );
		$this->unhook( 'UserLoggedIn' );
		$this->assertSame( AuthenticationResponse::PASS, $ret->status );
		$this->assertSame( $username, $ret->username );
		$this->assertSame( $id, $session->getUser()->getId() );
	}

	public function testAutoCreateFailOnLogin() {
		$username = self::usernameForCreation();

		$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock->method( 'getUniqueId' )->willReturn( 'primary' );
		$mock->method( 'beginPrimaryAuthentication' )
			->willReturn( AuthenticationResponse::newPass( $username ) );
		$mock->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$mock->method( 'testUserExists' )->willReturn( true );
		$mock->method( 'testUserForCreation' )
			->willReturn( StatusValue::newFatal( 'fail-from-primary' ) );

		$this->primaryauthMocks = [ $mock ];
		$this->initializeManager( true );
		$this->manager->setLogger( new NullLogger() );
		$session = $this->request->getSession();
		$session->clear();

		$this->assertSame( 0, $session->getUser()->getId() );
		$this->assertSame( 0, User::newFromName( $username )->getId() );

		$this->hook( 'UserLoggedIn', UserLoggedInHook::class, $this->never() );
		$this->hook( 'LocalUserCreated', LocalUserCreatedHook::class, $this->never() );
		$ret = $this->manager->beginAuthentication( [], 'http://localhost/' );
		$this->unhook( 'LocalUserCreated' );
		$this->unhook( 'UserLoggedIn' );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'authmanager-authn-autocreate-failed', $ret->message->getKey() );

		$this->assertSame( 0, User::newFromName( $username )->getId() );
		$this->assertSame( 0, $session->getUser()->getId() );
	}

	public function testAuthenticationSessionData() {
		$this->initializeManager( true );

		$this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) );
		$this->manager->setAuthenticationSessionData( 'foo', 'foo!' );
		$this->manager->setAuthenticationSessionData( 'bar', 'bar!' );
		$this->assertSame( 'foo!', $this->manager->getAuthenticationSessionData( 'foo' ) );
		$this->assertSame( 'bar!', $this->manager->getAuthenticationSessionData( 'bar' ) );
		$this->manager->removeAuthenticationSessionData( 'foo' );
		$this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) );
		$this->assertSame( 'bar!', $this->manager->getAuthenticationSessionData( 'bar' ) );
		$this->manager->removeAuthenticationSessionData( 'bar' );
		$this->assertNull( $this->manager->getAuthenticationSessionData( 'bar' ) );

		$this->manager->setAuthenticationSessionData( 'foo', 'foo!' );
		$this->manager->setAuthenticationSessionData( 'bar', 'bar!' );
		$this->manager->removeAuthenticationSessionData( null );
		$this->assertNull( $this->manager->getAuthenticationSessionData( 'foo' ) );
		$this->assertNull( $this->manager->getAuthenticationSessionData( 'bar' ) );
	}

	public function testCanLinkAccounts() {
		$types = [
			PrimaryAuthenticationProvider::TYPE_CREATE => false,
			PrimaryAuthenticationProvider::TYPE_LINK => true,
			PrimaryAuthenticationProvider::TYPE_NONE => false,
		];

		foreach ( $types as $type => $can ) {
			$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
			$mock->method( 'getUniqueId' )->willReturn( $type );
			$mock->method( 'accountCreationType' )
				->willReturn( $type );
			$this->primaryauthMocks = [ $mock ];
			$this->initializeManager( true );
			$this->assertSame( $can, $this->manager->canLinkAccounts(), $type );
		}
	}

	public function testBeginAccountLink() {
		$user = $this->getTestSysop()->getUser();
		$this->initializeManager();

		$this->request->getSession()->setSecret( AuthManager::ACCOUNT_LINK_STATE, 'test' );
		try {
			$this->manager->beginAccountLink( $user, [], 'http://localhost/' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( LogicException $ex ) {
			$this->assertEquals( 'Account linking is not possible', $ex->getMessage() );
		}
		$this->assertNull( $this->request->getSession()->getSecret( AuthManager::ACCOUNT_LINK_STATE ) );

		$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock->method( 'getUniqueId' )->willReturn( 'X' );
		$mock->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_LINK );
		$this->primaryauthMocks = [ $mock ];
		$this->initializeManager( true );

		$ret = $this->manager->beginAccountLink( new User, [], 'http://localhost/' );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'noname', $ret->message->getKey() );

		$ret = $this->manager->beginAccountLink(
			User::newFromName( 'UTDoesNotExist' ), [], 'http://localhost/'
		);
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'authmanager-userdoesnotexist', $ret->message->getKey() );
	}

	public function testContinueAccountLink() {
		$user = $this->getTestSysop()->getUser();
		$this->initializeManager();

		$session = [
			'userid' => $user->getId(),
			'username' => $user->getName(),
			'primary' => 'X',
		];

		try {
			$this->manager->continueAccountLink( [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( LogicException $ex ) {
			$this->assertEquals( 'Account linking is not possible', $ex->getMessage() );
		}

		$mock = $this->createMock( AbstractPrimaryAuthenticationProvider::class );
		$mock->method( 'getUniqueId' )->willReturn( 'X' );
		$mock->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_LINK );
		$mock->method( 'beginPrimaryAccountLink' )
			->willReturn( AuthenticationResponse::newFail( $this->message( 'fail' ) ) );
		$this->primaryauthMocks = [ $mock ];
		$this->initializeManager( true );

		$this->request->getSession()->setSecret( AuthManager::ACCOUNT_LINK_STATE, null );
		$ret = $this->manager->continueAccountLink( [] );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'authmanager-link-not-in-progress', $ret->message->getKey() );

		$this->request->getSession()->setSecret( AuthManager::ACCOUNT_LINK_STATE,
			[ 'username' => $user->getName() . '<>' ] + $session );
		$ret = $this->manager->continueAccountLink( [] );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'noname', $ret->message->getKey() );
		$this->assertNull( $this->request->getSession()->getSecret( AuthManager::ACCOUNT_LINK_STATE ) );

		$id = $user->getId();
		$this->request->getSession()->setSecret( AuthManager::ACCOUNT_LINK_STATE,
			[ 'userid' => $id + 1 ] + $session );
		try {
			$ret = $this->manager->continueAccountLink( [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertEquals(
				"User \"{$user->getName()}\" is valid, but ID $id !== " . ( $id + 1 ) . '!',
				$ex->getMessage()
			);
		}
		$this->assertNull( $this->request->getSession()->getSecret( AuthManager::ACCOUNT_LINK_STATE ) );
	}

	/**
	 * @dataProvider provideAccountLink
	 */
	public function testAccountLink(
		StatusValue $preTest, array $primaryResponses, array $managerResponses
	) {
		$user = $this->getTestSysop()->getUser();

		$this->initializeManager();

		// Set up lots of mocks...
		$req = $this->getMockForAbstractClass( AuthenticationRequest::class );
		$mocks = [];

		foreach ( [ 'pre', 'primary' ] as $key ) {
			$class = ucfirst( $key ) . 'AuthenticationProvider';
			$mocks[$key] = $this->getMockBuilder( "MediaWiki\\Auth\\Abstract$class" )
				->setMockClassName( "MockAbstract$class" )
				->getMock();
			$mocks[$key]->method( 'getUniqueId' )
				->willReturn( $key );

			for ( $i = 2; $i <= 3; $i++ ) {
				$mocks[$key . $i] = $this->getMockBuilder( "MediaWiki\\Auth\\Abstract$class" )
					->setMockClassName( "MockAbstract$class" )
					->getMock();
				$mocks[$key . $i]->method( 'getUniqueId' )
					->willReturn( $key . $i );
			}
		}

		$mocks['pre']->method( 'testForAccountLink' )
			->willReturnCallback(
				function ( $u )
					use ( $user, $preTest )
				{
					$this->assertSame( $user->getId(), $u->getId() );
					$this->assertSame( $user->getName(), $u->getName() );
					return $preTest;
				}
			);

		$mocks['pre2']->expects( $this->atMost( 1 ) )->method( 'testForAccountLink' )
			->willReturn( StatusValue::newGood() );

		$mocks['primary']->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_LINK );
		$ct = count( $primaryResponses );
		$callback = $this->returnCallback( function ( $u, $reqs ) use ( $user, $req, &$primaryResponses ) {
			$this->assertSame( $user->getId(), $u->getId() );
			$this->assertSame( $user->getName(), $u->getName() );
			$foundReq = false;
			foreach ( $reqs as $r ) {
				$this->assertSame( $user->getName(), $r->username );
				$foundReq = $foundReq || get_class( $r ) === get_class( $req );
			}
			$this->assertTrue( $foundReq, '$reqs contains $req' );
			return array_shift( $primaryResponses );
		} );
		$mocks['primary']->expects( $this->exactly( min( 1, $ct ) ) )
			->method( 'beginPrimaryAccountLink' )
			->will( $callback );
		$mocks['primary']->expects( $this->exactly( max( 0, $ct - 1 ) ) )
			->method( 'continuePrimaryAccountLink' )
			->will( $callback );

		$abstain = AuthenticationResponse::newAbstain();
		$mocks['primary2']->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_LINK );
		$mocks['primary2']->expects( $this->atMost( 1 ) )->method( 'beginPrimaryAccountLink' )
			->willReturn( $abstain );
		$mocks['primary2']->expects( $this->never() )->method( 'continuePrimaryAccountLink' );
		$mocks['primary3']->method( 'accountCreationType' )
			->willReturn( PrimaryAuthenticationProvider::TYPE_CREATE );
		$mocks['primary3']->expects( $this->never() )->method( 'beginPrimaryAccountLink' );
		$mocks['primary3']->expects( $this->never() )->method( 'continuePrimaryAccountLink' );

		$this->preauthMocks = [ $mocks['pre'], $mocks['pre2'] ];
		$this->primaryauthMocks = [ $mocks['primary3'], $mocks['primary2'], $mocks['primary'] ];
		$this->logger = new TestLogger( true, static function ( $message, $level ) {
			return $level === LogLevel::DEBUG ? null : $message;
		} );
		$this->initializeManager( true );

		$constraint = Assert::logicalOr(
			$this->equalTo( AuthenticationResponse::PASS ),
			$this->equalTo( AuthenticationResponse::FAIL )
		);
		$providers = array_merge( $this->preauthMocks, $this->primaryauthMocks );
		foreach ( $providers as $p ) {
			DynamicPropertyTestHelper::setDynamicProperty( $p, 'postCalled', false );
			$p->expects( $this->atMost( 1 ) )->method( 'postAccountLink' )
				->willReturnCallback( function ( $userArg, $response ) use ( $user, $constraint, $p ) {
					$this->assertInstanceOf( User::class, $userArg );
					$this->assertSame( $user->getName(), $userArg->getName() );
					$this->assertInstanceOf( AuthenticationResponse::class, $response );
					$this->assertThat( $response->status, $constraint );
					DynamicPropertyTestHelper::setDynamicProperty( $p, 'postCalled', $response->status );
				} );
		}

		$first = true;
		$expectLog = [];
		foreach ( $managerResponses as $i => $response ) {
			if ( $response instanceof AuthenticationResponse &&
				$response->status === AuthenticationResponse::PASS
			) {
				$expectLog[] = [ LogLevel::INFO, 'Account linked to {user} by primary' ];
			}

			try {
				if ( $first ) {
					$ret = $this->manager->beginAccountLink( $user, [ $req ], 'http://localhost/' );
				} else {
					$ret = $this->manager->continueAccountLink( [ $req ] );
				}
				if ( $response instanceof Exception ) {
					$this->fail( 'Expected exception not thrown', "Response $i" );
				}
			} catch ( Exception $ex ) {
				if ( !$response instanceof Exception ) {
					throw $ex;
				}
				$this->assertEquals( $response->getMessage(), $ex->getMessage(), "Response $i, exception" );
				$this->assertNull( $this->request->getSession()->getSecret( AuthManager::ACCOUNT_LINK_STATE ),
					"Response $i, exception, session state" );
				return;
			}

			$this->assertSame( 'http://localhost/', $req->returnToUrl );

			$ret->message = $this->message( $ret->message );
			$this->assertResponseEquals( $response, $ret, "Response $i, response" );
			if ( $response->status === AuthenticationResponse::PASS ||
				$response->status === AuthenticationResponse::FAIL
			) {
				$this->assertNull( $this->request->getSession()->getSecret( AuthManager::ACCOUNT_LINK_STATE ),
					"Response $i, session state" );
				foreach ( $providers as $p ) {
					$this->assertSame( $response->status, DynamicPropertyTestHelper::getDynamicProperty( $p, 'postCalled' ),
						"Response $i, post-auth callback called" );
				}
			} else {
				$this->assertNotNull(
					$this->request->getSession()->getSecret( AuthManager::ACCOUNT_LINK_STATE ),
					"Response $i, session state"
				);
				foreach ( $ret->neededRequests as $neededReq ) {
					$this->assertEquals( AuthManager::ACTION_LINK, $neededReq->action,
						"Response $i, neededRequest action" );
				}
				$this->assertEquals(
					$ret->neededRequests,
					$this->manager->getAuthenticationRequests( AuthManager::ACTION_LINK_CONTINUE ),
					"Response $i, continuation check"
				);
				foreach ( $providers as $p ) {
					$this->assertFalse( DynamicPropertyTestHelper::getDynamicProperty( $p, 'postCalled' ), "Response $i, post-auth callback not called" );
				}
			}

			$first = false;
		}

		$this->assertSame( $expectLog, $this->logger->getBuffer() );
	}

	public function provideAccountLink() {
		$req = $this->getMockForAbstractClass( AuthenticationRequest::class );
		$good = StatusValue::newGood();

		return [
			'Pre-link test fail in pre' => [
				StatusValue::newFatal( 'fail-from-pre' ),
				[],
				[
					AuthenticationResponse::newFail( $this->message( 'fail-from-pre' ) ),
				]
			],
			'Failure in primary' => [
				$good,
				$tmp = [
					AuthenticationResponse::newFail( $this->message( 'fail-from-primary' ) ),
				],
				$tmp
			],
			'All primary abstain' => [
				$good,
				[
					AuthenticationResponse::newAbstain(),
				],
				[
					AuthenticationResponse::newFail( $this->message( 'authmanager-link-no-primary' ) )
				]
			],
			'Primary UI, then redirect, then fail' => [
				$good,
				$tmp = [
					AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
					AuthenticationResponse::newRedirect( [ $req ], '/foo.html', [ 'foo' => 'bar' ] ),
					AuthenticationResponse::newFail( $this->message( 'fail-in-primary-continue' ) ),
				],
				$tmp
			],
			'Primary redirect, then abstain' => [
				$good,
				[
					$tmp = AuthenticationResponse::newRedirect(
						[ $req ], '/foo.html', [ 'foo' => 'bar' ]
					),
					AuthenticationResponse::newAbstain(),
				],
				[
					$tmp,
					new DomainException(
						'MockAbstractPrimaryAuthenticationProvider::continuePrimaryAccountLink() returned ABSTAIN'
					)
				]
			],
			'Primary UI, then pass' => [
				$good,
				[
					$tmp1 = AuthenticationResponse::newUI( [ $req ], $this->message( '...' ) ),
					AuthenticationResponse::newPass(),
				],
				[
					$tmp1,
					AuthenticationResponse::newPass( '' ),
				]
			],
			'Primary pass' => [
				$good,
				[
					AuthenticationResponse::newPass( '' ),
				],
				[
					AuthenticationResponse::newPass( '' ),
				]
			],
		];
	}

	public function testSetRequestContextUserFromSessionUser() {
		$user = $this->getTestUser()->getUser();
		$context = RequestContext::getMain();
		$context->setUser( $this->getTestUser()->getUser() );
		$context->getRequest()->getSession()->setUser( $user );
		$this->assertSame( $context->getRequest()->getSession()->getUser()->getName(), $context->getUser()->getName() );

		// Update the session with a new user, but leave the context user as the old user
		$newSessionUser = $this->getTestUser( 'sysop' )->getUser();
		$context->getRequest()->getSession()->setUser( $newSessionUser );
		$this->assertNotSame( $newSessionUser->getName(), $context->getUser()->getName() );

		$authManager = $this->getServiceContainer()->getAuthManager();
		$authManager->setRequestContextUserFromSessionUser();
		$this->assertSame( $context->getRequest()->getSession()->getUser()->getName(), $newSessionUser->getName() );
		$this->assertSame( $context->getRequest()->getSession()->getUser()->getName(), $context->getUser()->getName() );
	}

	/**
	 * @dataProvider provideAccountCreationAuthenticationRequestTestCases
	 *
	 * @param Closure $userProvider Closure returning the user performing the account creation
	 * @param string[] $expectedReqsByLevel Map of expected auth request classes keyed by requirement level
	 */
	public function testDefaultAccountCreationAuthenticationRequests(
		Closure $userProvider,
		array $expectedReqsByLevel
	): void {
		// Test the default primary and secondary authentication providers
		// irrespective of any potentially conflicting local configuration.
		$authConfig = $this->getServiceContainer()
			->getConfigSchema()
			->getDefaultFor( MainConfigNames::AuthManagerAutoConfig );

		$this->overrideConfigValues( [
			MainConfigNames::AuthManagerConfig => null,
			MainConfigNames::AuthManagerAutoConfig => $authConfig
		] );

		$authManager = $this->getServiceContainer()->getAuthManager();
		$userProvider = $userProvider->bindTo( $this );

		$reqs = $authManager->getAuthenticationRequests( AuthManager::ACTION_CREATE, $userProvider() );

		$reqsByLevel = [];
		foreach ( $reqs as $req ) {
			$reqsByLevel[$req->required][] = get_class( $req );
		}

		foreach ( $expectedReqsByLevel as $level => $expectedReqs ) {
			$reqs = $reqsByLevel[$level] ?? [];
			sort( $reqs );
			sort( $expectedReqs );

			$this->assertSame( $expectedReqs, $reqs );
		}
	}

	public static function provideAccountCreationAuthenticationRequestTestCases(): iterable {
		// phpcs:disable Squiz.Scope.StaticThisUsage.Found
		yield 'account creation on behalf of anonymous user' => [
			fn (): User => $this->getServiceContainer()->getUserFactory()->newAnonymous( '127.0.0.1' ),
			[
				AuthenticationRequest::OPTIONAL => [],
				AuthenticationRequest::REQUIRED => [
					UserDataAuthenticationRequest::class,
					UsernameAuthenticationRequest::class
				],
				AuthenticationRequest::PRIMARY_REQUIRED => [ PasswordAuthenticationRequest::class ]
			]
		];

		yield 'account creation on behalf of temporary user' => [
			function (): User {
				$req = new FauxRequest();
				return $this->getServiceContainer()
					->getTempUserCreator()
					->create( null, $req )
					->getUser();
			},
			[
				AuthenticationRequest::OPTIONAL => [],
				AuthenticationRequest::REQUIRED => [
					UserDataAuthenticationRequest::class,
					UsernameAuthenticationRequest::class
				],
				AuthenticationRequest::PRIMARY_REQUIRED => [ PasswordAuthenticationRequest::class ]
			]
		];

		yield 'account creation on behalf of registered user' => [
			fn (): User => $this->getTestUser()->getUser(),
			[
				AuthenticationRequest::OPTIONAL => [ CreationReasonAuthenticationRequest::class ],
				AuthenticationRequest::REQUIRED => [
					UserDataAuthenticationRequest::class,
					UsernameAuthenticationRequest::class
				],
				AuthenticationRequest::PRIMARY_REQUIRED => [
					PasswordAuthenticationRequest::class,
					TemporaryPasswordAuthenticationRequest::class
				]
			]
		];
		// phpcs:enable
	}
}
PK       ! D˭..  ..  7  auth/ConfirmLinkSecondaryAuthenticationProviderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\ButtonAuthenticationRequest;
use MediaWiki\Auth\ConfirmLinkAuthenticationRequest;
use MediaWiki\Auth\ConfirmLinkSecondaryAuthenticationProvider;
use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\Unit\Auth\AuthenticationProviderTestTrait;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\User\User;
use MediaWiki\User\UserNameUtils;
use MediaWikiIntegrationTestCase;
use StatusValue;
use stdClass;
use Wikimedia\TestingAccessWrapper;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\ConfirmLinkSecondaryAuthenticationProvider
 */
class ConfirmLinkSecondaryAuthenticationProviderTest extends MediaWikiIntegrationTestCase {
	use AuthenticationProviderTestTrait;
	use DummyServicesTrait;

	/**
	 * @dataProvider provideGetAuthenticationRequests
	 * @param string $action
	 * @param array $response
	 */
	public function testGetAuthenticationRequests( $action, $response ) {
		$provider = new ConfirmLinkSecondaryAuthenticationProvider();

		$this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) );
	}

	public static function provideGetAuthenticationRequests() {
		return [
			[ AuthManager::ACTION_LOGIN, [] ],
			[ AuthManager::ACTION_CREATE, [] ],
			[ AuthManager::ACTION_LINK, [] ],
			[ AuthManager::ACTION_CHANGE, [] ],
			[ AuthManager::ACTION_REMOVE, [] ],
		];
	}

	public function testBeginSecondaryAuthentication() {
		$user = $this->createMock( User::class );
		$obj = new stdClass;

		$mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class )
			->onlyMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] )
			->getMock();
		$mock->expects( $this->once() )->method( 'beginLinkAttempt' )
			->with( $this->identicalTo( $user ), $this->identicalTo( AuthManager::AUTHN_STATE ) )
			->willReturn( $obj );
		$mock->expects( $this->never() )->method( 'continueLinkAttempt' );

		$this->assertSame( $obj, $mock->beginSecondaryAuthentication( $user, [] ) );
	}

	public function testContinueSecondaryAuthentication() {
		$user = $this->createMock( User::class );
		$obj = new stdClass;
		$reqs = [ new stdClass ];

		$mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class )
			->onlyMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] )
			->getMock();
		$mock->expects( $this->never() )->method( 'beginLinkAttempt' );
		$mock->expects( $this->once() )->method( 'continueLinkAttempt' )
			->with(
				$this->identicalTo( $user ),
				$this->identicalTo( AuthManager::AUTHN_STATE ),
				$this->identicalTo( $reqs )
			)
			->willReturn( $obj );

		$this->assertSame( $obj, $mock->continueSecondaryAuthentication( $user, $reqs ) );
	}

	public function testBeginSecondaryAccountCreation() {
		$user = $this->createMock( User::class );
		$obj = new stdClass;

		$mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class )
			->onlyMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] )
			->getMock();
		$mock->expects( $this->once() )->method( 'beginLinkAttempt' )
			->with( $this->identicalTo( $user ), $this->identicalTo( AuthManager::ACCOUNT_CREATION_STATE ) )
			->willReturn( $obj );
		$mock->expects( $this->never() )->method( 'continueLinkAttempt' );

		$this->assertSame( $obj, $mock->beginSecondaryAccountCreation( $user, $user, [] ) );
	}

	public function testContinueSecondaryAccountCreation() {
		$user = $this->createMock( User::class );
		$obj = new stdClass;
		$reqs = [ new stdClass ];

		$mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class )
			->onlyMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] )
			->getMock();
		$mock->expects( $this->never() )->method( 'beginLinkAttempt' );
		$mock->expects( $this->once() )->method( 'continueLinkAttempt' )
			->with(
				$this->identicalTo( $user ),
				$this->identicalTo( AuthManager::ACCOUNT_CREATION_STATE ),
				$this->identicalTo( $reqs )
			)
			->willReturn( $obj );

		$this->assertSame( $obj, $mock->continueSecondaryAccountCreation( $user, $user, $reqs ) );
	}

	/**
	 * Get requests for testing
	 * @return AuthenticationRequest[]
	 */
	private function getLinkRequests() {
		$reqs = [];

		$mb = $this->getMockBuilder( AuthenticationRequest::class )
			->onlyMethods( [ 'getUniqueId' ] );
		for ( $i = 1; $i <= 3; $i++ ) {
			$uid = "Request$i";
			$req = $mb->getMockForAbstractClass();
			$req->method( 'getUniqueId' )->willReturn( $uid );
			$reqs[$uid] = $req;
		}

		return $reqs;
	}

	public function testBeginLinkAttempt() {
		$badReq = $this->getMockBuilder( AuthenticationRequest::class )
			->onlyMethods( [ 'getUniqueId' ] )
			->getMockForAbstractClass();
		$badReq->method( 'getUniqueId' )
			->willReturn( "BadReq" );

		$user = $this->createMock( User::class );
		$provider = new ConfirmLinkSecondaryAuthenticationProvider;
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$request = new FauxRequest();
		$mwServices = $this->getServiceContainer();

		$manager = $this->getMockBuilder( AuthManager::class )
			->onlyMethods( [ 'allowsAuthenticationDataChange' ] )
			->setConstructorArgs( [
				$request,
				$mwServices->getMainConfig(),
				$mwServices->getObjectFactory(),
				$mwServices->getHookContainer(),
				$mwServices->getReadOnlyMode(),
				$this->createNoOpMock( UserNameUtils::class ),
				$mwServices->getBlockManager(),
				$mwServices->getWatchlistManager(),
				$mwServices->getDBLoadBalancer(),
				$mwServices->getContentLanguage(),
				$mwServices->getLanguageConverterFactory(),
				$mwServices->getBotPasswordStore(),
				$mwServices->getUserFactory(),
				$mwServices->getUserIdentityLookup(),
				$mwServices->getUserOptionsManager()
			] )
			->getMock();
		$manager->method( 'allowsAuthenticationDataChange' )
			->willReturnCallback( static function ( $req ) {
				return $req->getUniqueId() !== 'BadReq'
					? StatusValue::newGood()
					: StatusValue::newFatal( 'no' );
			} );
		$this->initProvider( $provider, null, null, $manager );

		$this->assertEquals(
			AuthenticationResponse::newAbstain(),
			$providerPriv->beginLinkAttempt( $user, 'state' )
		);

		$request->getSession()->setSecret( 'state', [
			'maybeLink' => [],
		] );
		$this->assertEquals(
			AuthenticationResponse::newAbstain(),
			$providerPriv->beginLinkAttempt( $user, 'state' )
		);

		$reqs = $this->getLinkRequests();
		$request->getSession()->setSecret( 'state', [
			'maybeLink' => $reqs + [ 'BadReq' => $badReq ]
		] );
		$res = $providerPriv->beginLinkAttempt( $user, 'state' );
		$this->assertInstanceOf( AuthenticationResponse::class, $res );
		$this->assertSame( AuthenticationResponse::UI, $res->status );
		$this->assertSame( 'authprovider-confirmlink-message', $res->message->getKey() );
		$this->assertCount( 1, $res->neededRequests );
		$req = $res->neededRequests[0];
		$this->assertInstanceOf( ConfirmLinkAuthenticationRequest::class, $req );
		$expectReqs = $this->getLinkRequests();
		foreach ( $expectReqs as $r ) {
			$r->action = AuthManager::ACTION_CHANGE;
			$r->username = $user->getName();
		}
		$this->assertEquals( $expectReqs, TestingAccessWrapper::newFromObject( $req )->linkRequests );
	}

	public function testContinueLinkAttempt() {
		$user = $this->createMock( User::class );
		$obj = new stdClass;
		$reqs = $this->getLinkRequests();

		$done = [];

		// First, test the pass-through for not containing the ConfirmLinkAuthenticationRequest
		$mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class )
			->onlyMethods( [ 'beginLinkAttempt' ] )
			->getMock();
		$mock->expects( $this->once() )->method( 'beginLinkAttempt' )
			->with( $this->identicalTo( $user ), $this->identicalTo( 'state' ) )
			->willReturn( $obj );
		$this->assertSame(
			$obj,
			TestingAccessWrapper::newFromObject( $mock )->continueLinkAttempt( $user, 'state', $reqs )
		);

		// Now test the actual functioning
		$provider = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class )
			->onlyMethods( [
				'beginLinkAttempt', 'providerAllowsAuthenticationDataChange',
				'providerChangeAuthenticationData'
			] )
			->getMock();
		$provider->expects( $this->never() )->method( 'beginLinkAttempt' );
		$provider->method( 'providerAllowsAuthenticationDataChange' )
			->willReturnCallback( static function ( $req ) {
				return $req->getUniqueId() === 'Request3'
					? StatusValue::newFatal( 'foo' ) : StatusValue::newGood();
			} );
		$provider->method( 'providerChangeAuthenticationData' )
			->willReturnCallback( static function ( $req ) use ( &$done ) {
				$done[$req->getUniqueId()] = true;
			} );
		$config = new HashConfig( [
			MainConfigNames::AuthManagerConfig => [
				'preauth' => [],
				'primaryauth' => [],
				'secondaryauth' => [
					[ 'factory' => static function () use ( $provider ) {
						return $provider;
					} ],
				],
			],
		] );
		$request = new FauxRequest();
		$mwServices = $this->getServiceContainer();
		$manager = new AuthManager(
			$request,
			$config,
			$this->getDummyObjectFactory(),
			$mwServices->getHookContainer(),
			$mwServices->getReadOnlyMode(),
			$mwServices->getUserNameUtils(),
			$mwServices->getBlockManager(),
			$mwServices->getWatchlistManager(),
			$mwServices->getDBLoadBalancer(),
			$mwServices->getContentLanguage(),
			$mwServices->getLanguageConverterFactory(),
			$mwServices->getBotPasswordStore(),
			$mwServices->getUserFactory(),
			$mwServices->getUserIdentityLookup(),
			$mwServices->getUserOptionsManager()
		);
		$this->initProvider( $provider, null, null, $manager );
		$provider = TestingAccessWrapper::newFromObject( $provider );

		$req = new ConfirmLinkAuthenticationRequest( $reqs );

		$this->assertEquals(
			AuthenticationResponse::newAbstain(),
			$provider->continueLinkAttempt( $user, 'state', [ $req ] )
		);

		$request->getSession()->setSecret( 'state', [
			'maybeLink' => [],
		] );
		$this->assertEquals(
			AuthenticationResponse::newAbstain(),
			$provider->continueLinkAttempt( $user, 'state', [ $req ] )
		);

		$request->getSession()->setSecret( 'state', [
			'maybeLink' => $reqs
		] );
		$this->assertEquals(
			AuthenticationResponse::newPass(),
			$provider->continueLinkAttempt( $user, 'state', [ $req ] )
		);
		$this->assertSame( [], $done );

		$request->getSession()->setSecret( 'state', [
			'maybeLink' => [ $reqs['Request2'] ],
		] );
		$req->confirmedLinkIDs = [ 'Request1', 'Request2' ];
		$res = $provider->continueLinkAttempt( $user, 'state', [ $req ] );
		$this->assertEquals( AuthenticationResponse::newPass(), $res );
		$this->assertSame( [ 'Request2' => true ], $done );
		$done = [];

		$request->getSession()->setSecret( 'state', [
			'maybeLink' => $reqs,
		] );
		$req->confirmedLinkIDs = [ 'Request1', 'Request2' ];
		$res = $provider->continueLinkAttempt( $user, 'state', [ $req ] );
		$this->assertEquals( AuthenticationResponse::newPass(), $res );
		$this->assertSame( [ 'Request1' => true, 'Request2' => true ], $done );
		$done = [];

		$request->getSession()->setSecret( 'state', [
			'maybeLink' => $reqs,
		] );
		$req->confirmedLinkIDs = [ 'Request1', 'Request3' ];
		$res = $provider->continueLinkAttempt( $user, 'state', [ $req ] );
		$this->assertEquals( AuthenticationResponse::UI, $res->status );
		$this->assertCount( 1, $res->neededRequests );
		$this->assertInstanceOf( ButtonAuthenticationRequest::class, $res->neededRequests[0] );
		$this->assertSame( [ 'Request1' => true ], $done );
		$done = [];

		$res = $provider->continueLinkAttempt( $user, 'state', [ $res->neededRequests[0] ] );
		$this->assertEquals( AuthenticationResponse::newPass(), $res );
		$this->assertSame( [], $done );
	}

}
PK       ! %
#  #    auth/ThrottlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use InvalidArgumentException;
use MediaWiki\Auth\Throttler;
use MediaWiki\MainConfigNames;
use MediaWikiIntegrationTestCase;
use Psr\Log\AbstractLogger;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\TestingAccessWrapper;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\Throttler
 */
class ThrottlerTest extends MediaWikiIntegrationTestCase {
	public function testConstructor() {
		$cache = new HashBagOStuff();
		$logger = $this->getMockBuilder( AbstractLogger::class )
			->onlyMethods( [ 'log' ] )
			->getMockForAbstractClass();

		$throttler = new Throttler(
			[ [ 'count' => 123, 'seconds' => 456 ] ],
			[ 'type' => 'foo', 'cache' => $cache ]
		);
		$throttler->setLogger( $logger );
		$throttlerPriv = TestingAccessWrapper::newFromObject( $throttler );
		$this->assertSame( [ [ 'count' => 123, 'seconds' => 456 ] ], $throttlerPriv->conditions );
		$this->assertSame( 'foo', $throttlerPriv->type );
		$this->assertSame( $cache, $throttlerPriv->cache );
		$this->assertSame( $logger, $throttlerPriv->logger );

		$throttler = new Throttler( [ [ 'count' => 123, 'seconds' => 456 ] ] );
		$throttler->setLogger( new NullLogger() );
		$throttlerPriv = TestingAccessWrapper::newFromObject( $throttler );
		$this->assertSame( [ [ 'count' => 123, 'seconds' => 456 ] ], $throttlerPriv->conditions );
		$this->assertSame( 'custom', $throttlerPriv->type );
		$this->assertInstanceOf( BagOStuff::class, $throttlerPriv->cache );
		$this->assertInstanceOf( LoggerInterface::class, $throttlerPriv->logger );

		$this->overrideConfigValue(
			MainConfigNames::PasswordAttemptThrottle,
			[ [ 'count' => 321, 'seconds' => 654 ] ]
		);
		$throttler = new Throttler();
		$throttler->setLogger( new NullLogger() );
		$throttlerPriv = TestingAccessWrapper::newFromObject( $throttler );
		$this->assertSame( [ [ 'count' => 321, 'seconds' => 654 ] ], $throttlerPriv->conditions );
		$this->assertSame( 'password', $throttlerPriv->type );
		$this->assertInstanceOf( BagOStuff::class, $throttlerPriv->cache );
		$this->assertInstanceOf( LoggerInterface::class, $throttlerPriv->logger );

		try {
			new Throttler( [], [ 'foo' => 1, 'bar' => 2, 'baz' => 3 ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'unrecognized parameters: foo, bar, baz', $ex->getMessage() );
		}
	}

	/**
	 * @dataProvider provideNormalizeThrottleConditions
	 */
	public function testNormalizeThrottleConditions( $condition, $normalized ) {
		$throttler = new Throttler( $condition );
		$throttler->setLogger( new NullLogger() );
		$throttlerPriv = TestingAccessWrapper::newFromObject( $throttler );
		$this->assertSame( $normalized, $throttlerPriv->conditions );
	}

	public static function provideNormalizeThrottleConditions() {
		return [
			[
				[],
				[],
			],
			[
				[ 'count' => 1, 'seconds' => 2 ],
				[ [ 'count' => 1, 'seconds' => 2 ] ],
			],
			[
				[ [ 'count' => 1, 'seconds' => 2 ], [ 'count' => 2, 'seconds' => 3 ] ],
				[ [ 'count' => 1, 'seconds' => 2 ], [ 'count' => 2, 'seconds' => 3 ] ],
			],
		];
	}

	public function testNormalizeThrottleConditions2() {
		$priv = TestingAccessWrapper::newFromClass( Throttler::class );
		$this->assertSame( [], $priv->normalizeThrottleConditions( null ) );
		$this->assertSame( [], $priv->normalizeThrottleConditions( 'bad' ) );
	}

	public function testIncrease() {
		$cache = new HashBagOStuff();
		$throttler = new Throttler( [
			[ 'count' => 2, 'seconds' => 10, ],
			[ 'count' => 4, 'seconds' => 15, 'allIPs' => true ],
		], [ 'cache' => $cache ] );
		$throttler->setLogger( new NullLogger() );

		$result = $throttler->increase( 'SomeUser', '1.2.3.4' );
		$this->assertFalse( $result, 'should not throttle' );

		$result = $throttler->increase( 'SomeUser', '1.2.3.4' );
		$this->assertFalse( $result, 'should not throttle' );

		$result = $throttler->increase( 'SomeUser', '1.2.3.4' );
		$this->assertSame( [ 'throttleIndex' => 0, 'count' => 2, 'wait' => 10 ], $result );

		$result = $throttler->increase( 'OtherUser', '1.2.3.4' );
		$this->assertFalse( $result, 'should not throttle' );

		$result = $throttler->increase( 'SomeUser', '2.3.4.5' );
		$this->assertFalse( $result, 'should not throttle' );

		$result = $throttler->increase( 'SomeUser', '3.4.5.6' );
		$this->assertFalse( $result, 'should not throttle' );

		$result = $throttler->increase( 'SomeUser', '3.4.5.6' );
		$this->assertSame( [ 'throttleIndex' => 1, 'count' => 4, 'wait' => 15 ], $result );
	}

	public function testZeroCount() {
		$cache = new HashBagOStuff();
		$throttler = new Throttler( [ [ 'count' => 0, 'seconds' => 10 ] ], [ 'cache' => $cache ] );
		$throttler->setLogger( new NullLogger() );

		$result = $throttler->increase( 'SomeUser', '1.2.3.4' );
		$this->assertFalse( $result, 'should not throttle, count=0 is ignored' );

		$result = $throttler->increase( 'SomeUser', '1.2.3.4' );
		$this->assertFalse( $result, 'should not throttle, count=0 is ignored' );

		$result = $throttler->increase( 'SomeUser', '1.2.3.4' );
		$this->assertFalse( $result, 'should not throttle, count=0 is ignored' );
	}

	public function testNamespacing() {
		$cache = new HashBagOStuff();
		$throttler1 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ],
			[ 'cache' => $cache, 'type' => 'foo' ] );
		$throttler2 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ],
			[ 'cache' => $cache, 'type' => 'foo' ] );
		$throttler3 = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ],
			[ 'cache' => $cache, 'type' => 'bar' ] );
		$throttler1->setLogger( new NullLogger() );
		$throttler2->setLogger( new NullLogger() );
		$throttler3->setLogger( new NullLogger() );

		$throttled = [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ];

		$result = $throttler1->increase( 'SomeUser', '1.2.3.4' );
		$this->assertFalse( $result, 'should not throttle' );

		$result = $throttler1->increase( 'SomeUser', '1.2.3.4' );
		$this->assertEquals( $throttled, $result, 'should throttle' );

		$result = $throttler2->increase( 'SomeUser', '1.2.3.4' );
		$this->assertEquals( $throttled, $result, 'should throttle, same namespace' );

		$result = $throttler3->increase( 'SomeUser', '1.2.3.4' );
		$this->assertFalse( $result, 'should not throttle, different namespace' );
	}

	public function testExpiration() {
		$cache = $this->getMockBuilder( HashBagOStuff::class )
			->onlyMethods( [ 'add', 'incrWithInit' ] )->getMock();
		$throttler = new Throttler( [ [ 'count' => 3, 'seconds' => 10 ] ], [ 'cache' => $cache ] );
		$throttler->setLogger( new NullLogger() );

		$cache->expects( $this->once() )
			->method( 'incrWithInit' )
			->with( $this->anything(), 10, 1 );
		$throttler->increase( 'SomeUser' );
	}

	/**
	 */
	public function testException() {
		$throttler = new Throttler( [ [ 'count' => 3, 'seconds' => 10 ] ] );
		$throttler->setLogger( new NullLogger() );
		$this->expectException( InvalidArgumentException::class );
		$throttler->increase();
	}

	public function testLog() {
		$cache = new HashBagOStuff();
		$throttler = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], [ 'cache' => $cache ] );

		$logger = $this->getMockBuilder( AbstractLogger::class )
			->onlyMethods( [ 'log' ] )
			->getMockForAbstractClass();
		$logger->expects( $this->never() )->method( 'log' );
		$throttler->setLogger( $logger );
		$result = $throttler->increase( 'SomeUser', '1.2.3.4' );
		$this->assertFalse( $result, 'should not throttle' );

		$logger = $this->getMockBuilder( AbstractLogger::class )
			->onlyMethods( [ 'log' ] )
			->getMockForAbstractClass();
		$logger->expects( $this->once() )->method( 'log' )->with( $this->anything(), $this->anything(), [
			'throttle' => 'custom',
			'index' => 0,
			'ipKey' => '1.2.3.4',
			'username' => 'SomeUser',
			'count' => 1,
			'expiry' => 10,
			'method' => 'foo',
		] );
		$throttler->setLogger( $logger );
		$result = $throttler->increase( 'SomeUser', '1.2.3.4', 'foo' );
		$this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result );
	}

	public function testClear() {
		$cache = new HashBagOStuff();
		$throttler = new Throttler( [ [ 'count' => 1, 'seconds' => 10 ] ], [ 'cache' => $cache ] );
		$throttler->setLogger( new NullLogger() );

		$result = $throttler->increase( 'SomeUser', '1.2.3.4' );
		$this->assertFalse( $result, 'should not throttle' );

		$result = $throttler->increase( 'SomeUser', '1.2.3.4' );
		$this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result );

		$result = $throttler->increase( 'OtherUser', '1.2.3.4' );
		$this->assertFalse( $result, 'should not throttle' );

		$result = $throttler->increase( 'OtherUser', '1.2.3.4' );
		$this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result );

		$throttler->clear( 'SomeUser', '1.2.3.4' );

		$result = $throttler->increase( 'SomeUser', '1.2.3.4' );
		$this->assertFalse( $result, 'should not throttle' );

		$result = $throttler->increase( 'OtherUser', '1.2.3.4' );
		$this->assertSame( [ 'throttleIndex' => 0, 'count' => 1, 'wait' => 10 ], $result );
	}
}
PK       ! 'E    .  auth/AbstractPreAuthenticationProviderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\AbstractPreAuthenticationProvider;
use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\AuthManager;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use StatusValue;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\AbstractPreAuthenticationProvider
 */
class AbstractPreAuthenticationProviderTest extends MediaWikiIntegrationTestCase {
	public function testAbstractPreAuthenticationProvider() {
		$user = $this->createMock( User::class );

		$provider = $this->getMockForAbstractClass( AbstractPreAuthenticationProvider::class );

		$this->assertEquals(
			[],
			$provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] )
		);
		$this->assertEquals(
			StatusValue::newGood(),
			$provider->testForAuthentication( [] )
		);
		$this->assertEquals(
			StatusValue::newGood(),
			$provider->testForAccountCreation( $user, $user, [] )
		);
		$this->assertEquals(
			StatusValue::newGood(),
			$provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION )
		);
		$this->assertEquals(
			StatusValue::newGood(),
			$provider->testUserForCreation( $user, false )
		);
		$this->assertEquals(
			StatusValue::newGood(),
			$provider->testForAccountLink( $user )
		);

		$res = AuthenticationResponse::newPass();
		$provider->postAuthentication( $user, $res );
		$provider->postAccountCreation( $user, $user, $res );
		$provider->postAccountLink( $user, $res );
	}
}
PK       ! d    ,  auth/RememberMeAuthenticationRequestTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\RememberMeAuthenticationRequest;
use Wikimedia\TestingAccessWrapper;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\RememberMeAuthenticationRequest
 */
class RememberMeAuthenticationRequestTest extends AuthenticationRequestTestCase {

	public static function provideGetFieldInfo() {
		return [
			[ [ 1 ] ],
			[ [ null ] ],
		];
	}

	public function testGetFieldInfo_2() {
		$req = new RememberMeAuthenticationRequest();
		$reqWrapper = TestingAccessWrapper::newFromObject( $req );

		$reqWrapper->expiration = 30 * 24 * 3600;
		$this->assertNotEmpty( $req->getFieldInfo() );

		$reqWrapper->expiration = null;
		$this->assertSame( [], $req->getFieldInfo() );
	}

	public function testNoChoice() {
		$req = new RememberMeAuthenticationRequest(
			RememberMeAuthenticationRequest::ALWAYS_REMEMBER
		);
		$reqWrapper = TestingAccessWrapper::newFromObject( $req );
		$this->assertSame( [], $req->getFieldInfo() );
		$this->assertNotNull( $reqWrapper->expiration );

		$req = new RememberMeAuthenticationRequest(
			RememberMeAuthenticationRequest::NEVER_REMEMBER
		);
		$reqWrapper = TestingAccessWrapper::newFromObject( $req );
		$this->assertSame( [], $req->getFieldInfo() );
		$this->assertNull( $reqWrapper->expiration );
	}

	public function testInvalid() {
		$this->expectException( '\UnexpectedValueException' );
		new RememberMeAuthenticationRequest( 'invalid value' );
	}

	protected function getInstance( array $args = [] ) {
		if ( isset( $args[1] ) ) {
			$req = new RememberMeAuthenticationRequest( $args[1] );
		} else {
			$req = new RememberMeAuthenticationRequest();
		}
		$reqWrapper = TestingAccessWrapper::newFromObject( $req );
		$reqWrapper->expiration = $args[0];
		return $req;
	}

	public static function provideLoadFromSubmission() {
		return [
			'Empty request' => [
				[ 30 * 24 * 3600 ],
				[],
				[ 'expiration' => 30 * 24 * 3600, 'rememberMe' => false ]
			],
			'RememberMe present' => [
				[ 30 * 24 * 3600 ],
				[ 'rememberMe' => '' ],
				[ 'expiration' => 30 * 24 * 3600, 'rememberMe' => true ]
			],
			'RememberMe present but session provider cannot remember' => [
				[ null ],
				[ 'rememberMe' => '' ],
				false
			],
			'Empty request (CHOOSE_REMEMBER)' => [
				[ 30 * 24 * 3600, RememberMeAuthenticationRequest::CHOOSE_REMEMBER ],
				[],
				[ 'expiration' => 30 * 24 * 3600, 'rememberMe' => false ]
			],
			'RememberMe present (CHOOSE_REMEMBER)' => [
				[ 30 * 24 * 3600, RememberMeAuthenticationRequest::CHOOSE_REMEMBER ],
				[ 'rememberMe' => '' ],
				[ 'expiration' => 30 * 24 * 3600, 'rememberMe' => true ]
			],
			'RememberMe present but session provider cannot remember (CHOOSE_REMEMBER)' => [
				[ null, RememberMeAuthenticationRequest::CHOOSE_REMEMBER ],
				[ 'rememberMe' => '' ],
				false
			],
			'Empty request (FORCE_CHOOSE_REMEMBER)' => [
				[ 30 * 24 * 3600, RememberMeAuthenticationRequest::FORCE_CHOOSE_REMEMBER ],
				[],
				[ 'expiration' => 30 * 24 * 3600, 'rememberMe' => false, 'skippable' => false ]
			],
			'RememberMe present (FORCE_CHOOSE_REMEMBER)' => [
				[ 30 * 24 * 3600, RememberMeAuthenticationRequest::FORCE_CHOOSE_REMEMBER ],
				[ 'rememberMe' => '' ],
				[ 'expiration' => 30 * 24 * 3600, 'rememberMe' => true, 'skippable' => false ]
			],
			'RememberMe present but session provider cannot remember (FORCE_CHOOSE_REMEMBER)' => [
				[ null, RememberMeAuthenticationRequest::FORCE_CHOOSE_REMEMBER ],
				[ 'rememberMe' => '', 'skippable' => false ],
				false
			],
		];
	}
}
PK       !     2  auth/AbstractPrimaryAuthenticationProviderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use BadMethodCallException;
use MediaWiki\Auth\AbstractPrimaryAuthenticationProvider;
use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\PrimaryAuthenticationProvider;
use MediaWiki\Tests\Unit\Auth\AuthenticationProviderTestTrait;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use StatusValue;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\AbstractPrimaryAuthenticationProvider
 */
class AbstractPrimaryAuthenticationProviderTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;
	use AuthenticationProviderTestTrait;

	public function testAbstractPrimaryAuthenticationProvider() {
		$user = $this->createMock( User::class );

		$provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class );

		try {
			$provider->continuePrimaryAuthentication( [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( BadMethodCallException $ex ) {
		}

		try {
			$provider->continuePrimaryAccountCreation( $user, $user, [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( BadMethodCallException $ex ) {
		}

		$this->assertTrue( $provider->providerAllowsPropertyChange( 'foo' ) );
		$this->assertEquals(
			StatusValue::newGood(),
			$provider->testForAccountCreation( $user, $user, [] )
		);
		$this->assertEquals(
			StatusValue::newGood(),
			$provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION )
		);
		$this->assertEquals(
			StatusValue::newGood(),
			$provider->testUserForCreation( $user, false )
		);

		$this->assertNull(
			$provider->finishAccountCreation( $user, $user, AuthenticationResponse::newPass() )
		);
		$provider->autoCreatedAccount( $user, AuthManager::AUTOCREATE_SOURCE_SESSION );

		$res = AuthenticationResponse::newPass();
		$provider->postAuthentication( $user, $res );
		$provider->postAccountCreation( $user, $user, $res );
		$provider->postAccountLink( $user, $res );

		$provider->expects( $this->once() )
			->method( 'testUserExists' )
			->with( 'foo' )
			->willReturn( true );
		$this->assertTrue( $provider->testUserCanAuthenticate( 'foo' ) );
	}

	public function testProviderRevokeAccessForUser() {
		$reqs = [];
		for ( $i = 0; $i < 3; $i++ ) {
			$reqs[$i] = $this->createMock( AuthenticationRequest::class );
		}
		$username = 'TestProviderRevokeAccessForUser';

		$provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class );
		$provider->expects( $this->once() )->method( 'getAuthenticationRequests' )
			->with(
				$this->identicalTo( AuthManager::ACTION_REMOVE ),
				$this->identicalTo( [ 'username' => $username ] )
			)
			->willReturn( $reqs );
		$provider->expects( $this->exactly( 3 ) )->method( 'providerChangeAuthenticationData' )
			->willReturnCallback( function ( $req ) use ( $username ) {
				$this->assertSame( $username, $req->username );
			} );

		$provider->providerRevokeAccessForUser( $username );

		foreach ( $reqs as $i => $req ) {
			$this->assertNotNull( $req->username, "#$i" );
		}
	}

	/**
	 * @dataProvider providePrimaryAccountLink
	 * @param string $type PrimaryAuthenticationProvider::TYPE_* constant
	 * @param string $msg Error message from beginPrimaryAccountLink
	 */
	public function testPrimaryAccountLink( $type, $msg ) {
		$provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class );
		$provider->method( 'accountCreationType' )
			->willReturn( $type );

		$class = AbstractPrimaryAuthenticationProvider::class;
		$msg1 = "{$class}::beginPrimaryAccountLink $msg";
		$msg2 = "{$class}::continuePrimaryAccountLink is not implemented.";

		$user = User::newFromName( 'Whatever' );

		try {
			$provider->beginPrimaryAccountLink( $user, [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( BadMethodCallException $ex ) {
			$this->assertSame( $msg1, $ex->getMessage() );
		}
		try {
			$provider->continuePrimaryAccountLink( $user, [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( BadMethodCallException $ex ) {
			$this->assertSame( $msg2, $ex->getMessage() );
		}
	}

	public static function providePrimaryAccountLink() {
		return [
			[
				PrimaryAuthenticationProvider::TYPE_NONE,
				'should not be called on a non-link provider.',
			],
			[
				PrimaryAuthenticationProvider::TYPE_CREATE,
				'should not be called on a non-link provider.',
			],
			[
				PrimaryAuthenticationProvider::TYPE_LINK,
				'is not implemented.',
			],
		];
	}

	/**
	 * @dataProvider provideProviderNormalizeUsername
	 */
	public function testProviderNormalizeUsername( $name, $expect ) {
		// fake interwiki map for the 'Interwiki prefix' testcase
		$interwikiLookup = $this->getDummyInterwikiLookup( [ 'interwiki' ] );
		$this->setService( 'InterwikiLookup', $interwikiLookup );

		$provider = $this->getMockForAbstractClass( AbstractPrimaryAuthenticationProvider::class );
		$this->initProvider( $provider, null, null, null, null, $this->getServiceContainer()->getUserNameUtils() );
		$this->assertSame( $expect, $provider->providerNormalizeUsername( $name ) );
	}

	public static function provideProviderNormalizeUsername() {
		return [
			'Leading space' => [ ' Leading space', 'Leading space' ],
			'Trailing space ' => [ 'Trailing space ', 'Trailing space' ],
			'Namespace prefix' => [ 'Talk:Username', null ],
			'Interwiki prefix' => [ 'interwiki:Username', null ],
			'With hash' => [ 'name with # hash', null ],
			'Multi spaces' => [ 'Multi  spaces', 'Multi spaces' ],
			'Lowercase' => [ 'lowercase', 'Lowercase' ],
			'Invalid character' => [ 'in[]valid', null ],
			'With slash' => [ 'with / slash', null ],
			'Underscores' => [ '___under__scores___', 'Under scores' ],
		];
	}

}
PK       ! /  /  3  auth/TemporaryPasswordAuthenticationRequestTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\TemporaryPasswordAuthenticationRequest
 */
class TemporaryPasswordAuthenticationRequestTest extends AuthenticationRequestTestCase {

	protected function getInstance( array $args = [] ) {
		$ret = new TemporaryPasswordAuthenticationRequest;
		$ret->action = $args[0];
		return $ret;
	}

	public static function provideGetFieldInfo() {
		return [
			[ [ AuthManager::ACTION_CREATE ] ],
			[ [ AuthManager::ACTION_CHANGE ] ],
			[ [ AuthManager::ACTION_REMOVE ] ],
		];
	}

	public function testNewRandom() {
		global $wgPasswordPolicy;

		$policy = $wgPasswordPolicy;
		unset( $policy['policies'] );
		$policy['policies']['default'] = [
			'MinimalPasswordLength' => 1,
			'MinimumPasswordLengthToLogin' => 1,
		];

		$this->overrideConfigValues( [
			MainConfigNames::PasswordPolicy => $policy,
		] );

		$ret1 = TemporaryPasswordAuthenticationRequest::newRandom();
		$ret2 = TemporaryPasswordAuthenticationRequest::newRandom();
		$this->assertEquals( 10, strlen( $ret1->password ) );
		$this->assertEquals( 10, strlen( $ret2->password ) );
		$this->assertNotSame( $ret1->password, $ret2->password );

		$policy['policies']['default']['MinimalPasswordLength'] = 15;
		$this->overrideConfigValue( MainConfigNames::PasswordPolicy, $policy );
		$ret = TemporaryPasswordAuthenticationRequest::newRandom();
		$this->assertEquals( 15, strlen( $ret->password ) );

		$policy['policies']['default']['MinimalPasswordLength'] = [ 'value' => 20 ];
		$this->overrideConfigValue( MainConfigNames::PasswordPolicy, $policy );
		$ret = TemporaryPasswordAuthenticationRequest::newRandom();
		$this->assertEquals( 20, strlen( $ret->password ) );
	}

	public function testNewInvalid() {
		$ret = TemporaryPasswordAuthenticationRequest::newInvalid();
		$this->assertNull( $ret->password );
	}

	public static function provideLoadFromSubmission() {
		return [
			'Empty request' => [
				[ AuthManager::ACTION_REMOVE ],
				[],
				false,
			],
			'Create, empty request' => [
				[ AuthManager::ACTION_CREATE ],
				[],
				false,
			],
			'Create, mailpassword set' => [
				[ AuthManager::ACTION_CREATE ],
				[ 'mailpassword' => 1 ],
				[ 'mailpassword' => true, 'action' => AuthManager::ACTION_CREATE ],
			],
		];
	}

	public function testDescribeCredentials() {
		$username = 'TestDescribeCredentials';
		$req = new TemporaryPasswordAuthenticationRequest;
		$req->action = AuthManager::ACTION_LOGIN;
		$req->username = $username;
		$ret = $req->describeCredentials();
		$this->assertIsArray( $ret );
		$this->assertArrayHasKey( 'provider', $ret );
		$this->assertInstanceOf( Message::class, $ret['provider'] );
		$this->assertSame( 'authmanager-provider-temporarypassword', $ret['provider']->getKey() );
		$this->assertArrayHasKey( 'account', $ret );
		$this->assertInstanceOf( Message::class, $ret['account'] );
		$this->assertSame( [ $username ], $ret['account']->getParams() );
	}
}
PK       ! AT$    0  auth/CreatedAccountAuthenticationRequestTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\CreatedAccountAuthenticationRequest;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\CreatedAccountAuthenticationRequest
 */
class CreatedAccountAuthenticationRequestTest extends AuthenticationRequestTestCase {

	protected function getInstance( array $args = [] ) {
		return new CreatedAccountAuthenticationRequest( 42, 'Test' );
	}

	public function testConstructor() {
		$ret = new CreatedAccountAuthenticationRequest( 42, 'Test' );
		$this->assertSame( 42, $ret->id );
		$this->assertSame( 'Test', $ret->username );
	}

	public static function provideLoadFromSubmission() {
		return [
			'Empty request' => [
				[],
				[],
				false
			],
		];
	}
}
PK       !     0  auth/CreationReasonAuthenticationRequestTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\CreationReasonAuthenticationRequest;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\CreationReasonAuthenticationRequest
 */
class CreationReasonAuthenticationRequestTest extends AuthenticationRequestTestCase {

	protected function getInstance( array $args = [] ) {
		return new CreationReasonAuthenticationRequest();
	}

	public static function provideLoadFromSubmission() {
		return [
			'Empty request' => [
				[],
				[],
				false
			],
			'Reason given' => [
				[],
				$data = [ 'reason' => 'Because' ],
				$data,
			],
			'Reason empty' => [
				[],
				[ 'reason' => '' ],
				false
			],
		];
	}
}
PK       ! 3  3  9  auth/ResetPasswordSecondaryAuthenticationProviderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use DynamicPropertyTestHelper;
use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\ButtonAuthenticationRequest;
use MediaWiki\Auth\PasswordAuthenticationRequest;
use MediaWiki\Auth\ResetPasswordSecondaryAuthenticationProvider;
use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\Unit\Auth\AuthenticationProviderTestTrait;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\User\BotPasswordStore;
use MediaWiki\User\User;
use MediaWiki\User\UserNameUtils;
use MediaWikiIntegrationTestCase;
use StatusValue;
use stdClass;
use UnexpectedValueException;
use Wikimedia\TestingAccessWrapper;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\ResetPasswordSecondaryAuthenticationProvider
 */
class ResetPasswordSecondaryAuthenticationProviderTest extends MediaWikiIntegrationTestCase {
	use AuthenticationProviderTestTrait;
	use DummyServicesTrait;

	/**
	 * @dataProvider provideGetAuthenticationRequests
	 * @param string $action
	 * @param array $response
	 */
	public function testGetAuthenticationRequests( $action, $response ) {
		$provider = new ResetPasswordSecondaryAuthenticationProvider();

		$this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) );
	}

	public static function provideGetAuthenticationRequests() {
		return [
			[ AuthManager::ACTION_LOGIN, [] ],
			[ AuthManager::ACTION_CREATE, [] ],
			[ AuthManager::ACTION_LINK, [] ],
			[ AuthManager::ACTION_CHANGE, [] ],
			[ AuthManager::ACTION_REMOVE, [] ],
		];
	}

	public function testBasics() {
		$user = $this->createMock( User::class );
		$user2 = new User;
		$obj = new stdClass;
		$reqs = [ new stdClass ];

		$mb = $this->getMockBuilder( ResetPasswordSecondaryAuthenticationProvider::class )
			->onlyMethods( [ 'tryReset' ] );

		$methods = [
			'beginSecondaryAuthentication' => [ $user, $reqs ],
			'continueSecondaryAuthentication' => [ $user, $reqs ],
			'beginSecondaryAccountCreation' => [ $user, $user2, $reqs ],
			'continueSecondaryAccountCreation' => [ $user, $user2, $reqs ],
		];
		foreach ( $methods as $method => $args ) {
			$mock = $mb->getMock();
			$mock->expects( $this->once() )->method( 'tryReset' )
				->with( $this->identicalTo( $user ), $this->identicalTo( $reqs ) )
				->willReturn( $obj );
			$this->assertSame( $obj, $mock->$method( ...$args ) );
		}
	}

	public function testTryReset() {
		$username = 'TestTryReset';
		$user = User::newFromName( $username );

		$provider = $this->getMockBuilder(
			ResetPasswordSecondaryAuthenticationProvider::class
		)
			->onlyMethods( [
				'providerAllowsAuthenticationDataChange', 'providerChangeAuthenticationData'
			] )
			->getMock();
		$provider->method( 'providerAllowsAuthenticationDataChange' )
			->willReturnCallback( function ( $req ) use ( $username ) {
				$this->assertSame( $username, $req->username );
				return DynamicPropertyTestHelper::getDynamicProperty( $req, 'allow' );
			} );
		$provider->method( 'providerChangeAuthenticationData' )
			->willReturnCallback( function ( $req ) use ( $username ) {
				$this->assertSame( $username, $req->username );
				DynamicPropertyTestHelper::setDynamicProperty( $req, 'done', true );
			} );
		$config = new HashConfig( [
			MainConfigNames::AuthManagerConfig => [
				'preauth' => [],
				'primaryauth' => [],
				'secondaryauth' => [
					[ 'factory' => static function () use ( $provider ) {
						return $provider;
					} ],
				],
			],
		] );
		$mwServices = $this->getServiceContainer();
		$manager = new AuthManager(
			new FauxRequest,
			$config,
			$this->getDummyObjectFactory(),
			$this->createHookContainer(),
			$mwServices->getReadOnlyMode(),
			$this->createNoOpMock( UserNameUtils::class ),
			$mwServices->getBlockManager(),
			$mwServices->getWatchlistManager(),
			$mwServices->getDBLoadBalancer(),
			$mwServices->getContentLanguage(),
			$mwServices->getLanguageConverterFactory(),
			$this->createMock( BotPasswordStore::class ),
			$mwServices->getUserFactory(),
			$mwServices->getUserIdentityLookup(),
			$mwServices->getUserOptionsManager()
		);
		$this->initProvider( $provider, null, null, $manager );
		$provider = TestingAccessWrapper::newFromObject( $provider );

		$msg = wfMessage( 'foo' );
		$skipReq = new ButtonAuthenticationRequest(
			'skipReset',
			wfMessage( 'authprovider-resetpass-skip-label' ),
			wfMessage( 'authprovider-resetpass-skip-help' )
		);
		$passReq = new PasswordAuthenticationRequest();
		$passReq->action = AuthManager::ACTION_CHANGE;
		$passReq->password = 'Foo';
		$passReq->retype = 'Bar';
		DynamicPropertyTestHelper::setDynamicProperty( $passReq, 'allow', StatusValue::newGood() );
		DynamicPropertyTestHelper::setDynamicProperty( $passReq, 'done', false );

		$passReq2 = $this->getMockBuilder( PasswordAuthenticationRequest::class )
			->enableProxyingToOriginalMethods()
			->getMock();
		$passReq2->action = AuthManager::ACTION_CHANGE;
		$passReq2->password = 'Foo';
		$passReq2->retype = 'Foo';
		DynamicPropertyTestHelper::setDynamicProperty( $passReq2, 'allow', StatusValue::newGood() );
		DynamicPropertyTestHelper::setDynamicProperty( $passReq2, 'done', false );

		$passReq3 = new PasswordAuthenticationRequest();
		$passReq3->action = AuthManager::ACTION_LOGIN;
		$passReq3->password = 'Foo';
		$passReq3->retype = 'Foo';
		DynamicPropertyTestHelper::setDynamicProperty( $passReq3, 'allow', StatusValue::newGood() );
		DynamicPropertyTestHelper::setDynamicProperty( $passReq3, 'done', false );

		$this->assertEquals(
			AuthenticationResponse::newAbstain(),
			$provider->tryReset( $user, [] )
		);

		$manager->setAuthenticationSessionData( 'reset-pass', 'foo' );
		try {
			$provider->tryReset( $user, [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame( 'reset-pass is not valid', $ex->getMessage() );
		}

		$manager->setAuthenticationSessionData( 'reset-pass', (object)[] );
		try {
			$provider->tryReset( $user, [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame( 'reset-pass msg is missing', $ex->getMessage() );
		}

		$manager->setAuthenticationSessionData( 'reset-pass', [
			'msg' => 'foo',
		] );
		try {
			$provider->tryReset( $user, [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame( 'reset-pass msg is not valid', $ex->getMessage() );
		}

		$manager->setAuthenticationSessionData( 'reset-pass', [
			'msg' => $msg,
		] );
		try {
			$provider->tryReset( $user, [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame( 'reset-pass hard is missing', $ex->getMessage() );
		}

		$manager->setAuthenticationSessionData( 'reset-pass', [
			'msg' => $msg,
			'hard' => true,
			'req' => 'foo',
		] );
		try {
			$provider->tryReset( $user, [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame( 'reset-pass req is not valid', $ex->getMessage() );
		}

		$manager->setAuthenticationSessionData( 'reset-pass', [
			'msg' => $msg,
			'hard' => false,
			'req' => $passReq3,
		] );
		try {
			$provider->tryReset( $user, [ $passReq ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame( 'reset-pass req is not valid', $ex->getMessage() );
		}

		$manager->setAuthenticationSessionData( 'reset-pass', [
			'msg' => $msg,
			'hard' => true,
		] );
		$res = $provider->tryReset( $user, [] );
		$this->assertInstanceOf( AuthenticationResponse::class, $res );
		$this->assertSame( AuthenticationResponse::UI, $res->status );
		$this->assertEquals( $msg, $res->message );
		$this->assertCount( 1, $res->neededRequests );
		$this->assertInstanceOf(
			PasswordAuthenticationRequest::class,
			$res->neededRequests[0]
		);
		$this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
		$this->assertFalse( DynamicPropertyTestHelper::getDynamicProperty( $passReq, 'done' ) );

		$manager->setAuthenticationSessionData( 'reset-pass', [
			'msg' => $msg,
			'hard' => false,
			'req' => $passReq,
		] );
		$res = $provider->tryReset( $user, [] );
		$this->assertInstanceOf( AuthenticationResponse::class, $res );
		$this->assertSame( AuthenticationResponse::UI, $res->status );
		$this->assertEquals( $msg, $res->message );
		$this->assertCount( 2, $res->neededRequests );
		$expectedPassReq = clone $passReq;
		$expectedPassReq->required = AuthenticationRequest::OPTIONAL;
		$this->assertEquals( $expectedPassReq, $res->neededRequests[0] );
		$this->assertEquals( $skipReq, $res->neededRequests[1] );
		$this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
		$this->assertFalse( DynamicPropertyTestHelper::getDynamicProperty( $passReq, 'done' ) );

		$passReq->retype = 'Bad';
		$manager->setAuthenticationSessionData( 'reset-pass', [
			'msg' => $msg,
			'hard' => false,
			'req' => $passReq,
		] );
		$res = $provider->tryReset( $user, [ $skipReq, $passReq ] );
		$this->assertEquals( AuthenticationResponse::newPass(), $res );
		$this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
		$this->assertFalse( DynamicPropertyTestHelper::getDynamicProperty( $passReq, 'done' ) );

		$passReq->retype = 'Bad';
		$manager->setAuthenticationSessionData( 'reset-pass', [
			'msg' => $msg,
			'hard' => true,
		] );
		$res = $provider->tryReset( $user, [ $skipReq, $passReq ] );
		$this->assertSame( AuthenticationResponse::UI, $res->status );
		$this->assertSame( 'badretype', $res->message->getKey() );
		$this->assertCount( 1, $res->neededRequests );
		$this->assertInstanceOf(
			PasswordAuthenticationRequest::class,
			$res->neededRequests[0]
		);
		$this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
		$this->assertFalse( DynamicPropertyTestHelper::getDynamicProperty( $passReq, 'done' ) );

		$manager->setAuthenticationSessionData( 'reset-pass', [
			'msg' => $msg,
			'hard' => true,
		] );
		$res = $provider->tryReset( $user, [ $skipReq, $passReq3 ] );
		$this->assertSame( AuthenticationResponse::UI, $res->status );
		$this->assertEquals( $msg, $res->message );
		$this->assertCount( 1, $res->neededRequests );
		$this->assertInstanceOf(
			PasswordAuthenticationRequest::class,
			$res->neededRequests[0]
		);
		$this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
		$this->assertFalse( DynamicPropertyTestHelper::getDynamicProperty( $passReq, 'done' ) );

		$passReq->retype = $passReq->password;
		DynamicPropertyTestHelper::setDynamicProperty( $passReq, 'allow', StatusValue::newFatal( 'arbitrary-fail' ) );
		$res = $provider->tryReset( $user, [ $skipReq, $passReq ] );
		$this->assertSame( AuthenticationResponse::UI, $res->status );
		$this->assertSame( 'arbitrary-fail', $res->message->getKey() );
		$this->assertCount( 1, $res->neededRequests );
		$this->assertInstanceOf(
			PasswordAuthenticationRequest::class,
			$res->neededRequests[0]
		);
		$this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
		$this->assertFalse( DynamicPropertyTestHelper::getDynamicProperty( $passReq, 'done' ) );

		DynamicPropertyTestHelper::setDynamicProperty( $passReq, 'allow', StatusValue::newGood() );
		$res = $provider->tryReset( $user, [ $skipReq, $passReq ] );
		$this->assertEquals( AuthenticationResponse::newPass(), $res );
		$this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
		$this->assertTrue( DynamicPropertyTestHelper::getDynamicProperty( $passReq, 'done' ) );

		$manager->setAuthenticationSessionData( 'reset-pass', [
			'msg' => $msg,
			'hard' => false,
			'req' => $passReq2,
		] );
		$res = $provider->tryReset( $user, [ $passReq2 ] );
		$this->assertEquals( AuthenticationResponse::newPass(), $res );
		$this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
		$this->assertTrue( DynamicPropertyTestHelper::getDynamicProperty( $passReq2, 'done' ) );

		DynamicPropertyTestHelper::setDynamicProperty( $passReq, 'done', false );
		DynamicPropertyTestHelper::setDynamicProperty( $passReq2, 'done', false );
		$manager->setAuthenticationSessionData( 'reset-pass', [
			'msg' => $msg,
			'hard' => false,
			'req' => $passReq2,
		] );
		$res = $provider->tryReset( $user, [ $passReq ] );
		$this->assertInstanceOf( AuthenticationResponse::class, $res );
		$this->assertSame( AuthenticationResponse::UI, $res->status );
		$this->assertEquals( $msg, $res->message );
		$this->assertCount( 2, $res->neededRequests );
		$expectedPassReq = clone $passReq2;
		$expectedPassReq->required = AuthenticationRequest::OPTIONAL;
		$this->assertEquals( $expectedPassReq, $res->neededRequests[0] );
		$this->assertEquals( $skipReq, $res->neededRequests[1] );
		$this->assertNotNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );
		$this->assertFalse( DynamicPropertyTestHelper::getDynamicProperty( $passReq, 'done' ) );
		$this->assertFalse( DynamicPropertyTestHelper::getDynamicProperty( $passReq2, 'done' ) );
	}
}
PK       ! I    7  auth/CheckBlocksSecondaryAuthenticationProviderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\CheckBlocksSecondaryAuthenticationProvider;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Config\HashConfig;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\Unit\Auth\AuthenticationProviderTestTrait;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\TestingAccessWrapper;

/**
 * @group AuthManager
 * @group Database
 * @covers \MediaWiki\Auth\CheckBlocksSecondaryAuthenticationProvider
 */
class CheckBlocksSecondaryAuthenticationProviderTest extends MediaWikiIntegrationTestCase {
	use AuthenticationProviderTestTrait;

	public function testConstructor() {
		$provider = new CheckBlocksSecondaryAuthenticationProvider();
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$config = new HashConfig( [
			MainConfigNames::BlockDisablesLogin => false
		] );
		$this->initProvider( $provider, $config );
		$this->assertSame( false, $providerPriv->blockDisablesLogin );

		$provider = new CheckBlocksSecondaryAuthenticationProvider(
			[ 'blockDisablesLogin' => true ]
		);
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$config = new HashConfig( [
			MainConfigNames::BlockDisablesLogin => false
		] );
		$this->initProvider( $provider, $config );
		$this->assertSame( true, $providerPriv->blockDisablesLogin );
	}

	public function testBasics() {
		$provider = new CheckBlocksSecondaryAuthenticationProvider();
		$user = $this->getTestSysop()->getUser();

		$this->assertEquals(
			AuthenticationResponse::newAbstain(),
			$provider->beginSecondaryAccountCreation( $user, $user, [] )
		);
	}

	/**
	 * @dataProvider provideGetAuthenticationRequests
	 * @param string $action
	 * @param array $response
	 */
	public function testGetAuthenticationRequests( $action, $response ) {
		$provider = new CheckBlocksSecondaryAuthenticationProvider();

		$this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) );
	}

	public static function provideGetAuthenticationRequests() {
		return [
			[ AuthManager::ACTION_LOGIN, [] ],
			[ AuthManager::ACTION_CREATE, [] ],
			[ AuthManager::ACTION_LINK, [] ],
			[ AuthManager::ACTION_CHANGE, [] ],
			[ AuthManager::ACTION_REMOVE, [] ],
		];
	}

	/**
	 * @param array $blockOptions Options for DatabaseBlock
	 * @return User
	 */
	private function getBlockedUser( array $blockOptions ): User {
		$user = $this->getMutableTestUser()->getUser();
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$block = new DatabaseBlock( $blockOptions + [
			'address' => $user,
			'by' => $this->getTestSysop()->getUser(),
			'reason' => __METHOD__,
			'expiry' => time() + 100500,
		] );
		$blockStore->insertBlock( $block );
		if ( $block->getType() === DatabaseBlock::TYPE_IP ) {
			// When an ip is blocked, the provided user object needs to know the ip
			// That allows BlockManager::getUserBlock to load the ip block for this user
			$request = $this->getMockBuilder( FauxRequest::class )
				->onlyMethods( [ 'getIP' ] )->getMock();
			$request->method( 'getIP' )
				->willReturn( $blockOptions['address'] );
			// The global request is used by User::getRequest
			RequestContext::getMain()->setRequest( $request );
			// The ip from request is only used for the global user
			RequestContext::getMain()->setUser( $user );
		}

		return $user;
	}

	/**
	 * @param array $blockOptions Options for DatabaseBlock
	 * @return User
	 */
	private function getIpBlockedUser( array $blockOptions ) {
		static $ip = 10;
		return $this->getBlockedUser( [
			'address' => '10.10.10.' . $ip++,
		] + $blockOptions );
	}

	/**
	 * @param array $blockOptions Options for DatabaseBlock
	 * @return User
	 */
	private function getGloballyIpBlockedUser( array $blockOptions ) {
		static $ip = 100;
		$user = $this->getMutableTestUser()->getUser();
		TestingAccessWrapper::newFromObject( $user )->mGlobalBlock = new DatabaseBlock( $blockOptions + [
			'address' => '10.10.10.' . $ip++,
			'by' => $this->getTestSysop()->getUser(),
			'reason' => __METHOD__,
			'expiry' => time() + 100500,
		] );
		return $user;
	}

	/**
	 * @param string $blockType One of 'user', 'ip', 'global-ip', 'none'
	 * @param array $blockOptions Options for DatabaseBlock
	 * @return User
	 */
	private function getAnyBlockedUser( string $blockType, array $blockOptions = [] ) {
		if ( $blockType === 'user' ) {
			$user = $this->getBlockedUser( $blockOptions );
		} elseif ( $blockType === 'ip' ) {
			$user = $this->getIpBlockedUser( $blockOptions );
		} elseif ( $blockType === 'global-ip' ) {
			$user = $this->getGloballyIpBlockedUser( $blockOptions );
		} elseif ( $blockType === 'none' ) {
			$user = $this->getTestUser()->getUser();
		} else {
			$this->fail( 'Invalid block type' );
		}
		return $user;
	}

	/**
	 * @dataProvider provideBeginSecondaryAuthentication
	 */
	public function testBeginSecondaryAuthentication(
		string $blockType,
		array $blockOptions,
		bool $blockDisablesLogin,
		string $expectedResponseStatus
	) {
		/** @var AuthManager|MockObject $authManager */
		$authManager = $this->createNoOpMock( AuthManager::class );
		$provider = new CheckBlocksSecondaryAuthenticationProvider(
			[ 'blockDisablesLogin' => $blockDisablesLogin ]
		);
		$this->initProvider( $provider, new HashConfig(), null, $authManager );

		$user = $this->getAnyBlockedUser( $blockType, $blockOptions );

		$response = $provider->beginSecondaryAuthentication( $user, [] );
		$this->assertEquals( $expectedResponseStatus, $response->status );
	}

	public static function provideBeginSecondaryAuthentication() {
		// Only fail authentication when $wgBlockDisablesLogin is set, the block is not partial,
		// and not an IP block. Global blocks could in theory go either way, but GlobalBlocking
		// extension blocks are always IP blocks so we mock them as such.
		return [
			// block type (user/ip/global/none), block options, wgBlockDisablesLogin, expected response status
			'block does not disable login' => [ 'user', [], false, AuthenticationResponse::ABSTAIN ],
			'not blocked' => [ 'none', [], true, AuthenticationResponse::PASS ],
			'partial block' => [ 'user', [ 'sitewide' => false ], true, AuthenticationResponse::PASS ],
			'ip block' => [ 'ip', [], true, AuthenticationResponse::PASS ],
			'block' => [ 'user', [], true, AuthenticationResponse::FAIL ],
			'global block' => [ 'global-ip', [], true, AuthenticationResponse::PASS ],
		];
	}

}
PK       ! ;h4  4  "  auth/AuthenticationRequestTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Auth\PasswordAuthenticationRequest;
use MediaWiki\Message\Message;
use MediaWikiIntegrationTestCase;
use UnexpectedValueException;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\AuthenticationRequest
 */
class AuthenticationRequestTest extends MediaWikiIntegrationTestCase {
	public function testBasics() {
		$mock = $this->getMockForAbstractClass( AuthenticationRequest::class );

		$this->assertSame( get_class( $mock ), $mock->getUniqueId() );

		$this->assertIsArray( $mock->getMetadata() );

		$ret = $mock->describeCredentials();
		$this->assertIsArray( $ret );
		$this->assertArrayHasKey( 'provider', $ret );
		$this->assertInstanceOf( Message::class, $ret['provider'] );
		$this->assertArrayHasKey( 'account', $ret );
		$this->assertInstanceOf( Message::class, $ret['account'] );
	}

	public function testLoadRequestsFromSubmission() {
		$mb = $this->getMockBuilder( AuthenticationRequest::class )
			->onlyMethods( [ 'loadFromSubmission' ] );

		$data = [ 'foo', 'bar' ];

		$req1 = $mb->getMockForAbstractClass();
		$req1->expects( $this->once() )->method( 'loadFromSubmission' )
			->with( $this->identicalTo( $data ) )
			->willReturn( false );

		$req2 = $mb->getMockForAbstractClass();
		$req2->expects( $this->once() )->method( 'loadFromSubmission' )
			->with( $this->identicalTo( $data ) )
			->willReturn( true );

		$this->assertSame(
			[ $req2 ],
			AuthenticationRequest::loadRequestsFromSubmission( [ $req1, $req2 ], $data )
		);
	}

	public function testGetRequestByClass() {
		$mb = $this->getMockBuilder(
			AuthenticationRequest::class, 'AuthenticationRequestTest_AuthenticationRequest2'
		);

		$reqs = [
			$this->getMockForAbstractClass(
				AuthenticationRequest::class, [], 'AuthenticationRequestTest_AuthenticationRequest1'
			),
			$mb->getMockForAbstractClass(),
			$mb->getMockForAbstractClass(),
			$this->getMockForAbstractClass(
				PasswordAuthenticationRequest::class, [],
				'AuthenticationRequestTest_PasswordAuthenticationRequest'
			),
		];

		$this->assertNull( AuthenticationRequest::getRequestByClass(
			$reqs, 'AuthenticationRequestTest_AuthenticationRequest0'
		) );
		$this->assertSame( $reqs[0], AuthenticationRequest::getRequestByClass(
			$reqs, 'AuthenticationRequestTest_AuthenticationRequest1'
		) );
		$this->assertNull( AuthenticationRequest::getRequestByClass(
			$reqs, 'AuthenticationRequestTest_AuthenticationRequest2'
		) );
		$this->assertNull( AuthenticationRequest::getRequestByClass(
			$reqs, PasswordAuthenticationRequest::class
		) );
		$this->assertNull( AuthenticationRequest::getRequestByClass(
			$reqs, 'ClassThatDoesNotExist'
		) );

		$this->assertNull( AuthenticationRequest::getRequestByClass(
			$reqs, 'AuthenticationRequestTest_AuthenticationRequest0', true
		) );
		$this->assertSame( $reqs[0], AuthenticationRequest::getRequestByClass(
			$reqs, 'AuthenticationRequestTest_AuthenticationRequest1', true
		) );
		$this->assertNull( AuthenticationRequest::getRequestByClass(
			$reqs, 'AuthenticationRequestTest_AuthenticationRequest2', true
		) );
		$this->assertSame( $reqs[3], AuthenticationRequest::getRequestByClass(
			$reqs, PasswordAuthenticationRequest::class, true
		) );
		$this->assertNull( AuthenticationRequest::getRequestByClass(
			$reqs, 'ClassThatDoesNotExist', true
		) );
	}

	public function testGetUsernameFromRequests() {
		$mb = $this->getMockBuilder( AuthenticationRequest::class );

		for ( $i = 0; $i < 3; $i++ ) {
			$req = $mb->getMockForAbstractClass();
			$req->method( 'getFieldInfo' )->willReturn( [
				'username' => [
					'type' => 'string',
				],
			] );
			$reqs[] = $req;
		}

		$req = $mb->getMockForAbstractClass();
		$req->method( 'getFieldInfo' )->willReturn( [] );
		$req->username = 'baz';
		$reqs[] = $req;

		$this->assertNull( AuthenticationRequest::getUsernameFromRequests( $reqs ) );

		$reqs[1]->username = 'foo';
		$this->assertSame( 'foo', AuthenticationRequest::getUsernameFromRequests( $reqs ) );

		$reqs[0]->username = 'foo';
		$reqs[2]->username = 'foo';
		$this->assertSame( 'foo', AuthenticationRequest::getUsernameFromRequests( $reqs ) );

		$reqs[1]->username = 'bar';
		try {
			AuthenticationRequest::getUsernameFromRequests( $reqs );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame(
				'Conflicting username fields: "bar" from ' .
					get_class( $reqs[1] ) . '::$username vs. "foo" from ' .
					get_class( $reqs[0] ) . '::$username',
				$ex->getMessage()
			);
		}
	}

	public function testMergeFieldInfo() {
		$msg = wfMessage( 'foo' );

		$req1 = $this->createMock( AuthenticationRequest::class );
		$req1->required = AuthenticationRequest::REQUIRED;
		$req1->method( 'getFieldInfo' )->willReturn( [
			'string1' => [
				'type' => 'string',
				'label' => $msg,
				'help' => $msg,
			],
			'string2' => [
				'type' => 'string',
				'label' => $msg,
				'help' => $msg,
			],
			'optional' => [
				'type' => 'string',
				'label' => $msg,
				'help' => $msg,
				'optional' => true,
			],
			'select' => [
				'type' => 'select',
				'options' => [ 'foo' => $msg, 'baz' => $msg ],
				'label' => $msg,
				'help' => $msg,
			],
		] );

		$req2 = $this->createMock( AuthenticationRequest::class );
		$req2->required = AuthenticationRequest::REQUIRED;
		$req2->method( 'getFieldInfo' )->willReturn( [
			'string1' => [
				'type' => 'string',
				'label' => $msg,
				'help' => $msg,
				'sensitive' => true,
			],
			'string3' => [
				'type' => 'string',
				'label' => $msg,
				'help' => $msg,
			],
			'select' => [
				'type' => 'select',
				'options' => [ 'bar' => $msg, 'baz' => $msg ],
				'label' => $msg,
				'help' => $msg,
			],
		] );

		$req3 = $this->createMock( AuthenticationRequest::class );
		$req3->required = AuthenticationRequest::REQUIRED;
		$req3->method( 'getFieldInfo' )->willReturn( [
			'string1' => [
				'type' => 'checkbox',
				'label' => $msg,
				'help' => $msg,
			],
		] );

		$req4 = $this->createMock( AuthenticationRequest::class );
		$req4->required = AuthenticationRequest::REQUIRED;
		$req4->method( 'getFieldInfo' )->willReturn( [] );

		// Basic combining

		$this->assertEquals( [], AuthenticationRequest::mergeFieldInfo( [] ) );

		$fields = AuthenticationRequest::mergeFieldInfo( [ $req1 ] );
		$expect = $req1->getFieldInfo();
		foreach ( $expect as &$options ) {
			$options['optional'] = !empty( $options['optional'] );
			$options['sensitive'] = !empty( $options['sensitive'] );
		}
		unset( $options );
		$this->assertEquals( $expect, $fields );

		$fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req4 ] );
		$this->assertEquals( $expect, $fields );

		try {
			AuthenticationRequest::mergeFieldInfo( [ $req1, $req3 ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame(
				'Field type conflict for "string1", "string" vs "checkbox"',
				$ex->getMessage()
			);
		}

		$fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] );
		$expect += $req2->getFieldInfo();
		$expect['string1']['sensitive'] = true;
		$expect['string2']['optional'] = false;
		$expect['string3']['optional'] = false;
		$expect['string3']['sensitive'] = false;
		$expect['select']['options']['bar'] = $msg;
		$this->assertEquals( $expect, $fields );

		// Combining with something not required

		$req1->required = AuthenticationRequest::PRIMARY_REQUIRED;

		$fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] );
		$expect += $req2->getFieldInfo();
		$expect['string1']['optional'] = false;
		$expect['string1']['sensitive'] = true;
		$expect['string3']['optional'] = false;
		$expect['select']['optional'] = false;
		$expect['select']['options']['bar'] = $msg;
		$this->assertEquals( $expect, $fields );

		$req2->required = AuthenticationRequest::PRIMARY_REQUIRED;

		$fields = AuthenticationRequest::mergeFieldInfo( [ $req1, $req2 ] );
		$expect = $req1->getFieldInfo() + $req2->getFieldInfo();
		foreach ( $expect as &$options ) {
			$options['sensitive'] = !empty( $options['sensitive'] );
		}
		$expect['string1']['optional'] = false;
		$expect['string1']['sensitive'] = true;
		$expect['string2']['optional'] = true;
		$expect['string3']['optional'] = true;
		$expect['select']['optional'] = false;
		$expect['select']['options']['bar'] = $msg;
		$this->assertEquals( $expect, $fields );
	}

	/**
	 * @dataProvider provideLoadFromSubmission
	 * @param array $fieldInfo
	 * @param array $data
	 * @param array|bool $expectState
	 */
	public function testLoadFromSubmission( $fieldInfo, $data, $expectState ) {
		$mock = $this->getMockForAbstractClass( AuthenticationRequestForLoadFromSubmission::class );
		$mock->method( 'getFieldInfo' )
			->willReturn( $fieldInfo );

		$ret = $mock->loadFromSubmission( $data );
		if ( is_array( $expectState ) ) {
			$this->assertTrue( $ret );
			$expect = $mock::__set_state( $expectState );
			$this->assertEquals( $expect, $mock );
		} else {
			$this->assertFalse( $ret );
		}
	}

	public static function provideLoadFromSubmission() {
		return [
			'No fields' => [
				[],
				$data = [ 'foo' => 'bar' ],
				false
			],

			'Simple field' => [
				[
					'field' => [
						'type' => 'string',
					],
				],
				$data = [ 'field' => 'string!' ],
				$data
			],
			'Simple field, not supplied' => [
				[
					'field' => [
						'type' => 'string',
					],
				],
				[],
				false
			],
			'Simple field, empty' => [
				[
					'field' => [
						'type' => 'string',
					],
				],
				[ 'field' => '' ],
				false
			],
			'Simple field, optional, not supplied' => [
				[
					'field' => [
						'type' => 'string',
						'optional' => true,
					],
				],
				[],
				false
			],
			'Simple field, optional, empty' => [
				[
					'field' => [
						'type' => 'string',
						'optional' => true,
					],
				],
				$data = [ 'field' => '' ],
				$data
			],

			'Checkbox, checked' => [
				[
					'check' => [
						'type' => 'checkbox',
					],
				],
				[ 'check' => '' ],
				[ 'check' => true ]
			],
			'Checkbox, unchecked' => [
				[
					'check' => [
						'type' => 'checkbox',
					],
				],
				[],
				false
			],
			'Checkbox, optional, unchecked' => [
				[
					'check' => [
						'type' => 'checkbox',
						'optional' => true,
					],
				],
				[],
				[ 'check' => false ]
			],

			'Button, used' => [
				[
					'push' => [
						'type' => 'button',
					],
				],
				[ 'push' => '' ],
				[ 'push' => true ]
			],
			'Button, unused' => [
				[
					'push' => [
						'type' => 'button',
					],
				],
				[],
				false
			],
			'Button, optional, unused' => [
				[
					'push' => [
						'type' => 'button',
						'optional' => true,
					],
				],
				[],
				[ 'push' => false ]
			],
			'Button, image-style' => [
				[
					'push' => [
						'type' => 'button',
					],
				],
				[ 'push_x' => 0, 'push_y' => 0 ],
				[ 'push' => true ]
			],

			'Select' => [
				[
					'choose' => [
						'type' => 'select',
						'options' => [
							'foo' => wfMessage( 'mainpage' ),
							'bar' => wfMessage( 'mainpage' ),
						],
					],
				],
				$data = [ 'choose' => 'foo' ],
				$data
			],
			'Select, invalid choice' => [
				[
					'choose' => [
						'type' => 'select',
						'options' => [
							'foo' => wfMessage( 'mainpage' ),
							'bar' => wfMessage( 'mainpage' ),
						],
					],
				],
				$data = [ 'choose' => 'baz' ],
				false
			],
			'Multiselect (2)' => [
				[
					'choose' => [
						'type' => 'multiselect',
						'options' => [
							'foo' => wfMessage( 'mainpage' ),
							'bar' => wfMessage( 'mainpage' ),
						],
					],
				],
				$data = [ 'choose' => [ 'foo', 'bar' ] ],
				$data
			],
			'Multiselect (1)' => [
				[
					'choose' => [
						'type' => 'multiselect',
						'options' => [
							'foo' => wfMessage( 'mainpage' ),
							'bar' => wfMessage( 'mainpage' ),
						],
					],
				],
				$data = [ 'choose' => [ 'bar' ] ],
				$data
			],
			'Multiselect, string for some reason' => [
				[
					'choose' => [
						'type' => 'multiselect',
						'options' => [
							'foo' => wfMessage( 'mainpage' ),
							'bar' => wfMessage( 'mainpage' ),
						],
					],
				],
				[ 'choose' => 'foo' ],
				[ 'choose' => [ 'foo' ] ]
			],
			'Multiselect, invalid choice' => [
				[
					'choose' => [
						'type' => 'multiselect',
						'options' => [
							'foo' => wfMessage( 'mainpage' ),
							'bar' => wfMessage( 'mainpage' ),
						],
					],
				],
				[ 'choose' => [ 'foo', 'baz' ] ],
				false
			],
			'Multiselect, empty' => [
				[
					'choose' => [
						'type' => 'multiselect',
						'options' => [
							'foo' => wfMessage( 'mainpage' ),
							'bar' => wfMessage( 'mainpage' ),
						],
					],
				],
				[ 'choose' => [] ],
				false
			],
			'Multiselect, optional, nothing submitted' => [
				[
					'choose' => [
						'type' => 'multiselect',
						'options' => [
							'foo' => wfMessage( 'mainpage' ),
							'bar' => wfMessage( 'mainpage' ),
						],
						'optional' => true,
					],
				],
				[],
				[ 'choose' => [] ]
			],
		];
	}
}

// Dynamic properties from the testLoadFromSubmission not working in php8.2
abstract class AuthenticationRequestForLoadFromSubmission extends AuthenticationRequest {
	/** @var array */
	public $choose;
	/** @var bool */
	public $push;
	/** @var bool */
	public $check;
	/** @var string */
	public $field;
}
PK       ! U%  %  *  auth/UserDataAuthenticationRequestTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\UserDataAuthenticationRequest;
use MediaWiki\MainConfigNames;
use MediaWiki\User\User;
use StatusValue;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\UserDataAuthenticationRequest
 */
class UserDataAuthenticationRequestTest extends AuthenticationRequestTestCase {

	protected function getInstance( array $args = [] ) {
		return new UserDataAuthenticationRequest;
	}

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValue( MainConfigNames::HiddenPrefs, [] );
	}

	/**
	 * @dataProvider providePopulateUser
	 * @param string $email Email to set
	 * @param string $realname Realname to set
	 * @param StatusValue $expect Expected return
	 */
	public function testPopulateUser( $email, $realname, $expect ) {
		$this->clearHooks( [
			'UserGetEmail',
			'UserSetEmailAuthenticationTimestamp',
			'InvalidateEmailComplete',
			'UserSetEmail',
		] );

		$user = new User();
		$user->setEmail( 'default@example.com' );
		$user->setRealName( 'Fake Name' );

		$req = new UserDataAuthenticationRequest;
		$req->email = $email;
		$req->realname = $realname;
		$this->assertEquals( $expect, $req->populateUser( $user ) );
		if ( $expect->isOK() ) {
			$this->assertSame( $email ?: 'default@example.com', $user->getEmail() );
			$this->assertSame( $realname ?: 'Fake Name', $user->getRealName() );
		}
	}

	public static function providePopulateUser() {
		$good = StatusValue::newGood();
		return [
			[ 'email@example.com', 'Real Name', $good ],
			[ 'email@example.com', '', $good ],
			[ '', 'Real Name', $good ],
			[ '', '', $good ],
			[ 'invalid-email', 'Real Name', StatusValue::newFatal( 'invalidemailaddress' ) ],
		];
	}

	/**
	 * @dataProvider provideLoadFromSubmission
	 */
	public function testLoadFromSubmission(
		array $args, array $data, $expectState, $hiddenPref = null, $enableEmail = null
	) {
		$this->overrideConfigValues( [
			MainConfigNames::HiddenPrefs => $hiddenPref,
			MainConfigNames::EnableEmail => $enableEmail,
		] );
		parent::testLoadFromSubmission( $args, $data, $expectState );
	}

	public static function provideLoadFromSubmission() {
		$unhidden = [];
		$hidden = [ 'realname' ];

		return [
			'Empty request, unhidden, email enabled' => [
				[],
				[],
				false,
				$unhidden,
				true
			],
			'email + realname, unhidden, email enabled' => [
				[],
				$data = [ 'email' => 'Email', 'realname' => 'Name' ],
				$data,
				$unhidden,
				true
			],
			'email empty, unhidden, email enabled' => [
				[],
				$data = [ 'email' => '', 'realname' => 'Name' ],
				$data,
				$unhidden,
				true
			],
			'email omitted, unhidden, email enabled' => [
				[],
				[ 'realname' => 'Name' ],
				false,
				$unhidden,
				true
			],
			'realname empty, unhidden, email enabled' => [
				[],
				$data = [ 'email' => 'Email', 'realname' => '' ],
				$data,
				$unhidden,
				true
			],
			'realname omitted, unhidden, email enabled' => [
				[],
				[ 'email' => 'Email' ],
				false,
				$unhidden,
				true
			],
			'Empty request, hidden, email enabled' => [
				[],
				[],
				false,
				$hidden,
				true
			],
			'email + realname, hidden, email enabled' => [
				[],
				[ 'email' => 'Email', 'realname' => 'Name' ],
				[ 'email' => 'Email' ],
				$hidden,
				true
			],
			'email empty, hidden, email enabled' => [
				[],
				$data = [ 'email' => '', 'realname' => 'Name' ],
				[ 'email' => '' ],
				$hidden,
				true
			],
			'email omitted, hidden, email enabled' => [
				[],
				[ 'realname' => 'Name' ],
				false,
				$hidden,
				true
			],
			'realname empty, hidden, email enabled' => [
				[],
				$data = [ 'email' => 'Email', 'realname' => '' ],
				[ 'email' => 'Email' ],
				$hidden,
				true
			],
			'realname omitted, hidden, email enabled' => [
				[],
				[ 'email' => 'Email' ],
				[ 'email' => 'Email' ],
				$hidden,
				true
			],
			'email + realname, unhidden, email disabled' => [
				[],
				[ 'email' => 'Email', 'realname' => 'Name' ],
				[ 'realname' => 'Name' ],
				$unhidden,
				false
			],
			'email omitted, unhidden, email disabled' => [
				[],
				[ 'realname' => 'Name' ],
				[ 'realname' => 'Name' ],
				$unhidden,
				false
			],
			'email empty, unhidden, email disabled' => [
				[],
				[ 'email' => '', 'realname' => 'Name' ],
				[ 'realname' => 'Name' ],
				$unhidden,
				false
			],
		];
	}
}
PK       ! Z_?    0  auth/PasswordDomainAuthenticationRequestTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\PasswordDomainAuthenticationRequest;
use MediaWiki\Message\Message;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\PasswordDomainAuthenticationRequest
 */
class PasswordDomainAuthenticationRequestTest extends AuthenticationRequestTestCase {

	protected function getInstance( array $args = [] ) {
		$ret = new PasswordDomainAuthenticationRequest( [ 'd1', 'd2' ] );
		$ret->action = $args[0];
		return $ret;
	}

	public static function provideGetFieldInfo() {
		return [
			[ [ AuthManager::ACTION_LOGIN ] ],
			[ [ AuthManager::ACTION_CREATE ] ],
			[ [ AuthManager::ACTION_CHANGE ] ],
			[ [ AuthManager::ACTION_REMOVE ] ],
		];
	}

	public function testGetFieldInfo2() {
		$info = [];
		foreach ( [
			AuthManager::ACTION_LOGIN,
			AuthManager::ACTION_CREATE,
			AuthManager::ACTION_CHANGE,
			AuthManager::ACTION_REMOVE,
		] as $action ) {
			$req = new PasswordDomainAuthenticationRequest( [ 'd1', 'd2' ] );
			$req->action = $action;
			$info[$action] = $req->getFieldInfo();
		}

		$this->assertSame( [], $info[AuthManager::ACTION_REMOVE], 'No data needed to remove' );

		$this->assertArrayNotHasKey( 'retype', $info[AuthManager::ACTION_LOGIN],
			'No need to retype password on login' );
		$this->assertArrayHasKey( 'domain', $info[AuthManager::ACTION_LOGIN],
			'Domain needed on login' );
		$this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CREATE],
			'Need to retype when creating new password' );
		$this->assertArrayHasKey( 'domain', $info[AuthManager::ACTION_CREATE],
			'Domain needed on account creation' );
		$this->assertArrayHasKey( 'retype', $info[AuthManager::ACTION_CHANGE],
			'Need to retype when changing password' );
		$this->assertArrayNotHasKey( 'domain', $info[AuthManager::ACTION_CHANGE],
			'Domain not needed on account creation' );

		$this->assertNotEquals(
			$info[AuthManager::ACTION_LOGIN]['password']['label'],
			$info[AuthManager::ACTION_CHANGE]['password']['label'],
			'Password field for change is differentiated from login'
		);
		$this->assertNotEquals(
			$info[AuthManager::ACTION_CREATE]['password']['label'],
			$info[AuthManager::ACTION_CHANGE]['password']['label'],
			'Password field for change is differentiated from create'
		);
		$this->assertNotEquals(
			$info[AuthManager::ACTION_CREATE]['retype']['label'],
			$info[AuthManager::ACTION_CHANGE]['retype']['label'],
			'Retype field for change is differentiated from create'
		);
	}

	public static function provideLoadFromSubmission() {
		$domainList = [ 'domainList' => [ 'd1', 'd2' ] ];
		return [
			'Empty request, login' => [
				[ AuthManager::ACTION_LOGIN ],
				[],
				false,
			],
			'Empty request, change' => [
				[ AuthManager::ACTION_CHANGE ],
				[],
				false,
			],
			'Empty request, remove' => [
				[ AuthManager::ACTION_REMOVE ],
				[],
				false,
			],
			'Username + password, login' => [
				[ AuthManager::ACTION_LOGIN ],
				$data = [ 'username' => 'User', 'password' => 'Bar' ],
				false,
			],
			'Username + password + domain, login' => [
				[ AuthManager::ACTION_LOGIN ],
				$data = [ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd1' ],
				$data + [ 'action' => AuthManager::ACTION_LOGIN ] + $domainList,
			],
			'Username + password + bad domain, login' => [
				[ AuthManager::ACTION_LOGIN ],
				$data = [ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd5' ],
				false,
			],
			'Username + password + domain, change' => [
				[ AuthManager::ACTION_CHANGE ],
				[ 'username' => 'User', 'password' => 'Bar', 'domain' => 'd1' ],
				false,
			],
			'Username + password + domain + retype' => [
				[ AuthManager::ACTION_CHANGE ],
				[ 'username' => 'User', 'password' => 'Bar', 'retype' => 'baz', 'domain' => 'd1' ],
				[ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ] +
					$domainList,
			],
			'Username empty, login' => [
				[ AuthManager::ACTION_LOGIN ],
				[ 'username' => '', 'password' => 'Bar', 'domain' => 'd1' ],
				false,
			],
			'Username empty, change' => [
				[ AuthManager::ACTION_CHANGE ],
				[ 'username' => '', 'password' => 'Bar', 'retype' => 'baz', 'domain' => 'd1' ],
				[ 'password' => 'Bar', 'retype' => 'baz', 'action' => AuthManager::ACTION_CHANGE ] +
					$domainList,
			],
			'Password empty, login' => [
				[ AuthManager::ACTION_LOGIN ],
				[ 'username' => 'User', 'password' => '', 'domain' => 'd1' ],
				false,
			],
			'Password empty, login, with retype' => [
				[ AuthManager::ACTION_LOGIN ],
				[ 'username' => 'User', 'password' => '', 'retype' => 'baz', 'domain' => 'd1' ],
				false,
			],
			'Retype empty' => [
				[ AuthManager::ACTION_CHANGE ],
				[ 'username' => 'User', 'password' => 'Bar', 'retype' => '', 'domain' => 'd1' ],
				false,
			],
		];
	}

	public function testDescribeCredentials() {
		$username = 'TestDescribeCredentials';
		$req = new PasswordDomainAuthenticationRequest( [ 'd1', 'd2' ] );
		$req->action = AuthManager::ACTION_LOGIN;
		$req->username = $username;
		$req->domain = 'd2';
		$ret = $req->describeCredentials();
		$this->assertIsArray( $ret );
		$this->assertArrayHasKey( 'provider', $ret );
		$this->assertInstanceOf( Message::class, $ret['provider'] );
		$this->assertSame( 'authmanager-provider-password-domain', $ret['provider']->getKey() );
		$this->assertArrayHasKey( 'account', $ret );
		$this->assertInstanceOf( Message::class, $ret['account'] );
		$this->assertSame( 'authmanager-account-password-domain', $ret['account']->getKey() );
		$this->assertSame( [ $username, 'd2' ], $ret['account']->getParams() );
	}
}
PK       ! 6o#  o#  :  auth/AbstractPasswordPrimaryAuthenticationProviderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\AbstractPasswordPrimaryAuthenticationProvider;
use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\PasswordAuthenticationRequest;
use MediaWiki\Config\HashConfig;
use MediaWiki\Config\MultiConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Password\Password;
use MediaWiki\Password\PasswordFactory;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Unit\Auth\AuthenticationProviderTestTrait;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use MediaWikiIntegrationTestCase;
use Wikimedia\TestingAccessWrapper;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\AbstractPasswordPrimaryAuthenticationProvider
 */
class AbstractPasswordPrimaryAuthenticationProviderTest extends MediaWikiIntegrationTestCase {
	use AuthenticationProviderTestTrait;

	public function testConstructor() {
		$provider = $this->getMockForAbstractClass(
			AbstractPasswordPrimaryAuthenticationProvider::class
		);
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$this->assertTrue( $providerPriv->authoritative );

		$provider = $this->getMockForAbstractClass(
			AbstractPasswordPrimaryAuthenticationProvider::class,
			[ [ 'authoritative' => false ] ]
		);
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$this->assertFalse( $providerPriv->authoritative );
	}

	public function testGetPasswordFactory() {
		$provider = $this->getMockForAbstractClass(
			AbstractPasswordPrimaryAuthenticationProvider::class
		);
		$this->initProvider( $provider, $this->getServiceContainer()->getMainConfig() );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );

		$obj = $providerPriv->getPasswordFactory();
		$this->assertInstanceOf( PasswordFactory::class, $obj );
		$this->assertSame( $obj, $providerPriv->getPasswordFactory() );
	}

	public function testGetPassword() {
		$provider = $this->getMockForAbstractClass(
			AbstractPasswordPrimaryAuthenticationProvider::class
		);
		$this->initProvider( $provider, $this->getServiceContainer()->getMainConfig() );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );

		$obj = $providerPriv->getPassword( null );
		$this->assertInstanceOf( Password::class, $obj );

		$obj = $providerPriv->getPassword( 'invalid' );
		$this->assertInstanceOf( Password::class, $obj );
	}

	public function testGetNewPasswordExpiry() {
		$userName = 'TestGetNewPasswordExpiry';
		$config = new HashConfig;
		$provider = $this->getMockForAbstractClass(
			AbstractPasswordPrimaryAuthenticationProvider::class
		);
		$this->initProvider( $provider, new MultiConfig( [ $config, $this->getServiceContainer()->getMainConfig() ] ) );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );

		$config->set( MainConfigNames::PasswordExpirationDays, 0 );
		$this->assertNull( $providerPriv->getNewPasswordExpiry( $userName ) );

		$config->set( MainConfigNames::PasswordExpirationDays, 5 );
		$this->assertEqualsWithDelta(
			time() + 5 * 86400,
			wfTimestamp( TS_UNIX, $providerPriv->getNewPasswordExpiry( $userName ) ),
			2 /* Fuzz */
		);

		$this->initProvider(
			$provider,
			new MultiConfig( [ $config, $this->getServiceContainer()->getMainConfig() ] ),
			null,
			null,
			$this->createHookContainer( [
				'ResetPasswordExpiration' => function ( $user, &$expires ) use ( $userName ) {
					$this->assertSame( $userName, $user->getName() );
					$expires = '30001231235959';
				}
			] )
		);
		$this->assertSame( '30001231235959', $providerPriv->getNewPasswordExpiry( $userName ) );
	}

	public function testCheckPasswordValidity() {
		$uppCalled = 0;
		$uppStatus = Status::newGood( [] );
		$this->overrideConfigValue(
			MainConfigNames::PasswordPolicy,
			[
				'policies' => [
					'default' => [
						'Check' => true,
					],
				],
				'checks' => [
					'Check' => static function () use ( &$uppCalled, &$uppStatus ) {
						$uppCalled++;
						return $uppStatus;
					},
				],
			]
		);
		$this->clearHook( 'PasswordPoliciesForUser' );

		$provider = $this->getMockForAbstractClass(
			AbstractPasswordPrimaryAuthenticationProvider::class
		);
		$this->initProvider( $provider, $this->getServiceContainer()->getMainConfig() );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );

		$username = '127.0.0.1';
		$anon = new User();
		$anon->setName( $username );
		$userFactory = $this->createMock( UserFactory::class );
		$userFactory->method( 'newFromName' )->with( $username )->willReturn( $anon );
		$this->setService( 'UserFactory', $userFactory );

		$this->assertEquals( $uppStatus, $providerPriv->checkPasswordValidity( $username, 'bar' ) );

		$uppStatus->fatal( 'arbitrary-warning' );
		$this->assertEquals( $uppStatus, $providerPriv->checkPasswordValidity( $username, 'bar' ) );
	}

	public function testSetPasswordResetFlag() {
		$config = new HashConfig( [
			MainConfigNames::InvalidPasswordReset => true,
		] );

		$services = $this->getServiceContainer();
		$manager = new AuthManager(
			new FauxRequest(),
			$services->getMainConfig(),
			$services->getObjectFactory(),
			$services->getHookContainer(),
			$services->getReadOnlyMode(),
			$services->getUserNameUtils(),
			$services->getBlockManager(),
			$services->getWatchlistManager(),
			$services->getDBLoadBalancer(),
			$services->getContentLanguage(),
			$services->getLanguageConverterFactory(),
			$services->getBotPasswordStore(),
			$services->getUserFactory(),
			$services->getUserIdentityLookup(),
			$services->getUserOptionsManager()
		);

		$provider = $this->getMockForAbstractClass(
			AbstractPasswordPrimaryAuthenticationProvider::class
		);
		$this->initProvider( $provider, $config, null, $manager, $services->getHookContainer() );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );

		$manager->removeAuthenticationSessionData( null );
		$status = Status::newGood();
		$providerPriv->setPasswordResetFlag( 'Foo', $status );
		$this->assertNull( $manager->getAuthenticationSessionData( 'reset-pass' ) );

		$manager->removeAuthenticationSessionData( null );
		$status = Status::newGood( [ 'suggestChangeOnLogin' => true ] );
		$status->error( 'testing' );
		$providerPriv->setPasswordResetFlag( 'Foo', $status );
		$ret = $manager->getAuthenticationSessionData( 'reset-pass' );
		$this->assertNotNull( $ret );
		$this->assertSame( 'resetpass-validity-soft', $ret->msg->getKey() );
		$this->assertFalse( $ret->hard );

		$config->set( MainConfigNames::InvalidPasswordReset, false );
		$manager->removeAuthenticationSessionData( null );
		$providerPriv->setPasswordResetFlag( 'Foo', $status );
		$ret = $manager->getAuthenticationSessionData( 'reset-pass' );
		$this->assertNull( $ret );
	}

	public function testFailResponse() {
		$provider = $this->getMockForAbstractClass(
			AbstractPasswordPrimaryAuthenticationProvider::class,
			[ [ 'authoritative' => false ] ]
		);
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );

		$req = new PasswordAuthenticationRequest;

		$ret = $providerPriv->failResponse( $req );
		$this->assertSame( AuthenticationResponse::ABSTAIN, $ret->status );

		$provider = $this->getMockForAbstractClass(
			AbstractPasswordPrimaryAuthenticationProvider::class,
			[ [ 'authoritative' => true ] ]
		);
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );

		$req->password = '';
		$ret = $providerPriv->failResponse( $req );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'wrongpasswordempty', $ret->message->getKey() );

		$req->password = 'X';
		$ret = $providerPriv->failResponse( $req );
		$this->assertSame( AuthenticationResponse::FAIL, $ret->status );
		$this->assertSame( 'wrongpassword', $ret->message->getKey() );
	}

	/**
	 * @dataProvider provideGetAuthenticationRequests
	 * @param string $action
	 * @param array $response
	 */
	public function testGetAuthenticationRequests( $action, $response ) {
		$provider = $this->getMockForAbstractClass(
			AbstractPasswordPrimaryAuthenticationProvider::class
		);

		$this->assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) );
	}

	public static function provideGetAuthenticationRequests() {
		return [
			[ AuthManager::ACTION_LOGIN, [ new PasswordAuthenticationRequest() ] ],
			[ AuthManager::ACTION_CREATE, [ new PasswordAuthenticationRequest() ] ],
			[ AuthManager::ACTION_LINK, [] ],
			[ AuthManager::ACTION_CHANGE, [ new PasswordAuthenticationRequest() ] ],
			[ AuthManager::ACTION_REMOVE, [ new PasswordAuthenticationRequest() ] ],
		];
	}

	public function testProviderRevokeAccessForUser() {
		$req = new PasswordAuthenticationRequest;
		$req->action = AuthManager::ACTION_REMOVE;
		$req->username = 'foo';
		$req->password = null;

		$provider = $this->getMockForAbstractClass(
			AbstractPasswordPrimaryAuthenticationProvider::class
		);
		$provider->expects( $this->once() )
			->method( 'providerChangeAuthenticationData' )
			->with( $req );

		$provider->providerRevokeAccessForUser( 'foo' );
	}

}
PK       ! `    1  auth/CreateFromLoginAuthenticationRequestTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Auth;

use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\CreateFromLoginAuthenticationRequest;
use MediaWiki\Auth\UsernameAuthenticationRequest;

/**
 * @group AuthManager
 * @covers \MediaWiki\Auth\CreateFromLoginAuthenticationRequest
 */
class CreateFromLoginAuthenticationRequestTest extends AuthenticationRequestTestCase {

	protected function getInstance( array $args = [] ) {
		return new CreateFromLoginAuthenticationRequest(
			null, []
		);
	}

	public static function provideLoadFromSubmission() {
		return [
			'Empty request' => [
				[],
				[],
				[],
			],
		];
	}

	/**
	 * @dataProvider provideState
	 */
	public function testState(
		$createReq, $maybeLink, $username, $loginState, $createState, $createPrimaryState
	) {
		$req = new CreateFromLoginAuthenticationRequest( $createReq, $maybeLink );
		$this->assertSame( $username, $req->username );
		$this->assertSame( $loginState, $req->hasStateForAction( AuthManager::ACTION_LOGIN ) );
		$this->assertSame( $createState, $req->hasStateForAction( AuthManager::ACTION_CREATE ) );
		$this->assertFalse( $req->hasStateForAction( AuthManager::ACTION_LINK ) );
		$this->assertFalse( $req->hasPrimaryStateForAction( AuthManager::ACTION_LOGIN ) );
		$this->assertSame( $createPrimaryState,
			$req->hasPrimaryStateForAction( AuthManager::ACTION_CREATE ) );
	}

	public static function provideState() {
		$req1 = new UsernameAuthenticationRequest;
		$req2 = new UsernameAuthenticationRequest;
		$req2->username = 'Bob';

		return [
			'Nothing' => [ null, [], null, false, false, false ],
			'Link, no create' => [ null, [ $req2 ], null, true, true, false ],
			'No link, create but no name' => [ $req1, [], null, false, true, true ],
			'Link and create but no name' => [ $req1, [ $req2 ], null, true, true, true ],
			'No link, create with name' => [ $req2, [], 'Bob', false, true, true ],
			'Link and create with name' => [ $req2, [ $req2 ], 'Bob', true, true, true ],
		];
	}
}
PK       ! ~  ~  %  specials/DeletedContribsPagerTest.phpnu Iw        <?php

use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\CommentFormatter\CommentFormatter;
use MediaWiki\Context\RequestContext;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Pager\DeletedContribsPager;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Database
 * @covers \MediaWiki\Pager\DeletedContribsPager
 */
class DeletedContribsPagerTest extends MediaWikiIntegrationTestCase {
	private static User $user;

	/** @var DeletedContribsPager */
	private $pager;

	/** @var HookContainer */
	private $hookContainer;

	/** @var LinkRenderer */
	private $linkRenderer;

	/** @var IConnectionProvider */
	private $dbProvider;

	/** @var RevisionStore */
	private $revisionStore;

	/** @var NamespaceInfo */
	private $namespaceInfo;

	/** @var CommentFormatter */
	private $commentFormatter;

	/** @var LinkBatchFactory */
	private $linkBatchFactory;

	/** @var UserFactory */
	private $userFactory;

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

		$services = $this->getServiceContainer();
		$this->hookContainer = $services->getHookContainer();
		$this->linkRenderer = $services->getLinkRenderer();
		$this->dbProvider = $services->getConnectionProvider();
		$this->revisionStore = $services->getRevisionStore();
		$this->namespaceInfo = $services->getNamespaceInfo();
		$this->commentFormatter = $services->getCommentFormatter();
		$this->linkBatchFactory = $services->getLinkBatchFactory();
		$this->userFactory = $services->getUserFactory();
		$this->pager = $this->getDeletedContribsPager();
	}

	private function getDeletedContribsPager( $target = 'Some test user', $namespace = 0 ) {
		$target = UserIdentityValue::newAnonymous( $target );

		return new DeletedContribsPager(
			$this->hookContainer,
			$this->linkRenderer,
			$this->dbProvider,
			$this->revisionStore,
			$this->namespaceInfo,
			$this->commentFormatter,
			$this->linkBatchFactory,
			$this->userFactory,
			RequestContext::getMain(),
			[ 'namespace' => $namespace ],
			$target
		);
	}

	/**
	 * Flow uses DeletedContribsPager::reallyDoQuery hook to provide something other then
	 * stdClass as a row, and then manually formats its own row in ContributionsLineEnding.
	 * Emulate this behaviour and check that it works.
	 */
	public function testDeletedContribProvidedByHook() {
		$this->setTemporaryHook( 'DeletedContribsPager::reallyDoQuery', static function ( &$data ) {
			$data = [ [ new class() {
				public $ar_timestamp = 12345;
				public $testing = 'TESTING';
				public $ar_namespace = NS_MAIN;
				public $ar_title = 'Test';
				public $ar_rev_id = null;
			} ] ];
		} );
		$this->setTemporaryHook( 'DeletedContributionsLineEnding', function ( $pager, &$ret, $row ) {
			$this->assertSame( 'TESTING', $row->testing );
			$ret .= 'FROM_HOOK!';
		} );
		$pager = $this->getDeletedContribsPager();
		$this->assertStringContainsString( 'FROM_HOOK!', $pager->getBody() );
	}

	public static function provideEmptyResultIntegration() {
		$cases = [
			[ 'target' => '', 'namespace' => '' ],
			[ 'target' => '127.0.0.1', 'namespace' => '' ],
			[ 'target' => 'UserWithNoEdits', 'namespace' => 1 ],
		];
		foreach ( $cases as $case ) {
			yield [ $case ];
		}
	}

	/**
	 * Confirm that the query is valid for various filter options.
	 *
	 * @dataProvider provideEmptyResultIntegration
	 */
	public function testEmptyResultIntegration( $options ) {
		$pager = $this->getDeletedContribsPager(
			$options['target'],
			$options['namespace'],
		);
		$this->assertIsString( $pager->getBody() );
		$this->assertSame( 0, $pager->getNumRows() );
	}

	public function testPopulatedIntegrationNoPermissions() {
		$pager = $this->getDeletedContribsPager( self::$user->getName() );

		$this->assertIsString( $pager->getBody() );
		$this->assertSame( 1, $pager->getNumRows() );
	}

	public function testPopulatedIntegrationWithPermissions() {
		$this->setGroupPermissions( [ '*' => [
			'deletedhistory' => true,
			'deletedtext' => true,
			'undelete' => true,
		] ] );

		$pager = $this->getDeletedContribsPager( self::$user->getName() );
		$this->assertIsString( $pager->getBody() );
		$this->assertSame( 2, $pager->getNumRows() );
		$this->assertStringContainsString( '>+9<', $pager->getBody() );
	}

	public function testParentRevisionSizePreloading() {
		$this->setGroupPermissions( [ '*' => [
			'deletedhistory' => true,
			'deletedtext' => true,
			'undelete' => true,
		] ] );

		$pager = $this->getDeletedContribsPager( self::$user->getName() );
		// Make sure the query leaves (at least) one row unselected
		// so that we can test loading from parent revision ids
		TestingAccessWrapper::newFromObject( $pager )->deletedOnly = true;
		$this->assertIsString( $pager->getBody() );
		$this->assertSame( 1, $pager->getNumRows() );
		$this->assertStringContainsString( '>+9<', $pager->getBody() );
	}

	public function addDBDataOnce() {
		self::$user = $this->getTestUser()->getUser();
		$title = Title::makeTitle( NS_MAIN, 'DeletedContribsPagerTest' );

		// Make two edits (one will be revdel'd)
		$this->editPage( $title, 'Test', '', NS_MAIN, self::$user );
		$status = $this->editPage( $title, 'Test content.', '', NS_MAIN, self::$user );

		// Delete the page where the edits were made
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$this->deletePage( $page );

		// Suppress the second edit
		$this->getDb()->newUpdateQueryBuilder()
			->update( 'archive' )
			->set( [
				'ar_deleted' => RevisionRecord::DELETED_USER | RevisionRecord::DELETED_TEXT,
				// This is to ensure the minor edits path doesn't encounter an error
				'ar_minor_edit' => 1,
			] )
			->where( [
				'ar_rev_id' => $status->getNewRevision()->getId()
			] )
			->execute();
	}
}
PK       ! Å0J       specials/SpecialMovePageTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Specials;

use ErrorPageError;
use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;
use SpecialPageTestBase;

/**
 * @covers \MediaWiki\Specials\SpecialMovePage
 * @group Database
 */
class SpecialMovePageTest extends SpecialPageTestBase {

	protected function newSpecialPage() {
		return $this->getServiceContainer()->getSpecialPageFactory()->getPage( 'Movepage' );
	}

	public function testNoDefinedOldTitle() {
		$this->expectException( ErrorPageError::class );
		// The expected exception message will be in English because of T46111
		$this->expectExceptionMessage( wfMessage( 'notargettext' )->inLanguage( 'en' )->text() );
		$this->executeSpecialPage( '', null, null, $this->getTestSysop()->getUser() );
	}

	public function testOldTitleDoesNotExist() {
		$this->expectException( ErrorPageError::class );
		// The expected exception message will be in English because of T46111
		$this->expectExceptionMessage( wfMessage( 'nopagetext' )->inLanguage( 'en' )->text() );
		$this->executeSpecialPage( $this->getNonexistingTestPage()->getTitle(), null, null, $this->getTestSysop()->getUser() );
	}

	/** @dataProvider provideLoadFormForOldTitleWithSubpages */
	public function testLoadFormForOldTitleWithSubpages( $subpageCount, $maximumMovedPages, $shouldShowLimitedMessage ) {
		// Tests that the security patch for T357760 works.
		$this->overrideConfigValue( MainConfigNames::MaximumMovedPages, $maximumMovedPages );
		// NS_TALK supports subpages, so we can use that namespace for testing.
		$testPage = $this->getExistingTestPage( Title::newFromText( 'Test page for old title', NS_TALK ) );
		// Create a few testing subpages
		for ( $i = 0; $i < $subpageCount; $i++ ) {
			$this->getExistingTestPage( Title::newFromText( "Test page for old title/$i", NS_TALK ) );
		}
		// Load Special:MovePage with $testPage as the old title
		[ $html ] = $this->executeSpecialPage( $testPage->getTitle(), null, 'qqx', $this->getTestSysop()->getUser() );
		if ( $shouldShowLimitedMessage ) {
			$this->assertStringContainsString(
				'movesubpagetext-truncated',
				$html,
				'The the truncated subpage message should have been shown'
			);
			// This works because the subpages start from 0 and increase by 1. As such, the subpage with the number in
			// $maximumMovedPages will not be displayed (because it would cause the limit to be broken).
			$this->assertStringNotContainsString(
				"Talk:Test_page_for_old_title/$maximumMovedPages",
				$html,
				'The subpages list was not properly truncated.'
			);
		} else {
			$this->assertStringContainsString(
				'movesubpagetext',
				$html,
				'The the subpage message should have been shown'
			);
			$this->assertStringNotContainsString(
				'movesubpagetext-truncated',
				$html,
				'The the subpage message should have been shown'
			);
		}
	}

	public static function provideLoadFormForOldTitleWithSubpages() {
		return [
			'1 subpage, max subpages at 2' => [ 1, 2, false ],
			'3 subpages, max subpages at 2' => [ 3, 2, true ],
		];
	}
}
PK       ! zU&p  &p    specials/SpecialBlockTest.phpnu Iw        <?php

use MediaWiki\Block\BlockRestrictionStore;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\DatabaseBlockStore;
use MediaWiki\Block\Restriction\ActionRestriction;
use MediaWiki\Block\Restriction\NamespaceRestriction;
use MediaWiki\Block\Restriction\PageRestriction;
use MediaWiki\Content\ContentHandler;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Specials\SpecialBlock;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Title\Title;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Blocking
 * @group Database
 * @coversDefaultClass \MediaWiki\Specials\SpecialBlock
 */
class SpecialBlockTest extends SpecialPageTestBase {
	use MockAuthorityTrait;

	/** @var DatabaseBlockStore */
	private $blockStore;

	/**
	 * @inheritDoc
	 */
	protected function newSpecialPage() {
		$services = $this->getServiceContainer();
		return new SpecialBlock(
			$services->getBlockUtils(),
			$services->getBlockPermissionCheckerFactory(),
			$services->getBlockUserFactory(),
			$this->blockStore,
			$services->getUserNameUtils(),
			$services->getUserNamePrefixSearch(),
			$services->getBlockActionInfo(),
			$services->getTitleFormatter(),
			$services->getNamespaceInfo()
		);
	}

	protected function setUp(): void {
		parent::setUp();
		$this->blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
	}

	/**
	 * @covers ::getFormFields
	 */
	public function testGetFormFields() {
		$this->overrideConfigValues( [
			MainConfigNames::BlockAllowsUTEdit => true,
			MainConfigNames::EnablePartialActionBlocks => true,
			MainConfigNames::UseCodexSpecialBlock => false,
		] );
		$page = $this->newSpecialPage();
		$wrappedPage = TestingAccessWrapper::newFromObject( $page );
		$fields = $wrappedPage->getFormFields();
		$this->assertIsArray( $fields );
		$this->assertArrayHasKey( 'Target', $fields );
		$this->assertArrayHasKey( 'Expiry', $fields );
		$this->assertArrayHasKey( 'Reason', $fields );
		$this->assertArrayHasKey( 'CreateAccount', $fields );
		$this->assertArrayHasKey( 'DisableUTEdit', $fields );
		$this->assertArrayHasKey( 'AutoBlock', $fields );
		$this->assertArrayHasKey( 'HardBlock', $fields );
		$this->assertArrayHasKey( 'PreviousTarget', $fields );
		$this->assertArrayHasKey( 'Confirm', $fields );
		$this->assertArrayHasKey( 'EditingRestriction', $fields );
		$this->assertArrayNotHasKey( 'options-messages', $fields['EditingRestriction'] );
		$this->assertArrayNotHasKey( 'option-descriptions-messages', $fields['EditingRestriction'] );
		$this->assertArrayHasKey( 'PageRestrictions', $fields );
		$this->assertArrayHasKey( 'NamespaceRestrictions', $fields );
		$this->assertArrayHasKey( 'ActionRestrictions', $fields );
	}

	/**
	 * @dataProvider provideGetFormFieldsCodex
	 * @covers ::getFormFields
	 * @covers ::execute
	 */
	public function testCodexFormData( array $params, array $expected, bool $preErrors = false ): void {
		$this->overrideConfigValues( [
			MainConfigNames::BlockAllowsUTEdit => true,
			MainConfigNames::EnablePartialActionBlocks => true,
			MainConfigNames::UseCodexSpecialBlock => true,
		] );
		$context = RequestContext::getMain();
		$context->setRequest( new FauxRequest( array_merge( $params, [ 'uselang' => 'qqx' ] ) ) );
		$context->setTitle( Title::newFromText( 'Block', NS_SPECIAL ) );
		$context->setUser( $this->getTestSysop()->getUser() );
		$page = $this->newSpecialPage();
		$wrappedPage = TestingAccessWrapper::newFromObject( $page );
		$wrappedPage->execute( null );
		$actualJsConfigVars = $wrappedPage->getOutput()->getJsConfigVars();
		$this->assertArrayContains( $expected, $actualJsConfigVars );
		if ( $preErrors ) {
			$this->assertArrayHasKey( 'blockPreErrors', $actualJsConfigVars );
		} else {
			$this->assertArrayNotHasKey( 'blockPreErrors', $actualJsConfigVars );
		}
	}

	public static function provideGetFormFieldsCodex(): Generator {
		yield 'wpExpiry 3 hours' => [
			[ 'wpExpiry' => '3 hours' ],
			[ 'blockExpiryPreset' => '3 hours' ],
		];
		yield 'wpExpiry indefinite' => [
			[ 'wpExpiry' => 'indefinite' ],
			[ 'blockExpiryPreset' => 'infinite' ],
		];
		yield 'wpExpiry YYYY-MM-DDTHH:mm:SS' => [
			[ 'wpExpiry' => '2999-01-01T12:59:59' ],
			[ 'blockExpiryPreset' => '2999-01-01T12:59' ],
		];
		yield 'wpExpiry YYYY-MM-DD HH:mm:SS' => [
			[ 'wpExpiry' => '2999-01-01 12:59:59' ],
			[ 'blockExpiryPreset' => '2999-01-01T12:59' ],
		];
		yield 'wpExpiry YYYYMMDDHHmmSS' => [
			[ 'wpExpiry' => '29990101125959' ],
			[ 'blockExpiryPreset' => '2999-01-01T12:59' ],
		];
		yield 'wpExpiry YYYY-MM-DDTHH:mm' => [
			[ 'wpExpiry' => '2999-01-01T12:59' ],
			[ 'blockExpiryPreset' => '2999-01-01T12:59' ],
		];
		yield 'wpTarget NonexistentUser' => [
			[ 'wpTarget' => 'NonexistentUser' ],
			[ 'blockTargetUser' => 'NonexistentUser' ],
			true,
		];
	}

	/**
	 * @covers ::getFormFields
	 */
	public function testGetFormFieldsActionRestrictionDisabled() {
		$this->overrideConfigValue( MainConfigNames::EnablePartialActionBlocks, false );
		$page = $this->newSpecialPage();
		$wrappedPage = TestingAccessWrapper::newFromObject( $page );
		$fields = $wrappedPage->getFormFields();
		$this->assertArrayNotHasKey( 'ActionRestrictions', $fields );
	}

	/**
	 * @covers ::maybeAlterFormDefaults
	 */
	public function testMaybeAlterFormDefaults() {
		$this->overrideConfigValue( MainConfigNames::BlockAllowsUTEdit, true );

		$block = $this->insertBlock();

		// Refresh the block from the database.
		$block = $this->blockStore->newFromTarget( $block->getTargetUserIdentity() );

		$page = $this->newSpecialPage();

		$wrappedPage = TestingAccessWrapper::newFromObject( $page );
		$wrappedPage->target = $block->getTargetUserIdentity();
		$fields = $wrappedPage->getFormFields();

		$this->assertSame( $block->getTargetName(), $fields['Target']['default'] );
		$this->assertSame( $block->isHardblock(), $fields['HardBlock']['default'] );
		$this->assertSame( $block->isCreateAccountBlocked(), $fields['CreateAccount']['default'] );
		$this->assertSame( $block->isAutoblocking(), $fields['AutoBlock']['default'] );
		$this->assertSame( !$block->isUsertalkEditAllowed(), $fields['DisableUTEdit']['default'] );
		$this->assertSame( $block->getReasonComment()->text, $fields['Reason']['default'] );
		$this->assertSame( 'infinite', $fields['Expiry']['default'] );
	}

	/**
	 * @covers ::maybeAlterFormDefaults
	 */
	public function testMaybeAlterFormDefaultsPartial() {
		$this->overrideConfigValue( MainConfigNames::EnablePartialActionBlocks, true );
		$badActor = $this->getTestUser()->getUser();
		$sysop = $this->getTestSysop()->getUser();
		$pageSaturn = $this->getExistingTestPage( 'Saturn' );
		$pageMars = $this->getExistingTestPage( 'Mars' );
		$actionId = 100;

		$block = new DatabaseBlock( [
			'address' => $badActor,
			'by' => $sysop,
			'expiry' => 'infinity',
			'sitewide' => 0,
			'enableAutoblock' => true,
		] );

		$block->setRestrictions( [
			new PageRestriction( 0, $pageSaturn->getId() ),
			new PageRestriction( 0, $pageMars->getId() ),
			new NamespaceRestriction( 0, NS_TALK ),
			// Deleted page.
			new PageRestriction( 0, 999999 ),
			new ActionRestriction( 0, $actionId ),
		] );

		$this->blockStore->insertBlock( $block );

		// Refresh the block from the database.
		$block = $this->blockStore->newFromTarget( $block->getTargetUserIdentity() );

		$page = $this->newSpecialPage();

		$wrappedPage = TestingAccessWrapper::newFromObject( $page );
		$wrappedPage->target = $block->getTargetUserIdentity();
		$fields = $wrappedPage->getFormFields();

		$titles = [
			$pageMars->getTitle()->getPrefixedText(),
			$pageSaturn->getTitle()->getPrefixedText(),
		];

		$this->assertSame( $block->getTargetName(), $fields['Target']['default'] );
		$this->assertSame( 'partial', $fields['EditingRestriction']['default'] );
		$this->assertSame( implode( "\n", $titles ), $fields['PageRestrictions']['default'] );
		$this->assertSame( [ $actionId ], $fields['ActionRestrictions']['default'] );
	}

	/**
	 * @covers ::processForm
	 */
	public function testProcessForm() {
		$this->hideDeprecated( SpecialBlock::class . '::processForm' );
		$badActor = $this->getTestUser()->getUserIdentity();
		$context = RequestContext::getMain();
		$context->setUser( $this->getTestSysop()->getUser() );

		$page = $this->newSpecialPage();
		$reason = 'test';
		$expiry = 'infinity';
		$data = [
			'Target' => (string)$badActor,
			'Expiry' => 'infinity',
			'Reason' => [
				$reason,
			],
			'Confirm' => '1',
			'CreateAccount' => '0',
			'DisableUTEdit' => '0',
			'DisableEmail' => '0',
			'HardBlock' => '0',
			'AutoBlock' => '1',
			'HideUser' => '0',
			'Watch' => '0',
		];
		$result = $page->processForm( $data, $context );

		$this->assertTrue( $result );

		$block = $this->blockStore->newFromTarget( $badActor );
		$this->assertSame( $reason, $block->getReasonComment()->text );
		$this->assertSame( $expiry, $block->getExpiry() );
	}

	/**
	 * @covers ::processForm
	 */
	public function testProcessFormExisting() {
		$this->hideDeprecated( SpecialBlock::class . '::processForm' );
		$badActor = $this->getTestUser()->getUser();
		$sysop = $this->getTestSysop()->getUser();
		$context = RequestContext::getMain();
		$context->setUser( $sysop );

		// Create a block that will be updated.
		$block = new DatabaseBlock( [
			'address' => $badActor,
			'by' => $sysop,
			'expiry' => 'infinity',
			'sitewide' => 0,
			'enableAutoblock' => false,
		] );
		$this->blockStore->insertBlock( $block );

		$page = $this->newSpecialPage();
		$reason = 'test';
		$expiry = 'infinity';
		$data = [
			'Target' => (string)$badActor,
			'Expiry' => 'infinity',
			'Reason' => [
				$reason,
			],
			'Confirm' => '1',
			'CreateAccount' => '0',
			'DisableUTEdit' => '0',
			'DisableEmail' => '0',
			'HardBlock' => '0',
			'AutoBlock' => '1',
			'HideUser' => '0',
			'Watch' => '0',
		];
		$result = $page->processForm( $data, $context );

		$this->assertTrue( $result );

		$block = $this->blockStore->newFromTarget( $badActor );
		$this->assertSame( $reason, $block->getReasonComment()->text );
		$this->assertSame( $expiry, $block->getExpiry() );
		$this->assertTrue( $block->isAutoblocking() );
	}

	/**
	 * @covers ::processForm
	 */
	public function testProcessFormRestrictions() {
		$this->hideDeprecated( SpecialBlock::class . '::processForm' );
		$this->overrideConfigValue( MainConfigNames::EnablePartialActionBlocks, true );

		$badActor = $this->getTestUser()->getUser();
		$context = RequestContext::getMain();
		$context->setUser( $this->getTestSysop()->getUser() );

		$pageSaturn = $this->getExistingTestPage( 'Saturn' );
		$pageMars = $this->getExistingTestPage( 'Mars' );
		$actionId = 100;

		$titles = [
			$pageSaturn->getTitle()->getText(),
			$pageMars->getTitle()->getText(),
		];

		$page = $this->newSpecialPage();
		$reason = 'test';
		$expiry = 'infinity';
		$data = [
			'Target' => (string)$badActor,
			'Expiry' => 'infinity',
			'Reason' => [
				$reason,
			],
			'Confirm' => '1',
			'CreateAccount' => '0',
			'DisableUTEdit' => '0',
			'DisableEmail' => '0',
			'HardBlock' => '0',
			'AutoBlock' => '1',
			'HideUser' => '0',
			'Watch' => '0',
			'EditingRestriction' => 'partial',
			'PageRestrictions' => implode( "\n", $titles ),
			'NamespaceRestrictions' => '',
			'ActionRestrictions' => [ $actionId ],
		];
		$result = $page->processForm( $data, $context );

		$this->assertTrue( $result );

		$block = $this->blockStore->newFromTarget( $badActor );
		$this->assertSame( $reason, $block->getReasonComment()->text );
		$this->assertSame( $expiry, $block->getExpiry() );
		$this->assertCount( 3, $block->getRestrictions() );
		$this->assertTrue( $this->getBlockRestrictionStore()->equals( $block->getRestrictions(), [
			new PageRestriction( $block->getId(), $pageMars->getId() ),
			new PageRestriction( $block->getId(), $pageSaturn->getId() ),
			new ActionRestriction( $block->getId(), $actionId ),
		] ) );
	}

	/**
	 * @covers ::processForm
	 */
	public function testProcessFormRestrictionsChange() {
		$this->hideDeprecated( SpecialBlock::class . '::processForm' );
		$badActor = $this->getTestUser()->getUser();
		$context = RequestContext::getMain();
		$context->setUser( $this->getTestSysop()->getUser() );

		$pageSaturn = $this->getExistingTestPage( 'Saturn' );
		$pageMars = $this->getExistingTestPage( 'Mars' );

		$titles = [
			$pageSaturn->getTitle()->getText(),
			$pageMars->getTitle()->getText(),
		];

		// Create a partial block.
		$page = $this->newSpecialPage();
		$reason = 'test';
		$expiry = 'infinity';
		$data = [
			'Target' => (string)$badActor,
			'Expiry' => 'infinity',
			'Reason' => [
				$reason,
			],
			'Confirm' => '1',
			'CreateAccount' => '1',
			'DisableUTEdit' => '0',
			'DisableEmail' => '0',
			'HardBlock' => '0',
			'AutoBlock' => '1',
			'HideUser' => '0',
			'Watch' => '0',
			'EditingRestriction' => 'partial',
			'PageRestrictions' => implode( "\n", $titles ),
			'NamespaceRestrictions' => '',
		];
		$result = $page->processForm( $data, $context );

		$this->assertTrue( $result );

		$block = $this->blockStore->newFromTarget( $badActor );
		$this->assertSame( $reason, $block->getReasonComment()->text );
		$this->assertSame( $expiry, $block->getExpiry() );
		$this->assertFalse( $block->isSitewide() );
		$this->assertTrue( $block->isCreateAccountBlocked() );
		$this->assertCount( 2, $block->getRestrictions() );
		$this->assertTrue( $this->getBlockRestrictionStore()->equals( $block->getRestrictions(), [
			new PageRestriction( $block->getId(), $pageMars->getId() ),
			new PageRestriction( $block->getId(), $pageSaturn->getId() ),
		] ) );

		// Remove a page from the partial block.
		$data['PageRestrictions'] = $pageMars->getTitle()->getText();
		$result = $page->processForm( $data, $context );

		$this->assertTrue( $result );

		$block = $this->blockStore->newFromTarget( $badActor );
		$this->assertSame( $reason, $block->getReasonComment()->text );
		$this->assertSame( $expiry, $block->getExpiry() );
		$this->assertFalse( $block->isSitewide() );
		$this->assertTrue( $block->isCreateAccountBlocked() );
		$this->assertCount( 1, $block->getRestrictions() );
		$this->assertTrue( $this->getBlockRestrictionStore()->equals( $block->getRestrictions(), [
			new PageRestriction( $block->getId(), $pageMars->getId() ),
		] ) );

		// Remove the last page from the block.
		$data['PageRestrictions'] = '';
		$result = $page->processForm( $data, $context );

		$this->assertTrue( $result );

		$block = $this->blockStore->newFromTarget( $badActor );
		$this->assertSame( $reason, $block->getReasonComment()->text );
		$this->assertSame( $expiry, $block->getExpiry() );
		$this->assertFalse( $block->isSitewide() );
		$this->assertTrue( $block->isCreateAccountBlocked() );
		$this->assertSame( [], $block->getRestrictions() );

		// Change to sitewide.
		$data['EditingRestriction'] = 'sitewide';
		$result = $page->processForm( $data, $context );

		$this->assertTrue( $result );

		$block = $this->blockStore->newFromTarget( $badActor );
		$this->assertSame( $reason, $block->getReasonComment()->text );
		$this->assertSame( $expiry, $block->getExpiry() );
		$this->assertTrue( $block->isSitewide() );
		$this->assertSame( [], $block->getRestrictions() );

		// Ensure that there are no restrictions where the blockId is 0.
		$count = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'ipblocks_restrictions' )
			->where( [ 'ir_ipb_id' => 0 ] )
			->caller( __METHOD__ )->fetchRowCount();
		$this->assertSame( 0, $count );
	}

	/**
	 * @dataProvider provideProcessFormUserTalkEditFlag
	 * @covers ::processForm
	 */
	public function testProcessFormUserTalkEditFlag( $options, $expected ) {
		$this->hideDeprecated( SpecialBlock::class . '::processForm' );
		$this->overrideConfigValue( MainConfigNames::BlockAllowsUTEdit, $options['configAllowsUserTalkEdit'] );

		$performer = $this->getTestSysop()->getUser();
		$target = $this->getTestUser()->getUser();

		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser( $performer );

		$data = [
			'Target' => $target,
			'PreviousTarget' => $target,
			'Expiry' => 'infinity',
			'CreateAccount' => '1',
			'DisableEmail' => '0',
			'HardBlock' => '0',
			'AutoBlock' => '0',
			'Watch' => '0',
			'Confirm' => '1',
			'DisableUTEdit' => $options['optionBlocksUserTalkEdit'],
		];

		if ( !$options['userTalkNamespaceBlocked'] ) {
			$data['EditingRestriction'] = 'partial';
			$data['PageRestrictions'] = '';
			$data['NamespaceRestrictions'] = '';
		}

		$result = $this->newSpecialPage()->processForm(
			$data,
			$context
		);

		if ( is_string( $expected ) ) {
			$this->assertStatusError( $expected, $result );
		} else {
			$block = $this->blockStore->newFromTarget( $target );
			$this->assertSame( $expected, $block->isUsertalkEditAllowed() );
		}
	}

	/**
	 * Test cases for whether own user talk edit is allowed, with different combinations of:
	 * - whether user talk namespace blocked
	 * - config BlockAllowsUTEdit true/false
	 * - block option specifying whether to block own user talk edit
	 * For more about the desired behaviour, see T252892.
	 *
	 * @return array
	 */
	public static function provideProcessFormUserTalkEditFlag() {
		return [
			'Always allowed if user talk namespace not blocked' => [
				[
					'userTalkNamespaceBlocked' => false,
					'configAllowsUserTalkEdit' => true,
					'optionBlocksUserTalkEdit' => false,
				],
				true,
			],
			'Always allowed if user talk namespace not blocked (config is false)' => [
				[
					'userTalkNamespaceBlocked' => false,
					'configAllowsUserTalkEdit' => false,
					'optionBlocksUserTalkEdit' => false,
				],
				true,
			],
			'Error if user talk namespace not blocked, but option blocks user talk edit' => [
				[
					'userTalkNamespaceBlocked' => false,
					'configAllowsUserTalkEdit' => true,
					'optionBlocksUserTalkEdit' => true,
				],
				'ipb-prevent-user-talk-edit',
			],
			'Always blocked if user talk namespace blocked and wgBlockAllowsUTEdit is false' => [
				[
					'userTalkNamespaceBlocked' => true,
					'configAllowsUserTalkEdit' => false,
					'optionBlocksUserTalkEdit' => false,
				],
				false,
			],
			'Option used if user talk namespace blocked and config is true (blocked)' => [
				[
					'userTalkNamespaceBlocked' => true,
					'configAllowsUserTalkEdit' => true,
					'optionBlocksUserTalkEdit' => true,
				],
				false,
			],
			'Option used if user talk namespace blocked and config is true (not blocked)' => [
				[
					'userTalkNamespaceBlocked' => true,
					'configAllowsUserTalkEdit' => true,
					'optionBlocksUserTalkEdit' => false,
				],
				true,
			],
		];
	}

	/**
	 * @dataProvider provideProcessFormErrors
	 * @covers ::processForm
	 */
	public function testProcessFormErrors( $data, $expected, $options = [] ) {
		$this->hideDeprecated( SpecialBlock::class . '::processForm' );
		$this->overrideConfigValue( MainConfigNames::BlockAllowsUTEdit, true );

		$performer = $this->getTestSysop()->getUser();
		$target = !empty( $options['blockingSelf'] ) ? $performer : '1.2.3.4';
		$defaultData = [
			'Target' => $target,
			'PreviousTarget' => $target,
			'Expiry' => 'infinity',
			'CreateAccount' => '0',
			'DisableUTEdit' => '0',
			'DisableEmail' => '0',
			'HardBlock' => '0',
			'AutoBlock' => '0',
			'Confirm' => '0',
			'Watch' => '0',
		];

		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser( $performer );

		$result = $this->newSpecialPage()->processForm(
			array_merge( $defaultData, $data ),
			$context
		);

		if ( $result instanceof Status ) {
			$this->assertStatusMessage( $expected, $result );
		} else {
			$error = is_array( $result[0] ) ? $result[0][0] : $result[0];
			$this->assertEquals( $expected, $error );
		}
	}

	public static function provideProcessFormErrors() {
		return [
			'Invalid expiry' => [
				[
					'Expiry' => 'invalid',
				],
				'ipb_expiry_invalid',
			],
			'Expiry is in the past' => [
				[
					'Expiry' => 'yesterday',
				],
				'ipb_expiry_old',
			],
			'Bad ip address' => [
				[
					'Target' => '1.2.3.4/1234',
				],
				'badipaddress',
			],
			'Edit user talk page invalid with no restrictions' => [
				[
					'EditingRestriction' => 'partial',
					'DisableUTEdit' => '1',
					'PageRestrictions' => '',
					'NamespaceRestrictions' => '',
				],
				'ipb-prevent-user-talk-edit',
			],
			'Edit user talk page invalid with namespace restriction !== NS_USER_TALK ' => [
				[
					'EditingRestriction' => 'partial',
					'DisableUTEdit' => '1',
					'PageRestrictions' => '',
					'NamespaceRestrictions' => NS_USER,
				],
				'ipb-prevent-user-talk-edit',
			],
			'Blocking self and target changed' => [
				[
					'PreviousTarget' => 'other',
					'Confirm' => '1',
				],
				'ipb-blockingself',
				[
					'blockingSelf' => true,
				],
			],
			'Blocking self and no confirm' => [
				[],
				'ipb-blockingself',
				[
					'blockingSelf' => true,
				],
			],
			'Empty expiry' => [
				[
					'Expiry' => '',
				],
				'ipb_expiry_invalid',
			],
			'Expiry valid but longer than 50 chars' => [
				[
					'Expiry' => '30th September 9999 19:19:19.532453 Europe/Amsterdam',
				],
				'ipb_expiry_invalid',
			],
		];
	}

	/**
	 * @dataProvider provideProcessFormErrorsReblock
	 * @covers ::processForm
	 */
	public function testProcessFormErrorsReblock( $data, $permissions, $expected ) {
		$this->hideDeprecated( SpecialBlock::class . '::processForm' );
		$this->overrideConfigValue( MainConfigNames::BlockAllowsUTEdit, true );

		$performer = $this->getTestSysop()->getUser();
		$this->overrideUserPermissions( $performer, $permissions );
		$blockedUser = $this->getTestUser()->getUser();

		$block = new DatabaseBlock( [
			'address' => $blockedUser,
			'by' => $performer,
			'hideName' => true,
		] );
		$this->blockStore->insertBlock( $block );

		// Matches the existing block
		$defaultData = [
			'Target' => $blockedUser->getName(),
			'PreviousTarget' => $blockedUser->getName(),
			'Expiry' => 'infinity',
			'DisableUTEdit' => '1',
			'CreateAccount' => '0',
			'DisableEmail' => '0',
			'HardBlock' => '0',
			'AutoBlock' => '0',
			'HideUser' => '1',
			'Confirm' => '1',
			'Watch' => '0',
		];

		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser( $performer );

		$result = $this->newSpecialPage()->processForm(
			array_merge( $defaultData, $data ),
			$context
		);

		if ( $result instanceof Status ) {
			$this->assertStatusMessage( $expected, $result );
		} else {
			$error = is_array( $result[0] ) ? $result[0][0] : $result[0];
			$this->assertEquals( $expected, $error );
		}
	}

	public static function provideProcessFormErrorsReblock() {
		return [
			'Reblock user with Confirm false' => [
				[
					// Avoid error for hiding user with confirm false
					'HideUser' => '0',
					'Confirm' => '0',
				],
				[ 'block', 'hideuser' ],
				'ipb_already_blocked',
			],
			'Reblock user with Reblock false' => [
				[ 'Reblock' => '0' ],
				[ 'block', 'hideuser' ],
				'ipb_already_blocked',
			],
			'Reblock with confirm True but target has changed' => [
				[ 'PreviousTarget' => '1.2.3.4' ],
				[ 'block', 'hideuser' ],
				'ipb_already_blocked',
			],
			'Reblock with same block' => [
				[ 'HideUser' => '1' ],
				[ 'block', 'hideuser' ],
				'ipb_already_blocked',
			],
			'Reblock hidden user with wrong permissions' => [
				[ 'HideUser' => '0' ],
				[ 'block', 'hideuser' => false ],
				'cant-see-hidden-user',
			],
		];
	}

	/**
	 * @dataProvider provideProcessFormErrorsHideUser
	 * @covers ::processForm
	 */
	public function testProcessFormErrorsHideUser( $data, $permissions, $expected ) {
		$this->hideDeprecated( SpecialBlock::class . '::processForm' );
		$performer = $this->getTestSysop()->getUser();
		$this->overrideUserPermissions( $performer, array_merge( $permissions, [ 'block' ] ) );

		$defaultData = [
			'Target' => $this->getTestUser()->getUser(),
			'HideUser' => '1',
			'Expiry' => 'infinity',
			'Confirm' => '1',
			'CreateAccount' => '0',
			'DisableUTEdit' => '0',
			'DisableEmail' => '0',
			'HardBlock' => '0',
			'AutoBlock' => '0',
			'Watch' => '0',
		];

		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser( $performer );

		$result = $this->newSpecialPage()->processForm(
			array_merge( $defaultData, $data ),
			$context
		);

		if ( $result instanceof Status ) {
			$this->assertStatusMessage( $expected, $result );
		} else {
			$error = is_array( $result[0] ) ? $result[0][0] : $result[0];
			$this->assertEquals( $expected, $error );
		}
	}

	public static function provideProcessFormErrorsHideUser() {
		return [
			'HideUser with wrong permissions' => [
				[],
				[ 'hideuser' => '0' ],
				'badaccess-group0',
			],
			'Hideuser with partial block' => [
				[ 'EditingRestriction' => 'partial' ],
				[ 'hideuser' ],
				'ipb_hide_partial',
			],
			'Hideuser with finite expiry' => [
				[ 'Expiry' => '1 hour' ],
				[ 'hideuser' ],
				'ipb_expiry_temp',
			],
			'Hideuser with no confirm' => [
				[ 'Confirm' => '0' ],
				[ 'hideuser' ],
				'ipb-confirmhideuser',
			],
		];
	}

	/**
	 * @covers ::processForm
	 */
	public function testProcessFormErrorsHideUserProlific() {
		$this->hideDeprecated( SpecialBlock::class . '::processForm' );
		$this->overrideConfigValue( MainConfigNames::HideUserContribLimit, 0 );

		$performer = $this->mockRegisteredUltimateAuthority();
		$userToBlock = $this->getTestUser()->getUser();
		$pageSaturn = $this->getExistingTestPage( 'Saturn' );
		$pageSaturn->doUserEditContent(
			ContentHandler::makeContent( 'content', $pageSaturn->getTitle() ),
			$userToBlock,
			'summary'
		);

		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setAuthority( $performer );

		$result = $this->newSpecialPage()->processForm(
			[
				'Target' => $userToBlock,
				'CreateAccount' => '1',
				'HideUser' => '1',
				'Expiry' => 'infinity',
				'Confirm' => '1',
				'DisableUTEdit' => '0',
				'DisableEmail' => '0',
				'HardBlock' => '0',
				'AutoBlock' => '0',
				'Watch' => '0',
			],
			$context
		);

		if ( $result instanceof Status ) {
			$this->assertStatusMessage( 'ipb_hide_invalid', $result );
		} else {
			$error = is_array( $result[0] ) ? $result[0][0] : $result[0];
			$this->assertEquals( 'ipb_hide_invalid', $error );
		}
	}

	/**
	 * @dataProvider provideGetTargetAndType
	 * @covers ::getTargetAndTypeInternal
	 */
	public function testGetTargetAndType( $par, $requestData, $expectedTarget ) {
		$request = new FauxRequest( $requestData );
		/** @var SpecialBlock $page */
		$page = TestingAccessWrapper::newFromObject( $this->newSpecialPage() );
		[ $target, $type ] = $page->getTargetAndTypeInternal( $par, $request );
		$this->assertSame( $expectedTarget, $target );
	}

	public static function provideGetTargetAndType() {
		$invalidTarget = '';
		return [
			'Choose \'wpTarget\' parameter first' => [
				'2.2.2.0/24',
				[
					'wpTarget' => '1.1.1.0/24',
					'ip' => '3.3.3.0/24',
					'wpBlockAddress' => '4.4.4.0/24',
				],
				'1.1.1.0/24',
			],
			'Choose subpage parameter second' => [
				'2.2.2.0/24',
				[
					'wpTarget' => $invalidTarget,
					'ip' => '3.3.3.0/24',
					'wpBlockAddress' => '4.4.4.0/24',
				],
				'2.2.2.0/24',
			],
			'Choose \'ip\' parameter third' => [
				$invalidTarget,
				[
					'wpTarget' => $invalidTarget,
					'ip' => '3.3.3.0/24',
					'wpBlockAddress' => '4.4.4.0/24',
				],
				'3.3.3.0/24',
			],
			'Choose \'wpBlockAddress\' parameter fourth' => [
				$invalidTarget,
				[
					'wpTarget' => $invalidTarget,
					'ip' => $invalidTarget,
					'wpBlockAddress' => '4.4.4.0/24',
				],
				'4.4.4.0/24',
			],
			'Subpage, no valid request data' => [
				'2.2.2.0/24',
				[],
				'2.2.2.0/24',
			],
			'No valid request data or subpage parameter' => [
				null,
				[],
				null,
			],
		];
	}

	protected function insertBlock() {
		$badActor = $this->getTestUser()->getUser();
		$sysop = $this->getTestSysop()->getUser();

		$block = new DatabaseBlock( [
			'address' => $badActor,
			'by' => $sysop,
			'expiry' => 'infinity',
			'sitewide' => 1,
			'enableAutoblock' => true,
		] );

		$this->blockStore->insertBlock( $block );

		return $block;
	}

	/**
	 * Get a BlockRestrictionStore instance
	 *
	 * @return BlockRestrictionStore
	 */
	private function getBlockRestrictionStore(): BlockRestrictionStore {
		$dbProvider = $this->createMock( IConnectionProvider::class );

		return new BlockRestrictionStore( $dbProvider );
	}
}
PK       ! fzL    ,  specials/SpecialDeletedContributionsTest.phpnu Iw        <?php

use MediaWiki\Request\FauxRequest;
use MediaWiki\Specials\SpecialDeletedContributions;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use Wikimedia\Parsoid\Utils\DOMCompat;
use Wikimedia\Parsoid\Utils\DOMUtils;

/**
 * @group Database
 * @covers \MediaWiki\Specials\SpecialDeletedContributions
 */
class SpecialDeletedContributionsTest extends SpecialPageTestBase {

	use TempUserTestTrait;

	private static User $sysop;
	private static User $userNameWithSpaces;

	protected function newSpecialPage(): SpecialDeletedContributions {
		$services = $this->getServiceContainer();

		return new SpecialDeletedContributions(
			$services->getPermissionManager(),
			$services->getConnectionProvider(),
			$services->getRevisionStore(),
			$services->getNamespaceInfo(),
			$services->getUserNameUtils(),
			$services->getUserNamePrefixSearch(),
			$services->getUserOptionsLookup(),
			$services->getCommentFormatter(),
			$services->getLinkBatchFactory(),
			$services->getUserFactory(),
			$services->getUserIdentityLookup(),
			$services->getDatabaseBlockStore(),
			$services->getTempUserConfig()
		);
	}

	public function testExecuteNoTarget() {
		[ $html ] = $this->executeSpecialPage(
			'',
			null,
			null,
			self::$sysop,
		);
		$this->assertStringNotContainsString( 'mw-pager-body', $html );
	}

	public function testExecuteInvalidTarget() {
		[ $html ] = $this->executeSpecialPage(
			'#InvalidUserName',
			null,
			null,
			self::$sysop,
		);
		$this->assertStringNotContainsString( 'mw-pager-body', $html );
	}

	/** @dataProvider provideExecuteNoResultsForIPTarget */
	public function testExecuteNoResultsForIPTarget( $temporaryAccountsEnabled, $expectedPageTitleMessageKey ) {
		if ( $temporaryAccountsEnabled ) {
			$this->enableAutoCreateTempUser();
		} else {
			$this->disableAutoCreateTempUser();
		}
		[ $html ] = $this->executeSpecialPage(
			'127.0.0.1',
			null,
			null,
			self::$sysop,
			true
		);
		$specialPageDocument = DOMUtils::parseHTML( $html );
		$contentHtml = DOMCompat::querySelector( $specialPageDocument, '.mw-content-container' )->nodeValue;
		$this->assertStringNotContainsString( 'mw-pager-body', $contentHtml );
		$this->assertStringContainsString( "($expectedPageTitleMessageKey: 127.0.0.1", $contentHtml );
	}

	public static function provideExecuteNoResultsForIPTarget() {
		return [
			'Temporary accounts not enabled' => [ false, 'deletedcontributions-title' ],
			'Temporary accounts enabled' => [
				true, 'deletedcontributions-title-for-ip-when-temporary-accounts-enabled',
			],
		];
	}

	public function testExecuteUserNameWithEscapedSpaces() {
		$par = strtr( self::$userNameWithSpaces->getName(), ' ', '_' );
		[ $html ] = $this->executeSpecialPage(
			$par,
			null,
			null,
			self::$sysop,
		);
		$this->assertStringContainsString( 'mw-pager-body', $html );
	}

	public function testExecuteNamespaceFilter() {
		[ $html ] = $this->executeSpecialPage(
			self::$sysop->getName(),
			new FauxRequest( [
				'namespace' => NS_TALK,
			] ),
			null,
			self::$sysop,
		);
		$this->assertStringContainsString( 'mw-pager-body', $html );
	}

	public function addDBDataOnce() {
		self::$sysop = $this->getTestSysop()->getUser();
		self::$userNameWithSpaces = $this->getMutableTestUser( [], 'Test User' )->getUser();

		$title = Title::makeTitle( NS_TALK, 'DeletedContribsPagerTest' );

		// Make some edits
		$this->editPage( $title, '', '', NS_MAIN, self::$sysop );
		$this->editPage( $title, 'test', '', NS_MAIN, self::$userNameWithSpaces );
		$status = $this->editPage( $title, 'Test content.', '', NS_MAIN, self::$sysop );

		// Delete the page where the edits were made
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$this->deletePage( $page );
	}
}
PK       ! U6a  a  %  specials/QueryAllSpecialPagesTest.phpnu Iw        <?php
/**
 * Test class to run the query of most of all our special pages
 *
 * Copyright © 2011, Antoine Musso
 *
 * @author Antoine Musso
 */

use MediaWiki\SpecialPage\QueryPage;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Specials\SpecialLinkSearch;
use Wikimedia\Rdbms\ResultWrapper;

/**
 * @group Database
 * @covers \MediaWiki\SpecialPage\QueryPage<extended>
 */
class QueryAllSpecialPagesTest extends MediaWikiIntegrationTestCase {

	/**
	 * @var SpecialPage[]
	 */
	private $queryPages;

	/** @var string[] List query pages that cannot be tested automatically */
	protected $manualTest = [
		SpecialLinkSearch::class
	];

	/**
	 * @var string[] Names of pages whose query use the same DB table more than once.
	 * This is used to skip testing those pages when run against a MySQL backend
	 * which does not support reopening a temporary table.
	 * For more info, see https://phabricator.wikimedia.org/T256006
	 */
	protected $reopensTempTable = [
		'BrokenRedirects',
	];

	/**
	 * Initialize all query page objects
	 */
	protected function setUp(): void {
		parent::setUp();

		foreach ( QueryPage::getPages() as [ $class, $name ] ) {
			if ( !in_array( $class, $this->manualTest ) ) {
				$this->queryPages[$class] =
					$this->getServiceContainer()->getSpecialPageFactory()->getPage( $name );
			}
		}
	}

	/**
	 * Test SQL for each of our QueryPages objects
	 */
	public function testQuerypageSqlQuery() {
		foreach ( $this->queryPages as $page ) {
			// With MySQL, skips special pages reopening a temporary table
			// See https://bugs.mysql.com/bug.php?id=10327
			if (
				$this->getDb()->getType() === 'mysql' &&
				str_contains( $this->getDb()->getSoftwareLink(), 'MySQL' ) &&
				in_array( $page->getName(), $this->reopensTempTable )
			) {
				$this->markTestSkipped( "SQL query for page {$page->getName()} "
					. "cannot be tested on MySQL backend (it reopens a temporary table)" );
				continue;
			}

			$msg = "SQL query for page {$page->getName()} should give a result wrapper object";

			$result = $page->reallyDoQuery( 50 );
			$this->assertInstanceOf( ResultWrapper::class, $result, $msg );
		}
	}
}
PK       ! x6  6     specials/SpecialRedirectTest.phpnu Iw        <?php

use MediaWiki\Specials\SpecialRedirect;

/**
 * Test class for SpecialRedirect class
 *
 * @since 1.32
 *
 * @license GPL-2.0-or-later
 * @group Database
 */
class SpecialRedirectTest extends MediaWikiIntegrationTestCase {

	private const CREATE_USER = 'create_user';

	/**
	 * @dataProvider provideDispatch
	 * @covers \MediaWiki\Specials\SpecialRedirect::dispatchUser
	 * @covers \MediaWiki\Specials\SpecialRedirect::dispatchFile
	 * @covers \MediaWiki\Specials\SpecialRedirect::dispatchRevision
	 * @covers \MediaWiki\Specials\SpecialRedirect::dispatchPage
	 * @covers \MediaWiki\Specials\SpecialRedirect::dispatchLog
	 */
	public function testDispatch( $method, $type, $value, $expectedStatus ) {
		$userFactory = $this->getServiceContainer()->getUserFactory();
		$page = new SpecialRedirect(
			$this->getServiceContainer()->getRepoGroup(),
			$userFactory
		);

		// setup the user object
		if ( $value === self::CREATE_USER ) {
			$user = $userFactory->newFromName( __CLASS__ );
			$user->addToDatabase();
			$value = $user->getId();
		}

		$page->setParameter( $type . '/' . $value );

		$status = $page->$method();
		$this->assertSame(
			$expectedStatus === 'good', $status->isGood(),
			$method . ' does not return expected status "' . $expectedStatus . '"'
		);
	}

	public static function provideDispatch() {
		foreach ( [
			[ 'nonumeric', 'fatal' ],
			[ '3', 'fatal' ],
			[ self::CREATE_USER, 'good' ],
		] as $dispatchUser ) {
			yield [ 'dispatchUser', 'user', $dispatchUser[0], $dispatchUser[1] ];
		}
		foreach ( [
			[ 'bad<name', 'fatal' ],
			[ 'File:Non-exists.jpg', 'fatal' ],
			// TODO Cannot test the good path here, because a file must exists
		] as $dispatchFile ) {
			yield [ 'dispatchFile', 'file', $dispatchFile[0], $dispatchFile[1] ];
		}
		foreach ( [
			[ 'nonumeric', 'fatal' ],
			[ '0', 'fatal' ],
			[ '1', 'good' ],
		] as $dispatch ) {
			yield [ 'dispatchRevision', 'revision', $dispatch[0], $dispatch[1] ];
			yield [ 'dispatchPage', 'revision', $dispatch[0], $dispatch[1] ];
			yield [ 'dispatchLog', 'log', $dispatch[0], $dispatch[1] ];
		}
	}

}
PK       ! iGE  E    specials/SpecialSearchTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Language\ILanguageConverter;
use MediaWiki\Languages\LanguageConverterFactory;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Search\TitleMatcher;
use MediaWiki\Specials\SpecialSearch;
use MediaWiki\Title\Title;
use MediaWiki\User\User;

/**
 * Test class for SpecialSearch class
 * Copyright © 2012, Antoine Musso
 *
 * @author Antoine Musso
 * @group Database
 */
class SpecialSearchTest extends MediaWikiIntegrationTestCase {

	private function newSpecialPage() {
		$services = $this->getServiceContainer();
		return new SpecialSearch(
			$services->getSearchEngineConfig(),
			$services->getSearchEngineFactory(),
			$services->getNamespaceInfo(),
			$services->getContentHandlerFactory(),
			$services->getInterwikiLookup(),
			$services->getReadOnlyMode(),
			$services->getUserOptionsManager(),
			$services->getLanguageConverterFactory(),
			$services->getRepoGroup(),
			$services->getSearchResultThumbnailProvider(),
			$services->getTitleMatcher()
		);
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialSearch::load
	 */
	public function testAlternativeBackend() {
		$this->overrideConfigValue( MainConfigNames::SearchTypeAlternatives, [ 'MockSearchEngine' ] );

		$ctx = new RequestContext();
		$ctx->setRequest( new FauxRequest( [
			'search' => 'foo',
			'srbackend' => 'MockSearchEngine',
		] ) );
		$search = $this->newSpecialPage();
		$search->setContext( $ctx );

		$search->load();

		# Without the parameter srbackend it would be a SearchEngineDummy
		$this->assertInstanceOf( MockSearchEngine::class, $search->getSearchEngine() );
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialSearch::load
	 * @covers \MediaWiki\Specials\SpecialSearch::showResults
	 */
	public function testValidateSortOrder() {
		$ctx = new RequestContext();
		$ctx->setRequest( new FauxRequest( [
			'search' => 'foo',
			'fulltext' => 1,
			'sort' => 'invalid',
		] ) );
		$sp = Title::makeTitle( NS_SPECIAL, 'Search' );
		$this->getServiceContainer()
			->getSpecialPageFactory()
			->executePath( $sp, $ctx );
		$html = $ctx->getOutput()->getHTML();
		$this->assertStringContainsString( 'cdx-message--warning', $html, 'must contain warnings' );
		$this->assertMatchesRegularExpression( '/Sort order of invalid is unrecognized/',
			$html, 'must tell user sort order is invalid' );
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialSearch::load
	 * @dataProvider provideSearchOptionsTests
	 * @param array $requested Request parameters. For example:
	 *   [ 'ns5' => true, 'ns6' => true ]. Null to use default options.
	 * @param array $userOptions User options to test with. For example:
	 *   [ 'searchNs5' => 1 ];. Null to use default options.
	 * @param string $expectedProfile An expected search profile name
	 * @param array $expectedNS Expected namespaces
	 * @param string $message
	 */
	public function testProfileAndNamespaceLoading( $requested, $userOptions,
		$expectedProfile, $expectedNS, $message = 'Profile name and namespaces mismatches!'
	) {
		$context = new RequestContext;
		$context->setUser(
			$this->newUserWithSearchNS( $userOptions )
		);
		/*
		$context->setRequest( new MediaWiki\Request\FauxRequest( [
			'ns5'=>true,
			'ns6'=>true,
		] ));
		 */
		$context->setRequest( new FauxRequest( $requested ) );
		$search = $this->newSpecialPage();
		$search->setContext( $context );
		$search->load();

		/**
		 * Verify profile name and namespace in the same assertion to make
		 * sure we will be able to fully compare the above code. PHPUnit stop
		 * after an assertion fail.
		 */
		$this->assertEquals(
			[ /** Expected: */
				'ProfileName' => $expectedProfile,
				'Namespaces' => $expectedNS,
			],
			[ /** Actual: */
				'ProfileName' => $search->getProfile(),
				'Namespaces' => $search->getNamespaces(),
			],
			$message
		);
	}

	public static function provideSearchOptionsTests() {
		$defaultNS = MediaWikiServices::getInstance()->getSearchEngineConfig()->defaultNamespaces();
		$EMPTY_REQUEST = [];
		$NO_USER_PREF = null;

		return [
			/**
			 * Parameters:
			 *     <Web Request>, <User options>
			 * Followed by expected values:
			 *     <ProfileName>, <NSList>
			 * Then an optional message.
			 */
			[
				$EMPTY_REQUEST, $NO_USER_PREF,
				'default', $defaultNS,
				'T35270: No request nor user preferences should give default profile'
			],
			[
				[ 'ns5' => 1 ], $NO_USER_PREF,
				'advanced', [ 5 ],
				'Web request with specific NS should override user preference'
			],
			[
				$EMPTY_REQUEST, [
					'searchNs2' => 1,
					'searchNs14' => 1,
				] + array_fill_keys( array_map( static function ( $ns ) {
					return "searchNs$ns";
				}, $defaultNS ), 0 ),
				'advanced', [ 2, 14 ],
				'T35583: search with no option should honor User search preferences'
					. ' and have all other namespace disabled'
			],
		];
	}

	/**
	 * Helper to create a new User object with given options
	 * User remains anonymous though
	 * @param array|null $opt
	 * @return User
	 */
	protected function newUserWithSearchNS( $opt = null ) {
		$u = User::newFromId( 0 );
		if ( $opt === null ) {
			return $u;
		}
		$userOptionsManager = $this->getServiceContainer()->getUserOptionsManager();
		foreach ( $opt as $name => $value ) {
			$userOptionsManager->setOption( $u, $name, $value );
		}

		return $u;
	}

	/**
	 * Verify we do not expand search term in <title> on search result page
	 * https://gerrit.wikimedia.org/r/4841
	 * @covers \MediaWiki\Specials\SpecialSearch::setupPage
	 */
	public function testSearchTermIsNotExpanded() {
		// T303046
		$this->markTestSkippedIfDbType( 'sqlite' );
		$this->overrideConfigValue( MainConfigNames::SearchType, null );

		# Initialize [[Special::Search]]
		$ctx = new RequestContext();
		$term = '{{SITENAME}}';
		$ctx->setRequest( new FauxRequest( [ 'search' => $term, 'fulltext' => 1 ] ) );
		$ctx->setTitle( Title::makeTitle( NS_SPECIAL, 'Search' ) );
		$search = $this->newSpecialPage();
		$search->setContext( $ctx );

		# Simulate a user searching for a given term
		$search->execute( '' );

		# Lookup the HTML page title set for that page
		$pageTitle = $search
			->getContext()
			->getOutput()
			->getHTMLTitle();

		# Compare :-]
		$this->assertMatchesRegularExpression(
			'/' . preg_quote( $term, '/' ) . '/',
			$pageTitle,
			"Search term '{$term}' should not be expanded in Special:Search <title>"
		);
	}

	public static function provideRewriteQueryWithSuggestion() {
		return [
			[
				'With suggestion and no rewritten query shows did you mean',
				'/Did you mean: <a[^>]+>first suggestion/',
				'first suggestion',
				null,
				[ Title::newMainPage() ]
			],

			[
				'With rewritten query informs user of change',
				'/Showing results for <a[^>]+>first suggestion/',
				'asdf',
				'first suggestion',
				[ Title::newMainPage() ]
			],

			[
				'When both queries have no results user gets no results',
				'/There were no results matching the query/',
				'first suggestion',
				'first suggestion',
				[]
			],

			[
				'Prev/next links are using the rewritten query',
				'/search=rewritten\+query" rel="next" title="Next 20 results"/',
				'original query',
				'rewritten query',
				array_fill( 0, 100, Title::newMainPage() )
			],

			[
				'Show x results per page link uses the rewritten query',
				'/search=rewritten\+query" title="Show \d+ results/',
				'original query',
				'rewritten query',
				array_fill( 0, 100, Title::newMainPage() )
			],
		];
	}

	/**
	 * @dataProvider provideRewriteQueryWithSuggestion
	 * @covers \MediaWiki\Specials\SpecialSearch::showResults
	 */
	public function testRewriteQueryWithSuggestion(
		$message,
		$expectRegex,
		$suggestion,
		$rewrittenQuery,
		array $resultTitles
	) {
		$results = array_map( static function ( $title ) {
			return SearchResult::newFromTitle( $title );
		}, $resultTitles );

		$searchResults = new SpecialSearchTestMockResultSet(
			$suggestion,
			$rewrittenQuery,
			$results
		);

		$mockSearchEngine = $this->mockSearchEngine( $searchResults );
		$services = $this->getServiceContainer();
		$search = $this->getMockBuilder( SpecialSearch::class )
			->setConstructorArgs( [
				$services->getSearchEngineConfig(),
				$services->getSearchEngineFactory(),
				$services->getNamespaceInfo(),
				$services->getContentHandlerFactory(),
				$services->getInterwikiLookup(),
				$services->getReadOnlyMode(),
				$services->getUserOptionsManager(),
				$services->getLanguageConverterFactory(),
				$services->getRepoGroup(),
				$services->getSearchResultThumbnailProvider(),
				$services->getTitleMatcher()
			] )
			->onlyMethods( [ 'getSearchEngine' ] )
			->getMock();
		$search->method( 'getSearchEngine' )
			->willReturn( $mockSearchEngine );

		$search->getContext()->setTitle( Title::makeTitle( NS_SPECIAL, 'Search' ) );
		$search->getContext()->setLanguage( 'en' );
		$search->load();
		$search->showResults( 'this is a fake search' );

		$html = $search->getContext()->getOutput()->getHTML();
		foreach ( (array)$expectRegex as $regex ) {
			$this->assertMatchesRegularExpression( $regex, $html, $message );
		}
	}

	public static function provideLimitPreference() {
		return [
			[ 20, 20 ],
			[ 101, null ],
		];
	}

	/**
	 * @dataProvider provideLimitPreference
	 * @covers \MediaWiki\Specials\SpecialSearch::showResults
	 */
	public function testLimitPreference(
		$optionValue,
		$expectedLimit
	) {
		$results = array_fill( 0, 100, SearchResult::newFromTitle( Title::newMainPage() ) );

		$searchResults = new SpecialSearchTestMockResultSet(
			'?',
			'!',
			$results
		);

		$userOptionsManager = $this->getServiceContainer()->getUserOptionsManager();

		$user = $this->getTestSysop()->getUser();
		$userOptionsManager->setOption( $user, 'searchlimit', $optionValue );
		$user->saveSettings();

		$mockSearchEngine = $this->mockSearchEngine( $searchResults );
		$services = $this->getServiceContainer();
		$search = $this->getMockBuilder( SpecialSearch::class )
			->setConstructorArgs( [
				$services->getSearchEngineConfig(),
				$services->getSearchEngineFactory(),
				$services->getNamespaceInfo(),
				$services->getContentHandlerFactory(),
				$services->getInterwikiLookup(),
				$services->getReadOnlyMode(),
				$userOptionsManager,
				$services->getLanguageConverterFactory(),
				$services->getRepoGroup(),
				$services->getSearchResultThumbnailProvider(),
				$services->getTitleMatcher()
			] )
			->onlyMethods( [ 'getSearchEngine' ] )
			->getMock();
		$search->method( 'getSearchEngine' )
			->willReturn( $mockSearchEngine );

		$search->getContext()->setTitle( Title::makeTitle( NS_SPECIAL, 'Search' ) );
		$search->getContext()->setUser( $user );
		$search->getContext()->setLanguage( 'en' );
		$search->load();
		$search->showResults( 'this is a fake search' );

		$html = $search->getContext()->getOutput()->getHTML();
		if ( $expectedLimit === null ) {
			$this->assertDoesNotMatchRegularExpression( "/ title=\"Next \\d+ results\"/", $html );
		} else {
			$this->assertMatchesRegularExpression( "/ title=\"Next $expectedLimit results\"/", $html );
		}
	}

	protected function mockSearchEngine( SpecialSearchTestMockResultSet $results ) {
		$mock = $this->getMockBuilder( SearchEngine::class )
			->onlyMethods( [ 'searchText' ] )
			->getMock();

		$mock->method( 'searchText' )
			->willReturn( $results );

		$mock->setHookContainer( $this->getServiceContainer()->getHookContainer() );

		return $mock;
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialSearch::execute
	 */
	public function testSubPageRedirect() {
		$this->overrideConfigValue( MainConfigNames::Script, '/w/index.php' );

		$ctx = new RequestContext;
		$sp = Title::makeTitle( NS_SPECIAL, 'Search/foo_bar' );
		$this->getServiceContainer()->getSpecialPageFactory()->executePath( $sp, $ctx );
		$url = $ctx->getOutput()->getRedirect();

		$parts = parse_url( $url );
		$this->assertEquals( '/w/index.php', $parts['path'] );
		parse_str( $parts['query'], $query );
		$this->assertEquals( 'Special:Search', $query['title'] );
		$this->assertEquals( 'foo bar', $query['search'] );
	}

	/**
	 * If the 'search-match-redirect' user pref is false, then SpecialSearch::goResult() should
	 * return null
	 *
	 * @covers \MediaWiki\Specials\SpecialSearch::goResult
	 */
	public function testGoResult_userPrefRedirectOn() {
		$context = new RequestContext;
		$context->setUser(
			$this->newUserWithSearchNS( [ 'search-match-redirect' => false ] )
		);
		$context->setRequest(
			new FauxRequest( [ 'search' => 'TEST_SEARCH_PARAM', 'fulltext' => 1 ] )
		);
		$search = $this->newSpecialPage();
		$search->setContext( $context );
		$search->load();

		$this->assertNull( $search->goResult( 'TEST_SEARCH_PARAM' ) );
	}

	/**
	 * If the 'search-match-redirect' user pref is true, then SpecialSearch::goResult() should
	 * NOT return null if there is a near match found for the search term
	 *
	 * @covers \MediaWiki\Specials\SpecialSearch::goResult
	 */
	public function testGoResult_userPrefRedirectOff() {
		// mock the search engine so it returns a near match for an arbitrary search term
		$searchResults = new SpecialSearchTestMockResultSet(
			'TEST_SEARCH_SUGGESTION',
			'',
			[ SearchResult::newFromTitle( Title::newMainPage() ) ]
		);

		$nearMatcherMock = $this->getMockBuilder( TitleMatcher::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getNearMatch' ] )
			->getMock();

		$nearMatcherMock->method( 'getNearMatch' )
			->willReturn( $searchResults->getFirstResult() );

		$mockSearchEngine = $this->mockSearchEngine( $searchResults );
		$services = $this->getServiceContainer();
		$search = $this->getMockBuilder( SpecialSearch::class )
			->setConstructorArgs( [
				$services->getSearchEngineConfig(),
				$services->getSearchEngineFactory(),
				$services->getNamespaceInfo(),
				$services->getContentHandlerFactory(),
				$services->getInterwikiLookup(),
				$services->getReadOnlyMode(),
				$services->getUserOptionsManager(),
				$services->getLanguageConverterFactory(),
				$services->getRepoGroup(),
				$services->getSearchResultThumbnailProvider(),
				$nearMatcherMock
			] )
			->onlyMethods( [ 'getSearchEngine' ] )
			->getMock();
		$search->method( 'getSearchEngine' )
			->willReturn( $mockSearchEngine );

		// set up a mock user with 'search-match-redirect' set to true
		$context = new RequestContext;
		$context->setUser(
			$this->newUserWithSearchNS( [ 'search-match-redirect' => true ] )
		);
		$context->setRequest(
			new FauxRequest( [ 'search' => 'TEST_SEARCH_PARAM', 'fulltext' => 1 ] )
		);
		$search->setContext( $context );
		$search->load();

		$this->assertNotNull( $search->goResult( 'TEST_SEARCH_PARAM' ) );
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialSearch::showResults
	 */
	public function test_create_link_not_shown_if_variant_link_is_known() {
		$searchTerm = "Test create link not shown if variant link is known";
		$variantLink = "the replaced link variant text should not be visible";

		$variantTitle = $this->createNoOpMock( Title::class, [ 'isKnown', 'getPrefixedText',
			'getDBkey', 'isExternal' ] );

		$variantTitle->method( "isKnown" )->willReturn( true );
		$variantTitle->method( "isExternal" )->willReturn( false );
		$variantTitle->method( "getDBkey" )->willReturn( $searchTerm . " (variant)" );
		$variantTitle->method( "getPrefixedText" )->willReturn( $searchTerm . " (variant)" );

		$specialSearchFactory = function () use ( $variantTitle, $variantLink, $searchTerm ) {
			$languageConverter = $this->createMock( ILanguageConverter::class );
			$languageConverter->method( 'hasVariants' )->willReturn( true );
			$languageConverter->expects( $this->once() )
				->method( 'findVariantLink' )
				->willReturnCallback(
					static function ( &$link, &$nt, $unused = false ) use ( $searchTerm, $variantTitle, $variantLink ) {
						if ( $link === $searchTerm ) {
							$link = $variantLink;
							$nt = $variantTitle;
						}
					}
				);
			$languageConverterFactory = $this->createMock( LanguageConverterFactory::class );
			$languageConverterFactory->method( 'getLanguageConverter' )
				->willReturn( $languageConverter );

			$mockSearchEngineFactory = $this->createMock( SearchEngineFactory::class );
			$mockSearchEngineFactory->method( "create" )
				->willReturn( $this->mockSearchEngine( new SpecialSearchTestMockResultSet() ) );

			$services = $this->getServiceContainer();
			$specialSearch = new SpecialSearch(
				$services->getSearchEngineConfig(),
				$mockSearchEngineFactory,
				$services->getNamespaceInfo(),
				$services->getContentHandlerFactory(),
				$services->getInterwikiLookup(),
				$services->getReadOnlyMode(),
				$services->getUserOptionsManager(),
				$languageConverterFactory,
				$services->getRepoGroup(),
				$services->getSearchResultThumbnailProvider(),
				$services->getTitleMatcher()
			);
			$context = new RequestContext();
			$context->setRequest( new FauxRequest() );
			$context->setTitle( Title::makeTitle( NS_SPECIAL, 'Search' ) );
			$specialSearch->setContext( $context );
			$specialSearch->load();
			return $specialSearch;
		};
		$specialSearch = $specialSearchFactory();
		$specialSearch->showResults( $searchTerm );
		$html = $specialSearch->getContext()->getOutput()->getHTML();
		$this->assertStringNotContainsString( $variantLink, $html );
		$this->assertStringContainsString( 'class="mw-search-exists"', $html );
		$this->assertStringNotContainsString( 'class="mw-search-createlink"', $html );

		$specialSearch = $specialSearchFactory();
		$specialSearch->showResults( $searchTerm . "_search_create_link" );
		$html = $specialSearch->getContext()->getOutput()->getHTML();
		$this->assertStringContainsString( 'class="mw-search-createlink"', $html );
		$this->assertStringNotContainsString( 'class="mw-search-exists"', $html );
	}
}
PK       ! uh  h    specials/SpecialLogTest.phpnu Iw        <?php
/**
 * @license GPL-2.0-or-later
 * @author Legoktm
 */

use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Specials\SpecialLog;

/**
 * @group Database
 * @covers \MediaWiki\Specials\SpecialLog
 */
class SpecialLogTest extends SpecialPageTestBase {

	/**
	 * Returns a new instance of the special page under test.
	 *
	 * @return SpecialPage
	 */
	protected function newSpecialPage() {
		$services = $this->getServiceContainer();
		return new SpecialLog(
			$services->getLinkBatchFactory(),
			$services->getConnectionProvider(),
			$services->getActorNormalization(),
			$services->getUserIdentityLookup(),
			$services->getUserNameUtils(),
			$services->getLogFormatterFactory()
		);
	}

	/**
	 * Verify that no exception was thrown for an invalid date
	 * @see T201411
	 */
	public function testInvalidDate() {
		[ $html, ] = $this->executeSpecialPage(
			'',
			// There is no 13th month
			new FauxRequest( [ 'wpdate' => '2018-13-01' ] ),
			'qqx'
		);
		$this->assertStringContainsString( '(log-summary)', $html );
	}

	public function testSuppressionLog() {
		// Have "BadGuy" create a revision
		$user = ( new TestUser( 'BadGuy' ) )->getUser();
		$title = $this->insertPage( 'Foo', 'Bar', null, $user )['title'];
		$revId = $title->getLatestRevID();

		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser( $this->getTestUser( [ 'sysop', 'suppress' ] )->getUser() );
		// Hide our revision's comment
		$list = RevisionDeleter::createList( 'revision', $context, $title, [ $revId ] );
		$status = $list->setVisibility( [
			'value' => [
				RevisionRecord::DELETED_RESTRICTED => 1,
				RevisionRecord::DELETED_COMMENT => 1
			],
			'comment' => 'SpecialLogTest'
		] );
		$this->assertStatusGood( $status );

		// Allow everyone to read the suppression log
		$this->mergeMwGlobalArrayValue(
			'wgGroupPermissions', [
				'*' => [
					'suppressionlog' => true
				]
			]
		);

		[ $html, ] = $this->executeSpecialPage(
			'suppress',
			new FauxRequest( [ 'offender' => 'BadGuy' ] ),
			'qqx'
		);
		$this->assertStringNotContainsString( '(logempty)', $html );
		$this->assertStringContainsString( '(logentry-suppress-revision', $html );

		// Full suppression log
		[ $html, ] = $this->executeSpecialPage(
			'suppress',
			new FauxRequest(),
			'qqx'
		);
		$this->assertStringNotContainsString( '(logempty)', $html );
		$this->assertStringContainsString( '(logentry-suppress-revision', $html );

		// Suppression log for unknown user should be empty
		[ $html, ] = $this->executeSpecialPage(
			'suppress',
			new FauxRequest( [ 'offender' => 'GoodGuy' ] ),
			'qqx'
		);
		$this->assertStringContainsString( '(logempty)', $html );
		$this->assertStringNotContainsString( '(logentry-suppress-revision', $html );
	}

}
PK       ! +$  $  #  specials/SpecialPreferencesTest.phpnu Iw        <?php
/**
 * Test class for SpecialPreferences class.
 *
 * Copyright © 2013, Antoine Musso
 * Copyright © 2013, Wikimedia Foundation Inc.
 */

use MediaWiki\MainConfigNames;
use MediaWiki\Specials\SpecialPreferences;
use MediaWiki\User\Options\UserOptionsManager;

/**
 * @group Preferences
 * @group Database
 *
 * @covers \MediaWiki\Specials\SpecialPreferences
 */
class SpecialPreferencesTest extends SpecialPageTestBase {
	/**
	 * HACK: use this variable to override UserOptionsManager for use in the special page. Ideally we'd just do
	 * $this->setService, but that's super hard because some places that use UserOptionsManager read a lot from the
	 * global state and a mock would need to be super-complex for all the various checks to work.
	 */
	private ?UserOptionsManager $userOptionsManager = null;

	protected function tearDown(): void {
		$this->userOptionsManager = null;
		parent::tearDown();
	}

	protected function newSpecialPage() {
		return new SpecialPreferences(
			$this->getServiceContainer()->getPreferencesFactory(),
			$this->userOptionsManager ?? $this->getServiceContainer()->getUserOptionsManager()
		);
	}

	/**
	 * Make sure a username which is longer than $wgMaxSigChars
	 * is not throwing a fatal error (T43337).
	 */
	public function testLongUsernameDoesNotFatal() {
		$maxSigChars = 2;
		$this->overrideConfigValue( MainConfigNames::MaxSigChars, $maxSigChars );
		$nickname = str_repeat( 'x', $maxSigChars + 1 );
		$user = $this->getMutableTestUser()->getUser();

		$this->userOptionsManager = $this->createMock( UserOptionsManager::class );
		$this->userOptionsManager->method( 'getOption' )
			->with( $user, 'nickname' )
			->willReturn( $nickname );

		$this->executeSpecialPage( '', null, null, $user );
		// We assert that no error is thrown
		$this->addToAssertionCount( 1 );
	}

}
PK       ! p    !  specials/SpecialWatchlistTest.phpnu Iw        <?php

use MediaWiki\Context\DerivativeContext;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Specials\SpecialWatchlist;
use Wikimedia\TestingAccessWrapper;

/**
 * @author Addshore
 *
 * @group Database
 *
 * @covers \MediaWiki\Specials\SpecialWatchlist
 */
class SpecialWatchlistTest extends SpecialPageTestBase {
	protected function setUp(): void {
		parent::setUp();

		$this->setTemporaryHook(
			'ChangesListSpecialPageQuery',
			HookContainer::NOOP
		);

		$this->overrideConfigValues( [
			MainConfigNames::DefaultUserOptions =>
				[
					'extendwatchlist' => 1,
					'watchlistdays' => 3.0,
					'watchlisthideanons' => 0,
					'watchlisthidebots' => 0,
					'watchlisthideliu' => 0,
					'watchlisthideminor' => 0,
					'watchlisthideown' => 0,
					'watchlisthidepatrolled' => 1,
					'watchlisthidecategorization' => 0,
					'watchlistreloadautomatically' => 0,
					'watchlistunwatchlinks' => 0,
					'timecorrection' => '0'
				],
			MainConfigNames::WatchlistExpiry => true
		] );
	}

	/**
	 * Returns a new instance of the special page under test.
	 *
	 * @return SpecialWatchlist
	 */
	protected function newSpecialPage() {
		$services = $this->getServiceContainer();
		return new SpecialWatchlist(
			$services->getWatchedItemStore(),
			$services->getWatchlistManager(),
			$services->getUserOptionsLookup(),
			$services->getChangeTagsStore(),
			$services->getUserIdentityUtils(),
			$services->getTempUserConfig()
		);
	}

	public function testNotLoggedIn_throwsException() {
		$this->expectException( UserNotLoggedIn::class );
		$this->executeSpecialPage();
	}

	public function testUserWithNoWatchedItems_displaysNoWatchlistMessage() {
		$user = new TestUser( __METHOD__ );
		[ $html, ] = $this->executeSpecialPage( '', null, 'qqx', $user->getUser() );
		$this->assertStringContainsString( '(nowatchlist)', $html );
	}

	/**
	 * @dataProvider provideFetchOptionsFromRequest
	 */
	public function testFetchOptionsFromRequest(
		$expectedValuesDefaults, $expectedValues, $preferences, $inputParams
	) {
		// $defaults and $allFalse are just to make the expected values below
		// shorter by hiding the background.

		/** @var SpecialWatchlist $page */
		$page = TestingAccessWrapper::newFromObject(
			$this->newSpecialPage()
		);

		$page->registerFilters();

		// Does not consider $preferences, just wiki's defaults
		$wikiDefaults = $page->getDefaultOptions()->getAllValues();

		switch ( $expectedValuesDefaults ) {
			case 'allFalse':
				$allFalse = $wikiDefaults;

				foreach ( $allFalse as $key => $value ) {
					if ( $value === true ) {
						$allFalse[$key] = false;
					}
				}

				// This is not exposed on the form (only in preferences) so it
				// respects the preference.
				$allFalse['extended'] = true;

				$expectedValues += $allFalse;
				break;
			case 'wikiDefaults':
				$expectedValues += $wikiDefaults;
				break;
			default:
				$this->fail( "Unknown \$expectedValuesDefaults: $expectedValuesDefaults" );
		}

		$page = TestingAccessWrapper::newFromObject(
			$this->newSpecialPage()
		);

		$context = new DerivativeContext( $page->getContext() );

		$fauxRequest = new FauxRequest( $inputParams, /* $wasPosted= */ false );
		$user = $this->getTestUser()->getUser();

		$userOptionsManager = $this->getServiceContainer()->getUserOptionsManager();
		foreach ( $preferences as $key => $value ) {
			$userOptionsManager->setOption( $user, $key, $value );
		}

		$context->setRequest( $fauxRequest );
		$context->setUser( $user );
		$page->setContext( $context );

		$page->registerFilters();
		$formOptions = $page->getDefaultOptions();
		$page->fetchOptionsFromRequest( $formOptions );

		$this->assertArrayEquals(
			$expectedValues,
			$formOptions->getAllValues(),
			/* $ordered= */ false,
			/* $named= */ true
		);
	}

	public static function provideFetchOptionsFromRequest() {
		return [
			'ignores casing' => [
				'expectedValuesDefaults' => 'wikiDefaults',
				'expectedValues' => [
					'hideminor' => true,
				],
				'preferences' => [],
				'inputParams' => [
					'hideMinor' => 1,
				],
			],

			'first two same as prefs, second two overridden' => [
				'expectedValuesDefaults' => 'wikiDefaults',
				'expectedValues' => [
					// First two same as prefs
					'hideminor' => true,
					'hidebots' => false,

					// Second two overridden
					'hideanons' => false,
					'hideliu' => true,
					'userExpLevel' => 'registered'
				],
				'preferences' => [
					'watchlisthideminor' => 1,
					'watchlisthidebots' => 0,

					'watchlisthideanons' => 1,
					'watchlisthideliu' => 0,
				],
				'inputParams' => [
					'hideanons' => 0,
					'hideliu' => 1,
				],
			],

			'Defaults/preferences for form elements are entirely ignored for '
			. 'action=submit and omitted elements become false' => [
				'expectedValuesDefaults' => 'allFalse',
				'expectedValues' => [
					'hideminor' => false,
					'hidebots' => true,
					'hideanons' => false,
					'hideliu' => true,
					'userExpLevel' => 'unregistered'
				],
				'preferences' => [
					'watchlisthideminor' => 0,
					'watchlisthidebots' => 1,

					'watchlisthideanons' => 0,
					'watchlisthideliu' => 1,
				],
				'inputParams' => [
					'hidebots' => 1,
					'hideliu' => 1,
					'action' => 'submit',
				],
			],
		];
	}
}
PK       ! R;s>  >    specials/ImageListPagerTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Pager\ImageListPager;

/**
 * Test class for ImageListPagerTest class.
 *
 * Copyright © 2013, Antoine Musso
 * Copyright © 2013, Siebrand Mazeland
 * Copyright © 2013, Wikimedia Foundation Inc.
 *
 * @group Database
 */
class ImageListPagerTest extends MediaWikiIntegrationTestCase {
	/**
	 * @covers \MediaWiki\Pager\ImageListPager::formatValue
	 */
	public function testFormatValuesThrowException() {
		$services = $this->getServiceContainer();
		$page = new ImageListPager(
			RequestContext::getMain(),
			$services->getCommentStore(),
			$services->getLinkRenderer(),
			$services->getConnectionProvider(),
			$services->getRepoGroup(),
			$services->getUserNameUtils(),
			$services->getCommentFormatter(),
			$services->getLinkBatchFactory(),
			null,
			'',
			false,
			false
		);
		$this->expectException( UnexpectedValueException::class );
		$this->expectExceptionMessage( "invalid_field" );
		$page->formatValue( 'invalid_field', 'invalid_value' );
	}

	/**
	 * @covers \MediaWiki\Pager\ImageListPager::formatValue
	 */
	public function testFormatValues_img_timestamp() {
		$services = $this->getServiceContainer();
		$this->setUserLang( 'en' );
		$page = new ImageListPager(
			RequestContext::getMain(),
			$services->getCommentStore(),
			$services->getLinkRenderer(),
			$services->getConnectionProvider(),
			$services->getRepoGroup(),
			$services->getUserNameUtils(),
			$services->getCommentFormatter(),
			$services->getLinkBatchFactory(),
			null,
			'',
			false,
			false
		);
		$this->assertEquals( '12:34, 15 January 2001', $page->formatValue( 'img_timestamp', '20010115123456' ) );
	}

	/**
	 * @covers \MediaWiki\Pager\ImageListPager::formatValue
	 */
	public function testFormatValues_img_size() {
		$services = $this->getServiceContainer();
		$this->setUserLang( 'en' );
		$page = new ImageListPager(
			RequestContext::getMain(),
			$services->getCommentStore(),
			$services->getLinkRenderer(),
			$services->getConnectionProvider(),
			$services->getRepoGroup(),
			$services->getUserNameUtils(),
			$services->getCommentFormatter(),
			$services->getLinkBatchFactory(),
			null,
			'',
			false,
			false
		);

		// For very small values we specify exactly
		$this->assertEquals( '10 bytes', $page->formatValue( 'img_size', '10' ) );
		$this->assertEquals( '16 bytes', $page->formatValue( 'img_size', '16' ) );

		// Above 999 but below 1024 we report bytes with thousands separator
		$this->assertEquals( '1,000 bytes', $page->formatValue( 'img_size', '1000' ) );
		$this->assertEquals( '1,023 bytes', $page->formatValue( 'img_size', '1023' ) );

		// For kilobytes we round to the nearest
		$this->assertEquals( '1 KB', $page->formatValue( 'img_size', '1100' ) );
		$this->assertEquals( '2 KB', $page->formatValue( 'img_size', '1600' ) );

		// For megabytes and above we specify up to 2 decimal places if appropriate
		$this->assertEquals( '1 MB', $page->formatValue( 'img_size', '1048576' ) );
		$this->assertEquals( '1.05 MB', $page->formatValue( 'img_size', '1100000' ) );
		$this->assertEquals( '1.53 MB', $page->formatValue( 'img_size', '1600000' ) );

		$this->assertEquals( '1 GB', $page->formatValue( 'img_size', '1073741824' ) );
		$this->assertEquals( '1.02 GB', $page->formatValue( 'img_size', '1100000000' ) );
		$this->assertEquals( '1.49 GB', $page->formatValue( 'img_size', '1600000000' ) );
	}

	/**
	 * @covers \MediaWiki\Pager\ImageListPager::formatValue
	 */
	public function testFormatValues_count() {
		$services = $this->getServiceContainer();
		$page = new ImageListPager(
			RequestContext::getMain(),
			$services->getCommentStore(),
			$services->getLinkRenderer(),
			$services->getConnectionProvider(),
			$services->getRepoGroup(),
			$services->getUserNameUtils(),
			$services->getCommentFormatter(),
			$services->getLinkBatchFactory(),
			null,
			'',
			false,
			false
		);

		// Values are 0-indexed but humans count from 1.
		$this->assertSame( '1', $page->formatValue( 'count', '0' ) );
		$this->assertSame( '2', $page->formatValue( 'count', '1' ) );
		$this->assertSame( '3', $page->formatValue( 'count', '2' ) );
	}
}
PK       ! 6  6  $  specials/SpecialConfirmEmailTest.phpnu Iw        <?php

use MediaWiki\Specials\SpecialConfirmEmail;
use MediaWiki\User\UserFactory;

/**
 * @covers \MediaWiki\Specials\SpecialConfirmEmail
 * @group Database
 */
class SpecialConfirmEmailTest extends SpecialPageTestBase {
	protected function newSpecialPage() {
		return new SpecialConfirmEmail(
			$this->createMock( UserFactory::class )
		);
	}

	public function testExecute() {
		[ $html ] = $this->executeSpecialPage(
			'',
			null,
			null,
			$this->getTestUser()->getAuthority()
		);

		$this->assertStringContainsString( '(confirmemail_text)', $html );
	}
}
PK       ! j  j  /  specials/SpecialUncategorizedCategoriesTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Language\RawMessage;
use MediaWiki\Specials\SpecialUncategorizedCategories;
use Wikimedia\Rdbms\Expression;

/**
 * Tests for Special:UncategorizedCategories
 *
 * @group Database
 */
class SpecialUncategorizedCategoriesTest extends MediaWikiIntegrationTestCase {
	/**
	 * @dataProvider provideTestGetQueryInfoData
	 * @covers \MediaWiki\Specials\SpecialUncategorizedCategories::getQueryInfo
	 */
	public function testGetQueryInfo( $msgContent, $expected ) {
		$msg = new RawMessage( $msgContent );
		$mockContext = $this->createMock( RequestContext::class );
		$mockContext->method( 'msg' )->willReturn( $msg );
		$services = $this->getServiceContainer();
		$special = new SpecialUncategorizedCategories(
			$services->getNamespaceInfo(),
			$services->getConnectionProvider(),
			$services->getLinkBatchFactory(),
			$services->getLanguageConverterFactory()
		);
		$special->setContext( $mockContext );
		$this->assertEquals( [
			'tables' => [
				0 => 'page',
				1 => 'categorylinks',
			],
			'fields' => [
				'namespace' => 'page_namespace',
				'title' => 'page_title',
			],
			'conds' => [
				'cl_from' => null,
				'page_namespace' => 14,
				'page_is_redirect' => 0,
			] + $expected,
			'join_conds' => [
				'categorylinks' => [
					0 => 'LEFT JOIN',
					1 => 'cl_from = page_id',
				],
			],
		], $special->getQueryInfo() );
	}

	public static function provideTestGetQueryInfoData() {
		return [
			[
				"* Stubs\n* Test\n* *\n* * test123",
				[ 0 => new Expression( 'page_title', '!=', [ 'Stubs', 'Test', '*', '*_test123' ] ) ]
			],
			[
				"Stubs\n* Test\n* *\n* * test123",
				[ 0 => new Expression( 'page_title', '!=', [ 'Test', '*', '*_test123' ] ) ],
			],
			[
				"* StubsTest\n* *\n* * test123",
				[ 0 => new Expression( 'page_title', '!=', [ 'StubsTest', '*', '*_test123' ] ) ],
			],
			[ "", [] ],
			[ "\n\n\n", [] ],
			[ "\n", [] ],
			[ "Test\n*Test2", [ 0 => new Expression( 'page_title', '!=', [ 'Test2' ] ) ] ],
			[ "Test", [] ],
			[ "*Test\nTest2", [ 0 => new Expression( 'page_title', '!=', [ 'Test' ] ) ] ],
			[ "Test\nTest2", [] ],
		];
	}
}
PK       ! S    +  specials/SpecialSearchTestMockResultSet.phpnu Iw        <?php

class SpecialSearchTestMockResultSet extends SearchResultSet {
	/** @var array */
	protected $results;
	/** @var string|null */
	protected $suggestion;
	/** @var string|null */
	protected $rewrittenQuery;
	/** @var bool */
	protected $containedSyntax;

	public function __construct(
		$suggestion = null,
		$rewrittenQuery = null,
		array $results = [],
		$containedSyntax = false
	) {
		$this->suggestion = $suggestion;
		$this->rewrittenQuery = $rewrittenQuery;
		$this->results = $results;
		$this->containedSyntax = $containedSyntax;
	}

	public function expandResults() {
		return $this->results;
	}

	public function getTotalHits() {
		return $this->numRows();
	}

	public function hasSuggestion() {
		return $this->suggestion !== null;
	}

	public function getSuggestionQuery() {
		return $this->suggestion;
	}

	public function getSuggestionSnippet() {
		return $this->suggestion;
	}

	public function hasRewrittenQuery() {
		return $this->rewrittenQuery !== null;
	}

	public function getQueryAfterRewrite() {
		return $this->rewrittenQuery;
	}

	public function getQueryAfterRewriteSnippet() {
		return htmlspecialchars( $this->rewrittenQuery );
	}

	public function getFirstResult() {
		if ( count( $this->results ) === 0 ) {
			return null;
		}
		return $this->results[0]->getTitle();
	}
}
PK       ! 4W    %  specials/SpecialGoToInterwikiTest.phpnu Iw        <?php

use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\Interwiki\InterwikiLookupAdapter;
use MediaWiki\Site\HashSiteStore;
use MediaWiki\Title\Title;

/**
 * @covers \MediaWiki\Specials\SpecialGoToInterwiki
 */
class SpecialGoToInterwikiTest extends MediaWikiIntegrationTestCase {

	public function testExecute() {
		$this->setService( 'InterwikiLookup', new InterwikiLookupAdapter(
			new HashSiteStore(), // won't be used
			[
				'local' => new Interwiki( 'local', 'https://local.example.com/$1',
					'https://local.example.com/api.php', 'unittest_localwiki', 1 ),
				'nonlocal' => new Interwiki( 'nonlocal', 'https://nonlocal.example.com/$1',
					'https://nonlocal.example.com/api.php', 'unittest_nonlocalwiki', 0 ),
			]
		) );
		$this->getServiceContainer()->resetServiceForTesting( 'TitleFormatter' );
		$this->getServiceContainer()->resetServiceForTesting( 'TitleParser' );
		$this->getServiceContainer()->resetServiceForTesting( '_MediaWikiTitleCodec' );

		$this->assertNotTrue( Title::newFromText( 'Foo' )->isExternal() );
		$this->assertTrue( Title::newFromText( 'local:Foo' )->isExternal() );
		$this->assertTrue( Title::newFromText( 'nonlocal:Foo' )->isExternal() );
		$this->assertTrue( Title::newFromText( 'local:Foo' )->isLocal() );
		$this->assertNotTrue( Title::newFromText( 'nonlocal:Foo' )->isLocal() );

		$goToInterwiki = $this->getServiceContainer()->getSpecialPageFactory()
			->getPage( 'GoToInterwiki' );

		RequestContext::resetMain();
		$context = new DerivativeContext( RequestContext::getMain() );
		$goToInterwiki->setContext( $context );
		$goToInterwiki->execute( 'Foo' );
		$this->assertSame( Title::newFromText( 'Foo' )->getFullURL(),
			$context->getOutput()->getRedirect() );

		RequestContext::resetMain();
		$context = new DerivativeContext( RequestContext::getMain() );
		$goToInterwiki->setContext( $context );
		$goToInterwiki->execute( 'local:Foo' );
		$this->assertSame( Title::newFromText( 'local:Foo' )->getFullURL(),
			$context->getOutput()->getRedirect() );

		RequestContext::resetMain();
		$context = new DerivativeContext( RequestContext::getMain() );
		$goToInterwiki->setContext( $context );
		$goToInterwiki->execute( 'nonlocal:Foo' );
		$this->assertSame( '', $context->getOutput()->getRedirect() );
		$this->assertStringContainsString( Title::newFromText( 'nonlocal:Foo' )->getFullURL(),
			$context->getOutput()->getHTML() );

		RequestContext::resetMain();
		$context = new DerivativeContext( RequestContext::getMain() );
		$goToInterwiki->setContext( $context );
		$goToInterwiki->execute( 'force/Foo' );
		$this->assertSame( Title::newFromText( 'Foo' )->getFullURL(),
			$context->getOutput()->getRedirect() );

		RequestContext::resetMain();
		$context = new DerivativeContext( RequestContext::getMain() );
		$goToInterwiki->setContext( $context );
		$goToInterwiki->execute( 'force/local:Foo' );
		$this->assertSame( '', $context->getOutput()->getRedirect() );
		$this->assertStringContainsString( Title::newFromText( 'local:Foo' )->getFullURL(),
			$context->getOutput()->getHTML() );

		RequestContext::resetMain();
		$context = new DerivativeContext( RequestContext::getMain() );
		$goToInterwiki->setContext( $context );
		$goToInterwiki->execute( 'force/nonlocal:Foo' );
		$this->assertSame( '', $context->getOutput()->getRedirect() );
		$this->assertStringContainsString( Title::newFromText( 'nonlocal:Foo' )->getFullURL(),
			$context->getOutput()->getHTML() );
	}

}
PK       ! ŒR]  ]  *  specials/redirects/SpecialTalkPageTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\Specials\Redirects;

use MediaWiki\Context\RequestContext;
use MediaWiki\Output\OutputPage;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Specials\Redirects\SpecialTalkPage;
use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Specials\Redirects\SpecialTalkPage
 *
 * @license GPL-2.0-or-later
 */
class SpecialTalkPageTest extends MediaWikiIntegrationTestCase {

	private function executeSpecialPageAndGetOutput(
		string $subpage = '',
		?string $target = null
	): OutputPage {
		$services = $this->getServiceContainer();
		$context = new RequestContext();
		if ( $target !== null ) {
			$request = new FauxRequest( [ 'target' => $target ], true );
		} else {
			$request = new FauxRequest();
		}
		$context->setRequest( $request );
		$context->setTitle( Title::newFromText( 'Special:TalkPage' ) );
		$context->setLanguage( $services->getLanguageFactory()->getLanguage( 'qqx' ) );
		$page = new SpecialTalkPage( $services->getMainConfig(), $services->getTitleParser() );
		$page->setContext( $context );

		$page->execute( $subpage );

		return $page->getOutput();
	}

	/** @dataProvider provideRedirects */
	public function testRedirect( $subpage, $target, $expectedUrl, $expectedRedirect ) {
		$output = $this->executeSpecialPageAndGetOutput( $subpage, $target );

		$this->assertSame( $expectedUrl, $output->getRedirect(), 'should redirect to URL' );

		$this->assertHTMLEquals( $expectedRedirect, $output->getHTML(), 'redirect should contain appropriate HTML' );
	}

	public function provideRedirects() {
		$subjectTitleText = 'MediaWiki:ok';
		$subjectTitle = Title::newFromText( $subjectTitleText );
		$talkTitle = $subjectTitle->getTalkPageIfDefined();
		$talkUrl = $talkTitle->getFullUrlForRedirect();

		$standardRedirectHTML = "<div class=\"mw-specialpage-summary\">\n<p>(talkpage-summary)\n</p>\n</div>";

		yield [ $subjectTitleText, null, $talkUrl, $standardRedirectHTML ];
		yield [ '', $subjectTitleText, $talkUrl, $standardRedirectHTML ];
	}

	/** @dataProvider provideNoRedirects */
	public function testNoRedirect( $subpage, $target, ...$expectedHtmls ) {
		$output = $this->executeSpecialPageAndGetOutput( $subpage, $target );

		$this->assertSame( '', $output->getRedirect(), 'should not redirect' );
		foreach ( $expectedHtmls as $expectedHtml ) {
			$this->assertStringContainsString(
				$expectedHtml,
				$output->getHTML(),
				'should contain HTML'
			);
		}
		$this->assertStringContainsString(
			'<form',
			$output->getHTML(),
			'should contain form'
		);
	}

	public function provideNoRedirects() {
		yield [ '', null ];
		yield [ 'Special:TalkPage', null, 'title-invalid-talk-namespace', "value='Special:TalkPage'" ];
		yield [ '', 'Special:TalkPage', 'title-invalid-talk-namespace', "value='Special:TalkPage'" ];
		yield [ '', '<>', 'title-invalid-characters', "value='&lt;&gt;'" ];
	}

}
PK       ! ӁR    "  specials/SpecialShortPagesTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Specials\SpecialShortPages;

/**
 * Test class for SpecialShortPages class
 *
 * @since 1.30
 *
 * @license GPL-2.0-or-later
 */
class SpecialShortPagesTest extends MediaWikiIntegrationTestCase {

	/**
	 * @dataProvider provideGetQueryInfoRespectsContentNs
	 * @covers \MediaWiki\Specials\SpecialShortPages::getQueryInfo
	 */
	public function testGetQueryInfoRespectsContentNS( $contentNS, $blacklistNS, $expectedNS ) {
		$this->overrideConfigValues( [
			MainConfigNames::ShortPagesNamespaceExclusions => $blacklistNS,
			MainConfigNames::ContentNamespaces => $contentNS
		] );
		$this->setTemporaryHook( 'ShortPagesQuery', static function () {
			// empty hook handler
		} );

		$services = $this->getServiceContainer();
		$page = new SpecialShortPages(
			$services->getNamespaceInfo(),
			$services->getConnectionProvider(),
			$services->getLinkBatchFactory()
		);
		$queryInfo = $page->getQueryInfo();

		$this->assertArrayHasKey( 'conds', $queryInfo );
		$this->assertArrayHasKey( 'page_namespace', $queryInfo[ 'conds' ] );
		$this->assertEquals( $expectedNS, $queryInfo[ 'conds' ][ 'page_namespace' ] );
	}

	public static function provideGetQueryInfoRespectsContentNs() {
		return [
			[ [ NS_MAIN, NS_FILE ], [], [ NS_MAIN, NS_FILE ] ],
			[ [ NS_MAIN, NS_TALK ], [ NS_FILE ], [ NS_MAIN, NS_TALK ] ],
			[ [ NS_MAIN, NS_FILE ], [ NS_FILE ], [ NS_MAIN ] ],
			// NS_MAIN namespace is always forced
			[ [], [ NS_FILE ], [ NS_MAIN ] ]
		];
	}

}
PK       ! y;    "  specials/SpecialMIMESearchTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Specials\SpecialMIMESearch;
use MediaWiki\Title\Title;

/**
 * @group Database
 * @covers \MediaWiki\Specials\SpecialMIMESearch
 */
class SpecialMIMESearchTest extends MediaWikiIntegrationTestCase {

	/** @var SpecialMIMESearch */
	private $page;

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

		$services = $this->getServiceContainer();
		$this->page = new SpecialMIMESearch(
			$services->getConnectionProvider(),
			$services->getLinkBatchFactory(),
			$services->getLanguageConverterFactory()
		);
		$context = new RequestContext();
		$context->setTitle( Title::makeTitle( NS_SPECIAL, 'MIMESearch' ) );
		$context->setRequest( new FauxRequest() );
		$this->page->setContext( $context );
	}

	/**
	 * @dataProvider providerMimeFiltering
	 * @param string $par Subpage for special page
	 * @param string $major Major MIME type we expect to look for
	 * @param string $minor Minor MIME type we expect to look for
	 */
	public function testMimeFiltering( $par, $major, $minor ) {
		$this->page->run( $par );
		$qi = $this->page->getQueryInfo();
		$this->assertEquals( $qi['conds']['img_major_mime'], $major );
		if ( $minor !== null ) {
			$this->assertEquals( $qi['conds']['img_minor_mime'], $minor );
		} else {
			$this->assertArrayNotHasKey( 'img_minor_mime', $qi['conds'] );
		}
		$this->assertContains( 'image', $qi['tables'] );
	}

	public static function providerMimeFiltering() {
		return [
			[ 'image/gif', 'image', 'gif' ],
			[ 'image/png', 'image', 'png' ],
			[ 'application/pdf', 'application', 'pdf' ],
			[ 'image/*', 'image', null ],
			[ 'multipart/*', 'multipart', null ],
		];
	}
}
PK       ! :	       specials/SpecialPageExecutor.phpnu Iw        <?php

use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\Language\Language;
use MediaWiki\Output\OutputPage;
use MediaWiki\Permissions\Authority;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\FauxResponse;
use MediaWiki\Request\WebRequest;
use MediaWiki\SpecialPage\SpecialPage;

/**
 * @author Addshore
 *
 * @since 1.27
 */
class SpecialPageExecutor {

	/**
	 * @param SpecialPage $page The special page to execute
	 * @param string|null $subPage The subpage parameter to call the page with
	 * @param WebRequest|null $request Web request that may contain URL parameters, etc
	 * @param Language|string|null $language The language which should be used in the context;
	 * if not specified, the pseudo-code 'qqx' is used
	 * @param Authority|null $performer The user which should be used in the context of this special page
	 * @param bool $fullHtml if true, the entirety of the generated HTML will be returned, this
	 * includes the opening <!DOCTYPE> declaration and closing </html> tag. If false, only value
	 * of OutputPage::getHTML() will be returned except if the page is redirect or where OutputPage
	 * is completely disabled.
	 *
	 * @return array [ string, WebResponse ] A two-elements array containing the HTML output
	 * generated by the special page as well as the response object.
	 */
	public function executeSpecialPage(
		SpecialPage $page,
		$subPage = '',
		?WebRequest $request = null,
		$language = null,
		?Authority $performer = null,
		$fullHtml = false
	) {
		$context = $this->newContext( $request, $language, $performer );

		$output = new OutputPage( $context );
		$context->setOutput( $output );

		$page->setContext( $context );
		$output->setTitle( $page->getPageTitle() );

		$html = $this->getHTMLFromSpecialPage( $page, $subPage, $fullHtml );
		$response = $context->getRequest()->response();

		if ( $response instanceof FauxResponse ) {
			$code = $response->getStatusCode();

			if ( $code > 0 ) {
				$response->header( 'Status: ' . $code . ' ' . HttpStatus::getMessage( $code ) );
			}
		}

		return [ $html, $response ];
	}

	/**
	 * @param WebRequest|null $request
	 * @param Language|string|null $language Defaults to 'qqx'
	 * @param Authority|null $performer
	 *
	 * @return DerivativeContext
	 */
	private function newContext(
		?WebRequest $request = null,
		$language = null,
		?Authority $performer = null
	) {
		$context = new DerivativeContext( RequestContext::getMain() );

		$context->setRequest( $request ?: new FauxRequest() );

		$context->setLanguage( $language ?: 'qqx' );

		if ( $performer !== null ) {
			$context->setAuthority( $performer );
		}

		$this->setEditTokenFromUser( $context );

		// Make sure the skin context is correctly set https://phabricator.wikimedia.org/T200771
		$context->getSkin()->setContext( $context );

		return $context;
	}

	/**
	 * If we are trying to edit and no token is set, supply one.
	 *
	 * @param DerivativeContext $context
	 */
	private function setEditTokenFromUser( DerivativeContext $context ) {
		$request = $context->getRequest();

		// Edits via GET are a security issue and should not succeed. On the other hand, not all
		// POST requests are edits, but should ignore unused parameters.
		if ( !$request->getCheck( 'wpEditToken' ) && $request->wasPosted() ) {
			$request->setVal( 'wpEditToken', $context->getUser()->getEditToken() );
		}
	}

	/**
	 * @param SpecialPage $page
	 * @param string|null $subPage
	 * @param bool $fullHtml
	 *
	 * @return string HTML
	 */
	private function getHTMLFromSpecialPage( SpecialPage $page, $subPage, $fullHtml ) {
		ob_start();

		try {
			$page->execute( $subPage );

			$output = $page->getOutput();

			if ( $output->getRedirect() !== '' ) {
				$output->output();
				$html = ob_get_contents();
			} elseif ( $output->isDisabled() ) {
				$html = ob_get_contents();
			} elseif ( $fullHtml ) {
				$html = $output->output( true );
			} else {
				$html = $output->getHTML();
			}
		} finally {
			ob_end_clean();
		}

		return $html;
	}

}
PK       ! *Z       specials/SpecialPageDataTest.phpnu Iw        <?php

use MediaWiki\LinkedData\PageDataRequestHandler;
use MediaWiki\Output\OutputPage;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\FauxResponse;
use MediaWiki\Specials\SpecialPageData;

/**
 * @covers \MediaWiki\Specials\SpecialPageData
 * @group Database
 * @group SpecialPage
 *
 * @author Daniel Kinzler
 */
class SpecialPageDataTest extends SpecialPageTestBase {

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

		$this->setContentLang( 'qqx' );
	}

	protected function newSpecialPage() {
		$page = new SpecialPageData();

		// why is this needed?
		$page->getContext()->setOutput( new OutputPage( $page->getContext() ) );

		$page->setRequestHandler( new PageDataRequestHandler() );

		return $page;
	}

	public static function provideExecute() {
		$cases = [];

		$cases['Empty request'] = [ '', [], [], '!!', 200 ];

		$cases['Only title specified'] = [
			'',
			[ 'target' => 'Helsinki' ],
			[],
			'!!',
			303,
			[ 'Location' => '!.+!' ]
		];

		$cases['Accept only HTML'] = [
			'',
			[ 'target' => 'Helsinki' ],
			[ 'Accept' => 'text/HTML' ],
			'!!',
			303,
			[ 'Location' => '!Helsinki$!' ]
		];

		$cases['Accept only HTML with revid'] = [
			'',
			[
				'target' => 'Helsinki',
				'revision' => '4242',
			],
			[ 'Accept' => 'text/HTML' ],
			'!!',
			303,
			[ 'Location' => '!Helsinki(\?|&)oldid=4242!' ]
		];

		$cases['Nothing specified'] = [
			'main/Helsinki',
			[],
			[],
			'!!',
			303,
			[ 'Location' => '!Helsinki&action=raw!' ]
		];

		$cases['Nothing specified 2'] = [
			'/Helsinki',
			[],
			[],
			'!!',
			303,
			[ 'Location' => '!Helsinki&action=raw!' ]
		];

		$cases['Invalid Accept header'] = [
			'main/Helsinki',
			[],
			[ 'Accept' => 'text/foobar' ],
			'!!',
			406,
			[],
		];

		return $cases;
	}

	/**
	 * @dataProvider provideExecute
	 *
	 * @param string $subpage The subpage to request (or '')
	 * @param array $params Request parameters
	 * @param array $headers Request headers
	 * @param string $expRegExp Regex to match the output against.
	 * @param int $expCode Expected HTTP status code
	 * @param array $expHeaders Expected HTTP response headers
	 */
	public function testExecute(
		$subpage,
		array $params,
		array $headers,
		$expRegExp,
		$expCode = 200,
		array $expHeaders = []
	) {
		$request = new FauxRequest( $params );
		$request->response()->header( 'Status: 200 OK', true, 200 ); // init/reset

		foreach ( $headers as $name => $value ) {
			$request->setHeader( strtoupper( $name ), $value );
		}

		try {
			/** @var FauxResponse $response */
			[ $output, $response ] = $this->executeSpecialPage( $subpage, $request );

			$this->assertEquals( $expCode, $response->getStatusCode(), "status code" );
			$this->assertMatchesRegularExpression( $expRegExp, $output, "output" );

			foreach ( $expHeaders as $name => $exp ) {
				$value = $response->getHeader( $name );
				$this->assertNotNull( $value, "header: $name" );
				$this->assertIsString( $value, "header: $name" );
				$this->assertMatchesRegularExpression( $exp, $value, "header: $name" );
			}
		} catch ( HttpError $e ) {
			$this->assertEquals( $expCode, $e->getStatusCode(), "status code" );
			$this->assertMatchesRegularExpression( $expRegExp, $e->getHTML(), "error output" );
		}
	}

	public function testSpecialPageWithoutParameters() {
		$request = new FauxRequest();
		$request->response()->header( 'Status: 200 OK', true, 200 ); // init/reset

		[ $output, ] = $this->executeSpecialPage( '', $request );

		$this->assertStringContainsString( '(pagedata-text)', $output );
	}

}
PK       ! nŕ    "  specials/SpecialMyLanguageTest.phpnu Iw        <?php

use MediaWiki\Content\WikitextContent;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Specials\SpecialMyLanguage;
use MediaWiki\Specials\SpecialPageLanguage;
use MediaWiki\Title\Title;

/**
 * @group Database
 * @covers \MediaWiki\Specials\SpecialMyLanguage
 */
class SpecialMyLanguageTest extends MediaWikiIntegrationTestCase {
	public function addDBDataOnce() {
		$titles = [
			'Page/Another',
			'Page/Another/ar',
			'Page/Another/en',
			'Page/Another/ru',
			'Page/Another/zh',
			'Page/Foreign',
			'Page/Foreign/en',
			'Page/Foreign/zh',
			'Page/Redirect',
		];
		// In the real-world, they are in respective languages,
		// but we don't need to set all of them for tests.
		$pageLang = [
			'Page/Foreign' => 'zh',
		];
		$pageContent = [
			'Page/Redirect' => '#REDIRECT [[Page/Another#Section]]',
		];
		$user = $this->getTestSysop()->getUser();
		$context = RequestContext::getMain();
		$context->setUser( $user );
		foreach ( $titles as $title ) {
			$this->editPage(
				$title,
				new WikitextContent( $pageContent[$title] ?? 'SpecialMyLanguageTest content' ),
				'SpecialMyLanguageTest Summary',
				NS_MAIN,
				$user
			);
			if ( isset( $pageLang[$title] ) ) {
				SpecialPageLanguage::changePageLanguage(
					$context, Title::newFromText( $title ), $pageLang[$title], 'Test' );
			}
		}
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialMyLanguage::findTitle
	 * @dataProvider provideFindTitle
	 * @param string $expected
	 * @param string $subpage
	 * @param string $contLang
	 * @param string $userLang
	 */
	public function testFindTitle( $expected, $subpage, $contLang, $userLang ) {
		$this->setContentLang( $contLang );
		$services = $this->getServiceContainer();
		$special = new SpecialMyLanguage(
			$services->getLanguageNameUtils(),
			$services->getRedirectLookup()
		);
		$special->getContext()->setLanguage( $userLang );
		$this->overrideConfigValue( MainConfigNames::PageLanguageUseDB, true );
		// Test with subpages both enabled and disabled
		$this->mergeMwGlobalArrayValue( 'wgNamespacesWithSubpages', [ NS_MAIN => true ] );
		$this->assertTitle( $expected, $special->findTitle( $subpage ) );
		$this->mergeMwGlobalArrayValue( 'wgNamespacesWithSubpages', [ NS_MAIN => false ] );
		$this->assertTitle( $expected, $special->findTitle( $subpage ) );
	}

	/**
	 * @param string $expected
	 * @param Title|null $title
	 */
	private function assertTitle( $expected, $title ) {
		if ( $expected === null ) {
			$this->assertNull( $title );
		} else {
			$expected = Title::newFromText( $expected );
			$this->assertTrue( $expected->isSameLinkAs( $title ) );
		}
	}

	public static function provideFindTitle() {
		// See addDBDataOnce() for page declarations
		return [
			// [ $expected, $subpage, $contLang, $userLang ]
			[ null, '::Fail', 'en', 'en' ],
			[ 'Page/Another', 'Page/Another/en', 'en', 'en' ],
			[ 'Page/Another', 'Page/Another', 'en', 'en' ],
			[ 'Page/Another/ru', 'Page/Another', 'en', 'ru' ],
			[ 'Page/Another', 'Page/Another', 'en', 'es' ],
			[ 'Page/Another/zh', 'Page/Another', 'en', 'zh' ],
			[ 'Page/Another/zh', 'Page/Another', 'en', 'zh-hans' ],
			[ 'Page/Another/zh', 'Page/Another', 'en', 'zh-mo' ],
			[ 'Page/Another/zh', 'Page/Another', 'en', 'gan' ],
			[ 'Page/Another/zh', 'Page/Another', 'en', 'gan-hant' ],
			[ 'Page/Another/en', 'Page/Another', 'de', 'es' ],
			[ 'Page/Another/ar', 'Page/Another', 'en', 'ar' ],
			[ 'Page/Another/ar', 'Page/Another', 'en', 'arz' ],
			[ 'Page/Another/ar', 'Page/Another/de', 'en', 'arz' ],
			[ 'Page/Another/ru', 'Page/Another/ru', 'en', 'arz' ],
			[ 'Page/Another/ar', 'Page/Another/ru', 'en', 'ar' ],
			[ null, 'Special:Blankpage', 'en', 'ar' ],
			[ null, 'Media:Fail', 'en', 'ar' ],
			[ 'Page/Foreign/en', 'Page/Foreign', 'en', 'en' ],
			[ 'Page/Foreign', 'Page/Foreign', 'en', 'zh-hk' ],
			[ 'Page/Another/ar#Section', 'Page/Redirect', 'en', 'ar' ],
		];
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialMyLanguage::findTitleForTransclusion
	 * @dataProvider provideFindTitleForTransclusion
	 * @param string $expected
	 * @param string $subpage
	 * @param string $langCode
	 * @param string $userLang
	 */
	public function testFindTitleForTransclusion( $expected, $subpage, $langCode, $userLang ) {
		$this->setContentLang( $langCode );
		$services = $this->getServiceContainer();
		$special = new SpecialMyLanguage(
			$services->getLanguageNameUtils(),
			$services->getRedirectLookup()
		);
		$special->getContext()->setLanguage( $userLang );
		// Test with subpages both enabled and disabled
		$this->mergeMwGlobalArrayValue( 'wgNamespacesWithSubpages', [ NS_MAIN => true ] );
		$this->assertTitle( $expected, $special->findTitleForTransclusion( $subpage ) );
		$this->mergeMwGlobalArrayValue( 'wgNamespacesWithSubpages', [ NS_MAIN => false ] );
		$this->assertTitle( $expected, $special->findTitleForTransclusion( $subpage ) );
	}

	public static function provideFindTitleForTransclusion() {
		// See addDBDataOnce() for page declarations
		return [
			// [ $expected, $subpage, $langCode, $userLang ]
			[ 'Page/Another/en', 'Page/Another/en', 'en', 'en' ],
			[ 'Page/Another/en', 'Page/Another', 'en', 'en' ],
			[ 'Page/Another/en', 'Page/Another', 'en', 'frc' ],
		];
	}
}
PK       ! *S'  S'  &  specials/pagers/BlockListPagerTest.phpnu Iw        <?php

use MediaWiki\Block\BlockActionInfo;
use MediaWiki\Block\BlockRestrictionStore;
use MediaWiki\Block\BlockUtils;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\HideUserUtils;
use MediaWiki\Block\Restriction\NamespaceRestriction;
use MediaWiki\Block\Restriction\PageRestriction;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\CommentFormatter\RowCommentFormatter;
use MediaWiki\CommentStore\CommentStore;
use MediaWiki\Context\RequestContext;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\MainConfigNames;
use MediaWiki\Pager\BlockListPager;
use MediaWiki\Request\FauxRequest;
use MediaWiki\SpecialPage\SpecialPageFactory;
use MediaWiki\Utils\MWTimestamp;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Database
 * @coversDefaultClass \MediaWiki\Pager\BlockListPager
 */
class BlockListPagerTest extends MediaWikiIntegrationTestCase {

	/** @var BlockActionInfo */
	private $blockActionInfo;

	/** @var BlockRestrictionStore */
	private $blockRestrictionStore;

	/** @var BlockUtils */
	private $blockUtils;

	/** @var HideUserUtils */
	private $hideUserUtils;

	/** @var CommentStore */
	private $commentStore;

	/** @var LinkRenderer */
	private $linkRenderer;

	/** @var LinkBatchFactory */
	private $linkBatchFactory;

	/** @var IConnectionProvider */
	private $dbProvider;

	/** @var RowCommentFormatter */
	private $rowCommentFormatter;

	/** @var SpecialPageFactory */
	private $specialPageFactory;

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

		$services = $this->getServiceContainer();
		$this->blockActionInfo = $services->getBlockActionInfo();
		$this->blockRestrictionStore = $services->getBlockRestrictionStore();
		$this->blockUtils = $services->getBlockUtils();
		$this->hideUserUtils = $services->getHideUserUtils();
		$this->commentStore = $services->getCommentStore();
		$this->linkBatchFactory = $services->getLinkBatchFactory();
		$this->linkRenderer = $services->getLinkRenderer();
		$this->dbProvider = $services->getConnectionProvider();
		$this->rowCommentFormatter = $services->getRowCommentFormatter();
		$this->specialPageFactory = $services->getSpecialPageFactory();
	}

	private function getBlockListPager() {
		return new BlockListPager(
			RequestContext::getMain(),
			$this->blockActionInfo,
			$this->blockRestrictionStore,
			$this->blockUtils,
			$this->hideUserUtils,
			$this->commentStore,
			$this->linkBatchFactory,
			$this->linkRenderer,
			$this->dbProvider,
			$this->rowCommentFormatter,
			$this->specialPageFactory,
			[]
		);
	}

	/**
	 * @covers ::formatValue
	 * @dataProvider formatValueEmptyProvider
	 * @dataProvider formatValueDefaultProvider
	 */
	public function testFormatValue( $name, $expected, $row ) {
		// Set the time to now so it does not get off during the test.
		MWTimestamp::setFakeTime( '20230405060708' );

		$value = $row->$name ?? null;

		if ( $name === 'bl_timestamp' ) {
			// Wrap the expected timestamp in a string with the timestamp in the format
			// used by the BlockListPager.
			$linkRenderer = $this->getServiceContainer()->getLinkRenderer();
			$link = $linkRenderer->makeKnownLink(
				$this->specialPageFactory->getTitleForAlias( 'BlockList' ),
				MWTimestamp::getInstance( $value )->format( 'H:i, j F Y' ),
				[],
				[ 'wpTarget' => "#{$row->bl_id}" ],
			);
			$expected = $link;
		}

		$pager = $this->getBlockListPager();
		$wrappedPager = TestingAccessWrapper::newFromObject( $pager );
		$wrappedPager->mCurrentRow = $row;

		$formatted = $pager->formatValue( $name, $value );
		$this->assertStringMatchesFormat( $expected, $formatted );
	}

	/**
	 * Test empty values.
	 */
	public static function formatValueEmptyProvider() {
		$row = (object)[
			'bl_id' => 1,
		];

		return [
			[ 'test', 'Unable to format test', $row ],
			[ 'bl_timestamp', null, $row ],
			[ 'bl_expiry', 'infinite<br />0 seconds left', $row ],
		];
	}

	/**
	 * Test the default row values.
	 */
	public static function formatValueDefaultProvider() {
		$row = (object)[
			'bt_user' => 0,
			'bt_user_text' => null,
			'bt_address' => '127.0.0.1',
			'bl_id' => 1,
			'bl_by_text' => 'Admin',
			'bt_auto' => 0,
			'bl_anon_only' => 0,
			'bl_create_account' => 1,
			'bl_enable_autoblock' => 1,
			'bl_deleted' => 0,
			'bl_block_email' => 0,
			'bl_allow_usertalk' => 0,
			'bl_sitewide' => 1,
		];

		return [
			[
				'test',
				'Unable to format test',
				$row,
			],
			[
				'bl_timestamp',
				'20230405060708',
				$row,
			],
			[
				'bl_expiry',
				'infinite<br />0 seconds left',
				$row,
			],
			[
				'by',
				'<a %s><bdi>Admin</bdi></a>%s',
				$row,
			],
			[
				'params',
				'<ul><li>editing (sitewide)</li>' .
					'<li>account creation disabled</li><li>cannot edit own talk page</li></ul>',
				$row,
			]
		];
	}

	/**
	 * @covers ::formatValue
	 * @covers ::getRestrictionListHTML
	 */
	public function testFormatValueRestrictions() {
		$this->overrideConfigValues( [
			MainConfigNames::Script => '/w/index.php',
		] );

		$pager = $this->getBlockListPager();

		$row = (object)[
			'bl_id' => 0,
			'bt_user' => 0,
			'bl_anon_only' => 0,
			'bl_enable_autoblock' => 0,
			'bl_create_account' => 0,
			'bl_block_email' => 0,
			'bl_allow_usertalk' => 1,
			'bl_sitewide' => 0,
			'bl_deleted' => 0,
		];
		$wrappedPager = TestingAccessWrapper::newFromObject( $pager );
		$wrappedPager->mCurrentRow = $row;

		$pageName = 'Victor Frankenstein';
		$page = $this->insertPage( $pageName );
		$title = $page['title'];
		$pageId = $page['id'];

		$restrictions = [
			( new PageRestriction( 0, $pageId ) )->setTitle( $title ),
			new NamespaceRestriction( 0, NS_MAIN ),
			// Deleted page.
			new PageRestriction( 0, 999999 ),
		];

		$wrappedPager = TestingAccessWrapper::newFromObject( $pager );
		$wrappedPager->restrictions = $restrictions;

		$formatted = $pager->formatValue( 'params', '' );
		$this->assertEquals( '<ul><li>'
			// FIXME: Expectation value should not be dynamic
			// and must not depend on a localisation message.
			// TODO: Mock the message or consider using qqx.
			. wfMessage( 'blocklist-editing' )->text()
			. '<ul><li>'
			. wfMessage( 'blocklist-editing-page' )->text()
			. '<ul><li>'
			. '<a href="/wiki/Victor_Frankenstein" title="'
			. $pageName
			. '">'
			. $pageName
			. '</a></li></ul></li><li>'
			. wfMessage( 'blocklist-editing-ns' )->text()
			. '<ul><li>'
			. '<a href="/w/index.php?title=Special:AllPages&amp;namespace=0" title="'
			. 'Special:AllPages'
			. '">'
			. wfMessage( 'blanknamespace' )->text()
			. '</a></li></ul></li></ul></li></ul>',
			$formatted
		);
	}

	/**
	 * @covers ::preprocessResults
	 */
	public function testPreprocessResults() {
		// Test the Link Cache.
		$linkCache = $this->getServiceContainer()->getLinkCache();
		$wrappedlinkCache = TestingAccessWrapper::newFromObject( $linkCache );
		$admin = $this->getTestSysop()->getUser();

		$links = [
			'User:127.0.0.1',
			'User_talk:127.0.0.1',
			$admin->getUserPage()->getPrefixedDBkey(),
			$admin->getTalkPage()->getPrefixedDBkey(),
			'Comment_link'
		];

		foreach ( $links as $link ) {
			$this->assertNull( $wrappedlinkCache->entries->get( $link ) );
		}

		$row = (object)[
			'bt_address' => '127.0.0.1',
			'bt_user' => null,
			'bt_user_text' => null,
			'bl_by' => $admin->getId(),
			'bl_by_text' => $admin->getName(),
			'bl_sitewide' => 1,
			'bl_timestamp' => $this->getDb()->timestamp( wfTimestamp( TS_MW ) ),
			'bl_reason_text' => '[[Comment link]]',
			'bl_reason_data' => null,
		];
		$pager = $this->getBlockListPager();
		$pager->preprocessResults( new FakeResultWrapper( [ $row ] ) );

		foreach ( $links as $link ) {
			$this->assertTrue( $wrappedlinkCache->isBadLink( $link ), "Bad link [[$link]]" );
		}

		// Test sitewide blocks.
		$row = (object)[
			'bt_address' => '127.0.0.1',
			'bt_user' => null,
			'bt_user_text' => null,
			'bl_by' => $admin->getId(),
			'bl_by_text' => $admin->getName(),
			'bl_sitewide' => 1,
			'bl_reason_text' => '',
			'bl_reason_data' => null,
		];
		$pager = $this->getBlockListPager();
		$pager->preprocessResults( new FakeResultWrapper( [ $row ] ) );

		$this->assertObjectNotHasProperty( 'bl_restrictions', $row );

		$page = $this->getExistingTestPage( 'Victor Frankenstein' );
		$title = $page->getTitle();

		$target = '127.0.0.1';

		// Test partial blocks.
		$block = new DatabaseBlock( [
			'address' => $target,
			'by' => $this->getTestSysop()->getUser(),
			'reason' => 'Parce que',
			'expiry' => $this->getDb()->getInfinity(),
			'sitewide' => false,
		] );
		$block->setRestrictions( [
			new PageRestriction( 0, $page->getId() ),
		] );
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockStore->insertBlock( $block );

		$pager = $this->getBlockListPager();
		$result = $this->getDb()->newSelectQueryBuilder()
			->queryInfo( $pager->getQueryInfo() )
			->where( [ 'bl_id' => $block->getId() ] )
			->caller( __METHOD__ )
			->fetchResultSet();

		$pager->preprocessResults( $result );

		$wrappedPager = TestingAccessWrapper::newFromObject( $pager );

		$restrictions = $wrappedPager->restrictions;
		$this->assertIsArray( $restrictions );

		$restriction = $restrictions[0];
		$this->assertEquals( $page->getId(), $restriction->getValue() );
		$this->assertEquals( $page->getId(), $restriction->getTitle()->getArticleID() );
		$this->assertEquals( $title->getDBkey(), $restriction->getTitle()->getDBkey() );
		$this->assertEquals( $title->getNamespace(), $restriction->getTitle()->getNamespace() );
	}

	/**
	 * T352310 regression test
	 * @coversNothing
	 */
	public function testOffset() {
		if ( $this->getDb()->getType() === 'postgres' ) {
			$this->markTestSkipped( "PostgreSQL fatals when the first part of " .
				"the offset parameter has the wrong timestamp format" );
		}
		$request = new FauxRequest( [
			'offset' => '20231115010645|7'
		] );
		RequestContext::getMain()->setRequest( $request );
		$pager = $this->getBlockListPager();
		$pager->getFullOutput();
		$this->assertTrue( true );
	}
}
PK       ! pR    -  specials/Contribute/ContributeFactoryTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Specials\Contribute\Card\ContributeCard;
use MediaWiki\Specials\Contribute\Card\ContributeCardActionLink;
use MediaWiki\Specials\Contribute\ContributeFactory;

/**
 * @author MAbualruz
 * @group Database
 * @covers \MediaWiki\Specials\Contribute\ContributeFactory
 */
class ContributeFactoryTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers \MediaWiki\Specials\Contribute\ContributeFactory::getCards
	 */
	public function testGetCards() {
		$context = RequestContext::getMain();
		$services = $this->getServiceContainer();
		$hookContainer = $services->getHookContainer();
		$factory = new ContributeFactory(
			$context,
			new HookRunner( $hookContainer )
		);
		$cards = $factory->getCards();
		$this->assertIsArray( $cards );
		$this->assertNotEmpty( $cards );
		$defaltCard = $cards[ count( $cards ) - 1 ];
		$expectedCard = ( new ContributeCard(
			$context->msg( 'newpage' )->text(),
			$context->msg( 'newpage-desc' )->text(),
			'article',
			new ContributeCardActionLink(
				SpecialPage::getSafeTitleFor( 'Wantedpages' )->getLocalURL(),
				$context->msg( 'view-missing-pages' )->text()
			) ) )->toArray();
		$this->assertArrayEquals( [ 'title', 'icon', 'description', 'action' ], array_keys( $defaltCard ) );
		$this->assertArrayEquals( $expectedCard, $defaltCard );
	}

}
PK       ! \	  	  "  specials/SpecialRenameUserTest.phpnu Iw        <?php

use MediaWiki\Request\FauxRequest;
use MediaWiki\Specials\SpecialRenameUser;

/**
 * @group Database
 * @covers \MediaWiki\Specials\SpecialRenameUser
 * @covers \MediaWiki\RenameUser\RenameuserSQL
 */
class SpecialRenameUserTest extends SpecialPageTestBase {
	protected function newSpecialPage() {
		$services = $this->getServiceContainer();
		return new SpecialRenameUser(
			$services->getConnectionProvider(),
			$services->getMovePageFactory(),
			$services->getPermissionManager(),
			$services->getTitleFactory(),
			$services->getUserFactory(),
			$services->getUserNamePrefixSearch(),
			$services->getUserNameUtils()
		);
	}

	public static function provideRenameAndMove() {
		return [
			'no move' => [ false, false ],
			'normal move' => [ true, false ],
			'suppress redirect' => [ true, true ]
		];
	}

	/**
	 * @dataProvider provideRenameAndMove
	 * @param bool $movePages
	 * @param bool $suppressRedirects
	 */
	public function testRenameAndMove( $movePages, $suppressRedirects ) {
		$userFactory = $this->getServiceContainer()->getUserFactory();
		$titleFactory = $this->getServiceContainer()->getTitleFactory();

		$performer = $this->getTestSysop()->getUser();
		$oldUser = $this->getTestUser()->getUser();
		$oldName = $oldUser->getName();
		$newName = $oldName . ' new';
		$oldPage = $oldUser->getUserPage();
		$oldTalkPage = $oldUser->getTalkPage();
		$this->editPage( $oldPage, 'user page' );
		$this->editPage( $oldPage->getSubpage( 'subpage' ), 'subpage' );
		$this->editPage( $oldTalkPage, 'user talk page' );

		$formData = [
			'wpEditToken' => $performer->getEditToken(),
			'oldusername' => $oldName,
			'newusername' => $newName,
			'reason' => 'r',
		];
		if ( $movePages ) {
			$formData['movepages'] = '1';
		}
		if ( $suppressRedirects ) {
			$formData['suppressredirect'] = '1';
		}

		$this->executeSpecialPage(
			'',
			new FauxRequest( $formData, true ),
			null,
			$performer
		);

		$this->assertTrue( $userFactory->newFromName( $newName )->isRegistered(),
			'new user exists' );
		$this->assertSame(
			$movePages,
			$titleFactory->makeTitle( NS_USER, $newName )->exists(),
			'new user page exists'
		);
		$this->assertSame(
			$movePages,
			$titleFactory->makeTitle( NS_USER, "$newName/subpage" )->exists(),
			'new user subpage exists'
		);
		$this->assertSame(
			$movePages,
			$titleFactory->makeTitle( NS_USER_TALK, "$newName" )->exists(),
			'new user talk page exists'
		);

		$oldPage->resetArticleID( false );
		$this->assertSame( !$suppressRedirects, $oldPage->exists() );
	}
}
PK       ! e    %  specials/SpecialRecentChangesTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Specials\SpecialRecentChanges;
use MediaWiki\Tests\SpecialPage\AbstractChangesListSpecialPageTestCase;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\Watchlist\WatchedItemStoreInterface;
use Wikimedia\TestingAccessWrapper;

/**
 * Test class for SpecialRecentchanges class
 *
 * @group Database
 *
 * @covers \MediaWiki\Specials\SpecialRecentChanges
 * @covers \MediaWiki\SpecialPage\ChangesListSpecialPage
 */
class SpecialRecentChangesTest extends AbstractChangesListSpecialPageTestCase {
	use MockAuthorityTrait;
	use TempUserTestTrait;

	protected function getPage(): SpecialRecentChanges {
		return new SpecialRecentChanges(
			$this->getServiceContainer()->getWatchedItemStore(),
			$this->getServiceContainer()->getMessageCache(),
			$this->getServiceContainer()->getUserOptionsLookup(),
			$this->getServiceContainer()->getChangeTagsStore(),
			$this->getServiceContainer()->getUserIdentityUtils(),
			$this->getServiceContainer()->getTempUserConfig()
		);
	}

	/**
	 * @return TestingAccessWrapper
	 */
	protected function getPageAccessWrapper() {
		return TestingAccessWrapper::newFromObject( $this->getPage() );
	}

	// Below providers should only be for features specific to
	// RecentChanges.  Otherwise, it should go in ChangesListSpecialPageTest

	public function provideParseParameters() {
		return [
			[ 'limit=123', [ 'limit' => '123' ] ],

			[ '234', [ 'limit' => '234' ] ],

			[ 'days=3', [ 'days' => '3' ] ],

			[ 'days=0.25', [ 'days' => '0.25' ] ],

			[ 'namespace=5', [ 'namespace' => '5' ] ],

			[ 'namespace=5|3', [ 'namespace' => '5|3' ] ],

			[ 'tagfilter=foo', [ 'tagfilter' => 'foo' ] ],

			[ 'tagfilter=foo;bar', [ 'tagfilter' => 'foo;bar' ] ],
		];
	}

	public function validateOptionsProvider() {
		return [
			[
				// hidebots=1 is default for Special:RecentChanges
				[ 'hideanons' => 1, 'hideliu' => 1 ],
				true,
				[ 'hideliu' => 1 ],
				false,
			],
		];
	}

	public function testAddWatchlistJoins() {
		// Edit a test page so that it shows up in RC.
		$testPage = $this->getExistingTestPage( 'Test page' );
		$this->editPage( $testPage, 'Test content', '' );

		// Set up RC.
		$context = new RequestContext;
		$context->setTitle( Title::newFromText( __METHOD__ ) );
		$context->setUser( $this->getTestUser()->getUser() );
		$context->setRequest( new FauxRequest );

		// Confirm that the test page is in RC.
		$rc1 = $this->getPage();
		$rc1->setContext( $context );
		$rc1->execute( null );
		$this->assertStringContainsString( 'Test page', $rc1->getOutput()->getHTML() );
		$this->assertStringContainsString( 'mw-changeslist-line-not-watched', $rc1->getOutput()->getHTML() );

		// Watch the page, and check that it's now watched in RC.
		$watchedItemStore = $this->getServiceContainer()->getWatchedItemStore();
		$watchedItemStore->addWatch( $context->getUser(), $testPage );
		$rc2 = $this->getPage();
		$rc2->setContext( $context );
		$rc2->execute( null );
		$this->assertStringContainsString( 'Test page', $rc2->getOutput()->getHTML() );
		$this->assertStringContainsString( 'mw-changeslist-line-watched', $rc2->getOutput()->getHTML() );

		// Force a past expiry date on the watchlist item.
		$db = $this->getDb();
		$watchedItemId = $db->newSelectQueryBuilder()
			->select( 'wl_id' )
			->from( 'watchlist' )
			->where( [ 'wl_namespace' => $testPage->getNamespace(), 'wl_title' => $testPage->getDBkey() ] )
			->caller( __METHOD__ )->fetchField();
		$db->newUpdateQueryBuilder()
			->update( 'watchlist_expiry' )
			->set( [ 'we_expiry' => $db->timestamp( '20200101000000' ) ] )
			->where( [ 'we_item' => $watchedItemId ] )
			->caller( __METHOD__ )->execute();

		// Check that the page is still in RC, but that it's no longer watched.
		$rc3 = $this->getPage();
		$rc3->setContext( $context );
		$rc3->execute( null );
		$this->assertStringContainsString( 'Test page', $rc3->getOutput()->getHTML() );
		$this->assertStringContainsString( 'mw-changeslist-line-not-watched', $rc3->getOutput()->getHTML() );
	}

	public function testExperienceLevelFilter() {
		$this->disableAutoCreateTempUser();

		// Edit a test page so that it shows up in RC.
		$testPage = $this->getExistingTestPage( 'Experience page' );
		$this->editPage( $testPage, 'Registered content',
			'registered summary', NS_MAIN, $this->getTestUser()->getUser() );
		$this->editPage( $testPage, 'Anon content',
			'anon summary', NS_MAIN, $this->mockAnonUltimateAuthority() );

		// Set up RC.
		$context = new RequestContext;
		$context->setTitle( Title::newFromText( __METHOD__ ) );
		$context->setUser( $this->getTestUser()->getUser() );
		$context->setRequest( new FauxRequest );

		// Confirm that the test page is in RC.
		[ $html ] = ( new SpecialPageExecutor() )->executeSpecialPage(
			$this->getPage(),
			'',
			new FauxRequest()
		);
		$this->assertStringContainsString( 'Experience page', $html );

		// newcomer
		$req = new FauxRequest();
		$req->setVal( 'userExpLevel', 'newcomer' );
		[ $html ] = ( new SpecialPageExecutor() )->executeSpecialPage(
			$this->getPage(),
			'',
			$req
		);
		$this->assertStringContainsString( 'registered summary', $html );

		// anon
		$req = new FauxRequest();
		$req->setVal( 'userExpLevel', 'unregistered' );
		[ $html ] = ( new SpecialPageExecutor() )->executeSpecialPage(
			$this->getPage(),
			'',
			$req
		);
		$this->assertStringContainsString( 'anon summary', $html );
		$this->assertStringNotContainsString( 'registered summary', $html );

		// registered
		$req = new FauxRequest();
		$req->setVal( 'userExpLevel', 'registered' );
		[ $html ] = ( new SpecialPageExecutor() )->executeSpecialPage(
			$this->getPage(),
			'',
			$req
		);
		$this->assertStringContainsString( 'registered summary', $html );
		$this->assertStringNotContainsString( 'anon summary', $html );
	}

	/**
	 * This integration test just tries to run the isDenseFilter() queries, to
	 * check for syntax errors etc. It doesn't verify the logic.
	 */
	public function testIsDenseTagFilter() {
		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'rc-test-tag' );
		$req = new FauxRequest();
		$req->setVal( 'tagfilter', 'rc-test-tag' );
		$page = $this->getPage();

		// Make sure thresholds are passed
		$page->denseRcSizeThreshold = 0;
		$this->overrideConfigValue( MainConfigNames::MiserMode, true );

		( new SpecialPageExecutor() )->executeSpecialPage( $page, '', $req );
		$this->assertTrue( true );
	}

	public static function provideDenseTagFilter() {
		return [
			[ false ],
			[ true ]
		];
	}

	/**
	 * This integration test injects the return value of isDenseFilter(),
	 * verifying the correctness of the resulting STRAIGHT_JOIN.
	 *
	 * @dataProvider provideDenseTagFilter
	 */
	public function testDenseTagFilter( $dense ) {
		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'rc-test-tag' );
		$req = new FauxRequest();
		$req->setVal( 'tagfilter', 'rc-test-tag' );

		$page = new class (
			$dense,
			$this->getServiceContainer()->getWatchedItemStore(),
			$this->getServiceContainer()->getMessageCache(),
			$this->getServiceContainer()->getUserOptionsLookup()
		)  extends SpecialRecentChanges {
			private $dense;

			public function __construct(
				$dense,
				?WatchedItemStoreInterface $watchedItemStore = null,
				?MessageCache $messageCache = null,
				?\MediaWiki\User\Options\UserOptionsLookup $userOptionsLookup = null
			) {
				parent::__construct( $watchedItemStore, $messageCache, $userOptionsLookup );
				$this->dense = $dense;
			}

			protected function isDenseTagFilter( $tagIds, $limit ) {
				return $this->dense;
			}
		};

		( new SpecialPageExecutor() )->executeSpecialPage( $page, '', $req );
		$this->assertTrue( true );
	}
}
PK       !     "  specials/SpecialContributeTest.phpnu Iw        <?php

use MediaWiki\Permissions\UltimateAuthority;
use MediaWiki\Specials\SpecialContribute;
use MediaWiki\User\User;

/**
 * @author MAbualruz
 * @group Database
 * @covers \MediaWiki\Specials\SpecialContribute
 */
class SpecialContributeTest extends SpecialPageTestBase {
	/** @var string */
	private $pageName = __CLASS__ . 'BlaBlaTest';

	/** @var User */
	private $admin;

	/** @var SpecialContribute */
	private $specialContribute;

	protected function setUp(): void {
		parent::setUp();
		$this->admin = new UltimateAuthority( $this->getTestSysop()->getUser() );
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialContribute::execute
	 */
	public function testExecute() {
		$this->specialContribute = new SpecialContribute();
		[ $html ] = $this->executeSpecialPage(
			$this->admin->getUser()->getName(),
			null,
			'qqx',
			$this->admin,
			true
		);
		$this->assertStringContainsString( '<div class="mw-contribute-wrapper">', $html );
		$this->assertStringContainsString( '<div class="mw-contribute-card-content">', $html );
	}

	public function testIsShowable() {
		$this->specialContribute = new SpecialContribute();
		$this->executeSpecialPage(
			$this->admin->getUser()->getName(),
			null,
			'qqx',
			$this->admin,
			true
		);
		$this->assertFalse( $this->specialContribute->isShowable() );
	}

	/**
	 * @inheritDoc
	 */
	protected function newSpecialPage(): SpecialContribute {
		return $this->specialContribute;
	}

}
PK       ! (M(  (  %  specials/SpecialContributionsTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\UltimateAuthority;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Specials\SpecialContributions;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use Wikimedia\Parsoid\Utils\DOMCompat;
use Wikimedia\Parsoid\Utils\DOMUtils;

/**
 * @author Ammarpad
 * @group Database
 * @covers \MediaWiki\Specials\SpecialContributions
 * @covers \MediaWiki\SpecialPage\ContributionsSpecialPage
 */
class SpecialContributionsTest extends SpecialPageTestBase {
	use TempUserTestTrait;

	private const PAGE_NAME = __CLASS__ . 'BlaBlaTest';
	/** @var Authority */
	private static $admin;
	/** @var User */
	private static $user;
	/** @var User */
	private static $zeroUser;
	private static int $useModWikiIPRevId;

	public function addDBDataOnce() {
		$this->overrideConfigValue(
			MainConfigNames::RangeContributionsCIDRLimit,
			[
				'IPv4' => 16,
				'IPv6' => 32,
			]
		);
		$this->setTemporaryHook(
			'SpecialContributionsBeforeMainOutput',
			static function () {
			}
		);
		self::$admin = new UltimateAuthority( $this->getTestSysop()->getUser() );
		$this->assertTrue(
			$this->editPage(
				self::PAGE_NAME, 'Test Content', 'test', NS_MAIN, self::$admin
			)->isOK(),
			'Edit failed for admin'
		);

		self::$user = $this->getTestUser()->getUser();
		$this->assertTrue(
			$this->editPage(
				'Test', 'Test Content', 'test', NS_MAIN, self::$user
			)->isOK(),
			'Edit failed for user'
		);

		// The name of this user is '0' which is a valid name.
		self::$zeroUser = ( new TestUser( '0' ) )->getUser();
		$this->assertTrue(
			$this->editPage(
				'TestPage', 'Test Content', 'test', NS_MAIN, self::$zeroUser
			)->isOK(),
			'Edit failed for user'
		);

		$this->disableAutoCreateTempUser();
		$useModWikiIP = $this->getServiceContainer()->getUserFactory()
			->newFromName( '1.2.3.xxx', UserFactory::RIGOR_NONE );
		$useModWikiIPEditStatus = $this->editPage( 'Test1234', 'Test Content', 'test', NS_MAIN, $useModWikiIP );
		$this->assertStatusGood( $useModWikiIPEditStatus, 'Edit failed for IP in usemod format' );
		static::$useModWikiIPRevId = $useModWikiIPEditStatus->getNewRevision()->getId();

		$blockStatus = $this->getServiceContainer()->getBlockUserFactory()
			->newBlockUser(
				self::$user->getName(),
				self::$admin,
				'infinity',
				'',
				[ 'isHideUser' => true ],
			)
			->placeBlock();
		$this->assertStatusGood( $blockStatus, 'Block was not placed' );
	}

	public function testExecuteForZeroUser() {
		[ $html ] = $this->executeSpecialPage(
			self::$zeroUser->getName()
		);

		$this->assertStringContainsString( 'mw-pager-body', $html );
	}

	public function testExecuteEmptyTarget() {
		[ $html ] = $this->executeSpecialPage();
		// This 'topOnly' filter should always be added to Special:Contributions
		$this->assertStringContainsString( 'topOnly', $html );
		$this->assertStringNotContainsString( 'mw-pager-body', $html );
	}

	public function testExecuteHiddenTarget() {
		[ $html ] = $this->executeSpecialPage(
			self::$user->getName()
		);
		$this->assertStringNotContainsString( 'mw-pager-body', $html );
	}

	public function testExecuteHiddenTargetWithPermissions() {
		[ $html ] = $this->executeSpecialPage(
			self::$user->getName(),
			null,
			'qqx',
			// This is necessary because permission checks aren't actually
			// done on the UlitmateAuthority that is self::$admin. Instead,
			// they are done on a UserAuthority. See the TODO comment in
			// User::getThisAsAuthority for more details.
			$this->getTestUser( [
				'sysop',
				'bureaucrat',
				'suppress'
			] )->getUser()
		);
		$this->assertStringContainsString( 'mw-pager-body', $html );
	}

	public function testExecuteInvalidNamespace() {
		[ $html ] = $this->executeSpecialPage(
			'::1',
			new FauxRequest( [
				'namespace' => -1,
			] )
		);
		$this->assertStringNotContainsString( 'mw-pager-body', $html );
	}

	/** @dataProvider provideExecuteNoResultsForIPTarget */
	public function testExecuteNoResultsForIPTarget( $temporaryAccountsEnabled, $expectedPageTitleMessageKey ) {
		if ( $temporaryAccountsEnabled ) {
			$this->enableAutoCreateTempUser();
		} else {
			$this->disableAutoCreateTempUser();
		}
		[ $html ] = $this->executeSpecialPage( '4.3.2.1', null, null, null, true );
		$specialPageDocument = DOMUtils::parseHTML( $html );
		$contentHtml = DOMCompat::querySelector( $specialPageDocument, '.mw-content-container' )->nodeValue;
		$this->assertStringNotContainsString( 'mw-pager-body', $contentHtml );
		$this->assertStringContainsString( "($expectedPageTitleMessageKey: 4.3.2.1", $contentHtml );
	}

	public static function provideExecuteNoResultsForIPTarget() {
		return [
			'Temporary accounts not enabled' => [ false, 'contributions-title' ],
			'Temporary accounts enabled' => [
				true, 'contributions-title-for-ip-when-temporary-accounts-enabled',
			],
		];
	}

	public function testExecuteForUseModWikiIP() {
		// Regression test for T370413
		[ $html ] = $this->executeSpecialPage( '1.2.3.xxx' );
		$contributionsList = DOMCompat::querySelectorAll( DOMUtils::parseHTML( $html ), '.mw-contributions-list' );
		$this->assertCount(
			1, $contributionsList, 'Should have the contributions list as 1.2.3.xxx has made one edit'
		);
		$matchingLines = DOMCompat::querySelectorAll(
			$contributionsList[0], '[data-mw-revid="' . static::$useModWikiIPRevId . '"]'
		);
		$this->assertCount( 1, $matchingLines, "The edit made by the usemod IP is missing" );
	}

	/**
	 * @dataProvider provideTestExecuteRange
	 */
	public function testExecuteRange( $username, $shouldShowLinks ) {
		[ $html ] = $this->executeSpecialPage( $username, null, 'qqx', self::$admin, true );

		if ( $shouldShowLinks ) {
			$this->assertStringContainsString( 'blocklink', $html );
		} else {
			$this->assertStringNotContainsString( 'blocklink', $html );
			$this->assertStringContainsString( 'sp-contributions-outofrange', $html );
		}
	}

	/**
	 * @dataProvider provideTestExecuteNonRange
	 */
	public function testExecuteNonRange( $username, $shouldShowLinks ) {
		[ $html ] = $this->executeSpecialPage( $username, null, 'qqx', self::$admin, true );

		if ( $shouldShowLinks ) {
			$this->assertStringContainsString( 'blocklink', $html );
		} else {
			$this->assertStringNotContainsString( 'blocklink', $html );
		}
	}

	public static function provideTestExecuteRange() {
		yield 'Queryable IPv4 range should have blocklink for admin'
			=> [ '24.237.208.166/30', true ];
		yield 'Queryable IPv6 range should have blocklink for admin'
			=> [ '2001:DB8:0:0:0:0:0:01/43', true ];
		yield 'Unqueryable IPv4 range should not have blocklink for admin'
			=> [ '212.35.31.121/14', false ];
		yield 'Unqueryable IPv6 range should not have blocklink for admin'
			=> [ '2000::/24', false ];
	}

	public static function provideTestExecuteNonRange() {
		yield 'Valid IPv4 should have blocklink for admin' => [ '124.24.52.13', true ];
		yield 'Valid IPv6 should have blocklink for admin' => [ '2001:db8::', true ];
		yield 'Local user should have blocklink for admin' => [ 'UTSysop', true ];
		yield 'Invalid IP should not have blocklink for admin' => [ '24.237.222208.166', false ];
		yield 'External user should not have blocklink for admin' => [ 'imported>UTSysop', false ];
		yield 'Nonexistent user should not have blocklink for admin' => [ __CLASS__, false ];
	}

	public static function provideYearMonthParams() {
		yield 'Current year/month' => [
			'year' => date( 'Y' ),
			'month' => date( 'm' ),
			'expect' => true,
		];
		yield 'Old year/moth' => [
			'year' => '2007',
			'month' => '01',
			'expect' => false,
		];
		yield 'Garbage' => [
			'year' => '123garbage123',
			'month' => date( 'm' ),
			'expect' => true,
		];
	}

	/**
	 * @dataProvider provideYearMonthParams
	 */
	public function testYearMonthParams( string $year, string $month, bool $expect ) {
		[ $html ] = $this->executeSpecialPage(
			self::$admin->getUser()->getName(),
			new FauxRequest( [
				'year' => $year,
				'month' => $month,
			] ) );
		if ( $expect ) {
			$this->assertStringContainsString( self::PAGE_NAME, $html );
		} else {
			$this->assertStringNotContainsString( self::PAGE_NAME, $html );
		}
	}

	public function testBotParam() {
		[ $html ] = $this->executeSpecialPage(
			'::1',
			new FauxRequest( [
				'bot' => 1,
			] ),
			null,
			self::$admin
		);
		$this->assertStringContainsString( 'bot', $html );
	}

	public function testFeedFormat() {
		$specialPage = $this->newSpecialPage();
		[ $html ] = ( new SpecialPageExecutor() )->executeSpecialPage(
			$specialPage,
			'::1',
			new FauxRequest( [
				'feed' => 'atom',
				'namespace' => 2,
				'topOnly' => true,
				'newOnly' => true,
				'hideMinor' => true,
				'deletedOnly' => true,
				'tagfilter' => 'mw-reverted',
				'year' => '2000',
				'month' => '01',
			] )
		);
		$url = $specialPage->getOutput()->getRedirect();
		$this->assertStringContainsString( 'namespace', $url );
		$this->assertStringContainsString( 'toponly', $url );
		$this->assertStringContainsString( 'newonly', $url );
		$this->assertStringContainsString( 'hideminor', $url );
		$this->assertStringContainsString( 'deletedonly', $url );
		$this->assertStringContainsString( 'tagfilter', $url );
		$this->assertStringContainsString( 'year', $url );
		$this->assertStringContainsString( 'month', $url );
	}

	/**
	 * @dataProvider providePrefixSearchSubpages
	 */
	public function testPrefixSearchSubpages( $search, $expected ) {
		$specialPage = $this->newSpecialPage();
		$this->assertCount(
			$expected,
			$specialPage->prefixSearchSubpages( $search, 10, 0 )
		);
	}

	public static function providePrefixSearchSubpages() {
		return [
			'Invalid prefix' => [ '/', 0 ],
			'Valid prefix' => [ 'U', 1 ],
		];
	}

	protected function newSpecialPage(): SpecialContributions {
		$services = $this->getServiceContainer();

		return new SpecialContributions(
			$services->getLinkBatchFactory(),
			$services->getPermissionManager(),
			$services->getConnectionProvider(),
			$services->getRevisionStore(),
			$services->getNamespaceInfo(),
			$services->getUserNameUtils(),
			$services->getUserNamePrefixSearch(),
			$services->getUserOptionsLookup(),
			$services->getCommentFormatter(),
			$services->getUserFactory(),
			$services->getUserIdentityLookup(),
			$services->getDatabaseBlockStore(),
			$services->getTempUserConfig()
		);
	}

}
PK       ! AM6  6    specials/SpecialUploadTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Specials\SpecialUpload;

class SpecialUploadTest extends MediaWikiIntegrationTestCase {
	/**
	 * @covers \MediaWiki\Specials\SpecialUpload::getInitialPageText
	 * @dataProvider provideGetInitialPageText
	 */
	public function testGetInitialPageText( $expected, $inputParams ) {
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'en' );
		$result = SpecialUpload::getInitialPageText( ...$inputParams );
		$this->assertEquals( $expected, $result );
	}

	public static function provideGetInitialPageText() {
		return [
			[
				'expect' => "== Summary ==\nthis is a test\n",
				'params' => [
					'this is a test'
				],
			],
			[
				'expect' => "== Summary ==\nthis is a test\n",
				'params' => [
					"== Summary ==\nthis is a test",
				],
			],
		];
	}
}
PK       ! N<ٔ    %  specials/SpecialEditWatchlistTest.phpnu Iw        <?php

use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Specials\SpecialEditWatchlist;

/**
 * @author Addshore
 *
 * @group Database
 *
 * @covers \MediaWiki\Specials\SpecialEditWatchlist
 */
class SpecialEditWatchlistTest extends SpecialPageTestBase {

	/**
	 * Returns a new instance of the special page under test.
	 *
	 * @return SpecialPage
	 */
	protected function newSpecialPage() {
		$services = $this->getServiceContainer();
		return new SpecialEditWatchlist(
			$services->getWatchedItemStore(),
			$services->getTitleParser(),
			$services->getGenderCache(),
			$services->getLinkBatchFactory(),
			$services->getNamespaceInfo(),
			$services->getWikiPageFactory(),
			$services->getWatchlistManager()
		);
	}

	public function testNotLoggedIn_throwsException() {
		$this->expectException( UserNotLoggedIn::class );
		$this->executeSpecialPage();
	}

	public function testRootPage_displaysExplanationMessage() {
		$user = new TestUser( __METHOD__ );
		[ $html, ] = $this->executeSpecialPage( '', null, 'qqx', $user->getUser() );
		$this->assertStringContainsString( '(watchlistedit-normal-explain)', $html );
	}

	public function testClearPage_hasClearButtonForm() {
		$user = new TestUser( __METHOD__ );
		[ $html, ] = $this->executeSpecialPage( 'clear', null, 'qqx', $user->getUser() );
		$this->assertMatchesRegularExpression(
			'/<form action=\'.*?Special:EditWatchlist\/clear\'/',
			$html
		);
	}

	public function testEditRawPage_hasTitlesBox() {
		$user = new TestUser( __METHOD__ );
		[ $html, ] = $this->executeSpecialPage( 'raw', null, 'qqx', $user->getUser() );
		$this->assertStringContainsString(
			'<div id=\'mw-input-wpTitles\'',
			$html
		);
	}

}
PK       ! 5|    %  specials/SpecialPasswordResetTest.phpnu Iw        <?php

use MediaWiki\Request\FauxRequest;
use MediaWiki\Specials\SpecialPasswordReset;
use MediaWiki\Tests\SpecialPage\FormSpecialPageTestCase;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;

/**
 * @covers \MediaWiki\Specials\SpecialPasswordReset
 * @group Database
 */
class SpecialPasswordResetTest extends FormSpecialPageTestCase {

	use TempUserTestTrait;

	/**
	 * @inheritDoc
	 */
	protected function newSpecialPage() {
		return new SpecialPasswordReset(
			$this->getServiceContainer()->getPasswordReset()
		);
	}

	public function testView() {
		[ $html ] = $this->executeSpecialPage();
		// Check that the form fields are as expected
		$this->assertStringContainsString( '(passwordreset-username', $html );
		$this->assertStringContainsString( '(passwordreset-email', $html );
		$this->assertStringContainsString( '(passwordreset-text-many', $html );
		$this->assertStringContainsString( '(mailmypassword', $html );
	}

	public function testExecuteForTemporaryAccountUsername() {
		// Get an existing temporary account to test with
		$this->enableAutoCreateTempUser();
		$tempUser = $this->getServiceContainer()->getTempUserCreator()->create( null, new FauxRequest() )->getUser();
		// Make a fake POST request that tries to use a temporary account for the password
		$request = new FauxRequest(
			[ 'wpUsername' => $tempUser->getName() ],
			true
		);
		[ $html ] = $this->executeSpecialPage( '', $request );
		$this->assertStringContainsString( '(htmlform-user-not-valid', $html );
	}
}
PK       ! j4  4     specials/SpecialNewPagesTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Specials;

use DOMElement;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentity;
use SpecialPageTestBase;
use Wikimedia\Parsoid\DOM\Document;
use Wikimedia\Parsoid\DOM\Element;
use Wikimedia\Parsoid\Utils\DOMCompat;
use Wikimedia\Parsoid\Utils\DOMUtils;

/**
 * @group Database
 * @covers \MediaWiki\Specials\SpecialNewPages
 * @covers \MediaWiki\Pager\NewPagesPager
 */
class SpecialNewPagesTest extends SpecialPageTestBase {

	use TempUserTestTrait;

	private static UserIdentity $testUser1;

	/** @var Title[] */
	private static array $testUser1Pages;

	/** @var Title[] */
	private static array $allPages;

	private static int $editRevId;

	protected function newSpecialPage() {
		return $this->getServiceContainer()->getSpecialPageFactory()->getPage( 'Newpages' );
	}

	/**
	 * Asserts that the form fields for the Special:NewPages page are present.
	 *
	 * @param string $html The HTML returned by the special page
	 * @param bool $canAnonUsersCreatePages Whether anonymous users should be able to create pages
	 */
	private function verifyFormFieldsArePresent(
		string $html, bool $canAnonUsersCreatePages
	) {
		// Verify that the form labels are present. This is a good way to check that the form fields are present,
		// since the form labels should be generated by the form field definitions.
		$this->assertStringContainsString( '(namespace)', $html, 'Namespace filter not added to form' );
		$this->assertStringContainsString( '(newpages-username)', $html, 'Username filter not added to form' );
		$this->assertStringContainsString(
			'(namespace_association)', $html, 'Associated namespace filter not added to form'
		);
		$this->assertStringContainsString( '(tag-filter)', $html, 'Tag filter not added to form' );
		$this->assertStringContainsString( '(invert)', $html, 'Invert checkbox not added to form' );
		$this->assertStringContainsString( '(minimum-size)', $html, 'Size filter not added to form' );
		// Verify that the filter links are present in the form
		if ( $canAnonUsersCreatePages ) {
			$this->assertStringContainsString(
				'(newpages-showhide-registered', $html, 'Registered filter should be present'
			);
		} else {
			$this->assertStringNotContainsString(
				'(newpages-showhide-registered', $html, 'Registered filter should not be present'
			);
		}
		$this->assertStringContainsString( '(newpages-showhide-bots', $html, 'Missing bots filter' );
		$this->assertStringContainsString( '(newpages-showhide-redirect', $html, 'Missing redirect filter' );

		$this->assertStringContainsString( '(newpages-submit)', $html, 'Submit button text not as expected' );
	}

	/**
	 * @param string $group The group to allow or disallow creating pages
	 * @param bool $state Whether to allow or disallow the given $group from creating pages
	 */
	private function setGroupHasRightsToCreatePages( string $group, bool $state ) {
		// Remove the 'createtalk' and 'createpage' rights from the '*' group if they are present for the test.
		$groupPermissionsValue = $this->getServiceContainer()->getMainConfig()
			->get( MainConfigNames::GroupPermissions );
		if ( $state ) {
			$groupPermissionsValue[$group]['createtalk'] = true;
			$groupPermissionsValue[$group]['createpage'] = true;
		} else {
			unset( $groupPermissionsValue[$group]['createtalk'] );
			unset( $groupPermissionsValue[$group]['createpage'] );
		}
		$this->overrideConfigValue( MainConfigNames::GroupPermissions, $groupPermissionsValue );
	}

	/**
	 * Helper method used to expect that one element matches the given selector inside the given parent element.
	 *
	 * @param DOMElement|Document $document The element to search through
	 * @param string $selector The CSS selector which should match only one element
	 * @return DOMElement|Element The matched element
	 */
	private function getAndExpectSingleMatchingElement( $document, string $selector ) {
		$matchingClass = DOMCompat::querySelectorAll( $document, $selector );
		$this->assertCount( 1, $matchingClass, "One element was expected to match $selector" );
		return $matchingClass[0];
	}

	/**
	 * Verifies that the given new pages line has the expected elements.
	 *
	 * @param DOMElement|Element $line The line element to verify
	 * @param RevisionRecord $firstRevision The first revision of the page
	 */
	private function verifyLineHasExpectedElements( $line, RevisionRecord $firstRevision ) {
		// Verify the timestamp element is present
		$this->getAndExpectSingleMatchingElement( $line, ".mw-newpages-time" );
		// Verify that the page name is as expected.
		$pageNameElement = $this->getAndExpectSingleMatchingElement(
			$line, ".mw-newpages-pagename"
		);
		$this->assertSame(
			$this->getServiceContainer()->getTitleFormatter()->getPrefixedText( $firstRevision->getPage() ),
			$pageNameElement->textContent
		);
		// Verify that the edit page and page history links are there
		$editLinkElement = $this->getAndExpectSingleMatchingElement( $line, ".mw-newpages-edit" );
		$this->assertSame( '(editlink)', $editLinkElement->textContent );
		$pageHistoryLinkElement = $this->getAndExpectSingleMatchingElement(
			$line, ".mw-newpages-history"
		);
		$this->assertSame( '(hist)', $pageHistoryLinkElement->textContent );
		// Verify that the user link is present and correct, including that the username is hidden if the current
		// authority cannot see it.
		$authority = RequestContext::getMain()->getAuthority();
		$userNameElement = $this->getAndExpectSingleMatchingElement( $line, ".mw-userlink" );
		if ( $firstRevision->userCan( RevisionRecord::DELETED_USER, $authority ) ) {
			$expectedUserText = $firstRevision->getUser( RevisionRecord::RAW )->getName();
		} else {
			$expectedUserText = '(rev-deleted-user)';
		}
		$this->assertSame( $expectedUserText, $userNameElement->textContent );
		// Verify that the comment is present if visible or hidden if not
		$commentElement = $this->getAndExpectSingleMatchingElement( $line, ".comment" );
		if ( $firstRevision->userCan( RevisionRecord::DELETED_COMMENT, $authority ) ) {
			$this->assertStringContainsString( $firstRevision->getComment()->text, $commentElement->textContent );
		} else {
			$this->assertStringContainsString( '(rev-deleted-comment)', $commentElement->textContent );
		}
	}

	/**
	 * Perform testing steps that are common to all of the tests in this file.
	 *
	 * @param Title[] $expectedPages A list of Title objects for pages that should appear in the results
	 * @param Title[] $expectedPagesNotShown A list of Title objects for pages that should not appear in the results
	 * @param ?FauxRequest $fauxRequest A fake request to use for the test, null just uses the main request
	 * @param bool $canAnonUsersCreatePages Whether IP addresses can create pages
	 * @param ?bool $canTempUsersCreatePages Null if temporary accounts are disabled and not known about.
	 *   A boolean if temporary accounts are enabled, and the boolean is whether temporary accounts can create pages.
	 * @return string
	 */
	private function testLoadPage(
		array $expectedPages, array $expectedPagesNotShown, ?FauxRequest $fauxRequest = null,
		bool $canAnonUsersCreatePages = false, ?bool $canTempUsersCreatePages = false
	): string {
		$this->setGroupHasRightsToCreatePages( '*', $canAnonUsersCreatePages );
		if ( $canTempUsersCreatePages !== null ) {
			// If the $canTempUsersCreatePages is set to a boolean, then enable temp users as temporary users are
			// being used in the test.
			$this->enableAutoCreateTempUser();
			$this->setGroupHasRightsToCreatePages( 'temp', $canTempUsersCreatePages );
		}
		$this->overrideConfigValues( [
			MainConfigNames::UseNPPatrol => true,
			MainConfigNames::UseRCPatrol => true,
		] );
		// This is explicitly needed because the HTMLSizeFilterField uses the user's language and not the language
		// set by ::executeSpecialPage.
		$this->setUserLang( 'qqx' );
		// Call the special page and verify that the form fields are as expected.
		[ $html ] = $this->executeSpecialPage( '', $fauxRequest );
		$this->verifyFormFieldsArePresent( $html, $canAnonUsersCreatePages );
		// Verify that the pages which should be there are present in the page.
		$contributionsList = $this->getAndExpectSingleMatchingElement(
			DOMUtils::parseHTML( $html ), '.mw-contributions-list'
		);
		foreach ( $expectedPages as $page ) {
			// Find the line with the matching revision ID
			$firstRevision = $this->getServiceContainer()->getRevisionStore()->getFirstRevision( $page );
			$matchingLine = $this->getAndExpectSingleMatchingElement(
				$contributionsList, "li[data-mw-revid=\"{$firstRevision->getId()}\"]"
			);
			// Check that this matching line has the expected structure.
			$this->verifyLineHasExpectedElements( $matchingLine, $firstRevision );
		}
		// Check that the pages which shouldn't be there are not added to the page.
		foreach ( $expectedPagesNotShown as $page ) {
			$firstRevId = $this->getServiceContainer()->getRevisionStore()->getFirstRevision( $page )->getId();
			$matchingLines = DOMCompat::querySelectorAll( $contributionsList, "[data-mw-revid=\"$firstRevId\"]" );
			$this->assertCount(
				0, $matchingLines, "New page entry for revision $firstRevId was not expected"
			);
		}
		// Verify that the edit is never shown
		$matchingLines = DOMCompat::querySelectorAll(
			$contributionsList, '[data-mw-revid="' . self::$editRevId . '"]'
		);
		$this->assertCount(
			0, $matchingLines,
			'A revision ID which is not associated with a new page creation is present in Special:NewPages.'
		);
		// Return the HTML to allow further custom testing by the methods which called this method.
		return $html;
	}

	public function testLoadWithNoOptionsSpecified() {
		// Expect that by default all new main space page creations are shown, but no other pages.
		$expectedPages = [];
		$expectedPagesNotShown = [];
		foreach ( self::$allPages as $page ) {
			if ( $page->getNamespace() === NS_MAIN ) {
				$expectedPages[] = $page;
			} else {
				$expectedPagesNotShown[] = $page;
			}
		}
		$this->testLoadPage( $expectedPages, $expectedPagesNotShown );
	}

	public function testWhenFilteredToJustTestUser1Pages() {
		// Filter for all page creations by the first test user.
		$this->testLoadPage(
			self::$testUser1Pages, array_diff( self::$allPages, self::$testUser1Pages ),
			new FauxRequest( [ 'username' => self::$testUser1->getName(), 'namespace' => 'all' ] )
		);
	}

	public function testWhenFilteredToJustAnonCreations() {
		// Filter for all page creations by anon users in any namespace.
		$fauxRequest = new FauxRequest( [ 'hideliu' => true, 'namespace' => '' ] );
		$this->testLoadPage(
			array_diff( self::$allPages, self::$testUser1Pages ), self::$testUser1Pages, $fauxRequest,
			true, true
		);
	}

	public function testWhenFilteredToJustAnonCreationsWhenTemporaryAccountsAreDisabled() {
		// Filter for all page creations by anon users in any namespace.
		$fauxRequest = new FauxRequest( [ 'hideliu' => true, 'namespace' => '' ] );
		// The expected pages should only be creations where the author is not an IP address.
		$expectedPages = array_filter( self::$allPages, function ( $page ) {
			$firstRev = $this->getServiceContainer()->getRevisionStore()->getFirstRevision( $page );
			return !$firstRev->getUser()->isRegistered();
		} );
		$this->disableAutoCreateTempUser();
		$this->testLoadPage(
			$expectedPages, array_diff( self::$allPages, $expectedPages ), $fauxRequest,
			true, null
		);
	}

	public function addDBDataOnce() {
		// Create some pages so that there will be some entries in Special:NewPages.
		$testUser1 = $this->getMutableTestUser()->getUser();
		// Get the first test user to create a page and it's associated talk page in mainspace.
		$firstPage = $this->insertPage( 'SpecialNewPagesTest1', 'test', NS_MAIN, $testUser1 );
		$secondPage = $this->insertPage( 'SpecialNewPagesTest1', 'talk', NS_TALK, $testUser1 );
		// Get the first test user to create it's userpage
		$thirdPage = $this->insertPage( $testUser1->getName(), 'userpage', NS_USER, $testUser1 );
		// Get an anon user to create a page in the template namespace.
		$this->disableAutoCreateTempUser();
		$fourthPage = $this->insertPage(
			'SpecialNewPagesTest2', 'test', NS_TEMPLATE,
			$this->getServiceContainer()->getUserFactory()->newFromName( '127.0.0.1', UserFactory::RIGOR_NONE )
		);
		// Get a temporary account to create a page in the project namespace.
		$this->enableAutoCreateTempUser();
		$testTempUser = $this->getServiceContainer()->getTempUserCreator()
			->create( null, RequestContext::getMain()->getRequest() );
		$this->assertStatusGood( $testTempUser );
		$fifthPage = $this->insertPage(
			'SpecialNewPagesTest3', 'test', NS_PROJECT, $testTempUser->getUser()
		);
		// Get the sysop test user to make an edit, to test it won't appear in Special:NewPages.
		$editStatus = $this->editPage( $firstPage['title'], 'testing1234', 'test edit' );
		$this->assertStatusGood( $editStatus );
		self::$testUser1 = $testUser1;
		self::$testUser1Pages = [ $firstPage['title'], $secondPage['title'], $thirdPage['title'], ];
		self::$allPages = array_merge( self::$testUser1Pages, [ $fourthPage['title'], $fifthPage['title'] ] );
		self::$editRevId = $editStatus->getNewRevision()->getId();
	}
}
PK       ! pf1    "  specials/SpecialUserLogoutTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Request\FauxRequest;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Specials\SpecialUserLogout;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;

/**
 * @covers \MediaWiki\Specials\SpecialUserLogout
 * @group Database
 */
class SpecialUserLogoutTest extends SpecialPageTestBase {

	use TempUserTestTrait;

	/**
	 * Returns a new instance of the special page under test.
	 *
	 * @return SpecialPage
	 */
	protected function newSpecialPage() {
		return new SpecialUserLogout( $this->getServiceContainer()->getTempUserConfig() );
	}

	public function testUserLogoutComplete() {
		$oldName = __METHOD__;
		$user = new TestUser( $oldName );

		$session = RequestContext::getMain()->getRequest()->getSession();
		$fauxRequest = new FauxRequest(
			[ 'wpEditToken' => $session->getToken( 'logoutToken' ) ],
			/* $wasPosted= */ true,
			$session
		);

		$oldNameInHook = null;
		$this->setTemporaryHook(
			'UserLogoutComplete',
			static function ( $user, $injected_html, $oldName ) use ( &$oldNameInHook ) {
				$oldNameInHook = $oldName;
			}
		);

		[ $html ] = $this->executeSpecialPage( '', $fauxRequest, 'qqx', $user->getUser(), true );
		// Check that the page title and page content are as expected for a normal user logout
		$this->assertStringContainsString( '(logouttext:', $html );
		$this->assertStringContainsString( '(userlogout)', $html );

		$this->assertEquals(
			$oldName,
			$oldNameInHook,
			'old name in UserLogoutComplete hook was incorrect'
		);
	}

	public function testExecuteForTemporaryAccount() {
		$this->enableAutoCreateTempUser();
		$user = $this->getServiceContainer()->getTempUserCreator()->create( null, new FauxRequest() )->getUser();

		$session = RequestContext::getMain()->getRequest()->getSession();
		$session->setUser( $user );
		$fauxRequest = new FauxRequest( [ 'wpEditToken' => $session->getToken( 'logoutToken' ) ], true, $session );

		[ $html ] = $this->executeSpecialPage( '', $fauxRequest, 'qqx', $user, true );
		// Check that the page title and page content are as expected for the temporary account logout
		$this->assertStringContainsString( '(logouttext-for-temporary-account:', $html );
		$this->assertStringContainsString( '(templogout)', $html );
	}

	public function testViewForTemporaryAccountAfterApiLogout() {
		$user = $this->getServiceContainer()->getUserFactory()->newAnonymous( '1.2.3.4' );

		$fauxRequest = new FauxRequest( [ 'wasTempUser' => 1 ] );

		[ $html ] = $this->executeSpecialPage( '', $fauxRequest, 'qqx', $user, true );
		// Check that the page title and page content are as expected for the temporary account logout
		$this->assertStringContainsString( '(logouttext-for-temporary-account:', $html );
		$this->assertStringContainsString( '(templogout)', $html );
	}

	public function testViewForTemporaryAccount() {
		$this->enableAutoCreateTempUser();
		$user = $this->getServiceContainer()->getTempUserCreator()->create( null, new FauxRequest() )->getUser();

		[ $html ] = $this->executeSpecialPage( '', null, 'qqx', $user, true );
		// Check that the page title is as expected for a temporary account and that the submit button is present
		$this->assertStringContainsString( '(templogout)', $html );
		$this->assertStringContainsString( '(htmlform-submit)', $html );
	}
}
PK       ! "    %  specials/SpecialCreateAccountTest.phpnu Iw        <?php

use HtmlFormatter\HtmlFormatter;
use MediaWiki\Auth\AbstractPreAuthenticationProvider;
use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Specials\SpecialCreateAccount;

/**
 * @covers \MediaWiki\Specials\SpecialCreateAccount
 * @group Database
 */
class SpecialCreateAccountTest extends SpecialPageTestBase {
	/**
	 * @inheritDoc
	 */
	protected function newSpecialPage( ?IContextSource $context = null ) {
		$services = $this->getServiceContainer();
		$context ??= RequestContext::getMain();
		$page = new SpecialCreateAccount(
			$services->getAuthManager(),
			$services->getFormatterFactory()
		);
		$page->setContext( $context );
		$context->setTitle( $page->getPageTitle() );
		return $page;
	}

	public function testCheckPermissions() {
		$readOnlyMode = $this->getServiceContainer()->getReadOnlyMode();
		$readOnlyMode->setReason( 'Test' );

		$this->expectException( ErrorPageError::class );
		$specialPage = $this->newSpecialPage();
		$specialPage->checkPermissions();
	}

	/**
	 * Regression test for T360717 -- missing hidden fields from Special:CreateAccount
	 */
	public function testHiddenField() {
		$config = $this->getServiceContainer()->getMainConfig()->get( MainConfigNames::AuthManagerConfig );
		$config['preauth']['MockAuthProviderWithHiddenField'] = [
			'class' => MockAuthProviderWithHiddenField::class
		];
		$this->overrideConfigValue( MainConfigNames::AuthManagerConfig, $config );
		$specialPage = $this->newSpecialPage();
		$specialPage->execute( null );
		$html = $specialPage->getOutput()->getHTML();
		$this->assertStringContainsString(
			'<input id="mw-input-captchaId" name="captchaId" type="hidden" value="T360717">',
			$html
		);
	}

	public function testShouldShowTemporaryPasswordAndCreationReasonFieldsForRegisteredUser(): void {
		$user = $this->getTestUser()->getUser();

		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser( $user );

		$specialPage = $this->newSpecialPage( $context );
		$specialPage->execute( null );
		$doc = self::getOutputHtml( $specialPage );

		$this->assertNotNull( $doc->getElementById( 'wpReason' ) );
		$this->assertNotNull( $doc->getElementById( 'wpCreateaccountMail' ) );
	}

	public function testShouldNotShowTemporaryPasswordAndCreationReasonFieldsForTempUser(): void {
		$req = new FauxRequest();
		$tempUser = $this->getServiceContainer()
			->getTempUserCreator()
			->create( null, $req )
			->getUser();

		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser( $tempUser );

		$specialPage = $this->newSpecialPage( $context );
		$specialPage->execute( null );
		$doc = self::getOutputHtml( $specialPage );

		$this->assertNull(
			$doc->getElementById( 'wpReason' ),
			'Temporary users should not have to provide a reason for their account creation (T328718)'
		);
		$this->assertNull(
			$doc->getElementById( 'wpCreateaccountMail' ),
			'Temporary users should not have the option to have a temporary password sent on signup (T328718)'
		);
	}

	/**
	 * Convenience function to get the parsed DOM of the HTML generated by the given special page.
	 * @param SpecialPage $page
	 * @return DOMDocument
	 */
	private static function getOutputHtml( SpecialPage $page ): DOMDocument {
		$html = HtmlFormatter::wrapHTML( $page->getOutput()->getHTML() );
		return ( new HtmlFormatter( $html ) )->getDoc();
	}
}

class MockAuthRequestWithHiddenField extends AuthenticationRequest {
	public function getFieldInfo() {
		return [
			'captchaId' => [
				'type' => 'hidden',
				'value' => 'T360717',
				'label' => '',
				'help' => '',
			],
		];
	}
}

class MockAuthProviderWithHiddenField extends AbstractPreAuthenticationProvider {
	public function getAuthenticationRequests( $action, array $options ) {
		return [ new MockAuthRequestWithHiddenField ];
	}
}
PK       ! ܦ    #  specials/SpecialBookSourcesTest.phpnu Iw        <?php

use MediaWiki\Specials\SpecialBookSources;
use MediaWiki\Title\TitleFactory;

class SpecialBookSourcesTest extends SpecialPageTestBase {
	public static function provideISBNs() {
		return [
			[ '978-0-300-14424-6', true ],
			[ '0-14-020652-3', true ],
			[ '020652-3', false ],
			[ '9781234567897', true ],
			[ '1-4133-0454-0', true ],
			[ '978-1413304541', true ],
			[ '0136091814', true ],
			[ '0136091812', false ],
			[ '9780136091813', true ],
			[ '9780136091817', false ],
			[ '123456789X', true ],

			// T69021
			[ '1413304541', false ],
			[ '141330454X', false ],
			[ '1413304540', true ],
			[ '14133X4540', false ],
			[ '97814133X4541', false ],
			[ '978035642615X', false ],
			[ '9781413304541', true ],
			[ '9780356426150', true ],
		];
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialBookSources::isValidISBN
	 * @dataProvider provideISBNs
	 */
	public function testIsValidISBN( $isbn, $isValid ) {
		$this->assertSame( $isValid, SpecialBookSources::isValidISBN( $isbn ) );
	}

	protected function newSpecialPage() {
		$services = $this->getServiceContainer();
		return new SpecialBookSources(
			$services->getRevisionLookup(),
			$services->getTitleFactory()
		);
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialBookSources::execute
	 */
	public function testExecute() {
		$this->setService( 'TitleFactory', $this->createMock( TitleFactory::class ) );
		[ $html, ] = $this->executeSpecialPage( 'Invalid', null, 'qqx' );
		$this->assertStringContainsString( '(booksources-invalid-isbn)', $html );
		[ $html, ] = $this->executeSpecialPage( '0-7475-3269-9', null, 'qqx' );
		$this->assertStringNotContainsString( '(booksources-invalid-isbn)', $html );
		$this->assertStringContainsString( '(booksources-text)', $html );
	}
}
PK       ! J    !  specials/SpecialBlankPageTest.phpnu Iw        <?php

use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Specials\SpecialBlankpage;

/**
 * @license GPL-2.0-or-later
 * @author Addshore
 *
 * @covers \MediaWiki\Specials\SpecialBlankpage
 */
class SpecialBlankPageTest extends SpecialPageTestBase {

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

	/**
	 * Returns a new instance of the special page under test.
	 *
	 * @return SpecialPage
	 */
	protected function newSpecialPage() {
		return new SpecialBlankpage();
	}

	public function testHasWikiMsg() {
		[ $html, ] = $this->executeSpecialPage();
		$this->assertStringContainsString( '(intentionallyblankpage)', $html );
	}

}
PK       !     "  specials/SpecialUserRightsTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Specials\SpecialUserRights;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\WikiMap\WikiMap;

/**
 * @group Database
 * @covers \MediaWiki\Specials\SpecialUserRights
 */
class SpecialUserRightsTest extends SpecialPageTestBase {

	use TempUserTestTrait;

	/**
	 * @inheritDoc
	 */
	protected function newSpecialPage() {
		$services = $this->getServiceContainer();
		return new SpecialUserRights(
			$services->getUserGroupManagerFactory(),
			$services->getUserNameUtils(),
			$services->getUserNamePrefixSearch(),
			$services->getUserFactory(),
			$services->getActorStoreFactory(),
			$services->getWatchlistManager(),
			$services->getTempUserConfig()
		);
	}

	/** @dataProvider provideUserCanChangeRights */
	public function testUserCanChangeRights( $targetUser, $checkIfSelf, $expectedReturnValue ) {
		$objectUnderTest = $this->newSpecialPage();
		$this->assertSame( $expectedReturnValue, $objectUnderTest->userCanChangeRights( $targetUser, $checkIfSelf ) );
	}

	public static function provideUserCanChangeRights() {
		return [
			'Target user not registered' => [ UserIdentityValue::newAnonymous( 'Test' ), true, false ],
		];
	}

	public function testUserCanChangeRightsForTemporaryAccount() {
		$temporaryAccount = $this->getServiceContainer()->getTempUserCreator()
			->create( null, new FauxRequest() )->getUser();
		$this->testUserCanChangeRights( $temporaryAccount, false, false );
	}

	public function testSaveUserGroups() {
		$target = $this->getTestUser()->getUser();
		$performer = $this->getTestSysop()->getUser();
		$request = new FauxRequest(
			[
				'saveusergroups' => true,
				'conflictcheck-originalgroups' => '',
				'wpGroup-bot' => true,
				'wpExpiry-bot' => 'existing',
				'wpEditToken' => $performer->getEditToken( $target->getName() ),
			],
			true
		);

		$this->executeSpecialPage(
			$target->getName(),
			$request,
			'qqx',
			$performer
		);

		$this->assertSame( 1, $request->getSession()->get( 'specialUserrightsSaveSuccess' ) );
		$this->assertSame(
			[ 'bot' ],
			$this->getServiceContainer()->getUserGroupManager()->getUserGroups( $target )
		);
	}

	public function testSaveUserGroupsForTemporaryAccount() {
		$target = $this->getServiceContainer()->getTempUserCreator()->create( null, new FauxRequest() )->getUser();
		$performer = $this->getTestSysop()->getUser();
		$request = new FauxRequest(
			[
				'saveusergroups' => true,
				'conflictcheck-originalgroups' => '',
				'wpGroup-bot' => true,
				'wpExpiry-bot' => 'existing',
				'wpEditToken' => $performer->getEditToken( $target->getName() ),
			],
			true
		);

		[ $html ] = $this->executeSpecialPage( $target->getName(), $request, 'qqx', $performer );

		$this->assertNull( $request->getSession()->get( 'specialUserrightsSaveSuccess' ) );
		$this->assertCount( 0, $this->getServiceContainer()->getUserGroupManager()->getUserGroups( $target ) );
		$this->assertStringContainsString( 'userrights-no-group', $html );
	}

	public function testSaveUserGroups_change() {
		$target = $this->getTestUser( [ 'sysop' ] )->getUser();
		$performer = $this->getTestSysop()->getUser();
		$request = new FauxRequest(
			[
				'saveusergroups' => true,
				'conflictcheck-originalgroups' => 'sysop',
				'wpGroup-sysop' => true,
				'wpExpiry-sysop' => 'existing',
				'wpGroup-bot' => true,
				'wpExpiry-bot' => 'existing',
				'wpEditToken' => $performer->getEditToken( $target->getName() ),
			],
			true
		);

		$this->executeSpecialPage(
			$target->getName(),
			$request,
			'qqx',
			$performer
		);

		$this->assertSame( 1, $request->getSession()->get( 'specialUserrightsSaveSuccess' ) );
		$result = $this->getServiceContainer()->getUserGroupManager()->getUserGroups( $target );
		sort( $result );
		$this->assertSame(
			[ 'bot', 'sysop' ],
			$result
		);
	}

	public function testSaveUserGroups_change_expiry() {
		$expiry = wfTimestamp( TS_MW, (int)wfTimestamp( TS_UNIX ) + 100 );
		$target = $this->getTestUser( [ 'bot' ] )->getUser();
		$performer = $this->getTestSysop()->getUser();
		$request = new FauxRequest(
			[
				'saveusergroups' => true,
				'conflictcheck-originalgroups' => 'bot',
				'wpGroup-bot' => true,
				'wpExpiry-bot' => $expiry,
				'wpEditToken' => $performer->getEditToken( $target->getName() ),
			],
			true
		);

		$this->executeSpecialPage(
			$target->getName(),
			$request,
			'qqx',
			$performer
		);

		$this->assertSame( 1, $request->getSession()->get( 'specialUserrightsSaveSuccess' ) );
		$userGroups = $this->getServiceContainer()->getUserGroupManager()->getUserGroupMemberships( $target );
		$this->assertCount( 1, $userGroups );
		foreach ( $userGroups as $ugm ) {
			$this->assertSame( 'bot', $ugm->getGroup() );
			$this->assertSame( $expiry, $ugm->getExpiry() );
		}
	}

	private function getExternalDBname(): ?string {
		$availableDatabases = array_diff(
			$this->getConfVar( MainConfigNames::LocalDatabases ),
			[ WikiMap::getCurrentWikiDbDomain()->getDatabase() ]
		);

		if ( $availableDatabases === [] ) {
			return null;
		}

		// sort to ensure results are deterministic
		sort( $availableDatabases );
		return $availableDatabases[0];
	}

	public function testInterwikiRightsChange() {
		$externalDBname = $this->getExternalDBname();
		if ( $externalDBname === null ) {
			$this->markTestSkipped( 'No external database is available' );
		}

		// FIXME: This should not depend on WikiAdmin user existence
		// NOTE: This is here, as in WMF's CI setup, WikiAdmin is the only user
		// guaranteed to exist on the other wiki.
		$localUser = $this->getServiceContainer()->getUserFactory()->newFromName( 'WikiAdmin' );

		$externalUsername = $localUser->getName() . '@' . $externalDBname;

		// FIXME: This should benefit from $tablesUsed; until this is possible, purge user_groups on
		// the other wiki.
		$externalDbw = $this->getServiceContainer()
			->getConnectionProvider()
			->getPrimaryDatabase( $externalDBname );
		$externalDbw->truncateTable( 'user_groups', __METHOD__ );

		// ensure using SpecialUserRights with external usernames doesn't throw (T342747, T342322)
		$performer = $this->getTestUser( [ 'bureaucrat' ] );
		$request = new FauxRequest( [
			'saveusergroups' => true,
			'conflictcheck-originalgroups' => '',
			'wpGroup-sysop' => true,
			'wpExpiry-sysop' => 'existing',
			'wpEditToken' => $performer->getUser()->getEditToken( $externalUsername ),
		], true );
		[ $html, ] = $this->executeSpecialPage(
			$externalUsername,
			$request,
			null,
			$performer->getAuthority()
		);
		$this->assertSame( 1, $request->getSession()->get( 'specialUserrightsSaveSuccess' ) );
		// ensure logging is done with the right username (T344391)
		$this->assertSame(
			1,
			(int)$this->getDb()->newSelectQueryBuilder()
				->select( [ 'cnt' => 'COUNT(*)' ] )
				->from( 'logging' )
				->where( [
					'log_type' => 'rights',
					'log_action' => 'rights',
					'log_namespace' => NS_USER,
					'log_title' => $externalUsername,
				] )
				->caller( __METHOD__ )
				->fetchField()
		);
	}
}
PK       ! .  .    specials/ContribsPagerTest.phpnu Iw        <?php

use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\CommentFormatter\CommentFormatter;
use MediaWiki\Config\HashConfig;
use MediaWiki\Context\RequestContext;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\MainConfigNames;
use MediaWiki\Pager\ContribsPager;
use MediaWiki\Pager\IndexPager;
use MediaWiki\Permissions\SimpleAuthority;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Database
 * @covers \MediaWiki\Pager\ContribsPager
 * @covers \MediaWiki\Pager\ContributionsPager
 */
class ContribsPagerTest extends MediaWikiIntegrationTestCase {
	use TempUserTestTrait;

	/** @var ContribsPager */
	private $pager;

	/** @var LinkRenderer */
	private $linkRenderer;

	/** @var RevisionStore */
	private $revisionStore;

	/** @var LinkBatchFactory */
	private $linkBatchFactory;

	/** @var HookContainer */
	private $hookContainer;

	/** @var IConnectionProvider */
	private $dbProvider;

	/** @var NamespaceInfo */
	private $namespaceInfo;

	/** @var CommentFormatter */
	private $commentFormatter;

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

		$services = $this->getServiceContainer();
		$this->linkRenderer = $services->getLinkRenderer();
		$this->revisionStore = $services->getRevisionStore();
		$this->linkBatchFactory = $services->getLinkBatchFactory();
		$this->hookContainer = $services->getHookContainer();
		$this->dbProvider = $services->getConnectionProvider();
		$this->namespaceInfo = $services->getNamespaceInfo();
		$this->commentFormatter = $services->getCommentFormatter();
		$this->pager = $this->getContribsPager( [
			'start' => '2017-01-01',
			'end' => '2017-02-02',
		] );
	}

	private function getContribsPager( array $options, ?UserIdentity $targetUser = null ) {
		return new ContribsPager(
			new RequestContext(),
			$options,
			$this->linkRenderer,
			$this->linkBatchFactory,
			$this->hookContainer,
			$this->dbProvider,
			$this->revisionStore,
			$this->namespaceInfo,
			$targetUser,
			$this->commentFormatter
		);
	}

	/**
	 * Tests enabling/disabling ContribsPager::reallyDoQuery hook via the revisionsOnly option to restrict
	 * extensions are able to insert their own revisions
	 */
	public function testRevisionsOnlyOption() {
		$this->setTemporaryHook( 'ContribsPager::reallyDoQuery', static function ( &$data ) {
			$fakeRow = (object)[ 'rev_timestamp' => '20200717192356' ];
			$fakeRowWrapper = new FakeResultWrapper( [ $fakeRow ] );
			$data[] = $fakeRowWrapper;
		} );

		$allContribsPager = $this->getContribsPager( [] );
		$allContribsResults = $allContribsPager->reallyDoQuery( '', 2, IndexPager::QUERY_DESCENDING );
		$this->assertSame( 1, $allContribsResults->numRows() );

		$revOnlyPager = $this->getContribsPager( [ 'revisionsOnly' => true ] );
		$revOnlyResults = $revOnlyPager->reallyDoQuery( '', 2, IndexPager::QUERY_DESCENDING );
		$this->assertSame( 0, $revOnlyResults->numRows() );
	}

	/**
	 * @dataProvider dateFilterOptionProcessingProvider
	 * @param array $inputOpts Input options
	 * @param array $expectedOpts Expected options
	 */
	public function testDateFilterOptionProcessing( array $inputOpts, array $expectedOpts ) {
		$this->assertArraySubmapSame(
			$expectedOpts,
			ContribsPager::processDateFilter( $inputOpts ),
			"Matching date filter options"
		);
	}

	public static function dateFilterOptionProcessingProvider() {
		return [
			[
				[
					'start' => '2016-05-01',
					'end' => '2016-06-01',
					'year' => null,
					'month' => null
				],
				[
					'start' => '2016-05-01',
					'end' => '2016-06-01'
				]
			],
			[
				[
					'start' => '2016-05-01',
					'end' => '2016-06-01',
					'year' => '',
					'month' => ''
				],
				[
					'start' => '2016-05-01',
					'end' => '2016-06-01'
				]
			],
			[
				[
					'start' => '2016-05-01',
					'end' => '2016-06-01',
					'year' => '2012',
					'month' => '5'
				],
				[
					'start' => '',
					'end' => '2012-05-31'
				]
			],
			[
				[
					'start' => '',
					'end' => '',
					'year' => '2012',
					'month' => '5'
				],
				[
					'start' => '',
					'end' => '2012-05-31'
				]
			],
			[
				[
					'start' => '',
					'end' => '',
					'year' => '2012',
					'month' => ''
				],
				[
					'start' => '',
					'end' => '2012-12-31'
				]
			],
		];
	}

	/**
	 * @dataProvider provideQueryableRanges
	 */
	public function testQueryableRanges( $ipRange ) {
		$config = new HashConfig( [
			MainConfigNames::RangeContributionsCIDRLimit => [
				'IPv4' => 16,
				'IPv6' => 32,
			]
		] );

		$this->assertTrue(
			ContribsPager::isQueryableRange( $ipRange, $config ),
			"$ipRange is a queryable IP range"
		);
	}

	public static function provideQueryableRanges() {
		return [
			[ '116.17.184.5/32' ],
			[ '0.17.184.5/16' ],
			[ '2000::/32' ],
			[ '2001:db8::/128' ],
		];
	}

	/**
	 * @dataProvider provideUnqueryableRanges
	 */
	public function testUnqueryableRanges( $ipRange ) {
		$config = new HashConfig( [
			MainConfigNames::RangeContributionsCIDRLimit => [
				'IPv4' => 16,
				'IPv6' => 32,
			]
		] );

		$this->assertFalse(
			ContribsPager::isQueryableRange( $ipRange, $config ),
			"$ipRange is not a queryable IP range"
		);
	}

	public static function provideUnqueryableRanges() {
		return [
			[ '116.17.184.5/33' ],
			[ '0.17.184.5/15' ],
			[ '2000::/31' ],
			[ '2001:db8::/9999' ],
		];
	}

	public function testUniqueSortOrderWithoutIpChanges() {
		$pager = $this->getContribsPager( [
			'start' => '',
			'end' => '',
		] );

		/** @var ContribsPager $pager */
		$pager = TestingAccessWrapper::newFromObject( $pager );
		$queryInfo = $pager->buildQueryInfo( '', 1, false );

		$this->assertNotContains( 'ip_changes', $queryInfo[0] );
		$this->assertArrayNotHasKey( 'ip_changes', $queryInfo[5] );
		$this->assertContains( 'rev_timestamp', $queryInfo[1] );
		$this->assertContains( 'rev_id', $queryInfo[1] );
		$this->assertSame( [ 'rev_timestamp DESC', 'rev_id DESC' ], $queryInfo[4]['ORDER BY'] );
	}

	public function testUniqueSortOrderOnIpChanges() {
		$pager = $this->getContribsPager( [
			'target' => '116.17.184.5/32',
			'start' => '',
			'end' => '',
		] );

		/** @var ContribsPager $pager */
		$pager = TestingAccessWrapper::newFromObject( $pager );
		$queryInfo = $pager->buildQueryInfo( '', 1, false );

		$this->assertContains( 'ip_changes', $queryInfo[0] );
		$this->assertArrayHasKey( 'revision', $queryInfo[5] );
		$this->assertSame( [ 'ipc_rev_timestamp DESC', 'ipc_rev_id DESC' ], $queryInfo[4]['ORDER BY'] );
	}

	public function testCreateRevision() {
		$title = Title::makeTitle( NS_MAIN, __METHOD__ );

		$pager = $this->getContribsPager( [
			'target' => '116.17.184.5/32',
			'start' => '',
			'end' => '',
		] );

		$invalidObject = new class() {
			public $rev_id;
		};
		$this->assertNull( $pager->tryCreatingRevisionRecord( $invalidObject, $title ) );

		$invalidRow = (object)[
			'foo' => 'bar'
		];

		$this->assertNull( $pager->tryCreatingRevisionRecord( $invalidRow, $title ) );

		$validRow = (object)[
			'rev_id' => '2',
			'rev_page' => '2',
			'page_namespace' => $title->getNamespace(),
			'page_title' => $title->getDBkey(),
			'rev_text_id' => '47',
			'rev_timestamp' => '20180528192356',
			'rev_minor_edit' => '0',
			'rev_deleted' => '0',
			'rev_len' => '700',
			'rev_parent_id' => '0',
			'rev_sha1' => 'deadbeef',
			'rev_comment_text' => 'whatever',
			'rev_comment_data' => null,
			'rev_comment_cid' => null,
			'rev_user' => '1',
			'rev_user_text' => 'Editor',
			'rev_actor' => '11',
			'rev_content_format' => null,
			'rev_content_model' => null,
		];

		$this->assertNotNull( $pager->tryCreatingRevisionRecord( $validRow, $title ) );
	}

	/**
	 * Flow uses ContribsPager::reallyDoQuery hook to provide something other then
	 * stdClass as a row, and then manually formats its own row in ContributionsLineEnding.
	 * Emulate this behaviour and check that it works.
	 */
	public function testContribProvidedByHook() {
		$this->setTemporaryHook( 'ContribsPager::reallyDoQuery', static function ( &$data ) {
			$data = [ [ new class() {
				public $rev_timestamp = 12345;
				public $testing = 'TESTING';
			} ] ];
		} );
		$this->setTemporaryHook( 'ContributionsLineEnding', function ( $pager, &$ret, $row ) {
			$this->assertSame( 'TESTING', $row->testing );
			$ret .= 'FROM_HOOK!';
		} );
		$pager = $this->getContribsPager( [] );
		$this->assertStringContainsString( 'FROM_HOOK!', $pager->getBody() );
	}

	public static function provideEmptyResultIntegration() {
		$cases = [
			[ 'target' => '127.0.0.1' ],
			[ 'target' => '127.0.0.1/24' ],
			[ 'testUser' => true ],
			[ 'target' => '127.0.0.1', 'namespace' => 0 ],
			[ 'target' => '127.0.0.1', 'namespace' => 0, 'nsInvert' => true ],
			[ 'target' => '127.0.0.1', 'namespace' => 0, 'associated' => true ],
			[ 'target' => '127.0.0.1', 'tagfilter' => 'tag' ],
			[ 'target' => '127.0.0.1', 'topOnly' => true ],
			[ 'target' => '127.0.0.1', 'newOnly' => true ],
			[ 'target' => '127.0.0.1', 'hideMinor' => true ],
			[ 'target' => '127.0.0.1', 'revisionsOnly' => true ],
			[ 'target' => '127.0.0.1', 'deletedOnly' => true ],
			[ 'target' => '127.0.0.1', 'start' => '20010115000000' ],
			[ 'target' => '127.0.0.1', 'end' => '20210101000000' ],
			[ 'target' => '127.0.0.1', 'start' => '20010115000000', 'end' => '20210101000000' ],
		];
		foreach ( $cases as $case ) {
			yield [ $case ];
		}
	}

	/**
	 * This DB integration test confirms that the query is valid for various
	 * filter options, by running the query on an empty DB.
	 *
	 * @dataProvider provideEmptyResultIntegration
	 */
	public function testEmptyResultIntegration( $options ) {
		if ( !empty( $options['testUser'] ) ) {
			$targetUser = new UserIdentityValue( 1, 'User' );
		} else {
			$targetUser = $this->getServiceContainer()->getUserFactory()
				->newFromName( $options['target'] );
		}
		$pager = $this->getContribsPager( $options, $targetUser );
		$this->assertIsString( $pager->getBody() );
		$this->assertSame( 0, $pager->getNumRows() );
	}

	/**
	 * DB integration test for an IP range target with a few edits.
	 */
	public function testPopulatedIntegration() {
		$this->disableAutoCreateTempUser();
		$user = new SimpleAuthority( new UserIdentityValue( 0, '127.0.0.1' ), [] );
		$title = Title::makeTitle( NS_MAIN, 'ContribsPagerTest' );
		$this->editPage( $title, '', '', NS_MAIN, $user );
		$this->editPage( $title, 'Test content.', '', NS_MAIN, $user );
		$pager = $this->getContribsPager( [ 'target' => '127.0.0.1/16' ] );
		$this->assertIsString( $pager->getBody() );
		$this->assertSame( 2, $pager->getNumRows() );
	}

	/**
	 * DB integration test for a reader with permissions to delete and rollback.
	 */
	public function testPopulatedIntegrationWithPermissions() {
		$this->setGroupPermissions( [ '*' => [
			'deletedhistory' => true,
			'deleterevision' => true,
			'rollback' => true,
		] ] );
		$sysop = $this->getTestsysop()->getUser();
		$user = $this->getTestUser()->getUser();
		$title = Title::makeTitle( NS_MAIN, 'ContribsPagerTest' );

		// Edit from a different user so we show rollback links
		$this->editPage( $title, '', '', NS_MAIN, $sysop );
		$this->editPage( $title, 'Test content.', '', NS_MAIN, $user );

		$this->getDb()->newUpdateQueryBuilder()
			->update( 'revision' )
			->set( [
				'rev_deleted' => RevisionRecord::DELETED_USER,
				// Make a couple of alterations to ensure these paths are covered
				'rev_minor_edit' => 1,
				'rev_parent_id' => null,
			] )
			->where( [
				'rev_actor' => $user->getActorId()
			] )
			->execute();

		$pager = $this->getContribsPager( [], $user );
		$this->assertIsString( $pager->getBody() );
		$this->assertSame( 1, $pager->getNumRows() );
	}
}
PK       ! t^,  ,    specials/SpecialMuteTest.phpnu Iw        <?php

use MediaWiki\HookContainer\HookContainer;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Specials\SpecialMute;
use MediaWiki\User\Options\UserOptionsManager;

/**
 * @group SpecialPage
 * @group Database
 * @covers \MediaWiki\Specials\SpecialMute
 */
class SpecialMuteTest extends SpecialPageTestBase {

	/** @var UserOptionsManager */
	private $userOptionsManager;

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

		$this->userOptionsManager = $this->getServiceContainer()->getUserOptionsManager();
		$this->overrideConfigValue( MainConfigNames::EnableUserEmailMuteList, true );
	}

	/**
	 * @inheritDoc
	 */
	protected function newSpecialPage() {
		return new SpecialMute(
			$this->getServiceContainer()->getCentralIdLookupFactory()->getLookup( 'local' ),
			$this->userOptionsManager,
			$this->getServiceContainer()->getUserIdentityLookup(),
			$this->getServiceContainer()->getUserIdentityUtils()
		);
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialMute::execute
	 */
	public function testInvalidTarget() {
		$user = $this->getTestUser()->getUser();
		$this->expectException( ErrorPageError::class );
		$this->expectExceptionMessage( "username requested could not be found" );
		$this->executeSpecialPage(
			'InvalidUser', null, 'qqx', $user
		);
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialMute::execute
	 */
	public function testEmailBlacklistNotEnabled() {
		$this->setTemporaryHook(
			'SpecialMuteModifyFormFields',
			HookContainer::NOOP
		);

		$this->overrideConfigValue( MainConfigNames::EnableUserEmailMuteList, false );

		$user = $this->getTestUser()->getUser();
		$this->expectException( ErrorPageError::class );
		$this->expectExceptionMessage( "Mute features are unavailable" );
		$this->executeSpecialPage(
			$user->getName(), null, 'qqx', $user
		);
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialMute::execute
	 */
	public function testUserNotLoggedIn() {
		$this->expectException( UserNotLoggedIn::class );
		$this->executeSpecialPage( 'TestUser' );
	}

	public function testUserEmailNotConfirmed() {
		$targetUser = $this->getTestUser()->getUser();

		$loggedInUser = $this->getMutableTestUser()->getUser();
		$this->userOptionsManager->setOption( $loggedInUser, 'email-blacklist', "999" );
		$loggedInUser->invalidateEmail();
		$loggedInUser->saveSettings();

		$this->expectExceptionMessage( wfMessage( 'specialmute-error-no-email-set' ) );

		$fauxRequest = new FauxRequest( [ 'wpemail-blacklist' => true ], true );
		$this->executeSpecialPage( $targetUser->getName(), $fauxRequest, 'qqx', $loggedInUser );
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialMute::execute
	 */
	public function testMuteAddsUserToEmailBlacklist() {
		$targetUser = $this->getTestUser()->getUser();

		$loggedInUser = $this->getMutableTestUser()->getUser();
		$this->userOptionsManager->setOption( $loggedInUser, 'email-blacklist', "999" );
		$loggedInUser->confirmEmail();
		$loggedInUser->saveSettings();

		$fauxRequest = new FauxRequest( [ 'wpemail-blacklist' => true ], true );
		[ $html, ] = $this->executeSpecialPage(
			$targetUser->getName(), $fauxRequest, 'qqx', $loggedInUser
		);

		$this->assertStringContainsString( 'specialmute-success', $html );
		$this->assertEquals(
			"999\n" . $targetUser->getId(),
			$this->userOptionsManager->getOption( $loggedInUser, 'email-blacklist' )
		);
	}

	/**
	 * @covers \MediaWiki\Specials\SpecialMute::execute
	 */
	public function testUnmuteRemovesUserFromEmailBlacklist() {
		$targetUser = $this->getTestUser()->getUser();

		$loggedInUser = $this->getMutableTestUser()->getUser();
		$this->userOptionsManager->setOption( $loggedInUser, 'email-blacklist', "999\n" . $targetUser->getId() );
		$loggedInUser->confirmEmail();
		$loggedInUser->saveSettings();

		$fauxRequest = new FauxRequest( [ 'wpemail-blacklist' => false ], true );
		[ $html, ] = $this->executeSpecialPage(
			$targetUser->getName(), $fauxRequest, 'qqx', $loggedInUser
		);

		$this->assertStringContainsString( 'specialmute-success', $html );
		$this->assertSame( "999", $this->userOptionsManager->getOption( $loggedInUser, 'email-blacklist' ) );
	}
}
PK       ! oF      specials/SpecialUnblockTest.phpnu Iw        <?php

use MediaWiki\Block\DatabaseBlock;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Specials\SpecialUnblock;
use MediaWiki\Title\Title;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Blocking
 * @group Database
 * @coversDefaultClass \MediaWiki\Specials\SpecialUnblock
 */
class SpecialUnblockTest extends SpecialPageTestBase {
	/**
	 * @inheritDoc
	 */
	protected function newSpecialPage() {
		$services = $this->getServiceContainer();
		return new SpecialUnblock(
			$services->getUnblockUserFactory(),
			$services->getBlockUtils(),
			$services->getDatabaseBlockStore(),
			$services->getUserNameUtils(),
			$services->getUserNamePrefixSearch(),
			$services->getWatchlistManager()
		);
	}

	/**
	 * @dataProvider provideGetFields
	 * @covers ::getFields
	 */
	public function testGetFields( $target, $expected ) {
		$page = TestingAccessWrapper::newFromObject( $this->newSpecialPage() );
		$page->target = $target;
		$page->block = new DatabaseBlock( [
			'address' => '1.2.3.4',
			'by' => $this->getTestSysop()->getUser(),
		] );

		$fields = $page->getFields();
		$this->assertIsArray( $fields );
		foreach ( $expected as $fieldName ) {
			$this->assertArrayHasKey( $fieldName, $fields );
		}
	}

	public static function provideGetFields() {
		return [
			'No target specified' => [
				'',
				[ 'Target', 'Reason' ],
			],
			'Target is not blocked' => [
				'1.1.1.1',
				[ 'Target', 'Reason' ],
			],
			'Target is blocked' => [
				'1.2.3.4',
				[ 'Target', 'Reason', 'Name' ],
			],
		];
	}

	/**
	 * @dataProvider provideProcessUnblockErrors
	 * @covers ::execute
	 */
	public function testProcessUnblockErrors( $options, $expected ) {
		$performer = $this->getTestSysop()->getUser();

		$target = '1.1.1.1';
		if ( !empty( $options['block'] ) ) {
			$block = new DatabaseBlock( [
				'address' => $target,
				'by' => $performer,
				'hideName' => true,
			] );
			$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );
		}

		if ( !empty( $options['readOnly'] ) ) {
			$this->overrideConfigValue( MainConfigNames::ReadOnly, true );
			$this->expectException( ReadOnlyError::class );
		}

		if ( isset( $options['permissions'] ) ) {
			$this->overrideUserPermissions( $performer, $options['permissions'] );
		}

		$request = new FauxRequest( [
			'wpTarget' => $target,
			'wpReason' => '',
		], true );
		[ $html, ] = $this->executeSpecialPage( '', $request, 'qqx', $performer );

		$this->assertStringContainsString( $expected, $html );
	}

	public static function provideProcessUnblockErrors() {
		return [
			'Target is not blocked' => [
				[
					'permissions' => [ 'block', 'hideuser' => true ],
				],
				'ipb_cant_unblock',
			],
			'Wrong permissions for unhiding user' => [
				[
					'block' => true,
					'permissions' => [ 'block', 'hideuser' => false ],
				],
				'unblock-hideuser',
			],
			'Delete block failed' => [
				[
					'block' => true,
					'permissions' => [ 'block', 'hideuser' ],
					'readOnly' => true,
				],
				'ipb_cant_unblock',
			],
		];
	}

	/**
	 * @covers ::execute
	 */
	public function testProcessUnblockErrorsUnblockSelf() {
		$performer = $this->getTestSysop()->getUser();

		$this->overrideUserPermissions( $performer, [ 'block', 'unblockself' => false ] );

		// Blocker must be different user for unblock self to be disallowed
		$blocker = $this->getTestUser()->getUser();
		$block = new DatabaseBlock( [
			'by' => $blocker,
			'address' => $performer,
		] );
		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );

		$request = new FauxRequest( [
			'wpTarget' => $performer->getName(),
			'wpReason' => '',
		], true );
		[ $html, ] = $this->executeSpecialPage( '', $request, 'qqx', $performer );

		$this->assertStringContainsString( 'ipbnounblockself', $html );
	}

	/**
	 * @covers ::execute
	 */
	public function testWatched() {
		$performer = $this->getTestSysop()->getUser();

		$target = '1.2.3.4';
		$block = new DatabaseBlock( [
			'by' => $performer,
			'address' => $target,
		] );
		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );

		$request = new FauxRequest( [
			'wpTarget' => $target,
			'wpReason' => '',
			'wpWatch' => '1',
		], true );
		$this->executeSpecialPage( '', $request, 'qqx', $performer );

		$userPage = Title::makeTitle( NS_USER, $target );
		$this->assertTrue( $this->getServiceContainer()->getWatchlistManager()
			->isWatched( $performer, $userPage ) );
	}
}
PK       ! D^z       specials/SpecialPageTestBase.phpnu Iw        <?php

use MediaWiki\Language\Language;
use MediaWiki\Permissions\Authority;
use MediaWiki\Request\WebRequest;
use MediaWiki\SpecialPage\SpecialPage;

/**
 * Base class for testing special pages.
 *
 * @since 1.26
 *
 * @license GPL-2.0-or-later
 * @author Jeroen De Dauw < jeroendedauw@gmail.com >
 * @author Daniel Kinzler
 * @author Addshore
 * @author Thiemo Kreuz
 */
abstract class SpecialPageTestBase extends MediaWikiIntegrationTestCase {

	/** @var int */
	private $obLevel;

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

		$this->obLevel = ob_get_level();
	}

	protected function tearDown(): void {
		$obLevel = ob_get_level();

		while ( ob_get_level() > $this->obLevel ) {
			ob_end_clean();
		}

		try {
			if ( $obLevel !== $this->obLevel ) {
				$this->fail(
					"Test changed output buffer level: was {$this->obLevel} before test, but $obLevel after test."
				);
			}
		} finally {
			parent::tearDown();
		}
	}

	/**
	 * Returns a new instance of the special page under test.
	 *
	 * @return SpecialPage
	 */
	abstract protected function newSpecialPage();

	/**
	 * @param string|null $subPage The subpage parameter to call the page with
	 * @param WebRequest|null $request Web request that may contain URL parameters, etc
	 * @param Language|string|null $language The language which should be used in the context;
	 *  defaults to "qqx"
	 * @param Authority|null $performer The user which should be used in the context of this special page
	 * @param bool $fullHtml if true, the entirety of the generated HTML will be returned, this
	 * includes the opening <!DOCTYPE> declaration and closing </html> tag. If false, only value
	 * of OutputPage::getHTML() will be returned except if the page is redirect or where OutputPage
	 * is completely disabled.
	 *
	 * @return array [ string, WebResponse ] A two-elements array containing the HTML output
	 * generated by the special page as well as the response object.
	 */
	protected function executeSpecialPage(
		$subPage = '',
		?WebRequest $request = null,
		$language = null,
		?Authority $performer = null,
		$fullHtml = false
	) {
		return ( new SpecialPageExecutor() )->executeSpecialPage(
			$this->newSpecialPage(),
			$subPage,
			$request,
			$language ?: 'qqx',
			$performer,
			$fullHtml
		);
	}

}
PK       ! &r  r    Html/HtmlTest.phpnu Iw        <?php

use MediaWiki\Html\Html;
use MediaWiki\Html\HtmlJsCode;
use MediaWiki\MainConfigNames;

/**
 * @covers \MediaWiki\Html\Html
 */
class HtmlTest extends MediaWikiIntegrationTestCase {

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

		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, false );

		$langFactory = $this->getServiceContainer()->getLanguageFactory();
		$contLangObj = $langFactory->getLanguage( 'en' );

		// Hardcode namespaces during test runs,
		// so that html output based on existing namespaces
		// can be properly evaluated.
		$contLangObj->setNamespaces( [
			-2 => 'Media',
			-1 => 'Special',
			0 => '',
			1 => 'Talk',
			2 => 'User',
			3 => 'User_talk',
			4 => 'MyWiki',
			5 => 'MyWiki_Talk',
			6 => 'File',
			7 => 'File_talk',
			8 => 'MediaWiki',
			9 => 'MediaWiki_talk',
			10 => 'Template',
			11 => 'Template_talk',
			14 => 'Category',
			15 => 'Category_talk',
			100 => 'Custom',
			101 => 'Custom_talk',
		] );
		$this->setContentLang( $contLangObj );

		$userLangObj = $langFactory->getLanguage( 'es' );
		$userLangObj->setNamespaces( [
			-2 => "Medio",
			-1 => "Especial",
			0 => "",
			1 => "Discusión",
			2 => "Usuario",
			3 => "Usuario discusión",
			4 => "Wiki",
			5 => "Wiki discusión",
			6 => "Archivo",
			7 => "Archivo discusión",
			8 => "MediaWiki",
			9 => "MediaWiki discusión",
			10 => "Plantilla",
			11 => "Plantilla discusión",
			12 => "Ayuda",
			13 => "Ayuda discusión",
			14 => "Categoría",
			15 => "Categoría discusión",
			100 => "Personalizado",
			101 => "Personalizado discusión",
		] );
		$this->setUserLang( $userLangObj );
	}

	public function testOpenElement() {
		$this->expectPHPError(
			E_USER_NOTICE,
			static function () {
				Html::openElement( 'span id="x"' );
			},
			'given element name with space'
		);
	}

	public function testElementBasics() {
		$this->assertEquals(
			'<img>',
			Html::element( 'img', null, '' ),
			'Self-closing tag for short-tag elements'
		);

		$this->assertEquals(
			'<element></element>',
			Html::element( 'element', null, null ),
			'Close tag for empty element (null, null)'
		);

		$this->assertEquals(
			'<element></element>',
			Html::element( 'element', [], '' ),
			'Close tag for empty element (array, string)'
		);

		$this->assertEquals(
			"<p test=\"\u{0338}&quot;&amp;\">&#x338; &amp; &lt; ></p>",
			Html::element( 'p', [ 'test' => "\u{0338}\"&" ], "\u{0338} & < >" ),
			'Attribute and content escaping'
		);

		$this->assertEquals(
			'<p>&#x338; &amp;</p>',
			Html::rawElement( 'p', [], "\u{0338} &amp;" ),
			"Combining characters escaped even in raw contents (T387130)"
		);
	}

	public function dataXmlMimeType() {
		return [
			// ( $mimetype, $isXmlMimeType )
			# HTML is not an XML MimeType
			[ 'text/html', false ],
			# XML is an XML MimeType
			[ 'text/xml', true ],
			[ 'application/xml', true ],
			# XHTML is an XML MimeType
			[ 'application/xhtml+xml', true ],
			# Make sure other +xml MimeTypes are supported
			# SVG is another random MimeType even though we don't use it
			[ 'image/svg+xml', true ],
			# Complete random other MimeTypes are not XML
			[ 'text/plain', false ],
		];
	}

	/**
	 * @dataProvider dataXmlMimeType
	 */
	public function testXmlMimeType( $mimetype, $isXmlMimeType ) {
		$this->assertEquals( $isXmlMimeType, Html::isXmlMimeType( $mimetype ) );
	}

	public static function provideExpandAttributes() {
		// $expect, $attributes
		yield 'keep keys with an empty string' => [
			' foo=""',
			[ 'foo' => '' ]
		];
		yield 'False bool attribs have no output' => [
			'',
			[ 'selected' => false ]
		];
		yield 'Null bool attribs have no output' => [
			'',
			[ 'selected' => null ]
		];
		yield 'True bool attribs have output' => [
			' selected=""',
			[ 'selected' => true ]
		];
		yield 'True bool attribs have output (passed as numerical array)' => [
			' selected=""',
			[ 'selected' ]
		];
		yield 'integer value is cast to string' => [
			' value="1"',
			[ 'value' => 1 ]
		];
		yield 'float value is cast to string' => [
			' value="1.1"',
			[ 'value' => 1.1 ]
		];
		yield 'object value is converted to string' => [
			' value="stringValue"',
			[ 'value' => new HtmlTestValue() ]
		];
		yield 'Empty string is always quoted' => [
			' empty_string=""',
			[ 'empty_string' => '' ]
		];
		yield 'Simple string value needs no quotes' => [
			' key="value"',
			[ 'key' => 'value' ]
		];
		yield 'Number 1 value needs no quotes' => [
			' one="1"',
			[ 'one' => 1 ]
		];
		yield 'Number 0 value needs no quotes' => [
			' zero="0"',
			[ 'zero' => 0 ]
		];
	}

	/**
	 * @dataProvider provideExpandAttributes
	 */
	public function testExpandAttributes( string $expect, array $attribs ) {
		$this->assertEquals( $expect, Html::expandAttributes( $attribs ) );
	}

	public static function provideExpandAttributesEmpty() {
		// $attributes
		yield 'skip keys with null value' => [ [ 'foo' => null ] ];
		yield 'skip keys with false value' => [ [ 'foo' => false ] ];
	}

	/**
	 * @dataProvider provideExpandAttributesEmpty
	 */
	public function testExpandAttributesEmpty( array $attribs ) {
		$this->assertSame( '', Html::expandAttributes( $attribs ) );
	}

	public static function provideExpandAttributesClass() {
		// $expect, $classes
		// string values
		yield 'Normalization should strip redundant spaces' => [
			' class="redundant spaces here"',
			' redundant  spaces  here  '
		];
		yield 'Normalization should remove duplicates in string-lists' => [
			' class="foo bar"',
			'foo bar foo bar bar'
		];
		// array values
		yield 'Value with an empty array' => [
			' class=""',
			[]
		];
		yield 'Array with null, empty string and spaces' => [
			' class=""',
			[ null, '', ' ', '  ' ]
		];
		yield 'Normalization should remove duplicates in the array' => [
			' class="foo bar"',
			[ 'foo', 'bar', 'foo', 'bar', 'bar' ]
		];
		yield 'Normalization should remove duplicates in string-lists in the array' => [
			' class="foo bar"',
			[ 'foo bar', 'bar foo', 'foo', 'bar bar' ]
		];

		// Feature added in r96188 - pass attributes values as a PHP array
		// only applies to class, rel, and accesskey
		yield 'Associative array' => [
			' class="booltrue one"',
			[
				'booltrue' => true,
				'one' => 1,

				# Method use isset() internally, make sure we do discard
				# attributes values which have been assigned well known values
				'emptystring' => '',
				'boolfalse' => false,
				'zero' => 0,
				'null' => null,
			]
		];

		// How do we handle duplicate keys in HTML attributes expansion?
		// We could pass a "class" the values: 'GREEN' and [ 'GREEN' => false ]
		// The latter will take precedence
		yield 'Duplicate keys' => [
			' class=""',
			[
				'GREEN',
				'GREEN' => false,
				'GREEN',
			]
		];
	}

	/**
	 * Html::expandAttributes has special features for HTML
	 * attributes that use space separated lists and also
	 * allows arrays to be used as values.
	 *
	 * @dataProvider provideExpandAttributesClass
	 */
	public function testExpandAttributesClass( string $expect, $classes ) {
		$this->assertEquals(
			$expect,
			Html::expandAttributes( [ 'class' => $classes ] )
		);
	}

	public function testExpandAttributes_ArrayOnNonListValueAttribute_ThrowsException() {
		// Real-life test case found in the Popups extension (see Gerrit cf0fd64),
		// when used with an outdated BetaFeatures extension (see Gerrit deda1e7)
		$this->expectException( UnexpectedValueException::class );
		Html::expandAttributes( [
			'src' => [
				'ltr' => 'ltr.svg',
				'rtl' => 'rtl.svg'
			]
		] );
	}

	public function testNamespaceSelector() {
		$this->assertEquals(
			'<select id="namespace" name="namespace">' . "\n" .
				'<option value="0">(Principal)</option>' . "\n" .
				'<option value="1">Talk</option>' . "\n" .
				'<option value="2">User</option>' . "\n" .
				'<option value="3">User talk</option>' . "\n" .
				'<option value="4">MyWiki</option>' . "\n" .
				'<option value="5">MyWiki Talk</option>' . "\n" .
				'<option value="6">File</option>' . "\n" .
				'<option value="7">File talk</option>' . "\n" .
				'<option value="8">MediaWiki</option>' . "\n" .
				'<option value="9">MediaWiki talk</option>' . "\n" .
				'<option value="10">Template</option>' . "\n" .
				'<option value="11">Template talk</option>' . "\n" .
				'<option value="14">Category</option>' . "\n" .
				'<option value="15">Category talk</option>' . "\n" .
				'<option value="100">Custom</option>' . "\n" .
				'<option value="101">Custom talk</option>' . "\n" .
				'</select>',
			Html::namespaceSelector(),
			'Basic namespace selector without custom options'
		);

		$this->assertEquals(
			'<label for="mw-test-namespace">Select a namespace:</label>' . "\u{00A0}" .
				'<select id="mw-test-namespace" name="wpNamespace">' . "\n" .
				'<option value="all">todos</option>' . "\n" .
				'<option value="0">(Principal)</option>' . "\n" .
				'<option value="1">Talk</option>' . "\n" .
				'<option value="2" selected="">User</option>' . "\n" .
				'<option value="3">User talk</option>' . "\n" .
				'<option value="4">MyWiki</option>' . "\n" .
				'<option value="5">MyWiki Talk</option>' . "\n" .
				'<option value="6">File</option>' . "\n" .
				'<option value="7">File talk</option>' . "\n" .
				'<option value="8">MediaWiki</option>' . "\n" .
				'<option value="9">MediaWiki talk</option>' . "\n" .
				'<option value="10">Template</option>' . "\n" .
				'<option value="11">Template talk</option>' . "\n" .
				'<option value="14">Category</option>' . "\n" .
				'<option value="15">Category talk</option>' . "\n" .
				'<option value="100">Custom</option>' . "\n" .
				'<option value="101">Custom talk</option>' . "\n" .
				'</select>',
			Html::namespaceSelector(
				[ 'selected' => '2', 'all' => 'all', 'label' => 'Select a namespace:' ],
				[ 'name' => 'wpNamespace', 'id' => 'mw-test-namespace' ]
			),
			'Basic namespace selector with custom values'
		);

		$this->assertEquals(
			'<label for="namespace">Select a namespace:</label>' . "\u{00A0}" .
				'<select id="namespace" name="namespace">' . "\n" .
				'<option value="0">(Principal)</option>' . "\n" .
				'<option value="1">Talk</option>' . "\n" .
				'<option value="2">User</option>' . "\n" .
				'<option value="3">User talk</option>' . "\n" .
				'<option value="4">MyWiki</option>' . "\n" .
				'<option value="5">MyWiki Talk</option>' . "\n" .
				'<option value="6">File</option>' . "\n" .
				'<option value="7">File talk</option>' . "\n" .
				'<option value="8">MediaWiki</option>' . "\n" .
				'<option value="9">MediaWiki talk</option>' . "\n" .
				'<option value="10">Template</option>' . "\n" .
				'<option value="11">Template talk</option>' . "\n" .
				'<option value="14">Category</option>' . "\n" .
				'<option value="15">Category talk</option>' . "\n" .
				'<option value="100">Custom</option>' . "\n" .
				'<option value="101">Custom talk</option>' . "\n" .
				'</select>',
			Html::namespaceSelector(
				[ 'label' => 'Select a namespace:' ]
			),
			'Basic namespace selector with a custom label but no id attribtue for the <select>'
		);

		$this->assertEquals(
			'<select id="namespace" name="namespace">' . "\n" .
				'<option value="0">(Principal)</option>' . "\n" .
				'<option value="1">Discusión</option>' . "\n" .
				'<option value="2">Usuario</option>' . "\n" .
				'<option value="3">Usuario discusión</option>' . "\n" .
				'<option value="4">Wiki</option>' . "\n" .
				'<option value="5">Wiki discusión</option>' . "\n" .
				'<option value="6">Archivo</option>' . "\n" .
				'<option value="7">Archivo discusión</option>' . "\n" .
				'<option value="8">MediaWiki</option>' . "\n" .
				'<option value="9">MediaWiki discusión</option>' . "\n" .
				'<option value="10">Plantilla</option>' . "\n" .
				'<option value="11">Plantilla discusión</option>' . "\n" .
				'<option value="12">Ayuda</option>' . "\n" .
				'<option value="13">Ayuda discusión</option>' . "\n" .
				'<option value="14">Categoría</option>' . "\n" .
				'<option value="15">Categoría discusión</option>' . "\n" .
				'<option value="100">Personalizado</option>' . "\n" .
				'<option value="101">Personalizado discusión</option>' . "\n" .
				'</select>',
			Html::namespaceSelector(
				[ 'in-user-lang' => true ]
			),
			'Basic namespace selector in user language'
		);
	}

	public function testCanFilterOutNamespaces() {
		$this->assertEquals(
			'<select id="namespace" name="namespace">' . "\n" .
				'<option value="2">User</option>' . "\n" .
				'<option value="4">MyWiki</option>' . "\n" .
				'<option value="5">MyWiki Talk</option>' . "\n" .
				'<option value="6">File</option>' . "\n" .
				'<option value="7">File talk</option>' . "\n" .
				'<option value="8">MediaWiki</option>' . "\n" .
				'<option value="9">MediaWiki talk</option>' . "\n" .
				'<option value="10">Template</option>' . "\n" .
				'<option value="11">Template talk</option>' . "\n" .
				'<option value="14">Category</option>' . "\n" .
				'<option value="15">Category talk</option>' . "\n" .
				'</select>',
			Html::namespaceSelector(
				[ 'exclude' => [ 0, 1, 3, 100, 101 ] ]
			),
			'Namespace selector namespace filtering.'
		);
		$this->assertEquals(
			'<select id="namespace" name="namespace">' . "\n" .
				'<option value="" selected="">todos</option>' . "\n" .
				'<option value="2">User</option>' . "\n" .
				'<option value="4">MyWiki</option>' . "\n" .
				'<option value="5">MyWiki Talk</option>' . "\n" .
				'<option value="6">File</option>' . "\n" .
				'<option value="7">File talk</option>' . "\n" .
				'<option value="8">MediaWiki</option>' . "\n" .
				'<option value="9">MediaWiki talk</option>' . "\n" .
				'<option value="10">Template</option>' . "\n" .
				'<option value="11">Template talk</option>' . "\n" .
				'<option value="14">Category</option>' . "\n" .
				'<option value="15">Category talk</option>' . "\n" .
				'</select>',
			Html::namespaceSelector(
				[ 'exclude' => [ 0, 1, 3, 100, 101 ], 'all' => '' ]
			),
			'Namespace selector namespace filtering with empty custom "all" option.'
		);
	}

	public function testCanDisableANamespaces() {
		$this->assertEquals(
			'<select id="namespace" name="namespace">' . "\n" .
				'<option disabled="" value="0">(Principal)</option>' . "\n" .
				'<option disabled="" value="1">Talk</option>' . "\n" .
				'<option disabled="" value="2">User</option>' . "\n" .
				'<option disabled="" value="3">User talk</option>' . "\n" .
				'<option disabled="" value="4">MyWiki</option>' . "\n" .
				'<option value="5">MyWiki Talk</option>' . "\n" .
				'<option value="6">File</option>' . "\n" .
				'<option value="7">File talk</option>' . "\n" .
				'<option value="8">MediaWiki</option>' . "\n" .
				'<option value="9">MediaWiki talk</option>' . "\n" .
				'<option value="10">Template</option>' . "\n" .
				'<option value="11">Template talk</option>' . "\n" .
				'<option value="14">Category</option>' . "\n" .
				'<option value="15">Category talk</option>' . "\n" .
				'<option value="100">Custom</option>' . "\n" .
				'<option value="101">Custom talk</option>' . "\n" .
				'</select>',
			Html::namespaceSelector( [
				'disable' => [ 0, 1, 2, 3, 4 ]
			] ),
			'Namespace selector namespace disabling'
		);
	}

	/**
	 * @dataProvider provideHtml5InputTypes
	 */
	public function testHtmlElementAcceptsNewHtml5TypesInHtml5Mode( $HTML5InputType ) {
		$this->assertEquals(
			'<input type="' . $HTML5InputType . '">',
			Html::element( 'input', [ 'type' => $HTML5InputType ] ),
			'In HTML5, Html::element() should accept type="' . $HTML5InputType . '"'
		);
	}

	public function testWarningBox() {
		$this->assertEquals(
			'<div class="cdx-message cdx-message--block cdx-message--warning">'
				. '<span class="cdx-message__icon"></span>'
				. '<div class="cdx-message__content">warn</div></div>',
			Html::warningBox( 'warn' )
		);
	}

	public function testErrorBox() {
		$this->assertEquals(
			'<div class="cdx-message cdx-message--block cdx-message--error">'
				. '<span class="cdx-message__icon"></span>'
				. '<div class="cdx-message__content">err</div></div>',
			Html::errorBox( 'err' )
		);
		$this->assertEquals(
			'<div class="cdx-message cdx-message--block cdx-message--error errorbox-custom-class">'
				. '<span class="cdx-message__icon"></span>'
				. '<div class="cdx-message__content">'
				. '<h2>heading</h2>err'
				. '</div></div>',
			Html::errorBox( 'err', 'heading', 'errorbox-custom-class' )
		);
		$this->assertEquals(
			'<div class="cdx-message cdx-message--block cdx-message--error">'
				. '<span class="cdx-message__icon"></span>'
				. '<div class="cdx-message__content">'
				. '<h2>0</h2>err'
				. '</div></div>',
			Html::errorBox( 'err', '0', '' )
		);
	}

	public function testSuccessBox() {
		$this->assertEquals(
			'<div class="cdx-message cdx-message--block cdx-message--success">'
				. '<span class="cdx-message__icon"></span>'
				. '<div class="cdx-message__content">great</div></div>',
			Html::successBox( 'great' )
		);
		$this->assertEquals(
			'<div class="cdx-message cdx-message--block cdx-message--success">'
				. '<span class="cdx-message__icon"></span>'
				. '<div class="cdx-message__content">'
				. '<script>beware no escaping!</script>'
				. '</div></div>',
			Html::successBox( '<script>beware no escaping!</script>' )
		);
	}

	/**
	 * List of input element types values introduced by HTML5
	 * Full list at https://www.w3.org/TR/html-markup/input.html
	 */
	public static function provideHtml5InputTypes() {
		$types = [
			'datetime',
			'datetime-local',
			'date',
			'month',
			'time',
			'week',
			'number',
			'range',
			'email',
			'url',
			'search',
			'tel',
			'color',
		];

		foreach ( $types as $type ) {
			yield [ $type ];
		}
	}

	/**
	 * Test out Html::element drops or enforces default value
	 * @dataProvider provideElementsWithAttributesHavingDefaultValues
	 */
	public function testDropDefaults( $expected, $element, $attribs, $message = '' ) {
		$this->assertEquals( $expected, Html::element( $element, $attribs ), $message );
	}

	public static function provideElementsWithAttributesHavingDefaultValues() {
		# Use cases in a concise format:
		# <expected>, <element name>, <array of attributes> [, <message>]
		# Will be mapped to Html::element()
		$cases = [];

		# ## Generic cases, match $attribDefault static array
		$cases[] = [ '<area>',
			'area', [ 'shape' => 'rect' ]
		];

		$cases[] = [ '<button type="submit"></button>',
			'button', [ 'formaction' => 'GET' ]
		];
		$cases[] = [ '<button type="submit"></button>',
			'button', [ 'formenctype' => 'application/x-www-form-urlencoded' ]
		];

		$cases[] = [ '<canvas></canvas>',
			'canvas', [ 'height' => '150' ]
		];
		$cases[] = [ '<canvas></canvas>',
			'canvas', [ 'width' => '300' ]
		];
		# Also check with numeric values
		$cases[] = [ '<canvas></canvas>',
			'canvas', [ 'height' => 150 ]
		];
		$cases[] = [ '<canvas></canvas>',
			'canvas', [ 'width' => 300 ]
		];

		$cases[] = [ '<form></form>',
			'form', [ 'action' => 'GET' ]
		];
		$cases[] = [ '<form></form>',
			'form', [ 'autocomplete' => 'on' ]
		];
		$cases[] = [ '<form></form>',
			'form', [ 'enctype' => 'application/x-www-form-urlencoded' ]
		];

		$cases[] = [ '<input>',
			'input', [ 'formaction' => 'GET' ]
		];
		$cases[] = [ '<input>',
			'input', [ 'type' => 'text' ]
		];

		$cases[] = [ '<keygen>',
			'keygen', [ 'keytype' => 'rsa' ]
		];

		$cases[] = [ '<link>',
			'link', [ 'media' => 'all' ]
		];

		$cases[] = [ '<menu></menu>',
			'menu', [ 'type' => 'list' ]
		];

		$cases[] = [ '<script></script>',
			'script', [ 'type' => 'text/javascript' ]
		];

		$cases[] = [ '<style></style>',
			'style', [ 'media' => 'all' ]
		];
		$cases[] = [ '<style></style>',
			'style', [ 'type' => 'text/css' ]
		];

		$cases[] = [ '<textarea></textarea>',
			'textarea', [ 'wrap' => 'soft' ]
		];

		# ## SPECIFIC CASES

		# <link type="text/css">
		$cases[] = [ '<link>',
			'link', [ 'type' => 'text/css' ]
		];

		# <input> specific handling
		$cases[] = [ '<input type="checkbox">',
			'input', [ 'type' => 'checkbox', 'value' => 'on' ],
			'Default value "on" is stripped of checkboxes',
		];
		$cases[] = [ '<input type="radio">',
			'input', [ 'type' => 'radio', 'value' => 'on' ],
			'Default value "on" is stripped of radio buttons',
		];
		$cases[] = [ '<input type="submit" value="Submit">',
			'input', [ 'type' => 'submit', 'value' => 'Submit' ],
			'Default value "Submit" is kept on submit buttons (for possible l10n issues)',
		];
		$cases[] = [ '<input type="color">',
			'input', [ 'type' => 'color', 'value' => '' ],
		];
		$cases[] = [ '<input type="range">',
			'input', [ 'type' => 'range', 'value' => '' ],
		];

		# <button> specific handling
		# see remarks on https://msdn.microsoft.com/library/ms535211(v=vs.85).aspx
		$cases[] = [ '<button type="submit"></button>',
			'button', [ 'type' => 'submit' ],
			'According to standard the default type is "submit". '
				. 'Depending on compatibility mode IE might use "button", instead.',
		];

		# <select> specific handling
		$cases[] = [ '<select multiple=""></select>',
			'select', [ 'size' => '4', 'multiple' => true ],
		];
		# .. with numeric value
		$cases[] = [ '<select multiple=""></select>',
			'select', [ 'size' => 4, 'multiple' => true ],
		];
		$cases[] = [ '<select></select>',
			'select', [ 'size' => '1', 'multiple' => false ],
		];
		# .. with numeric value
		$cases[] = [ '<select></select>',
			'select', [ 'size' => 1, 'multiple' => false ],
		];

		# Passing an array as value
		$cases[] = [ '<a class="css-class-one css-class-two"></a>',
			'a', [ 'class' => [ 'css-class-one', 'css-class-two' ] ],
			"dropDefaults accepts values given as an array"
		];

		# FIXME: doDropDefault should remove defaults given in an array
		# Expected should be '<a></a>'
		$cases[] = [ '<a class=""></a>',
			'a', [ 'class' => [ '', '' ] ],
			"dropDefaults accepts values given as an array"
		];

		return $cases;
	}

	public function testWrapperInput() {
		$this->assertEquals(
			'<input type="radio" value="testval" name="testname">',
			Html::input( 'testname', 'testval', 'radio' ),
			'Input wrapper with type and value.'
		);
		$this->assertEquals(
			'<input name="testname">',
			Html::input( 'testname' ),
			'Input wrapper with all default values.'
		);
	}

	public function testWrapperCheck() {
		$this->assertEquals(
			'<input type="checkbox" value="1" name="testname">',
			Html::check( 'testname' ),
			'Checkbox wrapper unchecked.'
		);
		$this->assertEquals(
			'<input checked="" type="checkbox" value="1" name="testname">',
			Html::check( 'testname', true ),
			'Checkbox wrapper checked.'
		);
		$this->assertEquals(
			'<input type="checkbox" value="testval" name="testname">',
			Html::check( 'testname', false, [ 'value' => 'testval' ] ),
			'Checkbox wrapper with a value override.'
		);
	}

	public function testWrapperRadio() {
		$this->assertEquals(
			'<input type="radio" value="1" name="testname">',
			Html::radio( 'testname' ),
			'Radio wrapper unchecked.'
		);
		$this->assertEquals(
			'<input checked="" type="radio" value="1" name="testname">',
			Html::radio( 'testname', true ),
			'Radio wrapper checked.'
		);
		$this->assertEquals(
			'<input type="radio" value="testval" name="testname">',
			Html::radio( 'testname', false, [ 'value' => 'testval' ] ),
			'Radio wrapper with a value override.'
		);
	}

	public function testWrapperLabel() {
		$this->assertEquals(
			'<label for="testid">testlabel</label>',
			Html::label( 'testlabel', 'testid' ),
			'Label wrapper'
		);
	}

	public static function provideSrcSetImages() {
		return [
			[ [], '', 'when there are no images, return empty string' ],
			[
				[ '1x' => '1x.png', '1.5x' => '1_5x.png', '2x' => '2x.png' ],
				'1x.png 1x, 1_5x.png 1.5x, 2x.png 2x',
				'pixel depth keys may include a trailing "x"'
			],
			[
				[ '1'  => '1x.png', '1.5' => '1_5x.png', '2'  => '2x.png' ],
				'1x.png 1x, 1_5x.png 1.5x, 2x.png 2x',
				'pixel depth keys may omit a trailing "x"'
			],
			[
				[ '1'  => 'small.png', '1.5' => 'large.png', '2'  => 'large.png' ],
				'small.png 1x, large.png 1.5x',
				'omit larger duplicates'
			],
			[
				[ '1'  => 'small.png', '2'  => 'large.png', '1.5' => 'large.png' ],
				'small.png 1x, large.png 1.5x',
				'omit larger duplicates in irregular order'
			],
		];
	}

	/**
	 * @dataProvider provideSrcSetImages
	 */
	public function testSrcSet( $images, $expected, $message ) {
		$this->assertEquals( $expected, Html::srcSet( $images ), $message );
	}

	public static function provideInlineScript() {
		return [
			'Empty' => [
				'',
				'<script></script>'
			],
			'Simple' => [
				'EXAMPLE.label("foo");',
				'<script>EXAMPLE.label("foo");</script>'
			],
			'Ampersand' => [
				'EXAMPLE.is(a && b);',
				'<script>EXAMPLE.is(a && b);</script>'
			],
			'HTML' => [
				'EXAMPLE.label("<a>");',
				'<script>EXAMPLE.label("<a>");</script>'
			],
			'Script closing string (lower)' => [
				'EXAMPLE.label("</script>");',
				'<script>/* ERROR: Invalid script */</script>',
				true,
			],
			'Script closing with non-standard attributes (mixed)' => [
				'EXAMPLE.label("</SCriPT and STyLE>");',
				'<script>/* ERROR: Invalid script */</script>',
				true,
			],
			'HTML-comment-open and script-open' => [
				// In HTML, <script> contents aren't just plain CDATA until </script>,
				// there are levels of escaping modes, and the below sequence puts an
				// HTML parser in a state where </script> would *not* close the script.
				// https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escape-end-state
				'var a = "<!--<script>";',
				'<script>/* ERROR: Invalid script */</script>',
				true,
			],
		];
	}

	/**
	 * @dataProvider provideInlineScript
	 */
	public function testInlineScript( $code, $expected, $error = false ) {
		if ( $error ) {
			$html = @Html::inlineScript( $code );
		} else {
			$html = Html::inlineScript( $code );
		}
		$this->assertSame( $expected, $html );
	}

	public static function provideEncodeJsVar() {
		// $expected, $input
		yield 'boolean' => [ 'true', true ];
		yield 'null' => [ 'null', null ];
		yield 'array' => [ '["a",1]', [ 'a', 1 ] ];
		yield 'associative arary' => [ '{"a":"a","b":1}', [ 'a' => 'a', 'b' => 1 ] ];
		yield 'object' => [ '{"a":"a","b":1}', (object)[ 'a' => 'a', 'b' => 1 ] ];
		yield 'int' => [ '123456', 123456 ];
		yield 'float' => [ '1.5', 1.5 ];
		yield 'int-like string' => [ '"123456"', '123456' ];

		$code = 'function () { foo( 42 ); }';
		yield 'code' => [ $code, new HtmlJsCode( $code ) ];
	}

	/**
	 * @covers \MediaWiki\Html\Html
	 * @covers \MediaWiki\Html\HtmlJsCode
	 * @dataProvider provideEncodeJsVar
	 */
	public function testEncodeJsVar( string $expect, $input ) {
		$this->assertEquals(
			$expect,
			Html::encodeJsVar( $input )
		);
	}

	/**
	 * @covers \MediaWiki\Html\Html
	 * @covers \MediaWiki\Html\HtmlJsCode
	 */
	public function testEncodeObject() {
		$codeA = 'function () { foo( 42 ); }';
		$codeB = 'function ( jQuery ) { bar( 142857 ); }';
		$obj = HtmlJsCode::encodeObject( [
			'a' => new HtmlJsCode( $codeA ),
			'b' => new HtmlJsCode( $codeB )
		] );
		$this->assertEquals(
			"{\"a\":$codeA,\"b\":$codeB}",
			Html::encodeJsVar( $obj )
		);
	}

	public function testListDropdownOptions() {
		$this->assertEquals(
			[
				'other reasons' => 'other',
				'Empty group item' => 'Empty group item',
				'Foo' => [
					'Foo 1' => 'Foo 1',
					'Example' => 'Example',
				],
				'Bar' => [
					'Bar 1' => 'Bar 1',
				],
			],
			Html::listDropdownOptions(
				"*\n** Empty group item\n* Foo\n** Foo 1\n** Example\n* Bar\n** Bar 1",
				[ 'other' => 'other reasons' ]
			)
		);
	}

	public function testListDropdownOptionsOthers() {
		// Do not use the value for 'other' as option group - T251351
		$this->assertEquals(
			[
				'other reasons' => 'other',
				'Foo 1' => 'Foo 1',
				'Example' => 'Example',
				'Bar' => [
					'Bar 1' => 'Bar 1',
				],
			],
			Html::listDropdownOptions(
				"* other reasons\n** Foo 1\n** Example\n* Bar\n** Bar 1",
				[ 'other' => 'other reasons' ]
			)
		);
	}

	public function testListDropdownOptionsOoui() {
		$this->assertEquals(
			[
				[ 'data' => 'other', 'label' => 'other reasons' ],
				[ 'optgroup' => 'Foo' ],
				[ 'data' => 'Foo 1', 'label' => 'Foo 1' ],
				[ 'data' => 'Example', 'label' => 'Example' ],
				[ 'optgroup' => 'Bar' ],
				[ 'data' => 'Bar 1', 'label' => 'Bar 1' ],
			],
			Html::listDropdownOptionsOoui( [
				'other reasons' => 'other',
				'Foo' => [
					'Foo 1' => 'Foo 1',
					'Example' => 'Example',
				],
				'Bar' => [
					'Bar 1' => 'Bar 1',
				],
			] )
		);
	}

	public function testListDropdownOptionsCodex(): void {
		$this->assertEquals(
			[
				[ 'label' => 'other reasons', 'value' => 'other' ],
				[ 'label' => 'Foo', 'value' => '', 'disabled' => true ],
				[ 'label' => 'Foo 1', 'value' => 'Foo 1' ],
				[ 'label' => 'Example', 'value' => 'Example' ],
				[ 'label' => 'Bar', 'value' => '', 'disabled' => true ],
				[ 'label' => 'Bar 1', 'value' => 'Bar 1' ],
			],
			Html::listDropdownOptionsCodex( [
				'other reasons' => 'other',
				'Foo' => [
					'Foo 1' => 'Foo 1',
					'Example' => 'Example',
				],
				'Bar' => [
					'Bar 1' => 'Bar 1',
				],
			] )
		);
	}
}

class HtmlTestValue implements Stringable {
	public function __toString() {
		return 'stringValue';
	}
}
PK       ! J	v  	v  *  specialpage/ChangesListSpecialPageTest.phpnu Iw        <?php

namespace MediaWiki\Tests\SpecialPage;

use ChangesListBooleanFilterGroup;
use ChangesListStringOptionsFilterGroup;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\SpecialPage\ChangesListSpecialPage;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\IExpression;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * Test class for ChangesListSpecialPage class
 *
 * Copyright © 2011-, Antoine Musso, Stephane Bisson, Matthew Flaschen
 *
 * @author Antoine Musso
 * @author Stephane Bisson
 * @author Matthew Flaschen
 * @group Database
 *
 * @covers \MediaWiki\SpecialPage\ChangesListSpecialPage
 */
class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase {

	use TempUserTestTrait {
		enableAutoCreateTempUser as _enableAutoCreateTempUser;
		disableAutoCreateTempUser as _disableAutoCreateTempUser;
	}

	protected function setUp(): void {
		parent::setUp();
		$this->clearHooks();
	}

	/**
	 * @return ChangesListSpecialPage
	 */
	protected function getPageAccessWrapper() {
		$mock = $this->getMockBuilder( ChangesListSpecialPage::class )
			->setConstructorArgs(
				[
					'ChangesListSpecialPage',
					'',
					$this->getServiceContainer()->getUserIdentityUtils(),
					$this->getServiceContainer()->getTempUserConfig()
				]
			)
			->onlyMethods( [ 'getPageTitle' ] )
			->getMockForAbstractClass();

		$mock->method( 'getPageTitle' )->willReturn(
			Title::makeTitle( NS_SPECIAL, 'ChangesListSpecialPage' )
		);

		$mock = TestingAccessWrapper::newFromObject(
			$mock
		);

		return $mock;
	}

	private function buildQuery(
		array $requestOptions,
		?User $user = null
	): array {
		$context = new RequestContext;
		$context->setRequest( new FauxRequest( $requestOptions ) );
		if ( $user ) {
			$context->setUser( $user );
		}

		$this->changesListSpecialPage->setContext( $context );
		$this->changesListSpecialPage->filterGroups = [];
		$formOptions = $this->changesListSpecialPage->setup( null );

		# Filter out rc_timestamp conditions which depends on the test runtime
		# This condition is not needed as of march 2, 2011 -- hashar
		# @todo FIXME: Find a way to generate the correct rc_timestamp

		$tables = [];
		$fields = [];
		$queryConditions = [];
		$query_options = [];
		$join_conds = [];

		call_user_func_array(
			[ $this->changesListSpecialPage, 'buildQuery' ],
			[
				&$tables,
				&$fields,
				&$queryConditions,
				&$query_options,
				&$join_conds,
				$formOptions
			]
		);

		$queryConditions = array_filter(
			$queryConditions,
			[ __CLASS__, 'filterOutRcTimestampCondition' ]
		);

		return $queryConditions;
	}

	/**
	 * helper to test SpecialRecentchanges::buildQuery()
	 * @param array $expected
	 * @param array $requestOptions
	 * @param string $message
	 * @param User|null $user
	 */
	private function assertConditions(
		array $expected,
		array $requestOptions,
		string $message,
		?User $user = null
	) {
		$queryConditions = $this->buildQuery( $requestOptions, $user );

		$this->assertEquals(
			$this->normalizeCondition( $expected ),
			$this->normalizeCondition( $queryConditions ),
			$message
		);
	}

	private function normalizeCondition( array $conds ): array {
		$dbr = $this->getDb();
		$normalized = array_map(
			static function ( $k, $v ) use ( $dbr ) {
				if ( is_array( $v ) ) {
					sort( $v );
				}
				// (Ab)use makeList() to format only this entry
				return $dbr->makeList( [ $k => $v ], Database::LIST_AND );
			},
			array_keys( $conds ),
			$conds
		);
		sort( $normalized );
		return $normalized;
	}

	/**
	 * @param array|string|IExpression $var
	 * @return bool false if condition begins with 'rc_timestamp '
	 */
	private static function filterOutRcTimestampCondition( $var ): bool {
		if ( $var instanceof IExpression ) {
			$var = $var->toGeneralizedSql();
		}
		return is_array( $var ) || !str_contains( (string)$var, 'rc_timestamp ' );
	}

	public function testRcNsFilter() {
		$this->assertConditions(
			[ # expected
				'rc_namespace = 0',
			],
			[
				'namespace' => NS_MAIN,
			],
			"rc conditions with one namespace"
		);
	}

	public function testRcNsFilterInversion() {
		$this->assertConditions(
			[ # expected
				'rc_namespace != 0',
			],
			[
				'namespace' => NS_MAIN,
				'invert' => 1,
			],
			"rc conditions with namespace inverted"
		);
	}

	public function testRcNsFilterMultiple() {
		$this->assertConditions(
			[ # expected
				'rc_namespace IN (1,2,3)',
			],
			[
				'namespace' => '1;2;3',
			],
			"rc conditions with multiple namespaces"
		);
	}

	public function testRcNsFilterMultipleAssociated() {
		$this->assertConditions(
			[ # expected
				'rc_namespace IN (0,1,4,5,6,7)',
			],
			[
				'namespace' => '1;4;7',
				'associated' => 1,
			],
			"rc conditions with multiple namespaces and associated"
		);
	}

	public function testRcNsFilterAssociatedSpecial() {
		$this->assertConditions(
			[ # expected
				'rc_namespace IN (-1,0,1)',
			],
			[
				'namespace' => '1;-1',
				'associated' => 1,
			],
			"rc conditions with associated and special namespace"
		);
	}

	public function testRcNsFilterMultipleAssociatedInvert() {
		$this->assertConditions(
			[ # expected
				'rc_namespace NOT IN (2,3,8,9)',
			],
			[
				'namespace' => '2;3;9',
				'associated' => 1,
				'invert' => 1
			],
			"rc conditions with multiple namespaces, associated and inverted"
		);
	}

	public function testRcNsFilterMultipleInvert() {
		$this->assertConditions(
			[ # expected
				'rc_namespace NOT IN (1,2,3)',
			],
			[
				'namespace' => '1;2;3',
				'invert' => 1,
			],
			"rc conditions with multiple namespaces inverted"
		);
	}

	public function testRcNsFilterAllContents() {
		$namespaces = $this->getServiceContainer()->getNamespaceInfo()->getSubjectNamespaces();
		$this->assertConditions(
			[ # expected
				'rc_namespace IN (' . $this->getDb()->makeList( $namespaces ) . ')',
			],
			[
				'namespace' => 'all-contents',
			],
			"rc conditions with all-contents"
		);
	}

	public function testRcNsFilterInvalid() {
		$this->assertConditions(
			[ # expected
			],
			[
				'namespace' => 'invalid',
			],
			"rc conditions with invalid namespace"
		);
	}

	public function testRcNsFilterPartialInvalid() {
		$namespaces = array_merge(
			[ 1 ],
			$this->getServiceContainer()->getNamespaceInfo()->getSubjectNamespaces()
		);
		sort( $namespaces );
		$this->assertConditions(
			[ # expected
				'rc_namespace IN (' . $this->getDb()->makeList( $namespaces ) . ')',
			],
			[
				'namespace' => 'all-contents;1;invalid',
			],
			"rc conditions with invalid namespace"
		);
	}

	public function testRcHidemyselfFilter() {
		$user = $this->getTestUser()->getUser();
		$this->assertConditions(
			[ # expected
				$this->getDb()->expr( 'actor_name', '!=', $user->getName() ),
			],
			[
				'hidemyself' => 1,
			],
			"rc conditions: hidemyself=1 (logged in)",
			$user
		);

		$user = User::newFromName( '10.11.12.13', false );
		$this->assertConditions(
			[ # expected
				"actor_name != '10.11.12.13'",
			],
			[
				'hidemyself' => 1,
			],
			"rc conditions: hidemyself=1 (anon)",
			$user
		);
	}

	public function testRcHidebyothersFilter() {
		$user = $this->getTestUser()->getUser();
		$this->assertConditions(
			[ # expected
				'actor_user' => $user->getId(),
			],
			[
				'hidebyothers' => 1,
			],
			"rc conditions: hidebyothers=1 (logged in)",
			$user
		);

		$user = User::newFromName( '10.11.12.13', false );
		$this->assertConditions(
			[ # expected
				'actor_name' => '10.11.12.13',
			],
			[
				'hidebyothers' => 1,
			],
			"rc conditions: hidebyothers=1 (anon)",
			$user
		);
	}

	public function testRcHidepageedits() {
		$this->assertConditions(
			[ # expected
				"rc_type != 0",
			],
			[
				'hidepageedits' => 1,
			],
			"rc conditions: hidepageedits=1"
		);
	}

	public function testRcHidenewpages() {
		$this->assertConditions(
			[ # expected
				"rc_type != 1",
			],
			[
				'hidenewpages' => 1,
			],
			"rc conditions: hidenewpages=1"
		);
	}

	public function testRcHidelog() {
		$this->assertConditions(
			[ # expected
				"rc_type != 3",
			],
			[
				'hidelog' => 1,
			],
			"rc conditions: hidelog=1"
		);
	}

	public function testRcHidehumans() {
		$this->assertConditions(
			[ # expected
				'rc_bot' => 1,
			],
			[
				'hidebots' => 0,
				'hidehumans' => 1,
			],
			"rc conditions: hidebots=0 hidehumans=1"
		);
	}

	public function testRcHidepatrolledDisabledFilter() {
		$this->overrideConfigValue( MainConfigNames::UseRCPatrol, false );
		$user = $this->getTestUser()->getUser();
		$this->assertConditions(
			[ # expected
			],
			[
				'hidepatrolled' => 1,
			],
			"rc conditions: hidepatrolled=1 (user not allowed)",
			$user
		);
	}

	public function testRcHideunpatrolledDisabledFilter() {
		$this->overrideConfigValue( MainConfigNames::UseRCPatrol, false );
		$user = $this->getTestUser()->getUser();
		$this->assertConditions(
			[ # expected
			],
			[
				'hideunpatrolled' => 1,
			],
			"rc conditions: hideunpatrolled=1 (user not allowed)",
			$user
		);
	}

	public function testRcHidepatrolledFilter() {
		$user = $this->getTestSysop()->getUser();
		$this->assertConditions(
			[ # expected
				'rc_patrolled' => 0,
			],
			[
				'hidepatrolled' => 1,
			],
			"rc conditions: hidepatrolled=1",
			$user
		);
	}

	public function testRcHideunpatrolledFilter() {
		$user = $this->getTestSysop()->getUser();
		$this->assertConditions(
			[ # expected
				'rc_patrolled' => [ 1, 2 ],
			],
			[
				'hideunpatrolled' => 1,
			],
			"rc conditions: hideunpatrolled=1",
			$user
		);
	}

	public function testRcReviewStatusFilter() {
		$user = $this->getTestSysop()->getUser();
		$this->assertConditions(
			[ # expected
				'rc_patrolled' => 1,
			],
			[
				'reviewStatus' => 'manual'
			],
			"rc conditions: reviewStatus=manual",
			$user
		);
		$this->assertConditions(
			[ # expected
				'rc_patrolled' => [ 0, 2 ],
			],
			[
				'reviewStatus' => 'unpatrolled;auto'
			],
			"rc conditions: reviewStatus=unpatrolled;auto",
			$user
		);
	}

	public function testRcHideminorFilter() {
		$this->assertConditions(
			[ # expected
				'rc_minor = 0',
			],
			[
				'hideminor' => 1,
			],
			"rc conditions: hideminor=1"
		);
	}

	public function testRcHidemajorFilter() {
		$this->assertConditions(
			[ # expected
				'rc_minor = 1',
			],
			[
				'hidemajor' => 1,
			],
			"rc conditions: hidemajor=1"
		);
	}

	public function testHideCategorization() {
		$this->assertConditions(
			[
				# expected
				"rc_type != 6"
			],
			[
				'hidecategorization' => 1
			],
			"rc conditions: hidecategorization=1"
		);
	}

	/** @see TempUserTestTrait::enableAutoCreateTempUser */
	protected function enableAutoCreateTempUser( array $configOverrides = [] ): void {
		$this->_enableAutoCreateTempUser( $configOverrides );
		$this->changesListSpecialPage->setTempUserConfig( $this->getServiceContainer()->getTempUserConfig() );
	}

	/** @see TempUserTestTrait::disableAutoCreateTempUser */
	protected function disableAutoCreateTempUser( array $configOverrides = [] ): void {
		$this->_disableAutoCreateTempUser( $configOverrides );
		$this->changesListSpecialPage->setTempUserConfig( $this->getServiceContainer()->getTempUserConfig() );
	}

	public function testRegistrationHideliu() {
		$this->enableAutoCreateTempUser();
		$tempUserMatchPattern = $this->getServiceContainer()->getTempUserConfig()
			->getMatchCondition( $this->getDb(), 'actor_name', IExpression::LIKE )
			->toSql( $this->getDb() );
		$this->assertConditions(
			[
				"((actor_user IS NULL OR $tempUserMatchPattern))",
			],
			[
				'hideliu' => 1,
			],
			"rc conditions: hideliu=1"
		);
	}

	public function testRegistrationHideanons() {
		$this->enableAutoCreateTempUser();
		$tempUserMatchPattern = $this->getServiceContainer()->getTempUserConfig()
			->getMatchCondition( $this->getDb(), 'actor_name', IExpression::NOT_LIKE )
			->toSql( $this->getDb() );
		$this->assertConditions(
			[
				"((actor_user IS NOT NULL AND $tempUserMatchPattern))",
			],
			[
				'hideanons' => 1,
			],
			"rc conditions: hideanons=1"
		);
	}

	public function testFilterUserExpLevelAll() {
		$this->assertConditions(
			[
				# expected
			],
			[
				'userExpLevel' => 'registered;unregistered;newcomer;learner;experienced',
			],
			"rc conditions: userExpLevel=registered;unregistered;newcomer;learner;experienced"
		);
	}

	public function testFilterUserExpLevelRegisteredUnregistered() {
		$this->assertConditions(
			[
				# expected
			],
			[
				'userExpLevel' => 'registered;unregistered',
			],
			"rc conditions: userExpLevel=registered;unregistered"
		);
	}

	public function testFilterUserExpLevelRegisteredUnregisteredLearner() {
		$this->assertConditions(
			[
				# expected
			],
			[
				'userExpLevel' => 'registered;unregistered;learner',
			],
			"rc conditions: userExpLevel=registered;unregistered;learner"
		);
	}

	public function testFilterUserExpLevelAllExperienceLevels() {
		$this->disableAutoCreateTempUser();
		$this->assertConditions(
			[
				# expected
				'(actor_user IS NOT NULL)',
			],
			[
				'userExpLevel' => 'newcomer;learner;experienced',
			],
			"rc conditions: userExpLevel=newcomer;learner;experienced"
		);
	}

	public function testFilterUserExpLevelRegistered() {
		$this->disableAutoCreateTempUser();
		$this->assertConditions(
			[
				# expected
				'(actor_user IS NOT NULL)',
			],
			[
				'userExpLevel' => 'registered',
			],
			"rc conditions: userExpLevel=registered"
		);
	}

	public function testFilterUserExpLevelRegisteredTempAccountsEnabled() {
		$this->enableAutoCreateTempUser();
		$tempUserMatchPattern = $this->getServiceContainer()->getTempUserConfig()
			->getMatchCondition( $this->getDb(), 'actor_name', IExpression::NOT_LIKE )
			->toSql( $this->getDb() );
		$this->assertConditions(
			[
				# expected
				"((actor_user IS NOT NULL AND $tempUserMatchPattern))",
			],
			[
				'userExpLevel' => 'registered',
			],
			"rc conditions: userExpLevel=registered"
		);
	}

	public function testFilterUserExpLevelUnregistered() {
		$this->disableAutoCreateTempUser();
		$this->assertConditions(
			[
				# expected
				'(actor_user IS NULL)'
			],
			[
				'userExpLevel' => 'unregistered',
			],
			"rc conditions: userExpLevel=unregistered"
		);
	}

	public function testFilterUserExpLevelUnregisteredTempAccountsEnabled() {
		$this->enableAutoCreateTempUser();
		$tempUserMatchPattern = $this->getServiceContainer()->getTempUserConfig()
			->getMatchCondition( $this->getDb(), 'actor_name', IExpression::LIKE )
			->toSql( $this->getDb() );
		$this->assertConditions(
			[
				# expected
				"((actor_user IS NULL OR $tempUserMatchPattern))",
			],
			[
				'userExpLevel' => 'unregistered',
			],
			"rc conditions: userExpLevel=unregistered"
		);
	}

	public function testFilterUserExpLevelRegisteredOrLearner() {
		$this->disableAutoCreateTempUser();
		$this->assertConditions(
			[
				# expected
				'(actor_user IS NOT NULL)',
			],
			[
				'userExpLevel' => 'registered;learner',
			],
			"rc conditions: userExpLevel=registered;learner"
		);
	}

	public function testFilterUserExpLevelLearner() {
		$this->disableAutoCreateTempUser();
		ConvertibleTimestamp::setFakeTime( '20201231000000' );
		$this->assertConditions(
			[
				# expected
				"((actor_user IS NOT NULL AND "
				. "(user_editcount >= 10 AND (user_registration IS NULL OR user_registration <= '{$this->getDb()->timestamp( '20201227000000' )}')) AND "
				. "(user_editcount < 500 OR user_registration > '{$this->getDb()->timestamp( '20201201000000' )}')"
				. "))"
			],
			[
				'userExpLevel' => 'learner'
			],
			"rc conditions: userExpLevel=learner"
		);
	}

	public function testFilterUserExpLevelLearnerWhenTemporaryAccountsEnabled() {
		$this->enableAutoCreateTempUser();
		ConvertibleTimestamp::setFakeTime( '20201231000000' );

		$notLikeTempUserMatchExpression = $this->getServiceContainer()->getTempUserConfig()
			->getMatchCondition( $this->getDb(), 'actor_name', IExpression::NOT_LIKE )
			->toSql( $this->getDb() );

		$this->assertConditions(
			[
				# expected
				"(((actor_user IS NOT NULL AND $notLikeTempUserMatchExpression) AND "
				. "(user_editcount >= 10 AND (user_registration IS NULL OR user_registration <= '{$this->getDb()->timestamp( '20201227000000' )}')) AND "
				. "(user_editcount < 500 OR user_registration > '{$this->getDb()->timestamp( '20201201000000' )}')"
				. "))"
			],
			[
				'userExpLevel' => 'learner'
			],
			"rc conditions: userExpLevel=learner"
		);
	}

	public function testFilterUserExpLevelUnregisteredOrExperienced() {
		$this->disableAutoCreateTempUser();
		ConvertibleTimestamp::setFakeTime( '20201231000000' );
		$this->assertConditions(
			[
				# expected
				"(actor_user IS NULL OR "
				. "(actor_user IS NOT NULL AND "
					. "(user_editcount >= 500 AND (user_registration IS NULL OR user_registration <= '{$this->getDb()->timestamp( '20201201000000' )}'))"
				. "))"
			],
			[
				'userExpLevel' => 'unregistered;experienced'
			],
			"rc conditions: userExpLevel=unregistered;experienced"
		);
	}

	public function testFilterUserExpLevelUnregisteredOrExperiencedWhenTemporaryAccountsEnabled() {
		$this->enableAutoCreateTempUser();
		ConvertibleTimestamp::setFakeTime( '20201231000000' );

		$notLikeTempUserMatchExpression = $this->getServiceContainer()->getTempUserConfig()
			->getMatchCondition( $this->getDb(), 'actor_name', IExpression::NOT_LIKE )
			->toSql( $this->getDb() );
		$likeTempUserMatchExpression = $this->getServiceContainer()->getTempUserConfig()
			->getMatchCondition( $this->getDb(), 'actor_name', IExpression::LIKE )
			->toSql( $this->getDb() );

		$this->assertConditions(
			[
				# expected
				"((actor_user IS NULL OR $likeTempUserMatchExpression) OR "
				. "((actor_user IS NOT NULL AND $notLikeTempUserMatchExpression) AND "
					. "(user_editcount >= 500 AND (user_registration IS NULL OR user_registration <= '{$this->getDb()->timestamp( '20201201000000' )}'))"
				. "))"
			],
			[
				'userExpLevel' => 'unregistered;experienced'
			],
			"rc conditions: userExpLevel=unregistered;experienced"
		);
	}

	public function testFilterUserExpLevel() {
		$now = time();
		$this->overrideConfigValues( [
			MainConfigNames::LearnerEdits => 10,
			MainConfigNames::LearnerMemberSince => 4,
			MainConfigNames::ExperiencedUserEdits => 500,
			MainConfigNames::ExperiencedUserMemberSince => 30,
		] );

		$this->createUsers( [
			'Newcomer1' => [ 'edits' => 2, 'days' => 2 ],
			'Newcomer2' => [ 'edits' => 12, 'days' => 3 ],
			'Newcomer3' => [ 'edits' => 8, 'days' => 5 ],
			'Learner1' => [ 'edits' => 15, 'days' => 10 ],
			'Learner2' => [ 'edits' => 450, 'days' => 20 ],
			'Learner3' => [ 'edits' => 460, 'days' => 33 ],
			'Learner4' => [ 'edits' => 525, 'days' => 28 ],
			'Experienced1' => [ 'edits' => 538, 'days' => 33 ],
		], $now );

		// newcomers only
		$this->assertArrayEquals(
			[ 'Newcomer1', 'Newcomer2', 'Newcomer3' ],
			$this->fetchUsers( [ 'newcomer' ], $now )
		);

		// newcomers and learner
		$this->assertArrayEquals(
			[
				'Newcomer1', 'Newcomer2', 'Newcomer3',
				'Learner1', 'Learner2', 'Learner3', 'Learner4',
			],
			$this->fetchUsers( [ 'newcomer', 'learner' ], $now )
		);

		// newcomers and more learner
		$this->assertArrayEquals(
			[
				'Newcomer1', 'Newcomer2', 'Newcomer3',
				'Experienced1',
			],
			$this->fetchUsers( [ 'newcomer', 'experienced' ], $now )
		);

		// learner only
		$this->assertArrayEquals(
			[ 'Learner1', 'Learner2', 'Learner3', 'Learner4' ],
			$this->fetchUsers( [ 'learner' ], $now )
		);

		// more experienced only
		$this->assertArrayEquals(
			[ 'Experienced1' ],
			$this->fetchUsers( [ 'experienced' ], $now )
		);

		// learner and more experienced
		$this->assertArrayEquals(
			[
				'Learner1', 'Learner2', 'Learner3', 'Learner4',
				'Experienced1',
			],
			$this->fetchUsers( [ 'learner', 'experienced' ], $now )
		);
	}

	private function createUsers( array $specs, int $now ) {
		$dbw = $this->getDb();
		foreach ( $specs as $name => $spec ) {
			User::createNew(
				$name,
				[
					'editcount' => $spec['edits'],
					'registration' => $dbw->timestamp( $this->daysAgo( $spec['days'], $now ) ),
					'email' => 'ut',
				]
			);
		}
	}

	private function fetchUsers( array $filters, int $now ): array {
		$tables = [];
		$conds = [];
		$fields = [];
		$query_options = [];
		$join_conds = [];

		sort( $filters );

		call_user_func_array(
			[ $this->changesListSpecialPage, 'filterOnUserExperienceLevel' ],
			[
				get_class( $this->changesListSpecialPage ),
				$this->changesListSpecialPage->getContext(),
				$this->changesListSpecialPage->getDB(),
				&$tables,
				&$fields,
				&$conds,
				&$query_options,
				&$join_conds,
				$filters,
				$now
			]
		);

		// @todo: This is not at all safe or sensible. It just blindly assumes
		// nothing in $conds depends on any other tables.
		$result = $this->getDb()->newSelectQueryBuilder()
			->select( 'user_name' )
			->from( 'user' )
			->leftJoin( 'actor', null, 'actor_user=user_id' )
			->where( $conds )
			->andWhere( [ 'user_email' => 'ut' ] )
			->fetchResultSet();

		$usernames = [];
		foreach ( $result as $row ) {
			$usernames[] = $row->user_name;
		}

		return $usernames;
	}

	private function daysAgo( int $days, int $now ): int {
		$secondsPerDay = 86400;
		return $now - $days * $secondsPerDay;
	}

	public function testGetStructuredFilterJsData() {
		$this->changesListSpecialPage->filterGroups = [];

		$definition = [
			[
				'name' => 'gub-group',
				'title' => 'gub-group-title',
				'class' => ChangesListBooleanFilterGroup::class,
				'filters' => [
					[
						'name' => 'hidefoo',
						'label' => 'foo-label',
						'description' => 'foo-description',
						'default' => true,
						'showHide' => 'showhidefoo',
						'priority' => 2,
					],
					[
						'name' => 'hidebar',
						'label' => 'bar-label',
						'description' => 'bar-description',
						'default' => false,
						'priority' => 4,
					]
				],
			],

			[
				'name' => 'des-group',
				'title' => 'des-group-title',
				'class' => ChangesListStringOptionsFilterGroup::class,
				'isFullCoverage' => true,
				'filters' => [
					[
						'name' => 'grault',
						'label' => 'grault-label',
						'description' => 'grault-description',
					],
					[
						'name' => 'garply',
						'label' => 'garply-label',
						'description' => 'garply-description',
					],
				],
				'queryCallable' => static function () {
				},
				'default' => ChangesListStringOptionsFilterGroup::NONE,
			],

			[
				'name' => 'unstructured',
				'class' => ChangesListBooleanFilterGroup::class,
				'filters' => [
					[
						'name' => 'hidethud',
						'showHide' => 'showhidethud',
						'default' => true,
					],

					[
						'name' => 'hidemos',
						'showHide' => 'showhidemos',
						'default' => false,
					],
				],
			],

		];

		$this->changesListSpecialPage->registerFiltersFromDefinitions( $definition );

		$this->assertArrayEquals(
			[
				// Filters that only display in the unstructured UI are
				// are not included, and neither are groups that would
				// be empty due to the above.
				'groups' => [
					[
						'name' => 'gub-group',
						'title' => 'gub-group-title',
						'type' => ChangesListBooleanFilterGroup::TYPE,
						'priority' => -1,
						'filters' => [
							[
								'name' => 'hidebar',
								'label' => 'bar-label',
								'description' => 'bar-description',
								'default' => false,
								'priority' => 4,
								'cssClass' => null,
								'conflicts' => [],
								'subset' => [],
								'defaultHighlightColor' => null
							],
							[
								'name' => 'hidefoo',
								'label' => 'foo-label',
								'description' => 'foo-description',
								'default' => true,
								'priority' => 2,
								'cssClass' => null,
								'conflicts' => [],
								'subset' => [],
								'defaultHighlightColor' => null
							],
						],
						'fullCoverage' => true,
						'conflicts' => [],
					],

					[
						'name' => 'des-group',
						'title' => 'des-group-title',
						'type' => ChangesListStringOptionsFilterGroup::TYPE,
						'priority' => -2,
						'fullCoverage' => true,
						'filters' => [
							[
								'name' => 'grault',
								'label' => 'grault-label',
								'description' => 'grault-description',
								'cssClass' => null,
								'priority' => -2,
								'conflicts' => [],
								'subset' => [],
								'defaultHighlightColor' => null
							],
							[
								'name' => 'garply',
								'label' => 'garply-label',
								'description' => 'garply-description',
								'cssClass' => null,
								'priority' => -3,
								'conflicts' => [],
								'subset' => [],
								'defaultHighlightColor' => null
							],
						],
						'conflicts' => [],
						'separator' => ';',
						'default' => ChangesListStringOptionsFilterGroup::NONE,
					],
				],
				'messageKeys' => [
					'gub-group-title',
					'bar-label',
					'bar-description',
					'foo-label',
					'foo-description',
					'des-group-title',
					'grault-label',
					'grault-description',
					'garply-label',
					'garply-description',
				],
			],
			$this->changesListSpecialPage->getStructuredFilterJsData(),
			/** ordered= */ false,
			/** named= */ true
		);
	}

	public function provideParseParameters() {
		return [
			[ 'hidebots', [ 'hidebots' => true ] ],

			[ 'bots', [ 'hidebots' => false ] ],

			[ 'hideminor', [ 'hideminor' => true ] ],

			[ 'minor', [ 'hideminor' => false ] ],

			[ 'hidemajor', [ 'hidemajor' => true ] ],

			[ 'hideliu', [ 'hideliu' => true ] ],

			[ 'hidepatrolled', [ 'hidepatrolled' => true ] ],

			[ 'hideunpatrolled', [ 'hideunpatrolled' => true ] ],

			[ 'hideanons', [ 'hideanons' => true ] ],

			[ 'hidemyself', [ 'hidemyself' => true ] ],

			[ 'hidebyothers', [ 'hidebyothers' => true ] ],

			[ 'hidehumans', [ 'hidehumans' => true ] ],

			[ 'hidepageedits', [ 'hidepageedits' => true ] ],

			[ 'pagedits', [ 'hidepageedits' => false ] ],

			[ 'hidenewpages', [ 'hidenewpages' => true ] ],

			[ 'hidecategorization', [ 'hidecategorization' => true ] ],

			[ 'hidelog', [ 'hidelog' => true ] ],

			[
				'userExpLevel=learner;experienced',
				[
					'userExpLevel' => 'learner;experienced'
				],
			],

			// A few random combos
			[
				'bots,hideliu,hidemyself',
				[
					'hidebots' => false,
					'hideliu' => true,
					'hidemyself' => true,
				],
			],

			[
				'minor,hideanons,categorization',
				[
					'hideminor' => false,
					'hideanons' => true,
					'hidecategorization' => false,
				]
			],

			[
				'hidehumans,bots,hidecategorization',
				[
					'hidehumans' => true,
					'hidebots' => false,
					'hidecategorization' => true,
				],
			],

			[
				'hidemyself,userExpLevel=newcomer;learner,hideminor',
				[
					'hidemyself' => true,
					'hideminor' => true,
					'userExpLevel' => 'newcomer;learner',
				],
			],
		];
	}

	public static function provideGetFilterConflicts() {
		return [
			[
				"parameters" => [],
				"expectedConflicts" => false,
			],
			[
				"parameters" => [
					"hideliu" => true,
					"userExpLevel" => "newcomer",
				],
				"expectedConflicts" => false,
			],
			[
				"parameters" => [
					"hideanons" => true,
					"userExpLevel" => "learner",
				],
				"expectedConflicts" => false,
			],
			[
				"parameters" => [
					"hidemajor" => true,
					"hidenewpages" => true,
					"hidepageedits" => true,
					"hidecategorization" => false,
					"hidelog" => true,
					"hideWikidata" => true,
				],
				"expectedConflicts" => true,
			],
			[
				"parameters" => [
					"hidemajor" => true,
					"hidenewpages" => false,
					"hidepageedits" => true,
					"hidecategorization" => false,
					"hidelog" => false,
					"hideWikidata" => true,
				],
				"expectedConflicts" => true,
			],
			[
				"parameters" => [
					"hidemajor" => true,
					"hidenewpages" => false,
					"hidepageedits" => false,
					"hidecategorization" => true,
					"hidelog" => true,
					"hideWikidata" => true,
				],
				"expectedConflicts" => false,
			],
			[
				"parameters" => [
					"hideminor" => true,
					"hidenewpages" => true,
					"hidepageedits" => true,
					"hidecategorization" => false,
					"hidelog" => true,
					"hideWikidata" => true,
				],
				"expectedConflicts" => false,
			],
		];
	}

	/**
	 * @dataProvider provideGetFilterConflicts
	 */
	public function testGetFilterConflicts( $parameters, $expectedConflicts ) {
		$context = new RequestContext;
		$context->setRequest( new FauxRequest( $parameters ) );
		$this->changesListSpecialPage->setContext( $context );

		$this->assertEquals(
			$expectedConflicts,
			$this->changesListSpecialPage->areFiltersInConflict()
		);
	}

	public function validateOptionsProvider() {
		return [
			[
				[ 'hideanons' => 1, 'hideliu' => 1, 'hidebots' => 1 ],
				true,
				[ 'userExpLevel' => 'unregistered', 'hidebots' => 1, ],
				true,
			],
			[
				[ 'hideanons' => 1, 'hideliu' => 1, 'hidebots' => 0 ],
				true,
				[ 'hidebots' => 0, 'hidehumans' => 1 ],
				true,
			],
			[
				[ 'hideanons' => 1 ],
				true,
				[ 'userExpLevel' => 'registered' ],
				true,
			],
			[
				[ 'hideliu' => 1 ],
				true,
				[ 'userExpLevel' => 'unregistered' ],
				true,
			],
			[
				[ 'hideanons' => 1, 'hidebots' => 1 ],
				true,
				[ 'userExpLevel' => 'registered', 'hidebots' => 1 ],
				true,
			],
			[
				[ 'hideliu' => 1, 'hidebots' => 0 ],
				true,
				[ 'userExpLevel' => 'unregistered', 'hidebots' => 0 ],
				true,
			],
			[
				[ 'hidemyself' => 1, 'hidebyothers' => 1 ],
				true,
				[],
				true,
			],
			[
				[ 'hidebots' => 1, 'hidehumans' => 1 ],
				true,
				[],
				true,
			],
			[
				[ 'hidepatrolled' => 1, 'hideunpatrolled' => 1 ],
				true,
				[],
				true,
			],
			[
				[ 'hideminor' => 1, 'hidemajor' => 1 ],
				true,
				[],
				true,
			],
			[
				// changeType
				[ 'hidepageedits' => 1, 'hidenewpages' => 1, 'hidecategorization' => 1, 'hidelog' => 1, 'hidenewuserlog' => 1 ],
				true,
				[],
				true,
			],
		];
	}
}
PK       ! i]  ]  6  specialpage/AbstractChangesListSpecialPageTestCase.phpnu Iw        <?php

namespace MediaWiki\Tests\SpecialPage;

use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Html\FormOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\Output\OutputPage;
use MediaWiki\SpecialPage\ChangesListSpecialPage;
use MediaWikiIntegrationTestCase;

/**
 * Abstract base class for shared logic when testing ChangesListSpecialPage
 * and subclasses
 *
 * @group Database
 */
abstract class AbstractChangesListSpecialPageTestCase extends MediaWikiIntegrationTestCase {
	// Must be initialized by subclass
	/**
	 * @var ChangesListSpecialPage
	 */
	protected $changesListSpecialPage;

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValues( [
			MainConfigNames::RCWatchCategoryMembership => true,
			MainConfigNames::UseRCPatrol => true,
		] );

		$this->setGroupPermissions( 'patrollers', 'patrol', true );

		# setup the ChangesListSpecialPage (or subclass) object
		$this->changesListSpecialPage = $this->getPageAccessWrapper();
		$context = $this->changesListSpecialPage->getContext();
		$context = new DerivativeContext( $context );
		$context->setUser( $this->getTestUser( [ 'patrollers' ] )->getUser() );
		$this->changesListSpecialPage->setContext( $context );
		$this->changesListSpecialPage->registerFilters();
	}

	/**
	 * @return ChangesListSpecialPage
	 */
	abstract protected function getPageAccessWrapper();

	abstract public function provideParseParameters();

	/**
	 * @dataProvider provideParseParameters
	 */
	public function testParseParameters( $params, $expected ) {
		$opts = new FormOptions();
		foreach ( $expected as $key => $value ) {
			// Register it as null so sets aren't rejected.
			$opts->add(
				$key,
				null,
				FormOptions::guessType( $expected )
			);
		}

		$this->changesListSpecialPage->parseParameters(
			$params,
			$opts
		);

		$this->assertArrayEquals(
			$expected,
			$opts->getAllValues(),
			/** ordered= */ false,
			/** named= */ true
		);
	}

	/**
	 * @dataProvider validateOptionsProvider
	 */
	public function testValidateOptions(
		$optionsToSet,
		$expectedRedirect,
		$expectedRedirectOptions,
		$rcfilters
	) {
		$redirectQuery = [];
		$redirected = false;
		$output = $this->getMockBuilder( OutputPage::class )
			->disableProxyingToOriginalMethods()
			->disableOriginalConstructor()
			->getMock();
		$output->method( 'redirect' )->willReturnCallback(
			static function ( $url ) use ( &$redirectQuery, &$redirected ) {
				$query = parse_url( $url, PHP_URL_QUERY ) ?? '';
				parse_str( $query, $redirectQuery );
				$redirected = true;
			}
		);

		// Disable this hook or it could break changeType
		// depending on which other extensions are running.
		$this->setTemporaryHook(
			'ChangesListSpecialPageStructuredFilters',
			HookContainer::NOOP
		);

		// Give users patrol permissions so we can test that.
		$user = $this->getTestSysop()->getUser();
		$this->getServiceContainer()->getUserOptionsManager()->setOption(
			$user,
			'rcenhancedfilters-disable',
			$rcfilters ? 0 : 1
		);
		$ctx = new RequestContext();
		$ctx->setUser( $user );

		$ctx->setOutput( $output );
		$clsp = $this->changesListSpecialPage;
		$clsp->setContext( $ctx );
		$opts = $clsp->getDefaultOptions();

		foreach ( $optionsToSet as $option => $value ) {
			$opts->setValue( $option, $value );
		}

		$clsp->validateOptions( $opts );

		$this->assertEquals( $expectedRedirect, $redirected, 'redirection - ' . print_r( $optionsToSet, true ) );

		if ( $expectedRedirect ) {
			if ( count( $expectedRedirectOptions ) > 0 ) {
				$expectedRedirectOptions += [
					'title' => $clsp->getPageTitle()->getPrefixedText(),
				];
			}

			$this->assertArrayEquals(
				$expectedRedirectOptions,
				$redirectQuery,
				/* $ordered= */ false,
				/* $named= */ true,
				'redirection query'
			);
		}
	}

	abstract public function validateOptionsProvider();
}
PK       ! o    %  specialpage/SpecialPageTestHelper.phpnu Iw        <?php

namespace MediaWiki\Tests\SpecialPage;

use MediaWiki\Specials\SpecialAllPages;

/**
 * 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
 */
class SpecialPageTestHelper {

	public static function newSpecialAllPages() {
		return new SpecialAllPages();
	}

}
PK       ! _o  o  '  specialpage/FormSpecialPageTestCase.phpnu Iw        <?php

namespace MediaWiki\Tests\SpecialPage;

use MediaWiki\Block\BlockErrorFormatter;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\DAO\WikiAwareEntity;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\SpecialPage\FormSpecialPage;
use MediaWiki\User\User;
use MediaWiki\Utils\MWTimestamp;
use ReflectionMethod;
use SpecialPageTestBase;
use UserBlockedError;
use Wikimedia\Rdbms\ReadOnlyMode;

/**
 * Factory for handling the special page list and generating SpecialPage objects.
 *
 * 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
 *
 * @group SpecialPage
 */
abstract class FormSpecialPageTestCase extends SpecialPageTestBase {
	/**
	 * @return FormSpecialPage
	 */
	abstract protected function newSpecialPage();

	/**
	 * @covers \MediaWiki\SpecialPage\FormSpecialPage::checkExecutePermissions
	 */
	public function testCheckExecutePermissionsSitewideBlock() {
		$blockErrorFormatter = $this->createMock( BlockErrorFormatter::class );
		$blockErrorFormatter->method( 'getMessage' )
			->willReturn( $this->getMockMessage( 'test' ) );
		$this->setService( 'BlockErrorFormatter', $blockErrorFormatter );

		// Make the permission tests pass so we can check that the user is denied access because of their block.
		$permissionManager = $this->createMock( PermissionManager::class );
		$permissionManager->method( 'userHasRight' )->willReturn( true );
		$this->setService( 'PermissionManager', $permissionManager );

		$special = $this->newSpecialPage();
		$checkExecutePermissions = $this->getMethod( $special, 'checkExecutePermissions' );

		$user = $this->getMockBuilder( User::class )
			->onlyMethods( [ 'getBlock', 'getWikiId' ] )
			->getMock();
		$user->method( 'getWikiId' )->willReturn( WikiAwareEntity::LOCAL );
		$block = $this->createMock( DatabaseBlock::class );
		$block->method( 'isSitewide' )->willReturn( true );
		$block->method( 'getTargetUserIdentity' )->willReturn( $user );
		$block->method( 'getExpiry' )->willReturn( MWTimestamp::convert( TS_MW, 10 ) );
		$user->method( 'getBlock' )->willReturn( $block );

		$this->expectException( UserBlockedError::class );
		$checkExecutePermissions( $user );
	}

	/**
	 * @covers \MediaWiki\SpecialPage\FormSpecialPage::checkExecutePermissions
	 */
	public function testCheckExecutePermissionsPartialBlock() {
		$blockErrorFormatter = $this->createMock( BlockErrorFormatter::class );
		$blockErrorFormatter->method( 'getMessage' )
			->willReturn( $this->getMockMessage( 'test' ) );
		$this->setService( 'BlockErrorFormatter', $blockErrorFormatter );

		$readOnlyMode = $this->createMock( ReadOnlyMode::class );
		$readOnlyMode->method( 'isReadOnly' )->willReturn( false );
		$this->setService( 'ReadOnlyMode', $readOnlyMode );

		// Make the permission tests pass so we can check that the user is denied access because of their block.
		$permissionManager = $this->createMock( PermissionManager::class );
		$permissionManager->method( 'userHasRight' )->willReturn( true );
		$this->setService( 'PermissionManager', $permissionManager );

		$special = $this->newSpecialPage();
		$checkExecutePermissions = $this->getMethod( $special, 'checkExecutePermissions' );

		$user = $this->getMockBuilder( User::class )
			->onlyMethods( [ 'getBlock', 'getWikiId' ] )
			->getMock();
		$user->method( 'getWikiId' )->willReturn( WikiAwareEntity::LOCAL );
		$block = $this->createMock( DatabaseBlock::class );
		$block->method( 'isSitewide' )->willReturn( false );
		$block->method( 'getTargetUserIdentity' )->willReturn( $user );
		$block->method( 'getExpiry' )->willReturn( MWTimestamp::convert( TS_MW, 10 ) );
		$user->method( 'getBlock' )->willReturn( $block );

		$this->assertNull( $checkExecutePermissions( $user ) );
	}

	/**
	 * Get a protected/private method.
	 *
	 * @param FormSpecialPage $obj
	 * @param string $name
	 * @return callable
	 */
	protected function getMethod( FormSpecialPage $obj, $name ) {
		$method = new ReflectionMethod( $obj, $name );
		$method->setAccessible( true );
		return $method->getClosure( $obj );
	}
}
PK       ! ?C+*  *  &  specialpage/SpecialPageFactoryTest.phpnu Iw        <?php

namespace MediaWiki\Tests\SpecialPage;

use Exception;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\MainConfigSchema;
use MediaWiki\Output\OutputPage;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Request\FauxRequest;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\SpecialPage\SpecialPageFactory;
use MediaWiki\Specials\SpecialAllPages;
use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;
use RuntimeException;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;

/**
 * Factory for handling the special page list and generating SpecialPage objects.
 *
 * 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
 *
 * @covers \MediaWiki\SpecialPage\SpecialPageFactory
 * @group SpecialPage
 */
class SpecialPageFactoryTest extends MediaWikiIntegrationTestCase {
	private function getFactory() {
		return $this->getServiceContainer()->getSpecialPageFactory();
	}

	public function testHookNotCalledTwice() {
		$count = 0;
		$this->setTemporaryHook(
			'SpecialPage_initList',
			static function () use ( &$count ) {
				$count++;
			}
		);
		$spf = $this->getServiceContainer()->getSpecialPageFactory();
		$spf->getNames();
		$spf->getNames();
		$this->assertSame( 1, $count );
	}

	public function newSpecialAllPages() {
		return new SpecialAllPages();
	}

	public function specialPageProvider() {
		$specialPageTestHelper = new SpecialPageTestHelper();

		return [
			'class name' => [ 'SpecialAllPages', false ],
			'closure' => [ static function () {
				return new SpecialAllPages();
			}, false ],
			'function' => [ [ $this, 'newSpecialAllPages' ], false ],
			'callback string' => [ SpecialPageTestHelper::class . '::newSpecialAllPages', false ],
			'callback with object' => [
				[ $specialPageTestHelper, 'newSpecialAllPages' ],
				false
			],
			'callback array' => [
				[ SpecialPageTestHelper::class, 'newSpecialAllPages' ],
				false
			],
			'object factory spec' => [
				[ 'class' => SpecialAllPages::class ],
				false
			]
		];
	}

	/**
	 * @covers \MediaWiki\SpecialPage\SpecialPageFactory::getPage
	 * @dataProvider specialPageProvider
	 */
	public function testGetPage( $spec, $shouldReuseInstance ) {
		$this->overrideConfigValue(
			MainConfigNames::SpecialPages,
			[ 'testdummy' => $spec ] + MainConfigSchema::getDefaultValue( MainConfigNames::SpecialPages )
		);

		$factory = $this->getFactory();
		$page = $factory->getPage( 'testdummy' );
		$this->assertInstanceOf( SpecialPage::class, $page );

		$page2 = $factory->getPage( 'testdummy' );
		$this->assertEquals( $shouldReuseInstance, $page2 === $page, "Should re-use instance:" );
	}

	/**
	 * @covers \MediaWiki\SpecialPage\SpecialPageFactory::getNames
	 */
	public function testGetNames() {
		$this->overrideConfigValue(
			MainConfigNames::SpecialPages,
			[ 'testdummy' => SpecialAllPages::class ] +
			MainConfigSchema::getDefaultValue( MainConfigNames::SpecialPages )
		);

		$names = $this->getFactory()->getNames();
		$this->assertIsArray( $names );
		$this->assertContains( 'testdummy', $names );
	}

	/**
	 * @covers \MediaWiki\SpecialPage\SpecialPageFactory::resolveAlias
	 */
	public function testResolveAlias() {
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'de' );

		[ $name, $param ] = $this->getFactory()->resolveAlias( 'Spezialseiten/Foo' );
		$this->assertEquals( 'Specialpages', $name );
		$this->assertEquals( 'Foo', $param );
	}

	/**
	 * @covers \MediaWiki\SpecialPage\SpecialPageFactory::getLocalNameFor
	 */
	public function testGetLocalNameFor() {
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'de' );

		$name = $this->getFactory()->getLocalNameFor( 'Specialpages', 'Foo' );
		$this->assertEquals( 'Spezialseiten/Foo', $name );
	}

	/**
	 * @covers \MediaWiki\SpecialPage\SpecialPageFactory::getTitleForAlias
	 */
	public function testGetTitleForAlias() {
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'de' );

		$title = $this->getFactory()->getTitleForAlias( 'Specialpages/Foo' );
		$this->assertEquals( 'Spezialseiten/Foo', $title->getText() );
		$this->assertEquals( NS_SPECIAL, $title->getNamespace() );
	}

	public static function provideExecutePath() {
		yield [ 'BlankPage', 'intentionallyblankpage' ];

		$path = new PageReferenceValue( NS_SPECIAL, 'BlankPage', PageReferenceValue::LOCAL );
		yield [ $path, 'intentionallyblankpage' ];
	}

	/**
	 * @dataProvider provideExecutePath
	 * @covers \MediaWiki\SpecialPage\SpecialPageFactory::executePAth
	 */
	public function testExecutePath( $path, $expected ) {
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'qqx' );

		$context = new RequestContext();
		$context->setRequest( new FauxRequest() );

		$output = new OutputPage( $context );
		$context->setOutput( $output );

		$this->getFactory()->executePath( $path, $context );
		$this->assertStringContainsString( $expected, $output->getHTML() );
	}

	/**
	 * @dataProvider provideTestConflictResolution
	 */
	public function testConflictResolution(
		$test, $aliasesList, $alias, $expectedName, $expectedAlias, $expectWarnings
	) {
		$lang = clone $this->getServiceContainer()->getContentLanguage();
		$wrappedLang = TestingAccessWrapper::newFromObject( $lang );
		$wrappedLang->mExtendedSpecialPageAliases = $aliasesList;
		$this->overrideConfigValues( [
			MainConfigNames::DevelopmentWarnings => true,
			MainConfigNames::SpecialPages =>
				array_combine( array_keys( $aliasesList ), array_keys( $aliasesList ) )
		] );
		$this->setContentLang( $lang );

		// Catch the warnings we expect to be raised
		$warnings = [];
		set_error_handler( static function ( $errno, $errstr ) use ( &$warnings ) {
			if ( preg_match( '/First alias \'[^\']*\' for .*/', $errstr ) ||
				preg_match( '/Did not find a usable alias for special page .*/', $errstr )
			) {
				$warnings[] = $errstr;
				return true;
			}
			return false;
		} );
		$reset = new ScopedCallback( 'restore_error_handler' );

		[ $name, /*...*/ ] = $this->getFactory()->resolveAlias( $alias );
		$this->assertEquals( $expectedName, $name, "$test: Alias to name" );
		$result = $this->getFactory()->getLocalNameFor( $name );
		$this->assertEquals( $expectedAlias, $result, "$test: Alias to name to alias" );

		$gotWarnings = count( $warnings );
		if ( $gotWarnings !== $expectWarnings ) {
			$this->fail( "Expected $expectWarnings warning(s), but got $gotWarnings:\n" .
				implode( "\n", $warnings )
			);
		}
	}

	/**
	 * @dataProvider provideTestConflictResolution
	 */
	public function testConflictResolutionReversed(
		$test, $aliasesList, $alias, $expectedName, $expectedAlias, $expectWarnings
	) {
		// Make sure order doesn't matter by reversing the list
		$aliasesList = array_reverse( $aliasesList );
		$this->testConflictResolution(
			$test, $aliasesList, $alias, $expectedName, $expectedAlias, $expectWarnings
		);
	}

	public static function provideTestConflictResolution() {
		return [
			[
				'Canonical name wins',
				[ 'Foo' => [ 'Foo', 'Bar' ], 'Baz' => [ 'Foo', 'BazPage', 'Baz2' ] ],
				'Foo',
				'Foo',
				'Foo',
				1,
			],

			[
				'Doesn\'t redirect to a different special page\'s canonical name',
				[ 'Foo' => [ 'Foo', 'Bar' ], 'Baz' => [ 'Foo', 'BazPage', 'Baz2' ] ],
				'Baz',
				'Baz',
				'BazPage',
				1,
			],

			[
				'Canonical name wins even if not aliased',
				[ 'Foo' => [ 'FooPage' ], 'Baz' => [ 'Foo', 'BazPage', 'Baz2' ] ],
				'Foo',
				'Foo',
				'FooPage',
				1,
			],

			[
				'Doesn\'t redirect to a different special page\'s canonical name even if not aliased',
				[ 'Foo' => [ 'FooPage' ], 'Baz' => [ 'Foo', 'BazPage', 'Baz2' ] ],
				'Baz',
				'Baz',
				'BazPage',
				1,
			],

			[
				'First local name beats non-first',
				[ 'First' => [ 'Foo' ], 'NonFirst' => [ 'Bar', 'Foo' ] ],
				'Foo',
				'First',
				'Foo',
				0,
			],

			[
				'Doesn\'t redirect to a different special page\'s first alias',
				[
					'Foo' => [ 'Foo' ],
					'First' => [ 'Bar' ],
					'Baz' => [ 'Foo', 'Bar', 'BazPage', 'Baz2' ]
				],
				'Baz',
				'Baz',
				'BazPage',
				1,
			],

			[
				'Doesn\'t redirect wrong even if all aliases conflict',
				[
					'Foo' => [ 'Foo' ],
					'First' => [ 'Bar' ],
					'Baz' => [ 'Foo', 'Bar' ]
				],
				'Baz',
				'Baz',
				'Baz',
				2,
			],

		];
	}

	public function testGetAliasListRecursion() {
		$called = false;
		$this->setTemporaryHook(
			'SpecialPage_initList',
			function () use ( &$called ) {
				$this->getServiceContainer()
					->getSpecialPageFactory()
					->getLocalNameFor( 'Specialpages' );
				$called = true;
			}
		);
		$this->getFactory()->getLocalNameFor( 'Specialpages' );
		$this->assertTrue( $called, 'Recursive call succeeded' );
	}

	/**
	 * @covers \MediaWiki\SpecialPage\SpecialPageFactory::getPage
	 */
	public function testSpecialPageCreationThatRequiresService() {
		$type = null;

		$this->overrideConfigValue( MainConfigNames::SpecialPages,
			[ 'TestPage' => [
				'factory' => static function ( $spf ) use ( &$type ) {
					$type = get_class( $spf );

					return new class() extends SpecialPage {

					};
				},
				'services' => [
					'SpecialPageFactory'
				]
			] ]
		);

		$this->getFactory()->getPage( 'TestPage' );

		$this->assertEquals( SpecialPageFactory::class, $type );
	}

	/**
	 * @covers \MediaWiki\SpecialPage\SpecialPageFactory::capturePath
	 */
	public function testSpecialPageCapturePathExceptions() {
		$expectedException = new RuntimeException( 'Uh-oh!' );
		$this->overrideConfigValue( MainConfigNames::SpecialPages, [
			'ExceptionPage' => [
				'factory' => static function () use ( $expectedException ) {
					return new class( $expectedException ) extends SpecialPage {
						private Exception $expectedException;

						public function __construct( $expectedException ) {
							parent::__construct();
							$this->expectedException = $expectedException;
						}

						public function execute( $par ) {
							throw $this->expectedException;
						}

						public function isIncludable() {
							return true;
						}
					};
				},
			]
		] );

		$factory = $this->getFactory();
		$factory->getPage( 'ExceptionPage' );

		$this->expectExceptionObject( $expectedException );
		$factory->capturePath(
			Title::makeTitle( NS_SPECIAL, 'ExceptionPage' ),
			RequestContext::getMain()
		);
	}
}
PK       ! w      specialpage/SpecialPageTest.phpnu Iw        <?php

namespace MediaWiki\Tests\SpecialPage;

use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use UserNotLoggedIn;

/**
 * @covers \MediaWiki\SpecialPage\SpecialPage
 *
 * @group Database
 *
 * @author Katie Filbert < aude.wiki@gmail.com >
 */
class SpecialPageTest extends MediaWikiIntegrationTestCase {

	use TempUserTestTrait;

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

		$this->overrideConfigValues( [
			MainConfigNames::Script => '/index.php',
			MainConfigNames::LanguageCode => 'en',
		] );
	}

	/**
	 * @dataProvider getTitleForProvider
	 */
	public function testGetTitleFor( $expectedName, $name ) {
		$title = SpecialPage::getTitleFor( $name );
		$expected = Title::makeTitle( NS_SPECIAL, $expectedName );
		$this->assertEquals( $expected, $title );
	}

	public static function getTitleForProvider() {
		return [
			[ 'UserLogin', 'Userlogin' ]
		];
	}

	public function testInvalidGetTitleFor() {
		$this->expectPHPError(
			E_USER_NOTICE,
			function () {
				$title = SpecialPage::getTitleFor( 'cat' );
				$expected = Title::makeTitle( NS_SPECIAL, 'Cat' );
				$this->assertEquals( $expected, $title );
			}
		);
	}

	/**
	 * @dataProvider getTitleForWithWarningProvider
	 */
	public function testGetTitleForWithWarning( $expected, $name ) {
		$this->expectPHPError(
			E_USER_NOTICE,
			function () use ( $name, $expected ) {
				$title = SpecialPage::getTitleFor( $name );
				$this->assertEquals( $expected, $title );
			}
		);
	}

	public static function getTitleForWithWarningProvider() {
		return [
			[ Title::makeTitle( NS_SPECIAL, 'UserLogin' ), 'UserLogin' ]
		];
	}

	/**
	 * @dataProvider requireLoginAnonProvider
	 */
	public function testRequireLoginAnon( $expected, ...$params ) {
		$specialPage = new SpecialPage( 'Watchlist', 'viewmywatchlist' );

		$user = User::newFromId( 0 );
		$specialPage->getContext()->setUser( $user );
		$specialPage->getContext()->setLanguage(
			$this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' ) );

		$this->expectException( UserNotLoggedIn::class );
		$this->expectExceptionMessage( $expected );

		$specialPage->requireLogin( ...$params );
	}

	public static function requireLoginAnonProvider() {
		$lang = 'en';

		$expected1 = wfMessage( 'exception-nologin-text' )->inLanguage( $lang )->text();
		$expected2 = wfMessage( 'about' )->inLanguage( $lang )->text();

		return [
			[ $expected1 ],
			[ $expected2, 'about' ],
			[ $expected2, 'about', 'about' ],
		];
	}

	public function testRequireLoginNotAnon() {
		$specialPage = new SpecialPage( 'Watchlist', 'viewmywatchlist' );

		$user = $this->getTestSysop()->getUser();
		$specialPage->getContext()->setUser( $user );

		$specialPage->requireLogin();

		// no exception thrown, logged in use can access special page
		$this->assertTrue( true );
	}

	/**
	 * @dataProvider provideRequireNamedUserAnon
	 */
	public function testRequireNamedUserAnon( $expected, ...$params ) {
		$specialPage = new SpecialPage( 'Watchlist', 'viewmywatchlist' );

		$user = User::newFromId( 0 );
		$specialPage->getContext()->setUser( $user );
		$specialPage->getContext()->setLanguage(
			$this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' ) );

		$this->expectException( UserNotLoggedIn::class );
		$this->expectExceptionMessage( $expected );

		$specialPage->requireLogin( ...$params );
	}

	public static function provideRequireNamedUserAnon() {
		$lang = 'en';

		$expected1 = wfMessage( 'exception-nologin-text' )->inLanguage( $lang )->text();
		$expected2 = wfMessage( 'about' )->inLanguage( $lang )->text();

		return [
			[ $expected1 ],
			[ $expected2, 'about' ],
			[ $expected2, 'about', 'about' ],
		];
	}

	public function testRequireNamedUserForTempUser() {
		$this->enableAutoCreateTempUser();
		$specialPage = new SpecialPage( 'Watchlist', 'viewmywatchlist' );

		$user = $this->getServiceContainer()->getTempUserCreator()->create( null, new FauxRequest() )->getUser();
		$specialPage->getContext()->setUser( $user );

		$this->expectException( UserNotLoggedIn::class );
		$specialPage->requireNamedUser();
	}

	public function testRequireNamedUserForNamedUser() {
		$specialPage = new SpecialPage( 'Watchlist', 'viewmywatchlist' );

		$user = $this->getTestSysop()->getUser();
		$specialPage->getContext()->setUser( $user );

		$specialPage->requireNamedUser();

		// no exception thrown, so the test passes.
		$this->assertTrue( true );
	}
}
PK       ! N/Tt  t  7  objectcache/SqlBagOStuffMultiPrimaryIntegrationTest.phpnu Iw        <?php

/**
 * @group BagOStuff
 * @group Database
 * @covers \SqlBagOStuff
 */
class SqlBagOStuffMultiPrimaryIntegrationTest extends BagOStuffTestBase {
	protected function newCacheInstance() {
		return $this->getServiceContainer()->getObjectCacheFactory()->newFromParams( [
			'class' => SqlBagOStuff::class,
			'loggroup' => 'SQLBagOStuff',
			'multiPrimaryMode' => true,
			'purgePeriod' => 0,
			'reportDupes' => false
		] );
	}

	public function testModtoken() {
		$now = self::TEST_TIME;
		$this->cache->setMockTime( $now );
		$this->cache->set( 'test', 'a' );
		$this->assertSame( 'a', $this->cache->get( 'test' ) );

		$now--;
		// Modtoken comparison makes this a no-op
		$this->cache->set( 'test', 'b' );
		$this->assertSame( 'a', $this->cache->get( 'test' ) );

		$now += 2;
		$this->cache->set( 'test', 'c' );
		$this->assertSame( 'c', $this->cache->get( 'test' ) );
	}
}
PK       ! \αj  j  +  objectcache/SqlBagOStuffServerArrayTest.phpnu Iw        <?php

use MediaWiki\MediaWikiServices;
use Wikimedia\TestingAccessWrapper;

/**
 * @group BagOStuff
 * @group Database
 * @covers \SqlBagOStuff
 */
class SqlBagOStuffServerArrayTest extends BagOStuffTestBase {
	protected function newCacheInstance() {
		// Extract server config from main load balancer
		$lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
		$serverInfo = TestingAccessWrapper::newFromObject( $lb )->serverInfo;
		return $this->getServiceContainer()->getObjectCacheFactory()->newFromParams( [
			'class' => SqlBagOStuff::class,
			'servers' => [ $serverInfo->getServerInfo( 0 ) ]
		] );
	}
}
PK       ! mG    1  objectcache/ObjectCacheFactoryIntegrationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use Wikimedia\ObjectCache\EmptyBagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\Rdbms\DatabaseDomain;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \ObjectCacheFactory
 * @group BagOStuff
 * @group Database
 */
class ObjectCacheFactoryIntegrationTest extends MediaWikiIntegrationTestCase {

	protected function setUp(): void {
		$this->setCacheConfig();
		$this->setMainCache( CACHE_NONE );
		$this->overrideConfigValues( [
			MainConfigNames::MessageCacheType => CACHE_NONE,
			MainConfigNames::ParserCacheType => CACHE_NONE,
		] );

		// Mock ACCEL with 'hash' as being installed.
		// This makes tests deterministic regardless of whether APCu is installed.
		ObjectCacheFactory::$localServerCacheClass = 'HashBagOStuff';
	}

	protected function tearDown(): void {
		ObjectCacheFactory::$localServerCacheClass = null;
	}

	private function setCacheConfig( $arr = [] ) {
		$defaults = [
			CACHE_NONE => [ 'class' => EmptyBagOStuff::class ],
			CACHE_DB => [ 'class' => SqlBagOStuff::class ],
			'hash' => [ 'class' => HashBagOStuff::class ],
			CACHE_ANYTHING => [ 'class' => HashBagOStuff::class ],
		];
		$this->overrideConfigValue( MainConfigNames::ObjectCaches, $arr + $defaults );
	}

	public function testNewAnythingNothing() {
		$ocf = $this->getServiceContainer()->getObjectCacheFactory();
		$this->assertInstanceOf(
			SqlBagOStuff::class,
			$ocf->getInstance( $ocf->getAnythingId() ),
			'No available types. Fallback to DB'
		);
	}

	public function testNewAnythingHash() {
		$this->setMainCache( CACHE_HASH );

		$ocf = $this->getServiceContainer()->getObjectCacheFactory();
		$this->assertInstanceOf(
			HashBagOStuff::class,
			$ocf->getInstance( $ocf->getAnythingId() ),
			'Use an available type (hash)'
		);
	}

	public function testNewAnythingAccel() {
		$this->setMainCache( CACHE_ACCEL );

		$ocf = $this->getServiceContainer()->getObjectCacheFactory();
		$this->assertInstanceOf(
			HashBagOStuff::class,
			$ocf->getInstance( $ocf->getAnythingId() ),
			'Use an available type (CACHE_ACCEL)'
		);
	}

	public function testNewAnythingNoAccel() {
		// Mock APC not being installed (T160519, T147161)
		ObjectCacheFactory::$localServerCacheClass = EmptyBagOStuff::class;
		$this->setMainCache( CACHE_ACCEL );

		$ocf = $this->getServiceContainer()->getObjectCacheFactory();
		$this->assertInstanceOf(
			SqlBagOStuff::class,
			$ocf->getInstance( $ocf->getAnythingId() ),
			'Fallback to DB if available types fall back to Empty'
		);
	}

	public function testNewAnythingNoAccelNoDb() {
		$this->setCacheConfig( [
			// Mock APCu not being installed (T160519, T147161)
			CACHE_ACCEL => [ 'class' => EmptyBagOStuff::class ]
		] );
		$this->setMainCache( CACHE_ACCEL );

		$this->getServiceContainer()->disableStorage();

		$ocf = $this->getServiceContainer()->getObjectCacheFactory();
		$this->assertInstanceOf(
			EmptyBagOStuff::class,
			$ocf->getInstance( $ocf->getAnythingId() ),
			'Fallback to none if available types and DB are unavailable'
		);
	}

	public function testNewAnythingNothingNoDb() {
		$this->getServiceContainer()->disableStorage();

		$ocf = $this->getServiceContainer()->getObjectCacheFactory();
		$this->assertInstanceOf(
			EmptyBagOStuff::class,
			$ocf->getInstance( $ocf->getAnythingId() ),
			'No available types or DB. Fallback to none.'
		);
	}

	public function testNewFromIdWincacheAccel() {
		$ocf = $this->getServiceContainer()->getObjectCacheFactory();
		$className = get_class( $ocf );

		$this->expectDeprecationAndContinue( "/^Use of $className::newFromId with cache ID \"wincache\"\s/" );

		$this->assertInstanceOf(
			HashBagOStuff::class,
			$ocf->getInstance( 'wincache' ),
			'Fallback to APCu for deprecated wincache'
		);
	}

	public function testNewFromIdWincacheNoAccel() {
		// Mock APC not being installed (T160519, T147161)
		ObjectCacheFactory::$localServerCacheClass = EmptyBagOStuff::class;

		$ocf = $this->getServiceContainer()->getObjectCacheFactory();
		$className = get_class( $ocf );

		$this->expectDeprecationAndContinue( "/^Use of $className::newFromId with cache ID \"wincache\"\s/" );

		$this->assertInstanceOf(
			EmptyBagOStuff::class,
			$ocf->getInstance( 'wincache' ),
			'No caching if APCu is not available'
		);
	}

	public function provideLocalServerKeyspace() {
		$dbDomain = static function ( $dbName, $dbPrefix ) {
			global $wgDBmwschema;
			return ( new DatabaseDomain( $dbName, $wgDBmwschema, $dbPrefix ) )->getId();
		};
		return [
			'default' => [ false, 'my_wiki', '', $dbDomain( 'my_wiki', '' ) ],
			'custom' => [ 'custom', 'my_wiki', '', 'custom' ],
			'prefix' => [ false, 'my_wiki', 'nl_', $dbDomain( 'my_wiki', 'nl_' ) ],
			'empty string' => [ '', 'my_wiki', 'nl_', $dbDomain( 'my_wiki', 'nl_' ) ],
		];
	}

	/**
	 * @dataProvider provideLocalServerKeyspace
	 */
	public function testLocalServerKeyspace( $cachePrefix, $dbName, $dbPrefix, $expect ) {
		$this->overrideConfigValues( [
			MainConfigNames::CachePrefix => $cachePrefix,
			MainConfigNames::DBname => $dbName,
			MainConfigNames::DBprefix => $dbPrefix,
		] );
		// Regression against T247562, T361177.
		$cache = $this->getServiceContainer()->getObjectCacheFactory()->getInstance( CACHE_ACCEL );
		$cache = TestingAccessWrapper::newFromObject( $cache );
		$this->assertSame( $expect, $cache->keyspace );
	}

	public function testNewMultiWrite() {
		$this->overrideConfigValues( [
			MainConfigNames::CachePrefix => 'moon-river',
		] );
		$this->setCacheConfig( [
			'multi-example' => [
				'class' => 'MultiWriteBagOStuff',
				'caches' => [
					0 => [
						'class' => 'HashBagOStuff',
					],
					1 => [
						'class' => 'HashBagOStuff',
					],
				],
			],
		] );

		$ocf = $this->getServiceContainer()->getObjectCacheFactory();
		$multi = $ocf->getInstance( 'multi-example' );
		$caches = TestingAccessWrapper::newFromObject( $multi )->caches;

		$this->assertSame( 'moon-river:x', $multi->makeKey( 'x' ), 'MultiWrite key' );

		// Confirm that dependency injection is also applied to the objects constructed
		// for the child caches (T318272).
		$this->assertSame( 'moon-river:x', $caches[0]->makeKey( 'x' ), 'inject cache 0 keyspace' );
		$this->assertSame( 'moon-river:x', $caches[1]->makeKey( 'x' ), 'inject cache 1 keyspace' );
	}

	public static function provideIsDatabaseId() {
		return [
			[ CACHE_DB, CACHE_NONE, true ],
			[ CACHE_ANYTHING, CACHE_DB, true ],
			[ CACHE_ANYTHING, 'hash', false ],
			[ CACHE_ANYTHING, CACHE_ANYTHING, true ]
		];
	}

	/**
	 * @dataProvider provideIsDatabaseId
	 * @param string|int $id
	 * @param string|int $mainCacheType
	 * @param bool $expected
	 */
	public function testIsDatabaseId( $id, $mainCacheType, $expected ) {
		$this->overrideConfigValues( [
			MainConfigNames::MainCacheType => $mainCacheType
		] );
		$ocf = $this->getServiceContainer()->getObjectCacheFactory();
		$this->assertSame( $expected, $ocf->isDatabaseId( $id ) );
	}
}
PK       ! }4    +  objectcache/SqlBagOStuffIntegrationTest.phpnu Iw        <?php

/**
 * @group BagOStuff
 * @covers \SqlBagOStuff
 */
class SqlBagOStuffIntegrationTest extends BagOStuffTestBase {
	protected function newCacheInstance() {
		return $this->getServiceContainer()->getObjectCacheFactory()->getInstance( CACHE_DB );
	}
}
PK       ! <X  X    AutoLoaderTest.phpnu Iw        <?php

use Wikimedia\TestingAccessWrapper;

/**
 * @covers \AutoLoader
 */
class AutoLoaderTest extends MediaWikiIntegrationTestCase {

	/** @var string[] */
	private $oldPsr4;
	/** @var string[] */
	private $oldClassFiles;

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

		$this->mergeMwGlobalArrayValue( 'wgAutoloadLocalClasses', [
			'TestAutoloadedLocalClass' =>
				__DIR__ . '/../data/autoloader/TestAutoloadedLocalClass.php',
		] );
		$this->mergeMwGlobalArrayValue( 'wgAutoloadClasses', [
			'TestAutoloadedClass' => __DIR__ . '/../data/autoloader/TestAutoloadedClass.php',
		] );

		$access = TestingAccessWrapper::newFromClass( AutoLoader::class );
		$this->oldPsr4 = $access->psr4Namespaces;
		$this->oldClassFiles = $access->classFiles;
		AutoLoader::registerNamespaces( [
			'Test\\MediaWiki\\AutoLoader\\' => __DIR__ . '/../data/autoloader/psr4'
		] );
		AutoLoader::registerClasses( [
			'TestAnotherAutoloadedClass' => __DIR__ . '/../data/autoloader/TestAnotherAutoloadedClass.php',
		] );
	}

	protected function tearDown(): void {
		$access = TestingAccessWrapper::newFromClass( AutoLoader::class );
		$access->psr4Namespaces = $this->oldPsr4;
		$access->classFiles = $this->oldClassFiles;
		parent::tearDown();
	}

	public function testFind() {
		$path = __DIR__ . '/../data/autoloader/TestAutoloadedLocalClass.php';
		$this->assertSame( $path, AutoLoader::find( TestAutoloadedLocalClass::class ) );
	}

	public function testCoreClass() {
		$this->assertTrue( class_exists( 'TestAutoloadedLocalClass' ) );
	}

	public function testExtensionClass() {
		$this->assertTrue( class_exists( 'TestAnotherAutoloadedClass' ) );
	}

	public function testLegacyExtensionClass() {
		$this->assertTrue( class_exists( 'TestAutoloadedClass' ) );
	}

	public function testPsr4() {
		$this->assertTrue( class_exists( 'Test\\MediaWiki\\AutoLoader\\TestFooBar' ) );
	}
}
PK       ! 9A  A    debug/TestDeprecatedClass.phpnu Iw        <?php

use MediaWiki\Debug\DeprecationHelper;

#[\AllowDynamicProperties]
class TestDeprecatedClass {

	use DeprecationHelper;

	/** @var int */
	protected $protectedDeprecated = 1;
	/** @var int */
	protected $protectedNonDeprecated = 1;
	/** @var int */
	private $privateDeprecated = 1;
	/** @var int */
	private $privateNonDeprecated = 1;
	/** @var int */
	private $fallbackDeprecated = 1;

	/** @var string */
	private $foo = 'FOO';

	public function __construct() {
		$this->deprecatePublicProperty( 'protectedDeprecated', '1.23' );
		$this->deprecatePublicProperty( 'privateDeprecated', '1.24' );

		$this->deprecatePublicPropertyFallback( 'fallbackDeprecated', '1.25',
			function () {
				return $this->fallbackDeprecated;
			},
			function ( $value ) {
				$this->fallbackDeprecated = $value;
			}
		);
		$this->deprecatePublicPropertyFallback( 'fallbackDeprecatedMethodName', '1.26',
			'getFoo',
			'setFoo'
		);
		$this->deprecatePublicPropertyFallback( 'fallbackGetterOnly', '1.25',
			static function () {
				return 1;
			}
		);
	}

	public function setThings( $prod, $prond, $prid, $prind ) {
		$this->protectedDeprecated = $prod;
		$this->protectedNonDeprecated = $prond;
		$this->privateDeprecated = $prid;
		$this->privateNonDeprecated = $prind;
	}

	public function getThings() {
		return [
			'prod' => $this->protectedDeprecated,
			'prond' => $this->protectedNonDeprecated,
			'prid' => $this->privateDeprecated,
			'prind' => $this->privateNonDeprecated,
		];
	}

	public function getFoo() {
		return $this->foo;
	}

	public function setFoo( $foo ) {
		$this->foo = $foo;
	}
}
PK       ! :'      debug/MWDebugTest.phpnu Iw        <?php

use MediaWiki\Api\ApiFormatXml;
use MediaWiki\Api\ApiResult;
use MediaWiki\Context\RequestContext;
use MediaWiki\Debug\MWDebug;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Title\TitleValue;
use Psr\Log\LoggerInterface;

/**
 * @covers \MediaWiki\Debug\MWDebug
 */
class MWDebugTest extends MediaWikiIntegrationTestCase {

	protected function setUp(): void {
		$this->overrideConfigValue( MainConfigNames::DevelopmentWarnings, false );

		parent::setUp();
		/** Clear log before each test */
		MWDebug::clearLog();
	}

	public static function setUpBeforeClass(): void {
		parent::setUpBeforeClass();
		MWDebug::init();
	}

	public static function tearDownAfterClass(): void {
		MWDebug::deinit();
		parent::tearDownAfterClass();
	}

	public function testLog() {
		@MWDebug::log( 'logging a string' );
		$this->assertEquals(
			[ [
				'msg' => 'logging a string',
				'type' => 'log',
				'caller' => 'MWDebugTest->testLog',
			] ],
			MWDebug::getLog()
		);
	}

	public function testWarningProduction() {
		$logger = $this->createMock( LoggerInterface::class );
		$logger->expects( $this->once() )->method( 'info' );
		$this->setLogger( 'warning', $logger );

		@MWDebug::warning( 'Ohnosecond!' );
	}

	public function testWarningDevelopment() {
		$this->overrideConfigValue( MainConfigNames::DevelopmentWarnings, true );

		$this->expectPHPError(
			E_USER_NOTICE,
			static function () {
				MWDebug::warning( 'Ohnosecond!' );
			},
			'Ohnosecond!'
		);
	}

	/**
	 * Message from the error channel are copied to the debug toolbar "Console" log.
	 *
	 * This normally happens via wfDeprecated -> MWDebug::deprecated -> trigger_error
	 * -> MWExceptionHandler -> LoggerFactory -> LegacyLogger -> MWDebug::debugMsg.
	 *
	 * The above test asserts up until trigger_error.
	 * This test asserts from LegacyLogger down.
	 */
	public function testMessagesFromErrorChannel() {
		// Turn off to keep mw-error.log file empty in CI (and thus avoid build failure)
		$this->overrideConfigValue( MainConfigNames::DebugLogGroups, [] );

		MWExceptionHandler::handleError( E_USER_DEPRECATED, 'Warning message' );
		$this->assertEquals(
			[ [
				'msg' => 'PHP Deprecated: Warning message',
				'type' => 'warn',
				'caller' => 'MWDebugTest::testMessagesFromErrorChannel',
			] ],
			MWDebug::getLog()
		);
	}

	public function testDetectDeprecatedOverride() {
		$baseclassInstance = new TitleValue( NS_MAIN, 'Test' );

		$this->assertFalse(
			MWDebug::detectDeprecatedOverride(
				$baseclassInstance,
				TitleValue::class,
				'getNamespace',
				MW_VERSION
			)
		);

		// create a dummy subclass that overrides a method
		$subclassInstance = new class ( NS_MAIN, 'Test' ) extends TitleValue {
			public function getNamespace(): int {
				// never called
				return -100;
			}
		};

		$this->expectPHPError(
			E_USER_DEPRECATED,
			static function () use ( $subclassInstance ) {
				MWDebug::detectDeprecatedOverride(
					$subclassInstance,
					TitleValue::class,
					'getNamespace',
					MW_VERSION
				);
			},
			'@anonymous'
		);
	}

	public function testDeprecated() {
		$this->expectPHPError(
			E_USER_DEPRECATED,
			static function () {
				MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' );
			},
			'wfOldFunction'
		);
	}

	/**
	 * @doesNotPerformAssertions
	 */
	public function testDeprecatedIgnoreDuplicate() {
		@MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' );
		MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' );

		// If we reach here, than the second one did not throw any deprecation warning.
		// The first one was silenced to seed the ignore logic.
	}

	/**
	 * @doesNotPerformAssertions
	 */
	public function testDeprecatedIgnoreNonConsecutivesDuplicate() {
		@MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' );
		@MWDebug::warning( 'some warning' );
		@MWDebug::log( 'we could have logged something too' );
		// Another deprecation (not silenced)
		MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' );
	}

	public function testDebugMsg() {
		$this->overrideConfigValue( MainConfigNames::ShowDebug, true );

		// Generate a log to be sure there is at least one
		$logger = LoggerFactory::getInstance( 'test-debug-channel' );
		$logger->debug( 'My message', [] );
		$debugLog = (string)MWDebug::getHTMLDebugLog();

		$this->assertNotSame( '', $debugLog, 'MWDebug::getHTMLDebugLog() should not be an empty string' );
		$this->assertStringNotContainsString( "<ul id=\"mw-debug-html\">\n</ul>", $debugLog,
			'MWDebug::getHTMLDebugLog() should contain a non-empty debug log'
		);
	}

	public function testAppendDebugInfoToApiResultXmlFormat() {
		$request = $this->newApiRequest(
			[ 'action' => 'help', 'format' => 'xml' ],
			'/api.php?action=help&format=xml'
		);

		$context = new RequestContext();
		$context->setRequest( $request );

		$result = new ApiResult( false );

		MWDebug::appendDebugInfoToApiResult( $context, $result );

		$this->assertInstanceOf( ApiResult::class, $result );
		$data = $result->getResultData();

		$expectedKeys = [ 'mwVersion', 'phpEngine', 'phpVersion', 'gitRevision', 'gitBranch',
			'gitViewUrl', 'time', 'log', 'debugLog', 'queries', 'request', 'memory',
			'memoryPeak', 'includes', '_element' ];

		foreach ( $expectedKeys as $expectedKey ) {
			$this->assertArrayHasKey( $expectedKey, $data['debuginfo'], "debuginfo has $expectedKey" );
		}

		$xml = ApiFormatXml::recXmlPrint( 'help', $data, null );

		// exception not thrown
		$this->assertIsString( $xml );
	}

	/**
	 * @param string[] $params
	 * @param string $requestUrl
	 * @return FauxRequest
	 */
	private function newApiRequest( array $params, $requestUrl ) {
		$req = new FauxRequest( $params );
		$req->setRequestURL( $requestUrl );
		return $req;
	}
}
PK       ! |=]%  ]%    debug/DeprecationHelperTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Debug\DeprecationHelper
 */
class DeprecationHelperTest extends MediaWikiIntegrationTestCase {

	/** @var TestDeprecatedClass */
	private $testClass;

	/** @var TestDeprecatedSubclass */
	private $testSubclass;

	protected function setUp(): void {
		parent::setUp();
		$this->testClass = new TestDeprecatedClass();
		$this->testSubclass = new TestDeprecatedSubclass();
		$this->overrideConfigValue( MainConfigNames::DevelopmentWarnings, false );
	}

	/**
	 * @dataProvider provideGet
	 */
	public function testGet( $propName, $expectedLevel, $expectedMessage ) {
		if ( $expectedLevel ) {
			$this->assertErrorTriggered( function () use ( $propName ) {
				$this->assertSame( null, $this->testClass->$propName );
			}, $expectedLevel, $expectedMessage );
		} else {
			$this->assertDeprecationWarningIssued( function () use ( $propName ) {
				$expectedValue = $propName === 'fallbackDeprecatedMethodName' ? 'FOO' : 1;
				$this->assertSame( $expectedValue, $this->testClass->$propName );
			}, $expectedMessage );
		}
	}

	public static function provideGet() {
		return [
			[ 'protectedDeprecated', null,
				'Use of TestDeprecatedClass::$protectedDeprecated was deprecated in MediaWiki 1.23. ' .
					'[Called from DeprecationHelperTest::{closure}' ],
			[ 'privateDeprecated', null,
				'Use of TestDeprecatedClass::$privateDeprecated was deprecated in MediaWiki 1.24. ' .
					'[Called from DeprecationHelperTest::{closure}' ],
			[ 'fallbackDeprecated', null,
				'Use of TestDeprecatedClass::$fallbackDeprecated was deprecated in MediaWiki 1.25. ' .
					'[Called from DeprecationHelperTest::{closure}' ],
			[ 'fallbackDeprecatedMethodName', null,
				'Use of TestDeprecatedClass::$fallbackDeprecatedMethodName was deprecated in MediaWiki 1.26. ' .
					'[Called from DeprecationHelperTest::{closure}' ],
			[ 'fallbackGetterOnly', null,
				'Use of TestDeprecatedClass::$fallbackGetterOnly was deprecated in MediaWiki 1.25. ' .
					'[Called from DeprecationHelperTest::{closure}' ],
			[ 'protectedNonDeprecated', E_USER_ERROR,
				'Cannot access non-public property TestDeprecatedClass::$protectedNonDeprecated' ],
			[ 'privateNonDeprecated', E_USER_ERROR,
				'Cannot access non-public property TestDeprecatedClass::$privateNonDeprecated' ],
			[ 'nonExistent', E_USER_NOTICE, 'Undefined property: TestDeprecatedClass::$nonExistent' ],
		];
	}

	public function testDeprecateDynamicPropertyAccess() {
		$testObject = new class extends TestDeprecatedClass {
			public function __construct() {
				parent::__construct();
				$this->deprecateDynamicPropertiesAccess( '1.23' );
			}
		};
		$this->assertDeprecationWarningIssued(
			static function () use ( $testObject ) {
				$testObject->dynamic_property = 'bla';
			},
			'Use of TestDeprecatedClass::$dynamic_property was deprecated in MediaWiki 1.23. ' .
				'[Called from DeprecationHelperTest::{closure}'
		);
	}

	public function testDynamicPropertyNullCoalesce() {
		$testObject = new TestDeprecatedClass();
		$this->assertSame( 'bla', $testObject->dynamic_property ?? 'bla' );
	}

	public function testDynamicPropertyNullCoalesceDeprecated() {
		$testObject = new class extends TestDeprecatedClass {
			public function __construct() {
				parent::__construct();
				$this->deprecateDynamicPropertiesAccess( '1.23' );
			}
		};
		$this->assertDeprecationWarningIssued(
			function () use ( $testObject ) {
				$this->assertSame( 'bla', $testObject->dynamic_property ?? 'bla' );
			},
			'Use of TestDeprecatedClass::$dynamic_property was deprecated in MediaWiki 1.23. ' .
				'[Called from DeprecationHelperTest::{closure}'
		);
	}

	public function testDynamicPropertyOnMockObject() {
		$testObject = $this->getMockBuilder( TestDeprecatedClass::class )
			->enableProxyingToOriginalMethods()
			->getMock();
		$testObject->blabla = 'test';
		$this->assertSame( 'test', $testObject->blabla );
	}

	/**
	 * @dataProvider provideSet
	 */
	public function testSet( $propName, $expectedLevel, $expectedMessage, $expectedValue = 0 ) {
		$this->assertPropertySame( 1, $this->testClass, $propName );
		if ( $expectedLevel ) {
			$this->assertErrorTriggered( function () use ( $propName ) {
				$this->testClass->$propName = 0;
				$this->assertPropertySame( 1, $this->testClass, $propName );
			}, $expectedLevel, $expectedMessage );
		} else {
			if ( $propName === 'nonExistent' ) {
				$this->testClass->$propName = 0;
			} else {
				$this->assertDeprecationWarningIssued( function () use ( $propName ) {
					$this->testClass->$propName = 0;
				}, $expectedMessage );
			}
			$this->assertPropertySame( 0, $this->testClass, $propName );
		}

		$this->assertPropertySame( $expectedValue, $this->testClass, $propName );
	}

	public static function provideSet() {
		return [
			[ 'protectedDeprecated', null,
				'Use of TestDeprecatedClass::$protectedDeprecated was deprecated in MediaWiki 1.23. ' .
					'[Called from DeprecationHelperTest::{closure}' ],
			[ 'privateDeprecated', null,
				'Use of TestDeprecatedClass::$privateDeprecated was deprecated in MediaWiki 1.24. ' .
					'[Called from DeprecationHelperTest::{closure}' ],
			[ 'fallbackDeprecated', null,
				'Use of TestDeprecatedClass::$fallbackDeprecated was deprecated in MediaWiki 1.25. ' .
					'[Called from DeprecationHelperTest::{closure}' ],
			[ 'fallbackDeprecatedMethodName', null,
				'Use of TestDeprecatedClass::$fallbackDeprecatedMethodName was deprecated in MediaWiki 1.26. ' .
					'[Called from DeprecationHelperTest::{closure}' ],
			[ 'fallbackGetterOnly', E_USER_ERROR,
				'Cannot access non-public property TestDeprecatedClass::$fallbackGetterOnly' ],
			[ 'protectedNonDeprecated', E_USER_ERROR,
				'Cannot access non-public property TestDeprecatedClass::$protectedNonDeprecated', 1 ],
			[ 'privateNonDeprecated', E_USER_ERROR,
				'Cannot access non-public property TestDeprecatedClass::$privateNonDeprecated', 1 ],
			[ 'nonExistent', null,
				'Use of TestDeprecatedClass::$nonExistent was deprecated in MediaWiki 1.23. ' .
					'[Called from DeprecationHelperTest::{closure}' ],
		];
	}

	public function testInternalGet() {
		$this->assertSame( [
			'prod' => 1,
			'prond' => 1,
			'prid' => 1,
			'prind' => 1,
		], $this->testClass->getThings() );
	}

	public function testInternalSet() {
		$this->testClass->setThings( 2, 2, 2, 2 );
		$wrapper = TestingAccessWrapper::newFromObject( $this->testClass );
		$this->assertSame( 2, $wrapper->protectedDeprecated );
		$this->assertSame( 2, $wrapper->protectedNonDeprecated );
		$this->assertSame( 2, $wrapper->privateDeprecated );
		$this->assertSame( 2, $wrapper->privateNonDeprecated );
	}

	public function testSubclassGetSet() {
		$fullName = 'TestDeprecatedClass::$privateNonDeprecated';
		$this->assertErrorTriggered( function () {
			$this->assertSame( null, $this->testSubclass->getNondeprecatedPrivateParentProperty() );
		}, E_USER_ERROR, "Cannot access non-public property $fullName" );
		$this->assertErrorTriggered( function () {
			$this->testSubclass->setNondeprecatedPrivateParentProperty( 0 );
			$wrapper = TestingAccessWrapper::newFromObject( $this->testSubclass );
			$this->assertSame( 1, $wrapper->privateNonDeprecated );
		}, E_USER_ERROR, "Cannot access non-public property $fullName" );

		$fullName = 'TestDeprecatedSubclass::$subclassPrivateNondeprecated';
		$this->assertErrorTriggered( function () {
			$this->assertSame( null, $this->testSubclass->subclassPrivateNondeprecated );
		}, E_USER_ERROR, "Cannot access non-public property $fullName" );
		$this->assertErrorTriggered( function () {
			$this->testSubclass->subclassPrivateNondeprecated = 0;
			$wrapper = TestingAccessWrapper::newFromObject( $this->testSubclass );
			$this->assertSame( 1, $wrapper->subclassPrivateNondeprecated );
		}, E_USER_ERROR, "Cannot access non-public property $fullName" );
	}

	protected function assertErrorTriggered( callable $callback, $level, $message ) {
		$actualLevel = $actualMessage = null;
		set_error_handler( static function ( $errorCode, $errorStr ) use ( &$actualLevel, &$actualMessage ) {
			$actualLevel = $errorCode;
			$actualMessage = $errorStr;
		} );
		$callback();
		restore_error_handler();
		$this->assertSame( $level, $actualLevel );
		$this->assertSame( $message, $actualMessage );
	}

	protected function assertPropertySame( $expected, $object, $propName ) {
		try {
			$this->assertSame( $expected, TestingAccessWrapper::newFromObject( $object )->$propName );
		} catch ( ReflectionException $e ) {
			if ( !preg_match( "/Property (TestDeprecated(Class|Subclass)::\\$?)?$propName does not exist/",
				$e->getMessage() )
			) {
				throw $e;
			}
			// property_exists accepts monkey-patching, Reflection / TestingAccessWrapper doesn't
			if ( property_exists( $object, $propName ) ) {
				$this->assertSame( $expected, $object->$propName );
			}
		}
	}

	protected function assertDeprecationWarningIssued( callable $callback, string $expectedMessage ) {
		$this->expectDeprecationAndContinue( '/' . preg_quote( $expectedMessage, '/' ) . '/' );
		$callback();
	}

	/**
	 * Test bad MW version values to throw exceptions as expected
	 *
	 * @dataProvider provideBadMWVersion
	 */
	public function testBadMWVersion( $version, $expected ) {
		$this->expectException( $expected );

		wfDeprecated( __METHOD__, $version );
	}

	public static function provideBadMWVersion() {
		return [
			[ 1, Exception::class ],
			[ 1.33, Exception::class ],
			[ true, Exception::class ],
			[ null, Exception::class ]
		];
	}
}
PK       ! =)|  |  !  debug/logger/LegacyLoggerTest.phpnu Iw        <?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
 */

namespace MediaWiki\Tests\Logger;

use MediaWiki\Logger\LegacyLogger;
use MediaWiki\MainConfigNames;
use MediaWikiIntegrationTestCase;
use Psr\Log\LogLevel;

class LegacyLoggerTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers \MediaWiki\Logger\LegacyLogger::interpolate
	 * @dataProvider provideInterpolate
	 */
	public function testInterpolate( $message, $context, $expect ) {
		$this->assertEquals(
			$expect, LegacyLogger::interpolate( $message, $context ) );
	}

	public static function provideInterpolate() {
		$e = new \Exception( 'boom!' );
		$d = new \DateTime();
		$err = new \Error( 'Test error' );
		return [
			[
				'no-op',
				[],
				'no-op',
			],
			[
				'Hello {world}!',
				[
					'world' => 'World',
				],
				'Hello World!',
			],
			[
				'{greeting} {user}',
				[
					'greeting' => 'Goodnight',
					'user' => 'Moon',
				],
				'Goodnight Moon',
			],
			[
				'Oops {key_not_set}',
				[],
				'Oops {key_not_set}',
			],
			[
				'{ not interpolated }',
				[
					'not interpolated' => 'This should NOT show up in the message',
				],
				'{ not interpolated }',
			],
			[
				'{null}',
				[
					'null' => null,
				],
				'[Null]',
			],
			[
				'{bool}',
				[
					'bool' => true,
				],
				'true',
			],
			[
				'{float}',
				[
					'float' => 1.23,
				],
				'1.23',
			],
			[
				'{array}',
				[
					'array' => [ 1, 2, 3 ],
				],
				'[Array(3)]',
			],
			[
				'{exception}',
				[
					'exception' => $e,
				],
				'[Exception ' . get_class( $e ) . '( ' .
				$e->getFile() . ':' . $e->getLine() . ') ' .
				$e->getMessage() . ']',
			],
			[
				'{datetime}',
				[
					'datetime' => $d,
				],
				$d->format( 'c' ),
			],
			[
				'{object}',
				[
					'object' => new \stdClass,
				],
				'[Object stdClass]',
			],
			[
				'{exception}',
				[
					'exception' => $err,
				],
				'[Error ' . get_class( $err ) . '( ' .
					$err->getFile() . ':' . $err->getLine() . ') ' .
					$err->getMessage() . ']',
			],
		];
	}

	/**
	 * @covers \MediaWiki\Logger\LegacyLogger::shouldEmit
	 * @dataProvider provideShouldEmit
	 */
	public function testShouldEmit( $level, $config, $expected ) {
		$this->overrideConfigValue( MainConfigNames::DebugLogGroups, [ 'fakechannel' => $config ] );
		$this->assertEquals(
			$expected,
			LegacyLogger::shouldEmit( 'fakechannel', 'some message', $level, [] )
		);
	}

	public static function provideShouldEmit() {
		$dest = [ 'destination' => 'foobar' ];
		$tests = [
			[
				LogLevel::DEBUG,
				$dest,
				true
			],
			[
				LogLevel::WARNING,
				$dest + [ 'level' => LogLevel::INFO ],
				true,
			],
			[
				LogLevel::INFO,
				$dest + [ 'level' => LogLevel::CRITICAL ],
				false,
			],
			[
				\Monolog\Logger::INFO,
				$dest + [ 'level' => LogLevel::INFO ],
				true,
			],
			[
				\Monolog\Logger::WARNING,
				$dest + [ 'level' => LogLevel::EMERGENCY ],
				false,
			]
		];

		return $tests;
	}

}
PK       !        debug/TestDeprecatedSubclass.phpnu Iw        <?php

class TestDeprecatedSubclass extends TestDeprecatedClass {

	/** @var int */
	private $subclassPrivateNondeprecated = 1;

	public function getDeprecatedPrivateParentProperty() {
		return $this->privateDeprecated;
	}

	public function setDeprecatedPrivateParentProperty( $value ) {
		$this->privateDeprecated = $value;
	}

	public function getNondeprecatedPrivateParentProperty() {
		return $this->privateNonDeprecated;
	}

	public function setNondeprecatedPrivateParentProperty( $value ) {
		$this->privateNonDeprecated = $value;
	}

}
PK       ! 1f      export/ExportTest.phpnu Iw        <?php

use MediaWiki\Content\Content;
use MediaWiki\Content\ContentHandler;
use MediaWiki\Content\Renderer\ContentParseParams;
use MediaWiki\Content\TextContent;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Parser\ParserOutput;

/**
 * Test class for Export methods.
 *
 * @group Database
 *
 * @author Isaac Hutt <mhutti1@gmail.com>
 */
class ExportTest extends MediaWikiLangTestCase {

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValue( MainConfigNames::CapitalLinks, true );
	}

	/**
	 * @covers \WikiExporter::pageByTitle
	 */
	public function testPageByTitle() {
		$services = $this->getServiceContainer();

		$page = $this->getExistingTestPage();

		$xmlObject = $this->getXmlDumpForPage( $page );

		/**
		 * Check namespaces match xml
		 */
		foreach ( $xmlObject->siteinfo->namespaces->children() as $namespace ) {
			// Get the text content of the SimpleXMLElement
			$xmlNamespaces[] = (string)$namespace;
		}
		$xmlNamespaces = str_replace( ' ', '_', $xmlNamespaces );

		$actualNamespaces = (array)$services->getContentLanguage()->getNamespaces();
		$actualNamespaces = array_values( $actualNamespaces );
		$this->assertEquals( $actualNamespaces, $xmlNamespaces );

		// Check xml page title correct
		$xmlTitle = (array)$xmlObject->page->title;
		$this->assertEquals( $page->getTitle()->getPrefixedText(), $xmlTitle[0] );

		// Check xml page text is not empty
		$text = (array)$xmlObject->page->revision->text;
		$this->assertNotEquals( '', $text[0] );
	}

	/**
	 * Regression test for T328503 to verify that custom content types
	 * with a getNativeData() override that returns a non-string value export correctly.
	 *
	 * @covers \XmlDumpWriter::writeText
	 */
	public function testShouldExportContentWithNonStringNativeData(): void {
		// Make a mock ContentHandler for a Content that has a getNativeData() method
		// with a non-string return value.
		$contentModelId = 'non-string-test-content-model';
		$contentHandler = new class( $contentModelId ) extends ContentHandler {

			public function __construct( $contentModelId ) {
				parent::__construct(
					$contentModelId,
					[ CONTENT_FORMAT_TEXT ]
				);
			}

			public function serializeContent( Content $content, $format = null ) {
				return json_encode( $content->getNativeData() );
			}

			public function unserializeContent( $blob, $format = null ) {
				return $this->getTestContent( $blob );
			}

			public function makeEmptyContent() {
				return $this->getTestContent( '{}' );
			}

			protected function fillParserOutput(
				Content $content,
				ContentParseParams $cpoParams,
				ParserOutput &$output
			) {
				$output->setRawText( json_encode( $content->getNativeData() ) );
			}

			private function getTestContent( string $blob ): Content {
				return new class( $blob, $this->getModelID() ) extends TextContent {
					/** @var array */
					private $data;

					public function __construct( $text, $contentModelId ) {
						parent::__construct(
							$text,
							$contentModelId
						);

						$this->data = json_decode( $text, true );
					}

					public function getNativeData() {
						return $this->data;
					}
				};
			}
		};

		$this->setTemporaryHook(
			'ContentHandlerForModelID',
			static function (
				string $modelId,
				?ContentHandler &$handlerRef
			) use ( $contentModelId, $contentHandler ): void {
				if ( $modelId === $contentModelId ) {
					$handlerRef = $contentHandler;
				}
			}
		);

		$wikiPage = $this->getNonexistingTestPage( 'NonStringNativeDataExportTest' );

		$testText = json_encode( [ 'test' => 'data' ] );
		$content = $contentHandler->unserializeContent( $testText );

		$this->editPage( $wikiPage, $content );

		$xmlObject = $this->getXmlDumpForPage( $wikiPage );

		$this->assertSame( $contentModelId, (string)$xmlObject->page->revision->model );
		$this->assertSame( $testText, (string)$xmlObject->page->revision->text );
	}

	/**
	 * Convenience function to export the content of the given page in MediaWiki's XML dump format.
	 * @param PageIdentity $page page to export
	 * @return SimpleXMLElement root element of the generated XML
	 */
	private function getXmlDumpForPage( PageIdentity $page ): SimpleXMLElement {
		$exporter = $this->getServiceContainer()
			->getWikiExporterFactory()
			->getWikiExporter( $this->getDb(), WikiExporter::FULL );

		$sink = new DumpStringOutput();
		$exporter->setOutputSink( $sink );
		$exporter->openStream();
		$exporter->pageByTitle( $page );
		$exporter->closeStream();

		// phpcs:ignore Generic.PHP.NoSilencedErrors -- suppress deprecation per T268847
		$oldDisable = @libxml_disable_entity_loader( true );

		// This throws error if invalid xml output
		$xmlObject = simplexml_load_string( $sink );

		// phpcs:ignore Generic.PHP.NoSilencedErrors
		@libxml_disable_entity_loader( $oldDisable );

		return $xmlObject;
	}

}
PK       ! }=  =    shell/ShellTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Shell\Command;
use MediaWiki\Shell\Shell;

/**
 * @covers \MediaWiki\Shell\Shell
 * @group Shell
 */
class ShellTest extends MediaWikiIntegrationTestCase {

	public function testIsDisabled() {
		$this->assertIsBool( Shell::isDisabled() );
	}

	/**
	 * @dataProvider provideEscape
	 */
	public function testEscape( $args, $expected ) {
		if ( wfIsWindows() ) {
			$this->markTestSkipped( 'This test requires a POSIX environment.' );
		}
		$this->assertSame( $expected, Shell::escape( ...$args ) );
	}

	public static function provideEscape() {
		return [
			'simple' => [ [ 'true' ], "'true'" ],
			'with args' => [ [ 'convert', '-font', 'font name' ], "'convert' '-font' 'font name'" ],
			'array' => [ [ [ 'convert', '-font', 'font name' ] ], "'convert' '-font' 'font name'" ],
			'skip nulls' => [ [ 'ls', null ], "'ls'" ],
		];
	}

	/**
	 * @covers \MediaWiki\Shell\Shell::makeScriptCommand
	 * @dataProvider provideMakeScriptCommand
	 *
	 * @param string $expected expected in POSIX
	 * @param string $expectedWin expected in Windows
	 * @param string $script
	 * @param string[] $parameters
	 * @param string[] $options
	 * @param callable|null $hook
	 */
	public function testMakeScriptCommand(
		$expected,
		$expectedWin,
		$script,
		$parameters,
		$options = [],
		$hook = null
	) {
		// Running tests under Vagrant involves MWMultiVersion that uses the below hook
		$this->overrideConfigValue( MainConfigNames::Hooks, [] );

		if ( $hook ) {
			$this->setTemporaryHook( 'wfShellWikiCmd', $hook );
		}

		$command = Shell::makeScriptCommand( $script, $parameters, $options );
		$command->params( 'safe' )
			->unsafeParams( 'unsafe' );

		$this->assertInstanceOf( Command::class, $command );

		if ( wfIsWindows() ) {
			$this->assertEquals( $expectedWin, $command->getCommandString() );
		} else {
			$this->assertEquals( $expected, $command->getCommandString() );
		}
		$this->assertSame( [], $command->getDisallowedPaths() );
	}

	public static function provideMakeScriptCommand() {
		global $wgPhpCli;

		$IP = MW_INSTALL_PATH;

		return [
			'no option' => [
				"'$wgPhpCli' '$IP/maintenance/run.php' '$IP/maintenance/foobar.php' 'bar'\\''\"baz' 'safe' unsafe",
				"\"$wgPhpCli\" \"$IP/maintenance/run.php\" \"$IP/maintenance/foobar.php\" \"bar'\\\"baz\" \"safe\" unsafe",
				"$IP/maintenance/foobar.php",
				[ 'bar\'"baz' ],
			],
			'hook' => [
				"'$wgPhpCli' '$IP/maintenance/run.php' 'changed.php' '--wiki=somewiki' 'bar'\\''\"baz' 'safe' unsafe",
				"\"$wgPhpCli\" \"$IP/maintenance/run.php\" \"changed.php\" \"--wiki=somewiki\" \"bar'\\\"baz\" \"safe\" unsafe",
				'maintenance/foobar.php',
				[ 'bar\'"baz' ],
				[],
				static function ( &$script, array &$parameters ) {
					$script = 'changed.php';
					array_unshift( $parameters, '--wiki=somewiki' );
				}
			],
			'php option' => [
				"'/bin/perl' '$IP/maintenance/run.php' 'maintenance/foobar.php'  'safe' unsafe",
				"\"/bin/perl\" \"$IP/maintenance/run.php\" \"maintenance/foobar.php\"  \"safe\" unsafe",
				"maintenance/foobar.php",
				[],
				[ 'php' => '/bin/perl' ],
			],
			'wrapper option' => [
				"'$wgPhpCli' 'foobinize' 'maintenance/foobar.php'  'safe' unsafe",
				"\"$wgPhpCli\" \"foobinize\" \"maintenance/foobar.php\"  \"safe\" unsafe",
				"maintenance/foobar.php",
				[],
				[ 'wrapper' => 'foobinize' ],
			],
		];
	}

}
PK       ! y'[z  z    shell/CommandTest.phpnu Iw        <?php

use MediaWiki\Shell\Command;
use MediaWiki\Shell\Shell;
use Shellbox\Shellbox;

/**
 * @covers \MediaWiki\Shell\Command
 * @group Shell
 */
class CommandTest extends PHPUnit\Framework\TestCase {

	use MediaWikiCoversValidator;
	use MediaWikiTestCaseTrait;

	private function requirePosix() {
		if ( wfIsWindows() ) {
			$this->markTestSkipped( 'This test requires a POSIX environment.' );
		}
	}

	private function createCommand() {
		return new Command( Shellbox::createUnboxedExecutor() );
	}

	/**
	 * @dataProvider provideExecute
	 */
	public function testExecute( $command, $args, $expectedExitCode, $expectedOutput ) {
		$command = $this->getPhpCommand( $command );
		$result = $command
			->params( $args )
			->execute();

		$this->assertSame( $expectedExitCode, $result->getExitCode() );
		$this->assertSame( $expectedOutput, $result->getStdout() );
	}

	public static function provideExecute() {
		return [
			'success status' => [ 'success_status.php', [], 0, '' ],
			'failure status' => [ 'failure_status.php', [], 1, '' ],
			'output' => [ 'echo_args.php', [ 'x', '>', 'y' ], 0, 'x > y' ],
		];
	}

	public function testEnvironment() {
		$command = $this->getPhpCommand( 'echo_env.php' );
		$result = $command
			->params( [ 'FOO' ] )
			->environment( [ 'FOO' => 'bar' ] )
			->execute();
		$this->assertSame( "bar", $result->getStdout() );
	}

	public function testStdout() {
		$command = $this->getPhpCommand( 'echo_args.php' );

		$result = $command
			->unsafeParams( 'ThisIsStderr', '1>&2' )
			->execute();

		$this->assertStringNotContainsString( 'ThisIsStderr', $result->getStdout() );
		$this->assertEquals( "ThisIsStderr", $result->getStderr() );
	}

	public function testStdoutRedirection() {
		// The double redirection doesn't work on Windows
		$this->requirePosix();

		$command = $this->createCommand();

		$result = $command
			->params( 'bash', '-c', 'echo ThisIsStderr 1>&2' )
			->includeStderr( true )
			->execute();

		$this->assertEquals( "ThisIsStderr\n", $result->getStdout() );
		$this->assertSame( '', $result->getStderr() );
	}

	public function testOutput() {
		$command = $this->getPhpCommand(
			'stdout_stderr.php',
			[ 'correct stdout' ]
		);
		$result = $command->execute();
		$this->assertSame( 'correct stdout', $result->getStdout() );
		$this->assertSame( '', $result->getStderr() );

		$command = $this->getPhpCommand(
			'stdout_stderr.php',
			[ 'correct stdout ', 'correct stderr ' ]
		);
		$result = $command
			->includeStderr()
			->execute();
		$this->assertMatchesRegularExpression( '/correct stdout/m', $result->getStdout() );
		$this->assertMatchesRegularExpression( '/correct stderr/m', $result->getStdout() );
		$this->assertSame( '', $result->getStderr() );

		$command = $this->getPhpCommand(
			'stdout_stderr.php',
			[ 'correct stdout', 'correct stderr' ]
		);
		$result = $command
			->execute();
		$this->assertSame( 'correct stdout', $result->getStdout() );
		$this->assertSame( 'correct stderr', $result->getStderr() );
	}

	/**
	 * Test that null values are skipped by params() and unsafeParams()
	 */
	public function testNullsAreSkipped() {
		$command = $this->createCommand();
		$command->params( 'echo', 'a', null, 'b' );
		$command->unsafeParams( 'c', null, 'd' );

		if ( wfIsWindows() ) {
			$this->assertEquals( '"echo" "a" "b" c d', $command->getCommandString() );
		} else {
			$this->assertEquals( "'echo' 'a' 'b' c d", $command->getCommandString() );
		}
	}

	public function testT69870() {
		// Testing for Bug T69870
		//     wfShellExec() cuts off stdout at multiples of 8192 bytes.

		// hangs on Windows, see Bug T199989, non-blocking pipes
		$this->requirePosix();

		// Test several times because it involves a race condition that may randomly succeed or fail
		for ( $i = 0; $i < 10; $i++ ) {
			$command = $this->getPhpCommand( 'echo_333333_stars.php' );
			$output = $command
				->execute()
				->getStdout();
			$this->assertEquals( 333333, strlen( $output ) );
		}
	}

	public function testLogStderr() {
		$logger = new TestLogger( true, static function ( $message, $level, $context ) {
			return $level === Psr\Log\LogLevel::ERROR ? '1' : null;
		}, true );
		$command = $this->getPhpCommand( 'echo_args.php' );
		$command->setLogger( $logger );
		$command->unsafeParams( 'ThisIsStderr', '1>&2' );
		$command->execute();
		$this->assertSame( [], $logger->getBuffer() );

		$command = $this->getPhpCommand( 'echo_args.php' );
		$command->setLogger( $logger );
		$command->logStderr();
		$command->unsafeParams( 'ThisIsStderr', '1>&2' );
		$command->execute();
		$this->assertCount( 1, $logger->getBuffer() );
		$this->assertSame( 'ThisIsStderr', trim( $logger->getBuffer()[0][2]['error'] ) );
	}

	public function testInput() {
		// hangs on Windows, see Bug T199989, non-blocking pipes
		$this->requirePosix();

		$command = $this->getPhpCommand( 'echo_stdin.php' );
		$command->input( 'abc' );
		$result = $command->execute();
		$this->assertSame( 'abc', $result->getStdout() );

		// now try it with something that does not fit into a single block
		$command = $this->getPhpCommand( 'echo_stdin.php' );
		$command->input( str_repeat( '!', 1000000 ) );
		$result = $command->execute();
		$this->assertSame( 1000000, strlen( $result->getStdout() ) );

		// And try it with empty input
		$command = $this->getPhpCommand( 'echo_stdin.php' );
		$command->input( '' );
		$result = $command->execute();
		$this->assertSame( '', $result->getStdout() );
	}

	/**
	 * Ensure that it's possible to disable the default shell restrictions
	 * @see T257278
	 */
	public function testDisablingRestrictions() {
		$command = $this->createCommand();
		// As CommandFactory does for the firejail case:
		$command->restrict( Shell::RESTRICT_DEFAULT );
		// Disable restrictions
		$command->restrict( Shell::RESTRICT_NONE );
		$this->assertFalse( $command->getPrivateUserNamespace() );
		$this->assertFalse( $command->getFirejailDefaultSeccomp() );
		$this->assertFalse( $command->getNoNewPrivs() );
		$this->assertFalse( $command->getPrivateDev() );
		$this->assertFalse( $command->getDisableNetwork() );
		$this->assertSame( [], $command->getDisabledSyscalls() );
		$this->assertTrue( $command->getDisableSandbox() );
	}

	/**
	 * Creates a command that will execute one of the PHP test scripts by its
	 * file name, using the current PHP_BIN binary.
	 *
	 * NOTE: the PHP test scripts are located in the sub directory
	 * "bin".
	 *
	 * @param string $fileName a file name in the "bin" sub-directory
	 * @param array $args an array of arguments to pass to the PHP script
	 *
	 * @return Command a command instance pointing to the right script
	 */
	private function getPhpCommand( $fileName, array $args = [] ) {
		$command = new Command( Shellbox::createUnboxedExecutor() );
		$params = [
			PHP_BINARY,
			__DIR__
				. DIRECTORY_SEPARATOR
				. 'bin'
				. DIRECTORY_SEPARATOR
				. $fileName
		];
		$params = array_merge( $params, $args );

		$command->params( $params );
		$command->limits( [ 'memory' => 0 ] );
		return $command;
	}
}
PK       !         shell/bin/success_status.phpnu Iw        <?php
PK       ! ^$        shell/bin/stdout_stderr.phpnu Iw        <?php

if ( PHP_SAPI !== 'cli' ) {
	exit( 1 );
}

file_put_contents( "php://stdout", $argv[1] );
if ( isset( $argv[2] ) ) {
	file_put_contents( "php://stderr", $argv[2] );
}
PK       ! = R   R     shell/bin/echo_333333_stars.phpnu Iw        <?php

if ( PHP_SAPI !== 'cli' ) {
	exit( 1 );
}

echo str_repeat( '*', 333333 );
PK       ! [        shell/bin/echo_args.phpnu Iw        <?php

if ( PHP_SAPI !== 'cli' ) {
	exit( 1 );
}

for ( $i = 1; $i < count( $argv ); $i++ ) {
	fprintf( STDOUT, "%s", $argv[$i] );

	if ( $i + 1 < count( $argv ) ) {
		fprintf( STDOUT, " " );
	}
}
PK       ! w+        shell/bin/echo_env.phpnu Iw        <?php

if ( PHP_SAPI !== 'cli' ) {
	exit( 1 );
}

for ( $i = 1; $i < count( $argv ); $i++ ) {
	fprintf( STDOUT, "%s", getenv( $argv[$i] ) );

	if ( $i + 1 < count( $argv ) ) {
		fprintf( STDOUT, "\n" );
	}
}
PK       ! M        shell/bin/failure_status.phpnu Iw        <?php

exit( 1 );
PK       ! :Pp   p     shell/bin/echo_stdin.phpnu Iw        <?php

if ( PHP_SAPI !== 'cli' ) {
	exit( 1 );
}

$input_data = stream_get_contents( STDIN );
echo $input_data;
PK       ! [a  a  '  ParamValidator/TypeDef/TitleDefTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ParamValidator\TypeDef;

use MediaWiki\MainConfigNames;
use MediaWiki\ParamValidator\TypeDef\TitleDef;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use Wikimedia\Message\DataMessageValue;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\SimpleCallbacks;
use Wikimedia\ParamValidator\ValidationException;

/**
 * @covers \MediaWiki\ParamValidator\TypeDef\TitleDef
 * @group Database
 */
class TitleDefTest extends TypeDefIntegrationTestCase {
	protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'en' );
		return new TitleDef(
			$callbacks,
			$this->getServiceContainer()->getTitleFactory()
		);
	}

	/**
	 * @inheritDoc
	 * @dataProvider provideValidate
	 */
	public function testValidate(
		$value, $expect, array $settings = [], array $options = [], array $expectConds = []
	) {
		if ( $this->dataName() === 'must exist (success)' ) {
			$status = $this->editPage( Title::makeTitle( NS_MAIN, 'Exists' ), 'exists' );
			$this->assertTrue( $status->isOK() );
		}
		parent::testValidate( $value, $expect, $settings, $options, $expectConds );
	}

	public function provideValidate() {
		return [
			'plain' => [
				'value' => 'Foo',
				'expect' => 'Foo',
				'settings' => [],
			],
			'normalization' => [
				'value' => 'foo_bar',
				'expect' => 'Foo bar',
				'settings' => [],
			],
			'bad title' => [
				'value' => '<script>',
				'expect' => $this->getValidationException( 'badtitle', '<script>' ),
				'settings' => [],
			],
			'as object' => [
				'value' => 'Foo',
				'expect' => new TitleValue( NS_MAIN, 'Foo' ),
				'settings' => [ TitleDef::PARAM_RETURN_OBJECT => true ],
			],
			'as object, with namespace' => [
				'value' => 'User:Foo',
				'expect' => new TitleValue( NS_USER, 'Foo' ),
				'settings' => [ TitleDef::PARAM_RETURN_OBJECT => true ],
			],
			'object normalization' => [
				'value' => 'foo_bar',
				'expect' => new TitleValue( NS_MAIN, 'Foo bar' ),
				'settings' => [ TitleDef::PARAM_RETURN_OBJECT => true ],
			],
			'must exist (success)' => [
				'value' => 'Exists',
				'expect' => 'Exists',
				'settings' => [ TitleDef::PARAM_MUST_EXIST => true ],
			],
			'must exist (failure)' => [
				'value' => 'does not exist',
				'expect' => $this->getValidationException( 'missingtitle', 'does not exist',
					[ TitleDef::PARAM_MUST_EXIST => true ] ),
				'settings' => [ TitleDef::PARAM_MUST_EXIST => true ],
			],
			'Not a string' => [
				[ 1, 2, 3 ],
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-needstring', [], 'needstring' ),
					'test', '', []
				)
			],
		];
	}

	public function provideStringifyValue() {
		return [
			// Underscore-to-space conversion not happening here but later in validate().
			'String' => [ 'User:John_Doe', 'User:John_Doe' ],
			'TitleValue' => [ new TitleValue( NS_USER, 'John_Doe' ), 'User:John Doe' ],
			'Title' => [ Title::makeTitle( NS_USER, 'John_Doe' ), 'User:John Doe' ],
		];
	}

	public function provideCheckSettings() {
		// checkSettings() is itself used in tests. Testing it is a waste of time,
		// just provide the minimum required.
		return [
			'Basic test' => [ [], self::STDRET, array_merge_recursive( self::STDRET, [
				'allowedKeys' => [ TitleDef::PARAM_MUST_EXIST, TitleDef::PARAM_RETURN_OBJECT ],
			] ) ],
		];
	}

	public function provideGetInfo() {
		return [
			'no mustExist' => [
				'settings' => [],
				'expectParamInfo' => [ 'mustExist' => false ],
				'expectHelpInfo' => [
					ParamValidator::PARAM_TYPE =>
						'<message key="paramvalidator-help-type-title"></message>',
					TitleDef::PARAM_MUST_EXIST =>
						'<message key="paramvalidator-help-type-title-no-must-exist"></message>'
				],
			],
			'mustExist' => [
				'settings' => [ TitleDef::PARAM_MUST_EXIST => true ],
				'expectParamInfo' => [ 'mustExist' => true ],
				'expectHelpInfo' => [
					ParamValidator::PARAM_TYPE =>
						'<message key="paramvalidator-help-type-title"></message>',
					TitleDef::PARAM_MUST_EXIST =>
						'<message key="paramvalidator-help-type-title-must-exist"></message>'
				],
			],
		];
	}

}
PK       ! O    5  ParamValidator/TypeDef/TypeDefIntegrationTestCase.phpnu Iw        <?php

namespace MediaWiki\Tests\ParamValidator\TypeDef;

use MediaWikiIntegrationTestCase;
use Wikimedia\Tests\ParamValidator\TypeDef\TypeDefTestCaseTrait;

abstract class TypeDefIntegrationTestCase extends MediaWikiIntegrationTestCase {
	use TypeDefTestCaseTrait;

	/** Standard "$ret" array for provideCheckSettings */
	protected const STDRET =
		[ 'issues' => [ 'X' ], 'allowedKeys' => [ 'Y' ], 'messages' => [] ];
}
PK       ! dEqt  t  &  ParamValidator/TypeDef/TagsDefTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ParamValidator\TypeDef;

use MediaWiki\ChangeTags\ChangeTagsStore;
use MediaWiki\ParamValidator\TypeDef\TagsDef;
use MediaWikiIntegrationTestCase;
use Wikimedia\Message\DataMessageValue;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\SimpleCallbacks;
use Wikimedia\ParamValidator\ValidationException;

/**
 * @group Database
 * @covers \MediaWiki\ParamValidator\TypeDef\TagsDef
 */
class TagsDefTest extends MediaWikiIntegrationTestCase {

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

		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'tag1' );
		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'tag2' );

		// Since the type def shouldn't care about the specific user,
		// remove the right from relevant groups to ensure that it's not
		// checking.
		$this->setGroupPermissions( [
			'*' => [ 'applychangetags' => false ],
			'user' => [ 'applychangetags' => false ],
		] );
	}

	/**
	 * @dataProvider provideValidate
	 * @param mixed $value Value for getCallbacks()
	 * @param mixed|ValidationException $expect Expected result from TypeDef::validate().
	 *  If a ValidationException, it is expected that a ValidationException
	 *  with matching failure code and data will be thrown. Otherwise, the return value must be equal.
	 * @param array $settings Settings array.
	 * @param array $options Options array
	 * @param array[] $expectConds Expected conditions reported. Each array is
	 *  `[ $ex->getFailureCode() ] + $ex->getFailureData()`.
	 */
	public function testValidate(
		$value, $expect, array $settings = [], array $options = [], array $expectConds = []
	) {
		$callbacks = new SimpleCallbacks( [ 'test' => $value ] );
		$typeDef = new TagsDef(
			$callbacks,
			$this->getServiceContainer()->getChangeTagsStore()
		);
		$settings = $typeDef->normalizeSettings( $settings );

		if ( $expect instanceof ValidationException ) {
			try {
				$v = $typeDef->getValue( 'test', $settings, $options );
				$typeDef->validate( 'test', $v, $settings, $options );
				$this->fail( 'Expected exception not thrown' );
			} catch ( ValidationException $ex ) {
				$this->assertSame(
					$expect->getFailureMessage()->getCode(),
					$ex->getFailureMessage()->getCode()
				);
				$this->assertSame(
					$expect->getFailureMessage()->getData(),
					$ex->getFailureMessage()->getData()
				);
			}
		} else {
			$v = $typeDef->getValue( 'test', $settings, $options );
			$this->assertEquals( $expect, $typeDef->validate( 'test', $v, $settings, $options ) );
		}

		$conditions = [];
		foreach ( $callbacks->getRecordedConditions() as $c ) {
			$conditions[] = [ 'code' => $c['message']->getCode(), 'data' => $c['message']->getData() ];
		}
		$this->assertSame( $expectConds, $conditions );
	}

	public static function provideValidate() {
		$settings = [
			ParamValidator::PARAM_TYPE => 'tags',
			ParamValidator::PARAM_ISMULTI => true,
		];
		$valuesList = [ 'values-list' => [ 'tag1', 'doesnotexist', 'doesnotexist2' ] ];

		return [
			'Basic' => [ 'tag1', [ 'tag1' ] ],
			'Bad tag' => [
				'doesnotexist',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badtags', [], 'badtags', [
						'disallowedtags' => [ 'doesnotexist' ],
					] ),
					'test', 'doesnotexist', []
				),
			],
			'Multi' => [ 'tag1', 'tag1', $settings, [ 'values-list' => [ 'tag1', 'tag2' ] ] ],
			'Multi with bad tag (but not the tag)' => [
				'tag1', 'tag1', $settings, $valuesList
			],
			'Multi with bad tag' => [
				'doesnotexist',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badtags', [], 'badtags', [
						'disallowedtags' => [ 'doesnotexist', 'doesnotexist2' ],
					] ),
					'test', 'doesnotexist', $settings
				),
				$settings, $valuesList
			],
			'Not a string' => [
				[ 1, 2, 3 ],
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-needstring', [], 'needstring' ),
					'test', '', []
				)
			],
		];
	}

	public function testGetEnumValues() {
		$explicitlyDefinedTags = [ 'foo', 'bar', 'baz' ];
		$changeTagsStore = $this->createNoOpMock(
			ChangeTagsStore::class,
			[ 'listExplicitlyDefinedTags' ]
		);
		$changeTagsStore->method( 'listExplicitlyDefinedTags' )
			->willReturn( $explicitlyDefinedTags );

		$typeDef = new TagsDef( new SimpleCallbacks( [] ), $changeTagsStore );
		$this->assertSame(
			$explicitlyDefinedTags,
			$typeDef->getEnumValues( 'test', [], [] )
		);
	}

}
PK       ! e    .  HookContainer/HookContainerIntegrationTest.phpnu Iw        <?php

namespace MediaWiki\Tests\HookContainer {

	use MediaWiki\Registration\ExtensionRegistry;
	use Wikimedia\ScopedCallback;

	class HookContainerIntegrationTest extends \MediaWikiIntegrationTestCase {

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 */
		public function testHookRunsWhenExtensionRegistered() {
			$extensionRegistry = ExtensionRegistry::getInstance();
			$numHandlersExecuted = 0;
			$handlers = [ 'FooHook' => [ [
				'handler' => [
					'class' => 'FooExtension\\FooExtensionHooks',
					'name' => 'FooExtension-FooHandler',
				] ] ]
			];
			$reset = $extensionRegistry->setAttributeForTest( 'Hooks', $handlers );
			$this->assertSame( 0, $numHandlersExecuted );

			$this->resetServices();
			$hookContainer = $this->getServiceContainer()->getHookContainer();
			$hookContainer->run( 'FooHook', [ &$numHandlersExecuted ] );
			$this->assertSame( 1, $numHandlersExecuted );
			ScopedCallback::consume( $reset );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer::run
		 * @covers \MediaWiki\HookContainer\HookContainer::scopedRegister
		 */
		public function testHookRunsWithMultipleMixedHandlerTypes() {
			$handlerExt = [
				'FooHook' => [
					[ 'handler' => [
						'class' => 'FooExtension\\FooExtensionHooks',
						'name' => 'FooExtension-FooHandler',
					]
					]
				]
			];
			$resetExt = ExtensionRegistry::getInstance()->setAttributeForTest( 'Hooks', $handlerExt );

			$this->resetServices();
			$hookContainer = $this->getServiceContainer()->getHookContainer();

			$numHandlersExecuted = 0;
			$reset = $hookContainer->scopedRegister( 'FooHook', static function ( &$numHandlersRun ) {
				$numHandlersRun++;
			} );
			$reset2 = $hookContainer->scopedRegister( 'FooHook', static function ( &$numHandlersRun ) {
				$numHandlersRun++;
			} );

			$hookContainer->run( 'FooHook', [ &$numHandlersExecuted ] );
			$this->assertEquals( 3, $numHandlersExecuted );

			ScopedCallback::consume( $reset );
			ScopedCallback::consume( $reset2 );
			ScopedCallback::consume( $resetExt );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer
		 */
		public function testValidServiceInjection() {
			$handler = [
				'handler' => [
					'name' => 'FooExtension-Mash',
					'class' => 'FooExtension\\ServiceHooks',
					'services' => [ 'ReadOnlyMode' ]
				],
				'extensionPath' => '/path/to/extension.json'
			];
			$hooks = [ 'Mash' => [ $handler ] ];
			$reset = ExtensionRegistry::getInstance()->setAttributeForTest( 'Hooks', $hooks );

			$this->resetServices();
			$hookContainer = $this->getServiceContainer()->getHookContainer();

			$arg = 0;
			$ret = $hookContainer->run( 'Mash', [ &$arg ] );
			$this->assertTrue( $ret );
			$this->assertSame( 1, $arg );
		}

		/**
		 * @covers \MediaWiki\HookContainer\HookContainer
		 */
		public function testInvalidServiceInjection() {
			$handler = [
				'handler' => [
					'name' => 'FooExtension-Mash',
					'class' => 'FooExtension\\ServiceHooks',
					'services' => [ 'ReadOnlyMode' ]
				],
				'extensionPath' => '/path/to/extension.json'
			];
			$hooks = [ 'Mash' => [ $handler ] ];
			$reset = ExtensionRegistry::getInstance()->setAttributeForTest( 'Hooks', $hooks );

			$this->resetServices();
			$hookContainer = $this->getServiceContainer()->getHookContainer();

			$this->expectException( \UnexpectedValueException::class );
			$arg = 0;
			$hookContainer->run( 'Mash', [ &$arg ], [ 'noServices' => true ] );
		}
	}
}

namespace FooExtension {

	class FooExtensionHooks {

		public function onFooHook( &$numHandlersRun ) {
			$numHandlersRun++;
		}
	}

	class ServiceHooks {
		public function __construct( \Wikimedia\Rdbms\ReadOnlyMode $readOnlyMode ) {
		}

		public function onMash( &$arg ) {
			$arg++;
			return true;
		}
	}

}
PK       !  \    )  Navigation/PagerNavigationBuilderTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Navigation\PagerNavigationBuilder;
use MediaWiki\Page\PageReferenceValue;

/**
 * @covers \MediaWiki\Navigation\PagerNavigationBuilder
 */
class PagerNavigationBuilderTest extends MediaWikiIntegrationTestCase {

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

		$this->overrideConfigValues( [
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
		] );
	}

	private const EXPECTED_BASIC = '<div class="mw-pager-navigation-bar">(viewprevnext: <span class="mw-prevlink">(prevn: 50)</span>, <span class="mw-nextlink">(nextn: 50)</span>, <a href="/w/index.php?title=A&amp;limit=20" class="mw-numlink">20</a>(pipe-separator)<span class="mw-numlink">50</span>(pipe-separator)<a href="/w/index.php?title=A&amp;limit=100" class="mw-numlink">100</a>(pipe-separator)<a href="/w/index.php?title=A&amp;limit=250" class="mw-numlink">250</a>(pipe-separator)<a href="/w/index.php?title=A&amp;limit=500" class="mw-numlink">500</a>)</div>';
	private const EXPECTED_OVERRIDES = '<div class="mw-pager-navigation-bar">(parentheses: <a href="/w/index.php?title=A&amp;a=a&amp;d=d" title="(m)" class="mw-firstlink">(i)</a>(pipe-separator)<a href="/w/index.php?title=A&amp;a=a&amp;e=e" title="(n)" class="mw-lastlink">(j)</a>) (viewprevnext: <a href="/w/index.php?title=A&amp;a=a&amp;b=b" rel="prev" title="(k: 1)" class="mw-prevlink">(g: 1)</a>, <a href="/w/index.php?title=A&amp;a=a&amp;c=c" rel="next" title="(l: 1)" class="mw-nextlink">(h: 1)</a>, <span class="mw-numlink">1</span>(pipe-separator)<a href="/w/index.php?title=A&amp;a=a&amp;f=2" title="(o: 2)" class="mw-numlink">2</a>)</div>';
	private const EXPECTED_NULL_LINK_QUERY = '<div class="mw-pager-navigation-bar">(parentheses: <a href="/wiki/A" class="mw-firstlink">(i)</a>(pipe-separator)<a href="/w/index.php?title=A&amp;dir=prev" class="mw-lastlink">(j)</a>) (viewprevnext: <a href="/w/index.php?title=A&amp;dir=prev&amp;offset=1" rel="prev" class="mw-prevlink">(prevn: 1)</a>, <a href="/w/index.php?title=A&amp;dir=next&amp;offset=2" rel="next" class="mw-nextlink">(nextn: 1)</a>, <span class="mw-numlink">1</span>)</div>';

	public function testBasic() {
		$navBuilder = new PagerNavigationBuilder( new MockMessageLocalizer() );
		$navBuilder->setPage( PageReferenceValue::localReference( NS_MAIN, 'A' ) );
		$this->assertEquals( self::EXPECTED_BASIC, $navBuilder->getHtml() );
	}

	public function testOverrides() {
		$navBuilder = new PagerNavigationBuilder( new MockMessageLocalizer() );
		$navBuilder
			->setPage( PageReferenceValue::localReference( NS_MAIN, 'A' ) )
			->setLinkQuery( [ 'a' => 'a' ] )
			->setPrevLinkQuery( [ 'b' => 'b' ] )
			->setNextLinkQuery( [ 'c' => 'c' ] )
			->setFirstLinkQuery( [ 'd' => 'd' ] )
			->setLastLinkQuery( [ 'e' => 'e' ] )
			->setLimitLinkQueryParam( 'f' )
			->setPrevMsg( 'g' )
			->setNextMsg( 'h' )
			->setFirstMsg( 'i' )
			->setLastMsg( 'j' )
			->setPrevTooltipMsg( 'k' )
			->setNextTooltipMsg( 'l' )
			->setFirstTooltipMsg( 'm' )
			->setLastTooltipMsg( 'n' )
			->setLimitTooltipMsg( 'o' )
			->setCurrentLimit( 1 )
			->setLimits( [ 1, 2 ] );
		$this->assertEquals( self::EXPECTED_OVERRIDES, $navBuilder->getHtml() );
	}

	public function testNullLinkQuery() {
		$navBuilder = new PagerNavigationBuilder( new MockMessageLocalizer() );
		$navBuilder
			->setPage( PageReferenceValue::localReference( NS_MAIN, 'A' ) )
			->setFirstMsg( 'i' )
			->setLastMsg( 'j' )
			->setCurrentLimit( 1 )
			->setLimits( [ 1 ] )
			// null is allowed here, and it establishes the order of parameters for the output
			->setLinkQuery( [ 'dir' => null, 'offset' => '1' ] )
			->setPrevLinkQuery( [ 'dir' => 'prev' ] )
			->setNextLinkQuery( [ 'offset' => '2', 'dir' => 'next' ] )
			// null is allowed here, and it overrides defaults from setLinkQuery()
			->setFirstLinkQuery( [ 'offset' => null ] )
			->setLastLinkQuery( [ 'offset' => null, 'dir' => 'prev' ] );
		$this->assertEquals( self::EXPECTED_NULL_LINK_QUERY, $navBuilder->getHtml() );
	}

}
PK       ! v]W  W  !  exception/UserNotLoggedInTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;

/**
 * @covers \UserNotLoggedIn
 * @author Addshore
 * @author Dreamy Jazz
 * @group Database
 */
class UserNotLoggedInTest extends MediaWikiIntegrationTestCase {

	use TempUserTestTrait;

	/** @dataProvider provideConstruction */
	public function testConstruction( $userIsTemp, $expectedReasonMsgKey ) {
		if ( $userIsTemp ) {
			$this->enableAutoCreateTempUser();
			$user = $this->getServiceContainer()->getTempUserCreator()->create( null, new FauxRequest() )->getUser();
			RequestContext::getMain()->setUser( $user );
		}
		$e = new UserNotLoggedIn();
		$this->assertEquals( 'exception-nologin', $e->title );
		$this->assertEquals( $expectedReasonMsgKey, $e->msg );
		$this->assertEquals( [], $e->params );
	}

	public static function provideConstruction() {
		return [
			'User is not a temporary account' => [ false, 'exception-nologin-text' ],
			'User is a temporary account' => [ true, 'exception-nologin-text-for-temp-user' ],
		];
	}

	public function testConstructionForTempAccountWithAlwaysRedirectToLoginPageSet() {
		$this->enableAutoCreateTempUser();
		$user = $this->getServiceContainer()->getTempUserCreator()->create( null, new FauxRequest() )->getUser();
		RequestContext::getMain()->setUser( $user );
		$e = new UserNotLoggedIn( 'exception-nologin-text', 'exception-nologin', [], true );
		$this->assertEquals( 'exception-nologin', $e->title );
		$this->assertEquals( 'exception-nologin-text', $e->msg );
		$this->assertEquals( [], $e->params );
	}

	public function testConstructionForReasonMsgWithoutTemporaryAccountEquivalent() {
		$this->enableAutoCreateTempUser();
		$user = $this->getServiceContainer()->getTempUserCreator()->create( null, new FauxRequest() )->getUser();
		RequestContext::getMain()->setUser( $user );
		$e = new UserNotLoggedIn( 'changeemail-no-info' );
		$this->assertEquals( 'changeemail-no-info', $e->msg );
	}

	/** @dataProvider provideTemporaryAccountsEnabled */
	public function testReportForRedirectToLoginPage( $temporaryAccountsEnabled ) {
		if ( $temporaryAccountsEnabled ) {
			$this->enableAutoCreateTempUser();
		} else {
			$this->disableAutoCreateTempUser();
		}
		RequestContext::getMain()->setTitle( Title::newFromText( 'Preferences', NS_SPECIAL ) );
		$e = new UserNotLoggedIn();
		$e->report();
		$redirectUrl = RequestContext::getMain()->getOutput()->getRedirect();
		$parsedUrlParts = $this->getServiceContainer()->getUrlUtils()->parse( $redirectUrl );
		$this->assertNotNull( $parsedUrlParts );
		$this->assertArrayEquals(
			[
				'title' => 'Special:UserLogin',
				'returntoquery' => '',
				'returnto' => 'Special:Preferences',
				'warning' => 'exception-nologin-text',
			],
			wfCgiToArray( $parsedUrlParts['query'] ),
			false,
			true
		);
	}

	public static function provideTemporaryAccountsEnabled() {
		return [
			'Temporary accounts disabled' => [ false ],
			'Temporary accounts enabled' => [ true ],
		];
	}

	public function testReportForRedirectToAccountCreationPage() {
		$this->enableAutoCreateTempUser();
		$user = $this->getServiceContainer()->getTempUserCreator()->create( null, new FauxRequest() )->getUser();
		RequestContext::getMain()->setUser( $user );
		RequestContext::getMain()->setTitle( Title::newFromText( 'Preferences', NS_SPECIAL ) );
		$e = new UserNotLoggedIn();
		$e->report();
		$redirectUrl = RequestContext::getMain()->getOutput()->getRedirect();
		$parsedUrlParts = $this->getServiceContainer()->getUrlUtils()->parse( $redirectUrl );
		$this->assertNotNull( $parsedUrlParts );
		$this->assertArrayEquals(
			[
				'title' => 'Special:CreateAccount',
				'returntoquery' => '',
				'returnto' => 'Special:Preferences',
				'warning' => 'exception-nologin-text-for-temp-user',
			],
			wfCgiToArray( $parsedUrlParts['query'] ),
			false,
			true
		);
	}
}
PK       ! i      exception/MWExceptionTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @covers \MWException
 * @author Antoine Musso
 */
class MWExceptionTest extends MediaWikiIntegrationTestCase {

	public function testMwexceptionThrowing() {
		$this->expectException( MWException::class );
		throw new MWException();
	}

	public function testUseMessageCache() {
		$e = new MWException();
		$this->assertTrue( $e->useMessageCache() );
	}

	public function testIsLoggable() {
		$e = new MWException();
		$this->assertTrue( $e->isLoggable() );
	}

	/**
	 * Verify the exception classes are JSON serializabe.
	 *
	 * @dataProvider provideExceptionClasses
	 */
	public function testJsonSerializeExceptions( $exception_class ) {
		$json = MWExceptionHandler::jsonSerializeException(
			new $exception_class()
		);
		$this->assertIsString( $json,
			"The $exception_class exception should be JSON serializable, got false." );
	}

	public static function provideExceptionClasses() {
		return [
			[ Exception::class ],
			[ MWException::class ],
		];
	}

	/**
	 * @covers \MWException::report
	 */
	public function testReport() {
		// Turn off to keep mw-error.log file empty in CI (and thus avoid build failure)
		$this->overrideConfigValue( MainConfigNames::DebugLogGroups, [] );

		global $wgOut;
		$wgOut->disable();

		$e = new class( 'Uh oh!' ) extends MWException {
			public function report() {
				global $wgOut;
				$wgOut->addHTML( 'Oh no!' );
			}
		};

		MWExceptionHandler::handleException( $e );

		$this->assertStringContainsString( 'Oh no!', $wgOut->getHTML() );
	}

}
PK       ! z  z  "  exception/UserBlockedErrorTest.phpnu Iw        <?php

use MediaWiki\Block\AbstractBlock;
use MediaWiki\Block\BlockErrorFormatter;
use MediaWiki\Context\RequestContext;
use MediaWiki\Language\FormatterFactory;
use MediaWiki\Language\Language;
use MediaWiki\Language\RawMessage;
use MediaWiki\User\UserIdentity;

/**
 * @covers \UserBlockedError
 */
class UserBlockedErrorTest extends MediaWikiIntegrationTestCase {

	private function setUpMockBlockFormatter(
		$expectedBlock, $expectedUser, $expectedLanguage, $expectedIp, $returnMessages
	) {
		$mockBlockErrorFormatter = $this->createMock( BlockErrorFormatter::class );
		$mockBlockErrorFormatter->expects( $this->once() )
			->method( 'getMessages' )
			->with( $expectedBlock, $expectedUser, $expectedIp )
			->willReturn( $returnMessages );

		$formatterFactory = $this->createNoOpMock( FormatterFactory::class, [ 'getBlockErrorFormatter' ] );
		$formatterFactory->method( 'getBlockErrorFormatter' )->willReturn( $mockBlockErrorFormatter );

		$this->setService( 'FormatterFactory', $formatterFactory );
	}

	public function testConstructionProvidedOnlyBlockParameter() {
		$context = RequestContext::getMain();
		$block = $this->createMock( AbstractBlock::class );
		$this->setUpMockBlockFormatter(
			$block, $context->getUser(), $context->getLanguage(), $context->getRequest()->getIP(),
			[ new RawMessage( 'testing' ) ]
		);
		$e = new UserBlockedError( $block );
		$this->assertSame(
			( new RawMessage( 'testing' ) )->text(),
			$e->getMessageObject()->text(),
			'UserBlockedError did not use the expected message.'
		);
	}

	public function testConstructionProvidedAllParametersWithMultipleBlockMessages() {
		$mockUser = $this->createMock( UserIdentity::class );
		$mockLanguage = $this->createMock( Language::class );
		$block = $this->createMock( AbstractBlock::class );
		$this->setUpMockBlockFormatter(
			$block, $mockUser, $mockLanguage, '1.2.3.4',
			[ new RawMessage( 'testing' ), new RawMessage( 'testing2' ) ]
		);
		$e = new UserBlockedError( $block, $mockUser, $mockLanguage, '1.2.3.4' );
		$this->assertSame(
			"* testing\n* testing2",
			$e->getMessageObject()->text(),
			'UserBlockedError did not use the expected message.'
		);
	}
}
PK       ! 2o"  "    exception/ReadOnlyErrorTest.phpnu Iw        <?php

use MediaWiki\Tests\Unit\DummyServicesTrait;

/**
 * @covers \ReadOnlyError
 * @author Addshore
 */
class ReadOnlyErrorTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;

	public function testConstruction() {
		$reason = 'This site is read-only for $reasons';
		$this->setService( 'ReadOnlyMode', $this->getDummyReadOnlyMode( $reason ) );
		$e = new ReadOnlyError();
		$this->assertEquals( 'readonly', $e->title );
		$this->assertEquals( 'readonlytext', $e->msg );
		$this->assertEquals( [ $reason ], $e->params );
	}

}
PK       ! ׭Y  Y     exception/ThrottledErrorTest.phpnu Iw        <?php

use MediaWiki\Output\OutputPage;

/**
 * @covers \ThrottledError
 * @author Addshore
 */
class ThrottledErrorTest extends MediaWikiIntegrationTestCase {

	public function testExceptionSetsStatusCode() {
		$mockOut = $this->createMock( OutputPage::class );
		$mockOut->expects( $this->once() )
			->method( 'setStatusCode' )
			->with( 429 );
		$this->setMwGlobals( 'wgOut', $mockOut );

		try {
			throw new ThrottledError();
		} catch ( ThrottledError $e ) {
			ob_start();
			$e->report();
			$text = ob_get_clean();
			$this->assertStringContainsString( $e->getMessage(), $text );
		}
	}

}
PK       ! }!    "  exception/PermissionsErrorTest.phpnu Iw        <?php

use MediaWiki\Message\Message;
use MediaWiki\Permissions\PermissionStatus;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \PermissionsError
 */
class PermissionsErrorTest extends MediaWikiIntegrationTestCase {

	protected function setUp(): void {
		parent::setUp();
		$this->setGroupPermissions( '*', 'testpermission', true );
	}

	public static function provideConstruction() {
		$status = new PermissionStatus();
		$status->error( 'cat', 1, 2 );
		$status->error( 'dog', 3, 4 );
		$array = [ [ 'cat', 1, 2 ], [ 'dog', 3, 4 ] ];
		yield [ null, $status, $status ];
		yield [ null, $array, $status ];
		yield [ 'testpermission', $status, $status ];
		yield [ 'testpermission', $array, $status ];

		yield [ 'testpermission', [],
			PermissionStatus::newEmpty()->fatal( 'badaccess-groups', Message::listParam( [ '*' ], 'comma' ), 1 ) ];
	}

	/**
	 * @dataProvider provideConstruction
	 */
	public function testConstruction( $permission, $errors, $expected ) {
		$e = new PermissionsError( $permission, $errors );
		$et = TestingAccessWrapper::newFromObject( $e );

		$this->expectDeprecationAndContinue( '/Use of PermissionsError::\\$permission/' );
		$this->assertEquals( $permission, $e->permission );

		$this->assertStatusMessagesExactly( $expected, $et->status );

		$this->expectDeprecationAndContinue( '/Use of PermissionsError::\\$errors/' );
		$this->assertArrayEquals( $expected->toLegacyErrorArray(), $e->errors );

		// Test the deprecated public property setter
		$e->errors = $e->errors;
		$this->assertStatusMessagesExactly( $expected, $et->status );
	}

	public static function provideInvalidConstruction() {
		yield [ null, null ];
		yield [ null, [] ];
		yield [ null, new PermissionStatus() ];
	}

	/**
	 * @dataProvider provideInvalidConstruction
	 */
	public function testInvalidConstruction( $permission, $errors ) {
		$this->expectException( InvalidArgumentException::class );
		$e = new PermissionsError( $permission, $errors );
	}
}
PK       ! NrbWU  U    exception/BadTitleErrorTest.phpnu Iw        <?php

use MediaWiki\Output\OutputPage;

/**
 * @covers \BadTitleError
 * @author Addshore
 */
class BadTitleErrorTest extends MediaWikiIntegrationTestCase {

	public function testExceptionSetsStatusCode() {
		$mockOut = $this->createMock( OutputPage::class );
		$mockOut->expects( $this->once() )
			->method( 'setStatusCode' )
			->with( 404 );
		$this->setMwGlobals( 'wgOut', $mockOut );

		try {
			throw new BadTitleError();
		} catch ( BadTitleError $e ) {
			ob_start();
			$e->report();
			$text = ob_get_clean();
			$this->assertStringContainsString( $e->getMessage(), $text );
		}
	}

}
PK       ! n    '  libs/telemetry/expected-trace-data.jsonnu Iw        {
	"resourceSpans": [
		{
			"resource": {
				"attributes": [
					{
						"key": "service.name",
						"value": {
							"stringValue": "test-service"
						}
					},
					{
						"key": "host.name",
						"value": {
							"stringValue": "<HOST-NAME>"
						}
					}
				]
			},
			"scopeSpans": [
				{
					"scope": {
						"name": "org.wikimedia.telemetry"
					},
					"spans": [
						{
							"traceId": "<TRACE-ID>",
							"parentSpanId": "<SPAN-1-ID>",
							"spanId": "<SPAN-2-ID>",
							"name": "child",
							"startTimeUnixNano": 5481675965596,
							"endTimeUnixNano": 5481675965846,
							"kind": 1,
							"attributes": [
								{
									"key": "some-key",
									"value": {
										"stringValue": "test"
									}
								}
							]
						},
						{
							"traceId": "<TRACE-ID>",
							"parentSpanId": null,
							"spanId": "<SPAN-1-ID>",
							"name": "test",
							"startTimeUnixNano": 5481675965496,
							"endTimeUnixNano": 5481675965920,
							"kind": 2
						}
					]
				}
			]
		}
	]
}
PK       ! 4"U)    +  libs/telemetry/TelemetryIntegrationTest.phpnu Iw        <?php
namespace Wikimedia\Tests\Telemetry;

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Psr7\Response;
use MediaWiki\MainConfigNames;
use MediaWikiIntegrationTestCase;
use Wikimedia\Telemetry\Clock;
use Wikimedia\Telemetry\NoopTracer;
use Wikimedia\Telemetry\SpanInterface;
use Wikimedia\Telemetry\Tracer;
use Wikimedia\Telemetry\TracerState;

/**
 * @covers \Wikimedia\Telemetry\Tracer
 * @covers \Wikimedia\Telemetry\OtlpHttpExporter
 */
class TelemetryIntegrationTest extends MediaWikiIntegrationTestCase {
	private const EXAMPLE_TRACING_CONFIG = [
		'serviceName' => 'test-service',
		'samplingProbability' => 100,
		'endpoint' => 'http://198.51.100.42:4318/v1/traces'
	];

	private MockHandler $handler;

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

		$this->handler = new MockHandler();
		$this->setService( '_TracerHTTPClient', new Client( [
			'handler' => $this->handler,
			'http_errors' => false
		] ) );
	}

	protected function tearDown(): void {
		parent::tearDown();
		Clock::setMockTime( null );
		TracerState::destroyInstance();
	}

	public function testShouldDoNothingWhenTracingDisabled(): void {
		$this->overrideConfigValue( MainConfigNames::OpenTelemetryConfig, null );

		$tracer = $this->getServiceContainer()->getTracer();
		$span = $tracer->createSpan( 'test' )
			->start();

		$span->end();

		$tracer->shutdown();

		$this->assertInstanceOf( NoopTracer::class, $tracer );
		$this->assertNull( $this->handler->getLastRequest() );
	}

	public function testShouldNotExportDataWhenNoSpansWereCreated(): void {
		$this->overrideConfigValue( MainConfigNames::OpenTelemetryConfig, self::EXAMPLE_TRACING_CONFIG );

		$tracer = $this->getServiceContainer()->getTracer();

		$tracer->shutdown();

		$this->assertInstanceOf( Tracer::class, $tracer );
		$this->assertNull( $this->handler->getLastRequest() );
	}

	public function testShouldNotExportDataWhenTracerWasNotExplicitlyShutdown(): void {
		$this->overrideConfigValue( MainConfigNames::OpenTelemetryConfig, self::EXAMPLE_TRACING_CONFIG );

		$tracer = $this->getServiceContainer()->getTracer();
		$span = $tracer->createRootSpan( 'test' )
			->start();

		$span->end();

		$this->assertInstanceOf( Tracer::class, $tracer );
		$this->assertNull( $this->handler->getLastRequest() );
	}

	public function testShouldExportDataOnShutdownWhenTracingEnabled(): void {
		$this->overrideConfigValue( MainConfigNames::OpenTelemetryConfig, self::EXAMPLE_TRACING_CONFIG );
		$this->handler->append( new Response( 200 ) );

		$mockTime = 5481675965496;
		Clock::setMockTime( $mockTime );

		$tracer = $this->getServiceContainer()->getTracer();
		$span = $tracer->createRootSpan( 'test' )
			->setSpanKind( SpanInterface::SPAN_KIND_SERVER )
			->start();

		$span->activate();

		$mockTime += 100;
		Clock::setMockTime( $mockTime );

		$childSpan = $tracer->createSpan( 'child' )
			->setAttributes( [ 'some-key' => 'test', 'ignored' => new \stdClass() ] )
			->start();

		$mockTime += 250;
		Clock::setMockTime( $mockTime );

		$childSpan->end();

		$mockTime += 74;
		Clock::setMockTime( $mockTime );

		$span->end();

		$this->assertNull(
			$this->handler->getLastRequest(),
			'Exporting trace data should be deferred until the tracer is explicitly shut down'
		);

		$tracer->shutdown();

		$request = $this->handler->getLastRequest();

		$this->assertInstanceOf( Tracer::class, $tracer );
		$this->assertSame( 'http://198.51.100.42:4318/v1/traces', (string)$request->getUri() );
		$this->assertSame( 'application/json', $request->getHeaderLine( 'Content-Type' ) );

		$expected = file_get_contents( __DIR__ . '/expected-trace-data.json' );

		$expected = strtr( $expected, [
			'<TRACE-ID>' => $span->getContext()->getTraceId(),
			'<SPAN-1-ID>' => $span->getContext()->getSpanId(),
			'<SPAN-2-ID>' => $childSpan->getContext()->getSpanId(),
			'<HOST-NAME>' => wfHostname()
		] );

		$this->assertJsonStringEqualsJsonString(
			$expected,
			(string)$request->getBody()
		);
	}
}
PK       ! ,O    1  libs/objectcache/RESTBagOStuffIntegrationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use Wikimedia\ObjectCache\RESTBagOStuff;

/**
 * @group BagOStuff
 * @covers \Wikimedia\ObjectCache\RESTBagOStuff
 */
class RESTBagOStuffIntegrationTest extends BagOStuffTestBase {
	protected function newCacheInstance() {
		if ( !$this->getConfVar( MainConfigNames::EnableRemoteBagOStuffTests ) ) {
			$this->markTestSkipped( '$wgEnableRemoteBagOStuffTests is false' );
		}
		return $this->getCacheByClass( RESTBagOStuff::class );
	}
}
PK       ! >    2  libs/objectcache/RedisBagOStuffIntegrationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use Wikimedia\ObjectCache\RedisBagOStuff;

/**
 * @group BagOStuff
 * @covers \Wikimedia\ObjectCache\RedisBagOStuff
 * @requires extension redis
 */
class RedisBagOStuffIntegrationTest extends BagOStuffTestBase {
	protected function newCacheInstance() {
		if ( !$this->getConfVar( MainConfigNames::EnableRemoteBagOStuffTests ) ) {
			$this->markTestSkipped( '$wgEnableRemoteBagOStuffTests is false' );
		}
		return $this->getCacheByClass( RedisBagOStuff::class );
	}
}
PK       ! 喤.1  1  :  libs/objectcache/MemcachedPeclBagOStuffIntegrationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;

/**
 * @group BagOStuff
 * @covers \Wikimedia\ObjectCache\MemcachedPeclBagOStuff
 * @requires extension memcached
 */
class MemcachedPeclBagOStuffIntegrationTest extends BagOStuffTestBase {
	protected function newCacheInstance() {
		if ( !$this->getConfVar( MainConfigNames::EnableRemoteBagOStuffTests ) ) {
			$this->markTestSkipped( '$wgEnableRemoteBagOStuffTests is false' );
		}
		return MediaWikiServices::getInstance()->getObjectCacheFactory()->getInstance( 'memcached-pecl' );
	}
}
PK       ! kKE  E  &  libs/objectcache/BagOStuffTestBase.phpnu Iw        <?php

use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\MainConfigNames;
use Wikimedia\LightweightObjectStore\StorageAwareness;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\MultiWriteBagOStuff;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;

/**
 * @author Matthias Mullie <mmullie@wikimedia.org>
 * @group BagOStuff
 * @covers \Wikimedia\ObjectCache\BagOStuff
 * @covers \Wikimedia\ObjectCache\MediumSpecificBagOStuff
 */
abstract class BagOStuffTestBase extends MediaWikiIntegrationTestCase {
	/** @var BagOStuff */
	protected $cache;

	protected const TEST_TIME = 1563892142;

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

		try {
			$this->cache = $this->newCacheInstance();
		} catch ( InvalidArgumentException $e ) {
			$this->markTestSkipped( "Cannot create cache instance for " . static::class .
				': the configuration is presumably missing from $wgObjectCaches' );
		}
		$this->cache->deleteMulti( [
			$this->cache->makeKey( $this->testKey() ),
			$this->cache->makeKey( $this->testKey() ) . ':lock'
		] );
	}

	private function testKey() {
		return 'test-' . static::class;
	}

	/**
	 * @return BagOStuff
	 */
	abstract protected function newCacheInstance();

	protected function getCacheByClass( $className ) {
		$caches = $this->getConfVar( MainConfigNames::ObjectCaches );
		foreach ( $caches as $id => $cache ) {
			if ( ( $cache['class'] ?? '' ) === $className ) {
				return $this->getServiceContainer()->getObjectCacheFactory()->getInstance( $id );
			}
		}
		$this->markTestSkipped( "No $className is configured" );
	}

	public function testMakeKey() {
		$cache = new HashBagOStuff( [ 'keyspace' => 'local_prefix' ] );

		$localKey = $cache->makeKey( 'first', 'second', 'third' );
		$globalKey = $cache->makeGlobalKey( 'first', 'second', 'third' );

		$this->assertSame(
			'local_prefix:first:second:third',
			$localKey,
			'Local key interpolates parameters'
		);

		$this->assertSame(
			'global:first:second:third',
			$globalKey,
			'Global key interpolates parameters and contains global prefix'
		);

		$this->assertNotEquals(
			$localKey,
			$globalKey,
			'Local key and global key with same parameters should not be equal'
		);

		$this->assertNotEquals(
			$cache->makeKey( 'a', 'bc:', 'de' ),
			$cache->makeKey( 'a', 'bc', ':de' )
		);

		$keyEmptyCollection = $cache->makeKey( '', 'second', 'third' );
		$this->assertSame(
			'local_prefix::second:third',
			$keyEmptyCollection,
			'Local key interpolates empty parameters'
		);
	}

	public function testKeyIsGlobal() {
		$cache = new HashBagOStuff();

		$localKey = $cache->makeKey( 'first', 'second', 'third' );
		$globalKey = $cache->makeGlobalKey( 'first', 'second', 'third' );

		$this->assertFalse( $cache->isKeyGlobal( $localKey ) );
		$this->assertTrue( $cache->isKeyGlobal( $globalKey ) );
	}

	public function testMerge() {
		$key = $this->cache->makeKey( $this->testKey() );

		$calls = 0;
		$casRace = false; // emulate a race
		$callback = static function ( BagOStuff $cache, $key, $oldVal, &$expiry ) use ( &$calls, &$casRace ) {
			++$calls;
			if ( $casRace ) {
				// Uses CAS instead?
				$cache->set( $key, 'conflict', 5 );
			}

			return ( $oldVal === false ) ? 'merged' : $oldVal . 'merged';
		};

		// merge on non-existing value
		$merged = $this->cache->merge( $key, $callback, 5 );
		$this->assertTrue( $merged );
		$this->assertEquals( 'merged', $this->cache->get( $key ) );

		// merge on existing value
		$merged = $this->cache->merge( $key, $callback, 5 );
		$this->assertTrue( $merged );
		$this->assertEquals( 'mergedmerged', $this->cache->get( $key ) );

		$calls = 0;
		$casRace = true;
		$this->assertFalse(
			$this->cache->merge( $key, $callback, 5, 1 ),
			'Non-blocking merge (CAS)'
		);

		if ( $this->cache instanceof MultiWriteBagOStuff ) {
			$wrapper = TestingAccessWrapper::newFromObject( $this->cache );
			$this->assertEquals( count( $wrapper->caches ), $calls );
		} else {
			$this->assertSame( 1, $calls );
		}
	}

	public function testChangeTTLRenew() {
		$key = $this->cache->makeKey( $this->testKey() );
		$value = 'meow';

		$this->cache->add( $key, $value, 60 );
		$this->assertEquals( $value, $this->cache->get( $key ) );
		$this->assertTrue( $this->cache->changeTTL( $key, 120 ) );
		$this->assertTrue( $this->cache->changeTTL( $key, 120 ) );
		$this->assertTrue( $this->cache->changeTTL( $key, 0 ) );
		$this->assertEquals( $this->cache->get( $key ), $value );

		$this->cache->delete( $key );
		$this->assertFalse( $this->cache->changeTTL( $key, 15 ) );
	}

	public function testChangeTTLExpireRel() {
		$key = $this->cache->makeKey( $this->testKey() );
		$value = 'meow';

		$this->cache->add( $key, $value, 5 );
		$this->assertSame( $value, $this->cache->get( $key ) );
		$this->assertTrue( $this->cache->changeTTL( $key, -3600 ) );
		$this->assertFalse( $this->cache->get( $key ) );
		$this->assertFalse( $this->cache->changeTTL( $key, -3600 ) );
	}

	public function testChangeTTLExpireAbs() {
		$key = $this->cache->makeKey( $this->testKey() );
		$value = 'meow';

		$this->cache->add( $key, $value, 5 );
		$this->assertSame( $value, $this->cache->get( $key ) );

		$now = $this->cache->getCurrentTime();
		$this->assertTrue( $this->cache->changeTTL( $key, (int)$now - 3600 ) );
		$this->assertFalse( $this->cache->get( $key ) );
		$this->assertFalse( $this->cache->changeTTL( $key, (int)$now - 3600 ) );
	}

	public function testChangeTTLMulti() {
		$key1 = $this->cache->makeKey( 'test-key1' );
		$key2 = $this->cache->makeKey( 'test-key2' );
		$key3 = $this->cache->makeKey( 'test-key3' );
		$key4 = $this->cache->makeKey( 'test-key4' );

		// cleanup
		$this->cache->deleteMulti( [ $key1, $key2, $key3, $key4 ] );

		$ok = $this->cache->changeTTLMulti( [ $key1, $key2, $key3 ], 30 );
		$this->assertFalse( $ok, "No keys found" );
		$this->assertFalse( $this->cache->get( $key1 ) );
		$this->assertFalse( $this->cache->get( $key2 ) );
		$this->assertFalse( $this->cache->get( $key3 ) );

		$ok = $this->cache->setMulti( [ $key1 => 1, $key2 => 2, $key3 => 3 ] );
		$this->assertTrue( $ok, "setMulti() succeeded" );
		$this->assertCount( 3, $this->cache->getMulti( [ $key1, $key2, $key3 ] ),
			"setMulti() succeeded via getMulti() check" );

		$ok = $this->cache->changeTTLMulti( [ $key1, $key2, $key3 ], 300 );
		$this->assertTrue( $ok, "TTL bumped for all keys" );
		$this->assertSame( 1, $this->cache->get( $key1 ) );
		$this->assertEquals( 2, $this->cache->get( $key2 ) );
		$this->assertEquals( 3, $this->cache->get( $key3 ) );

		$ok = $this->cache->changeTTLMulti( [ $key1, $key2, $key3, $key4 ], 300 );
		$this->assertFalse( $ok, "One key missing" );
		$this->assertSame( 1, $this->cache->get( $key1 ), "Key still live" );

		$ok = $this->cache->setMulti( [ $key1 => 1, $key2 => 2, $key3 => 3 ] );
		$this->assertTrue( $ok, "setMulti() succeeded" );

		$now = $this->cache->getCurrentTime();
		$ok = $this->cache->changeTTLMulti( [ $key1, $key2, $key3 ], (int)$now + 86400 );
		$this->assertTrue( $ok, "Expiry set for all keys" );
		$this->assertSame( 1, $this->cache->get( $key1 ), "Key still live" );

		// cleanup
		$this->cache->deleteMulti( [ $key1, $key2, $key3, $key4 ] );
	}

	public function testAdd() {
		$key = $this->cache->makeKey( $this->testKey() );
		$this->assertFalse( $this->cache->get( $key ) );
		$this->assertTrue( $this->cache->add( $key, 'test', 5 ) );
		$this->assertFalse( $this->cache->add( $key, 'test', 5 ) );
	}

	public function testAddBackground() {
		$key = $this->cache->makeKey( $this->testKey() );
		$this->assertFalse( $this->cache->get( $key ) );
		$this->assertTrue(
			$this->cache->add( $key, 'test', 5, BagOStuff::WRITE_BACKGROUND )
		);
		for ( $i = 0; $i < 100 && $this->cache->get( $key ) !== 'test'; $i++ ) {
			usleep( 1000 );
		}
		$this->assertSame( 'test', $this->cache->get( $key ) );
	}

	public function testGet() {
		$value = [ 'this' => 'is', 'a' => 'test' ];

		$key = $this->cache->makeKey( $this->testKey() );
		$this->cache->add( $key, $value, 5 );
		$this->assertSame( $this->cache->get( $key ), $value );
	}

	public function testGetWithSetCallback() {
		$now = self::TEST_TIME;
		$cache = new HashBagOStuff( [] );
		$cache->setMockTime( $now );
		$key = $cache->makeKey( $this->testKey() );

		$this->assertFalse( $cache->get( $key ), "No value" );

		$value = $cache->getWithSetCallback(
			$key,
			30,
			static function ( &$ttl ) {
				$ttl = 10;

				return 'hello kitty';
			}
		);

		$this->assertEquals( 'hello kitty', $value );
		$this->assertEquals( $value, $cache->get( $key ), "Value set" );

		$now += 11;

		$this->assertFalse( $cache->get( $key ), "Value expired" );
	}

	public function testIncrWithInit() {
		$key = $this->cache->makeKey( $this->testKey() );

		$val = $this->cache->get( $key );
		$this->assertFalse( $val, "No value yet" );

		$val = $this->cache->incrWithInit( $key, 0, 1, 3 );
		$this->assertSame( 3, $val, "Correct init value" );

		$val = $this->cache->incrWithInit( $key, 0, 1, 3 );
		$this->assertSame( 4, $val, "Correct incremented value" );
		$this->cache->delete( $key );

		$val = $this->cache->incrWithInit( $key, 0, 5 );
		$this->assertSame( 5, $val, "Correct incremented value" );
	}

	public function testIncrWithInitAsync() {
		$key = $this->cache->makeKey( $this->testKey() );
		$val = $this->cache->get( $key );
		$this->assertFalse( $val, "No value yet" );

		$val = $this->cache->incrWithInit( $key, 0, 1, 3, BagOStuff::WRITE_BACKGROUND );
		if ( $val === true ) {
			$val = $this->cache->get( $key );
			for ( $i = 0; $i < 1000 && $val !== 3; $i++ ) {
				usleep( 1000 );
				$val = $this->cache->get( $key );
			}
		}
		$this->assertSame( 3, $val );

		$val = $this->cache->incrWithInit( $key, 0, 1, 3, BagOStuff::WRITE_BACKGROUND );
		if ( $val === true ) {
			$val = $this->cache->get( $key );
			for ( $i = 0; $i < 1000 && $val !== 4; $i++ ) {
				usleep( 1000 );
				$val = $this->cache->get( $key );
			}
		}
		$this->assertSame( 4, $val );
	}

	public function testGetMulti() {
		$value1 = [ 'this' => 'is', 'a' => 'test' ];
		$value2 = [ 'this' => 'is', 'another' => 'test' ];
		$value3 = [ 'testing a key that may be encoded when sent to cache backend' ];
		$value4 = [ 'another test where chars in key will be encoded' ];

		$key1 = $this->cache->makeKey( 'test-1' );
		$key2 = $this->cache->makeKey( 'test-2' );
		// internally, MemcachedBagOStuffs will encode to will-%25-encode
		$key3 = $this->cache->makeKey( 'will-%-encode' );
		$key4 = $this->cache->makeKey(
			'flowdb:flow_ref:wiki:by-source:v3:Parser\'s_"broken"_+_(page)_&_grill:testwiki:1:4.7'
		);

		// cleanup
		$this->cache->delete( $key1 );
		$this->cache->delete( $key2 );
		$this->cache->delete( $key3 );
		$this->cache->delete( $key4 );

		$this->cache->add( $key1, $value1, 5 );
		$this->cache->add( $key2, $value2, 5 );
		$this->cache->add( $key3, $value3, 5 );
		$this->cache->add( $key4, $value4, 5 );

		$this->assertEquals(
			[ $key1 => $value1, $key2 => $value2, $key3 => $value3, $key4 => $value4 ],
			$this->cache->getMulti( [ $key1, $key2, $key3, $key4 ] )
		);

		// cleanup
		$this->cache->delete( $key1 );
		$this->cache->delete( $key2 );
		$this->cache->delete( $key3 );
		$this->cache->delete( $key4 );
	}

	public function testSetDeleteMulti() {
		$map = [
			$this->cache->makeKey( 'test-1' ) => 'Siberian',
			$this->cache->makeKey( 'test-2' ) => [ 'Huskies' ],
			$this->cache->makeKey( 'test-3' ) => [ 'are' => 'the' ],
			$this->cache->makeKey( 'test-4' ) => (object)[ 'greatest' => 'animal' ],
			$this->cache->makeKey( 'test-5' ) => 4,
			$this->cache->makeKey( 'test-6' ) => 'ever'
		];

		$this->assertTrue( $this->cache->setMulti( $map ) );
		$this->assertEquals(
			$map,
			$this->cache->getMulti( array_keys( $map ) )
		);

		$this->assertTrue( $this->cache->deleteMulti( array_keys( $map ) ) );

		$this->assertEquals(
			[],
			$this->cache->getMulti( array_keys( $map ), BagOStuff::READ_LATEST )
		);
		$this->assertEquals(
			[],
			$this->cache->getMulti( array_keys( $map ) )
		);
	}

	public function testDelete() {
		// Delete of non-existent key should return true
		$key = $this->cache->makeKey( 'nonexistent' );
		$this->assertTrue( $this->cache->delete( $key ) );
		$this->assertTrue( $this->cache->delete( $key, BagOStuff::WRITE_BACKGROUND ) );
	}

	public function testSetSegmentable() {
		$key = $this->cache->makeKey( $this->testKey() );
		$tiny = 418;
		$small = wfRandomString( 32 );
		// 64 * 8 * 32768 = 16777216 bytes
		$big = str_repeat( wfRandomString( 32 ) . '-' . wfRandomString( 32 ), 32768 );

		$callback = static function ( $cache, $key, $oldValue ) {
			return $oldValue . '!';
		};

		$cases = [ 'tiny' => $tiny, 'small' => $small, 'big' => $big ];
		foreach ( $cases as $case => $value ) {
			$this->cache->set( $key, $value, 10, BagOStuff::WRITE_ALLOW_SEGMENTS );
			$this->assertEquals( $value, $this->cache->get( $key ), "get $case" );
			$this->assertEquals( [ $key => $value ], $this->cache->getMulti( [ $key ] ), "get $case" );

			$this->assertTrue(
				$this->cache->merge( $key, $callback, 5, 1, BagOStuff::WRITE_ALLOW_SEGMENTS ),
				"merge $case"
			);
			$this->assertEquals(
				"$value!",
				$this->cache->get( $key ),
				"merged $case"
			);
			$this->assertEquals(
				"$value!",
				$this->cache->getMulti( [ $key ] )[$key],
				"merged $case"
			);

			$this->assertTrue( $this->cache->deleteMulti( [ $key ] ), "delete $case" );
			$this->assertFalse( $this->cache->get( $key ), "deleted $case" );
			$this->assertEquals( [], $this->cache->getMulti( [ $key ] ), "deletd $case" );

			$this->cache->set( $key, "@$value", 10, BagOStuff::WRITE_ALLOW_SEGMENTS );
			$this->assertEquals( "@$value", $this->cache->get( $key ), "get $case" );
			$this->assertTrue(
				$this->cache->delete( $key, BagOStuff::WRITE_ALLOW_SEGMENTS ),
				"prune $case"
			);
			$this->assertFalse( $this->cache->get( $key ), "pruned $case" );
			$this->assertEquals( [], $this->cache->getMulti( [ $key ] ), "pruned $case" );
		}

		$this->cache->set( $key, 666, 10, BagOStuff::WRITE_ALLOW_SEGMENTS );

		$this->assertEquals( 666, $this->cache->get( $key ) );
		$this->assertEquals( 667, $this->cache->incrWithInit( $key, 10 ) );
		$this->assertEquals( 667, $this->cache->get( $key ) );

		$this->assertTrue( $this->cache->delete( $key ) );
		$this->assertFalse( $this->cache->get( $key ) );
	}

	public function testSetBackground() {
		$key = $this->cache->makeKey( $this->testKey() );
		$this->assertTrue(
			$this->cache->set( $key, 'background', BagOStuff::WRITE_BACKGROUND ) );
	}

	public function testGetScopedLock() {
		$key = $this->cache->makeKey( $this->testKey() );
		$value1 = $this->cache->getScopedLock( $key, 0 );
		$value2 = $this->cache->getScopedLock( $key, 0 );

		$this->assertInstanceOf( ScopedCallback::class, $value1, 'First call returned lock' );
		$this->assertNull( $value2, 'Duplicate call returned no lock' );

		unset( $value1 );

		$value3 = $this->cache->getScopedLock( $key, 0 );
		$this->assertInstanceOf( ScopedCallback::class, $value3, 'Lock returned callback after release' );
		unset( $value3 );

		$value1 = $this->cache->getScopedLock( $key, 0, 5, 'reentry' );
		$value2 = $this->cache->getScopedLock( $key, 0, 5, 'reentry' );

		$this->assertInstanceOf( ScopedCallback::class, $value1, 'First reentrant call returned lock' );
		$this->assertInstanceOf( ScopedCallback::class, $value2, 'Second reentrant call returned lock' );
	}

	public function testReportDupes() {
		$logger = $this->createMock( Psr\Log\NullLogger::class );
		$logger->expects( $this->once() )
			->method( 'warning' )
			->with( 'Duplicate get(): "{key}" fetched {count} times', [
				'key' => 'foo',
				'count' => 2,
			] );

		$cache = new HashBagOStuff( [
			'reportDupes' => true,
			'asyncHandler' => 'DeferredUpdates::addCallableUpdate',
			'logger' => $logger,
		] );
		$cache->get( 'foo' );
		$cache->get( 'bar' );
		$cache->get( 'foo' );

		DeferredUpdates::doUpdates();
	}

	public function testLocking() {
		$key = $this->cache->makeKey( $this->testKey() );
		$this->assertTrue( $this->cache->lock( $key ) );
		$this->assertFalse( $this->cache->lock( $key ) );
		$this->assertTrue( $this->cache->unlock( $key ) );
		$this->assertFalse( $this->cache->unlock( $key ) );

		$this->assertTrue( $this->cache->lock( $key, 5, 5, 'rclass' ) );
		$this->assertTrue( $this->cache->lock( $key, 5, 5, 'rclass' ) );
		$this->assertTrue( $this->cache->unlock( $key ) );
		$this->assertTrue( $this->cache->unlock( $key ) );
	}

	public function testErrorHandling() {
		$key = $this->cache->makeKey( $this->testKey() );
		$wrapper = TestingAccessWrapper::newFromObject( $this->cache );

		$wp = $this->cache->watchErrors();
		$this->cache->get( $key );
		$this->assertSame( StorageAwareness::ERR_NONE, $this->cache->getLastError() );
		$this->assertSame( StorageAwareness::ERR_NONE, $this->cache->getLastError( $wp ) );

		$wrapper->setLastError( StorageAwareness::ERR_UNREACHABLE );
		$this->assertSame( StorageAwareness::ERR_UNREACHABLE, $this->cache->getLastError() );
		$this->assertSame( StorageAwareness::ERR_UNREACHABLE, $this->cache->getLastError( $wp ) );

		$wp = $this->cache->watchErrors();
		$wrapper->setLastError( StorageAwareness::ERR_UNEXPECTED );
		$wp2 = $this->cache->watchErrors();
		$this->assertSame( StorageAwareness::ERR_UNEXPECTED, $this->cache->getLastError() );
		$this->assertSame( StorageAwareness::ERR_UNEXPECTED, $this->cache->getLastError( $wp ) );
		$this->assertSame( StorageAwareness::ERR_NONE, $this->cache->getLastError( $wp2 ) );

		$this->cache->get( $key );
		$this->assertSame( StorageAwareness::ERR_UNEXPECTED, $this->cache->getLastError() );
		$this->assertSame( StorageAwareness::ERR_UNEXPECTED, $this->cache->getLastError( $wp ) );
		$this->assertSame( StorageAwareness::ERR_NONE, $this->cache->getLastError( $wp2 ) );
	}
}
PK       ! ?    1  libs/objectcache/HashBagOStuffIntegrationTest.phpnu Iw        <?php

use Wikimedia\ObjectCache\HashBagOStuff;

/**
 * @group BagOStuff
 * @covers \Wikimedia\ObjectCache\HashBagOStuff
 */
class HashBagOStuffIntegrationTest extends BagOStuffTestBase {
	protected function newCacheInstance() {
		return new HashBagOStuff();
	}
}
PK       ! -    &  libs/objectcache/APCUBagOStuffTest.phpnu Iw        <?php

use Wikimedia\ObjectCache\APCUBagOStuff;

/**
 * @group BagOStuff
 * @covers \Wikimedia\ObjectCache\APCUBagOStuff
 * @requires extension apcu
 */
class APCUBagOStuffTest extends BagOStuffTestBase {
	protected function newCacheInstance() {
		// Make sure the APCu methods actually store anything
		if ( PHP_SAPI === 'cli' && !ini_get( 'apc.enable_cli' ) ) {
			$this->markTestSkipped( 'apc.enable_cli=1 is required to run this test.' );
		}
		return new APCUBagOStuff( [] );
	}
}
PK       !     9  libs/objectcache/MemcachedPhpBagOStuffIntegrationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @group BagOStuff
 * @covers \Wikimedia\ObjectCache\MemcachedPhpBagOStuff
 */
class MemcachedPhpBagOStuffIntegrationTest extends BagOStuffTestBase {
	protected function newCacheInstance() {
		if ( !$this->getConfVar( MainConfigNames::EnableRemoteBagOStuffTests ) ) {
			$this->markTestSkipped( '$wgEnableRemoteBagOStuffTests is false' );
		}
		return $this->getServiceContainer()->getObjectCacheFactory()->getInstance( 'memcached-php' );
	}
}
PK       ! ty$  $  ,  libs/objectcache/MultiWriteBagOStuffTest.phpnu Iw        <?php

use Wikimedia\LightweightObjectStore\StorageAwareness;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\MultiWriteBagOStuff;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \Wikimedia\ObjectCache\MultiWriteBagOStuff
 * @group BagOStuff
 * @group Database
 */
class MultiWriteBagOStuffTest extends MediaWikiIntegrationTestCase {
	/** @var HashBagOStuff */
	private $cache1;
	/** @var HashBagOStuff */
	private $cache2;
	/** @var MultiWriteBagOStuff */
	private $cache;

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

		$this->cache1 = new HashBagOStuff();
		$this->cache2 = new HashBagOStuff();
		$this->cache = new MultiWriteBagOStuff( [
			'caches' => [ $this->cache1, $this->cache2 ],
			'replication' => 'async',
			'asyncHandler' => 'DeferredUpdates::addCallableUpdate'
		] );
	}

	public function testSet() {
		$key = 'key';
		$value = 'value';
		$this->cache->set( $key, $value );

		// Set in tier 1
		$this->assertSame( $value, $this->cache1->get( $key ), 'Written to tier 1' );
		// Set in tier 2
		$this->assertSame( $value, $this->cache2->get( $key ), 'Written to tier 2' );
	}

	public function testAdd() {
		$key = 'key';
		$value = 'value';
		$ok = $this->cache->add( $key, $value );

		$this->assertTrue( $ok );
		// Set in tier 1
		$this->assertSame( $value, $this->cache1->get( $key ), 'Written to tier 1' );
		// Set in tier 2
		$this->assertSame( $value, $this->cache2->get( $key ), 'Written to tier 2' );
	}

	public function testSyncMergeAsync() {
		$key = 'keyA';
		$value = 'value';
		$func = static function () use ( $value ) {
			return $value;
		};

		// XXX: DeferredUpdates bound to transactions in CLI mode
		$dbw = $this->getDb();
		$dbw->begin();
		$this->cache->merge( $key, $func );

		// Set in tier 1
		$this->assertEquals( $value, $this->cache1->get( $key ), 'Written to tier 1' );
		// Not yet set in tier 2
		$this->assertFalse( $this->cache2->get( $key ), 'Not written to tier 2' );

		$dbw->commit();

		// Set in tier 2
		$this->assertEquals( $value, $this->cache2->get( $key ), 'Written to tier 2' );
	}

	public function testSyncMergeSync() {
		// Like setUp() but without 'async'
		$cache1 = new HashBagOStuff();
		$cache2 = new HashBagOStuff();
		$cache = new MultiWriteBagOStuff( [
			'caches' => [ $cache1, $cache2 ]
		] );
		$value = 'value';
		$func = static function () use ( $value ) {
			return $value;
		};

		$key = 'keyB';

		$dbw = $this->getDb();
		$dbw->begin();
		$cache->merge( $key, $func );

		// Set in tier 1
		$this->assertEquals( $value, $cache1->get( $key ), 'Written to tier 1' );
		// Immediately set in tier 2
		$this->assertEquals( $value, $cache2->get( $key ), 'Written to tier 2' );

		$dbw->commit();
	}

	public function testSetDelayed() {
		$key = 'key';
		$value = (object)[ 'v' => 'saved value' ];
		$expectValue = clone $value;

		// XXX: DeferredUpdates bound to transactions in CLI mode
		$dbw = $this->getDb();
		$dbw->begin();
		$this->cache->set( $key, $value );

		// Test that later changes to $value don't affect the saved value (e.g. T168040)
		$value->v = 'other value';

		// Set in tier 1
		$this->assertEquals( $expectValue, $this->cache1->get( $key ), 'Written to tier 1' );
		// Not yet set in tier 2
		$this->assertFalse( $this->cache2->get( $key ), 'Not written to tier 2' );

		$dbw->commit();

		// Set in tier 2
		$this->assertEquals( $expectValue, $this->cache2->get( $key ), 'Written to tier 2' );
	}

	public function testMakeKey() {
		$cache1 = $this->getMockBuilder( HashBagOStuff::class )
			->onlyMethods( [ 'makeKey' ] )->getMock();
		$cache1->expects( $this->never() )->method( 'makeKey' );

		$cache2 = $this->getMockBuilder( HashBagOStuff::class )
			->onlyMethods( [ 'makeKey' ] )->getMock();
		$cache2->expects( $this->never() )->method( 'makeKey' );

		$cache = new MultiWriteBagOStuff( [
			'keyspace' => 'generic',
			'caches' => [ $cache1, $cache2 ]
		] );

		$this->assertSame( 'generic:a:b', $cache->makeKey( 'a', 'b' ) );
	}

	public function testConvertGenericKey() {
		$cache1 = new class extends HashBagOStuff {
			protected function makeKeyInternal( $keyspace, $components ) {
				return $keyspace . ':short-one-way';
			}

			protected function requireConvertGenericKey(): bool {
				return true;
			}
		};
		$cache2 = new class extends HashBagOStuff {
			protected function makeKeyInternal( $keyspace, $components ) {
				return $keyspace . ':short-another-way';
			}

			protected function requireConvertGenericKey(): bool {
				return true;
			}
		};

		$cache = new MultiWriteBagOStuff( [
			'caches' => [ $cache1, $cache2 ]
		] );
		$key = $cache->makeKey( 'a', 'b' );
		$cache->set( $key, 'my_value' );

		$this->assertSame(
			'local:a:b',
			$key
		);
		$this->assertSame(
			[ 'local:short-one-way' ],
			array_keys( TestingAccessWrapper::newFromObject( $cache1 )->bag ),
			'key gets re-encoded for first backend'
		);
		$this->assertSame(
			[ 'local:short-another-way' ],
			array_keys( TestingAccessWrapper::newFromObject( $cache2 )->bag ),
			'key gets re-encoded for second backend'
		);
	}

	public function testMakeGlobalKey() {
		$cache1 = $this->getMockBuilder( HashBagOStuff::class )
			->onlyMethods( [ 'makeGlobalKey' ] )->getMock();
		$cache1->expects( $this->never() )->method( 'makeGlobalKey' );

		$cache2 = $this->getMockBuilder( HashBagOStuff::class )
			->onlyMethods( [ 'makeGlobalKey' ] )->getMock();
		$cache2->expects( $this->never() )->method( 'makeGlobalKey' );

		$cache = new MultiWriteBagOStuff( [ 'caches' => [ $cache1, $cache2 ] ] );

		$this->assertSame( 'global:a:b', $cache->makeGlobalKey( 'a', 'b' ) );
	}

	public function testDuplicateStoreAdd() {
		$bag = new HashBagOStuff();
		$cache = new MultiWriteBagOStuff( [
			'caches' => [ $bag, $bag ],
		] );

		$this->assertTrue( $cache->add( 'key', 1, 30 ) );
	}

	public function testIncrWithInit() {
		$key = $this->cache->makeKey( 'key' );
		$val = $this->cache->incrWithInit( $key, 0, 1, 3 );
		$this->assertSame( 3, $val, "Correct init value" );

		$val = $this->cache->incrWithInit( $key, 0, 1, 3 );
		$this->assertSame( 4, $val, "Correct init value" );
		$this->cache->delete( $key );

		$val = $this->cache->incrWithInit( $key, 0, 5 );
		$this->assertSame( 5, $val, "Correct init value" );
	}

	public function testErrorHandling() {
		$t1Cache = $this->createPartialMock( HashBagOStuff::class, [ 'set' ] );
		$t1CacheWrapper = TestingAccessWrapper::newFromObject( $t1Cache );
		$t1CacheNextError = StorageAwareness::ERR_NONE;
		$t1Cache->method( 'set' )
			->willReturnCallback( static function () use ( $t1CacheWrapper, &$t1CacheNextError ) {
				if ( $t1CacheNextError !== StorageAwareness::ERR_NONE ) {
					$t1CacheWrapper->setLastError( $t1CacheNextError );

					return false;
				}

				return true;
			} );
		$t2Cache = $this->createPartialMock( HashBagOStuff::class, [ 'set' ] );
		$t2CacheWrapper = TestingAccessWrapper::newFromObject( $t2Cache );
		$t2CacheNextError = StorageAwareness::ERR_NONE;
		$t2Cache->method( 'set' )
			->willReturnCallback( static function () use ( $t2CacheWrapper, &$t2CacheNextError ) {
				if ( $t2CacheNextError !== StorageAwareness::ERR_NONE ) {
					$t2CacheWrapper->setLastError( $t2CacheNextError );

					return false;
				}

				return true;
			} );
		$cache = new MultiWriteBagOStuff( [
			'keyspace' => 'repl_local',
			'caches' => [ $t1Cache, $t2Cache ]
		] );
		$cacheWrapper = TestingAccessWrapper::newFromObject( $cache );
		$key = 'a:key';

		$wp1 = $cache->watchErrors();
		$cache->set( $key, 'value', 3600 );
		$this->assertSame( StorageAwareness::ERR_NONE, $t1Cache->getLastError() );
		$this->assertSame( StorageAwareness::ERR_NONE, $t2Cache->getLastError() );
		$this->assertSame( StorageAwareness::ERR_NONE, $cache->getLastError() );
		$this->assertSame( StorageAwareness::ERR_NONE, $cache->getLastError( $wp1 ) );

		$t1CacheNextError = StorageAwareness::ERR_NO_RESPONSE;
		$t2CacheNextError = StorageAwareness::ERR_UNREACHABLE;

		$cache->set( $key, 'value', 3600 );
		$this->assertSame( $t1CacheNextError, $t1Cache->getLastError() );
		$this->assertSame( $t2CacheNextError, $t2Cache->getLastError() );
		$this->assertSame( $t2CacheNextError, $cache->getLastError() );
		$this->assertSame( $t2CacheNextError, $cache->getLastError( $wp1 ) );

		$t1CacheNextError = StorageAwareness::ERR_NO_RESPONSE;
		$t2CacheNextError = StorageAwareness::ERR_UNEXPECTED;

		$wp2 = $cache->watchErrors();
		$cache->set( $key, 'value', 3600 );
		$wp3 = $cache->watchErrors();
		$this->assertSame( $t1CacheNextError, $t1Cache->getLastError() );
		$this->assertSame( $t2CacheNextError, $t2Cache->getLastError() );
		$this->assertSame( $t2CacheNextError, $cache->getLastError( $wp2 ) );
		$this->assertSame( StorageAwareness::ERR_NONE, $cache->getLastError( $wp3 ) );

		$cacheWrapper->setLastError( StorageAwareness::ERR_UNEXPECTED );
		$wp4 = $cache->watchErrors();
		$this->assertSame( StorageAwareness::ERR_UNEXPECTED, $cache->getLastError() );
		$this->assertSame( StorageAwareness::ERR_UNEXPECTED, $cache->getLastError( $wp1 ) );
		$this->assertSame( StorageAwareness::ERR_UNEXPECTED, $cache->getLastError( $wp2 ) );
		$this->assertSame( StorageAwareness::ERR_UNEXPECTED, $cache->getLastError( $wp3 ) );
		$this->assertSame( StorageAwareness::ERR_NONE, $cache->getLastError( $wp4 ) );
		$this->assertSame( $t1CacheNextError, $t1Cache->getLastError() );
		$this->assertSame( $t2CacheNextError, $t2Cache->getLastError() );
	}
}
PK       ! fd~8  ~8  !  libs/http/MultiHttpClientTest.phpnu Iw        <?php

use MediaWiki\Status\Status;
use PHPUnit\Framework\Constraint\IsType;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\Http\MultiHttpClient;
use Wikimedia\Http\TelemetryHeadersInterface;
use Wikimedia\TestingAccessWrapper;

/**
 * The urls herein are not actually called, because we mock the return results.
 *
 * @covers \Wikimedia\Http\MultiHttpClient
 */
class MultiHttpClientTest extends MediaWikiIntegrationTestCase {
	/**
	 * @param array $options
	 * @return MultiHttpClient|MockObject
	 */
	private function createClient( $options = [] ) {
		$client = $this->getMockBuilder( MultiHttpClient::class )
			->setConstructorArgs( [ $options ] )
			->onlyMethods( [ 'isCurlEnabled' ] )->getMock();
		$client->method( 'isCurlEnabled' )->willReturn( false );
		return $client;
	}

	private function getHttpRequest( $statusValue, $statusCode, $headers = [] ) {
		$options = [
			'timeout' => 1,
			'connectTimeout' => 1
		];
		$httpRequest = $this->getMockBuilder( MWHttpRequest::class )
			->setConstructorArgs( [ '', $options ] )
			->getMock();
		$httpRequest->method( 'execute' )
			->willReturn( Status::wrap( $statusValue ) );
		$httpRequest->method( 'getResponseHeaders' )
			->willReturn( $headers );
		$httpRequest->method( 'getStatus' )
			->willReturn( $statusCode );
		return $httpRequest;
	}

	private function mockHttpRequestFactory( $httpRequest ) {
		$factory = $this->createMock( MediaWiki\Http\HttpRequestFactory::class );
		$factory->method( 'create' )
			->willReturn( $httpRequest );
		return $factory;
	}

	/**
	 * Test call of a single url that should succeed
	 */
	public function testMultiHttpClientSingleSuccess() {
		// Mock success
		$httpRequest = $this->getHttpRequest( StatusValue::newGood( 200 ), 200 );
		$this->setService( 'HttpRequestFactory', $this->mockHttpRequestFactory( $httpRequest ) );

		[ $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ] = $this->createClient()->run( [
			'method' => 'GET',
			'url' => "http://example.test",
		] );

		$this->assertSame( 200, $rcode );
	}

	/**
	 * Test call of a single url that should not exist, and therefore fail
	 */
	public function testMultiHttpClientSingleFailure() {
		// Mock an invalid tld
		$httpRequest = $this->getHttpRequest(
			StatusValue::newFatal( 'http-invalid-url', 'http://www.example.test' ), 0 );
		$this->setService( 'HttpRequestFactory', $this->mockHttpRequestFactory( $httpRequest ) );

		[ $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ] = $this->createClient()->run( [
			'method' => 'GET',
			'url' => "http://www.example.test",
		] );

		$this->assertSame( 0, $rcode );
	}

	/**
	 * Test call of multiple urls that should all succeed
	 */
	public function testMultiHttpClientMultipleSuccess() {
		// Mock success
		$httpRequest = $this->getHttpRequest( StatusValue::newGood( 200 ), 200 );
		$this->setService( 'HttpRequestFactory', $this->mockHttpRequestFactory( $httpRequest ) );

		$reqs = [
			[
				'method' => 'GET',
				'url' => 'http://example.test',
			],
			[
				'method' => 'GET',
				'url' => 'https://get.test',
			],
		];
		$responses = $this->createClient()->runMulti( $reqs );
		foreach ( $responses as $response ) {
			[ $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ] = $response['response'];
			$this->assertSame( 200, $rcode );
		}
	}

	/**
	 * Test call of multiple urls that should all fail
	 */
	public function testMultiHttpClientMultipleFailure() {
		// Mock page not found
		$httpRequest = $this->getHttpRequest(
			StatusValue::newFatal( "http-bad-status", 404, 'Not Found' ), 404 );
		$this->setService( 'HttpRequestFactory', $this->mockHttpRequestFactory( $httpRequest ) );

		$reqs = [
			[
				'method' => 'GET',
				'url' => 'http://example.test/12345',
			],
			[
				'method' => 'GET',
				'url' => 'http://example.test/67890',
			]
		];
		$responses = $this->createClient()->runMulti( $reqs );
		foreach ( $responses as $response ) {
			[ $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ] = $response['response'];
			$this->assertSame( 404, $rcode );
		}
	}

	/**
	 * Test of response header handling
	 */
	public function testMultiHttpClientHeaders() {
		// Represenative headers for typical requests, per MWHttpRequest::getResponseHeaders()
		$headers = [
			'content-type' => [
				'text/html; charset=utf-8',
			],
			'date' => [
				'Wed, 18 Jul 2018 14:52:41 GMT',
			],
			'set-cookie' => [
				'COUNTRY=NAe6; expires=Wed, 25-Jul-2018 14:52:41 GMT; path=/; domain=.example.test',
				'LAST_NEWS=1531925562; expires=Thu, 18-Jul-2019 14:52:41 GMT; path=/; domain=.example.test',
			]
		];

		// Mock success with specific headers
		$httpRequest = $this->getHttpRequest( StatusValue::newGood( 200 ), 200, $headers );
		$this->setService( 'HttpRequestFactory', $this->mockHttpRequestFactory( $httpRequest ) );

		[ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $this->createClient()->run( [
			'method' => 'GET',
			'url' => 'http://example.test',
		] );

		$this->assertSame( 200, $rcode );
		$this->assertSameSize( $headers, $rhdrs );
		foreach ( $headers as $name => $values ) {
			$value = implode( ', ', $values );
			$this->assertArrayHasKey( $name, $rhdrs );
			$this->assertEquals( $value, $rhdrs[$name] );
		}
	}

	public static function provideMultiHttpTimeout() {
		return [
			'default 10/30' => [
				[],
				[],
				10,
				30
			],
			'constructor override' => [
				[ 'connTimeout' => 2, 'reqTimeout' => 3 ],
				[],
				2,
				3
			],
			'run override' => [
				[],
				[ 'connTimeout' => 2, 'reqTimeout' => 3 ],
				2,
				3
			],
			'constructor max option limits default' => [
				[ 'maxConnTimeout' => 2, 'maxReqTimeout' => 3 ],
				[],
				2,
				3
			],
			'constructor max option limits regular constructor option' => [
				[
					'maxConnTimeout' => 2,
					'maxReqTimeout' => 3,
					'connTimeout' => 100,
					'reqTimeout' => 100
				],
				[],
				2,
				3
			],
			'constructor max option greater than regular constructor option' => [
				[
					'maxConnTimeout' => 2,
					'maxReqTimeout' => 3,
					'connTimeout' => 1,
					'reqTimeout' => 1
				],
				[],
				1,
				1
			],
			'constructor max option limits run option' => [
				[
					'maxConnTimeout' => 2,
					'maxReqTimeout' => 3,
				],
				[
					'connTimeout' => 100,
					'reqTimeout' => 100
				],
				2,
				3
			],
		];
	}

	/**
	 * Test of timeout parameter handling
	 * @dataProvider provideMultiHttpTimeout
	 */
	public function testMultiHttpTimeout( $createOptions, $runOptions,
		$expectedConnTimeout, $expectedReqTimeout
	) {
		$url = 'http://www.example.test';
		$httpRequest = $this->getHttpRequest( StatusValue::newGood( 200 ), 200 );
		$factory = $this->createMock( MediaWiki\Http\HttpRequestFactory::class );
		$factory->method( 'create' )
			->with(
				$url,
				$this->callback(
					static function ( $options ) use ( $expectedReqTimeout, $expectedConnTimeout ) {
						return $options['timeout'] === $expectedReqTimeout
							&& $options['connectTimeout'] === $expectedConnTimeout;
					}
				)
			)
			->willReturn( $httpRequest );
		$this->setService( 'HttpRequestFactory', $factory );

		$client = $this->createClient( $createOptions );

		$client->run(
			[ 'method' => 'GET', 'url' => $url ],
			$runOptions
		);

		$this->addToAssertionCount( 1 );
	}

	public function testUseReverseProxy() {
		// TODO: Cannot use TestingAccessWrapper here because it doesn't
		// support pass-by-reference (T287318)
		$class = new ReflectionClass( MultiHttpClient::class );
		$func = $class->getMethod( 'useReverseProxy' );
		$func->setAccessible( true );
		$req = [
			'url' => 'https://example.org/path?query=string',
		];
		$func->invokeArgs( new MultiHttpClient( [] ), [ &$req, 'http://localhost:1234' ] );
		$this->assertSame( 'http://localhost:1234/path?query=string', $req['url'] );
		$this->assertSame( 'example.org', $req['headers']['Host'] );
	}

	public function testNormalizeRequests() {
		// TODO: Cannot use TestingAccessWrapper here because it doesn't
		// support pass-by-reference (T287318)
		$class = new ReflectionClass( MultiHttpClient::class );
		$func = $class->getMethod( 'normalizeRequests' );
		$func->setAccessible( true );
		$reqs = [
			[ 'GET', 'https://example.org/path?query=string' ],
			[
				'method' => 'GET',
				'url' => 'https://example.com/path?query=another%20string',
				'headers' => [
					'header2' => 'value2'
				]
			],
		];
		$client = new MultiHttpClient( [
			'localVirtualHosts' => [ 'example.org' ],
			'localProxy' => 'http://localhost:1234',
			'headers' => [
				'header1' => 'value1'
			]
		] );
		$func->invokeArgs( $client, [ &$reqs ] );
		// Both requests have the default header added
		$this->assertSame( 'value1', $reqs[0]['headers']['header1'] );
		$this->assertSame( 'value1', $reqs[1]['headers']['header1'] );
		// Only Req #1 has an additional header
		$this->assertSame( 'value2', $reqs[1]['headers']['header2'] );
		$this->assertArrayNotHasKey( 'header2', $reqs[0]['headers'] );

		// Req #0 transformed to use reverse proxy
		$this->assertSame( 'http://localhost:1234/path?query=string', $reqs[0]['url'] );
		$this->assertSame( 'example.org', $reqs[0]['headers']['host'] );
		$this->assertFalse( $reqs[0]['proxy'] );
		// Req #1 left alone, domain doesn't match
		$this->assertSame( 'https://example.com/path?query=another%20string', $reqs[1]['url'] );
	}

	/**
	 * @dataProvider provideAssembleUrl
	 * @param array $bits
	 * @param string $expected
	 * @throws ReflectionException
	 */
	public function testAssembleUrl( array $bits, string $expected ) {
		$class = TestingAccessWrapper::newFromClass( MultiHttpClient::class );
		$this->assertSame( $expected, $class->assembleUrl( $bits ) );
	}

	public static function provideAssembleUrl(): Generator {
		$schemes = [
			'' => [],
			'http://' => [
				'scheme' => 'http',
			],
		];

		$hosts = [
			'' => [],
			'example.com' => [
				'host' => 'example.com',
			],
			'example.com:123' => [
				'host' => 'example.com',
				'port' => 123,
			],
			'id@example.com' => [
				'user' => 'id',
				'host' => 'example.com',
			],
			'id@example.com:123' => [
				'user' => 'id',
				'host' => 'example.com',
				'port' => 123,
			],
			'id:key@example.com' => [
				'user' => 'id',
				'pass' => 'key',
				'host' => 'example.com',
			],
			'id:key@example.com:123' => [
				'user' => 'id',
				'pass' => 'key',
				'host' => 'example.com',
				'port' => 123,
			],
		];

		foreach ( $schemes as $scheme => $schemeParts ) {
			foreach ( $hosts as $host => $hostParts ) {
				foreach ( [ '', '/', '/0', '/path' ] as $path ) {
					foreach ( [ '', '0', 'query' ] as $query ) {
						foreach ( [ '', '0', 'fragment' ] as $fragment ) {
							$parts = array_merge(
								$schemeParts,
								$hostParts
							);
							$url = $scheme .
								$host .
								$path;

							if ( $path !== '' ) {
								$parts['path'] = $path;
							}
							if ( $query !== '' ) {
								$parts['query'] = $query;
								$url .= '?' . $query;
							}
							if ( $fragment !== '' ) {
								$parts['fragment'] = $fragment;
								$url .= '#' . $fragment;
							}

							yield [ $parts, $url ];
						}
					}
				}
			}
		}

		yield [
			[
				'scheme' => 'http',
				'user' => 'id',
				'pass' => 'key',
				'host' => 'example.org',
				'port' => 321,
				'path' => '/over/there',
				'query' => 'name=ferret&foo=bar',
				'fragment' => 'nose',
			],
			'http://id:key@example.org:321/over/there?name=ferret&foo=bar#nose',
		];

		// Account for parse_url() on PHP >= 8 returning an empty query field for URLs ending with
		// '?' such as "http://url.with.empty.query/foo?" (T268852)
		yield [
			[
				'scheme' => 'http',
				'host' => 'url.with.empty.query',
				'path' => '/foo',
				'query' => '',
			],
			'http://url.with.empty.query/foo',
		];
	}

	public static function provideHeader() {
		// Invalid
		yield 'colon space' => [ false, [ 'Foo: X' => 'Y' ] ];
		yield 'colon' => [ false, [ 'Foo:bar' => 'X' ] ];
		yield 'two colon' => [ false, [ 'Foo:bar:baz' => 'X' ] ];
		yield 'trailing colon' => [ false, [ 'Foo:' => 'Y' ] ];
		yield 'leading colon' => [ false, [ ':Foo' => 'Y' ] ];
		// Valid
		yield 'word' => [ true, [ 'Foo' => 'X' ] ];
		yield 'dash' => [ true, [ 'Foo-baz' => 'X' ] ];
	}

	/**
	 * @dataProvider provideHeader
	 */
	public function testNormalizeIllegalHeader( bool $valid, array $headers ) {
		$class = new ReflectionClass( MultiHttpClient::class );
		$func = $class->getMethod( 'getCurlHandle' );
		$func->setAccessible( true );
		$req = [
			'method' => 'GET',
			'url' => 'http://localhost:1234',
			'query' => [],
			'body' => '',
			'headers' => $headers
		];

		if ( $valid ) {
			$this->expectNotToPerformAssertions();
		} else {
			$this->expectException( Exception::class );
			$this->expectExceptionMessage( 'Header name must not contain colon-space' );
		}
		$func->invokeArgs( new MultiHttpClient( [] ), [ &$req, [
			'connTimeout' => 1,
			'reqTimeout' => 1,
		] ] );
		// TODO: Factor out curl_multi_exec so can stub that,
		// and then simply test the public runMulti() method here.
		// Or move more logic to normalizeRequests and test that.
	}

	public function testForwardsTelemetryHeaders() {
		$telemetry = $this->getMockBuilder( TelemetryHeadersInterface::class )
			->getMock();
		$telemetry->expects( $this->once() )
			->method( 'getRequestHeaders' )
			->willReturn( [ 'header1' => 'value1', 'header2' => 'value2' ] );

		// TODO: Cannot use TestingAccessWrapper here because it doesn't
		// support pass-by-reference (T287318)
		$class = new ReflectionClass( MultiHttpClient::class );
		$func = $class->getMethod( 'normalizeRequests' );
		$func->setAccessible( true );
		$reqs = [
			[ 'GET', 'https://example.org/path?query=string' ],
		];
		$client = new MultiHttpClient( [
			'localVirtualHosts' => [ 'example.org' ],
			'localProxy' => 'http://localhost:1234',
			'telemetry' => $telemetry
		] );
		$func->invokeArgs( $client, [ &$reqs ] );
		$this->assertArrayHasKey( 'header1', $reqs[0]['headers'] );
		$this->assertSame( 'value1', $reqs[0]['headers']['header1'] );
		$this->assertArrayHasKey( 'header2', $reqs[0]['headers'] );
		$this->assertSame( 'value2', $reqs[0]['headers']['header2'] );
	}

	public function testGetCurlMulti() {
		$cm = TestingAccessWrapper::newFromObject( new MultiHttpClient( [] ) );
		$resource = $cm->getCurlMulti( [ 'usePipelining' => true ] );
		$this->assertThat(
			$resource,
			$this->logicalOr(
				$this->isType( IsType::TYPE_RESOURCE ),
				$this->isInstanceOf( 'CurlMultiHandle' )
			)
		);
	}
}
PK       ! }>cs.  s.  -  libs/serialization/SerializationTestTrait.phpnu Iw        <?php

// phpcs:disable MediaWiki.Commenting.FunctionComment.ObjectTypeHintParam
// phpcs:disable MediaWiki.Commenting.FunctionComment.MissingParamTag -- Traits are not excluded

namespace Wikimedia\Tests;

use Generator;
use ReflectionClass;

trait SerializationTestTrait {

	/**
	 * Data provider for deserialization test.
	 * - For each supported serialization format defined by ::getSupportedSerializationFormats
	 *  - For each acceptance test instance defined by ::getTestInstancesAndAssertions
	 *   - For each object deserialized from stored file for a particular MW version
	 * @return Generator for [ callable $deserializer, object $expectedObject, string $dataToDeserialize ]
	 */
	public function provideTestDeserialization(): Generator {
		// Creation of dynamic property is deprecated, can happen as backward-compatibility check
		$this->markTestSkippedIfPhp( '>=', '8.2' );

		$className = self::getClassToTest();
		foreach ( self::getSupportedSerializationFormats() as $serializationFormat ) {
			$serializationUtils = new SerializationTestUtils(
				self::getSerializedDataPath(),
				self::getTestInstances( self::getTestInstancesAndAssertions() ),
				$serializationFormat['ext'],
				$serializationFormat['serializer'],
				$serializationFormat['deserializer']
			);
			foreach ( $serializationUtils->getTestInstances() as $testCaseName => $expectedObject ) {
				$deserializationFixtures = $serializationUtils->getFixturesForTestCase(
					$className,
					$testCaseName
				);
				foreach ( $deserializationFixtures as $deserializedObjectInfo ) {
					yield "{$className}:{$testCaseName}, " .
						"deserialized from {$deserializedObjectInfo->ext}, " .
						"{$deserializedObjectInfo->version}" =>
							[ $serializationFormat['deserializer'], $expectedObject, $deserializedObjectInfo->data ];
				}
			}
		}
	}

	/**
	 * Tests that $deserialized objects retrieved from stored files for various MW versions
	 * equal to the $expected
	 * @dataProvider provideTestDeserialization
	 */
	public function testDeserialization( callable $deserializer, object $expected, string $data ) {
		$deserialized = $deserializer( $data );
		$this->assertInstanceOf( self::getClassToTest(), $deserialized );
		$this->validateObjectEquality( $expected, $deserialized );
	}

	/**
	 * Data provider for serialization test.
	 * - For each supported serialization format defined by ::getSupportedSerializationFormats
	 *  - For each acceptance test instance defined by ::getTestInstancesAndAssertions
	 * @return Generator for [ callable $serializer, string $expectedSerialization, object $testInstanceToSerialize ]
	 */
	public function provideSerialization(): Generator {
		// Creation of dynamic property is deprecated, can happen as backward-compatibility check
		$this->markTestSkippedIfPhp( '>=', '8.2' );

		$className = self::getClassToTest();
		foreach ( self::getSupportedSerializationFormats() as $serializationFormat ) {
			$serializationUtils = new SerializationTestUtils(
				self::getSerializedDataPath(),
				self::getTestInstances( self::getTestInstancesAndAssertions() ),
				$serializationFormat['ext'],
				$serializationFormat['serializer'],
				$serializationFormat['deserializer']
			);
			foreach ( $serializationUtils->getTestInstances() as $testCaseName => $testInstance ) {
				$expected = $serializationUtils->getStoredSerializedInstance( $className, $testCaseName );

				if ( $expected->data === null ) {
					// The fixture file is missing. This will be detected and reported elsewhere.
					// No need to cause an error here.
					continue;
				}

				yield "{$className}:{$testCaseName}, " .
					"serialized with {$serializationFormat['ext']}" =>
						[
							$serializationFormat['serializer'],
							$serializationFormat['deserializer'],
							$expected->data,
							$testInstance
						];
			}
		}
	}

	/**
	 * Test that the current master $serialized instances are
	 * equal to stored $expected instances.
	 * Serialization formats might change in backwards compatible ways
	 * (in particular, php 8.1 orders protected instance variables differently
	 * than earlier php), so do the comparision on the deserialized version.
	 * @dataProvider provideSerialization
	 */
	public function testSerialization( callable $serializer, callable $deserializer, string $expected, object $testInstance ) {
		$serTestInstance = $serializer( $testInstance );
		$deserExpected = $deserializer( $expected );
		$this->assertNotEmpty( $deserExpected );
		$deserTestInstance = $deserializer( $serTestInstance );
		$this->assertNotEmpty( $deserTestInstance );

		$this->validateObjectEquality( $deserExpected, $deserTestInstance );
	}

	/**
	 * Data provider for serialization round trip test.
	 * - For each supported serialization format defined by ::getSupportedSerializationFormats
	 *  - For each test instance defined by ::getTestInstances
	 * @return Generator for [ object $instance, callable $serializer, callable $deserializer ]
	 */
	public static function provideSerializationRoundTrip(): Generator {
		$testCases = self::getTestInstancesAndAssertions();
		$className = self::getClassToTest();
		foreach ( self::getSupportedSerializationFormats() as $serializationFormat ) {
			foreach ( $testCases as $testCaseName => [ 'instance' => $instance ] ) {
				yield "{$className}:{$testCaseName}, " .
					"serialized with {$serializationFormat['ext']}" => [
						$instance,
						$serializationFormat['serializer'],
						$serializationFormat['deserializer']
					];
			}
		}
	}

	/**
	 * Test that the $expected instance can be serialized and successfully be deserialized again.
	 *
	 * @dataProvider provideSerializationRoundTrip
	 */
	public function testSerializationRoundTrip(
		object $instance,
		callable $serializer,
		callable $deserializer
	) {
		$blob = $serializer( $instance );
		$this->assertNotEmpty( $blob );

		$actual = $deserializer( $blob );
		$this->assertNotEmpty( $actual );

		$this->validateObjectEquality( $instance, $actual );
	}

	/**
	 * @param mixed $expected
	 * @param mixed $actual
	 * @param string|null $propName
	 */
	private function validateArrayEquality( $expected, $actual, ?string $propName = null ) {
		$this->assertIsArray( $actual, "$propName: Expected array." );
		$eKeys = array_keys( $expected );
		$aKeys = array_keys( $actual );
		$this->assertSame( count( $eKeys ), count( $aKeys ), "$propName: Expected equal-sized arrays." );
		$i = 0;
		foreach ( $expected as $k => $v ) {
			$this->validateEquality( $k, $aKeys[$i], "$propName:$i" );
			$this->validateEquality( $v, $actual[$k], $k );
			$i++;
		}
	}

	/**
	 * @param mixed $expected
	 * @param mixed $actual
	 * @param string|null $propName
	 */
	private function validateEquality( $expected, $actual, ?string $propName = null ) {
		if ( is_array( $expected ) ) {
			$this->validateArrayEquality( $expected, $actual, $propName );
		} elseif ( is_object( $expected ) ) {
			$this->assertIsObject( $actual, "Expected an object, but found: " . get_debug_type( $actual ) );
			$this->validateObjectEquality( $expected, $actual );
		} else {
			$this->assertSame( $expected, $actual, $propName );
		}
	}

	/**
	 * Asserts that all the fields across class hierarchy for
	 * provided objects are equal.
	 * @param object $expected
	 * @param object $actual
	 * @param ReflectionClass|null $class
	 */
	private function validateObjectEquality(
		object $expected,
		object $actual,
		?ReflectionClass $class = null
	) {
		if ( !$class ) {
			$class = new ReflectionClass( $expected );
		}

		foreach ( $class->getProperties() as $prop ) {
			$prop->setAccessible( true );
			$this->validateEquality(
				$prop->getValue( $expected ),
				$prop->getValue( $actual ),
				$prop->getName()
			);
		}

		$parent = $class->getParentClass();
		if ( $parent ) {
			$this->validateObjectEquality( $expected, $actual, $parent );
		}
	}

	/**
	 * Data provider for acceptance testing, returning object instances created by current code.
	 * - For each acceptance test instance defined by ::getTestInstancesAndAssertions
	 * @return Generator for [ $instance which to run assertions on, $assertionsCallback ]
	 */
	public static function provideCurrentVersionTestObjects(): Generator {
		$className = self::getClassToTest();
		$testCases = self::getTestInstancesAndAssertions();
		foreach ( $testCases as $testCaseName => $testCase ) {
			yield "{$className}:{$testCaseName}, current" =>
			[ $testCase['instance'], $testCase['assertions'] ];
		}
	}

	/**
	 * Data provider for acceptance testing, returning instances deserialized
	 * from stored files for various MW versions.
	 * - For each supported serialization format defined by ::getSupportedSerializationFormats
	 *  - For each object deserialized from stored file for a particular MW version
	 * @return Generator for [ $instance which to run assertions on, $assertionsCallback ]
	 */
	public function provideDeserializedTestObjects(): Generator {
		// Creation of dynamic property is deprecated, can happen as backward-compatibility check
		$this->markTestSkippedIfPhp( '>=', '8.2' );

		$className = self::getClassToTest();
		$testCases = self::getTestInstancesAndAssertions();
		$testObjects = self::getTestInstances( $testCases );
		foreach ( self::getSupportedSerializationFormats() as $serializationFormat ) {
			$serializationUtils = new SerializationTestUtils(
				self::getSerializedDataPath(),
				$testObjects,
				$serializationFormat['ext'],
				$serializationFormat['serializer'],
				$serializationFormat['deserializer']
			);
			foreach ( $testCases as $testCaseName => [ 'assertions' => $assertions ] ) {
				$deserializedObjects = $serializationUtils->getDeserializedInstancesForTestCase(
					$className,
					$testCaseName
				);
				foreach ( $deserializedObjects as $deserializedObjectInfo ) {
					yield "{$className}:{$testCaseName}, " .
						"deserialized from {$deserializedObjectInfo->ext}, " .
						"{$deserializedObjectInfo->version}" =>
					[
						$deserializedObjectInfo->object,
						$assertions
					];
				}
			}
		}
	}

	/**
	 * Tests that assertions in $assertionsCallback succeed on $testInstance.
	 * @see self::getTestInstancesAndAssertions()
	 * @dataProvider provideDeserializedTestObjects
	 * @dataProvider provideCurrentVersionTestObjects
	 */
	public function testAcceptanceOfDeserializedInstances(
		object $testInstance,
		callable $assertionsCallback
	) {
		call_user_func( $assertionsCallback, $this, $testInstance );
	}

	/**
	 * Returns a map of $testCaseName to an instance to test.
	 * @param array[] $instancesAndAssertions
	 * @return array
	 */
	private static function getTestInstances( array $instancesAndAssertions ): array {
		return array_map( static function ( $testCase ) {
			return $testCase['instance'];
		}, $instancesAndAssertions );
	}

	/**
	 * @return string the name of the class to test.
	 */
	abstract public static function getClassToTest(): string;

	/**
	 * @return string the path to serialized data.
	 */
	abstract public static function getSerializedDataPath(): string;

	/**
	 * @return array a map of $testCaseName to a map, containing the following keys:
	 *  - 'instance' => an instance of the object to perform assertions on.
	 *  - 'assertions' => a callable that performs assertions on the deserialized objects.
	 *  Callable signature: ( MediaWikiIntegrationTestCase $testCase, object $instance )
	 */
	abstract public static function getTestInstancesAndAssertions(): array;

	/**
	 * Get a list of serialization formats supported by the tested class.
	 * @return string[][] a list of supported serialization formats info map,
	 * containing the following keys:
	 *  - 'ext' => string file extension for stored serializations
	 *  - 'serializer' => callable to serialize objects
	 *  - 'deserializer' => callable to deserialize objects
	 */
	abstract public static function getSupportedSerializationFormats(): array;
}
PK       ! f    -  libs/serialization/SerializationTestUtils.phpnu Iw        <?php

// phpcs:disable MediaWiki.Commenting.FunctionComment.ObjectTypeHintReturn

/**
 * 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
 * @ingroup Cache Parser
 */

namespace Wikimedia\Tests;

use InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

/**
 * Utilities for testing forward and backward compatibility serialized objects.
 */
class SerializationTestUtils {

	/** @var string */
	private $serializedDataPath;

	/** @var string */
	private $ext;

	/** @var callable */
	private $serializer;

	/** @var callable */
	private $deserializer;

	/** @var array */
	private $testInstances;

	/** @var LoggerInterface */
	private $logger;

	/**
	 * @param string $serializedDataPath absolute path to the directory with serialized objects.
	 * @param array $testInstances map of test names to test objects.
	 * @param string $ext file extension for serialized instance files
	 * @param callable $serializer
	 * @param callable $deserializer
	 */
	public function __construct(
		string $serializedDataPath,
		array $testInstances,
		string $ext,
		callable $serializer,
		callable $deserializer
	) {
		if ( !is_dir( $serializedDataPath ) ) {
			throw new InvalidArgumentException( "{$serializedDataPath} does not exist" );
		}
		$this->serializedDataPath = $serializedDataPath;
		$this->ext = $ext;
		$this->serializer = $serializer;
		$this->deserializer = $deserializer;
		$this->testInstances = $testInstances;
		$this->logger = new NullLogger();
	}

	/**
	 * @param LoggerInterface $logger
	 */
	public function setLogger( LoggerInterface $logger ): void {
		$this->logger = $logger;
	}

	/**
	 * Get the files with stored serialized instances of $class with extension $ext.
	 * @param class-string $class
	 * @param string $ext
	 * @return array
	 */
	private function getMatchingFiles( string $class, string $ext ): array {
		$classFile = self::classToFile( $class );
		$glob = $this->serializedDataPath . "/*-{$classFile}-*.$ext";
		$matches = glob( $glob );

		if ( !$matches ) {
			$this->log( 'No matches found for ' . $glob );
			return [];
		}

		// File names should look something like this: "1.35-CacheTime-empty.serialized".
		$pattern = '!/([^/-]+)-' . preg_quote( $classFile, '!' ) . '-([^/-]+)\.[^/]+$!';

		$files = [];
		foreach ( $matches as $path ) {
			if ( !preg_match( $pattern, $path, $m ) ) {
				$this->logger->warning( 'Skipping file with malformed name: ' . $path );
				continue;
			}
			$version = $m[1];
			$testCaseName = $m[2];

			$files[] = $this->getStoredSerializedInstance( $class, $testCaseName, $version );
		}

		return $files;
	}

	/**
	 * Get an array of test instances for $class keyed with test case name.
	 * @return object[]
	 */
	public function getTestInstances(): array {
		return $this->testInstances;
	}

	/**
	 * Get a test instance of $class for test case named $testCaseName.
	 * @param string $testCaseName
	 * @return object
	 */
	public function getTestInstanceForTestCase( string $testCaseName ): object {
		$instances = $this->getTestInstances();
		if ( !array_key_exists( $testCaseName, $instances ) ) {
			throw new InvalidArgumentException(
				"Test instance not found for test case {$testCaseName}"
			);
		}
		return $instances[$testCaseName];
	}

	/**
	 * Get an array of instances of $class deserialized from
	 * files for different code versions, keyed by the test case name.
	 * @param class-string $class
	 * @return array
	 */
	private function getDeserializedInstances( string $class ): array {
		return array_map( function ( $fileInfo ) {
			$fileInfo->object = call_user_func( $this->deserializer, $fileInfo->data );
			return $fileInfo;
		}, $this->getMatchingFiles( $class, $this->ext ) );
	}

	/**
	 * Get an array of serialization fixtures for $class stored in files
	 * for different MW versions, for test case name $testCaseName.
	 * @param class-string $class
	 * @param string $testCaseName
	 * @return array
	 */
	public function getFixturesForTestCase( string $class, string $testCaseName ): array {
		return array_filter(
			$this->getMatchingFiles( $class, $this->ext ),
			static function ( $fileInfo ) use ( $testCaseName ) {
				return $fileInfo->testCaseName === $testCaseName;
			} );
	}

	/**
	 * Get an array of instances of $class deserialized from stored files
	 * for different MW versions, for test case named $testCaseName.
	 * @param class-string $class
	 * @param string $testCaseName
	 * @return array
	 */
	public function getDeserializedInstancesForTestCase( string $class, string $testCaseName ): array {
		return array_filter( $this->getDeserializedInstances( $class ),
			static function ( $fileInfo ) use ( $testCaseName ) {
				return $fileInfo->testCaseName === $testCaseName;
			}
		);
	}

	/**
	 * Get test objects of $class, serialized using $serializer,
	 * keyed by test case name.
	 * @return array
	 */
	public function getSerializedInstances(): array {
		$instances = $this->getTestInstances();
		return array_map( function ( $object )  {
			return call_user_func( $this->serializer, $object );
		}, $instances );
	}

	/**
	 * Get the file info about a stored serialized instance of $class,
	 * for test case $testCaseName with extension $ext for $version of MW.
	 * @param class-string $class
	 * @param string $testCaseName
	 * @param string|null $version
	 * @return \stdClass
	 */
	public function getStoredSerializedInstance(
		string $class,
		string $testCaseName,
		?string $version = null
	) {
		$classFile = self::classToFile( $class );
		$curPath = "$this->serializedDataPath/{$this->getCurrentVersion()}-$classFile-$testCaseName.$this->ext";
		if ( $version ) {
			$path = "$this->serializedDataPath/$version-$classFile-$testCaseName.$this->ext";
		} else {
			// Find the latest version we have saved.
			$savedFiles = glob( "$this->serializedDataPath/?.??*-$classFile-$testCaseName.$this->ext" );
			if ( count( $savedFiles ) > 0 ) {
				// swap _ and - to ensure that 1.43-foo sorts after 1.43_wmf...-foo
				usort(
					$savedFiles,
					fn ( $a, $b ) => strtr( $a, '-_', '_-' ) <=> strtr( $b, '-_', '_-' )
				);
				$path = end( $savedFiles );
			} else {
				// Handle creation of a new test case from scratch (no prior
				// serialization file exists)
				$path = $curPath;
			}
		}

		return (object)[
			'version' => $version,
			'class' => $class,
			'testCaseName' => $testCaseName,
			'ext' => $this->ext,
			'path' => $path,
			'currentVersionPath' => $curPath,
			'data' => is_file( $path ) ? file_get_contents( $path ) : null,
		];
	}

	/**
	 * Returns the current version of MediaWiki in `1.xx` format.
	 * @return string
	 */
	private function getCurrentVersion(): string {
		return preg_replace( '/^(\d\.\d+).*$/', '$1', MW_VERSION );
	}

	/**
	 * Clean up the class name to make a filename.
	 *
	 * At the moment this strips the namespace prefix; in the future
	 * we might consider keeping it but replacing backslashes with
	 * dashes or some such.
	 *
	 * @param class-string $class
	 * @return string A cleaned-up filename
	 */
	private static function classToFile( string $class ): string {
		$arr = explode( '\\', $class );
		return end( $arr );
	}

	private function log( $msg ) {
		$this->logger->info( $msg );
	}
}
PK       ! ܺ!  !  5  libs/filebackend/fsfile/TempFSFileIntegrationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use Wikimedia\FileBackend\FSFile\TempFSFile;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Tests\FileBackend\FSFile\TempFSFileTestTrait;

/**
 * Just to test one deprecated method and one line of ServiceWiring code.
 */
class TempFSFileIntegrationTest extends MediaWikiIntegrationTestCase {
	use TempFSFileTestTrait;

	/**
	 * @coversNothing
	 */
	public function testServiceWiring() {
		$this->overrideConfigValue( MainConfigNames::TmpDirectory, '/hopefully invalid' );
		$factory = $this->getServiceContainer()->getTempFSFileFactory();
		$this->assertSame( '/hopefully invalid',
			( TestingAccessWrapper::newFromObject( $factory ) )->tmpDirectory );
	}

	// For TempFSFileTestTrait
	private function newFile() {
		return TempFSFile::factory( 'tmp' );
	}
}
PK       ! NU@R  R    language/TimeAdjustTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @group Language
 * @covers \MediaWiki\Language\Language::userAdjust
 */
class TimeAdjustTest extends MediaWikiLangTestCase {
	private const LOCAL_TZ_OFFSET = 17;

	/**
	 * Test offset usage for a given Language::userAdjust
	 * @dataProvider dataUserAdjust
	 */
	public function testUserAdjust( string $date, $correction, string $expected ) {
		$this->overrideConfigValue( MainConfigNames::LocalTZoffset, self::LOCAL_TZ_OFFSET );

		$this->assertSame(
			$expected,
			$this->getServiceContainer()->getContentLanguage()->userAdjust( $date, $correction )
		);
	}

	public static function dataUserAdjust() {
		// Note: make sure to use dates in the past, especially with geographical time zones, to avoid any
		// chance of tests failing due to a change to the time zone rules.
		yield 'Literal int 0 (technically undocumented)' => [ '20221015120000', 0, '20221015120000' ];
		yield 'Literal int 2 (technically undocumented)' => [ '20221015120000', 2, '20221015140000' ];
		yield 'Literal int -2 (technically undocumented)' => [ '20221015120000', -2, '20221015100000' ];

		yield 'Literal 0' => [ '20221015120000', '0', '20221015120000' ];
		yield 'Literal 5' => [ '20221015120000', '5', '20221015170000' ];
		yield 'Literal -5' => [ '20221015120000', '-5', '20221015070000' ];

		$offsetsData = [
			'+00:00' => [ '20221015120000', '20221015120000', 0 ],
			'+02:00' => [ '20221015120000', '20221015140000', 2 * 60 ],
			'+02:15' => [ '20221015120000', '20221015141500', 2.25 * 60 ],
			'+14:00' => [ '20221015120000', '20221016020000', 14 * 60 ],
			'-06:00' => [ '20221015120000', '20221015060000', -6 * 60 ],
			'-06:45' => [ '20221015120000', '20221015051500', -6.75 * 60 ],
			'-12:00' => [ '20221015120000', '20221015000000', -12 * 60 ],
		];
		foreach ( $offsetsData as $offset => [ $time, $expected, $minutesVal ] ) {
			yield "Literal $offset" => [ $time, $offset, $expected ];
			yield "Full format $offset" => [ $time, "Offset|$minutesVal", $expected ];
		}
		yield 'Literal +15:00, capped at +14' => [ '20221015120000', '+15:00', '20221016020000' ];
		yield 'Full format +15:00, capped at +14' => [ '20221015120000', 'Offset|' . ( 15 * 60 ), '20221016020000' ];
		yield 'Literal -13:00, capped at -12' => [ '20221015120000', '-13:00', '20221015000000' ];
		yield 'Full format -13:00, capped at -12' => [ '20221015120000', 'Offset|' . ( -13 * 60 ), '20221015000000' ];

		yield 'Geo: Europe/Rome when +2 and +2 is stored' => [
			'20221015120000',
			'ZoneInfo|120|Europe/Rome',
			'20221015140000'
		];
		yield 'Geo: Europe/Rome when +2 and +1 is stored' => [
			'20221015120000',
			'ZoneInfo|60|Europe/Rome',
			'20221015140000'
		];
		yield 'Geo: Europe/Rome when +1 and +2 is stored' => [
			'20220320120000',
			'ZoneInfo|120|Europe/Rome',
			'20220320130000'
		];
		yield 'Geo: Europe/Rome when +1 and +1 is stored' => [
			'20220320120000',
			'ZoneInfo|60|Europe/Rome',
			'20220320130000'
		];

		yield 'Invalid geographical zone, fall back to offset' => [
			'20221015120000',
			'ZoneInfo|42|Eriador/Hobbiton',
			'20221015124200'
		];

		// These fall back to the local offset
		yield 'System 0, fallback to local offset' => [ '20221015120000', 'System|0', '20221015121700' ];
		yield 'System 120, fallback to local offset' => [ '20221015120000', 'System|120', '20221015121700' ];
		yield 'System -60, fallback to local offset' => [ '20221015120000', 'System|-60', '20221015121700' ];

		yield 'Garbage, fallback to local offset' => [ '20221015120000', 'WhatAmIEvenDoingHere', '20221015121700' ];
		yield 'Empty string, fallback to local offset' => [ '20221015120000', '', '20221015121700' ];

		yield 'T32148 - local date in year 10000' => [
			'99991231235959',
			'ZoneInfo|600|Asia/Vladivostok',
			'99991231235959'
		];
		yield 'T32148 - date in year 10000 due to local offset' => [
			'99991231235959',
			'System|0',
			'99991231235959'
		];
	}
}
PK       ! [!7  7  "  language/LanguageConverterTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Language\Language;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageReference;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\Options\UserOptionsLookup;
use MediaWiki\User\User;

/**
 * @group Language
 * @covers \MediaWiki\Language\LanguageConverter
 */
class LanguageConverterTest extends MediaWikiLangTestCase {

	/** @var Language */
	protected $lang;

	/** @var DummyConverter */
	protected $lc;

	/**
	 * @param User $user
	 */
	private function setContextUser( User $user ) {
		// LanguageConverter::getPreferredVariant() reads the user from
		// RequestContext::getMain(), so set it occordingly
		RequestContext::getMain()->setUser( $user );
	}

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValues( [
			MainConfigNames::LanguageCode => 'en',
			MainConfigNames::DefaultLanguageVariant => false,
		] );
		$this->setContentLang( 'tg' );
		$this->setContextUser( new User );

		$this->lang = $this->createNoOpMock( Language::class, [ 'factory', 'getNsText', 'ucfirst' ] );
		$this->lang->method( 'getNsText' )->with( NS_MEDIAWIKI )->willReturn( 'MediaWiki' );
		$this->lang->method( 'ucfirst' )->willReturnCallback( 'ucfirst' );
		$this->lc = new DummyConverter( $this->lang );
	}

	protected function tearDown(): void {
		unset( $this->lc );
		unset( $this->lang );

		parent::tearDown();
	}

	public function testGetPreferredVariantDefaults() {
		$this->assertEquals( 'tg', $this->lc->getPreferredVariant() );
	}

	/**
	 * @dataProvider provideGetPreferredVariant
	 */
	public function testGetPreferredVariant( $requestVal, $expected ) {
		$request = RequestContext::getMain()->getRequest();
		$request->setVal( 'variant', $requestVal );

		$this->assertEquals( $expected, $this->lc->getPreferredVariant() );
	}

	public static function provideGetPreferredVariant() {
		yield 'normal (tg-latn)' => [ 'tg-latn', 'tg-latn' ];
		yield 'deprecated (bat-smg)' => [ 'bat-smg', 'sgs' ];
		yield 'BCP47 (en-simple)' => [ 'en-simple', 'simple' ];
	}

	/**
	 * @dataProvider provideGetPreferredVariantHeaders
	 */
	public function testGetPreferredVariantHeaders( $headerVal, $expected ) {
		$request = RequestContext::getMain()->getRequest();
		$request->setHeader( 'Accept-Language', $headerVal );

		$this->assertEquals( $expected, $this->lc->getPreferredVariant() );
	}

	public static function provideGetPreferredVariantHeaders() {
		yield 'normal (tg-latn)' => [ 'tg-latn', 'tg-latn' ];
		yield 'BCP47 (en-simple)' => [ 'en-simple', 'simple' ];
		yield 'with weight #1' => [ 'tg;q=1', 'tg' ];
		yield 'with weight #2' => [ 'tg-latn;q=1', 'tg-latn' ];
		yield 'with multi' => [ 'en, tg-latn;q=1', 'tg-latn' ];
	}

	/**
	 * @dataProvider provideGetPreferredVariantUserOption
	 */
	public function testGetPreferredVariantUserOption( $optionVal, $expected, $foreignLang ) {
		$optionName = 'variant';
		if ( $foreignLang ) {
			$this->setContentLang( 'en' );
			$optionName = 'variant-tg';
		}

		$user = new User;
		$user->load(); // from 'defaults'
		$user->mId = 1;
		$user->mDataLoaded = true;

		$userOptionsLookup = $this->createMock( UserOptionsLookup::class );
		$userOptionsLookup->method( 'getOption' )
			->with( $user, $optionName )
			->willReturn( $optionVal );
		$this->setService( 'UserOptionsLookup', $userOptionsLookup );

		$this->setContextUser( $user );

		$this->assertEquals( $expected, $this->lc->getPreferredVariant() );
	}

	public static function provideGetPreferredVariantUserOption() {
		yield 'normal (tg-latn)' => [ 'tg-latn', 'tg-latn', false ];
		yield 'deprecated (bat-smg)' => [ 'bat-smg', 'sgs', false ];
		yield 'BCP47 (en-simple)' => [ 'en-simple', 'simple', false ];
		yield 'for foreign language, normal (tg-latn)' => [ 'tg-latn', 'tg-latn', true ];
		yield 'for foreign language, deprecated (bat-smg)' => [ 'bat-smg', 'sgs', true ];
		yield 'for foreign language, BCP47 (en-simple)' => [ 'en-simple', 'simple', true ];
	}

	public function testGetPreferredVariantHeaderUserVsUrl() {
		$request = RequestContext::getMain()->getRequest();

		$this->setContentLang( 'tg-latn' );
		$request->setVal( 'variant', 'tg' );

		$user = User::newFromId( "admin" );
		$user->setId( 1 );
		$user->mFrom = 'defaults';
		// The user's data is ignored because the variant is set in the URL.
		$userOptionsLookup = $this->createMock( UserOptionsLookup::class );
		$userOptionsLookup->method( 'getOption' )
			->with( $user, 'variant' )
			->willReturn( 'tg-latn' );
		$this->setService( 'UserOptionsLookup', $userOptionsLookup );

		$this->setContextUser( $user );

		$this->assertEquals( 'tg', $this->lc->getPreferredVariant() );
	}

	/**
	 * @dataProvider provideGetPreferredVariant
	 */
	public function testGetPreferredVariantDefaultLanguageVariant( $globalVal, $expected ) {
		$this->overrideConfigValue( MainConfigNames::DefaultLanguageVariant, $globalVal );
		$this->assertEquals( $expected, $this->lc->getPreferredVariant() );
	}

	public function testGetPreferredVariantDefaultLanguageVsUrlVariant() {
		$request = RequestContext::getMain()->getRequest();

		$this->setContentLang( 'tg-latn' );
		$this->overrideConfigValue( MainConfigNames::DefaultLanguageVariant, 'tg' );
		$request->setVal( 'variant', null );
		$this->assertEquals( 'tg', $this->lc->getPreferredVariant() );
	}

	/**
	 * Test exhausting pcre.backtrack_limit
	 */
	public function testAutoConvertT124404() {
		$testString = str_repeat( 'xxx xxx xxx', 1000 );
		$testString .= "\n<big id='в'></big>";
		$this->setIniSetting( 'pcre.backtrack_limit', 200 );
		$result = $this->lc->autoConvert( $testString, 'tg-latn' );
		// The в in the id attribute should not get converted to a v
		$this->assertStringNotContainsString(
			'v',
			$result,
			"в converted to v despite being in attribue"
		);
	}

	/**
	 * @dataProvider provideTitlesToConvert
	 * @param LinkTarget|PageReference|callable $title title to convert
	 * @param string $expected
	 */
	public function testConvertTitle( $title, string $expected ): void {
		if ( is_callable( $title ) ) {
			$title = $title();
		}
		$actual = $this->lc->convertTitle( $title );
		$this->assertSame( $expected, $actual );
	}

	public static function provideTitlesToConvert(): array {
		return [
			'Title FromText default' => [
				Title::makeTitle( NS_MAIN, 'Dummy_title' ),
				'Dummy title',
			],
			'Title FromText with NS' => [
				Title::makeTitle( NS_FILE, 'Dummy_title' ),
				'Акс:Dummy title',
			],
			'Title MainPage default' => [
				static function () {
					// Don't call this until services have been set up
					return Title::newMainPage();
				},
				'Саҳифаи аслӣ',
			],
			'Title MainPage with MessageLocalizer' => [
				static function () {
					// Don't call this until services have been set up
					return Title::newMainPage( new MockMessageLocalizer() );
				},
				'Саҳифаи аслӣ',
			],
			'TitleValue' => [
				new TitleValue( NS_FILE, 'Dummy page' ),
				'Акс:Dummy page',
			],
			'PageReference' => [
				new PageReferenceValue( NS_FILE, 'Dummy page', PageReference::LOCAL ),
				'Акс:Dummy page',
			],
		];
	}
}
PK       ! s    -  language/LanguageConverterIntegrationTest.phpnu Iw        <?php

/**
 * @group Language
 * @covers \MediaWiki\Language\LanguageConverter
 */
class LanguageConverterIntegrationTest extends MediaWikiIntegrationTestCase {

	use LanguageConverterTestTrait;

	public function testHasVariant() {
		// See LanguageSrTest::testHasVariant() for additional tests
		$converterEn = $this->getLanguageConverter( 'en' );
		$this->assertTrue( $converterEn->hasVariant( 'en' ), 'base is always a variant' );
		$this->assertFalse( $converterEn->hasVariant( 'en-bogus' ), 'bogus en variant' );

		$converterBogus = $this->getLanguageConverter( 'bogus' );
		$this->assertTrue( $converterBogus->hasVariant( 'bogus' ), 'base is always a variant' );
	}
}
PK       ! +)  )  '  language/LanguageConverterTestTrait.phpnu Iw        <?php

use MediaWiki\Config\HashConfig;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Language\ILanguageConverter;
use MediaWiki\Languages\LanguageConverterFactory;
use MediaWiki\MainConfigNames;

trait LanguageConverterTestTrait {

	/** @var string */
	private $codeRegex = '/^(.+)ConverterTest$/';

	/** @var LanguageConverterFactory */
	private $factory;

	protected function getCode(): string {
		if ( preg_match( $this->codeRegex, get_class( $this ), $m ) ) {
			# Normalize language code since classes uses underscores
			return mb_strtolower( str_replace( '_', '-', $m[1] ) );
		}
		return '';
	}

	protected function getConverterFactory() {
		if ( $this->factory ) {
			return $this->factory;
		}

		$code = $this->getCode();
		$this->factory = new LanguageConverterFactory(
			new ServiceOptions( LanguageConverterFactory::CONSTRUCTOR_OPTIONS, new HashConfig( [
				MainConfigNames::UsePigLatinVariant => false,
				MainConfigNames::DisableLangConversion => false,
				MainConfigNames::DisableTitleConversion => false,
			] ) ),
			$this->getServiceContainer()->getObjectFactory(),
			function () use ( $code ) {
				$services = $this->getServiceContainer();
				if ( $code ) {
					return $services->getLanguageFactory()->getLanguage( $code );
				} else {
					return $services->getContentLanguage();
				}
			}
		);

		return $this->factory;
	}

	/**
	 * @param string|null $language Language code or null to use language
	 * returned by ::getCode(), or the content language if not set either.
	 * @return ILanguageConverter
	 */
	protected function getLanguageConverter( $language = null ): ILanguageConverter {
		if ( $language ) {
			$language = $this->getServiceContainer()->getLanguageFactory()
				->getLanguage( $language );
		}

		return $this->getConverterFactory()->getLanguageConverter( $language );
	}
}
PK       ! ;t  t    language/MessageTest.phpnu Iw        <?php

use MediaWiki\Api\ApiMessage;
use MediaWiki\Language\RawMessage;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Message\Message;
use MediaWiki\Message\UserGroupMembershipParam;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\Assert\ParameterTypeException;
use Wikimedia\Bcp47Code\Bcp47CodeValue;
use Wikimedia\Message\MessageSpecifier;

/**
 * @group Language
 * @group Database
 * @covers ::wfMessage
 * @covers \MediaWiki\Message\Message
 */
class MessageTest extends MediaWikiLangTestCase {

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

		$this->overrideConfigValue( MainConfigNames::ForceUIMsgAsContentMsg, [] );
		$this->setUserLang( 'en' );
	}

	/**
	 * @dataProvider provideConstructor
	 */
	public function testConstructor( $expectedLang, $key, $params, $language ) {
		$message = new Message( $key, $params, $language );

		$this->assertSame( $key, $message->getKey() );
		$this->assertSame( $params, $message->getParams() );
		$this->assertSame( $expectedLang->getCode(), $message->getLanguage()->getCode() );

		$messageSpecifier = $this->getMockForAbstractClass( MessageSpecifier::class );
		$messageSpecifier->method( 'getKey' )->willReturn( $key );
		$messageSpecifier->method( 'getParams' )->willReturn( $params );
		$message = new Message( $messageSpecifier, [], $language );

		$this->assertSame( $key, $message->getKey() );
		$this->assertSame( $params, $message->getParams() );
		$this->assertSame( $expectedLang->getCode(), $message->getLanguage()->getCode() );
	}

	public static function provideConstructor() {
		$langDe = MediaWikiServices::getInstance()->getLanguageFactory()->getLanguage( 'de' );
		$langEn = MediaWikiServices::getInstance()->getLanguageFactory()->getLanguage( 'en' );

		return [
			[ $langDe, 'foo', [], $langDe ],
			[ $langDe, 'foo', [ 'bar' ], $langDe ],
			[ $langEn, 'foo', [ 'bar' ], null ]
		];
	}

	public static function provideConstructorParams() {
		return [
			[
				[],
				[],
			],
			[
				[],
				[ [] ],
			],
			[
				[ 'foo' ],
				[ 'foo' ],
			],
			[
				[ 'foo', 'bar' ],
				[ 'foo', 'bar' ],
			],
			[
				[ 'baz' ],
				[ [ 'baz' ] ],
			],
			[
				[ 'baz', 'foo' ],
				[ [ 'baz', 'foo' ] ],
			],
			[
				[ Message::rawParam( 'baz' ) ],
				[ Message::rawParam( 'baz' ) ],
			],
			[
				[ Message::rawParam( 'baz' ), 'foo' ],
				[ Message::rawParam( 'baz' ), 'foo' ],
			],
			[
				[ Message::rawParam( 'baz' ) ],
				[ [ Message::rawParam( 'baz' ) ] ],
			],
			[
				[ Message::rawParam( 'baz' ), 'foo' ],
				[ [ Message::rawParam( 'baz' ), 'foo' ] ],
			],

			// Test handling of erroneous input, to detect if it changes
			[
				[ [ 'baz', 'foo' ], 'hhh' ],
				[ [ 'baz', 'foo' ], 'hhh' ],
			],
			[
				[ [ 'baz', 'foo' ], 'hhh', [ 'ahahahahha' ] ],
				[ [ 'baz', 'foo' ], 'hhh', [ 'ahahahahha' ] ],
			],
			[
				[ [ 'baz', 'foo' ], [ 'ahahahahha' ] ],
				[ [ 'baz', 'foo' ], [ 'ahahahahha' ] ],
			],
			[
				[ [ 'baz' ], [ 'ahahahahha' ] ],
				[ [ 'baz' ], [ 'ahahahahha' ] ],
			],
		];
	}

	/**
	 * @dataProvider provideConstructorParams
	 */
	public function testConstructorParams( $expected, $args ) {
		$msg = new Message( 'imasomething' );

		$returned = $msg->params( ...$args );

		$this->assertSame( $msg, $returned );
		$this->assertEquals( $expected, $msg->getParams() );
	}

	public static function provideConstructorLanguage() {
		return [
			[ 'foo', [ 'bar' ], 'en' ],
			[ 'foo', [ 'bar' ], 'de' ]
		];
	}

	/**
	 * @dataProvider provideConstructorLanguage
	 */
	public function testConstructorLanguage( $key, $params, $languageCode ) {
		$language = $this->getServiceContainer()->getLanguageFactory()
			->getLanguage( $languageCode );
		$message = new Message( $key, $params, $language );

		$this->assertEquals( $language, $message->getLanguage() );
	}

	public static function provideKeys() {
		return [
			'string' => [
				'key' => 'mainpage',
				'expected' => [ 'mainpage' ],
			],
			'single' => [
				'key' => [ 'mainpage' ],
				'expected' => [ 'mainpage' ],
			],
			'multi' => [
				'key' => [ 'mainpage-foo', 'mainpage-bar', 'mainpage' ],
				'expected' => [ 'mainpage-foo', 'mainpage-bar', 'mainpage' ],
			],
			'empty' => [
				'key' => [],
				'expected' => null,
				'exception' => InvalidArgumentException::class,
			],
			'null' => [
				'key' => null,
				'expected' => null,
				'exception' => InvalidArgumentException::class,
			],
			'bad type' => [
				'key' => 123,
				'expected' => null,
				'exception' => InvalidArgumentException::class,
			],
		];
	}

	/**
	 * @dataProvider provideKeys
	 */
	public function testKeys( $key, $expected, $exception = null ) {
		if ( $exception ) {
			$this->expectException( $exception );
		}

		$msg = new Message( $key );
		$this->assertContains( $msg->getKey(), $expected );
		$this->assertSame( $expected, $msg->getKeysToTry() );
		$this->assertSame( count( $expected ) > 1, $msg->isMultiKey() );
	}

	public function testWfMessage() {
		$this->assertInstanceOf( Message::class, wfMessage( 'mainpage' ) );
		$this->assertInstanceOf( Message::class, wfMessage( 'i-dont-exist-evar' ) );
	}

	public function testNewFromKey() {
		$this->assertInstanceOf( Message::class, Message::newFromKey( 'mainpage' ) );
		$this->assertInstanceOf( Message::class, Message::newFromKey( 'i-dont-exist-evar' ) );
	}

	public function testWfMessageParams() {
		$this->assertSame( 'Return to $1.', wfMessage( 'returnto' )->text() );
		$this->assertSame( 'Return to $1.', wfMessage( 'returnto', [] )->text() );
		$this->assertSame(
			'Return to 1,024.',
			wfMessage( 'returnto', Message::numParam( 1024 ) )->text()
		);
		$this->assertSame(
			'Return to 1,024.',
			wfMessage( 'returnto', [ Message::numParam( 1024 ) ] )->text()
		);
		$this->assertSame(
			'You have foo (bar).',
			wfMessage( 'new-messages', 'foo', 'bar' )->text()
		);
		$this->assertSame(
			'You have foo (bar).',
			wfMessage( 'new-messages', [ 'foo', 'bar' ] )->text()
		);
		$this->assertSame(
			'You have 1,024 (bar).',
			wfMessage(
				'new-messages',
				Message::numParam( 1024 ), 'bar'
			)->text()
		);
		$this->assertSame(
			'You have foo (2,048).',
			wfMessage(
				'new-messages',
				'foo', Message::numParam( 2048 )
			)->text()
		);
		$this->assertSame(
			'You have 1,024 (2,048).',
			wfMessage(
				'new-messages',
				[ Message::numParam( 1024 ), Message::numParam( 2048 ) ]
			)->text()
		);
	}

	public function testExists() {
		$this->assertTrue( wfMessage( 'mainpage' )->exists() );
		$this->assertTrue( wfMessage( 'mainpage' )->params( [] )->exists() );
		$this->assertTrue( wfMessage( 'mainpage' )->rawParams( 'foo', 123 )->exists() );
		$this->assertFalse( wfMessage( 'i-dont-exist-evar' )->exists() );
		$this->assertFalse( wfMessage( 'i-dont-exist-evar' )->params( [] )->exists() );
		$this->assertFalse( wfMessage( 'i-dont-exist-evar' )->rawParams( 'foo', 123 )->exists() );
	}

	public function testToStringKey() {
		$this->assertSame( 'Main Page', wfMessage( 'mainpage' )->text() );
		$this->assertSame( '⧼i-dont-exist-evar⧽', wfMessage( 'i-dont-exist-evar' )->text() );
		$this->assertSame( '⧼i&lt;dont&gt;exist-evar⧽', wfMessage( 'i<dont>exist-evar' )->text() );
		$this->assertSame( '⧼i-dont-exist-evar⧽', wfMessage( 'i-dont-exist-evar' )->plain() );
		$this->assertSame( '⧼i&lt;dont&gt;exist-evar⧽', wfMessage( 'i<dont>exist-evar' )->plain() );
		$this->assertSame( '⧼i-dont-exist-evar⧽', wfMessage( 'i-dont-exist-evar' )->escaped() );
		$this->assertSame(
			'⧼i&lt;dont&gt;exist-evar⧽',
			wfMessage( 'i<dont>exist-evar' )->escaped()
		);
	}

	public static function provideToString() {
		return [
			// key, transformation, transformed, transformed implicitly
			[ 'mainpage', 'plain', 'Main Page', 'Main Page' ],
			[ 'i-dont-exist-evar', 'plain', '⧼i-dont-exist-evar⧽', '⧼i-dont-exist-evar⧽' ],
			[ 'i-dont-exist-evar', 'escaped', '⧼i-dont-exist-evar⧽', '⧼i-dont-exist-evar⧽' ],
			[ 'script>alert(1)</script', 'escaped', '⧼script&gt;alert(1)&lt;/script⧽',
				'⧼script&gt;alert(1)&lt;/script⧽' ],
			[ 'script>alert(1)</script', 'plain', '⧼script&gt;alert(1)&lt;/script⧽',
				'⧼script&gt;alert(1)&lt;/script⧽' ],
			[ "\u{0338}isolated combining char", 'escaped', '⧼&#x338;isolated combining char⧽', '⧼&#x338;isolated combining char⧽' ],
		];
	}

	/**
	 * @dataProvider provideToString
	 */
	public function testToString( $key, $format, $expect, $expectImplicit ) {
		$msg = new Message( $key );
		$this->assertSame( $expect, $msg->$format() );

		// This used to behave the same as toString() and was a security risk.
		// It now has a stable return value that is always parsed/sanitized. (T146416)
		$this->assertSame( $expectImplicit, $msg->__toString(), '__toString is not affected by format call' );
	}

	public static function provideToString_raw() {
		return [
			[ '<span>foo</span>', 'parse', '<span>foo</span>', '<span>foo</span>' ],
			[ '<span>foo</span>', 'escaped', '&lt;span&gt;foo&lt;/span&gt;',
				'<span>foo</span>' ],
			[ '<span>foo</span>', 'plain', '<span>foo</span>', '<span>foo</span>' ],
			[ '<script>alert(1)</script>', 'parse', '&lt;script&gt;alert(1)&lt;/script&gt;',
				'&lt;script&gt;alert(1)&lt;/script&gt;' ],
			[ '<script>alert(1)</script>', 'escaped', '&lt;script&gt;alert(1)&lt;/script&gt;',
				'&lt;script&gt;alert(1)&lt;/script&gt;' ],
			[ '<script>alert(1)</script>', 'plain', '<script>alert(1)</script>',
				'&lt;script&gt;alert(1)&lt;/script&gt;' ],
			[ "\u{0338}isolated combining char", 'escaped', '&#x338;isolated combining char', '&#x338;isolated combining char' ],
		];
	}

	/**
	 * @dataProvider provideToString_raw
	 */
	public function testToString_raw( $message, $format, $expect, $expectImplicit ) {
		// make the message behave like RawMessage and use the key as-is
		$msg = $this->getMockBuilder( Message::class )->onlyMethods( [ 'fetchMessage' ] )
			->disableOriginalConstructor()
			->getMock();
		$msg->method( 'fetchMessage' )->willReturn( $message );
		/** @var Message $msg */

		$this->assertSame( $expect, $msg->$format() );

		$this->assertSame( $expectImplicit, $msg->__toString() );
	}

	public function testInLanguage() {
		$this->assertSame( 'Main Page', wfMessage( 'mainpage' )->inLanguage( 'en' )->text() );
		$this->assertSame( 'Главна страна',
			wfMessage( 'mainpage' )->inLanguage( 'sr-ec' )->text() );

		// NOTE: make sure internal caching of the message text is reset appropriately
		$msg = wfMessage( 'mainpage' );
		$this->assertSame( 'Main Page', $msg->inLanguage( 'en' )->text() );
		$this->assertSame(
			'Главна страна',
			$msg->inLanguage( 'sr-ec' )->text()
		);
	}

	public function testInLanguageBcp47() {
		$en = new Bcp47CodeValue( 'en' );
		$sr = new Bcp47CodeValue( 'sr-Cyrl' );
		$this->assertSame( 'Main Page', wfMessage( 'mainpage' )->inLanguage( $en )->text() );
		$this->assertSame( 'Главна страна',
			wfMessage( 'mainpage' )->inLanguage( $sr )->text() );

		// NOTE: make sure internal caching of the message text is reset appropriately
		$msg = wfMessage( 'mainpage' );
		$this->assertSame( 'Main Page', $msg->inLanguage( $en )->text() );
		$this->assertSame(
			'Главна страна',
			$msg->inLanguage( $sr )->text()
		);
	}

	public function testRawParams() {
		$this->assertSame(
			'(Заглавная страница)',
			wfMessage( 'parentheses', 'Заглавная страница' )->plain()
		);
		$this->assertSame(
			'(Заглавная страница $1)',
			wfMessage( 'parentheses', 'Заглавная страница $1' )->plain()
		);
		$this->assertSame(
			'(Заглавная страница)',
			wfMessage( 'parentheses' )->rawParams( 'Заглавная страница' )->plain()
		);
		$this->assertSame(
			'(Заглавная страница $1)',
			wfMessage( 'parentheses' )->rawParams( 'Заглавная страница $1' )->plain()
		);
	}

	/**
	 * @covers \MediaWiki\Language\RawMessage
	 */
	public function testRawMessage() {
		$msg = new RawMessage( 'example &' );
		$this->assertSame( 'example &', $msg->plain() );
		$this->assertSame( 'example &amp;', $msg->escaped() );
	}

	public static function provideRawMessage() {
		yield 'No params' => [
			new RawMessage( 'Foo Bar' ),
			'Foo Bar',
		];
		yield 'Single param' => [
			new RawMessage( '$1', [ 'Foo Bar' ] ),
			'Foo Bar',
		];
		yield 'Multiple params' => [
			new RawMessage( '$2 and $1', [ 'One', 'Two' ] ),
			'Two and One',
		];
	}

	/**
	 * @dataProvider provideRawMessage
	 * @covers \MediaWiki\Language\RawMessage
	 */
	public function testRawMessageParams( RawMessage $m, string $param ) {
		$this->assertEquals( [ $param ], $m->getParams() );
	}

	/**
	 * @dataProvider provideRawMessage
	 * @covers \MediaWiki\Language\RawMessage
	 */
	public function testRawMessageDisassembleSpecifier( RawMessage $m, string $text ) {
		// Check this just in case, although it's not really covered by this test.
		$this->assertEquals( $text, $m->text(), 'output from RawMessage itself' );
		// Verify that RawMessage can be used as a MessageSpecifier, producing the same output.
		$msg = wfMessage( $m );
		$this->assertEquals( $text, $msg->text(), 'output from RawMessage used as MessageSpecifier' );
		// Verify that if you disassemble it using MessageSpecifier's getKey() and getParams() methods,
		// then assemble a new MessageSpecifier using the return values, you will get the same output.
		$msg2 = wfMessage( $m->getKey(), ...$m->getParams() );
		$this->assertEquals( $text, $msg2->text(), 'output from RawMessage disassembled' );
	}

	/**
	 * @covers \MediaWiki\Language\RawMessage
	 * @covers \MediaWiki\Parser\CoreTagHooks::html
	 */
	public function testRawHtmlInMsg() {
		$this->overrideConfigValue( MainConfigNames::RawHtml, true );

		$msg = new RawMessage( '<html><script>alert("xss")</script></html>' );
		$txt = '<span class="error">&lt;html&gt; tags cannot be' .
			' used outside of normal pages.</span>';
		$this->assertSame( $txt, $msg->parse() );
	}

	public function testReplaceManyParams() {
		$msg = new RawMessage( '$1$2$3$4$5$6$7$8$9$10$11$12' );
		// One less than above has placeholders
		$params = [ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k' ];
		$this->assertSame(
			'abcdefghijka2',
			$msg->params( $params )->plain(),
			'Params > 9 are replaced correctly'
		);
	}

	public function testNumParams() {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$msg = new RawMessage( '$1' );

		$this->assertSame(
			$lang->formatNum( 123456.789 ),
			$msg->inLanguage( $lang )->numParams( 123456.789 )->plain(),
			'numParams is handled correctly'
		);
	}

	public function testDurationParams() {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$msg = new RawMessage( '$1' );

		$this->assertSame(
			$lang->formatDuration( 1234 ),
			$msg->inLanguage( $lang )->durationParams( 1234 )->plain(),
			'durationParams is handled correctly'
		);
	}

	/**
	 * FIXME: This should not need database, but Language#formatExpiry does (T57912)
	 */
	public function testExpiryParams() {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$msg = new RawMessage( '$1' );

		$ts = wfTimestampNow();
		$this->assertSame(
			$lang->formatExpiry( $ts ),
			$msg->inLanguage( $lang )->expiryParams( $ts )->plain(),
			'expiryParams is handled correctly'
		);
	}

	public function testDateTimeParams() {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$msg = new RawMessage( '$1' );

		$ts = wfTimestampNow();
		$this->assertSame(
			$lang->timeanddate( $ts ),
			$msg->inLanguage( $lang )->dateTimeParams( $ts )->plain(),
			'dateTime is handled correctly'
		);
	}

	public function testDateParams() {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$msg = new RawMessage( '$1' );

		$ts = wfTimestampNow();
		$this->assertSame(
			$lang->date( $ts ),
			$msg->inLanguage( $lang )->dateParams( $ts )->plain(),
			'date is handled correctly'
		);
	}

	public function testTimeParams() {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$msg = new RawMessage( '$1' );

		$ts = wfTimestampNow();
		$this->assertSame(
			$lang->time( $ts ),
			$msg->inLanguage( $lang )->timeParams( $ts )->plain(),
			'time is handled correctly'
		);
	}

	public function testUserGroupParams() {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'qqx' );
		$msg = new RawMessage( '$1' );
		$this->setUserLang( $lang );
		$this->assertSame(
			'(group-bot)',
			$msg->userGroupParams( 'bot' )->plain(),
			'user group is handled correctly'
		);
	}

	public function testUserGroupMemberParams() {
		$this->expectDeprecationAndContinue( '/UserGroupMembershipParam/' );
		$this->expectDeprecationAndContinue( '/objectParams/' );
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'qqx' );
		$msg = new RawMessage( '$1' );
		$this->setUserLang( $lang );
		$this->assertSame(
			'(group-bot-member: user)',
			$msg->objectParams(
				new UserGroupMembershipParam( 'bot', new UserIdentityValue( 1, 'user' ) )
			)->plain(),
			'user group member is handled correctly'
		);
	}

	public function testTimeperiodParams() {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$msg = new RawMessage( '$1' );

		$this->assertSame(
			$lang->formatTimePeriod( 1234 ),
			$msg->inLanguage( $lang )->timeperiodParams( 1234 )->plain(),
			'timeperiodParams is handled correctly'
		);
	}

	public function testSizeParams() {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$msg = new RawMessage( '$1' );

		$this->assertSame(
			$lang->formatSize( 123456 ),
			$msg->inLanguage( $lang )->sizeParams( 123456 )->plain(),
			'sizeParams is handled correctly'
		);
	}

	public function testBitrateParams() {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$msg = new RawMessage( '$1' );

		$this->assertSame(
			$lang->formatBitrate( 123456 ),
			$msg->inLanguage( $lang )->bitrateParams( 123456 )->plain(),
			'bitrateParams is handled correctly'
		);
	}

	public static function providePlaintextParams() {
		return [
			[
				"one $2 <div>\u{0338}foo</div> [[Bar]] {{Baz}} &lt;",
				'plain',
			],

			[
				// expect
				"one $2 <div>\u{0338}foo</div> [[Bar]] {{Baz}} &lt;",
				// format
				'text',
			],
			[
				'one $2 &lt;div&gt;&#x338;foo&lt;/div&gt; [[Bar]] {{Baz}} &amp;lt;',
				'escaped',
			],

			[
				'one $2 &lt;div&gt;&#x338;foo&lt;/div&gt; [[Bar]] {{Baz}} &amp;lt;',
				'parse',
			],

			[
				"<p>one $2 &lt;div&gt;&#x338;foo&lt;/div&gt; [[Bar]] {{Baz}} &amp;lt;\n</p>",
				'parseAsBlock',
			],
		];
	}

	/**
	 * @dataProvider providePlaintextParams
	 */
	public function testPlaintextParams( $expect, $format ) {
		$msg = new RawMessage( '$1 $2' );
		$params = [
			'one $2',
			"<div>\u{0338}foo</div> [[Bar]] {{Baz}} &lt;",
		];
		$this->assertSame(
			$expect,
			$msg->inLanguage( 'en' )->plaintextParams( $params )->$format(),
			"Fail formatting for $format"
		);
	}

	public static function provideListParam() {
		$lang = MediaWikiServices::getInstance()->getLanguageFactory()->getLanguage( 'de' );
		$msg1 = new Message( 'mainpage', [], $lang );
		$msg2 = new RawMessage( "''link''", [], $lang );

		return [
			'Simple comma list' => [
				[ 'a', 'b', 'c' ],
				'comma',
				'text',
				'a, b, c'
			],

			'Simple semicolon list' => [
				[ 'a', 'b', 'c' ],
				'semicolon',
				'text',
				'a; b; c'
			],

			'Simple pipe list' => [
				[ 'a', 'b', 'c' ],
				'pipe',
				'text',
				'a | b | c'
			],

			'Simple text list' => [
				[ 'a', 'b', 'c' ],
				'text',
				'text',
				'a, b and c'
			],

			'Empty list' => [
				[],
				'comma',
				'text',
				''
			],

			'List with all "before" params, ->text()' => [
				[ "''link''", Message::numParam( 12345678 ) ],
				'semicolon',
				'text',
				'\'\'link\'\'; 12,345,678'
			],

			'List with all "before" params, ->parse()' => [
				[ "''link''", Message::numParam( 12345678 ) ],
				'semicolon',
				'parse',
				'<i>link</i>; 12,345,678'
			],

			'List with all "after" params, ->text()' => [
				[ $msg1, $msg2, Message::rawParam( '[[foo]]' ) ],
				'semicolon',
				'text',
				'Main Page; \'\'link\'\'; [[foo]]'
			],

			'List with all "after" params, ->parse()' => [
				[ $msg1, $msg2, Message::rawParam( '[[foo]]' ) ],
				'semicolon',
				'parse',
				'Main Page; <i>link</i>; [[foo]]'
			],

			'List with both "before" and "after" params, ->text()' => [
				[ $msg1, $msg2, Message::rawParam( '[[foo]]' ), "''link''", Message::numParam( 12345678 ) ],
				'semicolon',
				'text',
				'Main Page; \'\'link\'\'; [[foo]]; \'\'link\'\'; 12,345,678'
			],

			'List with both "before" and "after" params, ->parse()' => [
				[ $msg1, $msg2, Message::rawParam( '[[foo]]' ), "''link''", Message::numParam( 12345678 ) ],
				'semicolon',
				'parse',
				'Main Page; <i>link</i>; [[foo]]; <i>link</i>; 12,345,678'
			],
		];
	}

	/**
	 * @dataProvider provideListParam
	 */
	public function testListParam( $list, $type, $format, $expect ) {
		$msg = new RawMessage( '$1' );
		$msg->params( [ Message::listParam( $list, $type ) ] );
		$this->assertEquals(
			$expect,
			$msg->inLanguage( 'en' )->$format()
		);
	}

	public function testMessageAsParam() {
		$msg = new Message( 'returnto', [
			new Message( 'apihelp-link', [
				'foo', new Message( 'mainpage', [],
					$this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' ) )
			], $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'de' ) )
		], $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'es' ) );

		$this->assertEquals(
			'Volver a [[Special:ApiHelp/foo|Página principal]].',
			$msg->text(),
			'Process with ->text()'
		);
		$this->assertEquals(
			'<p>Volver a <a href="/wiki/Special:ApiHelp/foo" title="Special:ApiHelp/foo">Página '
				. "principal</a>.\n</p>",
			$msg->parseAsBlock(),
			'Process with ->parseAsBlock()'
		);
	}

	public static function provideParser() {
		return [
			[
				"''&'' <x><!-- x -->",
				'plain',
			],

			[
				"''&'' <x><!-- x -->",
				'text',
			],
			[
				'<i>&amp;</i> &lt;x&gt;',
				'parse',
			],

			[
				"<p><i>&amp;</i> &lt;x&gt;\n</p>",
				'parseAsBlock',
			],
		];
	}

	/**
	 * @dataProvider provideParser
	 */
	public function testParser( $expect, $format ) {
		$msg = new RawMessage( "''&'' <x><!-- x -->" );
		$this->assertSame(
			$expect,
			$msg->inLanguage( 'en' )->$format()
		);
	}

	/**
	 * @covers \LanguageQqx
	 */
	public function testQqxPlaceholders() {
		$this->assertSame(
			'(test)',
			wfMessage( 'test' )->inLanguage( 'qqx' )->text()
		);
		$this->assertSame(
			'(test: a, b)',
			wfMessage( 'test' )->params( 'a', 'b' )->inLanguage( 'qqx' )->text()
		);
		$this->assertSame(
			'(test / other-test)',
			wfMessageFallback( 'test', 'other-test' )->inLanguage( 'qqx' )->text()
		);
		$this->assertSame(
			'(test / other-test: a, b)',
			wfMessageFallback( 'test', 'other-test' )->params( 'a', 'b' )->inLanguage( 'qqx' )->text()
		);
	}

	public function testInContentLanguage() {
		$this->setUserLang( 'fr' );

		// NOTE: make sure internal caching of the message text is reset appropriately
		$msg = wfMessage( 'mainpage' );
		$this->assertSame( 'Hauptseite', $msg->inLanguage( 'de' )->plain(), "inLanguage( 'de' )" );
		$this->assertSame( 'Main Page', $msg->inContentLanguage()->plain(), "inContentLanguage()" );
		$this->assertSame( 'Accueil', $msg->inLanguage( 'fr' )->plain(), "inLanguage( 'fr' )" );
	}

	public function testInContentLanguageOverride() {
		$this->overrideConfigValue( MainConfigNames::ForceUIMsgAsContentMsg, [ 'mainpage' ] );
		$this->setUserLang( 'fr' );

		// NOTE: make sure internal caching of the message text is reset appropriately.
		// NOTE: wgForceUIMsgAsContentMsg forces the messages *current* language to be used.
		$msg = wfMessage( 'mainpage' );
		$this->assertSame(
			'Accueil',
			$msg->inContentLanguage()->plain(),
			'inContentLanguage() with ForceUIMsg override enabled'
		);
		$this->assertSame( 'Main Page', $msg->inLanguage( 'en' )->plain(), "inLanguage( 'en' )" );
		$this->assertSame(
			'Main Page',
			$msg->inContentLanguage()->plain(),
			'inContentLanguage() with ForceUIMsg override enabled'
		);
		$this->assertSame( 'Hauptseite', $msg->inLanguage( 'de' )->plain(), "inLanguage( 'de' )" );
	}

	public function testInLanguageThrows() {
		$this->expectException( ParameterTypeException::class );
		wfMessage( 'foo' )->inLanguage( 123 );
	}

	/**
	 * @dataProvider provideSerializationRoundtrip
	 */
	public function testSerialization( $msgCallback, $serialized, $parsed ) {
		$msg = $msgCallback();
		$this->assertSame( $serialized, serialize( $msg ) );
		$this->assertSame( $parsed, $msg->parse() );
	}

	/**
	 * @dataProvider provideSerializationRoundtrip
	 * @dataProvider provideSerializationLegacy
	 */
	public function testUnserialization( $msgCallback, $serialized, $parsed ) {
		// Message objects hold references to lots of global state which is different in the provider
		// and in the test, so we need to delay constructing the expected object, hence the callback.
		$msg = $msgCallback();
		$this->assertEquals( $msg, unserialize( $serialized ) );
		$this->assertSame( $parsed, unserialize( $serialized )->parse() );
	}

	public function provideSerializationRoundtrip() {
		// Test cases where we can test both serialization and unserialization.
		// These really ought to use the MessageSerializationTestTrait, but
		// doing so is complicated (T373719).

		yield "Serializing raw parameters" => [
			fn () => ( new Message( 'parentheses' ) )->rawParams( '<a>foo</a>' ),
			'O:25:"MediaWiki\Message\Message":7:{s:9:"interface";b:1;s:8:"language";N;s:3:"key";s:11:"parentheses";s:9:"keysToTry";a:1:{i:0;s:11:"parentheses";}s:10:"parameters";a:1:{i:0;O:29:"Wikimedia\Message\ScalarParam":2:{s:7:"' . chr( 0 ) . '*' . chr( 0 ) . 'type";s:3:"raw";s:8:"' . chr( 0 ) . '*' . chr( 0 ) . 'value";s:10:"<a>foo</a>";}}s:11:"useDatabase";b:1;s:10:"titlevalue";N;}',
			'(<a>foo</a>)',
		];

		yield "Serializing message with a context page" => [
			fn () => ( new Message( 'rawmessage', [ '{{PAGENAME}}' ] ) )->page( PageReferenceValue::localReference( NS_MAIN, 'Testing' ) ),
			'O:25:"MediaWiki\Message\Message":7:{s:9:"interface";b:1;s:8:"language";N;s:3:"key";s:10:"rawmessage";s:9:"keysToTry";a:1:{i:0;s:10:"rawmessage";}s:10:"parameters";a:1:{i:0;s:12:"{{PAGENAME}}";}s:11:"useDatabase";b:1;s:10:"titlevalue";a:2:{i:0;i:0;i:1;s:7:"Testing";}}',
			'Testing',
		];

		yield "Serializing language" => [
			fn () => ( new Message( 'mainpage' ) )->inLanguage( 'de' ),
			'O:25:"MediaWiki\Message\Message":7:{s:9:"interface";b:0;s:8:"language";s:2:"de";s:3:"key";s:8:"mainpage";s:9:"keysToTry";a:1:{i:0;s:8:"mainpage";}s:10:"parameters";a:0:{}s:11:"useDatabase";b:1;s:10:"titlevalue";N;}',
			'Hauptseite',
		];
	}

	public function provideSerializationLegacy() {
		// Test cases where we can test only unserialization, because the serialization format changed.

		yield "MW 1.42: Magic arrays instead of MessageParam objects" => [
			fn () => ( new Message( 'parentheses' ) )->rawParams( '<a>foo</a>' ),
			'O:25:"MediaWiki\Message\Message":7:{s:9:"interface";b:1;s:8:"language";N;s:3:"key";s:11:"parentheses";s:9:"keysToTry";a:1:{i:0;s:11:"parentheses";}s:10:"parameters";a:1:{i:0;a:1:{s:3:"raw";s:10:"<a>foo</a>";}}s:11:"useDatabase";b:1;s:10:"titlevalue";N;}',
			'(<a>foo</a>)',
		];

		yield "MW 1.41: Un-namespaced class" => [
			fn () => new Message( 'mainpage' ),
			'O:7:"Message":7:{s:9:"interface";b:1;s:8:"language";N;s:3:"key";s:8:"mainpage";s:9:"keysToTry";a:1:{i:0;s:8:"mainpage";}s:10:"parameters";a:0:{}s:11:"useDatabase";b:1;s:10:"titlevalue";N;}',
			'Main Page',
		];

		yield "MW 1.34: 'titlestr' instead of 'titlevalue'" => [
			fn () => ( new Message( 'rawmessage', [ '{{PAGENAME}}' ] ) )->title( Title::newFromText( 'Testing' ) ),
			'C:7:"Message":242:{a:8:{s:9:"interface";b:1;s:8:"language";b:0;s:3:"key";s:10:"rawmessage";s:9:"keysToTry";a:1:{i:0;s:10:"rawmessage";}s:10:"parameters";a:1:{i:0;s:12:"{{PAGENAME}}";}s:6:"format";s:5:"parse";s:11:"useDatabase";b:1;s:8:"titlestr";s:7:"Testing";}}',
			'Testing',
		];
	}

	/**
	 * @dataProvider provideNewFromSpecifier
	 */
	public function testNewFromSpecifier( $value, $expectedText ) {
		$message = Message::newFromSpecifier( $value );
		$this->assertInstanceOf( Message::class, $message );
		if ( $value instanceof Message ) {
			$this->assertInstanceOf( get_class( $value ), $message );
			$this->assertEquals( $value, $message );
		}
		$this->assertSame( $expectedText, $message->text() );
	}

	public function provideNewFromSpecifier() {
		$messageSpecifier = $this->getMockForAbstractClass( MessageSpecifier::class );
		$messageSpecifier->method( 'getKey' )->willReturn( 'mainpage' );
		$messageSpecifier->method( 'getParams' )->willReturn( [] );

		return [
			'string' => [ 'mainpage', 'Main Page' ],
			'array' => [ [ 'new-messages', 'foo', 'bar' ], 'You have foo (bar).' ],
			'Message' => [ new Message( 'new-messages', [ 'foo', 'bar' ] ), 'You have foo (bar).' ],
			'RawMessage' => [ new RawMessage( 'foo ($1)', [ 'bar' ] ), 'foo (bar)' ],
			'ApiMessage' => [ new ApiMessage( [ 'mainpage' ], 'code', [ 'data' ] ), 'Main Page' ],
			'MessageSpecifier' => [ $messageSpecifier, 'Main Page' ],
			'nested RawMessage' => [ [ new RawMessage( 'foo ($1)', [ 'bar' ] ) ], 'foo (bar)' ],
		];
	}
}
PK       ! ؏    $  language/LanguageIntegrationTest.phpnu Iw        <?php

use MediaWiki\Config\HashConfig;
use MediaWiki\Config\MultiConfig;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Language\Language;
use MediaWiki\Languages\LanguageConverterFactory;
use MediaWiki\Languages\LanguageFallback;
use MediaWiki\Languages\LanguageNameUtils;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Language
 * @covers \MediaWiki\Language\Language
 * @covers \MediaWiki\Languages\LanguageNameUtils
 */
class LanguageIntegrationTest extends LanguageClassesTestCase {
	use DummyServicesTrait;
	use LanguageNameUtilsTestTrait;

	private function newLanguage( $class = Language::class, $code = 'en' ) {
		// Needed to support the setMwGlobals calls for the various tests, but this should
		// probably be changed to have the configuration injected into this method instead
		// at some point
		$config = $this->getServiceContainer()->getMainConfig();
		return new $class(
			$code,
			$this->createNoOpMock( NamespaceInfo::class ),
			$this->createNoOpMock( LocalisationCache::class ),
			$this->createNoOpMock( LanguageNameUtils::class ),
			$this->createNoOpMock( LanguageFallback::class ),
			$this->createNoOpMock( LanguageConverterFactory::class ),
			$this->createHookContainer(),
			$config
		);
	}

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

		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, true );
	}

	public function testLanguageConvertDoubleWidthToSingleWidth() {
		$this->assertSame(
			"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
			$this->getLang()->normalizeForSearch(
				"０１２３４５６７８９ＡＢＣＤＥＦＧＨＩＪＫＬＭＮＯＰＱＲＳＴＵＶＷＸＹＺａｂｃｄｅｆｇｈｉｊｋｌｍｎｏｐｑｒｓｔｕｖｗｘｙｚ"
			),
			'convertDoubleWidth() with the full alphabet and digits'
		);
	}

	/**
	 * @dataProvider provideFormattableTimes
	 */
	public function testFormatTimePeriod( $seconds, $format, $expected, $desc ) {
		$this->assertEquals( $expected, $this->getLang()->formatTimePeriod( $seconds, $format ), $desc );
	}

	public static function provideFormattableTimes() {
		return [
			[
				9.45,
				[],
				'9.5 s',
				'formatTimePeriod() rounding (<10s)'
			],
			[
				9.45,
				[ 'noabbrevs' => true ],
				'9.5 seconds',
				'formatTimePeriod() rounding (<10s)'
			],
			[
				9.95,
				[],
				'10 s',
				'formatTimePeriod() rounding (<10s)'
			],
			[
				9.95,
				[ 'noabbrevs' => true ],
				'10 seconds',
				'formatTimePeriod() rounding (<10s)'
			],
			[
				59.55,
				[],
				'1 min 0 s',
				'formatTimePeriod() rounding (<60s)'
			],
			[
				59.55,
				[ 'noabbrevs' => true ],
				'1 minute 0 seconds',
				'formatTimePeriod() rounding (<60s)'
			],
			[
				119.55,
				[],
				'2 min 0 s',
				'formatTimePeriod() rounding (<1h)'
			],
			[
				119.55,
				[ 'noabbrevs' => true ],
				'2 minutes 0 seconds',
				'formatTimePeriod() rounding (<1h)'
			],
			[
				3599.55,
				[],
				'1 h 0 min 0 s',
				'formatTimePeriod() rounding (<1h)'
			],
			[
				3599.55,
				[ 'noabbrevs' => true ],
				'1 hour 0 minutes 0 seconds',
				'formatTimePeriod() rounding (<1h)'
			],
			[
				7199.55,
				[],
				'2 h 0 min 0 s',
				'formatTimePeriod() rounding (>=1h)'
			],
			[
				7199.55,
				[ 'noabbrevs' => true ],
				'2 hours 0 minutes 0 seconds',
				'formatTimePeriod() rounding (>=1h)'
			],
			[
				7199.55,
				'avoidseconds',
				'2 h 0 min',
				'formatTimePeriod() rounding (>=1h), avoidseconds'
			],
			[
				7199.55,
				[ 'avoid' => 'avoidseconds', 'noabbrevs' => true ],
				'2 hours 0 minutes',
				'formatTimePeriod() rounding (>=1h), avoidseconds'
			],
			[
				7199.55,
				'avoidminutes',
				'2 h 0 min',
				'formatTimePeriod() rounding (>=1h), avoidminutes'
			],
			[
				7199.55,
				[ 'avoid' => 'avoidminutes', 'noabbrevs' => true ],
				'2 hours 0 minutes',
				'formatTimePeriod() rounding (>=1h), avoidminutes'
			],
			[
				172799.55,
				'avoidseconds',
				'48 h 0 min',
				'formatTimePeriod() rounding (=48h), avoidseconds'
			],
			[
				172799.55,
				[ 'avoid' => 'avoidseconds', 'noabbrevs' => true ],
				'48 hours 0 minutes',
				'formatTimePeriod() rounding (=48h), avoidseconds'
			],
			[
				259199.55,
				'avoidhours',
				'3 d',
				'formatTimePeriod() rounding (>48h), avoidhours'
			],
			[
				259199.55,
				[ 'avoid' => 'avoidhours', 'noabbrevs' => true ],
				'3 days',
				'formatTimePeriod() rounding (>48h), avoidhours'
			],
			[
				259199.55,
				'avoidminutes',
				'3 d 0 h',
				'formatTimePeriod() rounding (>48h), avoidminutes'
			],
			[
				259199.55,
				[ 'avoid' => 'avoidminutes', 'noabbrevs' => true ],
				'3 days 0 hours',
				'formatTimePeriod() rounding (>48h), avoidminutes'
			],
			[
				176399.55,
				'avoidseconds',
				'2 d 1 h 0 min',
				'formatTimePeriod() rounding (>48h), avoidseconds'
			],
			[
				176399.55,
				[ 'avoid' => 'avoidseconds', 'noabbrevs' => true ],
				'2 days 1 hour 0 minutes',
				'formatTimePeriod() rounding (>48h), avoidseconds'
			],
			[
				176399.55,
				'avoidminutes',
				'2 d 1 h',
				'formatTimePeriod() rounding (>48h), avoidminutes'
			],
			[
				176399.55,
				[ 'avoid' => 'avoidminutes', 'noabbrevs' => true ],
				'2 days 1 hour',
				'formatTimePeriod() rounding (>48h), avoidminutes'
			],
			[
				259199.55,
				'avoidseconds',
				'3 d 0 h 0 min',
				'formatTimePeriod() rounding (>48h), avoidseconds'
			],
			[
				259199.55,
				[ 'avoid' => 'avoidseconds', 'noabbrevs' => true ],
				'3 days 0 hours 0 minutes',
				'formatTimePeriod() rounding (>48h), avoidseconds'
			],
			[
				172801.55,
				'avoidseconds',
				'2 d 0 h 0 min',
				'formatTimePeriod() rounding, (>48h), avoidseconds'
			],
			[
				172801.55,
				[ 'avoid' => 'avoidseconds', 'noabbrevs' => true ],
				'2 days 0 hours 0 minutes',
				'formatTimePeriod() rounding, (>48h), avoidseconds'
			],
			[
				176460.55,
				[],
				'2 d 1 h 1 min 1 s',
				'formatTimePeriod() rounding, recursion, (>48h)'
			],
			[
				176460.55,
				[ 'noabbrevs' => true ],
				'2 days 1 hour 1 minute 1 second',
				'formatTimePeriod() rounding, recursion, (>48h)'
			],
		];
	}

	public function testTruncateForDatabase() {
		$this->assertEquals(
			"XXX",
			$this->getLang()->truncateForDatabase( "1234567890", 0, 'XXX' ),
			'truncate prefix, len 0, small ellipsis'
		);

		$this->assertEquals(
			"12345XXX",
			$this->getLang()->truncateForDatabase( "1234567890", 8, 'XXX' ),
			'truncate prefix, small ellipsis'
		);

		$this->assertSame(
			"123456789",
			$this->getLang()->truncateForDatabase( "123456789", 5, 'XXXXXXXXXXXXXXX' ),
			'truncate prefix, large ellipsis'
		);

		$this->assertEquals(
			"XXX67890",
			$this->getLang()->truncateForDatabase( "1234567890", -8, 'XXX' ),
			'truncate suffix, small ellipsis'
		);

		$this->assertSame(
			"123456789",
			$this->getLang()->truncateForDatabase( "123456789", -5, 'XXXXXXXXXXXXXXX' ),
			'truncate suffix, large ellipsis'
		);
		$this->assertEquals(
			"123XXX",
			$this->getLang()->truncateForDatabase( "123                ", 9, 'XXX' ),
			'truncate prefix, with spaces'
		);
		$this->assertEquals(
			"12345XXX",
			$this->getLang()->truncateForDatabase( "12345            8", 11, 'XXX' ),
			'truncate prefix, with spaces and non-space ending'
		);
		$this->assertEquals(
			"XXX234",
			$this->getLang()->truncateForDatabase( "1              234", -8, 'XXX' ),
			'truncate suffix, with spaces'
		);
		$this->assertEquals(
			"12345XXX",
			$this->getLang()->truncateForDatabase( "1234567890", 5, 'XXX', false ),
			'truncate without adjustment'
		);
		$this->assertEquals(
			"泰乐菌...",
			$this->getLang()->truncateForDatabase( "泰乐菌素123456789", 11, '...', false ),
			'truncate does not chop Unicode characters in half'
		);
		$this->assertEquals(
			"\n泰乐菌...",
			$this->getLang()->truncateForDatabase( "\n泰乐菌素123456789", 12, '...', false ),
			'truncate does not chop Unicode characters in half if there is a preceding newline'
		);
	}

	/**
	 * @dataProvider provideTruncateData
	 */
	public function testTruncateForVisual(
		$expected, $string, $length, $ellipsis = '...', $adjustLength = true
	) {
		$this->assertEquals(
			$expected,
			$this->getLang()->truncateForVisual( $string, $length, $ellipsis, $adjustLength )
		);
	}

	/**
	 * @return array Format is ($expected, $string, $length, $ellipsis, $adjustLength)
	 */
	public static function provideTruncateData() {
		return [
			[ "XXX", "тестирам да ли ради", 0, "XXX" ],
			[ "testnXXX", "testni scenarij", 8, "XXX" ],
			[ "حالة اختبار", "حالة اختبار", 5, "XXXXXXXXXXXXXXX" ],
			[ "XXXедент", "прецедент", -8, "XXX" ],
			[ "XXപിൾ", "ആപ്പിൾ", -5, "XX" ],
			[ "神秘XXX", "神秘                ", 9, "XXX" ],
			[ "ΔημιουργXXX", "Δημιουργία           Σύμπαντος", 11, "XXX" ],
			[ "XXXの家です", "地球は私たちの唯               の家です", -8, "XXX" ],
			[ "زندگیXXX", "زندگی زیباست", 6, "XXX", false ],
			[ "ცხოვრება...", "ცხოვრება არის საოცარი", 8, "...", false ],
			[ "\nທ່ານ...", "\nທ່ານບໍ່ຮູ້ຫນັງສື", 5, "...", false ],
		];
	}

	/**
	 * @dataProvider provideHTMLTruncateData
	 */
	public function testTruncateHtml( $len, $ellipsis, $input, $expected ) {
		// Actual HTML...
		$this->assertEquals(
			$expected,
			$this->getLang()->truncateHtml( $input, $len, $ellipsis )
		);
	}

	/**
	 * @return array Format is ($len, $ellipsis, $input, $expected)
	 */
	public static function provideHTMLTruncateData() {
		return [
			[ 0, 'XXX', "1234567890", "XXX" ],
			[ 8, 'XXX', "1234567890", "12345XXX" ],
			[ 5, 'XXXXXXXXXXXXXXX', '1234567890', "1234567890" ],
			[ 2, '***',
				'<p><span style="font-weight:bold;"></span></p>',
				'<p><span style="font-weight:bold;"></span></p>',
			],
			[ 2, '***',
				'<p><span style="font-weight:bold;">123456789</span></p>',
				'<p><span style="font-weight:bold;">***</span></p>',
			],
			[ 2, '***',
				'<p><span style="font-weight:bold;">&nbsp;23456789</span></p>',
				'<p><span style="font-weight:bold;">***</span></p>',
			],
			[ 3, '***',
				'<p><span style="font-weight:bold;">123456789</span></p>',
				'<p><span style="font-weight:bold;">***</span></p>',
			],
			[ 4, '***',
				'<p><span style="font-weight:bold;">123456789</span></p>',
				'<p><span style="font-weight:bold;">1***</span></p>',
			],
			[ 5, '***',
				'<tt><span style="font-weight:bold;">123456789</span></tt>',
				'<tt><span style="font-weight:bold;">12***</span></tt>',
			],
			[ 6, '***',
				'<p><a href="www.mediawiki.org">123456789</a></p>',
				'<p><a href="www.mediawiki.org">123***</a></p>',
			],
			[ 6, '***',
				'<p><a href="www.mediawiki.org">12&nbsp;456789</a></p>',
				'<p><a href="www.mediawiki.org">12&nbsp;***</a></p>',
			],
			[ 7, '***',
				'<small><span style="font-weight:bold;">123<p id="#moo">456</p>789</span></small>',
				'<small><span style="font-weight:bold;">123<p id="#moo">4***</p></span></small>',
			],
			[ 8, '***',
				'<div><span style="font-weight:bold;">123<span>4</span>56789</span></div>',
				'<div><span style="font-weight:bold;">123<span>4</span>5***</span></div>',
			],
			[ 9, '***',
				'<p><table style="font-weight:bold;"><tr><td>123456789</td></tr></table></p>',
				'<p><table style="font-weight:bold;"><tr><td>123456789</td></tr></table></p>',
			],
			[ 10, '***',
				'<p><font style="font-weight:bold;">123456789</font></p>',
				'<p><font style="font-weight:bold;">123456789</font></p>',
			],
			[ 10, '***',
				'<p><font style="font-weight:bold;">123456789</font',
				'<p><font style="font-weight:bold;">123456789</font</p>',
			],
		];
	}

	/**
	 * Test too short timestamp
	 */
	public function testSprintfDateTooShortTimestamp() {
		$this->expectException( InvalidArgumentException::class );
		$this->getLang()->sprintfDate( 'xiY', '1234567890123' );
	}

	/**
	 * Test too long timestamp
	 */
	public function testSprintfDateTooLongTimestamp() {
		$this->expectException( InvalidArgumentException::class );
		$this->getLang()->sprintfDate( 'xiY', '123456789012345' );
	}

	/**
	 * Test too short timestamp
	 */
	public function testSprintfDateNotAllDigitTimestamp() {
		$this->expectException( InvalidArgumentException::class );
		$this->getLang()->sprintfDate( 'xiY', '-1234567890123' );
	}

	/**
	 * @dataProvider provideSprintfDateSamples
	 */
	public function testSprintfDate( $format, $ts, $expected, $msg ) {
		$ttl = null;
		$this->assertSame(
			$expected,
			$this->getLang()->sprintfDate( $format, $ts, null, $ttl ),
			"sprintfDate('$format', '$ts'): $msg"
		);
		if ( $ttl ) {
			$dt = new DateTime( $ts );
			$lastValidTS = $dt->add( new DateInterval( 'PT' . ( $ttl - 1 ) . 'S' ) )->format( 'YmdHis' );
			$this->assertSame(
				$expected,
				$this->getLang()->sprintfDate( $format, $lastValidTS, null ),
				"sprintfDate('$format', '$ts'): TTL $ttl too high (output was different at $lastValidTS)"
			);
		} else {
			// advance the time enough to make all of the possible outputs different (except possibly L)
			$dt = new DateTime( $ts );
			$newTS = $dt->add( new DateInterval( 'P1Y1M8DT13H1M1S' ) )->format( 'YmdHis' );
			$this->assertSame(
				$expected,
				$this->getLang()->sprintfDate( $format, $newTS, null ),
				"sprintfDate('$format', '$ts'): Missing TTL (output was different at $newTS)"
			);
		}
	}

	/**
	 * sprintfDate should always use UTC when no zone is given.
	 * @dataProvider provideSprintfDateSamples
	 */
	public function testSprintfDateNoZone( $format, $ts, $expected, $ignore, $msg ) {
		$oldTZ = date_default_timezone_get();
		$res = date_default_timezone_set( 'Asia/Seoul' );
		if ( !$res ) {
			$this->markTestSkipped( "Error setting Timezone" );
		}

		$this->assertEquals(
			$expected,
			$this->getLang()->sprintfDate( $format, $ts ),
			"sprintfDate('$format', '$ts'): $msg"
		);

		date_default_timezone_set( $oldTZ );
	}

	/**
	 * sprintfDate should use passed timezone
	 * @dataProvider provideSprintfDateSamples
	 */
	public function testSprintfDateTZ( $format, $ts, $ignore, $expected, $msg ) {
		$tz = new DateTimeZone( 'Asia/Seoul' );
		if ( !$tz ) {
			$this->markTestSkipped( "Error getting Timezone" );
		}

		$this->assertEquals(
			$expected,
			$this->getLang()->sprintfDate( $format, $ts, $tz ),
			"sprintfDate('$format', '$ts', 'Asia/Seoul'): $msg"
		);
	}

	/**
	 * sprintfDate should only calculate a TTL if the caller is going to use it.
	 */
	public function testSprintfDateNoTtlIfNotNeeded() {
		$noTtl = 'unused'; // Value used to represent that the caller didn't pass a variable in.
		$ttl = null;
		$this->getLang()->sprintfDate( 'YmdHis', wfTimestampNow(), null, $noTtl );
		$this->getLang()->sprintfDate( 'YmdHis', wfTimestampNow(), null, $ttl );

		$this->assertSame(
			'unused',
			$noTtl,
			'If the caller does not set the $ttl variable, do not compute it.'
		);
		$this->assertIsInt( $ttl, 'TTL should have been computed.' );
	}

	public static function provideSprintfDateSamples() {
		return [
			[
				'xiY',
				'20111212000000',
				'1390', // note because we're testing English locale we get Latin-standard digits
				'1390',
				'Iranian calendar full year'
			],
			[
				'xiy',
				'20111212000000',
				'90',
				'90',
				'Iranian calendar short year'
			],
			[
				'o',
				'20120101235000',
				'2011',
				'2011',
				'ISO 8601 (week) year'
			],
			[
				'W',
				'20120101235000',
				'52',
				'52',
				'Week number'
			],
			[
				'W',
				'20120102235000',
				'01',
				'01',
				'Week number'
			],
			[
				'o-\\WW-N',
				'20091231235000',
				'2009-W53-4',
				'2009-W53-4',
				'leap week'
			],
			// What follows is mostly copied from
			// https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions#.23time
			[
				'Y',
				'20120102090705',
				'2012',
				'2012',
				'Full year'
			],
			[
				'y',
				'20120102090705',
				'12',
				'12',
				'2 digit year'
			],
			[
				'L',
				'20120102090705',
				'1',
				'1',
				'Leap year'
			],
			[
				'n',
				'20120102090705',
				'1',
				'1',
				'Month index, not zero pad'
			],
			[
				'N',
				'20120102090705',
				'1',
				'1',
				'Day of the week'
			],
			[
				'M',
				'20120102090705',
				'Jan',
				'Jan',
				'Month abbrev'
			],
			[
				'F',
				'20120102090705',
				'January',
				'January',
				'Full month'
			],
			[
				'xg',
				'20120102090705',
				'January',
				'January',
				'Genitive month name (same in EN)'
			],
			[
				'j',
				'20120102090705',
				'2',
				'2',
				'Day of month (not zero pad)'
			],
			[
				'd',
				'20120102090705',
				'02',
				'02',
				'Day of month (zero-pad)'
			],
			[
				'z',
				'20120102090705',
				'1',
				'1',
				'Day of year (zero-indexed)'
			],
			[
				'D',
				'20120102090705',
				'Mon',
				'Mon',
				'Day of week (abbrev)'
			],
			[
				'l',
				'20120102090705',
				'Monday',
				'Monday',
				'Full day of week'
			],
			[
				'N',
				'20120101090705',
				'7',
				'7',
				'Day of week (Mon=1, Sun=7)'
			],
			[
				'w',
				'20120101090705',
				'0',
				'0',
				'Day of week (Sun=0, Sat=6)'
			],
			[
				'N',
				'20120102090705',
				'1',
				'1',
				'Day of week'
			],
			[
				'a',
				'20120102090705',
				'am',
				'am',
				'am vs pm'
			],
			[
				'A',
				'20120102120000',
				'PM',
				'PM',
				'AM vs PM'
			],
			[
				'a',
				'20120102000000',
				'am',
				'am',
				'AM vs PM'
			],
			[
				'g',
				'20120102090705',
				'9',
				'9',
				'12 hour, not Zero'
			],
			[
				'h',
				'20120102090705',
				'09',
				'09',
				'12 hour, zero padded'
			],
			[
				'G',
				'20120102090705',
				'9',
				'9',
				'24 hour, not zero'
			],
			[
				'H',
				'20120102090705',
				'09',
				'09',
				'24 hour, zero'
			],
			[
				'H',
				'20120102110705',
				'11',
				'11',
				'24 hour, zero'
			],
			[
				'i',
				'20120102090705',
				'07',
				'07',
				'Minutes'
			],
			[
				's',
				'20120102090705',
				'05',
				'05',
				'seconds'
			],
			[
				'U',
				'20120102090705',
				'1325495225',
				'1325462825',
				'unix time'
			],
			[
				't',
				'20120102090705',
				'31',
				'31',
				'Days in current month'
			],
			[
				'c',
				'20120102090705',
				'2012-01-02T09:07:05+00:00',
				'2012-01-02T09:07:05+09:00',
				'ISO 8601 timestamp'
			],
			[
				'r',
				'20120102090705',
				'Mon, 02 Jan 2012 09:07:05 +0000',
				'Mon, 02 Jan 2012 09:07:05 +0900',
				'RFC 5322'
			],
			[
				'e',
				'20120102090705',
				'UTC',
				'Asia/Seoul',
				'Timezone identifier'
			],
			[
				'I',
				'19880602090705',
				'0',
				'1',
				'DST indicator'
			],
			[
				'O',
				'20120102090705',
				'+0000',
				'+0900',
				'Timezone offset'
			],
			[
				'P',
				'20120102090705',
				'+00:00',
				'+09:00',
				'Timezone offset with colon'
			],
			[
				'T',
				'20120102090705',
				'UTC',
				'KST',
				'Timezone abbreviation'
			],
			[
				'Z',
				'20120102090705',
				'0',
				'32400',
				'Timezone offset in seconds'
			],
			[
				'xmj xmF xmn xmY',
				'20120102090705',
				'7 Safar 2 1433',
				'7 Safar 2 1433',
				'Islamic'
			],
			[
				'xij xiF xin xiY',
				'20120102090705',
				'12 Dey 10 1390',
				'12 Dey 10 1390',
				'Iranian'
			],
			[
				'xjj xjF xjn xjY',
				'20120102090705',
				'7 Tevet 4 5772',
				'7 Tevet 4 5772',
				'Hebrew'
			],
			[
				'xjt',
				'20120102090705',
				'29',
				'29',
				'Hebrew number of days in month'
			],
			[
				'xjx',
				'20120102090705',
				'Tevet',
				'Tevet',
				'Hebrew genitive month name (No difference in EN)'
			],
			[
				'xkY',
				'20120102090705',
				'2555',
				'2555',
				'Thai year'
			],
			[
				'xkY',
				'19410101090705',
				'2484',
				'2484',
				'Thai year'
			],
			[
				'xoY',
				'20120102090705',
				'101',
				'101',
				'Minguo'
			],
			[
				'xtY',
				'18660101000000',
				'西暦1866',
				'西暦1866',
				'nengo - before meiji'
			],
			[
				'xtY',
				'18670101000000',
				'西暦1867',
				'西暦1867',
				'nengo - before meiji'
			],
			[
				'xtY',
				'18721231235959',
				'西暦1872',
				'西暦1872',
				'nengo - meiji, but Lunisolar calendar'
			],
			[
				'xtY',
				'18730101000000',
				'明治6',
				'明治6',
				'nengo - meiji 6th'
			],
			[
				'xtY',
				'19120729235959',
				'明治45',
				'明治45',
				'nengo - meiji 45th last day'
			],
			[
				'xtY',
				'19120730000000',
				'大正元',
				'大正元',
				'nengo - taisho first day'
			],
			[
				'xtY',
				'19130101000000',
				'大正2',
				'大正2',
				'nengo - taisho 2nd'
			],
			[
				'xtY',
				'19261224235959',
				'大正15',
				'大正15',
				'nengo - taisho last day'
			],
			[
				'xtY',
				'19261225000000',
				'昭和元',
				'昭和元',
				'nengo - first day of Showa'
			],
			[
				'xtY',
				'19270101000000',
				'昭和2',
				'昭和2',
				'nengo - second year of Showa'
			],
			[
				'xtY',
				'19890107235959',
				'昭和64',
				'昭和64',
				'nengo - last day of Showa'
			],
			[
				'xtY',
				'19890108000000',
				'平成元',
				'平成元',
				'nengo - first day of Heisei'
			],
			[
				'xtY',
				'19900101000000',
				'平成2',
				'平成2',
				'nengo - second year of Heisei'
			],
			[
				'xtY',
				'20190430235959',
				'平成31',
				'平成31',
				'nengo - last day of Heisei'
			],
			[
				'xtY',
				'20190501000000',
				'令和元',
				'令和元',
				'nengo - first day of Reiwa'
			],
			[
				'xtY',
				'20200501000000',
				'令和2',
				'令和2',
				'nengo - second year of Reiwa'
			],
			[
				'xrxkYY',
				'20120102090705',
				'MMDLV2012',
				'MMDLV2012',
				'Roman numerals'
			],
			[
				'xhxjYY',
				'20120102090705',
				'ה\'תשע"ב2012',
				'ה\'תשע"ב2012',
				'Hebrew numberals'
			],
			[
				'xnY',
				'20120102090705',
				'2012',
				'2012',
				'Raw numerals (doesn\'t mean much in EN)'
			],
			[
				'[[Y "(yea"\\r)]] \\"xx\\"',
				'20120102090705',
				'[[2012 (year)]] "x"',
				'[[2012 (year)]] "x"',
				'Various escaping'
			],

		];
	}

	/**
	 * @dataProvider provideFormatSizes
	 */
	public function testFormatSize( $size, $expected, $msg ) {
		$this->assertEquals(
			$expected,
			$this->getLang()->formatSize( $size ),
			"formatSize('$size'): $msg"
		);
	}

	public static function provideFormatSizes() {
		return [
			[
				0,
				"0 bytes",
				"Zero bytes"
			],
			[
				1024,
				"1 KB",
				"1 kilobyte"
			],
			[
				1024 * 1024,
				"1 MB",
				"1 megabyte"
			],
			[
				1024 * 1024 * 1024,
				"1 GB",
				"1 gigabyte"
			],
			[
				1024 ** 4,
				"1 TB",
				"1 terabyte"
			],
			[
				1024 ** 5,
				"1 PB",
				"1 petabyte"
			],
			[
				1024 ** 6,
				"1 EB",
				"1 exabyte"
			],
			[
				1024 ** 7,
				"1 ZB",
				"1 zettabyte"
			],
			[
				1024 ** 8,
				"1 YB",
				"1 yottabyte"
			],
			[
				1024 ** 9,
				"1 RB",
				"1 ronnabyte"
			],
			[
				1024 ** 10,
				"1 QB",
				"1 quettabyte"
			],
			[
				1024 ** 11,
				"1,024 QB",
				"1,024 quettabytes"
			],
			// How big!? THIS BIG!
		];
	}

	/**
	 * @dataProvider provideFormatBitrate
	 */
	public function testFormatBitrate( $bps, $expected, $msg ) {
		$this->assertEquals(
			$expected,
			$this->getLang()->formatBitrate( $bps ),
			"formatBitrate('$bps'): $msg"
		);
	}

	public static function provideFormatBitrate() {
		return [
			[
				0,
				"0 bps",
				"0 bits per second"
			],
			[
				999,
				"999 bps",
				"999 bits per second"
			],
			[
				1000,
				"1 kbps",
				"1 kilobit per second"
			],
			[
				1000 * 1000,
				"1 Mbps",
				"1 megabit per second"
			],
			[
				10 ** 9,
				"1 Gbps",
				"1 gigabit per second"
			],
			[
				10 ** 12,
				"1 Tbps",
				"1 terabit per second"
			],
			[
				10 ** 15,
				"1 Pbps",
				"1 petabit per second"
			],
			[
				10 ** 18,
				"1 Ebps",
				"1 exabit per second"
			],
			[
				10 ** 21,
				"1 Zbps",
				"1 zettabit per second"
			],
			[
				10 ** 24,
				"1 Ybps",
				"1 yottabit per second"
			],
			[
				10 ** 27,
				"1 Rbps",
				"1 ronnabits per second"
			],
			[
				10 ** 30,
				"1 Qbps",
				"1 quettabit per second"
			],
			[
				10 ** 33,
				"1,000 Qbps",
				"1,000 quettabits per second"
			],
		];
	}

	/**
	 * @dataProvider provideFormatDuration
	 */
	public function testFormatDuration( $duration, $expected, $intervals = [] ) {
		$this->assertEquals(
			$expected,
			$this->getLang()->formatDuration( $duration, $intervals ),
			"formatDuration('$duration'): $expected"
		);
	}

	public static function provideFormatDuration() {
		return [
			[
				0,
				'0 seconds',
			],
			[
				1,
				'1 second',
			],
			[
				2,
				'2 seconds',
			],
			[
				60,
				'1 minute',
			],
			[
				2 * 60,
				'2 minutes',
			],
			[
				3600,
				'1 hour',
			],
			[
				2 * 3600,
				'2 hours',
			],
			[
				24 * 3600,
				'1 day',
			],
			[
				2 * 86400,
				'2 days',
			],
			[
				365.2425 * 24 * 3600 / 12,
				'1 month',
				[ 'months', 'days' ]
			],
			[
				365.2425 * 24 * 3600 / 12 * 2,
				'2 months',
				[ 'months', 'days' ]
			],
			[
				( 365.2425 * 24 * 3600 / 12 * 2 ) + 24 * 3600,
				'2 months and 1 day',
				[ 'months', 'days' ]
			],
			[
				// ( 365 + ( 24 * 3 + 25 ) / 400 ) * 86400 = 31556952
				( 365 + ( 24 * 3 + 25 ) / 400.0 ) * 86400,
				'1 year',
				[ 'months', 'years' ]
			],
			[
				2 * 31556952,
				'2 years',
			],
			[
				10 * 31556952,
				'1 decade',
			],
			[
				20 * 31556952,
				'2 decades',
			],
			[
				100 * 31556952,
				'1 century',
			],
			[
				200 * 31556952,
				'2 centuries',
			],
			[
				1000 * 31556952,
				'1 millennium',
			],
			[
				2000 * 31556952,
				'2 millennia',
			],
			[
				9001,
				'2 hours, 30 minutes and 1 second'
			],
			[
				3601,
				'1 hour and 1 second'
			],
			[
				31556952 + 2 * 86400 + 9000,
				'1 year, 2 days, 2 hours and 30 minutes'
			],
			[
				42 * 1000 * 31556952 + 42,
				'42 millennia and 42 seconds'
			],
			[
				60,
				'60 seconds',
				[ 'seconds' ],
			],
			[
				61,
				'61 seconds',
				[ 'seconds' ],
			],
			[
				1,
				'1 second',
				[ 'seconds' ],
			],
			[
				31556952 + 2 * 86400 + 9000,
				'1 year, 2 days and 150 minutes',
				[ 'years', 'days', 'minutes' ],
			],
			[
				42,
				'0 days',
				[ 'years', 'days' ],
			],
			[
				31556952 + 2 * 86400 + 9000,
				'1 year, 2 days and 150 minutes',
				[ 'minutes', 'days', 'years' ],
			],
			[
				42,
				'0 days',
				[ 'days', 'years' ],
			],
			[
				( new DateTime( '2025-05-03 20:00:00' ) )->getTimestamp() - ( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				'11 months',
				[ 'months' ],
			],
			[
				( new DateTime( '2025-05-03 20:00:00' ) )->getTimestamp() - ( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				'11 months, 30 days, 4 hours, 39 minutes and 54 seconds',
				[ 'years', 'months', 'days', 'hours', 'minutes', 'seconds' ],
			],
		];
	}

	/**
	 * @dataProvider provideFormatDurationBetweenTimestamps
	 */
	public function testFormatDurationBetweenTimestamps(
		int $timestamp1,
		int $timestamp2,
		?int $precision,
		string $expected
	): void {
		$this->assertSame(
			$expected,
			$this->getLang()->formatDurationBetweenTimestamps( $timestamp1, $timestamp2, $precision )
		);
		$this->assertSame(
			$expected,
			$this->getLang()->formatDurationBetweenTimestamps( $timestamp2, $timestamp1, $precision )
		);
	}

	public function provideFormatDurationBetweenTimestamps(): array {
		return [
			// most test cases ported from provideFormatDuration()
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				null,
				'0 seconds',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2024-05-03 20:00:01' ) )->getTimestamp(),
				null,
				'1 second',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2024-05-03 20:00:02' ) )->getTimestamp(),
				null,
				'2 seconds',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2024-05-03 20:01:00' ) )->getTimestamp(),
				null,
				'1 minute',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2024-05-03 20:02:00' ) )->getTimestamp(),
				null,
				'2 minutes',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2024-05-03 21:00:00' ) )->getTimestamp(),
				null,
				'1 hour',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2024-05-03 22:00:00' ) )->getTimestamp(),
				null,
				'2 hours',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2024-05-04 20:00:00' ) )->getTimestamp(),
				null,
				'1 day',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2024-05-05 20:00:00' ) )->getTimestamp(),
				null,
				'2 days',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2024-06-03 20:00:00' ) )->getTimestamp(),
				2,
				'1 month',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2024-07-03 20:00:00' ) )->getTimestamp(),
				2,
				'2 months',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2024-07-04 20:00:00' ) )->getTimestamp(),
				2,
				'2 months and 1 day',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2025-05-03 20:00:00' ) )->getTimestamp(),
				2,
				'1 year',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2026-05-03 20:00:00' ) )->getTimestamp(),
				null,
				'2 years',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2034-05-03 20:00:00' ) )->getTimestamp(),
				null,
				'1 decade',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2044-05-03 20:00:00' ) )->getTimestamp(),
				null,
				'2 decades',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2124-05-03 20:00:00' ) )->getTimestamp(),
				null,
				'1 century',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2224-05-03 20:00:00' ) )->getTimestamp(),
				null,
				'2 centuries',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '3024-05-03 20:00:00' ) )->getTimestamp(),
				null,
				'1 millennium',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '4024-05-03 20:00:00' ) )->getTimestamp(),
				null,
				'2 millennia',
			],
			[
				0,
				9001,
				null,
				'2 hours, 30 minutes and 1 second',
			],
			[
				0,
				3601,
				null,
				'1 hour and 1 second',
			],
			[
				( new DateTime( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2025-05-05 22:30:00' ) )->getTimestamp(),
				null,
				'1 year, 2 days, 2 hours and 30 minutes',
			],
			[
				( new DateTimeImmutable( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTimeImmutable() )->setDate( 44024, 05, 03 )->setTime( 20, 0, 42 )->getTimestamp(),
				null,
				'42 millennia and 42 seconds',
			],
			[
				0,
				60,
				null,
				'1 minute',
			],
			[
				0,
				61,
				null,
				'1 minute and 1 second',
			],
			[
				( new DateTimeImmutable( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTimeImmutable() )->setDate( 2025, 05, 05 )->setTime( 22, 30, 0 )->getTimestamp(),
				null,
				'1 year, 2 days, 2 hours and 30 minutes',
			],
			[
				( new DateTimeImmutable( '2024-05-03 20:00:00' ) )->getTimestamp(),
				( new DateTimeImmutable( '2024-10-09 20:15:37' ) )->getTimestamp(),
				1,
				'5 months',
			],
			[
				( new DateTime( '2022-01-01 10:00:00' ) )->getTimestamp(),
				( new DateTime( '2022-01-01 12:30:00' ) )->getTimestamp(),
				2,
				'2 hours and 30 minutes',
			],
			[
				( new DateTime( '2022-01-01 10:00:00' ) )->getTimestamp(),
				( new DateTime( '2022-01-02 12:30:00' ) )->getTimestamp(),
				3,
				'1 day, 2 hours and 30 minutes',
			],
			[
				( new DateTime( '2022-01-01 10:00:00' ) )->getTimestamp(),
				( new DateTime( '2022-01-01 10:30:27' ) )->getTimestamp(),
				1,
				'30 minutes',
			],
			[
				( new DateTime( '2024-05-03 10:00:00' ) )->getTimestamp(),
				( new DateTime( '2025-05-03 10:00:00' ) )->getTimestamp(),
				6,
				'1 year',
			],
			[
				( new DateTime( '2024-01-28 10:00:00' ) )->getTimestamp(),
				( new DateTime( '2024-03-01 10:00:00' ) )->getTimestamp(),
				4,
				'1 month and 2 days',
			],
			[
				( new DateTime( '2023-01-28 10:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-03-01 10:00:00' ) )->getTimestamp(),
				6,
				'1 month and 1 day',
			],
			[
				( new DateTime( '2023-01-29 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-02-28 20:00:00' ) )->getTimestamp(),
				6,
				'30 days',
			],
			[
				( new DateTime( '2023-01-29 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-03-01 20:00:00' ) )->getTimestamp(),
				6,
				'1 month',
			],
			[
				( new DateTime( '2023-01-30 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-03-01 20:00:00' ) )->getTimestamp(),
				6,
				'30 days',
			],
			[
				( new DateTime( '2023-01-31 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-03-01 20:00:00' ) )->getTimestamp(),
				6,
				'29 days',
			],
			[
				( new DateTime( '2023-01-31 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-01-31 20:00:01' ) )->getTimestamp(),
				6,
				'1 second',
			],
			[
				( new DateTime( '2023-01-31 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-01-31 20:00:02' ) )->getTimestamp(),
				6,
				'2 seconds',
			],
			[
				( new DateTime( '2023-01-31 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-01-31 20:01:00' ) )->getTimestamp(),
				6,
				'1 minute',
			],
			[
				( new DateTime( '2023-01-31 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-01-31 20:02:00' ) )->getTimestamp(),
				6,
				'2 minutes',
			],
			[
				( new DateTime( '2023-01-31 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-01-31 21:00:00' ) )->getTimestamp(),
				6,
				'1 hour',
			],
			[
				( new DateTime( '2023-01-31 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-01-31 22:00:00' ) )->getTimestamp(),
				6,
				'2 hours',
			],
			[
				( new DateTime( '2023-01-31 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-02-01 20:00:00' ) )->getTimestamp(),
				6,
				'1 day',
			],
			[
				( new DateTime( '2023-01-31 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-02-02 20:00:00' ) )->getTimestamp(),
				6,
				'2 days',
			],
			[
				( new DateTime( '2023-03-31 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-04-31 20:00:00' ) )->getTimestamp(),
				6,
				'1 month',
			],
			[
				( new DateTime( '2023-01-31 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2023-03-31 20:00:00' ) )->getTimestamp(),
				6,
				'2 months',
			],
			[
				( new DateTime( '2023-01-31 20:00:00' ) )->getTimestamp(),
				( new DateTime( '2024-01-31 20:00:00' ) )->getTimestamp(),
				6,
				'1 year',
			],
			[
				( new DateTime( '2023-01-27 15:00:00' ) )->getTimestamp(),
				( new DateTime( '2025-04-31 20:06:00' ) )->getTimestamp(),
				5,
				'2 years, 3 months, 4 days, 5 hours and 6 minutes',
			],
			[
				( new DateTime( '2023-01-31 15:00:00' ) )->getTimestamp(),
				( new DateTime( '3025-04-31 20:06:07' ) )->getTimestamp(),
				7,
				'1 millennium, 2 years, 3 months, 5 hours, 6 minutes and 7 seconds',
			],
			[
				( new DateTime( '2023-01-28 20:00:00' ) )->getTimestamp(),
				( new DateTime( '4030-05-31 22:01:14' ) )->getTimestamp(),
				9,
				'2 millennia, 7 years, 4 months, 3 days, 2 hours, 1 minute and 14 seconds',
			],
		];
	}

	/**
	 * @dataProvider provideCheckTitleEncodingData
	 */
	public function testCheckTitleEncoding( $s ) {
		$this->assertEquals(
			$s,
			$this->getLang()->checkTitleEncoding( $s ),
			"checkTitleEncoding('$s')"
		);
	}

	public static function provideCheckTitleEncodingData() {
		return [
			[ "" ],
			[ "United States of America" ], // 7bit ASCII
			[ rawurldecode( "S%C3%A9rie%20t%C3%A9l%C3%A9vis%C3%A9e" ) ],
			[
				rawurldecode(
					"Acteur%7CAlbert%20Robbins%7CAnglais%7CAnn%20Donahue%7CAnthony%20E.%20Zuiker%7CCarol%20Mendelsohn"
				)
			],
			// The following two data sets come from T38839. They fail if checkTitleEncoding uses a regexp to test for
			// valid UTF-8 encoding and the pcre.recursion_limit is low (like, say, 1024). They succeed if checkTitleEncoding
			// uses mb_check_encoding for its test.
			[
				rawurldecode(
					"Acteur%7CAlbert%20Robbins%7CAnglais%7CAnn%20Donahue%7CAnthony%20E.%20Zuiker%7CCarol%20Mendelsohn%7C"
						. "Catherine%20Willows%7CDavid%20Hodges%7CDavid%20Phillips%7CGil%20Grissom%7CGreg%20Sanders%7CHodges%7C"
						. "Internet%20Movie%20Database%7CJim%20Brass%7CLady%20Heather%7C"
						. "Les%20Experts%20(s%C3%A9rie%20t%C3%A9l%C3%A9vis%C3%A9e)%7CLes%20Experts%20:%20Manhattan%7C"
						. "Les%20Experts%20:%20Miami%7CListe%20des%20personnages%20des%20Experts%7C"
						. "Liste%20des%20%C3%A9pisodes%20des%20Experts%7CMod%C3%A8le%20discussion:Palette%20Les%20Experts%7C"
						. "Nick%20Stokes%7CPersonnage%20de%20fiction%7CPersonnage%20fictif%7CPersonnage%20de%20fiction%7C"
						. "Personnages%20r%C3%A9currents%20dans%20Les%20Experts%7CRaymond%20Langston%7CRiley%20Adams%7C"
						. "Saison%201%20des%20Experts%7CSaison%2010%20des%20Experts%7CSaison%2011%20des%20Experts%7C"
						. "Saison%2012%20des%20Experts%7CSaison%202%20des%20Experts%7CSaison%203%20des%20Experts%7C"
						. "Saison%204%20des%20Experts%7CSaison%205%20des%20Experts%7CSaison%206%20des%20Experts%7C"
						. "Saison%207%20des%20Experts%7CSaison%208%20des%20Experts%7CSaison%209%20des%20Experts%7C"
						. "Sara%20Sidle%7CSofia%20Curtis%7CS%C3%A9rie%20t%C3%A9l%C3%A9vis%C3%A9e%7CWallace%20Langham%7C"
						. "Warrick%20Brown%7CWendy%20Simms%7C%C3%89tats-Unis"
				),
			],
			[
				rawurldecode(
					"Mod%C3%A8le%3AArrondissements%20homonymes%7CMod%C3%A8le%3ABandeau%20standard%20pour%20page%20d'homonymie%7C"
						. "Mod%C3%A8le%3ABatailles%20homonymes%7CMod%C3%A8le%3ACantons%20homonymes%7C"
						. "Mod%C3%A8le%3ACommunes%20fran%C3%A7aises%20homonymes%7CMod%C3%A8le%3AFilms%20homonymes%7C"
						. "Mod%C3%A8le%3AGouvernements%20homonymes%7CMod%C3%A8le%3AGuerres%20homonymes%7CMod%C3%A8le%3AHomonymie%7C"
						. "Mod%C3%A8le%3AHomonymie%20bateau%7CMod%C3%A8le%3AHomonymie%20d'%C3%A9tablissements%20scolaires%20ou"
						. "%20universitaires%7CMod%C3%A8le%3AHomonymie%20d'%C3%AEles%7CMod%C3%A8le%3AHomonymie%20de%20clubs%20sportifs%7C"
						. "Mod%C3%A8le%3AHomonymie%20de%20comt%C3%A9s%7CMod%C3%A8le%3AHomonymie%20de%20monument%7C"
						. "Mod%C3%A8le%3AHomonymie%20de%20nom%20romain%7CMod%C3%A8le%3AHomonymie%20de%20parti%20politique%7C"
						. "Mod%C3%A8le%3AHomonymie%20de%20route%7CMod%C3%A8le%3AHomonymie%20dynastique%7C"
						. "Mod%C3%A8le%3AHomonymie%20vid%C3%A9oludique%7CMod%C3%A8le%3AHomonymie%20%C3%A9difice%20religieux%7C"
						. "Mod%C3%A8le%3AInternationalisation%7CMod%C3%A8le%3AIsom%C3%A9rie%7CMod%C3%A8le%3AParonymie%7C"
						. "Mod%C3%A8le%3APatronyme%7CMod%C3%A8le%3APatronyme%20basque%7CMod%C3%A8le%3APatronyme%20italien%7C"
						. "Mod%C3%A8le%3APatronymie%7CMod%C3%A8le%3APersonnes%20homonymes%7CMod%C3%A8le%3ASaints%20homonymes%7C"
						. "Mod%C3%A8le%3ATitres%20homonymes%7CMod%C3%A8le%3AToponymie%7CMod%C3%A8le%3AUnit%C3%A9s%20homonymes%7C"
						. "Mod%C3%A8le%3AVilles%20homonymes%7CMod%C3%A8le%3A%C3%89difices%20religieux%20homonymes"
				)
			]
		];
		// phpcs:enable
	}

	/**
	 * @dataProvider provideRomanNumeralsData
	 */
	public function testRomanNumerals( $num, $numerals ) {
		$this->assertEquals(
			$numerals,
			Language::romanNumeral( $num ),
			"romanNumeral('$num')"
		);
	}

	public static function provideRomanNumeralsData() {
		return [
			[ 1, 'I' ],
			[ 2, 'II' ],
			[ 3, 'III' ],
			[ 4, 'IV' ],
			[ 5, 'V' ],
			[ 6, 'VI' ],
			[ 7, 'VII' ],
			[ 8, 'VIII' ],
			[ 9, 'IX' ],
			[ 10, 'X' ],
			[ 20, 'XX' ],
			[ 30, 'XXX' ],
			[ 40, 'XL' ],
			[ 49, 'XLIX' ],
			[ 50, 'L' ],
			[ 60, 'LX' ],
			[ 70, 'LXX' ],
			[ 80, 'LXXX' ],
			[ 90, 'XC' ],
			[ 99, 'XCIX' ],
			[ 100, 'C' ],
			[ 200, 'CC' ],
			[ 300, 'CCC' ],
			[ 400, 'CD' ],
			[ 500, 'D' ],
			[ 600, 'DC' ],
			[ 700, 'DCC' ],
			[ 800, 'DCCC' ],
			[ 900, 'CM' ],
			[ 999, 'CMXCIX' ],
			[ 1000, 'M' ],
			[ 1989, 'MCMLXXXIX' ],
			[ 2000, 'MM' ],
			[ 3000, 'MMM' ],
			[ 4000, 'MMMM' ],
			[ 5000, 'MMMMM' ],
			[ 6000, 'MMMMMM' ],
			[ 7000, 'MMMMMMM' ],
			[ 8000, 'MMMMMMMM' ],
			[ 9000, 'MMMMMMMMM' ],
			[ 9999, 'MMMMMMMMMCMXCIX' ],
			[ 10000, 'MMMMMMMMMM' ],
		];
	}

	/**
	 * @dataProvider provideHebrewNumeralsData
	 */
	public function testHebrewNumeral( $num, $numerals ) {
		$this->assertEquals(
			$numerals,
			Language::hebrewNumeral( $num ),
			"hebrewNumeral('$num')"
		);
	}

	public static function provideHebrewNumeralsData() {
		return [
			[ -1, -1 ],
			[ 0, 0 ],
			[ 1, "א'" ],
			[ 2, "ב'" ],
			[ 3, "ג'" ],
			[ 4, "ד'" ],
			[ 5, "ה'" ],
			[ 6, "ו'" ],
			[ 7, "ז'" ],
			[ 8, "ח'" ],
			[ 9, "ט'" ],
			[ 10, "י'" ],
			[ 11, 'י"א' ],
			[ 14, 'י"ד' ],
			[ 15, 'ט"ו' ],
			[ 16, 'ט"ז' ],
			[ 17, 'י"ז' ],
			[ 20, "כ'" ],
			[ 21, 'כ"א' ],
			[ 30, "ל'" ],
			[ 40, "מ'" ],
			[ 50, "נ'" ],
			[ 60, "ס'" ],
			[ 70, "ע'" ],
			[ 80, "פ'" ],
			[ 90, "צ'" ],
			[ 99, 'צ"ט' ],
			[ 100, "ק'" ],
			[ 101, 'ק"א' ],
			[ 110, 'ק"י' ],
			[ 200, "ר'" ],
			[ 300, "ש'" ],
			[ 400, "ת'" ],
			[ 500, 'ת"ק' ],
			[ 800, 'ת"ת' ],
			[ 1000, "א' אלף" ],
			[ 1001, "א'א'" ],
			[ 1012, "א'י\"ב" ],
			[ 1020, "א'ך'" ],
			[ 1030, "א'ל'" ],
			[ 1081, "א'פ\"א" ],
			[ 2000, "ב' אלפים" ],
			[ 2016, "ב'ט\"ז" ],
			[ 3000, "ג' אלפים" ],
			[ 4000, "ד' אלפים" ],
			[ 4904, "ד'תתק\"ד" ],
			[ 5000, "ה' אלפים" ],
			[ 5680, "ה'תר\"ף" ],
			[ 5690, "ה'תר\"ץ" ],
			[ 5708, "ה'תש\"ח" ],
			[ 5720, "ה'תש\"ך" ],
			[ 5740, "ה'תש\"ם" ],
			[ 5750, "ה'תש\"ן" ],
			[ 5775, "ה'תשע\"ה" ],
		];
	}

	/**
	 * @dataProvider providePluralData
	 */
	public function testConvertPlural( $expected, $number, $forms ) {
		$chosen = $this->getLang()->convertPlural( $number, $forms );
		$this->assertEquals( $expected, $chosen );
	}

	public static function providePluralData() {
		// Params are: [expected text, number given, [the plural forms]]
		return [
			[ 'plural', 0, [
				'singular', 'plural'
			] ],
			[ 'explicit zero', 0, [
				'0=explicit zero', 'singular', 'plural'
			] ],
			[ 'explicit one', 1, [
				'singular', 'plural', '1=explicit one',
			] ],
			[ 'singular', 1, [
				'singular', 'plural', '0=explicit zero',
			] ],
			[ 'plural', 3, [
				'0=explicit zero', '1=explicit one', 'singular', 'plural'
			] ],
			[ 'explicit eleven', 11, [
				'singular', 'plural', '11=explicit eleven',
			] ],
			[ 'plural', 12, [
				'singular', 'plural', '11=explicit twelve',
			] ],
			[ 'plural', 12, [
				'singular', 'plural', '=explicit form',
			] ],
			[ 'other', 2, [
				'kissa=kala', '1=2=3', 'other',
			] ],
			[ '', 2, [
				'0=explicit zero', '1=explicit one',
			] ],
		];
	}

	public function testEmbedBidi() {
		$lre = "\u{202A}"; // U+202A LEFT-TO-RIGHT EMBEDDING
		$rle = "\u{202B}"; // U+202B RIGHT-TO-LEFT EMBEDDING
		$pdf = "\u{202C}"; // U+202C POP DIRECTIONAL FORMATTING
		$lang = $this->getLang();
		$this->assertSame(
			'123',
			$lang->embedBidi( '123' ),
			'embedBidi with neutral argument'
		);
		$this->assertEquals(
			$lre . 'Ben_(WMF)' . $pdf,
			$lang->embedBidi( 'Ben_(WMF)' ),
			'embedBidi with LTR argument'
		);
		$this->assertEquals(
			$rle . 'יהודי (מנוחין)' . $pdf,
			$lang->embedBidi( 'יהודי (מנוחין)' ),
			'embedBidi with RTL argument'
		);
	}

	/**
	 * @dataProvider provideTranslateBlockExpiry
	 */
	public function testTranslateBlockExpiry( $expectedData, $str, $now, $desc ) {
		$lang = $this->getLang();
		if ( is_array( $expectedData ) ) {
			$func = array_shift( $expectedData );
			$expected = $lang->$func( ...$expectedData );
		} else {
			$expected = $expectedData;
		}
		// HACK:
		date_default_timezone_set( 'UTC' );
		$this->assertSame( $expected, $lang->translateBlockExpiry( $str, null, $now ), $desc );
	}

	public static function provideTranslateBlockExpiry() {
		return [
			[ '2 hours', '2 hours', 0, 'simple data from ipboptions' ],
			[ 'indefinite', 'infinite', 0, 'infinite from ipboptions' ],
			[ 'indefinite', 'infinity', 0, 'alternative infinite from ipboptions' ],
			[ 'indefinite', 'indefinite', 0, 'another alternative infinite from ipboptions' ],
			[ [ 'formatDurationBetweenTimestamps', 0, 1023 * 60 * 60 ], '1023 hours', 0, 'relative' ],
			[ [ 'formatDurationBetweenTimestamps', 0, -1023 ], '-1023 seconds', 0, 'negative relative' ],
			[
				[ 'formatDurationBetweenTimestamps', 665553906, 665553906 + ( 1023 * 60 * 60 ) ],
				'1023 hours',
				wfTimestamp( TS_UNIX, '1991-02-03 04:05:06' ),
				'relative with initial timestamp'
			],
			[ [ 'formatDurationBetweenTimestamps', 0, 0 ], 'now', 0, 'now' ],
			[
				[ 'timeanddate', '20120102070000' ],
				'2012-1-1 7:00 +1 day',
				0,
				'mixed, handled as absolute'
			],
			[ [ 'timeanddate', '19910203040506' ], '1991-2-3 4:05:06', 0, 'absolute' ],
			[ [ 'timeanddate', '19700101000000' ], '1970-1-1 0:00:00', 0, 'absolute at epoch' ],
			[ [ 'timeanddate', '19691231235959' ], '1969-12-31 23:59:59', 0, 'time before epoch' ],
			[
				[ 'timeanddate', '19910910000000' ],
				'10 september',
				wfTimestamp( TS_UNIX, '19910203040506' ),
				'partial'
			],
			[ 'dummy', 'dummy', 0, 'return garbage as is' ],
		];
	}

	/**
	 * @dataProvider provideFormatNum
	 */
	public function testFormatNum(
		$translateNumerals, $langCode, $number, $noSeparators, $expected
	) {
		$this->hideDeprecated( 'Language::formatNum with a non-numeric string' );
		$this->overrideConfigValue( MainConfigNames::TranslateNumerals, $translateNumerals );
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( $langCode );
		if ( $noSeparators ) {
			$formattedNum = $lang->formatNumNoSeparators( $number );
		} else {
			$formattedNum = $lang->formatNum( $number );
		}
		$this->assertIsString( $formattedNum );
		$this->assertEquals( $expected, $formattedNum );
	}

	public static function provideFormatNum() {
		return [
			[ true, 'en', 100, false, '100' ],
			[ true, 'en', 101, true, '101' ],
			[ false, 'en', 103, false, '103' ],
			[ false, 'en', 104, true, '104' ],
			[ true, 'en', '105', false, '105' ],
			[ true, 'en', '106', true, '106' ],
			[ false, 'en', '107', false, '107' ],
			[ false, 'en', '108', true, '108' ],
			[ true, 'en', -1, false, '−1' ],
			[ true, 'en', 10, false, '10' ],
			[ true, 'en', 100, false, '100' ],
			[ true, 'en', 1000, false, '1,000' ],
			[ true, 'en', 10000, false, '10,000' ],
			[ true, 'en', 100000, false, '100,000' ],
			[ true, 'en', 1000000, false, '1,000,000' ],
			[ true, 'en', -1.001, false, '−1.001' ],
			[ true, 'en', 1.001, false, '1.001' ],
			[ true, 'en', 10.0001, false, '10.0001' ],
			[ true, 'en', 100.001, false, '100.001' ],
			[ true, 'en', 1000.001, false, '1,000.001' ],
			[ true, 'en', 10000.001, false, '10,000.001' ],
			[ true, 'en', 100000.001, false, '100,000.001' ],
			[ true, 'en', 1000000.0001, false, '1,000,000.0001' ],
			[ true, 'en', -1.0001, false, '−1.0001' ],
			[ true, 'en', '200000000000000000000', false, '200,000,000,000,000,000,000' ],
			[ true, 'en', '-200000000000000000000', false, '−200,000,000,000,000,000,000' ],
			[ true, 'en', '1.23e10', false, '12,300,000,000' ],
			[ true, 'en', 1.23e10, false, '12,300,000,000' ],
			[ true, 'en', '1.23E-01', false, '0.123' ],
			[ true, 'en', 1.23e-1, false, '0.123' ],
			[ true, 'en', 0.0, false, '0' ],
			[ true, 'en', -0.0, false, '−0' ],
			[ true, 'en', INF, false, '∞' ],
			[ true, 'en', -INF, false, '−∞' ],
			[ true, 'en', NAN, false, 'Not a Number' ],
			[ true, 'kn', '1050', false, '೧,೦೫೦' ],
			[ true, 'kn', '1060', true, '೧೦೬೦' ],
			[ false, 'kn', '1070', false, '1,070' ],
			[ false, 'kn', '1080', true, '1080' ],
			[ true, 'kn', '.1090', false, '.೧೦೯೦' ],

			// Make sure non-numeric strings are not destroyed
			[ false, 'en', 'The number is 1234', false, 'The number is 1,234' ],
			[ false, 'en', '1234 is the number', false, '1,234 is the number' ],
			[ false, 'de', '.', false, '.' ],
			[ false, 'de', ',', false, ',' ],

			/** @see https://phabricator.wikimedia.org/T237467 */
			[ false, 'kn', "೭\u{FFFD}0", false, "೭\u{FFFD}0" ],
			[ false, 'kn', "-೭\u{FFFD}0", false, "-೭\u{FFFD}0" ],
			[ false, 'kn', "-1೭\u{FFFD}0", false, "−1೭\u{FFFD}0" ],

			/** @see https://phabricator.wikimedia.org/T267614 */
			[ false, 'ar', "1", false, "1" ],
			[ false, 'ar', "1234.5", false, "1٬234٫5" ],
			[ true, 'ar', "1", false, "١" ],
			[ true, 'ar', "1234.5", false, "١٬٢٣٤٫٥" ],

			// Test minimumGroupingDigits > 1
			[ false, 'pl', 1, false, '1' ],
			[ false, 'pl', 100, false, '100' ],
			[ false, 'pl', 1000, false, '1000' ],
			[ false, 'pl', 10000, false, "10\u{00A0}000" ],
			[ false, 'pl', 1000000, false, "1\u{00A0}000\u{00A0}000" ],
			[ false, 'pl', '1000.1', false, "1000,1" ],
		];
	}

	/**
	 * @dataProvider parseFormattedNumberProvider
	 */
	public function testParseFormattedNumber( $langCode, $number ) {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( $langCode );

		$localisedNum = $lang->formatNum( $number );
		$normalisedNum = $lang->parseFormattedNumber( $localisedNum );

		$this->assertEquals( $number, $normalisedNum );
	}

	public static function parseFormattedNumberProvider() {
		return [
			[ 'de', 377.01 ],
			[ 'fa', 334 ],
			[ 'fa', 382.772 ],
			[ 'ar', 1844 ],
			[ 'lzh', 3731 ],
			[ 'zh-classical', 7432 ],
			[ 'en', 1234.567 ],
			[ 'en', 0.0 ],
			[ 'en', -0.0 ],
			[ 'en', INF ],
			[ 'en', -INF ],
			[ 'en', NAN ],
		];
	}

	public function testListToText() {
		$lang = $this->getLang();
		$and = $lang->getMessageFromDB( 'and' );
		$s = $lang->getMessageFromDB( 'word-separator' );
		$c = $lang->getMessageFromDB( 'comma-separator' );

		$this->assertSame( '', $lang->listToText( [] ) );
		$this->assertEquals( 'a', $lang->listToText( [ 'a' ] ) );
		$this->assertEquals( "a{$and}{$s}b", $lang->listToText( [ 'a', 'b' ] ) );
		$this->assertEquals( "a{$c}b{$and}{$s}c", $lang->listToText( [ 'a', 'b', 'c' ] ) );
		$this->assertEquals( "a{$c}b{$c}c{$and}{$s}d", $lang->listToText( [ 'a', 'b', 'c', 'd' ] ) );
	}

	/**
	 * Example of the real localisation files being loaded.
	 *
	 * This might be a bit cumbersome to maintain long-term,
	 * but still valueable to have as integration test.
	 */
	public function testGetNamespaceAliasesReal() {
		$language = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'zh' );
		$aliases = $language->getNamespaceAliases();
		$this->assertSame( NS_FILE, $aliases['文件'] );
		$this->assertSame( NS_FILE, $aliases['檔案'] );
	}

	public function testGetNamespaceAliasesFullLogic() {
		$hooks = $this->createHookContainer( [
			'Language::getMessagesFileName' => static function ( $code, &$file ) {
				$file = __DIR__ . '/../../data/messages/Messages_' . $code . '.php';
			}
		] );
		$langNameUtils = $this->getDummyLanguageNameUtils( [ 'hookContainer' => $hooks ] );

		$this->overrideConfigValue( MainConfigNames::NamespaceAliases, [
			'Mouse' => NS_SPECIAL,
		] );
		$this->setService( 'LanguageNameUtils', $langNameUtils );

		$language = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'x-bar' );

		$this->assertEquals(
			[
				// from x-bar
				'Cat' => NS_FILE,
				'Cat_toots' => NS_FILE_TALK,
				// inherited from x-foo
				'Dog' => NS_USER,
				'Dog_woofs' => NS_USER_TALK,
				// add from site configuration
				'Mouse' => NS_SPECIAL,
			],
			$language->getNamespaceAliases()
		);
	}

	public function testEquals() {
		$languageFactory = $this->getServiceContainer()->getLanguageFactory();
		$en1 = $languageFactory->getLanguage( 'en' );
		$en2 = $languageFactory->getLanguage( 'en' );
		$en3 = $this->newLanguage();
		$this->assertTrue( $en1->equals( $en2 ), 'en1 equals en2' );
		$this->assertTrue( $en2->equals( $en3 ), 'en2 equals en3' );
		$this->assertTrue( $en3->equals( $en1 ), 'en3 equals en1' );

		$fr = $languageFactory->getLanguage( 'fr' );
		$this->assertFalse( $en1->equals( $fr ), 'en not equals fr' );

		$ar1 = $languageFactory->getLanguage( 'ar' );
		$ar2 = $this->newLanguage( LanguageAr::class, 'ar' );
		$this->assertTrue( $ar1->equals( $ar2 ), 'ar equals ar' );
	}

	/**
	 * @dataProvider provideUcfirst
	 */
	public function testUcfirst( $orig, $expected, $desc, $overrides = false ) {
		$lang = $this->newLanguage();
		if ( is_array( $overrides ) ) {
			$this->overrideConfigValue(
				MainConfigNames::OverrideUcfirstCharacters,
				$overrides
			);
		}
		$this->assertSame( $expected, $lang->ucfirst( $orig ), $desc );
	}

	public static function provideUcfirst() {
		return [
			[ 'alice', 'Alice', 'simple ASCII string', false ],
			[ 'århus', 'Århus', 'unicode string', false ],
			// overrides do not affect ASCII characters
			[ 'foo', 'Foo', 'ASCII is not overridden', [ 'f' => 'b' ] ],
			// but they do affect non-ascii ones
			[ 'èl', 'Ll', 'Non-ASCII is overridden', [ 'è' => 'L' ] ],
			[ 'ვიკიპედია', 'ვიკიპედია', 'Georgian case is preserved', false ],
		];
	}

	// The following methods are for LanguageNameUtilsTestTrait

	private function isSupportedLanguage( $code ) {
		return $this->getServiceContainer()->getLanguageNameUtils()->isSupportedLanguage( $code );
	}

	private function isValidCode( $code ) {
		return $this->getServiceContainer()->getLanguageNameUtils()->isValidCode( $code );
	}

	private function isValidBuiltInCode( $code ) {
		return $this->getServiceContainer()->getLanguageNameUtils()->isValidBuiltInCode( $code );
	}

	private function isKnownLanguageTag( $code ) {
		return $this->getServiceContainer()->getLanguageNameUtils()->isKnownLanguageTag( $code );
	}

	protected function setLanguageTemporaryHook( string $hookName, $handler ): void {
		$this->setTemporaryHook( $hookName, $handler );
	}

	protected function clearLanguageHook( string $hookName ): void {
		$this->clearHook( $hookName );
	}

	/**
	 * Call getLanguageName() and getLanguageNames() using the Language static methods.
	 *
	 * @param array $options To set globals for testing Language
	 * @param string $expected
	 * @param string $code
	 * @param mixed ...$otherArgs Optionally, pass $inLanguage and/or $include.
	 */
	private function assertGetLanguageNames( array $options, $expected, $code, ...$otherArgs ) {
		if ( $options ) {
			$this->overrideConfigValues( $options );
		}

		$langNameUtils = $this->getServiceContainer()->getLanguageNameUtils();
		$this->assertSame( $expected,
			$langNameUtils->getLanguageNames( ...$otherArgs )[strtolower( $code )] ?? '' );
		$this->assertSame( $expected, $langNameUtils->getLanguageName( $code, ...$otherArgs ) );
	}

	private function getLanguageNames( ...$args ) {
		return $this->getServiceContainer()->getLanguageNameUtils()->getLanguageNames( ...$args );
	}

	private function getLanguageName( ...$args ) {
		return $this->getServiceContainer()->getLanguageNameUtils()->getLanguageName( ...$args );
	}

	private function getFileName( ...$args ) {
		return MediaWikiServices::getInstance()->getLanguageNameUtils()->getFileName( ...$args );
	}

	private function getMessagesFileName( $code ) {
		return MediaWikiServices::getInstance()->getLanguageNameUtils()->getMessagesFileName( $code );
	}

	private function getJsonMessagesFileName( $code ) {
		return MediaWikiServices::getInstance()->getLanguageNameUtils()->getJsonMessagesFileName( $code );
	}

	/**
	 * @todo This really belongs in the cldr extension's tests.
	 */
	public function testCldr() {
		$this->markTestSkippedIfExtensionNotLoaded( 'CLDR' );

		$languageNameUtils = $this->getServiceContainer()->getLanguageNameUtils();

		// "pal" is an ancient language, which probably will not appear in Names.php, but appears in
		// CLDR in English
		$this->assertTrue( $languageNameUtils->isKnownLanguageTag( 'pal' ) );

		$this->assertSame( 'allemand', $languageNameUtils->getLanguageName( 'de', 'fr' ) );
	}

	/**
	 * @dataProvider provideGetNamespaces
	 */
	public function testGetNamespaces( string $langCode, array $config, array $expected ) {
		$services = $this->getServiceContainer();
		$langClass = Language::class . ucfirst( $langCode );
		if ( !class_exists( $langClass ) ) {
			$langClass = Language::class;
		}
		$config += [
			MainConfigNames::MetaNamespace => 'Project',
			MainConfigNames::MetaNamespaceTalk => false,
			MainConfigNames::ExtraNamespaces => [],
		];
		$nsInfo = new NamespaceInfo(
			new ServiceOptions( NamespaceInfo::CONSTRUCTOR_OPTIONS, $config, $services->getMainConfig() ),
			$services->getHookContainer(),
			ExtensionRegistry::getInstance()->getAttribute( 'ExtensionNamespaces' ),
			ExtensionRegistry::getInstance()->getAttribute( 'ImmovableNamespaces' )
		);
		/** @var Language $lang */
		$lang = new $langClass(
			$langCode,
			$nsInfo,
			$services->getLocalisationCache(),
			$this->createNoOpMock( LanguageNameUtils::class ),
			$this->createNoOpMock( LanguageFallback::class ),
			$this->createNoOpMock( LanguageConverterFactory::class ),
			$this->createMock( HookContainer::class ),
			new MultiConfig( [ new HashConfig( $config ), $services->getMainConfig() ] )
		);
		$namespaces = $lang->getNamespaces();
		$this->assertArraySubmapSame( $expected, $namespaces );
	}

	public static function provideGetNamespaces() {
		$enNamespaces = [
			NS_MEDIA            => 'Media',
			NS_SPECIAL          => 'Special',
			NS_MAIN             => '',
			NS_TALK             => 'Talk',
			NS_USER             => 'User',
			NS_USER_TALK        => 'User_talk',
			NS_FILE             => 'File',
			NS_FILE_TALK        => 'File_talk',
			NS_MEDIAWIKI        => 'MediaWiki',
			NS_MEDIAWIKI_TALK   => 'MediaWiki_talk',
			NS_TEMPLATE         => 'Template',
			NS_TEMPLATE_TALK    => 'Template_talk',
			NS_HELP             => 'Help',
			NS_HELP_TALK        => 'Help_talk',
			NS_CATEGORY         => 'Category',
			NS_CATEGORY_TALK    => 'Category_talk',
		];
		$ukNamespaces = [
			NS_MEDIA            => 'Медіа',
			NS_SPECIAL          => 'Спеціальна',
			NS_TALK             => 'Обговорення',
			NS_USER             => 'Користувач',
			NS_USER_TALK        => 'Обговорення_користувача',
			NS_FILE             => 'Файл',
			NS_FILE_TALK        => 'Обговорення_файлу',
			NS_MEDIAWIKI        => 'MediaWiki',
			NS_MEDIAWIKI_TALK   => 'Обговорення_MediaWiki',
			NS_TEMPLATE         => 'Шаблон',
			NS_TEMPLATE_TALK    => 'Обговорення_шаблону',
			NS_HELP             => 'Довідка',
			NS_HELP_TALK        => 'Обговорення_довідки',
			NS_CATEGORY         => 'Категорія',
			NS_CATEGORY_TALK    => 'Обговорення_категорії',
		];
		return [
			'Default configuration' => [
				'en',
				[],
				$enNamespaces + [
					NS_PROJECT => 'Project',
					NS_PROJECT_TALK => 'Project_talk',
				],
			],
			'Custom project NS + extra' => [
				'en',
				[
					MainConfigNames::MetaNamespace => 'Wikipedia',
					MainConfigNames::ExtraNamespaces => [
						100 => 'Borderlands',
						101 => 'Borderlands_talk',
					],
				],
				$enNamespaces + [
					NS_PROJECT => 'Wikipedia',
					NS_PROJECT_TALK => 'Wikipedia_talk',
					100 => 'Borderlands',
					101 => 'Borderlands_talk',
				],
			],
			'Custom project NS and talk + extra' => [
				'en',
				[
					MainConfigNames::MetaNamespace => 'Wikipedia',
					MainConfigNames::MetaNamespaceTalk => 'Wikipedia_drama',
					MainConfigNames::ExtraNamespaces => [
						100 => 'Borderlands',
						101 => 'Borderlands_talk',
					],
				],
				$enNamespaces + [
					NS_PROJECT => 'Wikipedia',
					NS_PROJECT_TALK => 'Wikipedia_drama',
					100 => 'Borderlands',
					101 => 'Borderlands_talk',
				],
			],
			'Ukrainian default' => [
				'uk',
				[],
				$ukNamespaces + [
					NS_MAIN => '',
					NS_PROJECT => 'Project',
					NS_PROJECT_TALK => 'Обговорення_Project',
				],
			],
			'Ukrainian custom NS' => [
				'uk',
				[
					MainConfigNames::MetaNamespace => 'Вікіпедія',
				],
				$ukNamespaces + [
					NS_MAIN => '',
					NS_PROJECT => 'Вікіпедія',
					NS_PROJECT_TALK => 'Обговорення_Вікіпедії',
				],
			],
		];
	}

	public function testGetGroupName() {
		$lang = $this->getLang();
		$groupName = $lang->getGroupName( 'bot' );
		$this->assertSame( 'Bots', $groupName );
	}

	public function testGetGroupMemberName() {
		$lang = $this->getLang();
		$user = new UserIdentityValue( 1, 'user' );
		$groupMemberName = $lang->getGroupMemberName( 'bot', $user );
		$this->assertSame( 'bot', $groupMemberName );

		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'qqx' );
		$groupMemberName = $lang->getGroupMemberName( 'bot', $user );
		$this->assertSame( '(group-bot-member: user)', $groupMemberName );
	}

	public function testMsg() {
		$lang = TestingAccessWrapper::newFromObject( $this->getLang() );
		$this->assertSame( 'Line 1:', $lang->msg( 'lineno', '1' )->text() );
	}

	public function testBlockDurations() {
		$lang = $this->getLang();
		$durations = $lang->getBlockDurations();

		$this->assertContains( 'other', $durations );
		$this->assertContains( 'infinite', $durations );
		$this->assertContains( '1 day', $durations );
	}

}
PK       ! 1  1    language/MessageCacheTest.phpnu Iw        <?php

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\ContentHandler;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Title\Title;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Language
 * @group Database
 * @covers \MessageCache
 */
class MessageCacheTest extends MediaWikiLangTestCase {

	protected function setUp(): void {
		parent::setUp();
		$this->configureLanguages();
		$this->getServiceContainer()->getMessageCache()->enable();
	}

	/**
	 * Helper function -- setup site language for testing
	 */
	protected function configureLanguages() {
		// for the test, we need the content language to be anything but English,
		// let's choose e.g. German (de)
		$this->setUserLang( 'de' );
		$this->setContentLang( 'de' );
	}

	public function addDBDataOnce() {
		$this->configureLanguages();

		// Set up messages and fallbacks ab -> ru -> de
		$this->makePage( 'FallbackLanguageTest-Full', 'ab' );
		$this->makePage( 'FallbackLanguageTest-Full', 'ru' );
		$this->makePage( 'FallbackLanguageTest-Full', 'de' );

		// Fallbacks where ab does not exist
		$this->makePage( 'FallbackLanguageTest-Partial', 'ru' );
		$this->makePage( 'FallbackLanguageTest-Partial', 'de' );

		// Fallback to the content language
		$this->makePage( 'FallbackLanguageTest-ContLang', 'de' );

		// Full key tests -- always want russian
		$this->makePage( 'MessageCacheTest-FullKeyTest', 'ab' );
		$this->makePage( 'MessageCacheTest-FullKeyTest', 'ru' );

		// In content language -- get base if no derivative
		$this->makePage( 'FallbackLanguageTest-NoDervContLang', 'de', 'de/none' );
	}

	/**
	 * Helper function for addDBData -- adds a simple page to the database
	 *
	 * @param string $title Title of page to be created
	 * @param string $lang Language and content of the created page
	 * @param string|null $content Content of the created page, or null for a generic string
	 *
	 * @return RevisionRecord
	 */
	private function makePage( $title, $lang, $content = null ) {
		$content ??= $lang;
		if ( $lang !== $this->getServiceContainer()->getContentLanguage()->getCode() ) {
			$title = "$title/$lang";
		}

		$title = Title::makeTitle( NS_MEDIAWIKI, $title );
		$wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$content = ContentHandler::makeContent( $content, $title );
		$summary = CommentStoreComment::newUnsavedComment( "$lang translation test case" );

		$newRevision = $wikiPage->newPageUpdater( $this->getTestSysop()->getUser() )
			->setContent( SlotRecord::MAIN, $content )
			->saveRevision( $summary );

		$this->assertNotNull( $newRevision, 'Create page ' . $title->getPrefixedDBkey() );
		return $newRevision;
	}

	/**
	 * Test message fallbacks, T3495
	 *
	 * @dataProvider provideMessagesForFallback
	 */
	public function testMessageFallbacks( $message, $langCode, $expectedContent ) {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( $langCode );
		$result = $this->getServiceContainer()->getMessageCache()->get( $message, true, $lang );
		$this->assertEquals( $expectedContent, $result, "Message fallback failed." );
	}

	public static function provideMessagesForFallback() {
		return [
			[ 'FallbackLanguageTest-Full', 'ab', 'ab' ],
			[ 'FallbackLanguageTest-Partial', 'ab', 'ru' ],
			[ 'FallbackLanguageTest-ContLang', 'ab', 'de' ],
			[ 'FallbackLanguageTest-None', 'ab', false ],

			// T48579
			[ 'FallbackLanguageTest-NoDervContLang', 'de', 'de/none' ],
			// UI language different from content language should only use de/none as last option
			[ 'FallbackLanguageTest-NoDervContLang', 'fit', 'de/none' ],
		];
	}

	public function testReplaceMsg() {
		$messageCache = $this->getServiceContainer()->getMessageCache();
		$message = 'go';
		$uckey = $this->getServiceContainer()->getContentLanguage()->ucfirst( $message );
		$oldText = $messageCache->get( $message ); // "Ausführen"

		$dbw = $this->getDb();
		$dbw->startAtomic( __METHOD__ ); // simulate request and block deferred updates
		$messageCache->replace( $uckey, 'Allez!' );
		$this->assertEquals( 'Allez!',
			$messageCache->getMsgFromNamespace( $uckey, 'de' ),
			'Updates are reflected in-process immediately' );
		$this->assertEquals( 'Allez!',
			$messageCache->get( $message ),
			'Updates are reflected in-process immediately' );
		$this->makePage( 'Go', 'de', 'Race!' );
		$dbw->endAtomic( __METHOD__ );

		$this->assertSame( 0,
			DeferredUpdates::pendingUpdatesCount(),
			'Post-commit deferred update triggers a run of all updates' );

		$this->assertEquals( 'Race!', $messageCache->get( $message ), 'Correct final contents' );

		$this->makePage( 'Go', 'de', $oldText );
		$messageCache->replace( $uckey, $oldText ); // deferred update runs immediately
		$this->assertEquals( $oldText, $messageCache->get( $message ), 'Content restored' );
	}

	public function testReplaceCache() {
		$this->overrideConfigValues( [
			MainConfigNames::MainCacheType => CACHE_HASH,
		] );

		$messageCache = $this->getServiceContainer()->getMessageCache();
		$messageCache->enable();

		// Populate one key
		$this->makePage( 'Key1', 'de', 'Value1' );
		$this->assertSame( 0,
			DeferredUpdates::pendingUpdatesCount(),
			'Post-commit deferred update triggers a run of all updates' );
		$this->assertEquals( 'Value1', $messageCache->get( 'Key1' ), 'Key1 was successfully edited' );

		// Screw up the database so MessageCache::loadFromDB() will
		// produce the wrong result for reloading Key1
		$this->getDb()->newDeleteQueryBuilder()
			->deleteFrom( 'page' )
			->where( [ 'page_namespace' => NS_MEDIAWIKI, 'page_title' => 'Key1' ] )
			->caller( __METHOD__ )
			->execute();

		// Populate the second key
		$this->makePage( 'Key2', 'de', 'Value2' );
		$this->assertSame( 0,
			DeferredUpdates::pendingUpdatesCount(),
			'Post-commit deferred update triggers a run of all updates' );
		$this->assertEquals( 'Value2', $messageCache->get( 'Key2' ), 'Key2 was successfully edited' );

		// Now test that the second edit didn't reload Key1
		$this->assertEquals( 'Value1', $messageCache->get( 'Key1' ),
			'Key1 wasn\'t reloaded by edit of Key2' );
	}

	/**
	 * @dataProvider provideNormalizeKey
	 */
	public function testNormalizeKey( $key, $expected ) {
		$actual = MessageCache::normalizeKey( $key );
		$this->assertEquals( $expected, $actual );
	}

	public static function provideNormalizeKey() {
		return [
			[ 'Foo', 'foo' ],
			[ 'foo', 'foo' ],
			[ 'fOo', 'fOo' ],
			[ 'FOO', 'fOO' ],
			[ 'Foo bar', 'foo_bar' ],
			[ 'Ćab', 'ćab' ],
			[ 'Ćab_e 3', 'ćab_e_3' ],
			[ 'ĆAB', 'ćAB' ],
			[ 'ćab', 'ćab' ],
			[ 'ćaB', 'ćaB' ],
		];
	}

	public function testNoDBAccessContentLanguage() {
		$languageCode = $this->getServiceContainer()->getMainConfig()->get( MainConfigNames::LanguageCode );

		$dbr = $this->getDb();

		$messageCache = $this->getServiceContainer()->getMessageCache();
		$messageCache->getMsgFromNamespace( 'allpages', $languageCode );

		$this->assertSame( 0, $dbr->trxLevel() );
		$dbr->setFlag( DBO_TRX, $dbr::REMEMBER_PRIOR ); // make queries trigger TRX

		$messageCache->getMsgFromNamespace( 'go', $languageCode );

		$dbr->restoreFlags();

		$this->assertSame( 0, $dbr->trxLevel(), "No DB read queries (content language)" );
	}

	public function testNoDBAccessNonContentLanguage() {
		$dbr = $this->getDb();

		$messageCache = $this->getServiceContainer()->getMessageCache();
		$messageCache->getMsgFromNamespace( 'allpages/nl', 'nl' );

		$this->assertSame( 0, $dbr->trxLevel() );
		$dbr->setFlag( DBO_TRX, $dbr::REMEMBER_PRIOR ); // make queries trigger TRX

		$messageCache->getMsgFromNamespace( 'go/nl', 'nl' );

		$dbr->restoreFlags();

		$this->assertSame( 0, $dbr->trxLevel(), "No DB read queries (non-content language)" );
	}

	/**
	 * Regression test for T218918
	 */
	public function testLoadFromDB_fetchLatestRevision() {
		// Create three revisions of the same message page.
		// Must be an existing message key.
		$key = 'Log';
		$this->makePage( $key, 'de', 'Test eins' );
		$this->makePage( $key, 'de', 'Test zwei' );
		$r3 = $this->makePage( $key, 'de', 'Test drei' );

		// Create an out-of-sequence revision by importing a
		// revision with an old timestamp. Hacky.
		$importRevision = new WikiRevision();
		$title = Title::newFromLinkTarget( $r3->getPageAsLinkTarget() );
		$importRevision->setTitle( $title );
		$importRevision->setComment( 'Imported edit' );
		$importRevision->setTimestamp( '19991122001122' );
		$content = ContentHandler::makeContent( 'IMPORTED OLD TEST', $title );
		$importRevision->setContent( SlotRecord::MAIN, $content );
		$importRevision->setUsername( 'ext>Alan Smithee' );

		$importer = $this->getServiceContainer()->getWikiRevisionOldRevisionImporterNoUpdates();
		$importer->import( $importRevision );

		// Now, load the message from the wiki page
		$messageCache = $this->getServiceContainer()->getMessageCache();
		$messageCache->enable();
		$messageCache = TestingAccessWrapper::newFromObject( $messageCache );

		$cache = $messageCache->loadFromDB( 'de' );

		$this->assertArrayHasKey( $key, $cache );

		// Text in the cache has an extra space in front!
		$this->assertSame( ' ' . 'Test drei', $cache[$key] );
	}

	/**
	 * @dataProvider provideIsMainCacheable
	 * @param string|null $code The language code
	 * @param string $message The message key
	 * @param bool $expected
	 */
	public function testIsMainCacheable( $code, $message, $expected ) {
		$messageCache = TestingAccessWrapper::newFromObject(
			$this->getServiceContainer()->getMessageCache() );
		$this->assertSame( $expected, $messageCache->isMainCacheable( $message, $code ) );
	}

	public static function provideIsMainCacheable() {
		$cases = [
			[ 'allpages', true ],
			[ 'Allpages', true ],
			[ 'Allpages/bat', true ],
			[ 'Conversiontable/zh-tw', true ],
			[ 'My_special_message', false ],
		];
		foreach ( [ null, 'en', 'fr' ] as $code ) {
			foreach ( $cases as $case ) {
				yield array_merge( [ $code ], $case );
			}
		}
	}

	/**
	 * @dataProvider provideLocalOverride
	 * @param string $messageKey
	 */
	public function testLocalOverride( $messageKey ) {
		$messageCache = $this->getServiceContainer()->getMessageCache();
		$languageFactory = $this->getServiceContainer()->getLanguageFactory();
		$languageZh = $languageFactory->getLanguage( 'zh' );
		$languageZh_tw = $languageFactory->getLanguage( 'zh-tw' );
		$languageZh_hk = $languageFactory->getLanguage( 'zh-hk' );
		$languageZh_mo = $languageFactory->getLanguage( 'zh-mo' );
		$oldMessageZh = $messageCache->get( $messageKey, true, $languageZh );
		$oldMessageZh_tw = $messageCache->get( $messageKey, true, $languageZh_tw );

		$localOverrideHK = $messageKey . '_zh-hk';
		$this->makePage( ucfirst( $messageKey ), 'zh-hk', $localOverrideHK );
		$this->assertEquals( $oldMessageZh, $messageCache->get( $messageKey, true, $languageZh ), 'Local override overlapped (main code)' );
		$this->assertEquals( $oldMessageZh_tw, $messageCache->get( $messageKey, true, $languageZh_tw ), 'Local override overlapped' );
		$this->assertEquals( $localOverrideHK, $messageCache->get( $messageKey, true, $languageZh_hk ), 'Local override failed (self)' );
		$this->assertEquals( $localOverrideHK, $messageCache->get( $messageKey, true, $languageZh_mo ), 'Local override failed (fallback)' );
	}

	public static function provideLocalOverride() {
		return [
			// Preloaded with preloadedMessages
			[ 'nstab-main' ],
			// Not preloaded
			[ 'nstab-help' ],
		];
	}

	/** @dataProvider provideXssLanguage */
	public function testXssLanguage( array $config, bool $expectXssMessage ): void {
		$this->overrideConfigValues( $config + [
			MainConfigNames::UseXssLanguage => false,
			MainConfigNames::RawHtmlMessages => [],
		] );

		$xss = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'x-xss' );
		$message = $this->getServiceContainer()->getMessageCache()
			->get( 'key', true, $xss );
		if ( $expectXssMessage ) {
			$this->assertSame(
				"<script>alert('key')</script>\"><script>alert('key')</script><x y=\"(\$*)",
				$message
			);
		} else {
			$this->assertFalse( $message );
		}
	}

	public static function provideXssLanguage(): iterable {
		yield 'default' => [
			'config' => [],
			'expectXssMessage' => false,
		];

		yield 'enabled' => [
			'config' => [
				MainConfigNames::UseXssLanguage => true,
			],
			'expectXssMessage' => true,
		];

		yield 'enabled but message marked as raw' => [
			'config' => [
				MainConfigNames::UseXssLanguage => true,
				MainConfigNames::RawHtmlMessages => [ 'key' ],
			],
			'expectXssMessage' => false,
		];
	}
}
PK       ! &^@gF  F  ,  language/LanguageFallbackIntegrationTest.phpnu Iw        <?php

use MediaWiki\Languages\LanguageFallback;
use MediaWiki\MainConfigNames;

/**
 * @group Language
 * @covers \MediaWiki\Languages\LanguageFallback
 */
class LanguageFallbackIntegrationTest extends MediaWikiIntegrationTestCase {
	use LanguageFallbackTestTrait;

	private function getCallee( array $options = [] ) {
		if ( isset( $options['siteLangCode'] ) ) {
			$this->overrideConfigValue( MainConfigNames::LanguageCode, $options['siteLangCode'] );
		}
		if ( isset( $options['fallbackMap'] ) ) {
			$this->setService( 'LocalisationCache', $this->getMockLocalisationCache(
				1, $options['fallbackMap'] ) );
		}
		return $this->getServiceContainer()->getLanguageFallback();
	}

	private function getMessagesKey() {
		return LanguageFallback::MESSAGES;
	}

	private function getStrictKey() {
		return LanguageFallback::STRICT;
	}
}
PK       ! q?  ?  $  language/LanguageClassesTestCase.phpnu Iw        <?php

use MediaWiki\Language\Language;

/**
 * Helping class to run tests using a clean language instance.
 *
 * This is intended for the MediaWiki language class tests under
 * tests/phpunit/includes/languages.
 *
 * Before each tests, a new language object is built which you
 * can retrieve in your test using the $this->getLang() method:
 *
 * @par Using the crafted language object:
 * @code
 * function testHasLanguageObject() {
 *   $langObject = $this->getLang();
 *   $this->assertInstanceOf( 'LanguageFoo',
 *     $langObject
 *   );
 * }
 * @endcode
 */
abstract class LanguageClassesTestCase extends MediaWikiIntegrationTestCase {
	/**
	 * @var Language
	 *
	 * A new object is created before each tests thanks to PHPUnit
	 * setUp() method, it is deleted after each test too. To get
	 * this object you simply use the getLang method.
	 *
	 * You must have setup a language code first. See $LanguageClassCode
	 * @code
	 *  function testWeAreTheChampions() {
	 *    $this->getLang();  # language object
	 *  }
	 * @endcode
	 */
	private $languageObject;

	/**
	 * @return Language
	 */
	protected function getLang() {
		return $this->languageObject;
	}

	/**
	 * Create a new language object before each test.
	 */
	protected function setUp(): void {
		parent::setUp();
		$lang = false;
		if ( preg_match( '/Language(.+)Test/', static::class, $m ) ) {
			# Normalize language code since classes uses underscores
			$lang = strtolower( str_replace( '_', '-', $m[1] ) );
		}
		if ( $lang === false ||
			!$this->getServiceContainer()->getLanguageNameUtils()->isSupportedLanguage( $lang )
		) {
			# Fallback to english language
			$lang = 'en';
			wfDebug(
				__METHOD__ . ' could not extract a language name '
					. 'out of ' . static::class . ", falling back to 'en'"
			);
		}
		$this->languageObject = $this->getServiceContainer()->getLanguageFactory()
			->getLanguage( $lang );
	}

	/**
	 * Delete the internal language object so each test starts
	 * out with a fresh language instance.
	 */
	protected function tearDown(): void {
		unset( $this->languageObject );
		parent::tearDown();
	}
}
PK       ! "  "  '  language/converters/UzConverterTest.phpnu Iw        <?php
/**
 * PHPUnit tests for the Uzbek language.
 * The language can be represented using two scripts:
 *  - Latin (uz-latn)
 *  - Cyrillic (uz-cyrl)
 *
 * @author Robin Pepermans
 * @author Antoine Musso <hashar at free dot fr>
 * @copyright Copyright © 2012, Robin Pepermans
 * @copyright Copyright © 2011, Antoine Musso <hashar at free dot fr>
 * @file
 *
 * @todo methods in test class should be tidied:
 *  - Should be split into separate test methods and data providers
 *  - Tests for LanguageConverter and Language should probably be separate.
 */

/**
 * @group Language
 * @covers \UzConverter
 * @covers \MediaWiki\Language\LanguageConverter
 */
class UzConverterTest extends MediaWikiIntegrationTestCase {

	use LanguageConverterTestTrait;

	/**
	 * @author Nikola Smolenski
	 */
	public function testConversionToCyrillic() {
		// A convertion of Latin to Cyrillic
		$this->assertEquals( 'абвгғ',
			$this->convertToCyrillic( 'abvggʻ' )
		);
		// Same as above, but assert that -{}-s must be removed and not converted
		$this->assertEquals( 'ljабnjвгўоdb',
			$this->convertToCyrillic( '-{lj}-ab-{nj}-vgoʻo-{db}-' )
		);
		// A simple convertion of Cyrillic to Cyrillic
		$this->assertEquals( 'абвг',
			$this->convertToCyrillic( 'абвг' )
		);
		// Same as above, but assert that -{}-s must be removed and not converted
		$this->assertEquals( 'ljабnjвгdaž',
			$this->convertToCyrillic( '-{lj}-аб-{nj}-вг-{da}-ž' )
		);
	}

	public function testConversionToLatin() {
		// A simple convertion of Latin to Latin
		$this->assertEquals( 'abdef',
			$this->convertToLatin( 'abdef' )
		);
		// A convertion of Cyrillic to Latin
		$this->assertEquals( 'gʻabtsdOʻQyo',
			$this->convertToLatin( 'ғабцдЎҚё' )
		);
	}

	# #### HELPERS #####################################################

	/**
	 * Wrapper to verify text stay the same after applying conversion
	 * @param string $text Text to convert
	 * @param string $variant Language variant 'uz-cyrl' or 'uz-latn'
	 * @param string $msg Optional message
	 */
	protected function assertUnConverted( $text, $variant, $msg = '' ) {
		$this->assertEquals(
			$text,
			$this->convertTo( $text, $variant ),
			$msg
		);
	}

	/**
	 * Wrapper to verify a text is different once converted to a variant.
	 * @param string $text Text to convert
	 * @param string $variant Language variant 'uz-cyrl' or 'uz-latn'
	 * @param string $msg Optional message
	 */
	protected function assertConverted( $text, $variant, $msg = '' ) {
		$this->assertNotEquals(
			$text,
			$this->convertTo( $text, $variant ),
			$msg
		);
	}

	/**
	 * Verifiy the given Cyrillic text is not converted when using
	 * using the Cyrillic variant and converted to Latin when using
	 * the Latin variant.
	 * @param string $text Text to convert
	 * @param string $msg Optional message
	 */
	protected function assertCyrillic( $text, $msg = '' ) {
		$this->assertUnConverted( $text, 'uz-cyrl', $msg );
		$this->assertConverted( $text, 'uz-latn', $msg );
	}

	/**
	 * Verifiy the given Latin text is not converted when using
	 * using the Latin variant and converted to Cyrillic when using
	 * the Cyrillic variant.
	 * @param string $text Text to convert
	 * @param string $msg Optional message
	 */
	protected function assertLatin( $text, $msg = '' ) {
		$this->assertUnConverted( $text, 'uz-latn', $msg );
		$this->assertConverted( $text, 'uz-cyrl', $msg );
	}

	/**
	 * Wrapper for converter::convertTo() method
	 * @param string $text
	 * @param string $variant
	 * @return string
	 */
	protected function convertTo( $text, $variant ) {
		return $this->getLanguageConverter()->convertTo( $text, $variant );
	}

	protected function convertToCyrillic( $text ) {
		return $this->convertTo( $text, 'uz-cyrl' );
	}

	protected function convertToLatin( $text ) {
		return $this->convertTo( $text, 'uz-latn' );
	}
}
PK       ! ͵3  3  (  language/converters/ZghConverterTest.phpnu Iw        <?php

/**
 * @group Language
 * @covers \ShiConverter
 */
class ZghConverterTest extends MediaWikiIntegrationTestCase {

	use LanguageConverterTestTrait;

	/**
	 * @dataProvider provideAutoConvertToAllVariants
	 */
	public function testAutoConvertToAllVariants( $result, $value ) {
		$this->assertEquals( $result,
			$this->getLanguageConverter()->autoConvertToAllVariants( $value ) );
	}

	public static function provideAutoConvertToAllVariants() {
		return [
			[
				[
					'zgh'      => 'ⴰⴱⴳⴳⵯⴷⴹⴻⴼⴽⴽⵯ ⵀⵃⵄⵅⵇⵉⵊⵍⵎⵏ ⵓⵔⵕⵖⵙⵚⵛⵜⵟⵡ ⵢⵣⵥ',
					'zgh-latn' => 'abggʷdḍefkkʷ hḥɛxqijlmn urṛɣsṣctṭw yzẓ',
				],
				'ⴰⴱⴳⴳⵯⴷⴹⴻⴼⴽⴽⵯ ⵀⵃⵄⵅⵇⵉⵊⵍⵎⵏ ⵓⵔⵕⵖⵙⵚⵛⵜⵟⵡ ⵢⵣⵥ'
			],
		];
	}
}
PK       ! E
  
  (  language/converters/BanConverterTest.phpnu Iw        <?php

/**
 * @group Language
 * @covers \BanConverter
 */
class BanConverterTest extends MediaWikiIntegrationTestCase {

	use LanguageConverterTestTrait;

	public function testHasVariants() {
		$this->assertTrue( $this->getLanguageConverter()->hasVariants(), 'ban has variants' );
	}

	public function testHasVariantBogus() {
		$variants = [
			'ban-bali',
			'ban-x-dharma',
			'ban-x-palmleaf',
			'ban-x-pku',
			'ban',
		];

		foreach ( $variants as $variant ) {
			$this->assertTrue( $this->getLanguageConverter()->hasVariant( $variant ),
				"no variant for $variant language" );
		}
	}

	public function testBalineseDetection() {
		$this->assertBalinese(
			'ᬫᬦ᭄ᬢ᭄ᬭ - abc',
			'Balinese guessing characters'
		);
	}

	public function testConversionToLatin() {
		// A simple conversion of Latin to Latin
		$this->assertEquals( 'mantra',
			$this->convertToLatin( 'mantra' )
		);
		// A simple conversion of Balinese to Latin
		$this->assertEquals( 'mantra',
			$this->convertToLatin( 'ᬫᬦ᭄ᬢ᭄ᬭ' )
		);
		// This text has some Latin, but is recognized as Balinese, so it should be converted
		$this->assertEquals( 'abcdmantra',
			$this->convertToLatin( 'abcdᬫᬦ᭄ᬢ᭄ᬭ' )
		);
	}

	# #### HELPERS #####################################################

	/**
	 * Wrapper to verify text stay the same after applying conversion
	 * @param string $text Text to convert
	 * @param string $variant Language variant 'ban-bali' or 'ban'
	 * @param string $msg Optional message
	 */
	protected function assertUnConverted( $text, $variant, $msg = '' ) {
		$this->assertEquals(
			$text,
			$this->convertTo( $text, $variant ),
			$msg
		);
	}

	/**
	 * Wrapper to verify a text is different once converted to a variant.
	 * @param string $text Text to convert
	 * @param string $variant Language variant 'ban-bali' or 'ban'
	 * @param string $msg Optional message
	 */
	protected function assertConverted( $text, $variant, $msg = '' ) {
		$this->assertNotEquals(
			$text,
			$this->convertTo( $text, $variant ),
			$msg
		);
	}

	/**
	 * Verifiy the given Balinese text is not converted when using
	 * using the Balinese variant and converted to Latin when using
	 * the Latin variant.
	 * @param string $text Text to convert
	 * @param string $msg Optional message
	 */
	protected function assertBalinese( $text, $msg = '' ) {
		$this->assertUnConverted( $text, 'ban-bali', $msg );
		$this->assertConverted( $text, 'ban', $msg );
	}

	/**
	 * Wrapper for converter::convertTo() method
	 * @param string $text
	 * @param string $variant
	 * @return string
	 */
	protected function convertTo( $text, $variant ) {
		return $this->getLanguageConverter()->convertTo( $text, $variant );
	}

	protected function convertToLatin( $text ) {
		return $this->convertTo( $text, 'ban' );
	}
}
PK       ! 'D    (  language/converters/GanConverterTest.phpnu Iw        <?php

/**
 * @group Language
 * @covers \GanConverter
 */
class GanConverterTest extends MediaWikiIntegrationTestCase {

	use LanguageConverterTestTrait;

	/**
	 * @dataProvider provideAutoConvertToAllVariants
	 */
	public function testAutoConvertToAllVariants( $result, $value ) {
		$this->assertEquals( $result, $this->getLanguageConverter()->autoConvertToAllVariants( $value ) );
	}

	public static function provideAutoConvertToAllVariants() {
		return [
			// zh2Hans
			[
				[
					'gan' => '㑯',
					'gan-hans' => '㑔',
					'gan-hant' => '㑯',
				],
				'㑯'
			],
			// zh2Hant
			[
				[
					'gan' => '㐷',
					'gan-hans' => '㐷',
					'gan-hant' => '傌',
				],
				'㐷'
			],
		];
	}
}
PK       ! r    7  language/converters/LanguageConverterConversionTest.phpnu Iw        <?php

namespace Tests\Languages\Converters;

use MediaWikiIntegrationTestCase;

/**
 * @group Language
 * @covers \MediaWiki\Language\LanguageConverter
 */
class LanguageConverterConversionTest extends MediaWikiIntegrationTestCase {

	/**
	 * @dataProvider provideConversionData
	 */
	public function testConversion( $variant, $text, $expected ) {
		$language = $this->getServiceContainer()->getLanguageFactory()->getParentLanguage(
			str_starts_with( $variant, 'ike' ) ? 'iu' : $variant
		);

		$converter = $this->getServiceContainer()->getLanguageConverterFactory()->getLanguageConverter( $language );

		$this->assertEquals(
			$expected,
			$converter->convertTo( $text, $variant )
		);
	}

	public static function provideConversionData() {
		$jsonFile = file_get_contents( __DIR__ . '/../../../data/languageConverter/conversionData.json' );
		$converterDataArr = json_decode( $jsonFile, true );

		return $converterDataArr;
	}
}
PK       ! `>    '  language/converters/IuConverterTest.phpnu Iw        <?php

/**
 * @group Language
 * @covers \IuConverter
 */
class IuConverterTest extends MediaWikiIntegrationTestCase {

	use LanguageConverterTestTrait;

	/**
	 * @dataProvider provideAutoConvertToAllVariants
	 */
	public function testAutoConvertToAllVariants( $result, $value ) {
		$this->assertEquals( $result, $this->getLanguageConverter()->autoConvertToAllVariants( $value ) );
	}

	public static function provideAutoConvertToAllVariants() {
		return [
			// ike-cans
			[
				[
					'ike-cans' => 'ᐴ',
					'ike-latn' => 'PUU',
					'iu' => 'PUU',
				],
				'PUU'
			],
			// ike-latn
			[
				[
					'ike-cans' => 'ᐴ',
					'ike-latn' => 'puu',
					'iu' => 'ᐴ',
				],
				'ᐴ'
			],
		];
	}
}
PK       ! qV    (  language/converters/WuuConverterTest.phpnu Iw        <?php

/**
 * @group Language
 * @covers \WuuConverter
 */
class WuuConverterTest extends MediaWikiIntegrationTestCase {

	use LanguageConverterTestTrait;

	/**
	 * @dataProvider provideAutoConvertToAllVariants
	 */
	public function testAutoConvertToAllVariants( $result, $value ) {
		$this->assertEquals( $result, $this->getLanguageConverter()->autoConvertToAllVariants( $value ) );
	}

	public static function provideAutoConvertToAllVariants() {
		return [
			// wuuHant2Hans
			[
				[
					'wuu' => '㑯',
					'wuu-hans' => '㑔',
					'wuu-hant' => '㑯',
				],
				'㑯'
			],
			// wuuHans2Hant
			[
				[
					'wuu' => '㐷',
					'wuu-hans' => '㐷',
					'wuu-hant' => '傌',
				],
				'㐷'
			],
		];
	}
}
PK       ! ˚    (  language/converters/ShiConverterTest.phpnu Iw        <?php

/**
 * @group Language
 * @covers \ShiConverter
 */
class ShiConverterTest extends MediaWikiIntegrationTestCase {
	use LanguageConverterTestTrait;

	/**
	 * @dataProvider provideAutoConvertToAllVariants
	 */
	public function testAutoConvertToAllVariants( $result, $value ) {
		$this->assertEquals( $result,
			$this->getLanguageConverter()->autoConvertToAllVariants( $value ) );
	}

	public static function provideAutoConvertToAllVariants() {
		return [
			[
				[
					'shi'      => 'ABGGʷDḌEFKKʷ HḤƐXQIJLMN URṚƔSṢCTṬW YZẒ OPV',
					'shi-tfng' => 'ⴰⴱⴳⴳⵯⴷⴹⴻⴼⴽⴽⵯ ⵀⵃⵄⵅⵇⵉⵊⵍⵎⵏ ⵓⵔⵕⵖⵙⵚⵛⵜⵟⵡ ⵢⵣⵥ ⵓⴱⴼ',
					'shi-latn' => 'ABGGʷDḌEFKKʷ HḤƐXQIJLMN URṚƔSṢCTṬW YZẒ OPV',
				],
				'ABGGʷDḌEFKKʷ HḤƐXQIJLMN URṚƔSṢCTṬW YZẒ OPV'
			],
			[
				[
					'shi'      => 'abggʷdḍefkkʷ hḥɛxqijlmn urṛɣsṣctṭw yzẓ opv',
					'shi-tfng' => 'ⴰⴱⴳⴳⵯⴷⴹⴻⴼⴽⴽⵯ ⵀⵃⵄⵅⵇⵉⵊⵍⵎⵏ ⵓⵔⵕⵖⵙⵚⵛⵜⵟⵡ ⵢⵣⵥ ⵓⴱⴼ',
					'shi-latn' => 'abggʷdḍefkkʷ hḥɛxqijlmn urṛɣsṣctṭw yzẓ opv',
				],
				'abggʷdḍefkkʷ hḥɛxqijlmn urṛɣsṣctṭw yzẓ opv'
			],
			[
				[
					'shi'      => 'ⴰⴱⴳⴳⵯⴷⴹⴻⴼⴽⴽⵯ ⵀⵃⵄⵅⵇⵉⵊⵍⵎⵏ ⵓⵔⵕⵖⵙⵚⵛⵜⵟⵡ ⵢⵣⵥ',
					'shi-tfng' => 'ⴰⴱⴳⴳⵯⴷⴹⴻⴼⴽⴽⵯ ⵀⵃⵄⵅⵇⵉⵊⵍⵎⵏ ⵓⵔⵕⵖⵙⵚⵛⵜⵟⵡ ⵢⵣⵥ',
					'shi-latn' => 'abggʷdḍefkkʷ hḥɛxqijlmn urṛɣsṣctṭw yzẓ',
				],
				'ⴰⴱⴳⴳⵯⴷⴹⴻⴼⴽⴽⵯ ⵀⵃⵄⵅⵇⵉⵊⵍⵎⵏ ⵓⵔⵕⵖⵙⵚⵛⵜⵟⵡ ⵢⵣⵥ'
			],
		];
	}
}
PK       ! ̴
  
  '  language/converters/ShConverterTest.phpnu Iw        <?php

/**
 * @group Language
 * @covers \ShConverter
 */
class ShConverterTest extends MediaWikiIntegrationTestCase {
	use LanguageConverterTestTrait;

	/**
	 * @dataProvider provideAutoConvertToAllVariants
	 */
	public function testAutoConvertToAllVariants( $result, $value ) {
		$this->assertEquals( $result, $this->getLanguageConverter()->autoConvertToAllVariants( $value ) );
	}

	public static function provideAutoConvertToAllVariants() {
		return [
			[
				[
					'sh-latn' => 'g',
					'sh-cyrl' => 'г',
				],
				'g'
			],
			[
				[
					'sh-latn' => 'г',
					'sh-cyrl' => 'г',
				],
				'г'
			],
		];
	}

	public function testConvertTo() {
		$this->testConversionToLatin();
		$this->testConversionToCyrillic();
	}

	/**
	 * Wrapper for testConvertTo() for Cyrillic
	 */
	public function testConversionToCyrillic() {
		// A simple conversion of Latin to Cyrillic
		$this->assertEquals( 'абвг',
			$this->convertToCyrillic( 'abvg' )
		);
		// Same as above, but assert that -{}-s must be removed and not converted
		$this->assertEquals( 'ljабnjвгdž',
			$this->convertToCyrillic( '-{lj}-ab-{nj}-vg-{dž}-' )
		);
		// A simple conversion of Cyrillic to Cyrillic
		$this->assertEquals( 'абвг',
			$this->convertToCyrillic( 'абвг' )
		);
		// Same as above, but assert that -{}-s must be removed and not converted
		$this->assertEquals( 'ljабnjвгdž',
			$this->convertToCyrillic( '-{lj}-аб-{nj}-вг-{dž}-' )
		);
		// Roman numerals are not converted
		$this->assertEquals( 'а I б II в III г IV шђжчћ',
			$this->convertToCyrillic( 'a I b II v III g IV šđžčć' )
		);
		// Manual conversion rules work
		$this->assertEquals( 'Cyrillic',
			$this->convertToCyrillic( '-{sh-latn:Latin; sh-cyrl:Cyrillic;}-' )
		);
	}

	/**
	 * Wrapper for testConvertTo() for Latin
	 */
	public function testConversionToLatin() {
		// A simple conversion of Latin to Latin
		$this->assertEquals( 'abvg',
			$this->convertToLatin( 'abvg' )
		);
		// Same as above, but assert that -{}-s must be removed and not converted
		$this->assertEquals( 'ljabnjvgdž',
			$this->convertToLatin( '-{lj}-ab-{nj}-vg-{dž}-' )
		);
		// Manual conversion rules work
		$this->assertEquals( 'Latin',
			$this->convertToLatin( '-{sh-latn:Latin; sh-cyrl:Cyrillic;}-' )
		);
	}

	/**
	 * Wrapper for converter::convertTo() method
	 * @param string $text
	 * @param string $variant
	 * @return string
	 */
	protected function convertTo( $text, $variant ) {
		return $this->getLanguageConverter()->convertTo( $text, $variant );
	}

	protected function convertToCyrillic( $text ) {
		return $this->convertTo( $text, 'sh-cyrl' );
	}

	protected function convertToLatin( $text ) {
		return $this->convertTo( $text, 'sh-latn' );
	}
}
PK       ! ZD
  
  (  language/converters/TlyConverterTest.phpnu Iw        <?php
/**
 * PHPUnit tests for the Talysh converter.
 * The language can be represented using two scripts:
 *  - Latin (tly)
 *  - Cyrillic (tly-cyrl)
 *
 * @author Amir E. Aharoni
 */

/**
 * @group Language
 * @covers \MediaWiki\Language\LanguageConverter
 * @covers \TlyConverter
 */
class TlyConverterTest extends MediaWikiIntegrationTestCase {
	use LanguageConverterTestTrait;

	public function testConversionToCyrillic() {
		// A conversion of Latin to Cyrillic
		$this->assertEquals(
			'АаБбВвГгҒғДдЕеӘәЖжЗзИиЫыЈјКкЛлМмНнОоПпРрСсТтУуҮүФфХхҺһЧчҸҹШш',
			$this->convertToCyrillic(
				'AaBbVvQqĞğDdEeƏəJjZzİiIıYyKkLlMmNnOoPpRrSsTtUuÜüFfXxHhÇçCcŞş'
			)
		);

		// A simple conversion of Cyrillic to Cyrillic
		$this->assertEquals( 'Лик',
			$this->convertToCyrillic( 'Лик' )
		);

		// Assert that -{}-s are handled correctly
		// NB: Latin word followed by Latin word, and the second one is converted
		$this->assertEquals( 'Lankon Осторо',
			$this->convertToCyrillic( '-{Lankon}- Ostoro' )
		);

		// Assert that -{}-s are handled correctly
		// NB: Latin word followed by Cyrillic word, and nothing is converted
		$this->assertEquals( 'Lankon Осторо',
			$this->convertToCyrillic( '-{Lankon}- Осторо' )
		);
	}

	public function testConversionToLatin() {
		// A conversion of Cyrillic to Latin
		$this->assertEquals(
			'AaBbCcÇçDdEeƏəFfĞğHhXxIıİiJjKkQqLlMmNnOoPpRrSsŞşTtUuÜüVvYyZz',
			$this->convertToLatin(
				'АаБбҸҹЧчДдЕеӘәФфҒғҺһХхЫыИиЖжКкГгЛлМмНнОоПпРрСсШшТтУуҮүВвЈјЗз'
			)
		);

		// A simple conversion of Latin to Latin
		$this->assertEquals( 'Lik',
			$this->convertToLatin( 'Lik' )
		);

		// Assert that -{}-s are handled correctly
		// NB: Cyrillic word followed by Cyrillic word, and the second one is converted
		$this->assertEquals( 'Ланкон Ostoro',
			$this->convertToLatin( '-{Ланкон}- Осторо' )
		);

		// Assert that -{}-s are handled correctly
		// NB: Cyrillic word followed by Latin word, and nothing is converted
		$this->assertEquals( 'Ланкон Ostoro',
			$this->convertToLatin( '-{Ланкон}- Ostoro' )
		);
	}

	# #### HELPERS #####################################################

	/**
	 * Wrapper for converter::convertTo() method
	 * @param string $text
	 * @param string $variant
	 * @return string
	 */
	protected function convertTo( $text, $variant ) {
		return $this->getLanguageConverter()->convertTo( $text, $variant );
	}

	protected function convertToCyrillic( $text ) {
		return $this->convertTo( $text, 'tly-cyrl' );
	}

	protected function convertToLatin( $text ) {
		return $this->convertTo( $text, 'tly' );
	}
}
PK       ! 8    (  language/converters/CrhConverterTest.phpnu Iw        <?php

/**
 * @group Language
 * @covers \CrhConverter
 * @covers \MediaWiki\Languages\Data\CrhExceptions
 */
class CrhConverterTest extends MediaWikiIntegrationTestCase {

	use LanguageConverterTestTrait;

	/**
	 * @dataProvider provideAutoConvertToAllVariantsByWord
	 *
	 * Test individual words and test minimal contextual transforms
	 * by creating test strings "<cyrillic> <latin>" and
	 * "<latin> <cyrillic>" and then converting to all variants.
	 */
	public function testAutoConvertToAllVariantsByWord( $cyrl, $lat ) {
		$value = $lat;
		$result = [
			'crh'      => $value,
			'crh-cyrl' => $cyrl,
			'crh-latn' => $lat,
			];
		$this->assertEquals( $result, $this->getLanguageConverter()->autoConvertToAllVariants( $value ) );

		$value = $cyrl;
		$result = [
			'crh'      => $value,
			'crh-cyrl' => $cyrl,
			'crh-latn' => $lat,
			];
		$this->assertEquals( $result, $this->getLanguageConverter()->autoConvertToAllVariants( $value ) );

		$value = $cyrl . ' ' . $lat;
		$result = [
			'crh'      => $value,
			'crh-cyrl' => $cyrl . ' ' . $cyrl,
			'crh-latn' => $lat . ' ' . $lat,
			];
		$this->assertEquals( $result, $this->getLanguageConverter()->autoConvertToAllVariants( $value ) );

		$value = $lat . ' ' . $cyrl;
		$result = [
			'crh'      => $value,
			'crh-cyrl' => $cyrl . ' ' . $cyrl,
			'crh-latn' => $lat . ' ' . $lat,
			];
		$this->assertEquals( $result, $this->getLanguageConverter()->autoConvertToAllVariants( $value ) );
	}

	public static function provideAutoConvertToAllVariantsByWord() {
		return [
			// general words, covering more of the alphabet
			[ 'рузгярнынъ', 'ruzgârnıñ' ], [ 'Париж', 'Parij' ], [ 'чёкюч', 'çöküç' ],
			[ 'элифбени', 'elifbeni' ], [ 'полициясы', 'politsiyası' ], [ 'хусусында', 'hususında' ],
			[ 'акъшамларны', 'aqşamlarnı' ], [ 'опькеленюв', 'öpkelenüv' ],
			[ 'кулюмсиреди', 'külümsiredi' ], [ 'айтмайджагъым', 'aytmaycağım' ],
			[ 'козьяшсыз', 'közyaşsız' ],

			// exception words
			[ 'инструменталь', 'instrumental' ], [ 'гургуль', 'gürgül' ], [ 'тюшюнмемек', 'tüşünmemek' ],

			// specific problem words
			[ 'куню', 'künü' ], [ 'сюргюнлиги', 'sürgünligi' ], [ 'озю', 'özü' ], [ 'этти', 'etti' ],
			[ 'эсас', 'esas' ], [ 'дёрт', 'dört' ], [ 'кельди', 'keldi' ], [ 'км²', 'km²' ],
			[ 'юзь', 'yüz' ], [ 'АКъШ', 'AQŞ' ], [ 'ШСДжБнен', 'ŞSCBnen' ], [ 'июль', 'iyül' ],
			[ 'ишгъаль', 'işğal' ], [ 'ишгъальджилерине', 'işğalcilerine' ], [ 'район', 'rayon' ],
			[ 'районынынъ', 'rayonınıñ' ], [ 'Ногъай', 'Noğay' ], [ 'Юрьтю', 'Yürtü' ],
			[ 'ватандан', 'vatandan' ], [ 'ком-кок', 'köm-kök' ], [ 'АКЪКЪЫ', 'AQQI' ],
			[ 'ДАГЪГЪА', 'DAĞĞA' ], [ '13-юнджи', '13-ünci' ], [ 'ДЖУРЬМЕК', 'CÜRMEK' ],
			[ 'джумлеси', 'cümlesi' ], [ 'ильи', 'ilyi' ], [ 'Ильи', 'İlyi' ], [ 'бруцел', 'brutsel' ],
			[ 'коцюб', 'kotsüb' ], [ 'плацен', 'platsen' ], [ 'эпицентр', 'epitsentr' ],

			// -tsin- words
			[ 'кетсин', 'ketsin' ], [ 'кирлетсин', 'kirletsin' ], [ 'этсин', 'etsin' ],
			[ 'етсин', 'yetsin' ], [ 'этсинлерми', 'etsinlermi' ], [ 'принцини', 'printsini' ],
			[ 'медицина', 'meditsina' ], [ 'Щетсин', 'Şçetsin' ], [ 'Щекоцины', 'Şçekotsinı' ],

			// regex pattern words
			[ 'коюнден', 'köyünden' ], [ 'аньге', 'ange' ],

			// multi part words
			[ 'эки юз', 'eki yüz' ],

			// affix patterns
			[ 'койнинъ', 'köyniñ' ], [ 'Авджыкойде', 'Avcıköyde' ], [ 'экваториаль', 'ekvatorial' ],
			[ 'Джанкой', 'Canköy' ], [ 'усть', 'üst' ], [ 'роль', 'rol' ], [ 'буюк', 'büyük' ],
			[ 'джонк', 'cönk' ],

			// Roman numerals vs Initials, part 1 - Roman numeral initials without spaces
			[ 'А.Б.Дж.Д.М. Къадырова XII', 'A.B.C.D.M. Qadırova XII' ],
			// Roman numerals vs Initials, part 2 - Roman numeral initials with spaces
			[ 'Г. Х. Ы. В. X. Л. Меметов III', 'G. H. I. V. X. L. Memetov III' ],

			// ALL CAPS, made up acronyms
			[ 'НЪАБ', 'ÑAB' ], [ 'КЪЫДЖ', 'QIC' ], [ 'ГЪУК', 'ĞUK' ], [ 'ДЖОТ', 'COT' ], [ 'ДЖА', 'CA' ],
		];
	}

	/**
	 * @dataProvider provideAutoConvertToAllVariantsByString
	 *
	 * Run tests that require some context (like Roman numerals) or with
	 * many-to-one mappings, or other asymmetric results (like smart quotes)
	 */
	public function testAutoConvertToAllVariantsByString( $result, $value ) {
		$this->assertEquals( $result, $this->getLanguageConverter()->autoConvertToAllVariants( $value ) );
	}

	public static function provideAutoConvertToAllVariantsByString() {
		return [
			[ // Roman numerals and quotes, esp. single-letter Roman numerals at the end of a string
				[
					'crh'      => 'VI,VII IX “dört” «дёрт» XI XII I V X L C D M',
					'crh-cyrl' => 'VI,VII IX «дёрт» «дёрт» XI XII I V X L C D M',
					'crh-latn' => 'VI,VII IX “dört” "dört" XI XII I V X L C D M',
				],
				'VI,VII IX “dört” «дёрт» XI XII I V X L C D M'
			],
			[ // Many-to-one mappings: many Cyrillic to one Latin
				[
					'crh'      => 'шофер шофёр şoför корбекул корьбекул корьбекуль körbekül',
					'crh-cyrl' => 'шофер шофёр шофёр корбекул корьбекул корьбекуль корьбекуль',
					'crh-latn' => 'şoför şoför şoför körbekül körbekül körbekül körbekül',
				],
				'шофер шофёр şoför корбекул корьбекул корьбекуль körbekül'
			],
			[ // Many-to-one mappings: many Latin to one Cyrillic
				[
					'crh'      => 'fevqülade fevqulade февкъульаде beyude beyüde бейуде',
					'crh-cyrl' => 'февкъульаде февкъульаде февкъульаде бейуде бейуде бейуде',
					'crh-latn' => 'fevqülade fevqulade fevqulade beyude beyüde beyüde',
				],
				'fevqülade fevqulade февкъульаде beyude beyüde бейуде'
			],
		];
	}
}
PK       ! wZ;  ;  (  language/converters/MniConverterTest.phpnu Iw        <?php
/**
 * PHPUnit tests for the Meitei converter.
 * The language can be represented using two scripts:
 *  - Meitei (mni)
 *  - Bangla (mni-beng)
 *
 * @author Nokib Sarkar
 */

/**
 * @covers \MniConverter
 */
class MniConverterTest extends MediaWikiIntegrationTestCase {

	use LanguageConverterTestTrait;

	/**
	 * @covers \MediaWiki\Language\LanguageConverter::convertTo
	 */
	public function testConversionToBengali() {
		// A conversion of Meitei to Bengali
		$this->assertEquals(
			'মীতৈ অসী কংলৈপাক্কী অচৌবা ফুরুপকী মনুংদা ১নী ।',
			$this->convertToBengali(
				'ꯃꯤꯇꯩ ꯑꯁꯤ ꯀꯪꯂꯩꯄꯥꯛꯀꯤ ꯑꯆꯧꯕ ꯐꯨꯔꯨꯞꯀꯤ ꯃꯅꯨꯡꯗ ꯱ꯅꯤ ꯫'
			)
		);

		// A simple conversion of Bengali to Bengali
		$this->assertEquals( 'লৌমী লৌবুক তা কুম্দুবা ফৌ',
			$this->convertToBengali( 'ꯂꯧꯃꯤ ꯂꯧꯕꯨꯛ ꯇꯥ ꯀꯨꯝꯗꯨꯕꯥ ꯐꯧ' )
		);
	}

	public function testRemoveHalanta() {
		// Remove Halanta from the end of the word
		$this->assertEquals( 'ন ক ম ং ল ৎ প ',
			$this->convertTo( 'ꯟ ꯛ ꯝ ꯡ ꯜ ꯠ ꯞ ', 'mni-beng' )
		);
	}

	/**
	 * Wrapper for converter::convertTo() method
	 * @param string $text
	 * @param string $variant
	 * @return string
	 */
	protected function convertTo( $text, $variant ) {
		return $this->getLanguageConverter()->convertTo( $text, $variant );
	}

	protected function convertToBengali( $text ) {
		return $this->convertTo( $text, 'mni-beng' );
	}
}
PK       ! 3p  p  '  language/converters/TgConverterTest.phpnu Iw        <?php

/**
 * @group Language
 * @covers \TgConverter
 */
class TgConverterTest extends MediaWikiIntegrationTestCase {
	use LanguageConverterTestTrait;

	/**
	 * @dataProvider provideAutoConvertToAllVariants
	 */
	public function testAutoConvertToAllVariants( $result, $value ) {
		$this->assertEquals( $result, $this->getLanguageConverter()->autoConvertToAllVariants( $value ) );
	}

	public static function provideAutoConvertToAllVariants() {
		return [
			[
				[
					'tg'      => 'г',
					'tg-latn' => 'g',
				],
				'г'
			],
			[
				[
					'tg'      => 'g',
					'tg-latn' => 'g',
				],
				'g'
			],
		];
	}
}
PK       ! ׈X  X  '  language/converters/SrConverterTest.phpnu Iw        <?php

/**
 * @group Language
 * @covers \SrConverter
 */
class SrConverterTest extends MediaWikiIntegrationTestCase {
	use LanguageConverterTestTrait;

	public function testHasVariants() {
		$this->assertTrue( $this->getLanguageConverter()->hasVariants(), 'sr has variants' );
	}

	public function testHasVariantBogus() {
		$variants = [
			'sr-ec',
			'sr-el',
		];

		foreach ( $variants as $variant ) {
			$this->assertTrue( $this->getLanguageConverter()->hasVariant( $variant ),
				"no variant for $variant language" );
		}
	}

	public function testEasyConversions() {
		$this->assertCyrillic(
			'шђчћжШЂЧЋЖ',
			'Cyrillic guessing characters'
		);
		$this->assertLatin(
			'šđčćžŠĐČĆŽ',
			'Latin guessing characters'
		);
	}

	public function testMixedConversions() {
		$this->assertCyrillic(
			'шђчћжШЂЧЋЖ - šđčćž',
			'Mostly Cyrillic characters'
		);
		$this->assertLatin(
			'šđčćžŠĐČĆŽ - шђчћж',
			'Mostly Latin characters'
		);
	}

	public function testSameAmountOfLatinAndCyrillicGetConverted() {
		$this->assertConverted(
			'4 Latin: šđčć | 4 Cyrillic: шђчћ',
			'sr-ec'
		);
		$this->assertConverted(
			'4 Latin: šđčć | 4 Cyrillic: шђчћ',
			'sr-el'
		);
	}

	/**
	 * @author Nikola Smolenski
	 */
	public function testConversionToCyrillic() {
		// A simple conversion of Latin to Cyrillic
		$this->assertEquals( 'абвг',
			$this->convertToCyrillic( 'abvg' )
		);
		// Same as above, but assert that -{}-s must be removed and not converted
		$this->assertEquals( 'ljабnjвгdž',
			$this->convertToCyrillic( '-{lj}-ab-{nj}-vg-{dž}-' )
		);
		// A simple conversion of Cyrillic to Cyrillic
		$this->assertEquals( 'абвг',
			$this->convertToCyrillic( 'абвг' )
		);
		// Same as above, but assert that -{}-s must be removed and not converted
		$this->assertEquals( 'ljабnjвгdž',
			$this->convertToCyrillic( '-{lj}-аб-{nj}-вг-{dž}-' )
		);
		// This text has some Latin, but is recognized as Cyrillic, so it should not be converted
		$this->assertEquals( 'abvgшђжчћ',
			$this->convertToCyrillic( 'abvgшђжчћ' )
		);
		// Same as above, but assert that -{}-s must be removed
		$this->assertEquals( 'љabvgњшђжчћџ',
			$this->convertToCyrillic( '-{љ}-abvg-{њ}-шђжчћ-{џ}-' )
		);
		// This text has some Cyrillic, but is recognized as Latin, so it should be converted
		$this->assertEquals( 'абвгшђжчћ',
			$this->convertToCyrillic( 'абвгšđžčć' )
		);
		// Same as above, but assert that -{}-s must be removed and not converted
		$this->assertEquals( 'ljабвгnjшђжчћdž',
			$this->convertToCyrillic( '-{lj}-абвг-{nj}-šđžčć-{dž}-' )
		);
		// Roman numerals are not converted
		$this->assertEquals( 'а I б II в III г IV шђжчћ',
			$this->convertToCyrillic( 'a I b II v III g IV šđžčć' )
		);
		// Same, but put the roman numerals at the start/end of the string
		$this->assertEquals( 'XX а I б II в III г IV шђжчћ XX',
			$this->convertToCyrillic( 'XX a I b II v III g IV šđžčć XX' )
		);
	}

	public function testConversionToLatin() {
		// A simple conversion of Latin to Latin
		$this->assertEquals( 'abcd',
			$this->convertToLatin( 'abcd' )
		);
		// A simple conversion of Cyrillic to Latin
		$this->assertEquals( 'abcd',
			$this->convertToLatin( 'абцд' )
		);
		// This text has some Latin, but is recognized as Cyrillic, so it should be converted
		$this->assertEquals( 'abcdšđžčć',
			$this->convertToLatin( 'abcdшђжчћ' )
		);
		// This text has some Cyrillic, but is recognized as Latin, so it should not be converted
		$this->assertEquals( 'абцдšđžčć',
			$this->convertToLatin( 'абцдšđžčć' )
		);
		// Roman numerals are not converted (inverse of ToCyrillic test)
		$this->assertEquals( 'a I b II v III g IV šđžčć',
			$this->convertToLatin( 'а I б II в III г IV шђжчћ' )
		);
		// Same, but put the roman numerals at the start/end of the string
		$this->assertEquals( 'XX a I b II v III g IV šđžčć XX',
			$this->convertToLatin( 'XX а I б II в III г IV шђжчћ XX' )
		);
	}

	# #### HELPERS #####################################################

	/**
	 * Wrapper to verify text stay the same after applying conversion
	 * @param string $text Text to convert
	 * @param string $variant Language variant 'sr-ec' or 'sr-el'
	 * @param string $msg Optional message
	 */
	protected function assertUnConverted( $text, $variant, $msg = '' ) {
		$this->assertEquals(
			$text,
			$this->convertTo( $text, $variant ),
			$msg
		);
	}

	/**
	 * Wrapper to verify a text is different once converted to a variant.
	 * @param string $text Text to convert
	 * @param string $variant Language variant 'sr-ec' or 'sr-el'
	 * @param string $msg Optional message
	 */
	protected function assertConverted( $text, $variant, $msg = '' ) {
		$this->assertNotEquals(
			$text,
			$this->convertTo( $text, $variant ),
			$msg
		);
	}

	/**
	 * Verifiy the given Cyrillic text is not converted when using
	 * using the Cyrillic variant and converted to Latin when using
	 * the Latin variant.
	 * @param string $text Text to convert
	 * @param string $msg Optional message
	 */
	protected function assertCyrillic( $text, $msg = '' ) {
		$this->assertUnConverted( $text, 'sr-ec', $msg );
		$this->assertConverted( $text, 'sr-el', $msg );
	}

	/**
	 * Verifiy the given Latin text is not converted when using
	 * using the Latin variant and converted to Cyrillic when using
	 * the Cyrillic variant.
	 * @param string $text Text to convert
	 * @param string $msg Optional message
	 */
	protected function assertLatin( $text, $msg = '' ) {
		$this->assertUnConverted( $text, 'sr-el', $msg );
		$this->assertConverted( $text, 'sr-ec', $msg );
	}

	/**
	 * Wrapper for converter::convertTo() method
	 * @param string $text
	 * @param string $variant
	 * @return string
	 */
	protected function convertTo( $text, $variant ) {
		return $this->getLanguageConverter()->convertTo( $text, $variant );
	}

	protected function convertToCyrillic( $text ) {
		return $this->convertTo( $text, 'sr-ec' );
	}

	protected function convertToLatin( $text ) {
		return $this->convertTo( $text, 'sr-el' );
	}
}
PK       ! 'mJ
  J
  '  language/converters/ZhConverterTest.phpnu Iw        <?php

/**
 * @group Language
 * @covers \ZhConverter
 */
class ZhConverterTest extends MediaWikiIntegrationTestCase {

	use LanguageConverterTestTrait;

	/**
	 * @dataProvider provideAutoConvertToAllVariants
	 */
	public function testAutoConvertToAllVariants( $result, $value ) {
		$this->assertEquals( $result,
			$this->getLanguageConverter()->autoConvertToAllVariants( $value ) );
	}

	public static function provideAutoConvertToAllVariants() {
		return [
			// Plain hant -> hans
			[
				[
					'zh'      => '㑯',
					'zh-hans' => '㑔',
					'zh-hant' => '㑯',
					'zh-cn'   => '㑔',
					'zh-hk'   => '㑯',
					'zh-mo'   => '㑯',
					'zh-my'   => '㑔',
					'zh-sg'   => '㑔',
					'zh-tw'   => '㑯',
				],
				'㑯'
			],
			// Plain hans -> hant
			[
				[
					'zh'      => '㐷',
					'zh-hans' => '㐷',
					'zh-hant' => '傌',
					'zh-cn'   => '㐷',
					'zh-hk'   => '傌',
					'zh-mo'   => '傌',
					'zh-my'   => '㐷',
					'zh-sg'   => '㐷',
					'zh-tw'   => '傌',
				],
				'㐷'
			],
			// zh-cn specific
			[
				[
					'zh'      => '仲介',
					'zh-hans' => '仲介',
					'zh-hant' => '仲介',
					'zh-cn'   => '中介',
					'zh-hk'   => '仲介',
					'zh-mo'   => '仲介',
					'zh-my'   => '中介',
					'zh-sg'   => '中介',
					'zh-tw'   => '仲介',
				],
				'仲介'
			],
			// zh-hk specific
			[
				[
					'zh'      => '中文里',
					'zh-hans' => '中文里',
					'zh-hant' => '中文裡',
					'zh-cn'   => '中文里',
					'zh-hk'   => '中文裏',
					'zh-mo'   => '中文裏',
					'zh-my'   => '中文里',
					'zh-sg'   => '中文里',
					'zh-tw'   => '中文裡',
				],
				'中文里'
			],
			// zh-tw specific
			[
				[
					'zh'      => '甲肝',
					'zh-hans' => '甲肝',
					'zh-hant' => '甲肝',
					'zh-cn'   => '甲肝',
					'zh-hk'   => '甲肝',
					'zh-mo'   => '甲肝',
					'zh-my'   => '甲肝',
					'zh-sg'   => '甲肝',
					'zh-tw'   => 'A肝',
				],
				'甲肝'
			],
			// zh-tw overrides zh-hant
			[
				[
					'zh'      => '账',
					'zh-hans' => '账',
					'zh-hant' => '賬',
					'zh-cn'   => '账',
					'zh-hk'   => '賬',
					'zh-mo'   => '賬',
					'zh-my'   => '账',
					'zh-sg'   => '账',
					'zh-tw'   => '帳',
				],
				'账'
			],
			// zh-hk overrides zh-hant
			[
				[
					'zh'      => '一地里',
					'zh-hans' => '一地里',
					'zh-hant' => '一地裡',
					'zh-cn'   => '一地里',
					'zh-hk'   => '一地裏',
					'zh-mo'   => '一地裏',
					'zh-my'   => '一地里',
					'zh-sg'   => '一地里',
					'zh-tw'   => '一地裡',
				],
				'一地里'
			],
		];
	}
}
PK       ! cCN7  7  '  language/converters/KuConverterTest.phpnu Iw        <?php

/**
 * @group Language
 * @covers \KuConverter
 */
class KuConverterTest extends MediaWikiIntegrationTestCase {

	use LanguageConverterTestTrait;

	/**
	 * @dataProvider provideAutoConvertToAllVariants
	 */
	public function testAutoConvertToAllVariants( $result, $value ) {
		$this->assertEquals( $result, $this->getLanguageConverter()->autoConvertToAllVariants( $value ) );
	}

	public static function provideAutoConvertToAllVariants() {
		return [
			[
				[
					'ku'      => '١',
					'ku-arab' => '١',
					'ku-latn' => '1',
				],
				'١'
			],
			[
				[
					'ku'      => 'Wîkîpediya ensîklopediyeke azad bi rengê wîkî ye.',
					'ku-arab' => 'ویکیپەدیائە نسیکلۆپەدیەکەئا زاد ب رەنگێ ویکی یە.',
					'ku-latn' => 'Wîkîpediya ensîklopediyeke azad bi rengê wîkî ye.',
				],
				'Wîkîpediya ensîklopediyeke azad bi rengê wîkî ye.'
			],
			[
				[
					'ku'      => 'ویکیپەدیا ەنسیکلۆپەدیەکەئا زاد ب رەنگێ ویکی یە.',
					'ku-arab' => 'ویکیپەدیا ەنسیکلۆپەدیەکەئا زاد ب رەنگێ ویکی یە.',
					'ku-latn' => 'wîkîpedîa ensîklopedîekea zad b rengê wîkî îe.',
				],
				'ویکیپەدیا ەنسیکلۆپەدیەکەئا زاد ب رەنگێ ویکی یە.'
			],
		];
	}
}
PK       ! #T  T  "  language/LocalisationCacheTest.phpnu Iw        <?php

use MediaWiki\Tests\Language\MockLocalisationCacheTrait;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Language
 * @group Database
 * @covers \LocalisationCache
 * @author Niklas Laxström
 */
class LocalisationCacheTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;
	use MockLocalisationCacheTrait;

	public function testPluralRulesFallback() {
		$cache = $this->getMockLocalisationCache();

		$this->assertEquals(
			$cache->getItem( 'ar', 'pluralRules' ),
			$cache->getItem( 'arz', 'pluralRules' ),
			'arz plural rules (undefined) fallback to ar (defined)'
		);

		$this->assertEquals(
			$cache->getItem( 'ar', 'compiledPluralRules' ),
			$cache->getItem( 'arz', 'compiledPluralRules' ),
			'arz compiled plural rules (undefined) fallback to ar (defined)'
		);

		$this->assertNotEquals(
			$cache->getItem( 'ksh', 'pluralRules' ),
			$cache->getItem( 'de', 'pluralRules' ),
			'ksh plural rules (defined) dont fallback to de (defined)'
		);

		$this->assertNotEquals(
			$cache->getItem( 'ksh', 'compiledPluralRules' ),
			$cache->getItem( 'de', 'compiledPluralRules' ),
			'ksh compiled plural rules (defined) dont fallback to de (defined)'
		);
	}

	public function testRecacheFallbacks() {
		$lc = $this->getMockLocalisationCache();
		$lc->recache( 'ba' );
		$messages = $lc->getItem( 'ba', 'messages' );

		// Fallbacks are only used to fill missing data
		$this->assertSame( 'ba', $messages['present-ba'] );
		$this->assertSame( 'ru', $messages['present-ru'] );
		$this->assertSame( 'en', $messages['present-en'] );
	}

	public function testRecacheFallbacksWithHooks() {
		// Use hook to provide updates for messages. This is what the
		// LocalisationUpdate extension does. See T70781.

		$lc = $this->getMockLocalisationCache( [
			'LocalisationCacheRecacheFallback' =>
				static function (
					LocalisationCache $lc,
					$code,
					array &$cache
				) {
					if ( $code === 'ru' ) {
						$cache['messages']['present-ba'] = 'ru-override';
						$cache['messages']['present-ru'] = 'ru-override';
						$cache['messages']['present-en'] = 'ru-override';
					}
				}
		] );
		$lc->recache( 'ba' );
		$messages = $lc->getItem( 'ba', 'messages' );

		// Updates provided by hooks follow the normal fallback order.
		$this->assertSame( 'ba', $messages['present-ba'] );
		$this->assertSame( 'ru-override', $messages['present-ru'] );
		$this->assertSame( 'ru-override', $messages['present-en'] );
	}

	public function testRecacheExtensionMessagesFiles(): void {
		global $IP;

		// first, recache the l10n cache and test it
		$lc = $this->getMockLocalisationCache( [], [
			'ExtensionMessagesFiles' => [
				__METHOD__ => "$IP/tests/phpunit/data/localisationcache/ExtensionMessagesFiles.php",
			]
		] );
		$lc->recache( 'de' );
		$this->assertExtensionMessagesFiles( $lc );

		// then, make another l10n cache sharing the first one’s LCStore and test that (T343375)
		$lc = $this->getMockLocalisationCache( [], [
			'ExtensionMessagesFiles' => [
				__METHOD__ => "$IP/tests/phpunit/data/localisationcache/ExtensionMessagesFiles.php",
			]
		] );
		// no recache this time, but load only the core data first by getting the fallbackSequence
		$lc->getItem( 'de', 'fallbackSequence' );
		$this->assertExtensionMessagesFiles( $lc );
	}

	public function testRecacheTranslationAliasesDirs(): void {
		global $IP;

		$lc = $this->getMockLocalisationCache( [], [
			'TranslationAliasesDirs' => [
				__METHOD__ => "$IP/tests/phpunit/data/localisationcache/translation-alias/"
			]
		] );

		$lc->recache( 'nl' );
		$specialPageAliases = $lc->getItem( 'nl', 'specialPageAliases' );
		$this->assertSame(
			[ "Vertalersmeldingen(TEST)", "NotifyTranslators(TEST)" ],
			$specialPageAliases['NotifyTranslators'],
			'specialPageAliases can be set in TranslationAliasesDirs'
		);
		$this->assertSame(
			[ 'ActieveGebruikers(TEST)', 'ActieveGebruikers', 'ActiveUsers' ],
			$specialPageAliases['Activeusers'],
			'specialPageAliases from extension/core files are merged'
		);

		$lc->recache( 'pt' );
		$specialPageAliases = $lc->getItem( 'pt', 'specialPageAliases' );
		$this->assertSame(
			[ 'Utilizadores_activos(TEST)', 'Utilizadores_activos', 'Usuários_ativos', 'ActiveUsers' ],
			$specialPageAliases['Activeusers'],
			'specialPageAliases from extension/core files and fallback languages are merged'
		);

		$this->expectException( UnexpectedValueException::class );
		$this->expectExceptionMessageMatches( '/invalid key:/i' );
		$lc->recache( 'fr' );
	}

	/**
	 * Assert that the given LocalisationCache, which should be configured with
	 * ExtensionMessagesFiles containing the ExtensionMessagesFiles.php test fixture file,
	 * contains the expected data.
	 */
	private function assertExtensionMessagesFiles( LocalisationCache $lc ): void {
		$specialPageAliases = $lc->getItem( 'de', 'specialPageAliases' );
		$this->assertSame(
			[ 'LokalisierungsPufferTest' ],
			$specialPageAliases['LocalisationCacheTest'],
			'specialPageAliases can be set in ExtensionMessagesFiles'
		);
		$this->assertSame(
			[ 'Aktive_Benutzer*innen', 'Aktive_Benutzer', 'ActiveFolx', 'ActiveUsers' ],
			$specialPageAliases['Activeusers'],
			'specialPageAliases from extension/core files and fallback languages are merged'
		);
		$namespaceNames = $lc->getItem( 'de', 'namespaceNames' );
		$this->assertSame(
			'LokalisierungsPufferTest',
			$namespaceNames[98]
		);
		$this->assertFalse(
			$lc->getItem( 'de', 'rtl' ),
			'rtl cannot be set in ExtensionMessagesFiles'
		);
	}

	public function testLoadCoreDataAvoidsInitLanguage(): void {
		$lc = $this->getMockLocalisationCache();

		$lc->getItem( 'de', 'fallback' );
		$lc->getItem( 'de', 'rtl' );
		$lc->getItem( 'de', 'fallbackSequence' );
		$lc->getItem( 'de', 'originalFallbackSequence' );

		$this->assertArrayNotHasKey( 'de',
			TestingAccessWrapper::newFromObject( $lc )->initialisedLangs );
	}

	public function testShallowFallbackForInvalidCode(): void {
		$lc = $this->getMockLocalisationCache();
		$invalidCode = '!invalid!';

		$this->assertSame( false, $lc->getItem( $invalidCode, 'rtl' ) );
		$this->assertSame( 'windows-1252', $lc->getItem( $invalidCode, 'fallback8bitEncoding' ) );
	}
}
PK       ! 7;  7;  )  language/LanguageConverterFactoryTest.phpnu Iw        <?php

use MediaWiki\Config\HashConfig;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Language\LanguageConverter;
use MediaWiki\Languages\LanguageConverterFactory;
use MediaWiki\MainConfigNames;
use Wikimedia\TestingAccessWrapper;

/**
 * @group large
 * @group Language
 * @covers \MediaWiki\Languages\LanguageConverterFactory
 */
class LanguageConverterFactoryTest extends MediaWikiLangTestCase {
	/**
	 * @dataProvider codeProvider
	 */
	public function testLanguageConverters(
		$langCode,
		$staticDefaultVariant,
		$type,
		$variants,
		$variantFallbacks,
		$variantNames,
		$flags,
		$manualLevel
	) {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( $langCode );
		$factory = new LanguageConverterFactory(
			new ServiceOptions( LanguageConverterFactory::CONSTRUCTOR_OPTIONS, new HashConfig( [
				MainConfigNames::UsePigLatinVariant => false,
				MainConfigNames::DisableLangConversion => false,
				MainConfigNames::DisableTitleConversion => false,
			] ) ),
			$this->getServiceContainer()->getObjectFactory(),
			static function () use ( $lang ) {
				return $lang;
			}
		);
		$this->assertFalse( $factory->isConversionDisabled() );
		$this->assertFalse( $factory->isLinkConversionDisabled() );
		$converter = $factory->getLanguageConverter( $lang );
		$this->verifyConverter(
			$converter,
			$lang,
			$langCode,
			$staticDefaultVariant,
			$type,
			$variants,
			$variantFallbacks,
			$variantNames,
			$flags,
			$manualLevel
		);
	}

	public function testCreateFromCodeEnPigLatin() {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$factory = new LanguageConverterFactory(
			new ServiceOptions( LanguageConverterFactory::CONSTRUCTOR_OPTIONS, new HashConfig( [
				MainConfigNames::UsePigLatinVariant => true,
				MainConfigNames::DisableLangConversion => false,
				MainConfigNames::DisableTitleConversion => false,
			] ) ),
			$this->getServiceContainer()->getObjectFactory(),
			static function () use ( $lang ) {
				return $lang;
			}
		);
		$this->assertFalse( $factory->isConversionDisabled() );
		$this->assertFalse( $factory->isLinkConversionDisabled() );

		$converter = $factory->getLanguageConverter( $lang );

		$this->verifyConverter(
			$converter,
			$lang,
			'en',
			'en',
			EnConverter::class,
			[ 'en', 'en-x-piglatin' ],
			[],
			[],
			[],
			[ 'en' => 'bidirectional', 'en-x-piglatin' => 'bidirectional' ]
		);
	}

	/**
	 * @dataProvider booleanProvider
	 */
	public function testDisabledBooleans( $pigLatinDisabled, $conversionDisabled, $titleDisabled ) {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$factory = new LanguageConverterFactory(
			new ServiceOptions( LanguageConverterFactory::CONSTRUCTOR_OPTIONS, new HashConfig( [
				MainConfigNames::UsePigLatinVariant => !$pigLatinDisabled,
				MainConfigNames::DisableLangConversion => $conversionDisabled,
				MainConfigNames::DisableTitleConversion => $titleDisabled,
			] ) ),
			$this->getServiceContainer()->getObjectFactory(),
			static function () use ( $lang ) {
				return $lang;
			}
		);
		$converter = $factory->getLanguageConverter( $lang );

		$this->assertSame( $conversionDisabled, $factory->isConversionDisabled() );
		$this->assertSame( $conversionDisabled || $titleDisabled, $factory->isLinkConversionDisabled() );

		if ( $pigLatinDisabled ) {
			$this->assertNotContains(
				'en-x-piglatin', $converter->getVariants()
			);
		} else {
			$this->assertContains(
				'en-x-piglatin', $converter->getVariants()
			);
		}
	}

	public static function booleanProvider() {
		return [
			[ false, false, false ],
			[ false, false, true ],
			[ false, true, false ],
			[ false, true, true ],
			[ true, false, false ],
			[ true, false, true ],
			[ true, true, false ],
			[ true, true, true ],
		];
	}

	public function testDefaultContentLanguageFallback() {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$factory = new LanguageConverterFactory(
			new ServiceOptions( LanguageConverterFactory::CONSTRUCTOR_OPTIONS, new HashConfig( [
				MainConfigNames::UsePigLatinVariant => false,
				MainConfigNames::DisableLangConversion => false,
				MainConfigNames::DisableTitleConversion => false,
			] ) ),
			$this->getServiceContainer()->getObjectFactory(),
			static function () use ( $lang ) {
				return $lang;
			}
		);
		$this->assertFalse( $factory->isConversionDisabled() );
		$this->assertFalse( $factory->isLinkConversionDisabled() );

		$converter = $factory->getLanguageConverter();

		$this->verifyConverter(
			$converter,
			$lang,
			'en',
			'en',
			TrivialLanguageConverter::class,
			[ 'en' ],
			[],
			[],
			[],
			[]
		);
	}

	private function verifyConverter(
		$converter,
		$lang,
		$langCode,
		$staticDefaultVariant,
		$type,
		$variants,
		$variantFallbacks,
		$variantNames,
		$flags,
		$manualLevel
	) {
		$this->assertInstanceOf( $type, $converter );

		if ( $converter instanceof LanguageConverter ) {
			$testConverter = TestingAccessWrapper::newFromObject( $converter );
			$this->assertSame( $lang, $testConverter->mLangObj, "Language should be as provided" );

			$this->assertEquals( $langCode, $testConverter->getMainCode(),
				"getMainCode should be as $langCode" );
			$this->assertEquals( $staticDefaultVariant, $testConverter->getStaticDefaultVariant(),
				"getStaticDefaultVariant should be as $staticDefaultVariant" );
			$this->assertEquals( $manualLevel, $testConverter->getManualLevel(), "Manual Level" );

			$this->assertEquals( $variants, $testConverter->getVariants(), "Variants" );
			$this->assertEquals( $variantFallbacks, $testConverter->getVariantsFallbacks(), "Variant Fallbacks" );
			$defaultFlags = [
				'A' => 'A',
				'T' => 'T',
				'R' => 'R',
				'D' => 'D',
				'-' => '-',
				'H' => 'H',
				'N' => 'N',
			];
			$this->assertArraySubmapSame(
				array_merge( $defaultFlags, $flags ),
				$converter->getFlags(),
				"Flags"
			);
		}
	}

	public static function codeProvider() {
		$trivialWithNothingElseCodes = [
			'aa', 'ab', 'abs', 'ace', 'ady', 'ady-cyrl', 'aeb', 'aeb-arab', 'aeb-latn',
			'af', 'aln', 'als', 'am', 'an', 'ang', 'anp', 'ar', 'arc', 'arn',
			'arq', 'ary', 'arz', 'as', 'ase', 'ast', 'atj', 'av', 'avk', 'awa', 'ay',
			'az', 'azb', 'ba', 'ban-bali', 'bar', 'bat-smg', 'bbc', 'bbc-latn', 'bcc',
			'bcl', 'be', 'be-tarask', 'be-x-old', 'bg', 'bgn', 'bh', 'bho', 'bi', 'bjn',
			'bm', 'bn', 'bo', 'bpy', 'bqi', 'br', 'brh', 'bs', 'btm', 'bto', 'bug', 'bxr',
			'ca', 'cbk-zam', 'cdo', 'ce', 'ceb', 'ch', 'cho', 'chr', 'chy', 'ckb', 'co',
			'cps', 'cr', 'crh-latn', 'crh-cyrl', 'cs', 'csb', 'cu', 'cv', 'cy', 'da',
			'de', 'de-at', 'de-ch', 'de-formal', 'din', 'diq', 'dsb', 'dtp', 'dty',
			'dv', 'dz', 'ee', 'egl', 'el', 'eml', 'en', 'en-ca', 'en-gb', 'eo', 'es',
			'es-419', 'es-formal', 'et', 'eu', 'ext', 'fa', 'ff', 'fi', 'fit', 'fiu-vro',
			'fj', 'fo', 'fr', 'frc', 'frp', 'frr', 'fur', 'fy', 'ga', 'gag', 'gan-hans',
			'gan-hant', 'gcr', 'gd', 'gl', 'glk', 'gn', 'gom', 'gom-deva', 'gom-latn',
			'gor', 'got', 'grc', 'gsw', 'gu', 'gv', 'ha', 'hak', 'haw', 'he', 'hi',
			'hif', 'hif-latn', 'hil', 'ho', 'hr', 'hrx', 'hsb', 'ht', 'hu', 'hu-formal',
			'hy', 'hyw', 'hz', 'ia', 'id', 'ie', 'ig', 'ii', 'ik', 'ike-cans',
			'ike-latn', 'ilo', 'inh', 'io', 'is', 'it', 'ja', 'jam', 'jbo', 'jut',
			'jv', 'ka', 'kaa', 'kab', 'kbd', 'kbd-cyrl', 'kbp', 'kg', 'khw', 'ki',
			'kiu', 'kj', 'kjp', 'kl', 'km', 'kn', 'ko', 'ko-kp', 'koi', 'kr', 'krc', 'kri', 'krj',
			'krl', 'ks', 'ks-arab', 'ks-deva', 'ksh', 'ku-latn', 'ku-arab', 'kum', 'kv',
			'kw', 'ky', 'la', 'lad', 'lb', 'lbe', 'lez', 'lfn', 'lg', 'li', 'lij', 'liv',
			'lki', 'lmo', 'ln', 'lo', 'lrc', 'loz', 'lt', 'ltg', 'lus', 'luz', 'lv',
			'lzh', 'lzz', 'mai', 'map-bms', 'mdf', 'mg', 'mh', 'mhr', 'mi', 'min', 'mk',
			'ml', 'mn', 'mnw', 'mo', 'mr', 'mrj', 'ms', 'mt', 'mus', 'mwl', 'my',
			'myv', 'mzn', 'na', 'nah', 'nan', 'nap', 'nb', 'nds', 'nds-nl', 'ne', 'new',
			'ng', 'niu', 'nl', 'nl-informal', 'nn', 'no', 'nov', 'nqo', 'nrm', 'nso',
			'nv', 'ny', 'nys', 'oc', 'olo', 'om', 'or', 'os', 'pa', 'pag', 'pam', 'pap',
			'pcd', 'pdc', 'pdt', 'pfl', 'pi', 'pih', 'pl', 'pms', 'pnb', 'pnt', 'prg',
			'ps', 'pt', 'pt-br', 'qu', 'qug', 'rgn', 'rif', 'rm', 'rmy', 'rn', 'ro',
			'roa-rup', 'roa-tara', 'ru', 'rue', 'rup', 'ruq', 'ruq-cyrl', 'ruq-latn',
			'rw', 'sa', 'sah', 'sat', 'sc', 'scn', 'sco', 'sd', 'sdc', 'sdh', 'se',
			'sei', 'ses', 'sg', 'sgs', 'sh-cyrl', 'sh-latn', 'shi-tfng', 'shi-latn', 'shn',
			'shy-latn', 'si', 'simple', 'sk', 'skr', 'skr-arab', 'sl', 'sli', 'sm', 'sma', 'sn',
			'so', 'sq', 'sr-ec', 'sr-el', 'srn', 'ss', 'st', 'sty', 'stq', 'su', 'sv',
			'sw', 'szl', 'szy', 'ta', 'tay', 'tcy', 'te', 'tet', 'tg-cyrl', 'tg-latn',
			'th', 'ti', 'tk', 'tl', 'tly-latn', 'tn', 'to', 'tpi', 'tr', 'tru', 'ts', 'tt',
			'tt-cyrl', 'tt-latn', 'tum', 'tw', 'ty', 'tyv', 'tzm', 'udm', 'ug', 'ug-arab',
			'ug-latn', 'uk', 'ur', 'uz-cyrl', 'uz-latn', 've', 'vec', 'vep', 'vi', 'vls',
			'vmf', 'vo', 'vot', 'vro', 'wa', 'war', 'wo', 'wuu-hans', 'wuu-hant', 'xal',
			'xh', 'xmf', 'xsy', 'yi', 'yo', 'yue', 'yue-hans', 'yue-hant', 'za', 'zea',
			'zh-classical', 'zh-cn', 'zh-hans', 'zh-hant', 'zh-hk', 'zh-min-nan', 'zh-mo',
			'zh-my', 'zh-sg', 'zh-tw', 'zu',
		];
		foreach ( $trivialWithNothingElseCodes as $code ) {
			# $langCode, $mainVariantCode, $type, $variants, $variantFallbacks, $variantNames, $flags, $manualLevel
			yield $code => [ $code, $code, TrivialLanguageConverter::class, [], [], [], [], [] ];
		}

		// Languages with a type of than TrivialLanguageConverter or with variants/flags/manual level
		yield 'ban' => [
			'ban', 'ban', BanConverter::class,
			[ 'ban', 'ban-bali', 'ban-x-dharma', 'ban-x-palmleaf', 'ban-x-pku' ],
			[
				'ban-bali' => 'ban',
				'ban-x-dharma' => 'ban',
				'ban-x-palmleaf' => 'ban',
				'ban-x-pku' => 'ban',
			], [], [], [
				'ban' => 'bidirectional',
				'ban-bali' => 'bidirectional',
				'ban-x-dharma' => 'bidirectional',
				'ban-x-palmleaf' => 'bidirectional',
				'ban-x-pku' => 'bidirectional',
			]
		];

		yield 'crh' => [
			'crh', 'crh', CrhConverter::class,
			[ 'crh', 'crh-cyrl', 'crh-latn' ],
			[
				'crh' => 'crh-latn',
				'crh-cyrl' => 'crh-latn',
				'crh-latn' => 'crh-cyrl',
			], [], [], [
				'crh' => 'bidirectional',
				'crh-cyrl' => 'bidirectional',
				'crh-latn' => 'bidirectional'
			]
		];

		yield 'gan' => [
			'gan', 'gan', GanConverter::class,
			[ 'gan', 'gan-hans', 'gan-hant' ],
			[
				'gan' => [ 'gan-hans', 'gan-hant' ],
				'gan-hans' => [ 'gan' ],
				'gan-hant' => [ 'gan' ],
			], [], [], [
				'gan' => 'disable',
				'gan-hans' => 'bidirectional',
				'gan-hant' => 'bidirectional'
			]
		];

		yield 'iu' => [
			'iu', 'iu', IuConverter::class,
			[ 'iu', 'ike-cans', 'ike-latn' ],
			[
				'iu' => 'ike-cans',
				'ike-cans' => 'iu',
				'ike-latn' => 'iu',
			], [], [], [
				'iu' => 'bidirectional',
				'ike-cans' => 'bidirectional',
				'ike-latn' => 'bidirectional'
			]
		];

		yield 'ku' => [
			'ku', 'ku', KuConverter::class,
			[ 'ku', 'ku-arab', 'ku-latn' ],
			[
				'ku' => 'ku-latn',
				'ku-arab' => 'ku-latn',
				'ku-latn' => 'ku-arab'
			], [], [], [
				'ku' => 'bidirectional',
				'ku-arab' => 'bidirectional',
				'ku-latn' => 'bidirectional'
			]
		];

		yield 'mni' => [
			'mni', 'mni', MniConverter::class,
			[ 'mni', 'mni-beng' ],
			[
				'mni-beng' => 'mni'
			], [], [], [
				'mni' => 'bidirectional',
				'mni-beng' => 'bidirectional'
			]
		];

		yield 'sh' => [
			'sh', 'sh-latn', ShConverter::class,
			[ 'sh-latn', 'sh-cyrl' ],
			[ 'sh-cyrl' => 'sh-latn' ],
			[], [], [
				'sh-latn' => 'bidirectional',
				'sh-cyrl' => 'bidirectional'
			]
		];

		yield 'shi' => [
			'shi', 'shi', ShiConverter::class,
			[ 'shi', 'shi-tfng', 'shi-latn' ],
			[
				'shi' => [ 'shi-latn', 'shi-tfng' ],
				'shi-tfng' => 'shi',
				'shi-latn' => 'shi'
			], [], [], [
				'shi' => 'bidirectional',
				'shi-tfng' => 'bidirectional',
				'shi-latn' => 'bidirectional'
			]
		];

		yield 'sr' => [
			'sr', 'sr', SrConverter::class,
			[ 'sr', 'sr-ec', 'sr-el' ],
			[
				'sr' => 'sr-ec',
				'sr-ec' => 'sr',
				'sr-el' => 'sr'
			], [], [
				'S' => 'S',
				'писмо' => 'S',
				'pismo' => 'S',
				'W' => 'W',
				'реч' => 'W',
				'reč' => 'W',
				'ријеч' => 'W',
				'riječ' => 'W'
			], [
				'sr' => 'bidirectional',
				'sr-ec' => 'bidirectional',
				'sr-el' => 'bidirectional'
			]
		];

		yield 'tg' => [
			'tg', 'tg', TgConverter::class,
			[ 'tg', 'tg-latn' ],
			[], [], [], [
				'tg' => 'bidirectional',
				'tg-latn' => 'bidirectional'
			]
		];

		yield 'tly' => [
			'tly', 'tly', TlyConverter::class,
			[ 'tly', 'tly-cyrl' ],
			[ 'tly-cyrl' => 'tly' ],
			[],
			[
				'tly' => 'tly',
				'tly-cyrl' => 'tly-cyrl'
			],
			[
				'tly' => 'bidirectional',
				'tly-cyrl' => 'bidirectional',
			]
		];

		yield 'uz' => [
			'uz', 'uz', UzConverter::class,
			[ 'uz', 'uz-latn', 'uz-cyrl' ],
			[
				'uz' => 'uz-latn',
				'uz-cyrl' => 'uz',
				'uz-latn' => 'uz',
			], [], [
				'uz' => 'uz',
				'uz-latn' => 'uz-latn',
				'uz-cyrl' => 'uz-cyrl'
			], [
				'uz' => 'bidirectional',
				'uz-latn' => 'bidirectional',
				'uz-cyrl' => 'bidirectional',
			]
		];

		yield 'wuu' => [
			'wuu', 'wuu', WuuConverter::class,
			[ 'wuu', 'wuu-hans', 'wuu-hant' ],
			[
				'wuu' => [ 'wuu-hans', 'wuu-hant' ],
				'wuu-hans' => [ 'wuu' ],
				'wuu-hant' => [ 'wuu' ],
			], [], [], [
				'wuu' => 'disable',
				'wuu-hans' => 'bidirectional',
				'wuu-hant' => 'bidirectional'
			]
		];

		yield 'zgh' => [
			'zgh', 'zgh', ZghConverter::class,
			[ 'zgh', 'zgh-latn' ],
			[], [], [], [
				'zgh' => 'bidirectional',
				'zgh-latn' => 'bidirectional'
			]
		];

		$zh_variants = [
			'zh',
			'zh-hans',
			'zh-hant',
			'zh-cn',
			'zh-hk',
			'zh-mo',
			'zh-my',
			'zh-sg',
			'zh-tw'
		];

		$zh_variantfallbacks = [
			'zh' => [ 'zh-hans', 'zh-hant', 'zh-cn', 'zh-tw', 'zh-hk', 'zh-sg', 'zh-mo', 'zh-my' ],
			'zh-hans' => [ 'zh-cn', 'zh-sg', 'zh-my' ],
			'zh-hant' => [ 'zh-tw', 'zh-hk', 'zh-mo' ],
			'zh-cn' => [ 'zh-hans', 'zh-sg', 'zh-my' ],
			'zh-sg' => [ 'zh-my', 'zh-hans', 'zh-cn' ],
			'zh-my' => [ 'zh-sg', 'zh-hans', 'zh-cn' ],
			'zh-tw' => [ 'zh-hant', 'zh-hk', 'zh-mo' ],
			'zh-hk' => [ 'zh-mo', 'zh-hant', 'zh-tw' ],
			'zh-mo' => [ 'zh-hk', 'zh-hant', 'zh-tw' ],
		];
		$zh_ml = [
			'zh' => 'disable',
			'zh-hans' => 'unidirectional',
			'zh-hant' => 'unidirectional',
			'zh-cn' => 'bidirectional',
			'zh-hk' => 'bidirectional',
			'zh-mo' => 'bidirectional',
			'zh-my' => 'bidirectional',
			'zh-sg' => 'bidirectional',
			'zh-tw' => 'bidirectional',
		];

		$zh_flags = [
			'A' => 'A',
			'T' => 'T',
			'R' => 'R',
			'D' => 'D',
			'-' => '-',
			'H' => 'H',
			'N' => 'N',
			'zh' => 'zh',
			'zh-hans' => 'zh-hans',
			'zh-hant' => 'zh-hant',
			'zh-cn' => 'zh-cn',
			'zh-hk' => 'zh-hk',
			'zh-mo' => 'zh-mo',
			'zh-my' => 'zh-my',
			'zh-sg' => 'zh-sg',
			'zh-tw' => 'zh-tw'
		];
		yield 'zh' => [ 'zh', 'zh', ZhConverter::class, $zh_variants, $zh_variantfallbacks, [], $zh_flags, $zh_ml ];
	}
}
PK       ! -f  f    language/ConverterRuleTest.phpnu Iw        <?php

use MediaWiki\Language\ConverterRule;

/**
 * @group Language
 * @covers \MediaWiki\Language\ConverterRule
 */
class ConverterRuleTest extends MediaWikiIntegrationTestCase {

	public function testParseEmpty() {
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$converter = new EnConverter( $lang );
		$rule = new ConverterRule( '', $converter );
		$rule->parse();

		$this->assertSame( false, $rule->getTitle(), 'title' );
		$this->assertSame( [], $rule->getConvTable(), 'conversion table' );
		$this->assertSame( 'none', $rule->getRulesAction(), 'rules action' );
	}

}
PK       ! s`s7  7  *  registration/ExtensionRegistrationTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Registration;

use AutoLoader;
use Generator;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\Settings\Config\ArrayConfigBuilder;
use MediaWiki\Settings\Config\PhpIniSink;
use MediaWiki\Settings\SettingsBuilder;
use MediaWikiIntegrationTestCase;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Registration\ExtensionRegistry
 */
class ExtensionRegistrationTest extends MediaWikiIntegrationTestCase {

	/** @var array */
	private $autoloaderState;

	/** @var ?ExtensionRegistry */
	private $originalExtensionRegistry = null;

	protected function setUp(): void {
		// phpcs:disable MediaWiki.Usage.DeprecatedGlobalVariables.Deprecated$wgHooks
		global $wgHooks;

		parent::setUp();

		$this->autoloaderState = AutoLoader::getState();

		// Make sure to restore globals
		$this->stashMwGlobals( [
			'wgHooks',
			'wgAutoloadClasses',
			'wgNamespaceProtection',
			'wgNamespaceModels',
			'wgAvailableRights',
			'wgAuthManagerAutoConfig',
			'wgGroupPermissions',
		] );

		// For the purpose of this test, make $wgHooks behave like a real global config array.
		$wgHooks = [];
	}

	protected function tearDown(): void {
		AutoLoader::restoreState( $this->autoloaderState );

		if ( $this->originalExtensionRegistry ) {
			$this->setExtensionRegistry( $this->originalExtensionRegistry );
		}

		parent::tearDown();
	}

	public function testExportNamespaces() {
		$manifest = [
			'namespaces' => [
				[
					'id' => 1300,
					'name' => 'ExtensionRegistrationTest',
					'constant' => 'NS_EXTENSION_REGISTRATION_TEST',
					'defaultcontentmodel' => 'Foo',
					'protection' => [ 'sysop' ],
				]
			]
		];

		$file = $this->makeManifestFile( $manifest );

		$registry = new ExtensionRegistry();
		$registry->queue( $file );
		$registry->loadFromQueue();

		$this->assertTrue( defined( 'NS_EXTENSION_REGISTRATION_TEST' ) );
		$this->assertSame( 1300, constant( 'NS_EXTENSION_REGISTRATION_TEST' ) );

		$expectedNamespaceNames = [ 1300 => 'ExtensionRegistrationTest' ];
		$this->assertSame( $expectedNamespaceNames, $registry->getAttribute( 'ExtensionNamespaces' ) );

		$this->assertArrayHasKey( 1300, $GLOBALS['wgNamespaceProtection'] );
		$this->assertArrayHasKey( 1300, $GLOBALS['wgNamespaceContentModels'] );
	}

	private function setExtensionRegistry( ExtensionRegistry $registry ) {
		$class = new \ReflectionClass( ExtensionRegistry::class );

		if ( !$this->originalExtensionRegistry ) {
			$this->originalExtensionRegistry = $class->getStaticPropertyValue( 'instance' );
		}

		$class->setStaticPropertyValue( 'instance', $registry );
	}

	public static function onAnEvent() {
		// no-op
	}

	public static function onBooEvent() {
		// no-op
	}

	public function testExportHooks() {
		$manifest = [
			'Hooks' => [
				'AnEvent' => self::class . '::onAnEvent',
				'BooEvent' => 'main',
			],
			'HookHandlers' => [
				'main' => [ 'class' => self::class ]
			],
		];

		$file = $this->makeManifestFile( $manifest );

		$registry = new ExtensionRegistry();
		$this->setExtensionRegistry( $registry );

		$registry->queue( $file );
		$registry->loadFromQueue();

		$this->resetServices();
		$hookContainer = $this->getServiceContainer()->getHookContainer();
		$this->assertTrue( $hookContainer->isRegistered( 'AnEvent' ), 'AnEvent' );
		$this->assertTrue( $hookContainer->isRegistered( 'BooEvent' ), 'BooEvent' );
	}

	public function testExportAutoload() {
		global $wgAutoloadClasses;
		$oldAutoloadClasses = $wgAutoloadClasses;

		$manifest = [
			'AutoloadClasses' => [
				'TestAutoloaderClass' =>
					__DIR__ . '/../../data/autoloader/TestAutoloadedClass.php',
			],
			'AutoloadNamespaces' => [
				'Dummy\Test\Namespace\\' =>
					__DIR__ . '/../../data/autoloader/psr4/',
			],
			'HookHandler' => [
				'main' => [ 'class' => 'Whatever' ]
			],
		];

		$file = $this->makeManifestFile( $manifest );

		$registry = new ExtensionRegistry();
		$registry->setCache( new HashBagOStuff() );

		$registry->queue( $file );
		$registry->loadFromQueue();

		$this->assertArrayHasKey( 'TestAutoloaderClass', AutoLoader::getClassFiles() );
		$this->assertArrayHasKey( 'Dummy\Test\Namespace\\', AutoLoader::getNamespaceDirectories() );

		// Now, reset and do it again, but with the cached extension info.
		// This is needed because autoloader registration is currently handled
		// differently when loading from the cache (T240535).
		AutoLoader::restoreState( $this->autoloaderState );
		$wgAutoloadClasses = $oldAutoloadClasses;

		$registry->queue( $file );
		$registry->loadFromQueue();

		$this->assertArrayHasKey( 'TestAutoloaderClass', AutoLoader::getClassFiles() );
		$this->assertArrayHasKey( 'Dummy\Test\Namespace\\', AutoLoader::getNamespaceDirectories() );
	}

	/**
	 * @dataProvider provideExportConfigToGlobals
	 * @dataProvider provideExportAttributesToGlobals
	 */
	public function testExportGlobals( $desc, $before, $manifest, $expected ) {
		$this->setMwGlobals( $before );

		$file = $this->makeManifestFile( $manifest );

		$registry = new ExtensionRegistry();
		$registry->queue( $file );
		$registry->loadFromQueue();

		foreach ( $expected as $name => $expectedValue ) {
			$this->assertArrayHasKey( $name, $GLOBALS, $desc );
			$this->assertEquals( $expectedValue, $GLOBALS[$name], $desc );
		}
	}

	private function newSettingsBuilder(): SettingsBuilder {
		$settings = new SettingsBuilder(
			__DIR__,
			$this->createMock( ExtensionRegistry::class ),
			new ArrayConfigBuilder(),
			$this->createMock( PhpIniSink::class ),
			null
		);

		return $settings;
	}

	public static function callbackForTest( array $ext, SettingsBuilder $settings ) {
		$settings->overrideConfigValue( 'RunCallbacksTest', 'foo' );
		self::assertSame( 'CallbackTest', $ext['name'] );
	}

	public function testRunCallbacks() {
		$manifest = [
			'name' => 'CallbackTest',
			'callback' => [ __CLASS__, 'callbackForTest' ],
		];

		$file = $this->makeManifestFile( $manifest );

		$settings = $this->newSettingsBuilder();

		$registry = new ExtensionRegistry();
		$registry->setSettingsBuilder( $settings );

		$settings->enterRegistrationStage();
		$registry->queue( $file );
		$registry->loadFromQueue();

		$this->assertSame( 'foo', $settings->getConfig()->get( 'RunCallbacksTest' ) );
	}

	/**
	 * Provides defaults coming from extension, global values from custom settings.
	 * The global value should be merged on top of the default from the extension (backwards merge).
	 *
	 * @return Generator
	 */
	public static function provideExportConfigToGlobals() {
		yield [
			'Simple non-array values',
			[
				'mwtestFooBarConfig' => true,
				'mwtestFooBarConfig2' => 'string',
			],
			[
				'config_prefix' => 'mwtest',
				'config' => [
					'FooBarDefault' => [ 'value' => 1234 ],
					'FooBarConfig' => [ 'value' => false ],
				]
			],
			[
				'mwtestFooBarConfig' => true,
				'mwtestFooBarConfig2' => 'string',
				'mwtestFooBarDefault' => 1234,
			],
		];

		yield [
			'No global already set, simple assoc array',
			[],
			[
				'config_prefix' => 'mwtest',
				'config' => [
					'DefaultOptions' => [
						'value' => [
							'foobar' => true,
						]
					]
				]
			],
			[
				'mwtestDefaultOptions' => [
					'foobar' => true,
				]
			],
		];

		yield [
			'No global already set, simple assoc array, manifest version 1',
			[],
			[
				'manifest_version' => 1,
				'config' => [
					'_prefix' => 'mwtest',
					'SomeMap' => [
						'foobar' => true,
					]
				]
			],
			[
				'mwtestSomeMap' => [
					'foobar' => true,
				]
			],
		];

		yield [
			'Global already set, simple assoc array, manifest version 1',
			[
				'mwtestSomeMap' => [
					'foobar' => true,
					'foo' => 'string'
				],
			],
			[
				'manifest_version' => 1,
				'config' => [
					'_prefix' => 'mwtest',
					'SomeMap' => [
						'barbaz' => 12345,
						'foobar' => false,
					]
				]
			],
			[
				'mwtestSomeMap' => [
					'barbaz' => 12345,
					'foo' => 'string',
					'foobar' => true,
				],
			]
		];

		yield [
			'Global already set, simple list array',
			[
				'mwtestList' => [ 'x', 'y', 'z' ],
			],
			[
				'manifest_version' => 1,
				'config' => [
					'_prefix' => 'mwtest',
					'List' => [ 'a', 'b' ]
				]
			],
			[
				'mwtestList' => [ 'a', 'b', 'x', 'y', 'z' ],
			]
		];

		yield [
			'New variable, explicit merge strategy',
			[
				'wgNamespacesFoo' => [
					100 => true,
					102 => false
				],
			],
			[
				'config' => [
					'NamespacesFoo' => [
						'value' => [
							100 => false,
							500 => true,
						],
						'merge_strategy' => 'array_plus',
					],
				]
			],
			[
				'wgNamespacesFoo' => [
					100 => true,
					102 => false,
					500 => true,
				],
			]
		];

		yield [
			'New variable, explicit merge strategy, manifest version 1',
			[
				'wgNamespacesFoo' => [
					100 => true,
					102 => false
				],
			],
			[
				'manifest_version' => 1,
				'config' => [
					'NamespacesFoo' => [
						100 => false,
						500 => true,
						ExtensionRegistry::MERGE_STRATEGY => 'array_plus',
					],
				]
			],
			[
				'wgNamespacesFoo' => [
					100 => true,
					102 => false,
					500 => true,
				],
			]
		];

		yield [
			'False local setting should not be overridden by default (T100767)',
			[
				'wgT100767' => false,
			],
			[
				'config' => [
					'T100767' => [ 'value' => true ],
				]
			],
			[
				'wgT100767' => false,
			],
		];

		yield [
			'test array_replace_recursive',
			[
				'mwtestJsonConfigs' => [
					'JsonZeroConfig' => [
						'namespace' => 480,
						'nsName' => 'Zero',
						'isLocal' => false,
						'remote' => [
							'username' => 'foo',
						],
					],
				],
			],
			[
				'config_prefix' => 'mwtest',
				'config' => [
					'JsonConfigs' => [
						'value' => [
							'JsonZeroConfig' => [
								'isLocal' => true,
							],
						],
						'merge_strategy' => 'array_replace_recursive',
					],
				]
			],
			[
				'mwtestJsonConfigs' => [
					'JsonZeroConfig' => [
						'namespace' => 480,
						'nsName' => 'Zero',
						'isLocal' => false,
						'remote' => [
							'username' => 'foo',
						],
					],
				],
			],
		];

		yield [
			'Default doesn\'t override null',
			[
				'wgNullGlobal' => null,
			],
			[
				'config' => [
					'NullGlobal' => [ 'value' => 'not-null' ]
				]
			],
			[
				'wgNullGlobal' => null
			],
		];

		yield [
			'provide_default passive case',
			[
				'wgFlatArray' => [],
			],
			[
				'config' => [
					'FlatArray' => [
						'value' => [ 1 ],
						'merge_strategy' => 'provide_default'
					],
				]
			],
			[
				'wgFlatArray' => []
			],
		];

		yield [
			'provide_default active case',
			[],
			[
				'config' => [
					'FlatArray' => [
						'value' => [ 1 ],
						'merge_strategy' => 'provide_default'
					],
				]
			],
			[
				'wgFlatArray' => [ 1 ]
			],
		];
	}

	/**
	 * Provide global values as default coming from core, new value from extension attribute.
	 * The value coming from the extension should be merged on top of the global.
	 *
	 * @return Generator
	 */
	public static function provideExportAttributesToGlobals() {
		yield [
			'AvailableRights appends to default value, per config schema',
			[
				'wgAvailableRights' => [
					'aaa',
					'bbb'
				],
			],
			[ 'AvailableRights' => [ 'ccc', ] ],
			[
				// NOTE: This is backwards! Fortunately, the order in AvailableRights
				//       is not significant.
				'wgAvailableRights' => [
					'ccc',
					'aaa',
					'bbb',
				],
			]
		];

		yield [
			'AuthManagerAutoConfig appends to default value, per top level key',
			[
				'wgAuthManagerAutoConfig' => [
					'preauth' => [ 'default' => 'DefaultPreAuth' ],
					'primaryauth' => [ 'default' => 'DefaultPrimaryAuth' ],
					'secondaryauth' => [ 'default' => 'DefaultSecondaryAuth' ],
				],
			],
			[
				'AuthManagerAutoConfig' => [
					'primaryauth' => [ 'my' => 'MyPrimaryAuth' ],
				],
			],
			[
				'wgAuthManagerAutoConfig' => [
					'preauth' => [ 'default' => 'DefaultPreAuth' ],
					'primaryauth' => [ 'default' => 'DefaultPrimaryAuth', 'my' => 'MyPrimaryAuth' ],
					'secondaryauth' => [ 'default' => 'DefaultSecondaryAuth' ],
				],
			]
		];

		yield [
			'Global already set, $wgGroupPermissions',
			[
				'wgGroupPermissions' => [
					'sysop' => [
						'something' => true,
					],
					'user' => [
						'somethingtwo' => true,
					]
				],
			],
			[
				'GroupPermissions' => [
					'customgroup' => [
						'right' => true,
					],
					'user' => [
						'right' => true,
						'somethingtwo' => false,
						'nonduplicated' => true,
					],
				],
			],
			[
				'wgGroupPermissions' => [
					'customgroup' => [
						'right' => true,
					],
					'sysop' => [
						'something' => true,
					],
					'user' => [
						// NOTE: somethingtwo should be false here, since the value from
						//       the extension should override the core default!
						//       See e.g. https://www.mediawiki.org/wiki/Topic:W2ttbedo3apzno4w
						//       and https://phabricator.wikimedia.org/T98347#2589540.
						'somethingtwo' => true,
						'right' => true,
						'nonduplicated' => true,
					]
				],
			],
		];
	}

	/**
	 * @param array $manifest
	 *
	 * @return string
	 */
	private function makeManifestFile( array $manifest ): string {
		$manifest += [
			'name' => 'Test',
			'manifest_version' => 2,
			'config' => [],
			'callbacks' => [],
			'defines' => [],
			'credits' => [],
			'attributes' => [],
			'autoloaderPaths' => []
		];

		$file = $this->getNewTempFile();
		file_put_contents( $file, json_encode( $manifest ) );
		return $file;
	}

	public function testExportAutoloaderWithPsr4Namespaces() {
		$dir = __DIR__ . '/../../data/registration';
		$registry = new ExtensionRegistry();
		$data = $registry->readFromQueue( [
			"{$dir}/autoload_namespaces.json" => 1
		] );

		$access = TestingAccessWrapper::newFromObject( $registry );
		$access->exportExtractedData( $data );

		$this->assertTrue(
			class_exists( 'Test\\MediaWiki\\AutoLoader\\TestFooBar' ),
			"Registry initializes Autoloader from AutoloadNamespaces"
		);
	}

}
PK       ! ڡ]Y   Y   '  registration/FooBar/templates/README.mdnu Iw        This file exists to support ExtensionProcessorTest which checks for directory existence.
PK       ! 6      parser/TagHooksTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use InvalidArgumentException;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;

/**
 * @group Database
 * @group Parser
 *
 * @covers \MediaWiki\Parser\Parser
 * @covers \MediaWiki\Parser\BlockLevelPass
 * @covers \MediaWiki\Parser\StripState
 *
 * @covers \MediaWiki\Parser\Preprocessor_Hash
 * @covers \MediaWiki\Parser\PPDStack_Hash
 * @covers \MediaWiki\Parser\PPDStackElement_Hash
 * @covers \MediaWiki\Parser\PPDPart_Hash
 * @covers \MediaWiki\Parser\PPFrame_Hash
 * @covers \MediaWiki\Parser\PPTemplateFrame_Hash
 * @covers \MediaWiki\Parser\PPCustomFrame_Hash
 * @covers \MediaWiki\Parser\PPNode_Hash_Tree
 * @covers \MediaWiki\Parser\PPNode_Hash_Text
 * @covers \MediaWiki\Parser\PPNode_Hash_Array
 * @covers \MediaWiki\Parser\PPNode_Hash_Attr
 */
class TagHooksTest extends MediaWikiIntegrationTestCase {
	public static function provideValidNames() {
		return [
			[ 'foo' ],
			[ 'foo-bar' ],
			[ 'foo_bar' ],
			[ 'FOO-BAR' ],
			[ 'foo bar' ]
		];
	}

	public static function provideBadNames() {
		return [ [ "foo<bar" ], [ "foo>bar" ], [ "foo\nbar" ], [ "foo\rbar" ] ];
	}

	private function getParserOptions() {
		$popt = ParserOptions::newFromUserAndLang( new User,
			$this->getServiceContainer()->getContentLanguage() );
		return $popt;
	}

	/**
	 * @dataProvider provideValidNames
	 */
	public function testTagHooks( $tag ) {
		$parser = $this->getServiceContainer()->getParserFactory()->create();

		$parser->setHook( $tag, [ $this, 'tagCallback' ] );
		$parserOutput = $parser->parse(
			"Foo<$tag>Bar</$tag>Baz",
			Title::makeTitle( NS_MAIN, 'Test' ),
			$this->getParserOptions()
		);
		$this->assertEquals( "<p>FooOneBaz\n</p>", $parserOutput->getRawText() );
	}

	/**
	 * @dataProvider provideBadNames
	 */
	public function testBadTagHooks( $tag ) {
		$parser = $this->getServiceContainer()->getParserFactory()->create();

		$this->expectException( InvalidArgumentException::class );
		$parser->setHook( $tag, [ $this, 'tagCallback' ] );
	}

	public function tagCallback( $text, $params, $parser ) {
		return str_rot13( $text );
	}
}
PK       ! ʷ@  @    parser/CacheTimeTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use MediaWiki\MainConfigNames;
use MediaWiki\Parser\CacheTime;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiIntegrationTestCase;
use Wikimedia\Tests\SerializationTestTrait;

/**
 * @covers \MediaWiki\Parser\CacheTime
 */
class CacheTimeTest extends MediaWikiIntegrationTestCase {
	use SerializationTestTrait;

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

		MWTimestamp::setFakeTime( ParserCacheSerializationTestCases::FAKE_TIME );
		$this->overrideConfigValue(
			MainConfigNames::ParserCacheExpireTime,
			ParserCacheSerializationTestCases::FAKE_CACHE_EXPIRY
		);
	}

	/**
	 * Overrides SerializationTestTrait::getClassToTest
	 * @return string
	 */
	public static function getClassToTest(): string {
		return CacheTime::class;
	}

	/**
	 * Overrides SerializationTestTrait::getSerializedDataPath
	 * @return string
	 */
	public static function getSerializedDataPath(): string {
		return __DIR__ . '/../../data/ParserCache';
	}

	/**
	 * Overrides SerializationTestTrait::getTestInstancesAndAssertions
	 * @return array
	 */
	public static function getTestInstancesAndAssertions(): array {
		return ParserCacheSerializationTestCases::getCacheTimeTestCases();
	}

	/**
	 * Overrides SerializationTestTrait::getSupportedSerializationFormats
	 * @return array
	 */
	public static function getSupportedSerializationFormats(): array {
		return ParserCacheSerializationTestCases::getSupportedSerializationFormats(
			self::getClassToTest()
		);
	}

	public function testCacheExpiryDoesNotIncrease() {
		$cacheTime = new CacheTime();
		$this->assertSame(
			ParserCacheSerializationTestCases::FAKE_CACHE_EXPIRY,
			$cacheTime->getCacheExpiry()
		);

		$cacheTime->updateCacheExpiry( 10 );
		$this->assertSame( 10, $cacheTime->getCacheExpiry() );

		$cacheTime->updateCacheExpiry( 5 );
		$this->assertSame( 5, $cacheTime->getCacheExpiry() );

		$cacheTime->updateCacheExpiry( 100500 );
		$this->assertSame( 5, $cacheTime->getCacheExpiry() );
	}

	public function testCacheExpiryDoesNotIncreaseNotNegative() {
		$cacheTime = new CacheTime();
		$cacheTime->updateCacheExpiry( -10 );
		$this->assertSame( 0, $cacheTime->getCacheExpiry() );
	}

	public function testCacheExpiryNotMoreThenGlobal() {
		$cacheTime = new CacheTime();
		$cacheTime->updateCacheExpiry(
			ParserCacheSerializationTestCases::FAKE_CACHE_EXPIRY + 1
		);
		$this->assertSame(
			ParserCacheSerializationTestCases::FAKE_CACHE_EXPIRY,
			$cacheTime->getCacheExpiry()
		);
	}

	public function testExpired() {
		$cacheTime = new CacheTime();
		$cacheTime->updateCacheExpiry( 0 );
		$this->assertTrue( $cacheTime->expired( MWTimestamp::now() ) );

		$cacheTime = new CacheTime();
		$cacheTime->setCacheTime( MWTimestamp::now() );
		$this->assertTrue(
			$cacheTime->expired(
				MWTimestamp::convert( TS_MW, MWTimestamp::now( TS_UNIX ) + 10 )
			)
		);

		$cacheTime = new CacheTime();
		$cacheTime->updateCacheExpiry( 10 );
		$cacheTime->setCacheTime( MWTimestamp::now() );
		$this->assertTrue(
			$cacheTime->expired(
				MWTimestamp::convert( TS_MW, MWTimestamp::now( TS_UNIX ) + 15 )
			)
		);
	}

	public function testGetSetOptions() {
		$options = ParserOptions::allCacheVaryingOptions();
		$cacheTime = new CacheTime();
		$cacheTime->recordOptions( $options );
		$this->assertArrayEquals( $options, $cacheTime->getUsedOptions() );
	}
}
PK       ! Yfr&  r&    parser/ExtraParserTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use MediaWiki\Context\RequestContext;
use MediaWiki\Interwiki\ClassicInterwikiLookup;
use MediaWiki\MainConfigNames;
use MediaWiki\Parser\Parser;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use Wikimedia\TestingAccessWrapper;

/**
 * Parser-related tests that don't suit for parserTests.txt
 *
 * @group Database
 */
class ExtraParserTest extends MediaWikiIntegrationTestCase {

	/** @var ParserOptions */
	protected $options;
	/** @var Parser */
	protected $parser;

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

		$this->setUserLang( 'en' );
		$this->overrideConfigValues( [
			MainConfigNames::ShowExceptionDetails => true,
			MainConfigNames::CleanSignatures => true,
			MainConfigNames::LanguageCode => 'en',
		] );

		$services = $this->getServiceContainer();
		$contLang = $services->getContentLanguage();

		// FIXME: This test should pass without setting global content language
		$this->options = ParserOptions::newFromUserAndLang( new User, $contLang );
		$this->options->setTemplateCallback( [ __CLASS__, 'statelessFetchTemplate' ] );

		$this->parser = $services->getParserFactory()->create();
	}

	/**
	 * @see T10689
	 * @covers \MediaWiki\Parser\Parser::parse
	 */
	public function testLongNumericLinesDontKillTheParser() {
		$longLine = '1.' . str_repeat( '1234567890', 100000 ) . "\n";

		$title = Title::makeTitle( NS_MAIN, 'Unit test' );
		$options = ParserOptions::newFromUser( new User() );
		$this->assertEquals( "<p>$longLine</p>",
			$this->parser->parse( $longLine, $title, $options )->getRawText() );
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::braceSubstitution
	 * @covers \MediaWiki\SpecialPage\SpecialPageFactory::capturePath
	 */
	public function testSpecialPageTransclusionRestoresGlobalState() {
		$text = "{{Special:ApiHelp/help}}";
		$title = Title::makeTitle( NS_MAIN, 'TestSpecialPageTransclusionRestoresGlobalState' );
		$options = ParserOptions::newFromUser( new User() );

		RequestContext::getMain()->setTitle( $title );

		$parsed = $this->parser->parse( $text, $title, $options )->getRawText();
		$this->assertStringContainsString( 'apihelp-header', $parsed );
	}

	/**
	 * Test the parser entry points
	 * @covers \MediaWiki\Parser\Parser::parse
	 */
	public function testParse() {
		$title = Title::newFromText( __FUNCTION__ );
		$parserOutput = $this->parser->parse( "Test\n{{Foo}}\n{{Bar}}", $title, $this->options );
		$this->assertEquals(
			"<p>Test\nContent of <i>Template:Foo</i>\nContent of <i>Template:Bar</i>\n</p>",
			$parserOutput->getRawText()
		);
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::preSaveTransform
	 */
	public function testPreSaveTransform() {
		$title = Title::newFromText( __FUNCTION__ );
		$outputText = $this->parser->preSaveTransform(
			"Test\r\n{{subst:Foo}}\n{{Bar}}",
			$title,
			new User(),
			$this->options
		);
		$this->assertEquals( "Test\nContent of ''Template:Foo''\n{{Bar}}", $outputText );

		$outputText = $this->parser->preSaveTransform(
			"hello\n\n{{subst:ns:0}}",
			$title,
			new User(),
			$this->options
		);
		$this->assertEquals( "hello", $outputText,
			"Pre-save transform removes trailing whitespace after substituting templates" );
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::preprocess
	 */
	public function testPreprocess() {
		$title = Title::newFromText( __FUNCTION__ );
		$outputText = $this->parser->preprocess( "Test\n{{Foo}}\n{{Bar}}", $title, $this->options );

		$this->assertEquals(
			"Test\nContent of ''Template:Foo''\nContent of ''Template:Bar''",
			$outputText
		);
	}

	/**
	 * cleanSig() makes all templates substs and removes tildes
	 * @covers \MediaWiki\Parser\Parser::cleanSig
	 */
	public function testCleanSig() {
		$outputText = $this->parser->cleanSig( "{{Foo}} ~~~~" );

		$this->assertEquals( "{{SUBST:Foo}} ", $outputText );
	}

	/**
	 * cleanSig() should do nothing if disabled
	 * @covers \MediaWiki\Parser\Parser::cleanSig
	 */
	public function testCleanSigDisabled() {
		$this->overrideConfigValue( MainConfigNames::CleanSignatures, false );

		$outputText = $this->parser->cleanSig( "{{Foo}} ~~~~" );

		$this->assertEquals( "{{Foo}} ~~~~", $outputText );
	}

	/**
	 * cleanSigInSig() just removes tildes
	 * @dataProvider provideStringsForCleanSigInSig
	 * @covers \MediaWiki\Parser\Parser::cleanSigInSig
	 */
	public function testCleanSigInSig( $in, $out ) {
		$this->assertEquals( Parser::cleanSigInSig( $in ), $out );
	}

	public static function provideStringsForCleanSigInSig() {
		return [
			[ "{{Foo}} ~~~~", "{{Foo}} " ],
			[ "~~~", "" ],
			[ "~~~~~", "" ],
		];
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::getSection
	 */
	public function testGetSection() {
		$outputText2 = $this->parser->getSection(
			"Section 0\n== Heading 1 ==\nSection 1\n=== Heading 2 ===\n"
				. "Section 2\n== Heading 3 ==\nSection 3\n",
			2
		);
		$outputText1 = $this->parser->getSection(
			"Section 0\n== Heading 1 ==\nSection 1\n=== Heading 2 ===\n"
				. "Section 2\n== Heading 3 ==\nSection 3\n",
			1
		);

		$this->assertEquals( "=== Heading 2 ===\nSection 2", $outputText2 );
		$this->assertEquals( "== Heading 1 ==\nSection 1\n=== Heading 2 ===\nSection 2", $outputText1 );
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::replaceSection
	 */
	public function testReplaceSection() {
		$outputText = $this->parser->replaceSection(
			"Section 0\n== Heading 1 ==\nSection 1\n=== Heading 2 ===\n"
				. "Section 2\n== Heading 3 ==\nSection 3\n",
			1,
			"New section 1"
		);

		$this->assertEquals( "Section 0\nNew section 1\n\n== Heading 3 ==\nSection 3", $outputText );
	}

	/**
	 * Templates and comments are not affected, but noinclude/onlyinclude is.
	 * @covers \MediaWiki\Parser\Parser::getPreloadText
	 */
	public function testGetPreloadText() {
		$title = Title::newFromText( __FUNCTION__ );
		$outputText = $this->parser->getPreloadText(
			"{{Foo}}<noinclude> censored</noinclude> information <!-- is very secret -->",
			$title,
			$this->options
		);

		$this->assertEquals( "{{Foo}} information <!-- is very secret -->", $outputText );
	}

	/**
	 * @param Title $title
	 * @param bool $parser
	 *
	 * @return array
	 */
	public static function statelessFetchTemplate( $title, $parser = false ) {
		$text = "Content of ''" . $title->getFullText() . "''";
		$deps = [];

		return [
			'text' => $text,
			'finalTitle' => $title,
			'deps' => $deps ];
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::parse
	 */
	public function testTrackingCategory() {
		$title = Title::newFromText( __FUNCTION__ );
		$catName = wfMessage( 'broken-file-category' )->inContentLanguage()->text();
		$cat = Title::makeTitleSafe( NS_CATEGORY, $catName );
		$expected = [ $cat->getDBkey() ];
		$parserOutput = $this->parser->parse( "[[file:nonexistent]]", $title, $this->options );
		$result = $parserOutput->getCategoryNames();
		$this->assertEquals( $expected, $result );
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::parse
	 */
	public function testTrackingCategorySpecial() {
		// Special pages shouldn't have tracking cats.
		$title = SpecialPage::getTitleFor( 'Contributions' );
		$parserOutput = $this->parser->parse( "[[file:nonexistent]]", $title, $this->options );
		$result = $parserOutput->getCategoryNames();
		$this->assertSame( [], $result );
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::parseLinkParameter
	 * @dataProvider provideParseLinkParameter
	 */
	public function testParseLinkParameter( $input, $expected, $expectedLinks, $desc ) {
		static $testInterwikis = [
			[
				'iw_prefix' => 'local',
				'iw_url' => 'http://doesnt.matter.invalid/$1',
				'iw_api' => '',
				'iw_wikiid' => '',
				'iw_local' => 0
			],
			[
				'iw_prefix' => 'mw',
				'iw_url' => 'https://www.mediawiki.org/wiki/$1',
				'iw_api' => 'https://www.mediawiki.org/w/api.php',
				'iw_wikiid' => '',
				'iw_local' => 0
			]
		];
		$this->overrideConfigValue(
			MainConfigNames::InterwikiCache,
			ClassicInterwikiLookup::buildCdbHash( $testInterwikis )
		);
		Title::clearCaches();
		$this->parser->startExternalParse(
			Title::newFromText( __FUNCTION__ ),
			$this->options,
			Parser::OT_HTML
		);
		$output = TestingAccessWrapper::newFromObject( $this->parser )
			->parseLinkParameter( $input );

		$this->assertEquals( $expected[0], $output[0], "$desc (type)" );

		if ( $expected[0] === 'link-title' ) {
			$this->assertTrue(
				$output[1]->equals( Title::newFromText( $expected[1] ) ),
				"$desc (target); link list title instance matches new title instance"
			);
		} else {
			$this->assertEquals( $expected[1], $output[1], "$desc (target)" );
		}

		foreach ( $expectedLinks as $func => $expected ) {
			$output = $this->parser->getOutput()->$func();
			$this->assertEquals( $expected, $output, "$desc ($func)" );
		}
	}

	public static function provideParseLinkParameter() {
		return [
			[
				'',
				[ 'no-link', false ],
				[],
				'Return no link when requested',
			],
			[
				'https://example.com/',
				[ 'link-url', 'https://example.com/' ],
				[ 'getExternalLinks' => [ 'https://example.com/' => 1 ] ],
				'External link',
			],
			[
				'//example.com/',
				[ 'link-url', '//example.com/' ],
				[ 'getExternalLinks' => [ '//example.com/' => 1 ] ],
				'External link',
			],
			[
				'Test',
				[ 'link-title', 'Test' ],
				[ 'getLinks' => [ 0 => [ 'Test' => 0 ] ] ],
				'Internal link',
			],
			[
				'mw:Test',
				[ 'link-title', 'mw:Test' ],
				[ 'getInterwikiLinks' => [ 'mw' => [ 'Test' => 1 ] ] ],
				'Internal link (interwiki)',
			],
			[
				'https://',
				[ null, false ],
				[],
				'Invalid link target',
			],
			[
				'<>',
				[ null, false ],
				[],
				'Invalid link target',
			],
			[
				' ',
				[ null, false ],
				[],
				'Invalid link target',
			],
		];
	}
}
PK       ! ˢA      parser/StripStateTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use MediaWiki\Parser\Parser;
use MediaWiki\Parser\StripState;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Parser\StripState
 */
class StripStateTest extends MediaWikiIntegrationTestCase {
	protected function setUp(): void {
		parent::setUp();
		$this->setContentLang( 'qqx' );
	}

	private function getMarker() {
		static $i;
		return Parser::MARKER_PREFIX . '-blah-' . sprintf( '%08X', $i++ ) . Parser::MARKER_SUFFIX;
	}

	private static function getWarning( $message, $max = '' ) {
		return "<span class=\"error\">($message: $max)</span>";
	}

	public function testAddNoWiki() {
		$ss = new StripState;
		$marker = $this->getMarker();
		$ss->addNoWiki( $marker, '<>' );
		$text = "x{$marker}y";
		$text = $ss->unstripGeneral( $text );
		$text = str_replace( '<', '', $text );
		$text = $ss->unstripNoWiki( $text );
		$this->assertSame( 'x<>y', $text );
	}

	public function testAddGeneral() {
		$ss = new StripState;
		$marker = $this->getMarker();
		$ss->addGeneral( $marker, '<>' );
		$text = "x{$marker}y";
		$text = $ss->unstripNoWiki( $text );
		$text = str_replace( '<', '', $text );
		$text = $ss->unstripGeneral( $text );
		$this->assertSame( 'x<>y', $text );
	}

	public function testUnstripBoth() {
		$ss = new StripState;
		$mk1 = $this->getMarker();
		$mk2 = $this->getMarker();
		$ss->addNoWiki( $mk1, '<1>' );
		$ss->addGeneral( $mk2, '<2>' );
		$text = "x{$mk1}{$mk2}y";
		$text = str_replace( '<', '', $text );
		$text = $ss->unstripBoth( $text );
		$this->assertSame( 'x<1><2>y', $text );
	}

	public static function provideUnstripRecursive() {
		return [
			[ 0, 'text' ],
			[ 1, '=text=' ],
			[ 2, '==text==' ],
			[ 3, '==' . self::getWarning( 'unstrip-depth-warning', 2 ) . '==' ],
		];
	}

	/** @dataProvider provideUnstripRecursive */
	public function testUnstripRecursive( $depth, $expected ) {
		$ss = new StripState( null, [ 'depthLimit' => 2 ] );
		$text = 'text';
		for ( $i = 0; $i < $depth; $i++ ) {
			$mk = $this->getMarker();
			$ss->addNoWiki( $mk, "={$text}=" );
			$text = $mk;
		}
		$text = $ss->unstripNoWiki( $text );
		$this->assertSame( $expected, $text );
	}

	public function testUnstripLoop() {
		$ss = new StripState( null, [ 'depthLimit' => 2 ] );
		$mk = $this->getMarker();
		$ss->addNoWiki( $mk, $mk );
		$text = $ss->unstripNoWiki( $mk );
		$this->assertSame( self::getWarning( 'parser-unstrip-loop-warning' ), $text );
	}

	public static function provideUnstripSize() {
		return [
			[ 0, 'x' ],
			[ 1, 'xx' ],
			[ 2, str_repeat( self::getWarning( 'unstrip-size-warning', 5 ), 2 ) ]
		];
	}

	/** @dataProvider provideUnstripSize */
	public function testUnstripSize( $depth, $expected ) {
		$ss = new StripState( null, [ 'sizeLimit' => 5 ] );
		$text = 'x';
		for ( $i = 0; $i < $depth; $i++ ) {
			$mk = $this->getMarker();
			$ss->addNoWiki( $mk, $text );
			$text = "$mk$mk";
		}
		$text = $ss->unstripNoWiki( $text );
		$this->assertSame( $expected, $text );
	}

	public static function provideGetLimitReport() {
		for ( $i = 1; $i < 4; $i++ ) {
			yield [ $i ];
		}
	}

	/** @dataProvider provideGetLimitReport */
	public function testGetLimitReport( $depth ) {
		$sizeLimit = 100000;
		$ss = new StripState( null, [ 'depthLimit' => 5, 'sizeLimit' => $sizeLimit ] );
		$text = 'x';
		for ( $i = 0; $i < $depth; $i++ ) {
			$mk = $this->getMarker();
			$ss->addNoWiki( $mk, $text );
			$text = "$mk$mk";
		}
		$text = $ss->unstripNoWiki( $text );
		$report = $ss->getLimitReport();
		$messages = [];
		foreach ( $report as [ $msg, $params ] ) {
			$messages[$msg] = $params;
		}
		$this->assertSame( [ $depth - 1, 5 ], $messages['limitreport-unstrip-depth'] );
		$this->assertSame(
			[
				strlen( $this->getMarker() ) * 2 * ( pow( 2, $depth ) - 2 ) + pow( 2, $depth ),
				$sizeLimit
			],
			$messages['limitreport-unstrip-size' ] );
	}

	public function testReplaceNowikis() {
		$ss = new StripState();

		// Note that unlike other uses of addNowiki, these add the original source
		// with the nowiki wrappers. When wikitext is being processed, the parser
		// uses strip markers in this fashion.
		$s1 = "[[Foo]]";
		$nowikiS1 = "<nowiki>$s1</nowiki>";
		$m1 = $this->getMarker();
		$ss->addNoWiki( $m1, $nowikiS1 );

		$s2 = "[[Foo]]";
		$nowikiS2 = "<nowiki>$s2</nowiki>";
		$m2 = $this->getMarker();
		$ss->addNoWiki( $m2, $nowikiS2 );

		$s3 = "";
		$nowikiS3 = "<nowiki />";
		$m3 = $this->getMarker();
		$ss->addNoWiki( $m3, $nowikiS3 );

		$text = "$s1; $s2; $s3";
		$strippedText = "$m1; $m2; $m3";
		$unstrippedText = "$nowikiS1; $nowikiS2; $nowikiS3";

		$this->assertSame( $ss->unstripGeneral( $strippedText ), $strippedText );
		$this->assertSame( $ss->unstripNoWiki( $strippedText ), $unstrippedText );

		$out1 = $ss->replaceNoWikis( $strippedText, static function ( $s ) {
			return $s;
		} );
		$this->assertSame( $out1, $unstrippedText );

		// Simulate Scribunto lua modules use of unstripNowiki
		$out2 = $ss->replaceNoWikis( $strippedText, static function ( $s ) {
			return preg_replace( "#</?nowiki[^>]*>#", '', $s );
		} );
		$this->assertSame( $out2, $text );
	}
}
PK       !     6  parser/BeforeParserFetchTemplateRevisionRecordTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use MediaWiki\Content\WikitextContent;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\Parser\Parser;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWikiLangTestCase;
use MockTitleTrait;

/**
 * @group Database
 * @covers \MediaWiki\Parser\Parser
 */
class BeforeParserFetchTemplateRevisionRecordTest extends MediaWikiLangTestCase {
	use MockTitleTrait;

	private function checkResult( $expected, $actual ) {
		if ( ( $expected['revision-record'] ?? true ) === false ) {
			$this->assertSame( false, $actual['revision-record'] );
		} else {
			$this->assertNotNull( $actual['revision-record'] );
		}
		$this->assertSame( $expected['text'], $actual['text'] );
		$this->assertSame( $expected['finalTitle'], $actual['finalTitle']->getPrefixedText() );
		// Simplify 'deps'
		$simpleActualDeps = array_map(
			fn ( $dep ) => $dep['title']->getPrefixedText(),
			$actual['deps']
		);
		$this->assertArrayEquals( $expected['deps'], $simpleActualDeps );
	}

	public static function provideWithParser() {
		yield "Without \$parser" => [ false ];
		yield "With \$parser" => [ true ];
	}

	private function commonSetup( $suffix = null ) {
		$suffix ??= $this->getCallerName();
		$parser = $this->getServiceContainer()->getParserFactory()->create();
		$parser->setOptions( ParserOptions::newFromAnon() );

		$page = $this->getNonexistingTestPage( "Base $suffix" );
		$this->editPage( $page, 'Base page content', 'Make testing content' );

		$redirectPage = $this->getNonexistingTestPage( "Redirect $suffix" );
		$this->editPage(
			$redirectPage,
			'#REDIRECT [[' . $page->getTitle()->getPrefixedText() . ']]',
			"Make redirect link for testing"
		);

		return [ $parser, $page, $redirectPage ];
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::statelessFetchTemplate
	 * @dataProvider provideWithParser
	 */
	public function testStatelessFetchTemplateBasic( bool $withParser ) {
		[ $parser, $page, $redirectPage ] = $this->commonSetup( __FUNCTION__ );

		// Basic redirect test
		$ret = Parser::statelessFetchTemplate(
			$redirectPage->getTitle(), $withParser ? $parser : null
		);
		$this->checkResult( [
			'text' => 'Base page content',
			'finalTitle' => 'Base ' . __FUNCTION__,
			'deps' => [
				'Base ' . __FUNCTION__,
				'Redirect ' . __FUNCTION__,
			],
		], $ret );
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::statelessFetchTemplate
	 * @dataProvider provideWithParser
	 */
	public function testStatelessFetchTemplateSkip( bool $withParser ) {
		[ $parser, $page, $redirectPage ] = $this->commonSetup( __FUNCTION__ );

		// Create a hook to prevent resolution of the redirect
		$this->setTemporaryHook(
			'BeforeParserFetchTemplateRevisionRecord',
			static function ( ?LinkTarget $contextTitle, LinkTarget $title, bool &$skip, ?RevisionRecord &$revRecord ) {
				$skip = true;
			}
		);

		$ret = Parser::statelessFetchTemplate(
			$redirectPage->getTitle(), $withParser ? $parser : null
		);
		$this->checkResult( [
			'text' => false,
			'finalTitle' => 'Redirect ' . __FUNCTION__,
			'deps' => [
				'Redirect ' . __FUNCTION__,
			],
		], $ret );
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::statelessFetchTemplate
	 * @dataProvider provideWithParser
	 */
	public function testStatelessFetchTemplateMissing( bool $withParser ) {
		[ $parser, $page, $redirectPage ] = $this->commonSetup( __FUNCTION__ );

		// Create a hook to redirect to a non-existing page
		$baseTitle = 'Base ' . __FUNCTION__;
		$missing = $this->getNonexistingTestPage( 'Missing ' . __FUNCTION__ );
		$this->setTemporaryHook(
			'BeforeParserFetchTemplateRevisionRecord',
			static function ( ?LinkTarget $contextTitle, LinkTarget $title, bool &$skip, ?RevisionRecord &$revRecord ) use ( $baseTitle, $missing ) {
				if ( $title->getPrefixedText() === $baseTitle ) {
					$revRecord = new MutableRevisionRecord( $missing->getTitle() );
				}
			}
		);
		$ret = Parser::statelessFetchTemplate(
			$redirectPage->getTitle(), $withParser ? $parser : null
		);
		$this->checkResult( [
			'text' => false,
			'finalTitle' => 'Missing ' . __FUNCTION__,
			'deps' => [
				'Base ' . __FUNCTION__,
				# The $missing page is duplicated in the deps here, but that's
				# harmless.
				'Missing ' . __FUNCTION__,
				'Missing ' . __FUNCTION__,
				'Redirect ' . __FUNCTION__,
			],
		], $ret );
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::statelessFetchTemplate
	 * @dataProvider provideWithParser
	 */
	public function testStatelessFetchTemplateSubstituted( bool $withParser ) {
		[ $parser, $page, $redirectPage ] = $this->commonSetup( __FUNCTION__ );

		// Create a hook to redirect to a non-existing page
		$baseTitle = 'Base ' . __FUNCTION__;
		$subst = $this->getNonexistingTestPage( 'Subst ' . __FUNCTION__ );
		$this->setTemporaryHook(
			'BeforeParserFetchTemplateRevisionRecord',
			static function ( ?LinkTarget $contextTitle, LinkTarget $title, bool &$skip, ?RevisionRecord &$revRecord ) use ( $baseTitle, $subst ) {
				if ( $title->getPrefixedText() === $baseTitle ) {
					$revRecord = new MutableRevisionRecord( $subst->getTitle() );
					$revRecord->setContent( SlotRecord::MAIN, new WikitextContent( 'foo' ) );
				}
			}
		);
		$ret = Parser::statelessFetchTemplate(
			$redirectPage->getTitle(), $withParser ? $parser : null
		);
		$this->checkResult( [
			'text' => 'foo',
			'finalTitle' => 'Subst ' . __FUNCTION__,
			'deps' => [
				'Base ' . __FUNCTION__,
				'Subst ' . __FUNCTION__,
				'Redirect ' . __FUNCTION__,
			],
		], $ret );
	}
}
PK       ! ).  .    parser/SanitizerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use InvalidArgumentException;
use MediaWiki\MainConfigNames;
use MediaWiki\Parser\Sanitizer;
use MediaWikiIntegrationTestCase;
use UnexpectedValueException;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Sanitizer
 */
class SanitizerTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers \MediaWiki\Parser\Sanitizer::internalRemoveHTMLtags
	 * @dataProvider provideHtml5Tags
	 *
	 * @param string $tag Name of an HTML5 element (ie: 'video')
	 * @param bool $escaped Whether sanitizer let the tag in or escape it (ie: '&lt;video&gt;')
	 */
	public function testInternalRemoveHtmlTagsOnHtml5Tags( $tag, $escaped ) {
		if ( $escaped ) {
			$this->assertEquals( "&lt;$tag&gt;",
				Sanitizer::internalRemoveHtmlTags( "<$tag>" )
			);
		} else {
			$this->assertEquals( "<$tag></$tag>\n",
				Sanitizer::internalRemoveHtmlTags( "<$tag></$tag>\n" )
			);
		}
	}

	/**
	 * @covers \MediaWiki\Parser\Sanitizer::removeSomeTags
	 * @dataProvider provideHtml5Tags
	 *
	 * @param string $tag Name of an HTML5 element (ie: 'video')
	 * @param bool $escaped Whether sanitizer let the tag in or escape it (ie: '&lt;video&gt;')
	 */
	public function testRemoveSomeTagsOnHtml5Tags( $tag, $escaped ) {
		if ( $escaped ) {
			$this->assertEquals( "&lt;$tag&gt;",
				Sanitizer::removeSomeTags( "<$tag>" )
			);
		} else {
			$this->assertEquals( "<$tag></$tag>\n",
				Sanitizer::removeSomeTags( "<$tag></$tag>\n" )
			);
			$this->assertEquals( "<$tag></$tag>",
				Sanitizer::removeSomeTags( "<$tag>" )
			);
		}
	}

	public static function provideHtml5Tags() {
		$ESCAPED = true; # We want tag to be escaped
		$VERBATIM = false; # We want to keep the tag
		return [
			[ 'data', $VERBATIM ],
			[ 'mark', $VERBATIM ],
			[ 'time', $VERBATIM ],
			[ 'video', $ESCAPED ],
		];
	}

	public function dataRemoveHTMLtags() {
		return [
			// former testSelfClosingTag
			[
				'<div>Hello world</div />',
				'<div>Hello world</div>',
				'Self-closing closing div'
			],
			// Make sure special nested HTML5 semantics are not broken
			// https://html.spec.whatwg.org/multipage/semantics.html#the-kbd-element
			[
				'<kbd><kbd>Shift</kbd>+<kbd>F3</kbd></kbd>',
				'<kbd><kbd>Shift</kbd>+<kbd>F3</kbd></kbd>',
				'Nested <kbd>.'
			],
			// https://html.spec.whatwg.org/multipage/semantics.html#the-sub-and-sup-elements
			[
				'<var>x<sub><var>i</var></sub></var>, <var>y<sub><var>i</var></sub></var>',
				'<var>x<sub><var>i</var></sub></var>, <var>y<sub><var>i</var></sub></var>',
				'Nested <var>.'
			],
			// https://html.spec.whatwg.org/multipage/semantics.html#the-dfn-element
			[
				'<dfn><abbr title="Garage Door Opener">GDO</abbr></dfn>',
				'<dfn><abbr title="Garage Door Opener">GDO</abbr></dfn>',
				'<abbr> inside <dfn>',
			],
		];
	}

	/**
	 * @dataProvider dataRemoveHTMLtags
	 * @covers \MediaWiki\Parser\Sanitizer::internalRemoveHtmlTags
	 */
	public function testInternalRemoveHTMLtags( $input, $output, $msg = null ) {
		$this->assertEquals( $output, Sanitizer::internalRemoveHtmlTags( $input ), $msg );
	}

	/**
	 * @dataProvider dataRemoveHTMLtags
	 * @covers \MediaWiki\Parser\Sanitizer::removeSomeTags
	 */
	public function testRemoveSomeTags( $input, $output, $msg = null ) {
		$this->assertEquals( $output, Sanitizer::removeSomeTags( $input ), $msg );
	}

	/**
	 * @dataProvider provideDeprecatedAttributes
	 * @covers \MediaWiki\Parser\Sanitizer::fixTagAttributes
	 * @covers \MediaWiki\Parser\Sanitizer::validateTagAttributes
	 * @covers \MediaWiki\Parser\Sanitizer::validateAttributes
	 */
	public function testDeprecatedAttributesUnaltered( $inputAttr, $inputEl, $message = '' ) {
		$this->assertEquals( " $inputAttr",
			Sanitizer::fixTagAttributes( $inputAttr, $inputEl ),
			$message
		);
	}

	public static function provideDeprecatedAttributes() {
		/** [ <attribute>, <element>, [message] ] */
		return [
			[ 'clear="left"', 'br' ],
			[ 'clear="all"', 'br' ],
			[ 'width="100"', 'td' ],
			[ 'nowrap="true"', 'td' ],
			[ 'nowrap=""', 'td' ],
			[ 'align="right"', 'td' ],
			[ 'align="center"', 'table' ],
			[ 'align="left"', 'tr' ],
			[ 'align="center"', 'div' ],
			[ 'align="left"', 'h1' ],
			[ 'align="left"', 'p' ],
		];
	}

	/**
	 * @dataProvider provideValidateTagAttributes
	 * @covers \MediaWiki\Parser\Sanitizer::validateTagAttributes
	 * @covers \MediaWiki\Parser\Sanitizer::validateAttributes
	 */
	public function testValidateTagAttributes( $element, $attribs, $expected ) {
		$actual = Sanitizer::validateTagAttributes( $attribs, $element );
		$this->assertArrayEquals( $expected, $actual, false, true );
	}

	public static function provideValidateTagAttributes() {
		return [
			[ 'math',
				[ 'id' => 'foo bar', 'bogus' => 'stripped', 'data-foo' => 'bar' ],
				[ 'id' => 'foo_bar', 'data-foo' => 'bar' ],
			],
			[ 'meta',
				[ 'id' => 'foo bar', 'itemprop' => 'foo', 'content' => 'bar' ],
				[ 'itemprop' => 'foo', 'content' => 'bar' ],
			],
			[ 'div',
				[ 'role' => 'presentation', 'aria-hidden' => 'true' ],
				[ 'role' => 'presentation', 'aria-hidden' => 'true' ],
			],
			[ 'div',
				[ 'role' => 'menuitem', 'aria-hidden' => 'false' ],
				[ 'role' => 'menuitem', 'aria-hidden' => 'false' ],
			],
		];
	}

	/**
	 * @dataProvider provideAttributesAllowed
	 * @covers \MediaWiki\Parser\Sanitizer::attributesAllowedInternal
	 */
	public function testAttributesAllowedInternal( $element, $attribs ) {
		$sanitizer = TestingAccessWrapper::newFromClass( Sanitizer::class );
		$actual = $sanitizer->attributesAllowedInternal( $element );
		$this->assertArrayEquals( $attribs, array_keys( $actual ) );
	}

	public static function provideAttributesAllowed() {
		/** [ <element>, [ <good attribute 1>, <good attribute 2>, ...] ] */
		return [
			[ 'math', [ 'class', 'style', 'id', 'title' ] ],
			[ 'meta', [ 'itemprop', 'content' ] ],
			[ 'link', [ 'itemprop', 'href', 'title' ] ],
		];
	}

	/**
	 * @dataProvider provideEscapeIdForStuff
	 *
	 * @covers \MediaWiki\Parser\Sanitizer::escapeIdForAttribute()
	 * @covers \MediaWiki\Parser\Sanitizer::escapeIdForLink()
	 * @covers \MediaWiki\Parser\Sanitizer::escapeIdForExternalInterwiki()
	 * @covers \MediaWiki\Parser\Sanitizer::escapeIdInternal()
	 * @covers \MediaWiki\Parser\Sanitizer::escapeIdInternalUrl()
	 *
	 * @param string $stuff
	 * @param string[] $config
	 * @param string $id
	 * @param string|false $expected
	 * @param int|null $mode
	 */
	public function testEscapeIdForStuff( $stuff, array $config, $id, $expected, $mode = null ) {
		$func = "Sanitizer::escapeIdFor{$stuff}";
		$iwFlavor = array_pop( $config );
		$this->overrideConfigValues( [
			MainConfigNames::FragmentMode => $config,
			MainConfigNames::ExternalInterwikiFragmentMode => $iwFlavor,
		] );
		$escaped = $func( $id, $mode );
		self::assertEquals( $expected, $escaped );
	}

	public static function provideEscapeIdForStuff() {
		// Test inputs and outputs
		$text = 'foo тест_#%!\'()[]:<>&&amp;&amp;amp;%F0';
		$legacyEncoded = 'foo_.D1.82.D0.B5.D1.81.D1.82_.23.25.21.27.28.29.5B.5D:.3C.3E' .
			'.26.26amp.3B.26amp.3Bamp.3B.25F0';
		$html5EncodedId = 'foo_тест_#%!\'()[]:<>&&amp;&amp;amp;%F0';
		$html5EncodedHref = 'foo_тест_#%!\'()[]:<>&&amp;&amp;amp;%25F0';

		// Settings: last element is $wgExternalInterwikiFragmentMode, the rest is $wgFragmentMode
		$legacy = [ 'legacy', 'legacy' ];
		$legacyNew = [ 'legacy', 'html5', 'legacy' ];
		$newLegacy = [ 'html5', 'legacy', 'legacy' ];
		$new = [ 'html5', 'legacy' ];
		$allNew = [ 'html5', 'html5' ];

		return [
			// Pure legacy: how MW worked before 2017
			[ 'Attribute', $legacy, $text, $legacyEncoded, Sanitizer::ID_PRIMARY ],
			[ 'Attribute', $legacy, $text, false, Sanitizer::ID_FALLBACK ],
			[ 'Link', $legacy, $text, $legacyEncoded ],
			[ 'ExternalInterwiki', $legacy, $text, $legacyEncoded ],

			// Transition to a new world: legacy links with HTML5 fallback
			[ 'Attribute', $legacyNew, $text, $legacyEncoded, Sanitizer::ID_PRIMARY ],
			[ 'Attribute', $legacyNew, $text, $html5EncodedId, Sanitizer::ID_FALLBACK ],
			[ 'Link', $legacyNew, $text, $legacyEncoded ],
			[ 'ExternalInterwiki', $legacyNew, $text, $legacyEncoded ],

			// New world: HTML5 links, legacy fallbacks
			[ 'Attribute', $newLegacy, $text, $html5EncodedId, Sanitizer::ID_PRIMARY ],
			[ 'Attribute', $newLegacy, $text, $legacyEncoded, Sanitizer::ID_FALLBACK ],
			[ 'Link', $newLegacy, $text, $html5EncodedHref ],
			[ 'ExternalInterwiki', $newLegacy, $text, $legacyEncoded ],

			// Distant future: no legacy fallbacks, but still linking to leagacy wikis
			[ 'Attribute', $new, $text, $html5EncodedId, Sanitizer::ID_PRIMARY ],
			[ 'Attribute', $new, $text, false, Sanitizer::ID_FALLBACK ],
			[ 'Link', $new, $text, $html5EncodedHref ],
			[ 'ExternalInterwiki', $new, $text, $legacyEncoded ],

			// Just before the heat death of universe: external interwikis are also HTML5 \m/
			[ 'Attribute', $allNew, $text, $html5EncodedId, Sanitizer::ID_PRIMARY ],
			[ 'Attribute', $allNew, $text, false, Sanitizer::ID_FALLBACK ],
			[ 'Link', $allNew, $text, $html5EncodedHref ],
			[ 'ExternalInterwiki', $allNew, $text, $html5EncodedHref ],

			// Whitespace
			[ 'attribute', $allNew, "foo bar", 'foo_bar', Sanitizer::ID_PRIMARY ],
			[ 'attribute', $allNew, "foo\fbar", 'foo_bar', Sanitizer::ID_PRIMARY ],
			[ 'attribute', $allNew, "foo\nbar", 'foo_bar', Sanitizer::ID_PRIMARY ],
			[ 'attribute', $allNew, "foo\tbar", 'foo_bar', Sanitizer::ID_PRIMARY ],
			[ 'attribute', $allNew, "foo\rbar", 'foo_bar', Sanitizer::ID_PRIMARY ],
		];
	}

	/**
	 * @covers \MediaWiki\Parser\Sanitizer::escapeIdInternal()
	 */
	public function testInvalidFragmentThrows() {
		$this->overrideConfigValue( MainConfigNames::FragmentMode, [ 'boom!' ] );
		$this->expectException( InvalidArgumentException::class );
		Sanitizer::escapeIdForAttribute( 'This should throw' );
	}

	/**
	 * @covers \MediaWiki\Parser\Sanitizer::escapeIdForAttribute()
	 */
	public function testNoPrimaryFragmentModeThrows() {
		$this->overrideConfigValue( MainConfigNames::FragmentMode, [ 666 => 'html5' ] );
		$this->expectException( UnexpectedValueException::class );
		Sanitizer::escapeIdForAttribute( 'This should throw' );
	}

	/**
	 * @covers \MediaWiki\Parser\Sanitizer::escapeIdForLink()
	 */
	public function testNoPrimaryFragmentModeThrows2() {
		$this->overrideConfigValue( MainConfigNames::FragmentMode, [ 666 => 'html5' ] );
		$this->expectException( UnexpectedValueException::class );
		Sanitizer::escapeIdForLink( 'This should throw' );
	}

	/**
	 * Test escapeIdReferenceListInternal for consistency with escapeIdForAttribute
	 *
	 * @dataProvider provideEscapeIdReferenceListInternal
	 * @covers \MediaWiki\Parser\Sanitizer::escapeIdReferenceListInternal
	 */
	public function testEscapeIdReferenceListInternal( $referenceList, $id1, $id2 ) {
		$sanitizer = TestingAccessWrapper::newFromClass( Sanitizer::class );
		$actual = $sanitizer->escapeIdReferenceListInternal( $referenceList );

		$this->assertEquals(
			$actual,
			Sanitizer::escapeIdForAttribute( $id1 )
			. ' '
			. Sanitizer::escapeIdForAttribute( $id2 )
		);
	}

	public static function provideEscapeIdReferenceListInternal() {
		/** [ <reference list>, <individual id 1>, <individual id 2> ] */
		return [
			[ 'foo bar', 'foo', 'bar' ],
			[ '#1 #2', '#1', '#2' ],
			[ '+1 +2', '+1', '+2' ],
		];
	}

	/**
	 * Test cleanUrl
	 *
	 * @dataProvider provideCleanUrl
	 * @covers \MediaWiki\Parser\Sanitizer::cleanUrl
	 */
	public function testCleanUrl( string $input, string $output ) {
		$this->assertEquals( $output, Sanitizer::cleanUrl( $input ) );
	}

	public static function provideCleanUrl() {
		return [
			[ 'http://www.example.com/file.txt', 'http://www.example.com/file.txt' ],
			[
				"https://www.exa\u{00AD}\u{200B}\u{2060}\u{FEFF}" .
				"\u{034F}\u{180B}\u{180C}\u{180D}\u{200C}\u{200D}" .
				"\u{FE00}\u{FE08}\u{FE0F}mple.com",
				'https://www.example.com'
			],
		];
	}

}
PK       ! XR	  R	    parser/MagicWordFactoryTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use MediaWiki\Language\Language;
use MediaWiki\Parser\MagicWord;
use MediaWiki\Parser\MagicWordArray;
use MediaWiki\Parser\MagicWordFactory;
use MediaWikiIntegrationTestCase;
use UnexpectedValueException;

/**
 * @covers \MediaWiki\Parser\MagicWordFactory
 * @covers \MediaWiki\Parser\MagicWord
 *
 * @author Derick N. Alangi
 */
class MagicWordFactoryTest extends MediaWikiIntegrationTestCase {
	private function makeMagicWordFactory( ?Language $contLang = null ) {
		$services = $this->getServiceContainer();
		return new MagicWordFactory( $contLang ?:
			$services->getLanguageFactory()->getLanguage( 'en' ),
			$services->getHookContainer()
		);
	}

	public function testGetContentLanguage() {
		$contLang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );

		$magicWordFactory = $this->makeMagicWordFactory( $contLang );
		$magicWordContLang = $magicWordFactory->getContentLanguage();

		$this->assertSame( $contLang, $magicWordContLang );
	}

	public function testGetMagicWord() {
		$magicWordIdValid = 'pageid';
		$magicWordFactory = $this->makeMagicWordFactory();
		$mwActual = $magicWordFactory->get( $magicWordIdValid );
		$contLang = $magicWordFactory->getContentLanguage();
		$expected = new MagicWord( $magicWordIdValid, [ 'PAGEID' ], false, $contLang );

		$this->assertEquals( $expected, $mwActual );
	}

	public function testGetInvalidMagicWord() {
		$magicWordFactory = $this->makeMagicWordFactory();

		$this->expectException( UnexpectedValueException::class );
		@$magicWordFactory->get( 'invalid magic word' );
	}

	public function testGetVariableIDs() {
		$magicWordFactory = $this->makeMagicWordFactory();
		$varIds = $magicWordFactory->getVariableIDs();

		$this->assertIsArray( $varIds );
		$this->assertNotEmpty( $varIds );
		$this->assertContainsOnly( 'string', $varIds );
	}

	public function testGetSubstArray() {
		$magicWordFactory = $this->makeMagicWordFactory();
		$substArray = $magicWordFactory->getSubstArray();

		$text = 'SafeSubst:x';
		$this->assertSame( 'safesubst', $substArray->matchStartAndRemove( $text ) );
		$this->assertSame( 'x', $text );
	}

	public function testGetDoubleUnderscoreArray() {
		$magicWordFactory = $this->makeMagicWordFactory();
		$actual = $magicWordFactory->getDoubleUnderscoreArray();

		$this->assertInstanceOf( MagicWordArray::class, $actual );
	}
}
PK       ! _'    3  parser/validateParserCacheSerializationTestData.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use MediaWiki\Logger\ConsoleLogger;
use MediaWiki\Maintenance\Maintenance;
use Wikimedia\Tests\SerializationTestUtils;

define( 'MW_AUTOLOAD_TEST_CLASSES', true );
define( 'MW_PHPUNIT_TEST', true );

require_once __DIR__ . '/../../../../maintenance/Maintenance.php';

// phpcs:disable MediaWiki.Files.ClassMatchesFilename.WrongCase
class ValidateParserCacheSerializationTestData extends Maintenance {

	public function __construct() {
		parent::__construct();

		$this->addArg(
			'path',
			'Path of serialization files.',
			false
		);
		$this->addOption( 'create', 'Create missing serialization' );
		$this->addOption( 'update', 'Update mismatching serialization files' );
		$this->addOption( 'version', 'Specify version for which to check serialization. '
			. 'Also determines which files may be created or updated if '
			. 'the respective options are set.'
			. 'Unserialization is always checked against all versions. ', false, true );
	}

	public function execute() {
		$testClasses = [ CacheTimeTest::class, ParserOutputTest::class ];
		foreach ( $testClasses as $testClass ) {
			$this->validateSerialization( $testClass,
				array_map( static function ( $testCase ) {
					return $testCase['instance'];
				}, $testClass::getTestInstancesAndAssertions() ) );
		}
	}

	/**
	 * Ensures that objects will serialize into the form expected for the given version.
	 * If the respective options are set in the constructor, this will create missing files or
	 * update mismatching files.
	 *
	 * @param string $testClassName
	 * @param array $testInstances
	 */
	public function validateSerialization( string $testClassName, array $testInstances ) {
		$className = $testClassName::getClassToTest();
		$supportedFormats = $testClassName::getSupportedSerializationFormats();
		$ok = true;
		foreach ( $supportedFormats as $serializationFormat ) {
			$serializationUtils = new SerializationTestUtils(
				$this->getArg( 1 ) ?: $testClassName::getSerializedDataPath(),
				$testInstances,
				$serializationFormat['ext'],
				$serializationFormat['serializer'],
				$serializationFormat['deserializer']
			);
			$serializationUtils->setLogger( new ConsoleLogger( 'validator' ) );
			foreach ( $serializationUtils->getSerializedInstances() as $testCaseName => $currentSerialized ) {
				$expected = $serializationUtils
					->getStoredSerializedInstance( $className, $testCaseName, $this->getOption( 'version' ) );
				$ok = $this->validateSerializationData( $currentSerialized, $expected ) && $ok;
			}
		}
		if ( !$ok ) {
			$this->output( "\n\n" );
			$this->fatalError( "Serialization data mismatch! "
				. "If this was expected, rerun the script with the --update option "
				. "to update the expected serialization. WARNING: make sure "
				. "a forward compatible version of the code is live before deploying a "
				. "serialization change!\n"
			);
		}
	}

	private function validateSerializationData( $data, $fileInfo ): bool {
		if ( !$fileInfo->data ) {
			if ( $this->hasOption( 'create' ) ) {
				$this->output( 'Creating file: ' . $fileInfo->path . "\n" );
				file_put_contents( $fileInfo->path, $data );
			} else {
				$this->fatalError( "File not found: {$fileInfo->path}. "
					. "Rerun the script with the --create option set to create it."
				);
			}
		} else {
			if ( $data !== $fileInfo->data ) {
				if ( $this->hasOption( 'update' ) ) {
					$this->output( 'Data mismatch, updating file: ' . $fileInfo->currentVersionPath . "\n" );
					file_put_contents( $fileInfo->currentVersionPath, $data );
				} else {
					$this->output( 'Serialization MISMATCH: ' . $fileInfo->path . "\n" );
					return false;
				}
			} else {
				$this->output( "Serialization OK: " . $fileInfo->path . "\n" );
			}
		}
		return true;
	}
}

return ValidateParserCacheSerializationTestData::class;
PK       ! g    )  parser/LinkHolderArrayIntegrationTest.phpnu Iw        <?php

declare( strict_types = 1 );

namespace MediaWiki\Tests\Parser;

use MediaWiki\Language\ILanguageConverter;
use MediaWiki\MainConfigNames;
use MediaWiki\Parser\LinkHolderArray;
use MediaWiki\Parser\Parser;
use MediaWiki\Title\Title;
use MediaWikiLangTestCase;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Parser\LinkHolderArray
 */
class LinkHolderArrayIntegrationTest extends MediaWikiLangTestCase {

	/**
	 * @dataProvider provideIsBig
	 * @covers \MediaWiki\Parser\LinkHolderArray::isBig
	 *
	 * @param int $size
	 * @param int $global
	 * @param bool $expected
	 */
	public function testIsBig( int $size, int $global, bool $expected ) {
		$this->overrideConfigValue( MainConfigNames::LinkHolderBatchSize, $global );
		$linkHolderArray = new LinkHolderArray(
			$this->createMock( Parser::class ),
			$this->createMock( ILanguageConverter::class ),
			$this->createHookContainer()
		);
		/** @var LinkHolderArray $linkHolderArray */
		$linkHolderArray = TestingAccessWrapper::newFromObject( $linkHolderArray );
		$linkHolderArray->size = $size;

		$this->assertSame( $expected, $linkHolderArray->isBig() );
	}

	public static function provideIsBig() {
		yield [ 0, 0, false ];
		yield [ 0, 1, false ];
		yield [ 1, 0, true ];
		yield [ 1, 1, false ];
	}

	/**
	 * @dataProvider provideMakeHolder_withNsText
	 * @covers \MediaWiki\Parser\LinkHolderArray::makeHolder
	 *
	 * @param bool $isExternal
	 * @param string $expected
	 */
	public function testMakeHolder_withNsText(
		bool $isExternal,
		string $expected
	) {
		$link = new LinkHolderArray(
			$this->createMock( Parser::class ),
			$this->createMock( ILanguageConverter::class ),
			$this->createHookContainer()
		);
		/** @var LinkHolderArray $link */
		$link = TestingAccessWrapper::newFromObject( $link );
		$parser = $this->createMock( Parser::class );
		$parser->method( 'nextLinkID' )->willReturn( 9 );
		$link->parent = $parser;
		$title = $this->createMock( Title::class );
		$title->method( 'getPrefixedDBkey' )->willReturn( 'Talk:Dummy' );
		$title->method( 'getNamespace' )->willReturn( 1234 );
		$title->method( 'isExternal' )->willReturn( $isExternal );

		$this->assertSame( 0, $link->size );
		$result = $link->makeHolder(
			$title,
			'test1 text',
			'test2 trail',
			'test3 prefix'
		);
		$this->assertSame( $expected, $result );
		$this->assertSame( 1, $link->size );

		if ( $isExternal ) {
			$this->assertArrayEquals(
				[
					9 => [
						'title' => $title,
						'text' => 'test3 prefixtest1 texttest',
						'pdbk' => 'Talk:Dummy',
					],
				],
				$link->interwikis
			);
			$this->assertCount( 0, $link->internals );
		} else {
			$this->assertArrayEquals(
				[
					1234 => [
						9 => [
							'title' => $title,
							'text' => 'test3 prefixtest1 texttest',
							'pdbk' => 'Talk:Dummy',
						],
					],
				],
				$link->internals
			);
			$this->assertCount( 0, $link->interwikis );
		}
	}

	public static function provideMakeHolder_withNsText() {
		yield [
			false,
			'<!--LINK\'" 1234:9-->2 trail',
		];
		yield [
			true,
			'<!--IWLINK\'" 9-->2 trail',
		];
	}
}
PK       ! @k|S  S    parser/PreprocessorTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use MediaWiki\Parser\Parser;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\Preprocessor;
use MediaWiki\Parser\Preprocessor_Hash;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;

/**
 * @covers \MediaWiki\Parser\Preprocessor
 *
 * @covers \MediaWiki\Parser\Preprocessor_Hash
 * @covers \MediaWiki\Parser\PPDStack_Hash
 * @covers \MediaWiki\Parser\PPDStackElement_Hash
 * @covers \MediaWiki\Parser\PPDPart_Hash
 * @covers \MediaWiki\Parser\PPFrame_Hash
 * @covers \MediaWiki\Parser\PPTemplateFrame_Hash
 * @covers \MediaWiki\Parser\PPCustomFrame_Hash
 * @covers \MediaWiki\Parser\PPNode_Hash_Tree
 * @covers \MediaWiki\Parser\PPNode_Hash_Text
 * @covers \MediaWiki\Parser\PPNode_Hash_Array
 * @covers \MediaWiki\Parser\PPNode_Hash_Attr
 */
class PreprocessorTest extends MediaWikiIntegrationTestCase {
	/** @var ParserOptions */
	protected $mOptions;
	/** @var Preprocessor */
	protected $preprocessor;

	protected function setUp(): void {
		parent::setUp();
		$this->mOptions = ParserOptions::newFromUserAndLang( new User,
			$this->getServiceContainer()->getContentLanguage() );

		$wanCache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
		$parser = $this->createMock( Parser::class );
		$parser->method( 'getStripList' )->willReturn( [
			'gallery', 'display map' /* Used by Maps, see r80025 CR */, '/foo'
		] );

		$this->preprocessor = new Preprocessor_Hash(
			$parser,
			$wanCache,
			[ 'cacheThreshold' => 1000 ]
		);
	}

	public static function provideCases() {
		return [
			[ "Foo", "<root>Foo</root>" ],
			[ "<!-- Foo -->", "<root><comment>&lt;!-- Foo --&gt;</comment></root>" ],
			[ "<!-- Foo --><!-- Bar -->", "<root><comment>&lt;!-- Foo --&gt;</comment><comment>&lt;!-- Bar --&gt;</comment></root>" ],
			[ "<!-- Foo -->  <!-- Bar -->", "<root><comment>&lt;!-- Foo --&gt;</comment>  <comment>&lt;!-- Bar --&gt;</comment></root>" ],
			[ "<!-- Foo --> \n <!-- Bar -->", "<root><comment>&lt;!-- Foo --&gt;</comment> \n <comment>&lt;!-- Bar --&gt;</comment></root>" ],
			[ "<!-- Foo --> \n <!-- Bar -->\n", "<root><comment>&lt;!-- Foo --&gt;</comment> \n<comment> &lt;!-- Bar --&gt;\n</comment></root>" ],
			[ "<!-- Foo -->  <!-- Bar -->\n", "<root><comment>&lt;!-- Foo --&gt;</comment>  <comment>&lt;!-- Bar --&gt;</comment>\n</root>" ],
			[ "<!-->Bar", "<root><comment>&lt;!--&gt;Bar</comment></root>" ],
			[ "<!-- Comment -- comment", "<root><comment>&lt;!-- Comment -- comment</comment></root>" ],
			[ "== Foo ==\n  <!-- Bar -->\n== Baz ==\n", "<root><h level=\"2\" i=\"1\">== Foo ==</h>\n<comment>  &lt;!-- Bar --&gt;\n</comment><h level=\"2\" i=\"2\">== Baz ==</h>\n</root>" ],
			[ "<gallery/>", "<root><ext><name>gallery</name><attr></attr></ext></root>" ],
			[ "Foo <gallery/> Bar", "<root>Foo <ext><name>gallery</name><attr></attr></ext> Bar</root>" ],
			[ "<gallery></gallery>", "<root><ext><name>gallery</name><attr></attr><inner></inner><close>&lt;/gallery&gt;</close></ext></root>" ],
			[ "<foo> <gallery></gallery>", "<root>&lt;foo&gt; <ext><name>gallery</name><attr></attr><inner></inner><close>&lt;/gallery&gt;</close></ext></root>" ],
			[ "<foo> <gallery><gallery></gallery>", "<root>&lt;foo&gt; <ext><name>gallery</name><attr></attr><inner>&lt;gallery&gt;</inner><close>&lt;/gallery&gt;</close></ext></root>" ],
			[ "<noinclude> Foo bar </noinclude>", "<root><ignore>&lt;noinclude&gt;</ignore> Foo bar <ignore>&lt;/noinclude&gt;</ignore></root>" ],
			[ "<noinclude>\n{{Foo}}\n</noinclude>", "<root><ignore>&lt;noinclude&gt;</ignore>\n<template lineStart=\"1\"><title>Foo</title></template>\n<ignore>&lt;/noinclude&gt;</ignore></root>" ],
			[ "<noinclude>\n{{Foo}}\n</noinclude>\n", "<root><ignore>&lt;noinclude&gt;</ignore>\n<template lineStart=\"1\"><title>Foo</title></template>\n<ignore>&lt;/noinclude&gt;</ignore>\n</root>" ],
			[ "<gallery>foo bar", "<root>&lt;gallery&gt;foo bar</root>" ],
			[ "<{{foo}}>", "<root>&lt;<template><title>foo</title></template>&gt;</root>" ],
			[ "<{{{foo}}}>", "<root>&lt;<tplarg><title>foo</title></tplarg>&gt;</root>" ],
			[ "<gallery></gallery</gallery>", "<root><ext><name>gallery</name><attr></attr><inner>&lt;/gallery</inner><close>&lt;/gallery&gt;</close></ext></root>" ],
			[ "=== Foo === ", "<root><h level=\"3\" i=\"1\">=== Foo === </h></root>" ],
			[ "==<!-- -->= Foo === ", "<root><h level=\"2\" i=\"1\">==<comment>&lt;!-- --&gt;</comment>= Foo === </h></root>" ],
			[ "=== Foo ==<!-- -->= ", "<root><h level=\"1\" i=\"1\">=== Foo ==<comment>&lt;!-- --&gt;</comment>= </h></root>" ],
			[ "=== Foo ===<!-- -->\n", "<root><h level=\"3\" i=\"1\">=== Foo ===<comment>&lt;!-- --&gt;</comment></h>\n</root>" ],
			[ "=== Foo ===<!-- --> <!-- -->\n", "<root><h level=\"3\" i=\"1\">=== Foo ===<comment>&lt;!-- --&gt;</comment> <comment>&lt;!-- --&gt;</comment></h>\n</root>" ],
			[ "== Foo ==\n== Bar == \n", "<root><h level=\"2\" i=\"1\">== Foo ==</h>\n<h level=\"2\" i=\"2\">== Bar == </h>\n</root>" ],
			[ "===========", "<root><h level=\"5\" i=\"1\">===========</h></root>" ],
			[ "Foo\n=\n==\n=\n", "<root>Foo\n=\n==\n=\n</root>" ],
			[ "{{Foo}}", "<root><template><title>Foo</title></template></root>" ],
			[ "\n{{Foo}}", "<root>\n<template lineStart=\"1\"><title>Foo</title></template></root>" ],
			[ "{{Foo|bar}}", "<root><template><title>Foo</title><part><name index=\"1\" /><value>bar</value></part></template></root>" ],
			[ "{{Foo|bar}}a", "<root><template><title>Foo</title><part><name index=\"1\" /><value>bar</value></part></template>a</root>" ],
			[ "{{Foo|bar|baz}}", "<root><template><title>Foo</title><part><name index=\"1\" /><value>bar</value></part><part><name index=\"2\" /><value>baz</value></part></template></root>" ],
			[ "{{Foo|1=bar}}", "<root><template><title>Foo</title><part><name>1</name>=<value>bar</value></part></template></root>" ],
			[ "{{Foo|=bar}}", "<root><template><title>Foo</title><part><name></name>=<value>bar</value></part></template></root>" ],
			[ "{{Foo|bar=baz}}", "<root><template><title>Foo</title><part><name>bar</name>=<value>baz</value></part></template></root>" ],
			[ "{{Foo|{{bar}}=baz}}", "<root><template><title>Foo</title><part><name><template><title>bar</title></template></name>=<value>baz</value></part></template></root>" ],
			[ "{{Foo|1=bar|baz}}", "<root><template><title>Foo</title><part><name>1</name>=<value>bar</value></part><part><name index=\"1\" /><value>baz</value></part></template></root>" ],
			[ "{{Foo|1=bar|2=baz}}", "<root><template><title>Foo</title><part><name>1</name>=<value>bar</value></part><part><name>2</name>=<value>baz</value></part></template></root>" ],
			[ "{{Foo|bar|foo=baz}}", "<root><template><title>Foo</title><part><name index=\"1\" /><value>bar</value></part><part><name>foo</name>=<value>baz</value></part></template></root>" ],
			[ "{{{1}}}", "<root><tplarg><title>1</title></tplarg></root>" ],
			[ "{{{1|}}}", "<root><tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg></root>" ],
			[ "{{{Foo}}}", "<root><tplarg><title>Foo</title></tplarg></root>" ],
			[ "{{{Foo|}}}", "<root><tplarg><title>Foo</title><part><name index=\"1\" /><value></value></part></tplarg></root>" ],
			[ "{{{Foo|bar|baz}}}", "<root><tplarg><title>Foo</title><part><name index=\"1\" /><value>bar</value></part><part><name index=\"2\" /><value>baz</value></part></tplarg></root>" ],
			[ "{<!-- -->{Foo}}", "<root>{<comment>&lt;!-- --&gt;</comment>{Foo}}</root>" ],
			[ "{{{{Foobar}}}}", "<root>{<tplarg><title>Foobar</title></tplarg>}</root>" ],
			[ "{{{ {{Foo}} }}}", "<root><tplarg><title> <template><title>Foo</title></template> </title></tplarg></root>" ],
			[ "{{ {{{Foo}}} }}", "<root><template><title> <tplarg><title>Foo</title></tplarg> </title></template></root>" ],
			[ "{{{{{Foo}}}}}", "<root><template><title><tplarg><title>Foo</title></tplarg></title></template></root>" ],
			[ "{{{{{Foo}} }}}", "<root><tplarg><title><template><title>Foo</title></template> </title></tplarg></root>" ],
			[ "{{{{{{Foo}}}}}}", "<root><tplarg><title><tplarg><title>Foo</title></tplarg></title></tplarg></root>" ],
			[ "{{{{{{Foo}}}}}", "<root>{<template><title><tplarg><title>Foo</title></tplarg></title></template></root>" ],
			[ "[[[Foo]]", "<root>[[[Foo]]</root>" ],
			[ "{{Foo|[[[[bar]]|baz]]}}", "<root><template><title>Foo</title><part><name index=\"1\" /><value>[[[[bar]]|baz]]</value></part></template></root>" ], // This test is important, since it means the difference between having the [[ rule stacked or not
			[ "{{Foo|[[[[bar]|baz]]}}", "<root>{{Foo|[[[[bar]|baz]]}}</root>" ],
			[ "{{Foo|Foo [[[[bar]|baz]]}}", "<root>{{Foo|Foo [[[[bar]|baz]]}}</root>" ],
			[ "Foo <display map>Bar</display map             >Baz", "<root>Foo <ext><name>display map</name><attr></attr><inner>Bar</inner><close>&lt;/display map             &gt;</close></ext>Baz</root>" ],
			[ "Foo <display map foo>Bar</display map             >Baz", "<root>Foo <ext><name>display map</name><attr> foo</attr><inner>Bar</inner><close>&lt;/display map             &gt;</close></ext>Baz</root>" ],
			[ "Foo <gallery bar=\"baz\" />", "<root>Foo <ext><name>gallery</name><attr> bar=&quot;baz&quot; </attr></ext></root>" ],
			[ "Foo <gallery bar=\"1\" baz=2 />", "<root>Foo <ext><name>gallery</name><attr> bar=&quot;1&quot; baz=2 </attr></ext></root>" ],
			[ "</foo>Foo<//foo>", "<root><ext><name>/foo</name><attr></attr><inner>Foo</inner><close>&lt;//foo&gt;</close></ext></root>" ], # Worth prohibiting IMHO
			[ "{{#ifexpr: ({{{1|1}}} = 2) | Foo | Bar }}", "<root><template><title>#ifexpr: (<tplarg><title>1</title><part><name index=\"1\" /><value>1</value></part></tplarg> = 2) </title><part><name index=\"1\" /><value> Foo </value></part><part><name index=\"2\" /><value> Bar </value></part></template></root>" ],
			[ "{{#if: {{{1|}}} | Foo | {{Bar}} }}", "<root><template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> Foo </value></part><part><name index=\"2\" /><value> <template><title>Bar</title></template> </value></part></template></root>" ],
			[ "{{#if: {{{1|}}} | Foo | [[Bar]] }}", "<root><template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> Foo </value></part><part><name index=\"2\" /><value> [[Bar]] </value></part></template></root>" ],
			[ "{{#if: {{{1|}}} | [[Foo]] | Bar }}", "<root><template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> [[Foo]] </value></part><part><name index=\"2\" /><value> Bar </value></part></template></root>" ],
			[ "{{#if: {{{1|}}} | 1 | {{#if: {{{1|}}} | 2 | 3 }} }}", "<root><template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> 1 </value></part><part><name index=\"2\" /><value> <template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> 2 </value></part><part><name index=\"2\" /><value> 3 </value></part></template> </value></part></template></root>" ],
			[ "{{ {{Foo}}", "<root>{{ <template><title>Foo</title></template></root>" ],
			[ "{{Foobar {{Foo}} {{Bar}} {{Baz}} ", "<root>{{Foobar <template><title>Foo</title></template> <template><title>Bar</title></template> <template><title>Baz</title></template> </root>" ],
			[ "[[Foo]] |", "<root>[[Foo]] |</root>" ],
			[ "{{Foo|Bar|", "<root>{{Foo|Bar|</root>" ],
			[ "[[Foo]", "<root>[[Foo]</root>" ],
			[ "[[Foo|Bar]", "<root>[[Foo|Bar]</root>" ],
			[ "{{Foo| [[Bar] }}", "<root>{{Foo| [[Bar] }}</root>" ],
			[ "{{Foo| [[Bar|Baz] }}", "<root>{{Foo| [[Bar|Baz] }}</root>" ],
			[ "{{Foo|bar=[[baz]}}", "<root>{{Foo|bar=[[baz]}}</root>" ],
			[ "{{foo|", "<root>{{foo|</root>" ],
			[ "{{foo|}", "<root>{{foo|}</root>" ],
			[ "{{foo|} }}", "<root><template><title>foo</title><part><name index=\"1\" /><value>} </value></part></template></root>" ],
			[ "{{foo|bar=|}", "<root>{{foo|bar=|}</root>" ],
			[ "{{Foo|} Bar=", "<root>{{Foo|} Bar=</root>" ],
			[ "{{Foo|} Bar=}}", "<root><template><title>Foo</title><part><name>} Bar</name>=<value></value></part></template></root>" ],
			/* [ file_get_contents( __DIR__ . '/QuoteQuran.txt' ], file_get_contents( __DIR__ . '/QuoteQuranExpanded.txt' ) ], */
		];
		// phpcs:enable
	}

	/**
	 * Get XML preprocessor tree from the preprocessor (which may not be the
	 * native XML-based one).
	 *
	 * @param string $wikiText
	 * @return string
	 */
	protected function preprocessToXml( $wikiText ) {
		$preprocessor = $this->preprocessor;
		if ( method_exists( $preprocessor, 'preprocessToXml' ) ) {
			return $this->normalizeXml( $preprocessor->preprocessToXml( $wikiText ) );
		}

		$dom = $preprocessor->preprocessToObj( $wikiText );
		if ( is_callable( [ $dom, 'saveXML' ] ) ) {
			return $dom->saveXML();
		} else {
			return $this->normalizeXml( $dom->__toString() );
		}
	}

	/**
	 * Normalize XML string to the form that a DOMDocument saves out.
	 *
	 * @param string $xml
	 * @return string
	 */
	protected function normalizeXml( $xml ) {
		// Normalize self-closing tags
		$xml = preg_replace( '!<([a-z]+)/>!', '<$1></$1>', str_replace( ' />', '/>', $xml ) );
		// Remove <equals> tags, which only occur in Preprocessor_Hash and
		// have no semantic value
		$xml = preg_replace( '!</?equals>!', '', $xml );
		return $xml;
	}

	/**
	 * @dataProvider provideCases
	 * @dataProvider provideHeadings
	 */
	public function testPreprocessorOutput( $wikiText, $expectedXml ) {
		$this->assertEquals(
			$this->normalizeXml( $expectedXml ),
			$this->preprocessToXml( $wikiText )
		);
	}

	/**
	 * These are more complex test cases taken out of wiki articles.
	 */
	public static function provideFiles() {
		return [
			[ "QuoteQuran" ], # https://en.wikipedia.org/w/index.php?title=Template:QuoteQuran/sandbox&oldid=237348988 GFDL + CC BY-SA by Striver
			[ "Factorial" ], # https://en.wikipedia.org/w/index.php?title=Template:Factorial&oldid=98548758 GFDL + CC BY-SA by Polonium
			[ "All_system_messages" ], # https://tl.wiktionary.org/w/index.php?title=Suleras:All_system_messages&oldid=2765 GPL text generated by MediaWiki
			[ "Fundraising" ], # https://tl.wiktionary.org/w/index.php?title=MediaWiki:Sitenotice&oldid=5716 GFDL + CC BY-SA, copied there by Sky Harbor.
			[ "NestedTemplates" ], # T29936
		];
		// phpcs:enable
	}

	/**
	 * @dataProvider provideFiles
	 */
	public function testPreprocessorOutputFiles( $filename ) {
		$folder = __DIR__ . "/../../data/preprocess";
		$wikiText = file_get_contents( "$folder/$filename.txt" );
		$output = $this->preprocessToXml( $wikiText );

		$expectedFilename = "$folder/$filename.expected";
		if ( is_file( $expectedFilename ) ) {
			$expectedXml = $this->normalizeXml( file_get_contents( $expectedFilename ) );
			$this->assertEquals( $expectedXml, $output );
		} else {
			$tempFilename = tempnam( $folder, "$filename." );
			file_put_contents( $tempFilename, $output );
			$this->markTestIncomplete( "File $expectedFilename missing. Output stored as $tempFilename" );
		}
	}

	/**
	 * Tests from T30642 · https://phabricator.wikimedia.org/T30642
	 */
	public static function provideHeadings() {
		return [
			/* These should become headings: */
			[ "== h ==<!--c1-->", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment></h></root>" ],
			[ "== h == 	<!--c1-->", "<root><h level=\"2\" i=\"1\">== h == 	<comment>&lt;!--c1--&gt;</comment></h></root>" ],
			[ "== h ==<!--c1--> 	", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment> 	</h></root>" ],
			[ "== h == 	<!--c1--> 	", "<root><h level=\"2\" i=\"1\">== h == 	<comment>&lt;!--c1--&gt;</comment> 	</h></root>" ],
			[ "== h ==<!--c1--><!--c2-->", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment></h></root>" ],
			[ "== h == 	<!--c1--><!--c2-->", "<root><h level=\"2\" i=\"1\">== h == 	<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment></h></root>" ],
			[ "== h ==<!--c1--><!--c2--> 	", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment> 	</h></root>" ],
			[ "== h == 	<!--c1--><!--c2--> 	", "<root><h level=\"2\" i=\"1\">== h == 	<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment> 	</h></root>" ],
			[ "== h == 	<!--c1-->  <!--c2-->", "<root><h level=\"2\" i=\"1\">== h == 	<comment>&lt;!--c1--&gt;</comment>  <comment>&lt;!--c2--&gt;</comment></h></root>" ],
			[ "== h ==<!--c1-->  <!--c2--> 	", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment>  <comment>&lt;!--c2--&gt;</comment> 	</h></root>" ],
			[ "== h == 	<!--c1-->  <!--c2--> 	", "<root><h level=\"2\" i=\"1\">== h == 	<comment>&lt;!--c1--&gt;</comment>  <comment>&lt;!--c2--&gt;</comment> 	</h></root>" ],
			[ "== h ==<!--c1--><!--c2--><!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment></h></root>" ],
			[ "== h ==<!--c1-->  <!--c2--><!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment>  <comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment></h></root>" ],
			[ "== h ==<!--c1--><!--c2-->  <!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment>  <comment>&lt;!--c3--&gt;</comment></h></root>" ],
			[ "== h ==<!--c1-->  <!--c2-->  <!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment>  <comment>&lt;!--c2--&gt;</comment>  <comment>&lt;!--c3--&gt;</comment></h></root>" ],
			[ "== h ==  <!--c1--><!--c2--><!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==  <comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment></h></root>" ],
			[ "== h ==  <!--c1-->  <!--c2--><!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==  <comment>&lt;!--c1--&gt;</comment>  <comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment></h></root>" ],
			[ "== h ==  <!--c1--><!--c2-->  <!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==  <comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment>  <comment>&lt;!--c3--&gt;</comment></h></root>" ],
			[ "== h ==  <!--c1-->  <!--c2-->  <!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==  <comment>&lt;!--c1--&gt;</comment>  <comment>&lt;!--c2--&gt;</comment>  <comment>&lt;!--c3--&gt;</comment></h></root>" ],
			[ "== h ==<!--c1--><!--c2--><!--c3-->  ", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment>  </h></root>" ],
			[ "== h ==<!--c1-->  <!--c2--><!--c3-->  ", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment>  <comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment>  </h></root>" ],
			[ "== h ==<!--c1--><!--c2-->  <!--c3-->  ", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment>  <comment>&lt;!--c3--&gt;</comment>  </h></root>" ],
			[ "== h ==<!--c1-->  <!--c2-->  <!--c3-->  ", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment>  <comment>&lt;!--c2--&gt;</comment>  <comment>&lt;!--c3--&gt;</comment>  </h></root>" ],
			[ "== h ==  <!--c1--><!--c2--><!--c3-->  ", "<root><h level=\"2\" i=\"1\">== h ==  <comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment>  </h></root>" ],
			[ "== h ==  <!--c1-->  <!--c2--><!--c3-->  ", "<root><h level=\"2\" i=\"1\">== h ==  <comment>&lt;!--c1--&gt;</comment>  <comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment>  </h></root>" ],
			[ "== h ==  <!--c1--><!--c2-->  <!--c3-->  ", "<root><h level=\"2\" i=\"1\">== h ==  <comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment>  <comment>&lt;!--c3--&gt;</comment>  </h></root>" ],
			[ "== h ==  <!--c1-->  <!--c2-->  <!--c3-->  ", "<root><h level=\"2\" i=\"1\">== h ==  <comment>&lt;!--c1--&gt;</comment>  <comment>&lt;!--c2--&gt;</comment>  <comment>&lt;!--c3--&gt;</comment>  </h></root>" ],
			[ "== h ==<!--c1--> 	<!--c2-->", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment> 	<comment>&lt;!--c2--&gt;</comment></h></root>" ],
			[ "== h == 	<!--c1--> 	<!--c2-->", "<root><h level=\"2\" i=\"1\">== h == 	<comment>&lt;!--c1--&gt;</comment> 	<comment>&lt;!--c2--&gt;</comment></h></root>" ],
			[ "== h ==<!--c1--> 	<!--c2--> 	", "<root><h level=\"2\" i=\"1\">== h ==<comment>&lt;!--c1--&gt;</comment> 	<comment>&lt;!--c2--&gt;</comment> 	</h></root>" ],

			/* These are not working: */
			[ "== h == x <!--c1--><!--c2--><!--c3-->  ", "<root>== h == x <comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment>  </root>" ],
			[ "== h ==<!--c1--> x <!--c2--><!--c3-->  ", "<root>== h ==<comment>&lt;!--c1--&gt;</comment> x <comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment>  </root>" ],
			[ "== h ==<!--c1--><!--c2--><!--c3--> x ", "<root>== h ==<comment>&lt;!--c1--&gt;</comment><comment>&lt;!--c2--&gt;</comment><comment>&lt;!--c3--&gt;</comment> x </root>" ],
		];
		// phpcs:enable
	}

}
PK       ! z׬}1  }1  "  parser/RevisionOutputCacheTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use InvalidArgumentException;
use MediaWiki\Json\JsonCodec;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\RevisionOutputCache;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Tests\Json\JsonDeserializableSuperClass;
use MediaWiki\User\User;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiIntegrationTestCase;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Psr\Log\NullLogger;
use TestLogger;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Stats\StatsFactory;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\UUID\GlobalIdGenerator;

/**
 * @covers \MediaWiki\Parser\RevisionOutputCache
 */
class RevisionOutputCacheTest extends MediaWikiIntegrationTestCase {

	/** @var int */
	private $time;

	/** @var string */
	private $cacheTime;

	/** @var RevisionRecord */
	private $revision;

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

		$this->time = time();
		$this->cacheTime = MWTimestamp::convert( TS_MW, $this->time + 1 );
		MWTimestamp::setFakeTime( $this->time );

		$this->revision = new MutableRevisionRecord(
			new PageIdentityValue(
				42,
				NS_MAIN,
				'Testing_Testing',
				PageIdentity::LOCAL
			),
			RevisionRecord::LOCAL
		);
		$this->revision->setId( 24 );
		$this->revision->setTimestamp( MWTimestamp::convert( TS_MW, $this->time ) );
	}

	/**
	 * @param BagOStuff|null $storage
	 * @param LoggerInterface|null $logger
	 * @param int $expiry
	 * @param string $epoch
	 *
	 * @return RevisionOutputCache
	 */
	private function createRevisionOutputCache(
		?BagOStuff $storage = null,
		?LoggerInterface $logger = null,
		$expiry = 3600,
		$epoch = '19900220000000'
	): RevisionOutputCache {
		$globalIdGenerator = $this->createMock( GlobalIdGenerator::class );
		$globalIdGenerator->method( 'newUUIDv1' )->willReturn( 'uuid-uuid' );
		return new RevisionOutputCache(
			'test',
			new WANObjectCache( [ 'cache' => $storage ?: new HashBagOStuff() ] ),
			$expiry,
			$epoch,
			new JsonCodec(),
			StatsFactory::newNull(),
			$logger ?: new NullLogger(),
			$globalIdGenerator
		);
	}

	/**
	 * @return array
	 */
	private function getDummyUsedOptions(): array {
		return array_slice(
			ParserOptions::allCacheVaryingOptions(),
			0,
			2
		);
	}

	/**
	 * @return ParserOutput
	 */
	private function createDummyParserOutput(): ParserOutput {
		$parserOutput = new ParserOutput();
		$parserOutput->setRawText( 'TEST' );
		foreach ( $this->getDummyUsedOptions() as $option ) {
			$parserOutput->recordOption( $option );
		}
		$parserOutput->updateCacheExpiry( 4242 );
		return $parserOutput;
	}

	/**
	 * @covers \MediaWiki\Parser\RevisionOutputCache::makeParserOutputKey
	 */
	public function testMakeParserOutputKey() {
		$cache = $this->createRevisionOutputCache();

		$options1 = ParserOptions::newFromAnon();
		$options1->setOption( $this->getDummyUsedOptions()[0], 'value1' );
		$key1 = $cache->makeParserOutputKey( $this->revision, $options1, $this->getDummyUsedOptions() );
		$this->assertNotNull( $key1 );

		$options2 = ParserOptions::newFromAnon();
		$options2->setOption( $this->getDummyUsedOptions()[0], 'value2' );
		$key2 = $cache->makeParserOutputKey( $this->revision, $options2, $this->getDummyUsedOptions() );
		$this->assertNotNull( $key2 );
		$this->assertNotSame( $key1, $key2 );
	}

	/*
	 * Test that fetching without storing first returns false.
	 * @covers \MediaWiki\Parser\RevisionOutputCache::get
	 */
	public function testGetEmpty() {
		$cache = $this->createRevisionOutputCache();
		$options = ParserOptions::newFromAnon();

		$this->assertFalse( $cache->get( $this->revision, $options ) );
	}

	/**
	 * Test that fetching with the same options return the saved value.
	 * @covers \MediaWiki\Parser\RevisionOutputCache::get
	 * @covers \MediaWiki\Parser\RevisionOutputCache::save
	 */
	public function testSaveGetSameOptions() {
		$cache = $this->createRevisionOutputCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );

		$options1 = ParserOptions::newFromAnon();
		$options1->setOption( $this->getDummyUsedOptions()[0], 'value1' );
		$cache->save( $parserOutput, $this->revision, $options1, $this->cacheTime );

		$savedOutput = $cache->get( $this->revision, $options1 );
		$this->assertInstanceOf( ParserOutput::class, $savedOutput );
		// RevisionOutputCache adds a comment to the HTML, so check if the result starts with page content.
		$this->assertStringStartsWith( 'TEST_TEXT',
			$savedOutput->getRawText() );
		$this->assertSame( $this->cacheTime, $savedOutput->getCacheTime() );
		$this->assertSame( $this->revision->getId(), $savedOutput->getCacheRevisionId() );
	}

	/**
	 * Test that non-cacheable output is not stored
	 * @covers \MediaWiki\Parser\RevisionOutputCache::save
	 */
	public function testDoesNotStoreNonCacheable() {
		$cache = $this->createRevisionOutputCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );
		$parserOutput->updateCacheExpiry( 0 );

		$options1 = ParserOptions::newFromAnon();
		$cache->save( $parserOutput, $this->revision, $options1, $this->cacheTime );

		$this->assertFalse( $cache->get( $this->revision, $options1 ) );
	}

	/**
	 * Test that setting the cache epoch will cause outdated entries to be ignored
	 * @covers \MediaWiki\Parser\RevisionOutputCache::get
	 */
	public function testExpiresByEpoch() {
		$store = new HashBagOStuff();
		$cache = $this->createRevisionOutputCache( $store );
		$parserOutput = new ParserOutput( 'TEST_TEXT' );

		$options = ParserOptions::newFromAnon();
		$cache->save( $parserOutput, $this->revision, $options, $this->cacheTime );

		// determine cache epoch younger than cache time
		$cacheTime = MWTimestamp::convert( TS_UNIX, $parserOutput->getCacheTime() );
		$epoch = MWTimestamp::convert( TS_MW, $cacheTime + 60 );

		// create a cache with the new epoch
		$cache = $this->createRevisionOutputCache( $store, null, 60 * 60, $epoch );
		$this->assertFalse( $cache->get( $this->revision, $options ) );
	}

	/**
	 * Test that setting the cache expiry period will cause outdated entries to be ignored
	 * @covers \MediaWiki\Parser\RevisionOutputCache::get
	 */
	public function testExpiresByDuration() {
		$store = new HashBagOStuff();

		// original cache is good for an hour
		$cache = $this->createRevisionOutputCache( $store );
		$parserOutput = new ParserOutput( 'TEST_TEXT' );

		$options = ParserOptions::newFromAnon();
		$cache->save( $parserOutput, $this->revision, $options, $this->cacheTime );

		// move the clock forward by 60 seconds
		$cacheTime = MWTimestamp::convert( TS_UNIX, $parserOutput->getCacheTime() );
		MWTimestamp::setFakeTime( $cacheTime + 60 );

		// create a cache that expires after 30 seconds
		$cache = $this->createRevisionOutputCache( $store, null, 30 );
		$this->assertFalse( $cache->get( $this->revision, $options ) );
	}

	/**
	 * Test that ParserOptions::isSafeToCache is respected on save
	 * @covers \MediaWiki\Parser\RevisionOutputCache::save
	 */
	public function testDoesNotStoreNotSafeToCache() {
		$cache = $this->createRevisionOutputCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );

		$options = ParserOptions::newFromAnon();
		$options->setOption( 'wrapclass', 'wrapwrap' );

		$cache->save( $parserOutput, $this->revision, $options, $this->cacheTime );

		$this->assertFalse( $cache->get( $this->revision, $options ) );
	}

	/**
	 * Test that ParserOptions::isSafeToCache is respected on get
	 * @covers \MediaWiki\Parser\RevisionOutputCache::get
	 */
	public function testDoesNotGetNotSafeToCache() {
		$cache = $this->createRevisionOutputCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );

		$cache->save( $parserOutput, $this->revision, ParserOptions::newFromAnon(), $this->cacheTime );

		$otherOptions = ParserOptions::newFromAnon();
		$otherOptions->setOption( 'wrapclass', 'wrapwrap' );

		$this->assertFalse( $cache->get( $this->revision, $otherOptions ) );
	}

	/**
	 * Test that fetching with different used option don't return a value.
	 * @covers \MediaWiki\Parser\RevisionOutputCache::get
	 * @covers \MediaWiki\Parser\RevisionOutputCache::save
	 */
	public function testSaveGetDifferentUsedOption() {
		$cache = $this->createRevisionOutputCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );
		$optionName = $this->getDummyUsedOptions()[0];
		$parserOutput->recordOption( $optionName );

		$options1 = ParserOptions::newFromAnon();
		$options1->setOption( $optionName, 'value1' );
		$cache->save( $parserOutput, $this->revision, $options1, $this->cacheTime );

		$options2 = ParserOptions::newFromAnon();
		$options2->setOption( $optionName, 'value2' );
		$this->assertFalse( $cache->get( $this->revision, $options2 ) );
	}

	/**
	 * @covers \MediaWiki\Parser\RevisionOutputCache::save
	 */
	public function testSaveNoText() {
		$this->expectException( InvalidArgumentException::class );
		$this->createRevisionOutputCache()->save(
			new ParserOutput( null ),
			$this->revision,
			ParserOptions::newFromAnon()
		);
	}

	public static function provideCorruptData() {
		yield 'JSON serialization, bad data' => [ 'bla bla' ];
		yield 'JSON serialization, no _class_' => [ '{"test":"test"}' ];
		yield 'JSON serialization, non-existing _class_' => [ '{"_class_":"NonExistentBogusClass"}' ];

		$wrongInstance = new JsonDeserializableSuperClass( 'test' );
		yield 'JSON serialization, wrong class' => [ json_encode( $wrongInstance->jsonSerialize() ) ];
	}

	/**
	 * Test that we handle corrupt data gracefully.
	 * This is important for forward-compatibility with JSON serialization.
	 * We want to be sure that we don't crash horribly if we have to roll
	 * back to a version of the code that doesn't know about JSON.
	 *
	 * @dataProvider provideCorruptData
	 * @covers \MediaWiki\Parser\RevisionOutputCache::get
	 * @covers \MediaWiki\Parser\RevisionOutputCache::restoreFromJson
	 * @param string $data
	 */
	public function testCorruptData( string $data ) {
		$cache = $this->createRevisionOutputCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );

		$options1 = ParserOptions::newFromAnon();
		$cache->save( $parserOutput, $this->revision, $options1, $this->cacheTime );

		$outputKey = $cache->makeParserOutputKey(
			$this->revision,
			$options1,
			$parserOutput->getUsedOptions()
		);

		$backend = TestingAccessWrapper::newFromObject( $cache )->cache;
		$backend->set( $outputKey, $data );

		// just make sure we don't crash and burn
		$this->assertFalse( $cache->get( $this->revision, $options1 ) );
	}

	/**
	 * @covers \MediaWiki\Parser\RevisionOutputCache::encodeAsJson
	 */
	public function testNonSerializableJsonIsReported() {
		$testLogger = new TestLogger( true );
		$cache = $this->createRevisionOutputCache( null, $testLogger );

		$parserOutput = $this->createDummyParserOutput();
		$parserOutput->setExtensionData( 'test', new User() );
		$cache->save( $parserOutput, $this->revision, ParserOptions::newFromAnon() );
		$this->assertArraySubmapSame(
			[ [ LogLevel::ERROR, 'Unable to serialize JSON' ] ],
			$testLogger->getBuffer()
		);
	}

	/**
	 * @covers \MediaWiki\Parser\RevisionOutputCache::encodeAsJson
	 */
	public function testCyclicStructuresDoNotBlowUpInJson() {
		$this->markTestSkipped( 'Temporarily disabled: T314338' );
		$testLogger = new TestLogger( true );
		$cache = $this->createRevisionOutputCache( null, $testLogger );

		$parserOutput = $this->createDummyParserOutput();
		$cyclicArray = [ 'a' => 'b' ];
		$cyclicArray['c'] = &$cyclicArray;
		$parserOutput->setExtensionData( 'test', $cyclicArray );
		$cache->save( $parserOutput, $this->revision, ParserOptions::newFromAnon() );
		$this->assertArraySubmapSame(
			[ [ LogLevel::ERROR, 'Unable to serialize JSON' ] ],
			$testLogger->getBuffer()
		);
	}

	/**
	 * Tests that unicode characters are not \u escaped
	 * @covers \MediaWiki\Parser\RevisionOutputCache::encodeAsJson
	 */
	public function testJsonEncodeUnicode() {
		$unicodeCharacter = "Э";
		$cache = $this->createRevisionOutputCache( new HashBagOStuff() );
		$parserOutput = $this->createDummyParserOutput();
		$parserOutput->setRawText( $unicodeCharacter );
		$cache->save( $parserOutput, $this->revision, ParserOptions::newFromAnon() );

		$backend = TestingAccessWrapper::newFromObject( $cache )->cache;
		$json = $backend->get(
			$cache->makeParserOutputKey( $this->revision, ParserOptions::newFromAnon() )
		);
		$this->assertStringNotContainsString( "\u003E", $json );
		$this->assertStringContainsString( $unicodeCharacter, $json );
	}
}
PK       ! Z`C  `C    parser/ParserOptionsTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use DummyContentForTesting;
use InvalidArgumentException;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\MainConfigNames;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWikiLangTestCase;
use stdClass;
use Wikimedia\ScopedCallback;

/**
 * @covers \MediaWiki\Parser\ParserOptions
 * @group Database
 */
class ParserOptionsTest extends MediaWikiLangTestCase {

	use TempUserTestTrait;

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

		$this->overrideConfigValues( [
			MainConfigNames::RenderHashAppend => '',
			MainConfigNames::UsePigLatinVariant => false,
		] );
		$this->setTemporaryHook( 'PageRenderingHash', HookContainer::NOOP );
	}

	protected function tearDown(): void {
		ParserOptions::clearStaticCache();
		parent::tearDown();
	}

	public function testNewCanonical() {
		$user = $this->createMock( User::class );
		$userLang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'fr' );
		$contLang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'qqx' );

		$this->setContentLang( $contLang );
		$this->setUserLang( $userLang );

		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'de' );
		$lang2 = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'bug' );
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser( $user );
		$context->setLanguage( $lang );

		// Just a user uses $wgLang
		$popt = ParserOptions::newCanonical( $user );
		$this->assertSame( $user, $popt->getUserIdentity() );
		$this->assertSame( $userLang, $popt->getUserLangObj() );

		// Passing both works
		$popt = ParserOptions::newCanonical( $user, $lang );
		$this->assertSame( $user, $popt->getUserIdentity() );
		$this->assertSame( $lang, $popt->getUserLangObj() );

		// Passing 'canonical' uses an anon and $contLang, and ignores any passed $userLang
		$popt = ParserOptions::newFromAnon();
		$this->assertTrue( $popt->getUserIdentity()->isAnon() );
		$this->assertSame( $contLang, $popt->getUserLangObj() );
		$popt = ParserOptions::newCanonical( 'canonical', $lang2 );
		$this->assertSame( $contLang, $popt->getUserLangObj() );

		// Passing an IContextSource uses the user and lang from it, and ignores
		// any passed $userLang
		$popt = ParserOptions::newCanonical( $context );
		$this->assertSame( $user, $popt->getUserIdentity() );
		$this->assertSame( $lang, $popt->getUserLangObj() );
		$popt = ParserOptions::newCanonical( $context, $lang2 );
		$this->assertSame( $lang, $popt->getUserLangObj() );

		// Passing something else raises an exception
		try {
			$popt = ParserOptions::newCanonical( 'bogus' );
			$this->fail( 'Excpected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
		}
	}

	private function commonTestNewFromContext( IContextSource $context, UserIdentity $expectedUser ) {
		$popt = ParserOptions::newFromContext( $context );
		$this->assertTrue( $expectedUser->equals( $popt->getUserIdentity() ) );
		$this->assertSame( $context->getLanguage(), $popt->getUserLangObj() );
	}

	/** @dataProvider provideNewFromContext */
	public function testNewFromContext( $contextUserIdentity, $contextLanguage ) {
		$this->enableAutoCreateTempUser();
		// Get a context which has our provided user and language set, then call ::newFromContext with it.
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser(
			$this->getServiceContainer()->getUserFactory()->newFromUserIdentity( $contextUserIdentity )
		);
		$context->setLanguage( $contextLanguage );
		$this->commonTestNewFromContext( $context, $context->getUser() );
	}

	public static function provideNewFromContext() {
		return [
			'Username does not exist and user lang as en' => [ UserIdentityValue::newAnonymous( 'Testabc' ), 'en' ],
			'Username is IP address, no stashed temporary username, and user lang as qqx' => [
				UserIdentityValue::newAnonymous( '1.2.3.4' ), 'qqx',
			],
		];
	}

	public function testNewFromContextForNamedAccount() {
		$this->testNewFromContext( $this->getTestUser()->getUser(), 'qqx' );
	}

	public function testNewFromContextForTemporaryAccount() {
		$this->testNewFromContext(
			$this->getServiceContainer()->getTempUserCreator()
				->create( null, new FauxRequest() )->getUser(),
			'de'
		);
	}

	public function testNewFromContextForAnonWhenTempNameStashed() {
		$this->enableAutoCreateTempUser();
		// Get a context which uses an anon user as the user.
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser(
			$this->getServiceContainer()->getUserFactory()
				->newFromUserIdentity( UserIdentityValue::newAnonymous( '1.2.3.4' ) )
		);
		// Create a temporary account name and stash it in associated Session for the $context
		$stashedName = $this->getServiceContainer()->getTempUserCreator()
			->acquireAndStashName( $context->getRequest()->getSession() );
		// Call ::newFromContext and expect that that stashed name is used
		$this->commonTestNewFromContext( $context, UserIdentityValue::newAnonymous( $stashedName ) );
	}

	public function testNewFromContextForAnonWhenTempNameStashedButFeatureSinceDisabled() {
		$this->enableAutoCreateTempUser();
		// Get a context which uses an anon user as the user.
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser(
			$this->getServiceContainer()->getUserFactory()
				->newFromUserIdentity( UserIdentityValue::newAnonymous( '1.2.3.4' ) )
		);
		// Create a temporary account name and stash it in associated Session for the $context
		$this->getServiceContainer()->getTempUserCreator()
			->acquireAndStashName( $context->getRequest()->getSession() );
		// Simulate that in the interim the temporary accounts system has been disabled, and check that an IP
		// address is used in this case
		$this->disableAutoCreateTempUser( [ 'known' => true ] );
		// Call ::newFromContext and expect that that stashed name is used
		$this->commonTestNewFromContext( $context, UserIdentityValue::newAnonymous( '1.2.3.4' ) );
	}

	/**
	 * @dataProvider provideIsSafeToCache
	 * @param bool $expect Expected value
	 * @param array $options Options to set
	 * @param array|null $usedOptions
	 */
	public function testIsSafeToCache( bool $expect, array $options, ?array $usedOptions = null ) {
		$popt = ParserOptions::newFromAnon();
		foreach ( $options as $name => $value ) {
			$popt->setOption( $name, $value );
		}
		$this->assertSame( $expect, $popt->isSafeToCache( $usedOptions ) );
	}

	public static function provideIsSafeToCache() {
		$seven = static function () {
			return 7;
		};

		return [
			'No overrides' => [ true, [] ],
			'No overrides, some used' => [ true, [], [ 'thumbsize', 'removeComments' ] ],
			'In-key options are ok' => [ true, [
				'thumbsize' => 1e100,
				'printable' => false,
			] ],
			'In-key options are ok, some used' => [ true, [
				'thumbsize' => 1e100,
				'printable' => false,
			], [ 'thumbsize', 'removeComments' ] ],
			'Non-in-key options are not ok' => [ false, [
				'removeComments' => false,
			] ],
			'Non-in-key options are not ok, used' => [ false, [
				'removeComments' => false,
			], [ 'removeComments' ] ],
			'Non-in-key options are ok if other used' => [ true, [
				'removeComments' => false,
			], [ 'thumbsize' ] ],
			'Non-in-key options are ok if nothing used' => [ true, [
				'removeComments' => false,
			], [] ],
			'Unknown used options do not crash' => [ true, [
			], [ 'unknown' ] ],
			'Non-in-key options are not ok (2)' => [ false, [
				'wrapclass' => 'foobar',
			] ],
			'Callback not default' => [ true, [
				'speculativeRevIdCallback' => $seven,
			] ],
		];
	}

	/**
	 * @dataProvider provideOptionsHash
	 * @param array $usedOptions
	 * @param string $expect Expected value
	 * @param array $options Options to set
	 * @param array $globals Globals to set
	 * @param callable|null $hookFunc PageRenderingHash hook function
	 */
	public function testOptionsHash(
		$usedOptions, $expect, $options, $globals = [], $hookFunc = null
	) {
		$this->overrideConfigValues( $globals );
		$this->setTemporaryHook( 'PageRenderingHash', $hookFunc ?: HookContainer::NOOP );

		$popt = ParserOptions::newFromAnon();
		foreach ( $options as $name => $value ) {
			$popt->setOption( $name, $value );
		}
		$this->assertSame( $expect, $popt->optionsHash( $usedOptions ) );
	}

	public static function provideOptionsHash() {
		$used = [ 'thumbsize', 'printable' ];

		$allUsableOptions = array_diff(
			ParserOptions::allCacheVaryingOptions(),
			array_keys( ParserOptions::getLazyOptions() )
		);

		return [
			'Canonical options, nothing used' => [ [], 'canonical', [] ],
			'Canonical options, used some options' => [ $used, 'canonical', [] ],
			'Canonical options, used some more options' => [ array_merge( $used, [ 'wrapclass' ] ), 'canonical', [] ],
			'Used some options, non-default values' => [
				$used,
				'printable=1!thumbsize=200',
				[
					'thumbsize' => 200,
					'printable' => true,
				]
			],

			'Canonical options, used all non-lazy options' => [ $allUsableOptions, 'canonical', [] ],
			'Canonical options, nothing used, but with hooks and $wgRenderHashAppend' => [
				[],
				'canonical!wgRenderHashAppend!onPageRenderingHash',
				[],
				[ MainConfigNames::RenderHashAppend => '!wgRenderHashAppend' ],
				__CLASS__ . '::onPageRenderingHash',
			],
		];
	}

	public function testUsedLazyOptionsInHash() {
		$this->setTemporaryHook( 'ParserOptionsRegister',
			function ( &$defaults, &$inCacheKey, &$lazyOptions ) {
				$lazyFuncs = $this->getMockBuilder( stdClass::class )
					->addMethods( [ 'neverCalled', 'calledOnce' ] )
					->getMock();
				$lazyFuncs->expects( $this->never() )->method( 'neverCalled' );
				$lazyFuncs->expects( $this->once() )->method( 'calledOnce' )->willReturn( 'value' );

				$defaults += [
					'opt1' => null,
					'opt2' => null,
					'opt3' => null,
				];
				$inCacheKey += [
					'opt1' => true,
					'opt2' => true,
				];
				$lazyOptions += [
					'opt1' => [ $lazyFuncs, 'calledOnce' ],
					'opt2' => [ $lazyFuncs, 'neverCalled' ],
					'opt3' => [ $lazyFuncs, 'neverCalled' ],
				];
			}
		);

		ParserOptions::clearStaticCache();

		$popt = ParserOptions::newFromAnon();
		$popt->registerWatcher( function () {
			$this->fail( 'Watcher should not have been called' );
		} );
		$this->assertSame( 'opt1=value', $popt->optionsHash( [ 'opt1', 'opt3' ] ) );

		// Second call to see that opt1 isn't resolved a second time
		$this->assertSame( 'opt1=value', $popt->optionsHash( [ 'opt1', 'opt3' ] ) );
	}

	public function testLazyOptionWithDefault() {
		$loaded = false;
		$this->setTemporaryHook(
			'ParserOptionsRegister',
			static function ( &$defaults, &$inCacheKey, &$lazyLoad ) use ( &$loaded ) {
				$defaults['test_option'] = 'default!';
				$inCacheKey['test_option'] = true;
				$lazyLoad['test_option'] = static function () use ( &$loaded ) {
					$loaded = true;
					return 'default!';
				};
			}
		);

		$po = ParserOptions::newFromAnon();
		$this->assertSame( 'default!', $po->getOption( 'test_option' ) );
		$this->assertTrue( $loaded );
		$this->assertSame(
			'canonical',
			$po->optionsHash( [ 'test_option' ], Title::makeTitle( NS_MAIN, 'Test' ) )
		);
	}

	public static function onPageRenderingHash( &$confstr ) {
		$confstr .= '!onPageRenderingHash';
	}

	public function testGetInvalidOption() {
		$popt = ParserOptions::newFromAnon();
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( "Unknown parser option bogus" );
		$popt->getOption( 'bogus' );
	}

	public function testSetInvalidOption() {
		$popt = ParserOptions::newFromAnon();
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( "Unknown parser option bogus" );
		$popt->setOption( 'bogus', true );
	}

	public function testMatches() {
		$popt1 = ParserOptions::newFromAnon();
		$popt2 = ParserOptions::newFromAnon();
		$this->assertTrue( $popt1->matches( $popt2 ) );

		$popt2->setInterfaceMessage( !$popt2->getInterfaceMessage() );
		$this->assertFalse( $popt1->matches( $popt2 ) );

		$ctr = 0;
		$this->setTemporaryHook( 'ParserOptionsRegister',
			static function ( &$defaults, &$inCacheKey, &$lazyOptions ) use ( &$ctr ) {
				$defaults['testMatches'] = null;
				$lazyOptions['testMatches'] = static function () use ( &$ctr ) {
					return ++$ctr;
				};
			}
		);
		ParserOptions::clearStaticCache();

		$popt1 = ParserOptions::newFromAnon();
		$popt2 = ParserOptions::newFromAnon();
		$this->assertFalse( $popt1->matches( $popt2 ) );

		ScopedCallback::consume( $reset );
	}

	/**
	 * This test fails if tearDown() does not call ParserOptions::clearStaticCache(),
	 * because the lazy option from the hook in the previous test remains active.
	 */
	public function testTeardownClearedCache() {
		$popt1 = ParserOptions::newFromAnon();
		$popt2 = ParserOptions::newFromAnon();
		$this->assertTrue( $popt1->matches( $popt2 ) );
	}

	public function testMatchesForCacheKey() {
		$user = new UserIdentityValue( 0, '127.0.0.1' );
		$cOpts = ParserOptions::newCanonical(
			$user,
			$this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' )
		);

		$uOpts = ParserOptions::newFromAnon();
		$this->assertTrue( $cOpts->matchesForCacheKey( $uOpts ) );

		$uOpts = ParserOptions::newFromUser( $user );
		$this->assertTrue( $cOpts->matchesForCacheKey( $uOpts ) );

		$this->getServiceContainer()
			->getUserOptionsManager()
			->setOption( $user, 'thumbsize', 251 );
		$uOpts = ParserOptions::newFromUser( $user );
		$this->assertFalse( $cOpts->matchesForCacheKey( $uOpts ) );

		$this->getServiceContainer()
			->getUserOptionsManager()
			->setOption( $user, 'stubthreshold', 800 );
		$uOpts = ParserOptions::newFromUser( $user );
		$this->assertFalse( $cOpts->matchesForCacheKey( $uOpts ) );

		$uOpts = ParserOptions::newFromUserAndLang(
			$user,
			$this->getServiceContainer()->getLanguageFactory()->getLanguage( 'zh' )
		);
		$this->assertFalse( $cOpts->matchesForCacheKey( $uOpts ) );
	}

	public function testAllCacheVaryingOptions() {
		$this->setTemporaryHook( 'ParserOptionsRegister', HookContainer::NOOP );
		$this->assertSame( [
			'collapsibleSections',
			'dateformat', 'printable', 'suppressSectionEditLinks',
			'thumbsize', 'useParsoid', 'userlang',
		], ParserOptions::allCacheVaryingOptions() );

		ParserOptions::clearStaticCache();

		$this->setTemporaryHook( 'ParserOptionsRegister', static function ( &$defaults, &$inCacheKey ) {
			$defaults += [
				'foo' => 'foo',
				'bar' => 'bar',
				'baz' => 'baz',
			];
			$inCacheKey += [
				'foo' => true,
				'bar' => false,
			];
		} );
		$this->assertSame( [
			'collapsibleSections',
			'dateformat', 'foo', 'printable', 'suppressSectionEditLinks',
			'thumbsize', 'useParsoid', 'userlang',
		], ParserOptions::allCacheVaryingOptions() );
	}

	public function testGetSpeculativeRevid() {
		$options = ParserOptions::newFromAnon();

		$this->assertFalse( $options->getSpeculativeRevId() );

		$counter = 0;
		$options->setSpeculativeRevIdCallback( static function () use( &$counter ) {
			return ++$counter;
		} );

		// make sure the same value is re-used once it is determined
		$this->assertSame( 1, $options->getSpeculativeRevId() );
		$this->assertSame( 1, $options->getSpeculativeRevId() );
	}

	public function testSetupFakeRevision() {
		$options = ParserOptions::newFromAnon();

		$page = Title::newFromText( __METHOD__ );
		$content = new DummyContentForTesting( '12345' );
		$user = UserIdentityValue::newRegistered( 123, 'TestTest' );
		$fakeRevisionScope = $options->setupFakeRevision( $page, $content, $user );

		$fakeRevision = $options->getCurrentRevisionRecordCallback()( $page );
		$this->assertNotNull( $fakeRevision );
		$this->assertSame( '12345', $fakeRevision->getContent( SlotRecord::MAIN )->getNativeData() );
		$this->assertSame( $user, $fakeRevision->getUser() );
		$this->assertTrue( $fakeRevision->getPage()->exists() );

		ScopedCallback::consume( $fakeRevisionScope );
		$this->assertFalse( $options->getCurrentRevisionRecordCallback()( $page ) );
	}

	public function testRenderReason() {
		$options = ParserOptions::newFromAnon();

		$this->assertIsString( $options->getRenderReason() );

		$options->setRenderReason( 'just a test' );
		$this->assertIsString( 'just a test', $options->getRenderReason() );
	}

	public function testSuppressSectionEditLinks() {
		$options = ParserOptions::newFromAnon();

		$this->assertFalse( $options->getSuppressSectionEditLinks() );

		$options->setSuppressSectionEditLinks();
		$this->assertTrue( $options->getSuppressSectionEditLinks() );
	}

	public function testCollapsibleSections() {
		$options = ParserOptions::newFromAnon();

		$this->assertFalse( $options->getCollapsibleSections() );

		$options->setCollapsibleSections();
		$this->assertTrue( $options->getCollapsibleSections() );
	}
}
PK       ! WWs  Ws    parser/ParserCacheTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use InvalidArgumentException;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Json\JsonCodec;
use MediaWiki\Page\PageRecord;
use MediaWiki\Page\PageStoreRecord;
use MediaWiki\Page\WikiPageFactory;
use MediaWiki\Parser\CacheTime;
use MediaWiki\Parser\ParserCache;
use MediaWiki\Parser\ParserCacheFilter;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Tests\Json\JsonDeserializableSuperClass;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleFactory;
use MediaWiki\User\User;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiIntegrationTestCase;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Psr\Log\NullLogger;
use TestLogger;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\ObjectCache\EmptyBagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\Stats\StatsFactory;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\UUID\GlobalIdGenerator;
use WikiPage;

/**
 * @covers \MediaWiki\Parser\ParserCache
 */
class ParserCacheTest extends MediaWikiIntegrationTestCase {

	/** @var int */
	private $time;

	/** @var string */
	private $cacheTime;

	/** @var PageRecord */
	private $page;

	protected function setUp(): void {
		parent::setUp();
		$this->time = time();
		$this->cacheTime = MWTimestamp::convert( TS_MW, $this->time + 1 );
		$this->page = $this->createPageRecord();

		MWTimestamp::setFakeTime( $this->time );
	}

	/**
	 * @param array $overrides
	 * @return PageRecord
	 */
	private function createPageRecord( array $overrides = [] ): PageRecord {
		return new PageStoreRecord( (object)array_merge( [
			'page_id' => 42,
			'page_namespace' => NS_MAIN,
			'page_title' => 'Testing_Testing',
			'page_latest' => 24,
			'page_is_new' => false,
			'page_is_redirect' => false,
			'page_touched' => $this->time,
			'page_lang' => 'qqx',
		], $overrides ), PageRecord::LOCAL );
	}

	/**
	 * @param HookContainer|null $hookContainer
	 * @param BagOStuff|null $storage
	 * @param LoggerInterface|null $logger
	 * @param WikiPageFactory|null $wikiPageFactory
	 * @return ParserCache
	 */
	private function createParserCache(
		?HookContainer $hookContainer = null,
		?BagOStuff $storage = null,
		?LoggerInterface $logger = null,
		?WikiPageFactory $wikiPageFactory = null
	): ParserCache {
		if ( !$wikiPageFactory ) {
			$wikiPageMock = $this->createMock( WikiPage::class );
			$wikiPageMock->method( 'getContentModel' )->willReturn( CONTENT_MODEL_WIKITEXT );
			$wikiPageFactory = $this->createMock( WikiPageFactory::class );
			$wikiPageFactory->method( 'newFromTitle' )->willReturn( $wikiPageMock );
		}
		$globalIdGenerator = $this->createMock( GlobalIdGenerator::class );
		$globalIdGenerator->method( 'newUUIDv1' )->willReturn( 'uuid-uuid' );
		return new ParserCache(
			'test',
			$storage ?: new HashBagOStuff(),
			'19900220000000',
			$hookContainer ?: $this->createHookContainer( [] ),
			new JsonCodec(),
			StatsFactory::newNull(),
			$logger ?: new NullLogger(),
			$this->createMock( TitleFactory::class ),
			$wikiPageFactory,
			$globalIdGenerator
		);
	}

	/**
	 * @return array
	 */
	private function getDummyUsedOptions(): array {
		return array_slice(
			ParserOptions::allCacheVaryingOptions(),
			0,
			2
		);
	}

	/**
	 * @return ParserOutput
	 */
	private function createDummyParserOutput(): ParserOutput {
		$parserOutput = new ParserOutput();
		$parserOutput->setRawText( 'TEST' );
		foreach ( $this->getDummyUsedOptions() as $option ) {
			$parserOutput->recordOption( $option );
		}
		$parserOutput->updateCacheExpiry( 4242 );
		$parserOutput->setRenderId( 'dummy-render-id' );
		$parserOutput->setCacheRevisionId( 0 );
		// ParserOutput::getCacheTime() also sets it as a side effect
		$parserOutput->setRevisionTimestamp( $parserOutput->getCacheTime() );
		return $parserOutput;
	}

	/**
	 * @covers \MediaWiki\Parser\ParserCache::getMetadata
	 */
	public function testGetMetadataMissing() {
		$cache = $this->createParserCache();
		$metadataFromCache = $cache->getMetadata( $this->page, ParserCache::USE_CURRENT_ONLY );
		$this->assertNull( $metadataFromCache );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserCache::getMetadata
	 */
	public function testGetMetadataAllGood() {
		$cache = $this->createParserCache();
		$parserOutput = $this->createDummyParserOutput();

		$cache->save( $parserOutput, $this->page, ParserOptions::newFromAnon(), $this->cacheTime );

		$metadataFromCache = $cache->getMetadata( $this->page, ParserCache::USE_CURRENT_ONLY );
		$this->assertNotNull( $metadataFromCache );
		$this->assertSame( $this->getDummyUsedOptions(), $metadataFromCache->getUsedOptions() );
		$this->assertSame( 4242, $metadataFromCache->getCacheExpiry() );
		$this->assertSame( $this->page->getLatest(), $metadataFromCache->getCacheRevisionId() );
		$this->assertSame( $this->cacheTime, $metadataFromCache->getCacheTime() );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserCache::getMetadata
	 */
	public function testGetMetadataExpired() {
		$cache = $this->createParserCache();
		$parserOutput = $this->createDummyParserOutput();
		$cache->save( $parserOutput, $this->page, ParserOptions::newFromAnon(), $this->cacheTime );

		$this->page = $this->createPageRecord( [ 'page_touched' => $this->time + 10000 ] );
		$this->assertNull( $cache->getMetadata( $this->page, ParserCache::USE_CURRENT_ONLY ) );
		$metadataFromCache = $cache->getMetadata( $this->page, ParserCache::USE_EXPIRED );
		$this->assertNotNull( $metadataFromCache );
		$this->assertSame( $this->getDummyUsedOptions(), $metadataFromCache->getUsedOptions() );
		$this->assertSame( 4242, $metadataFromCache->getCacheExpiry() );
		$this->assertSame( $this->page->getLatest(), $metadataFromCache->getCacheRevisionId() );
		$this->assertSame( $this->cacheTime, $metadataFromCache->getCacheTime() );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserCache::getMetadata
	 */
	public function testGetMetadataOutdated() {
		$cache = $this->createParserCache();
		$parserOutput = $this->createDummyParserOutput();
		$cache->save( $parserOutput, $this->page, ParserOptions::newFromAnon(), $this->cacheTime );

		$this->page = $this->createPageRecord( [ 'page_latest' => $this->page->getLatest() + 1 ] );
		$this->assertNull( $cache->getMetadata( $this->page, ParserCache::USE_CURRENT_ONLY ) );
		$this->assertNull( $cache->getMetadata( $this->page, ParserCache::USE_EXPIRED ) );
		$metadataFromCache = $cache->getMetadata( $this->page, ParserCache::USE_OUTDATED );
		$this->assertSame( $this->getDummyUsedOptions(), $metadataFromCache->getUsedOptions() );
		$this->assertSame( 4242, $metadataFromCache->getCacheExpiry() );
		$this->assertNotSame( $this->page->getLatest(), $metadataFromCache->getCacheRevisionId() );
		$this->assertSame( $this->cacheTime, $metadataFromCache->getCacheTime() );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserCache::makeParserOutputKey
	 */
	public function testMakeParserOutputKey() {
		$cache = $this->createParserCache();

		$options1 = ParserOptions::newFromAnon();
		$options1->setOption( $this->getDummyUsedOptions()[0], 'value1' );
		$key1 = $cache->makeParserOutputKey( $this->page, $options1, $this->getDummyUsedOptions() );
		$this->assertNotNull( $key1 );

		$options2 = ParserOptions::newFromAnon();
		$options2->setOption( $this->getDummyUsedOptions()[0], 'value2' );
		$key2 = $cache->makeParserOutputKey( $this->page, $options2, $this->getDummyUsedOptions() );
		$this->assertNotNull( $key2 );
		$this->assertNotSame( $key1, $key2 );
	}

	/*
	 * Test that fetching without storing first returns false.
	 * @covers \ParserCache::get
	 */
	public function testGetEmpty() {
		$cache = $this->createParserCache();
		$options = ParserOptions::newFromAnon();

		$this->assertFalse( $cache->get( $this->page, $options ) );
	}

	/**
	 * Test that fetching with the same options return the saved value.
	 * @covers \MediaWiki\Parser\ParserCache::get
	 * @covers \MediaWiki\Parser\ParserCache::save
	 */
	public function testSaveGetSameOptions() {
		$cache = $this->createParserCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );

		$options1 = ParserOptions::newFromAnon();
		$options1->setOption( $this->getDummyUsedOptions()[0], 'value1' );
		$cache->save( $parserOutput, $this->page, $options1, $this->cacheTime );

		$savedOutput = $cache->get( $this->page, $options1 );
		$this->assertInstanceOf( ParserOutput::class, $savedOutput );
		// ParserCache adds a comment to the HTML, so check if the result starts with page content.
		$this->assertStringStartsWith( 'TEST_TEXT', $savedOutput->getRawText() );
		$this->assertSame( $this->cacheTime, $savedOutput->getCacheTime() );
		$this->assertSame( $this->page->getLatest(), $savedOutput->getCacheRevisionId() );
	}

	/**
	 * Test that fetching with different unused option returns a value.
	 * @covers \MediaWiki\Parser\ParserCache::get
	 * @covers \MediaWiki\Parser\ParserCache::save
	 */
	public function testSaveGetDifferentUnusedOption() {
		$cache = $this->createParserCache();
		$optionName = $this->getDummyUsedOptions()[0];
		$parserOutput = new ParserOutput( 'TEST_TEXT' );

		$options1 = ParserOptions::newFromAnon();
		$options1->setOption( $optionName, 'value1' );
		$cache->save( $parserOutput, $this->page, $options1, $this->cacheTime );

		$options2 = ParserOptions::newFromAnon();
		$options2->setOption( $optionName, 'value2' );
		$savedOutput = $cache->get( $this->page, $options2 );
		$this->assertInstanceOf( ParserOutput::class, $savedOutput );
		// ParserCache adds a comment to the HTML, so check if the result starts with page content.
		$this->assertStringStartsWith( 'TEST_TEXT', $savedOutput->getRawText() );
		$this->assertSame( $this->cacheTime, $savedOutput->getCacheTime() );
		$this->assertSame( $this->page->getLatest(), $savedOutput->getCacheRevisionId() );
	}

	/**
	 * Test that non-cacheable output is not stored
	 * @covers \MediaWiki\Parser\ParserCache::save
	 * @covers \MediaWiki\Parser\ParserCache::get
	 */
	public function testDoesNotStoreNonCacheable() {
		$cache = $this->createParserCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );
		$parserOutput->updateCacheExpiry( 0 );

		$options1 = ParserOptions::newFromAnon();
		$cache->save( $parserOutput, $this->page, $options1, $this->cacheTime );

		$this->assertFalse( $cache->get( $this->page, $options1 ) );
		$this->assertFalse( $cache->get( $this->page, $options1, true ) );
		$this->assertFalse( $cache->getDirty( $this->page, $options1 ) );
	}

	/**
	 * Test that ParserCacheFilter can be used to prevent content from being cached
	 * @covers \MediaWiki\Parser\ParserCache::save
	 * @covers \MediaWiki\Parser\ParserCache::get
	 */
	public function testDoesNotStoreFiltered() {
		$cache = $this->createParserCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );
		$parserOutput->resetParseStartTime();
		$parserOutput->recordTimeProfile();

		// Only cache output that took at least 100 seconds of CPU to generate
		$cache->setFilter( new ParserCacheFilter( [
			'default' => [ 'minCpuTime' => 100 ]
		] ) );

		$options1 = ParserOptions::newFromAnon();
		$cache->save( $parserOutput, $this->page, $options1, $this->cacheTime );

		$this->assertFalse( $cache->get( $this->page, $options1 ) );
		$this->assertFalse( $cache->get( $this->page, $options1, true ) );
		$this->assertFalse( $cache->getDirty( $this->page, $options1 ) );
	}

	/**
	 * Test that ParserOptions::isSafeToCache is respected on save
	 * @covers \MediaWiki\Parser\ParserCache::save
	 */
	public function testDoesNotStoreNotSafeToCacheAndUsed() {
		$cache = $this->createParserCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );
		$parserOutput->recordOption( 'wrapclass' );

		$options = ParserOptions::newFromAnon();
		$options->setOption( 'wrapclass', 'wrapwrap' );

		$cache->save( $parserOutput, $this->page, $options, $this->cacheTime );

		$this->assertFalse( $cache->get( $this->page, $options ) );
		$this->assertFalse( $cache->get( $this->page, $options, true ) );
		$this->assertFalse( $cache->getDirty( $this->page, $options ) );
	}

	/**
	 * Test that ParserOptions::isSafeToCache is respected on get
	 * @covers \MediaWiki\Parser\ParserCache::get
	 */
	public function testDoesNotGetNotSafeToCache() {
		$cache = $this->createParserCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );
		$parserOutput->recordOption( 'wrapclass' );

		$cache->save( $parserOutput, $this->page, ParserOptions::newFromAnon(), $this->cacheTime );

		$otherOptions = ParserOptions::newFromAnon();
		$otherOptions->setOption( 'wrapclass', 'wrapwrap' );

		$this->assertFalse( $cache->get( $this->page, $otherOptions ) );
		$this->assertFalse( $cache->get( $this->page, $otherOptions, true ) );
		$this->assertFalse( $cache->getDirty( $this->page, $otherOptions ) );
	}

	/**
	 * Test that ParserOptions::isSafeToCache is respected on save
	 * @covers \MediaWiki\Parser\ParserCache::save
	 * @covers \MediaWiki\Parser\ParserCache::get
	 */
	public function testStoresNotSafeToCacheAndUnused() {
		$cache = $this->createParserCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );

		$options = ParserOptions::newFromAnon();
		$options->setOption( 'wrapclass', 'wrapwrap' );

		$cache->save( $parserOutput, $this->page, $options, $this->cacheTime );
		$this->assertStringContainsString( 'TEST_TEXT', $cache->get( $this->page, $options )
			->getRawText() );
	}

	/**
	 * Test that fetching with different used option don't return a value.
	 * @covers \MediaWiki\Parser\ParserCache::get
	 * @covers \MediaWiki\Parser\ParserCache::save
	 */
	public function testSaveGetDifferentUsedOption() {
		$cache = $this->createParserCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );
		$optionName = $this->getDummyUsedOptions()[0];
		$parserOutput->recordOption( $optionName );

		$options1 = ParserOptions::newFromAnon();
		$options1->setOption( $optionName, 'value1' );
		$cache->save( $parserOutput, $this->page, $options1, $this->cacheTime );

		$options2 = ParserOptions::newFromAnon();
		$options2->setOption( $optionName, 'value2' );
		$this->assertFalse( $cache->get( $this->page, $options2 ) );
	}

	/**
	 * Test that output with expired metadata can be retrieved with getDirty
	 * @covers \MediaWiki\Parser\ParserCache::getDirty
	 * @covers \MediaWiki\Parser\ParserCache::get
	 */
	public function testGetExpiredMetadata() {
		$cache = $this->createParserCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );
		$parserOutput->updateCacheExpiry( 10 );

		$options1 = ParserOptions::newFromAnon();
		$cache->save( $parserOutput, $this->page, $options1, $this->cacheTime );

		MWTimestamp::setFakeTime( $this->time + 15 * 1000 );
		$this->assertFalse( $cache->get( $this->page, $options1 ) );
		$this->assertInstanceOf( ParserOutput::class,
			$cache->get( $this->page, $options1, true ) );
		$this->assertInstanceOf( ParserOutput::class,
			$cache->getDirty( $this->page, $options1 ) );
	}

	/**
	 * Test that expired output with not expired metadata can be retrieved with getDirty
	 * @covers \MediaWiki\Parser\ParserCache::getDirty
	 * @covers \MediaWiki\Parser\ParserCache::get
	 */
	public function testGetExpiredContent() {
		$cache = $this->createParserCache();
		$optionName = $this->getDummyUsedOptions()[0];

		$parserOutput1 = new ParserOutput( 'TEST_TEXT1' );
		$parserOutput1->recordOption( $optionName );
		$parserOutput1->updateCacheExpiry( 10 );
		$options1 = ParserOptions::newFromAnon();
		$options1->setOption( $optionName, 'value1' );
		$cache->save( $parserOutput1, $this->page, $options1, $this->cacheTime );

		$parserOutput2 = new ParserOutput( 'TEST_TEXT2' );
		$parserOutput2->recordOption( $optionName );
		$parserOutput2->updateCacheExpiry( 100500600 );
		$options2 = ParserOptions::newFromAnon();
		$options2->setOption( $optionName, 'value2' );
		$cache->save( $parserOutput2, $this->page, $options2, $this->cacheTime );

		MWTimestamp::setFakeTime( $this->time + 15 * 1000 );
		$this->assertFalse( $cache->get( $this->page, $options1 ) );
		$this->assertInstanceOf( ParserOutput::class,
			$cache->get( $this->page, $options1, true ) );
		$this->assertInstanceOf( ParserOutput::class,
			$cache->getDirty( $this->page, $options1 ) );
	}

	/**
	 * Test that output with outdated metadata can be retrieved with getDirty
	 * @covers \MediaWiki\Parser\ParserCache::getDirty
	 * @covers \MediaWiki\Parser\ParserCache::get
	 */
	public function testGetOutdatedMetadata() {
		$cache = $this->createParserCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );

		$options1 = ParserOptions::newFromAnon();
		$cache->save( $parserOutput, $this->page, $options1, $this->cacheTime );
		$this->assertInstanceOf( ParserOutput::class,
			$cache->get( $this->page, $options1 ) );

		$this->page = $this->createPageRecord( [ 'page_latest' => $this->page->getLatest() + 1 ] );
		$this->assertFalse( $cache->get( $this->page, $options1 ) );
		$this->assertInstanceOf( ParserOutput::class,
			$cache->get( $this->page, $options1, true ) );
		$this->assertInstanceOf( ParserOutput::class,
			$cache->getDirty( $this->page, $options1 ) );
	}

	/**
	 * Test that outdated output with good metadata can be retrieved with getDirty
	 * @covers \MediaWiki\Parser\ParserCache::getDirty
	 * @covers \MediaWiki\Parser\ParserCache::get
	 */
	public function testGetOutdatedContent() {
		$cache = $this->createParserCache();
		$optionName = $this->getDummyUsedOptions()[0];

		$parserOutput1 = new ParserOutput( 'TEST_TEXT' );
		$parserOutput1->recordOption( $optionName );
		$options1 = ParserOptions::newFromAnon();
		$options1->setOption( $optionName, 'value1' );
		$cache->save( $parserOutput1, $this->page, $options1, $this->cacheTime );

		$this->page = $this->createPageRecord( [ 'page_latest' => $this->page->getLatest() + 1 ] );
		$parserOutput2 = new ParserOutput( 'TEST_TEXT' );
		$parserOutput2->recordOption( $optionName );
		$options2 = ParserOptions::newFromAnon();
		$options2->setOption( $optionName, 'value2' );
		$cache->save( $parserOutput2, $this->page, $options2, $this->cacheTime );

		$this->assertFalse( $cache->get( $this->page, $options1 ) );
		$this->assertInstanceOf( ParserOutput::class,
			$cache->get( $this->page, $options1, true ) );
		$this->assertInstanceOf( ParserOutput::class,
			$cache->getDirty( $this->page, $options1 ) );
	}

	/**
	 * Test that fetching after deleting a key returns false.
	 * @covers \MediaWiki\Parser\ParserCache::deleteOptionsKey
	 */
	public function testDeleteOptionsKey() {
		$cache = $this->createParserCache();
		$parserOutput = new ParserOutput( 'TEST_TEXT' );

		$options1 = ParserOptions::newFromAnon();
		$cache->save( $parserOutput, $this->page, $options1, $this->cacheTime );
		$this->assertInstanceOf( ParserOutput::class,
			$cache->get( $this->page, $options1 ) );
		$cache->deleteOptionsKey( $this->page );

		$this->assertFalse( $cache->get( $this->page, $options1 ) );
	}

	/**
	 * Test that RejectParserCacheValue hook can reject ParserOutput
	 * @covers \MediaWiki\Parser\ParserCache::get
	 */
	public function testRejectedByHook() {
		$parserOutput = new ParserOutput( 'TEST_TEXT' );
		$options = ParserOptions::newFromAnon();
		$options->setOption( $this->getDummyUsedOptions()[0], 'value1' );

		$wikiPageMock = $this->createMock( WikiPage::class );
		$wikiPageMock->method( 'getContentModel' )->willReturn( CONTENT_MODEL_WIKITEXT );
		$wikiPageFactory = $this->createMock( WikiPageFactory::class );
		$wikiPageFactory->method( 'newFromTitle' )
			->with( $this->page )
			->willReturn( $wikiPageMock );
		$hookContainer = $this->createHookContainer( [
			'RejectParserCacheValue' =>
				function ( ParserOutput $value, WikiPage $hookPage, ParserOptions $popts )
				use ( $wikiPageMock, $parserOutput, $options ) {
					// parse start time is not saved/restored in the cache
					$parserOutput->clearParseStartTime();
					$this->assertEquals( $parserOutput, $value );
					$this->assertSame( $wikiPageMock, $hookPage );
					$this->assertSame( $options, $popts );
					return false;
				}
		] );
		$cache = $this->createParserCache( $hookContainer, null, null, $wikiPageFactory );
		$cache->save( $parserOutput, $this->page, $options, $this->cacheTime );
		$this->assertFalse( $cache->get( $this->page, $options ) );
	}

	/**
	 * Test that ParserCacheSaveComplete hook is run
	 * @covers \MediaWiki\Parser\ParserCache::save
	 */
	public function testParserCacheSaveCompleteHook() {
		$parserOutput = new ParserOutput( 'TEST_TEXT' );
		$options = ParserOptions::newFromAnon();
		$options->setOption( $this->getDummyUsedOptions()[0], 'value1' );

		$hookContainer = $this->createHookContainer( [
			'ParserCacheSaveComplete' =>
				function (
					ParserCache $hookCache, ParserOutput $value, Title $hookTitle, ParserOptions $popts, int $revId
				) use ( $parserOutput, $options ) {
					$this->assertSame( $parserOutput, $value );
					$this->assertSame( $options, $popts );
					$this->assertSame( 42, $revId );
				}
		] );
		$cache = $this->createParserCache( $hookContainer );
		$cache->save( $parserOutput, $this->page, $options, $this->cacheTime, 42 );
	}

	/**
	 * Tests that parser cache respects skipped if page does not exist
	 * @covers \MediaWiki\Parser\ParserCache::get
	 */
	public function testSkipIfNotExist() {
		$mockPage = $this->createNoOpMock( PageRecord::class, [ 'exists', 'assertWiki' ] );
		$mockPage->method( 'exists' )->willReturn( false );
		$wikiPageMock = $this->createMock( WikiPage::class );
		$wikiPageMock->method( 'getContentModel' )->willReturn( 'wikitext' );
		$wikiPageFactoryMock = $this->createMock( WikiPageFactory::class );
		$wikiPageFactoryMock->method( 'newFromTitle' )
			->with( $mockPage )
			->willReturn( $wikiPageMock );
		$cache = $this->createParserCache( null, null, null, $wikiPageFactoryMock );
		$this->assertFalse( $cache->get( $mockPage, ParserOptions::newFromAnon() ) );
	}

	/**
	 * Tests that parser cache respects skipped if page is redirect
	 * @covers \MediaWiki\Parser\ParserCache::get
	 */
	public function testSkipIfRedirect() {
		$cache = $this->createParserCache();
		$page = $this->createPageRecord( [
			'page_is_redirect' => true
		] );
		$this->assertFalse( $cache->get( $page, ParserOptions::newFromAnon() ) );
	}

	/**
	 * Tests that getCacheStorage returns underlying BagOStuff
	 * @covers \MediaWiki\Parser\ParserCache::getCacheStorage
	 */
	public function testGetCacheStorage() {
		$storage = new EmptyBagOStuff();
		$cache = $this->createParserCache( null, $storage );
		$this->assertSame( $storage, $cache->getCacheStorage() );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserCache::save
	 */
	public function testSaveNoText() {
		$this->expectException( InvalidArgumentException::class );
		$this->createParserCache()->save(
			new ParserOutput( null ),
			$this->page,
			ParserOptions::newFromAnon()
		);
	}

	public static function provideCorruptData() {
		yield 'JSON serialization, bad data' => [ 'bla bla' ];
		yield 'JSON serialization, no _class_' => [ '{"test":"test"}' ];
		yield 'JSON serialization, non-existing _class_' => [ '{"_class_":"NonExistentBogusClass"}' ];
		$wrongInstance = new JsonDeserializableSuperClass( 'test' );
		yield 'JSON serialization, wrong class' => [ json_encode( $wrongInstance->jsonSerialize() ) ];
	}

	/**
	 * Test that we handle corrupt data gracefully.
	 * This is important for forward-compatibility with JSON serialization.
	 * We want to be sure that we don't crash horribly if we have to roll
	 * back to a version of the code that doesn't know about JSON.
	 *
	 * @dataProvider provideCorruptData
	 * @covers \MediaWiki\Parser\ParserCache::get
	 * @covers \MediaWiki\Parser\ParserCache::restoreFromJson
	 * @param string $data
	 */
	public function testCorruptData( string $data ) {
		$cache = $this->createParserCache( null, new HashBagOStuff() );
		$parserOutput = new ParserOutput( 'TEST_TEXT' );

		$options1 = ParserOptions::newFromAnon();
		$cache->save( $parserOutput, $this->page, $options1, $this->cacheTime );

		$outputKey = $cache->makeParserOutputKey(
			$this->page,
			$options1,
			$parserOutput->getUsedOptions()
		);

		$cache->getCacheStorage()->set( $outputKey, $data );

		// just make sure we don't crash and burn
		$this->assertFalse( $cache->get( $this->page, $options1 ) );
	}

	/**
	 * Test that we handle corrupt data gracefully.
	 * This is important for forward-compatibility with JSON serialization.
	 * We want to be sure that we don't crash horribly if we have to roll
	 * back to a version of the code that doesn't know about JSON.
	 *
	 * @covers \MediaWiki\Parser\ParserCache::getMetadata
	 */
	public function testCorruptMetadata() {
		$cacheStorage = new HashBagOStuff();
		$cache = $this->createParserCache( null, $cacheStorage );
		$parserOutput = new ParserOutput( 'TEST_TEXT' );

		$options1 = ParserOptions::newFromAnon();
		$cache->save( $parserOutput, $this->page, $options1, $this->cacheTime );

		// Mess up the metadata
		$optionsKey = TestingAccessWrapper::newFromObject( $cache )->makeMetadataKey(
			$this->page
		);
		$cacheStorage->set( $optionsKey, 'bad data' );

		// Recreate the cache to drop in-memory cached metadata.
		$cache = $this->createParserCache( null, $cacheStorage );

		// just make sure we don't crash and burn
		$this->assertNull( $cache->getMetadata( $this->page ) );
	}

	/**
	 * Test what happens when upgrading from 1.35 or earlier,
	 * when old cache entries do not yet use JSON.
	 *
	 * @covers \MediaWiki\Parser\ParserCache::get
	 */
	public function testMigrationToJson() {
		$bagOStuff = new HashBagOStuff();

		$wikiPageMock = $this->createMock( WikiPage::class );
		$wikiPageMock->method( 'getContentModel' )->willReturn( CONTENT_MODEL_WIKITEXT );
		$wikiPageFactory = $this->createMock( WikiPageFactory::class );
		$wikiPageFactory->method( 'newFromTitle' )->willReturn( $wikiPageMock );
		$globalIdGenerator = $this->createMock( GlobalIdGenerator::class );
		$globalIdGenerator->method( 'newUUIDv1' )->willReturn( 'uuid-uuid' );
		$cache = $this->getMockBuilder( ParserCache::class )
			->setConstructorArgs( [
				'test',
				$bagOStuff,
				'19900220000000',
				$this->createHookContainer( [] ),
				new JsonCodec(),
				StatsFactory::newNull(),
				new NullLogger(),
				$this->createMock( TitleFactory::class ),
				$wikiPageFactory,
				$globalIdGenerator
			] )
			->onlyMethods( [ 'convertForCache' ] )
			->getMock();

		// Emulate pre-1.36 behavior: rely on native PHP serialization.
		// Note that backwards compatibility of the actual serialization is covered
		// by ParserOutputTest which uses various versions of serialized data
		// under tests/phpunit/data/ParserCache.
		$cache->method( 'convertForCache' )->willReturnCallback(
			static function ( CacheTime $obj, string $key ) {
				return $obj;
			}
		);

		$parserOutput1 = new ParserOutput( 'Lorem Ipsum' );

		$options = ParserOptions::newFromAnon();
		$cache->save( $parserOutput1, $this->page, $options, $this->cacheTime );

		// emulate migration to JSON
		$cache = $this->createParserCache( null, $bagOStuff );

		// make sure we can load non-json cache data
		$cachedOutput = $cache->get( $this->page, $options );
		$parserOutput1->clearParseStartTime(); // not saved/restored
		$this->assertEquals( $parserOutput1, $cachedOutput );

		// now test that the cache works when using JSON
		$parserOutput2 = new ParserOutput( 'dolor sit amet' );
		$cache->save( $parserOutput2, $this->page, $options, $this->cacheTime );

		// make sure we can load json cache data
		$cachedOutput = $cache->get( $this->page, $options );
		$parserOutput2->clearParseStartTime(); // not saved/restored
		$this->assertEquals( $parserOutput2, $cachedOutput );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserCache::convertForCache
	 */
	public function testNonSerializableJsonIsReported() {
		$testLogger = new TestLogger( true );
		$cache = $this->createParserCache( null, null, $testLogger );

		$parserOutput = $this->createDummyParserOutput();
		$parserOutput->setExtensionData( 'test', new User() );
		$cache->save( $parserOutput, $this->page, ParserOptions::newFromAnon() );
		$this->assertArraySubmapSame(
			[ [ LogLevel::ERROR, 'Unable to serialize JSON' ] ],
			$testLogger->getBuffer()
		);
	}

	/**
	 * @covers \MediaWiki\Parser\ParserCache::convertForCache
	 */
	public function testCyclicStructuresDoNotBlowUpInJson() {
		$this->markTestSkipped( 'Temporarily disabled: T314338' );
		$testLogger = new TestLogger( true );
		$cache = $this->createParserCache( null, null, $testLogger );

		$parserOutput = $this->createDummyParserOutput();
		$cyclicArray = [ 'a' => 'b' ];
		$cyclicArray['c'] = &$cyclicArray;
		$parserOutput->setExtensionData( 'test', $cyclicArray );
		$cache->save( $parserOutput, $this->page, ParserOptions::newFromAnon() );
		$this->assertArraySubmapSame(
			[ [ LogLevel::ERROR, 'Unable to serialize JSON' ] ],
			$testLogger->getBuffer()
		);
	}

	/**
	 * Tests that unicode characters are not \u escaped
	 *
	 * @covers \MediaWiki\Parser\ParserCache::convertForCache
	 */
	public function testJsonEncodeUnicode() {
		$unicodeCharacter = "Э";
		$cache = $this->createParserCache( null, new HashBagOStuff() );

		$parserOutput = $this->createDummyParserOutput();
		$parserOutput->setRawText( $unicodeCharacter );
		$cache->save( $parserOutput, $this->page, ParserOptions::newFromAnon() );
		$json = $cache->getCacheStorage()->get(
			$cache->makeParserOutputKey( $this->page, ParserOptions::newFromAnon() )
		);
		$this->assertStringNotContainsString( "\u003E", $json );
		$this->assertStringContainsString( $unicodeCharacter, $json );
	}
}
PK       ! x6  6    parser/ParserMethodsTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use HtmlArmor;
use LogicException;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Language\RawMessage;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Parser\Parser;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentityValue;
use MediaWikiLangTestCase;
use MockTitleTrait;

/**
 * @group Database
 * @covers \MediaWiki\Parser\Parser
 * @covers \MediaWiki\Parser\BlockLevelPass
 */
class ParserMethodsTest extends MediaWikiLangTestCase {
	use MockTitleTrait;

	public static function providePreSaveTransform() {
		return [
			[ 'hello this is ~~~',
				"hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]",
			],
			[ 'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
				'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
			],
		];
	}

	/**
	 * @dataProvider providePreSaveTransform
	 */
	public function testPreSaveTransform( $text, $expected ) {
		$title = Title::makeTitle( NS_MAIN, 'TestPreSaveTransform' );
		$user = new User();
		$user->setName( "127.0.0.1" );
		$popts = ParserOptions::newFromUser( $user );
		$text = $this->getServiceContainer()->getParser()
			->preSaveTransform( $text, $title, $user, $popts );

		$this->assertEquals( $expected, $text );
	}

	public static function provideStripOuterParagraph() {
		// This mimics the most common use case (stripping paragraphs generated by the parser).
		$message = new RawMessage( "Message text." );

		return [
			[
				"<p>Text.</p>",
				"Text.",
			],
			[
				"<p class='foo'>Text.</p>",
				"<p class='foo'>Text.</p>",
			],
			[
				"<p>Text.\n</p>\n",
				"Text.",
			],
			[
				"<p>Text.</p><p>More text.</p>",
				"<p>Text.</p><p>More text.</p>",
			],
			[
				$message->parse(),
				"Message text.",
			],
		];
	}

	/**
	 * @dataProvider provideStripOuterParagraph
	 */
	public function testStripOuterParagraph( $text, $expected ) {
		$this->assertEquals( $expected, Parser::stripOuterParagraph( $text ) );
	}

	public static function provideFormatPageTitle() {
		return [
			"Non-main namespace" => [
				[ 'Talk', ':', 'Hello' ],
				'<span class="mw-page-title-namespace">Talk</span><span class="mw-page-title-separator">:</span><span class="mw-page-title-main">Hello</span>',
			],
			"Main namespace (ignores the separator)" => [
				[ '', ':', 'Hello' ],
				'<span class="mw-page-title-main">Hello</span>',
			],
			"Pieces are HTML-escaped" => [
				[ 'Ta&lk', ':', 'He&llo' ],
				'<span class="mw-page-title-namespace">Ta&amp;lk</span><span class="mw-page-title-separator">:</span><span class="mw-page-title-main">He&amp;llo</span>',
			],
			"In the future, the colon separator could be localized" => [
				[ 'Talk', ' : ', 'Hello' ],
				'<span class="mw-page-title-namespace">Talk</span><span class="mw-page-title-separator"> : </span><span class="mw-page-title-main">Hello</span>',
			],
			"In the future, displaytitle could be customized separately from the namespace" => [
				[ 'Talk', ':', new HtmlArmor( '<span class="whatever">Hello</span>' ) ],
				'<span class="mw-page-title-namespace">Talk</span><span class="mw-page-title-separator">:</span><span class="mw-page-title-main"><span class="whatever">Hello</span></span>',
			],
		];
	}

	/**
	 * @dataProvider provideFormatPageTitle
	 */
	public function testFormatPageTitle( $args, $expected ) {
		$this->assertEquals( $expected, Parser::formatPageTitle( ...$args ) );
	}

	public function testRecursiveParse() {
		$title = Title::makeTitle( NS_MAIN, 'Foo' );
		$parser = $this->getServiceContainer()->getParser();
		$po = ParserOptions::newFromAnon();
		$parser->setHook( 'recursivecallparser', [ $this, 'helperParserFunc' ] );
		$this->expectException( LogicException::class );
		$this->expectExceptionMessage(
			"Parser state cleared while parsing. Did you call Parser::parse recursively?"
		);
		$parser->parse( '<recursivecallparser>baz</recursivecallparser>', $title, $po );
	}

	public function helperParserFunc( $input, $args, $parser ) {
		$title = Title::makeTitle( NS_MAIN, 'Foo' );
		$po = ParserOptions::newFromAnon();
		$parser->parse( $input, $title, $po );
		return 'bar';
	}

	public function testCallParserFunction() {
		// Normal parses test passing PPNodes. Test passing an array.
		$title = Title::makeTitle( NS_MAIN, 'TestCallParserFunction' );
		$parser = $this->getServiceContainer()->getParser();
		$parser->startExternalParse(
			$title,
			ParserOptions::newFromAnon(),
			Parser::OT_HTML
		);
		$frame = $parser->getPreprocessor()->newFrame();
		$ret = $parser->callParserFunction( $frame, '#tag',
			[ 'pre', 'foo', 'style' => 'margin-left: 1.6em' ]
		);
		$ret['text'] = $parser->getStripState()->unstripBoth( $ret['text'] );
		$this->assertSame( [
			'found' => true,
			'text' => '<pre style="margin-left: 1.6em">foo</pre>',
		], $ret, 'callParserFunction works for {{#tag:pre|foo|style=margin-left: 1.6em}}' );
	}

	/**
	 * @covers \MediaWiki\Parser\Parser
	 * @covers \MediaWiki\Parser\ParserOutput::getSections
	 */
	public function testGetSections() {
		$this->overrideConfigValue( MainConfigNames::FragmentMode, [ 'html5' ] );
		$title = Title::makeTitle( NS_MAIN, 'TestGetSections' );
		$out = $this->getServiceContainer()->getParser()->parse(
			"==foo==\n<h2>bar</h2>\n==baz==\n== Romeo+Juliet %A Ó %20 ==\ntest",
			$title,
			ParserOptions::newFromAnon()
		);
		$this->assertSame( [
			[
				'toclevel' => 1,
				'level' => '2',
				'line' => 'foo',
				'number' => '1',
				'index' => '1',
				'fromtitle' => $title->getPrefixedDBkey(),
				'byteoffset' => 0,
				'anchor' => 'foo',
				'linkAnchor' => 'foo',
			],
			[
				'toclevel' => 1,
				'level' => '2',
				'line' => 'bar',
				'number' => '2',
				'index' => '',
				'fromtitle' => false,
				'byteoffset' => null,
				'anchor' => 'bar',
				'linkAnchor' => 'bar',
			],
			[
				'toclevel' => 1,
				'level' => '2',
				'line' => 'baz',
				'number' => '3',
				'index' => '2',
				'fromtitle' => $title->getPrefixedDBkey(),
				'byteoffset' => 21,
				'anchor' => 'baz',
				'linkAnchor' => 'baz',
			],
			[
				'toclevel' => 1,
				'level' => '2',
				'line' => 'Romeo+Juliet %A Ó %20',
				'number' => '4',
				'index' => '3',
				'fromtitle' => $title->getPrefixedDBkey(),
				'byteoffset' => 29,
				'anchor' => 'Romeo+Juliet_%A_Ó_%20',
				'linkAnchor' => 'Romeo+Juliet_%A_Ó_%2520',
			]
		], $out->getSections(), 'getSections() with proper value when <h2> is used' );
	}

	/**
	 * @dataProvider provideNormalizeLinkUrl
	 */
	public function testNormalizeLinkUrl( $explanation, $url, $expected ) {
		$this->assertEquals( $expected, Parser::normalizeLinkUrl( $url ), $explanation );
	}

	public static function provideNormalizeLinkUrl() {
		return [
			[
				'Escaping of unsafe characters',
				'http://example.org/foo bar?param[]="value"&param[]=valüe',
				'http://example.org/foo%20bar?param%5B%5D=%22value%22&param%5B%5D=val%C3%BCe',
			],
			[
				'Case normalization of percent-encoded characters',
				'http://example.org/%ab%cD%Ef%FF',
				'http://example.org/%AB%CD%EF%FF',
			],
			[
				'Unescaping of safe characters',
				'http://example.org/%3C%66%6f%6F%3E?%3C%66%6f%6F%3E#%3C%66%6f%6F%3E',
				'http://example.org/%3Cfoo%3E?%3Cfoo%3E#%3Cfoo%3E',
			],
			[
				'Context-sensitive replacement of sometimes-safe characters',
				'http://example.org/%23%2F%3F%26%3D%2B%3B?%23%2F%3F%26%3D%2B%3B#%23%2F%3F%26%3D%2B%3B',
				'http://example.org/%23%2F%3F&=+;?%23/?%26%3D%2B%3B#%23/?&=+;',
			],
			[
				'Removing dot segments in the path part only',
				'http://example.org/foo/../bar?param=foo/../bar#foo/../bar',
				'http://example.org/bar?param=foo/../bar#foo/../bar',
			],
			[
				'IPv6 links aren\'t escaped',
				'http://[::1]/foobar',
				'http://[::1]/foobar',
			],
			[
				'non-IPv6 links aren\'t unescaped',
				'http://%5B::1%5D/foobar',
				'http://%5B::1%5D/foobar',
			],
		];
	}

	public function provideRevisionAccess() {
		$title = $this->makeMockTitle( 'ParserRevisionAccessTest', [
			'language' => MediaWikiServices::getInstance()->getLanguageFactory()->getLanguage( 'en' )
		] );

		$frank = new UserIdentityValue( 5, 'Frank' );

		$text = '* user:{{REVISIONUSER}};id:{{REVISIONID}};time:{{REVISIONTIMESTAMP}};';
		$po = new ParserOptions( $frank );

		yield 'current' => [ $text, $po, 0, 'user:CurrentAuthor;id:200;time:20160606000000;' ];
		yield 'anonymous' => [ $text, $po, null, 'user:;id:;time:' ];
		yield 'current with ID' => [ $text, $po, 200, 'user:CurrentAuthor;id:200;time:20160606000000;' ];

		$text = '* user:{{REVISIONUSER}};id:{{REVISIONID}};time:{{REVISIONTIMESTAMP}};';
		$po = new ParserOptions( $frank );

		yield 'old' => [ $text, $po, 100, 'user:OldAuthor;id:100;time:20140404000000;' ];

		$oldRevision = new MutableRevisionRecord( $title );
		$oldRevision->setId( 100 );
		$oldRevision->setUser( new UserIdentityValue( 7, 'FauxAuthor' ) );
		$oldRevision->setTimestamp( '20141111111111' );
		$oldRevision->setContent( SlotRecord::MAIN, new WikitextContent( 'FAUX' ) );

		$po = new ParserOptions( $frank );
		$po->setCurrentRevisionRecordCallback( static function () use ( $oldRevision ) {
			return $oldRevision;
		} );

		yield 'old with override' => [ $text, $po, 100, 'user:FauxAuthor;id:100;time:20141111111111;' ];

		$text = '* user:{{REVISIONUSER}};user-subst:{{subst:REVISIONUSER}};';

		$po = new ParserOptions( $frank );
		$po->setIsPreview( true );

		yield 'preview without override, using context' => [
			$text,
			$po,
			null,
			'user:Frank;',
			'user-subst:Frank;',
		];

		$text = '* user:{{REVISIONUSER}};time:{{REVISIONTIMESTAMP}};'
			. 'user-subst:{{subst:REVISIONUSER}};time-subst:{{subst:REVISIONTIMESTAMP}};';

		$newRevision = new MutableRevisionRecord( $title );
		$newRevision->setUser( new UserIdentityValue( 9, 'NewAuthor' ) );
		$newRevision->setTimestamp( '20180808000000' );
		$newRevision->setContent( SlotRecord::MAIN, new WikitextContent( 'NEW' ) );

		$po = new ParserOptions( $frank );
		$po->setIsPreview( true );
		$po->setCurrentRevisionRecordCallback( static function () use ( $newRevision ) {
			return $newRevision;
		} );

		yield 'preview' => [
			$text,
			$po,
			null,
			'user:NewAuthor;time:20180808000000;',
			'user-subst:NewAuthor;time-subst:20180808000000;',
		];

		$po = new ParserOptions( $frank );
		$po->setCurrentRevisionRecordCallback( static function () use ( $newRevision ) {
			return $newRevision;
		} );

		yield 'pre-save' => [
			$text,
			$po,
			null,
			'user:NewAuthor;time:20180808000000;',
			'user-subst:NewAuthor;time-subst:20180808000000;',
		];

		$text = "(ONE)<includeonly>(TWO)</includeonly>"
			. "<noinclude>#{{:ParserRevisionAccessTest}}#</noinclude>";

		$newRevision = new MutableRevisionRecord( $title );
		$newRevision->setUser( new UserIdentityValue( 9, 'NewAuthor' ) );
		$newRevision->setTimestamp( '20180808000000' );
		$newRevision->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );

		$po = new ParserOptions( $frank );
		$po->setIsPreview( true );
		$po->setCurrentRevisionRecordCallback( static function () use ( $newRevision ) {
			return $newRevision;
		} );

		yield 'preview with self-transclude' => [ $text, $po, null, '(ONE)#(ONE)(TWO)#' ];
	}

	/**
	 * @dataProvider provideRevisionAccess
	 */
	public function testRevisionAccess(
		$text,
		ParserOptions $po,
		$revId,
		$expectedInHtml,
		$expectedInPst = null
	) {
		$title = $this->makeMockTitle( 'ParserRevisionAccessTest', [
			'language' => $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' )
		] );

		$oldRevision = new MutableRevisionRecord( $title );
		$oldRevision->setId( 100 );
		$oldRevision->setUser( new UserIdentityValue( 7, 'OldAuthor' ) );
		$oldRevision->setTimestamp( '20140404000000' );
		$oldRevision->setContent( SlotRecord::MAIN, new WikitextContent( 'OLD' ) );

		$currentRevision = new MutableRevisionRecord( $title );
		$currentRevision->setId( 200 );
		$currentRevision->setUser( new UserIdentityValue( 9, 'CurrentAuthor' ) );
		$currentRevision->setTimestamp( '20160606000000' );
		$currentRevision->setContent( SlotRecord::MAIN, new WikitextContent( 'CURRENT' ) );

		$revisionStore = $this->createMock( RevisionStore::class );

		$revisionStore
			->method( 'getKnownCurrentRevision' )
			->willReturnMap( [
				[ $title, 100, $oldRevision ],
				[ $title, 200, $currentRevision ],
				[ $title, 0, $currentRevision ],
			] );

		$revisionStore
			->method( 'getRevisionById' )
			->willReturnMap( [
				[ 100, 0, null, $oldRevision ],
				[ 200, 0, null, $currentRevision ],
			] );

		$this->setService( 'RevisionStore', $revisionStore );

		$parser = $this->getServiceContainer()->getParser();
		$parser->parse( $text, $title, $po, true, true, $revId );
		$html = $parser->getOutput()->getRawText();

		$this->assertStringContainsString( $expectedInHtml, $html, 'In HTML' );

		if ( $expectedInPst !== null ) {
			$pst = $parser->preSaveTransform( $text, $title, $po->getUserIdentity(), $po );
			$this->assertStringContainsString( $expectedInPst, $pst, 'After Pre-Safe Transform' );
		}
	}

	public static function provideGuessSectionNameFromWikiText() {
		return [
			[ '1/2', 'html5', '#1/2' ],
			[ '1/2', 'legacy', '#1.2F2' ],
		];
	}

	/** @dataProvider provideGuessSectionNameFromWikiText */
	public function testGuessSectionNameFromWikiText( $input, $mode, $expected ) {
		$this->overrideConfigValue( MainConfigNames::FragmentMode, [ $mode ] );
		$result = $this->getServiceContainer()->getParser()
			->guessSectionNameFromWikiText( $input );
		$this->assertEquals( $expected, $result );
	}

	// @todo Add tests for cleanSig() / cleanSigInSig(), getSection(),
	// replaceSection(), getPreloadText()
}
PK       ! Hqq  q    parser/ParserTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use MediaWiki\Category\TrackingCategories;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Http\HttpRequestFactory;
use MediaWiki\Language\Language;
use MediaWiki\Languages\LanguageConverterFactory;
use MediaWiki\Languages\LanguageNameUtils;
use MediaWiki\Linker\LinkRendererFactory;
use MediaWiki\Page\File\BadFileLookup;
use MediaWiki\Page\PageReference;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Parser\MagicWord;
use MediaWiki\Parser\MagicWordFactory;
use MediaWiki\Parser\Parser;
use MediaWiki\Parser\ParserFactory;
use MediaWiki\Preferences\SignatureValidatorFactory;
use MediaWiki\SpecialPage\SpecialPageFactory;
use MediaWiki\Tidy\TidyDriverBase;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleFormatter;
use MediaWiki\User\Options\UserOptionsLookup;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserNameUtils;
use MediaWiki\Utils\UrlUtils;
use MediaWikiIntegrationTestCase;
use Psr\Log\NullLogger;
use ReflectionObject;
use Wikimedia\ObjectCache\WANObjectCache;

/**
 * @covers \MediaWiki\Parser\Parser::__construct
 */
class ParserTest extends MediaWikiIntegrationTestCase {
	/**
	 * Helper method to create mocks
	 * @return array
	 */
	private function createConstructorArguments() {
		$options = new ServiceOptions(
			Parser::CONSTRUCTOR_OPTIONS,
			array_fill_keys( Parser::CONSTRUCTOR_OPTIONS, null )
		);

		$contLang = $this->createMock( Language::class );
		$mw = new MagicWord( null, [], true, $contLang );

		// Stub out a MagicWordFactory so the Parser can initialize its
		// function hooks when it is created.
		$mwFactory = $this->createNoOpMock( MagicWordFactory::class,
			[ 'get', 'getVariableIDs', 'getSubstArray', 'newArray' ] );
		$mwFactory->method( 'get' )->willReturn( $mw );
		$mwFactory->method( 'getVariableIDs' )->willReturn( [] );

		$urlUtils = $this->createNoOpMock( UrlUtils::class, [ 'validProtocols' ] );
		$urlUtils->method( 'validProtocols' )->willReturn( '' );

		return [
			$options,
			$mwFactory,
			$contLang,
			$this->createNoOpMock( ParserFactory::class ),
			$urlUtils,
			$this->createNoOpMock( SpecialPageFactory::class ),
			$this->createNoOpMock( LinkRendererFactory::class ),
			$this->createNoOpMock( NamespaceInfo::class ),
			new NullLogger(),
			$this->createNoOpMock( BadFileLookup::class ),
			$this->createNoOpMock( LanguageConverterFactory::class, [ 'isConversionDisabled' ] ),
			$this->createNoOpMock( LanguageNameUtils::class ),
			$this->createNoOpMock( HookContainer::class, [ 'run' ] ),
			$this->createNoOpMock( TidyDriverBase::class ),
			$this->createNoOpMock( WANObjectCache::class ),
			$this->createNoOpMock( UserOptionsLookup::class ),
			$this->createNoOpMock( UserFactory::class ),
			$this->createNoOpMock( TitleFormatter::class ),
			$this->createNoOpMock( HttpRequestFactory::class ),
			$this->createNoOpMock( TrackingCategories::class ),
			$this->createNoOpMock( SignatureValidatorFactory::class ),
			$this->createNoOpMock( UserNameUtils::class )
		];
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::__construct
	 */
	public function testConstructorArguments() {
		$args = $this->createConstructorArguments();

		// Fool Parser into thinking we are constructing via a ParserFactory
		ParserFactory::$inParserFactory += 1;
		try {
			$parser = new Parser( ...$args );
		} finally {
			ParserFactory::$inParserFactory -= 1;
		}

		$refObject = new ReflectionObject( $parser );
		foreach ( $refObject->getProperties() as $prop ) {
			$prop->setAccessible( true );
			foreach ( $args as $idx => $mockTest ) {
				if ( $prop->isInitialized( $parser ) && $prop->getValue( $parser ) === $mockTest ) {
					unset( $args[$idx] );
				}
			}
		}
		// The WANObjectCache gets set on the Preprocessor, not the
		// Parser.
		$preproc = $parser->getPreprocessor();
		$refObject = new ReflectionObject( $preproc );
		foreach ( $refObject->getProperties() as $prop ) {
			$prop->setAccessible( true );
			foreach ( $args as $idx => $mockTest ) {
				if ( $prop->getValue( $preproc ) === $mockTest ) {
					unset( $args[$idx] );
				}
			}
		}

		$this->assertSame( [], $args, 'Not all arguments to the Parser constructor were ' .
			'found on the Parser object' );
	}

	/**
	 * @return Parser
	 */
	private function newParser() {
		$args = $this->createConstructorArguments();
		ParserFactory::$inParserFactory += 1;
		try {
			return new Parser( ...$args );
		} finally {
			ParserFactory::$inParserFactory -= 1;
		}
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::setPage
	 * @covers \MediaWiki\Parser\Parser::getPage
	 * @covers \MediaWiki\Parser\Parser::getTitle
	 */
	public function testSetPage() {
		$parser = $this->newParser();

		$page = new PageReferenceValue( NS_SPECIAL, 'Dummy', PageReference::LOCAL );
		$parser->setPage( $page );
		$this->assertTrue( $page->isSamePageAs( $parser->getPage() ) );
		$this->assertTrue( $page->isSamePageAs( $parser->getTitle() ) );
		$this->assertInstanceOf( Title::class, $parser->getTitle() );
	}

	/**
	 * @covers \MediaWiki\Parser\Parser::setPage
	 * @covers \MediaWiki\Parser\Parser::getPage
	 * @covers \MediaWiki\Parser\Parser::getTitle
	 */
	public function testSetTitle() {
		$parser = $this->newParser();

		$title = Title::makeTitle( NS_SPECIAL, 'Dummy' );
		$parser->setTitle( $title );
		$this->assertSame( $title, $parser->getTitle() );
		$this->assertTrue( $title->isSamePageAs( $parser->getPage() ) );
	}
}
PK       ! &HQ  Q    parser/ParserOutputTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use LogicException;
use MediaWiki\Context\RequestContext;
use MediaWiki\Debug\MWDebug;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\ParserOutputFlags;
use MediaWiki\Parser\ParserOutputLinkTypes;
use MediaWiki\Parser\ParserOutputStringSets;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiLangTestCase;
use Wikimedia\Bcp47Code\Bcp47CodeValue;
use Wikimedia\Parsoid\Core\SectionMetadata;
use Wikimedia\Parsoid\Core\TOCData;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Tests\SerializationTestTrait;

/**
 * @covers \MediaWiki\Parser\ParserOutput
 * @covers \Mediawiki\Parser\CacheTime
 * @group Database
 *        ^--- trigger DB shadowing because we are using Title magic
 */
class ParserOutputTest extends MediaWikiLangTestCase {
	use SerializationTestTrait;

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

		MWTimestamp::setFakeTime( ParserCacheSerializationTestCases::FAKE_TIME );
		$this->overrideConfigValue(
			MainConfigNames::ParserCacheExpireTime,
			ParserCacheSerializationTestCases::FAKE_CACHE_EXPIRY
		);
	}

	/**
	 * Overrides SerializationTestTrait::getClassToTest
	 * @return string
	 */
	public static function getClassToTest(): string {
		return ParserOutput::class;
	}

	/**
	 * Overrides SerializationTestTrait::getSerializedDataPath
	 * @return string
	 */
	public static function getSerializedDataPath(): string {
		return __DIR__ . '/../../data/ParserCache';
	}

	/**
	 * Overrides SerializationTestTrait::getTestInstancesAndAssertions
	 * @return array
	 */
	public static function getTestInstancesAndAssertions(): array {
		return ParserCacheSerializationTestCases::getParserOutputTestCases();
	}

	/**
	 * Overrides SerializationTestTrait::getSupportedSerializationFormats
	 * @return array
	 */
	public static function getSupportedSerializationFormats(): array {
		return ParserCacheSerializationTestCases::getSupportedSerializationFormats(
			self::getClassToTest() );
	}

	public static function provideIsLinkInternal() {
		return [
			// Different domains
			[ false, 'http://example.org', 'http://mediawiki.org' ],
			// Same domains
			[ true, 'http://example.org', 'http://example.org' ],
			[ true, 'https://example.org', 'https://example.org' ],
			[ true, '//example.org', '//example.org' ],
			// Same domain different cases
			[ true, 'http://example.org', 'http://EXAMPLE.ORG' ],
			// Paths, queries, and fragments are not relevant
			[ true, 'http://example.org', 'http://example.org/wiki/Main_Page' ],
			[ true, 'http://example.org', 'http://example.org?my=query' ],
			[ true, 'http://example.org', 'http://example.org#its-a-fragment' ],
			// Different protocols
			[ false, 'http://example.org', 'https://example.org' ],
			[ false, 'https://example.org', 'http://example.org' ],
			// Protocol relative servers always match http and https links
			[ true, '//example.org', 'http://example.org' ],
			[ true, '//example.org', 'https://example.org' ],
			// But they don't match strange things like this
			[ false, '//example.org', 'irc://example.org' ],
		];
	}

	/**
	 * Test to make sure ParserOutput::isLinkInternal behaves properly
	 * @dataProvider provideIsLinkInternal
	 * @covers \MediaWiki\Parser\ParserOutput::isLinkInternal
	 */
	public function testIsLinkInternal( $shouldMatch, $server, $url ) {
		$this->assertEquals( $shouldMatch, ParserOutput::isLinkInternal( $server, $url ) );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::appendJsConfigVar
	 * @covers \MediaWiki\Parser\ParserOutput::setJsConfigVar
	 * @covers \MediaWiki\Parser\ParserOutput::getJsConfigVars
	 */
	public function testJsConfigVars() {
		$po = new ParserOutput();

		$po->setJsConfigVar( 'a', '1' );
		$po->appendJsConfigVar( 'b', 'a' );
		$po->appendJsConfigVar( 'b', '0' );

		$this->assertEqualsCanonicalizing( [
			'a' => 1,
			'b' => [ 'a' => true, '0' => true ],
		], $po->getJsConfigVars() );

		$po->setJsConfigVar( 'c', '2' );
		$po->appendJsConfigVar( 'b', 'b' );
		$po->appendJsConfigVar( 'b', '1' );

		$this->assertEqualsCanonicalizing( [
			'a' => 1,
			'b' => [ 'a' => true, 'b' => true, '0' => true, '1' => true ],
			'c' => 2,
		], $po->getJsConfigVars() );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::appendExtensionData
	 * @covers \MediaWiki\Parser\ParserOutput::setExtensionData
	 * @covers \MediaWiki\Parser\ParserOutput::getExtensionData
	 */
	public function testExtensionData() {
		$po = new ParserOutput();

		$po->setExtensionData( "one", "Foo" );
		$po->appendExtensionData( "three", "abc" );

		$this->assertEquals( "Foo", $po->getExtensionData( "one" ) );
		$this->assertNull( $po->getExtensionData( "spam" ) );

		$po->setExtensionData( "two", "Bar" );
		$this->assertEquals( "Foo", $po->getExtensionData( "one" ) );
		$this->assertEquals( "Bar", $po->getExtensionData( "two" ) );

		// Note that overwriting extension data (as this test case
		// does) is deprecated and will eventually throw an
		// exception. However, at the moment it is still worth testing
		// this case to ensure backward compatibility. (T300981)
		$po->setExtensionData( "one", null );
		$this->assertNull( $po->getExtensionData( "one" ) );
		$this->assertEquals( "Bar", $po->getExtensionData( "two" ) );

		$this->assertEqualsCanonicalizing( [
			'abc' => true,
		], $po->getExtensionData( "three" ) );

		$po->appendExtensionData( "three", "xyz" );
		$this->assertEqualsCanonicalizing( [
			'abc' => true,
			'xyz' => true,
		], $po->getExtensionData( "three" ) );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::setPageProperty
	 * @covers \MediaWiki\Parser\ParserOutput::setNumericPageProperty
	 * @covers \MediaWiki\Parser\ParserOutput::setUnsortedPageProperty
	 * @covers \MediaWiki\Parser\ParserOutput::getPageProperty
	 * @covers \MediaWiki\Parser\ParserOutput::unsetPageProperty
	 * @covers \MediaWiki\Parser\ParserOutput::getPageProperties
	 * @dataProvider providePageProperties
	 */
	public function testPageProperties( string $setPageProperty, $value1, $value2, bool $expectDeprecation = false ) {
		$po = new ParserOutput();
		if ( $expectDeprecation ) {
			MWDebug::filterDeprecationForTest( '/::setPageProperty with non-string value/' );
		}

		$po->$setPageProperty( 'foo', $value1 );

		$properties = $po->getPageProperties();
		$this->assertSame( $value1, $po->getPageProperty( 'foo' ) );
		$this->assertSame( $value1, $properties['foo'] );

		$po->$setPageProperty( 'foo', $value2 );

		$properties = $po->getPageProperties();
		$this->assertSame( $value2, $po->getPageProperty( 'foo' ) );
		$this->assertSame( $value2, $properties['foo'] );

		$po->unsetPageProperty( 'foo' );

		$properties = $po->getPageProperties();
		$this->assertSame( null, $po->getPageProperty( 'foo' ) );
		$this->assertArrayNotHasKey( 'foo', $properties );
	}

	public static function providePageProperties() {
		yield 'Unsorted' => [ 'setUnsortedPageProperty', 'val', 'second val' ];
		yield 'Numeric' => [ 'setNumericPageProperty', 42, 3.14 ];
		yield 'Unsorted (old style)' => [ 'setPageProperty', 'val', 'second val' ];
		yield 'Numeric (old style)' => [ 'setPageProperty', 123, 456, true ];
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::setNumericPageProperty
	 */
	public function testNumericPageProperties() {
		$po = new ParserOutput();

		$po->setNumericPageProperty( 'foo', '123' );

		$properties = $po->getPageProperties();
		$this->assertSame( 123, $po->getPageProperty( 'foo' ) );
		$this->assertSame( 123, $properties['foo'] );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::setUnsortedPageProperty
	 */
	public function testUnsortedPageProperties() {
		$po = new ParserOutput();

		$po->setUnsortedPageProperty( 'foo', 123 );

		$properties = $po->getPageProperties();
		$this->assertSame( '123', $po->getPageProperty( 'foo' ) );
		$this->assertSame( '123', $properties['foo'] );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::setLanguage
	 * @covers \MediaWiki\Parser\ParserOutput::getLanguage
	 */
	public function testLanguage() {
		$po = new ParserOutput();

		$langFr = new Bcp47CodeValue( 'fr' );
		$langCrhCyrl = new Bcp47CodeValue( 'crh-cyrl' );

		// Fallback to null
		$this->assertSame( null, $po->getLanguage() );

		// Simple case
		$po->setLanguage( $langFr );
		$this->assertSame( $langFr->toBcp47Code(), $po->getLanguage()->toBcp47Code() );

		// Language with a variant
		$po->setLanguage( $langCrhCyrl );
		$this->assertSame( $langCrhCyrl->toBcp47Code(), $po->getLanguage()->toBcp47Code() );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::getWrapperDivClass
	 * @covers \MediaWiki\Parser\ParserOutput::addWrapperDivClass
	 * @covers \MediaWiki\Parser\ParserOutput::clearWrapperDivClass
	 */
	public function testWrapperDivClass() {
		$po = new ParserOutput();
		$opts = ParserOptions::newFromAnon();
		$pipeline = MediaWikiServices::getInstance()->getDefaultOutputPipeline();

		$po->setRawText( 'Kittens' );
		$text = $pipeline->run( $po, $opts, [] )->getContentHolderText();
		$this->assertStringContainsString( 'Kittens', $text );
		$this->assertStringNotContainsString( '<div', $text );
		$this->assertSame( 'Kittens', $po->getRawText() );

		$po->addWrapperDivClass( 'foo' );
		$text = $pipeline->run( $po, $opts, [] )->getContentHolderText();
		$this->assertStringContainsString( 'Kittens', $text );
		$this->assertStringContainsString( '<div', $text );
		$this->assertStringContainsString( 'class="mw-content-ltr foo"', $text );

		$po->addWrapperDivClass( 'bar' );
		$text = $pipeline->run( $po, $opts, [] )->getContentHolderText();
		$this->assertStringContainsString( 'Kittens', $text );
		$this->assertStringContainsString( '<div', $text );
		$this->assertStringContainsString( 'class="mw-content-ltr foo bar"', $text );

		$po->addWrapperDivClass( 'bar' ); // second time does nothing, no "foo bar bar".
		$text = $pipeline->run( $po, $opts, [ 'unwrap' => true ] )->getContentHolderText();
		$this->assertStringContainsString( 'Kittens', $text );
		$this->assertStringNotContainsString( '<div', $text );
		$this->assertStringNotContainsString( 'class="', $text );

		$text = $pipeline->run( $po, $opts, [ 'wrapperDivClass' => '' ] )->getContentHolderText();
		$this->assertStringContainsString( 'Kittens', $text );
		$this->assertStringNotContainsString( '<div', $text );
		$this->assertStringNotContainsString( 'class="', $text );

		$text = $pipeline->run( $po, $opts, [ 'wrapperDivClass' => 'xyzzy' ] )->getContentHolderText();
		$this->assertStringContainsString( 'Kittens', $text );
		$this->assertStringContainsString( '<div', $text );
		$this->assertStringContainsString( 'class="mw-content-ltr xyzzy"', $text );
		$this->assertStringNotContainsString( 'foo bar', $text );

		$text = $po->getRawText();
		$this->assertSame( 'Kittens', $text );

		$po->clearWrapperDivClass();
		$text = $pipeline->run( $po, $opts, [] )->getContentHolderText();
		$this->assertStringContainsString( 'Kittens', $text );
		$this->assertStringNotContainsString( '<div', $text );
		$this->assertStringNotContainsString( 'class="', $text );
	}

	/**
	 * This test aims at being replaced by its version in DefaultOutputPipelineFactoryTest when
	 * ParserOutput::getText gets deprecated.
	 * @covers \MediaWiki\Parser\ParserOutput::getText
	 * @dataProvider provideGetText
	 * @param array $options Options to getText()
	 * @param string $text Parser text
	 * @param string $expect Expected output
	 */
	public function testGetText( $options, $text, $expect ) {
		// Avoid other skins affecting the section edit links
		$this->overrideConfigValue( MainConfigNames::DefaultSkin, 'fallback' );
		RequestContext::resetMain();

		$this->overrideConfigValues( [
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
		] );

		$po = new ParserOutput( $text );
		self::initSections( $po );
		$actual = $po->getText( $options );
		$this->assertSame( $expect, $actual );
	}

	private static function initSections( ParserOutput $po ): void {
		$po->setTOCData( new TOCData(
			SectionMetadata::fromLegacy( [
				'index' => "1",
				'level' => 1,
				'toclevel' => 1,
				'number' => "1",
				'line' => "Section 1",
				'anchor' => "Section_1"
			] ),
			SectionMetadata::fromLegacy( [
				'index' => "2",
				'level' => 1,
				'toclevel' => 1,
				'number' => "2",
				'line' => "Section 2",
				'anchor' => "Section_2"
			] ),
			SectionMetadata::fromLegacy( [
				'index' => "3",
				'level' => 2,
				'toclevel' => 2,
				'number' => "2.1",
				'line' => "Section 2.1",
				'anchor' => "Section_2.1"
			] ),
			SectionMetadata::fromLegacy( [
				'index' => "4",
				'level' => 1,
				'toclevel' => 1,
				'number' => "3",
				'line' => "Section 3",
				'anchor' => "Section_3"
			] ),
		) );
	}

	public static function provideGetText() {
		$text = <<<EOF
<p>Test document.
</p>
<meta property="mw:PageProp/toc" />
<div class="mw-heading mw-heading2"><h2 id="Section_1">Section 1</h2><mw:editsection page="Test Page" section="1">Section 1</mw:editsection></div>
<p>One
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_2">Section 2</h2><mw:editsection page="Test Page" section="2">Section 2</mw:editsection></div>
<p>Two
</p>
<div class="mw-heading mw-heading3"><h3 id="Section_2.1">Section 2.1</h3></div>
<p>Two point one
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_3">Section 3</h2><mw:editsection page="Test Page" section="4">Section 3</mw:editsection></div>
<p>Three
</p>
EOF;

		$dedupText = <<<EOF
<p>This is a test document.</p>
<style data-mw-deduplicate="duplicate1">.Duplicate1 {}</style>
<style data-mw-deduplicate="duplicate1">.Duplicate1 {}</style>
<style data-mw-deduplicate="duplicate2">.Duplicate2 {}</style>
<style data-mw-deduplicate="duplicate1">.Duplicate1 {}</style>
<style data-mw-deduplicate="duplicate2">.Duplicate2 {}</style>
<style data-mw-not-deduplicate="duplicate1">.Duplicate1 {}</style>
<style data-mw-deduplicate="duplicate1">.Same-attribute-different-content {}</style>
<style data-mw-deduplicate="duplicate3">.Duplicate1 {}</style>
<style>.Duplicate1 {}</style>
EOF;

		return [
			'No options' => [
				[], $text, <<<EOF
<p>Test document.
</p>
<div id="toc" class="toc" role="navigation" aria-labelledby="mw-toc-heading"><input type="checkbox" role="button" id="toctogglecheckbox" class="toctogglecheckbox" style="display:none" /><div class="toctitle" lang="en" dir="ltr"><h2 id="mw-toc-heading">Contents</h2><span class="toctogglespan"><label class="toctogglelabel" for="toctogglecheckbox"></label></span></div>
<ul>
<li class="toclevel-1 tocsection-1"><a href="#Section_1"><span class="tocnumber">1</span> <span class="toctext">Section 1</span></a></li>
<li class="toclevel-1 tocsection-2"><a href="#Section_2"><span class="tocnumber">2</span> <span class="toctext">Section 2</span></a>
<ul>
<li class="toclevel-2 tocsection-3"><a href="#Section_2.1"><span class="tocnumber">2.1</span> <span class="toctext">Section 2.1</span></a></li>
</ul>
</li>
<li class="toclevel-1 tocsection-4"><a href="#Section_3"><span class="tocnumber">3</span> <span class="toctext">Section 3</span></a></li>
</ul>
</div>

<div class="mw-heading mw-heading2"><h2 id="Section_1">Section 1</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=1" title="Edit section: Section 1">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>One
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_2">Section 2</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=2" title="Edit section: Section 2">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>Two
</p>
<div class="mw-heading mw-heading3"><h3 id="Section_2.1">Section 2.1</h3></div>
<p>Two point one
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_3">Section 3</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=4" title="Edit section: Section 3">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>Three
</p>
EOF
			],
			'Disable section edit links' => [
				[ 'enableSectionEditLinks' => false ], $text, <<<EOF
<p>Test document.
</p>
<div id="toc" class="toc" role="navigation" aria-labelledby="mw-toc-heading"><input type="checkbox" role="button" id="toctogglecheckbox" class="toctogglecheckbox" style="display:none" /><div class="toctitle" lang="en" dir="ltr"><h2 id="mw-toc-heading">Contents</h2><span class="toctogglespan"><label class="toctogglelabel" for="toctogglecheckbox"></label></span></div>
<ul>
<li class="toclevel-1 tocsection-1"><a href="#Section_1"><span class="tocnumber">1</span> <span class="toctext">Section 1</span></a></li>
<li class="toclevel-1 tocsection-2"><a href="#Section_2"><span class="tocnumber">2</span> <span class="toctext">Section 2</span></a>
<ul>
<li class="toclevel-2 tocsection-3"><a href="#Section_2.1"><span class="tocnumber">2.1</span> <span class="toctext">Section 2.1</span></a></li>
</ul>
</li>
<li class="toclevel-1 tocsection-4"><a href="#Section_3"><span class="tocnumber">3</span> <span class="toctext">Section 3</span></a></li>
</ul>
</div>

<div class="mw-heading mw-heading2"><h2 id="Section_1">Section 1</h2></div>
<p>One
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_2">Section 2</h2></div>
<p>Two
</p>
<div class="mw-heading mw-heading3"><h3 id="Section_2.1">Section 2.1</h3></div>
<p>Two point one
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_3">Section 3</h2></div>
<p>Three
</p>
EOF
			],
			'Disable TOC, but wrap' => [
				[ 'allowTOC' => false, 'wrapperDivClass' => 'mw-parser-output' ], $text, <<<EOF
<div class="mw-content-ltr mw-parser-output" lang="en" dir="ltr"><p>Test document.
</p>

<div class="mw-heading mw-heading2"><h2 id="Section_1">Section 1</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=1" title="Edit section: Section 1">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>One
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_2">Section 2</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=2" title="Edit section: Section 2">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>Two
</p>
<div class="mw-heading mw-heading3"><h3 id="Section_2.1">Section 2.1</h3></div>
<p>Two point one
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_3">Section 3</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=4" title="Edit section: Section 3">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>Three
</p></div>
EOF
			],
			'Style deduplication' => [
				[], $dedupText, <<<EOF
<p>This is a test document.</p>
<style data-mw-deduplicate="duplicate1">.Duplicate1 {}</style>
<link rel="mw-deduplicated-inline-style" href="mw-data:duplicate1">
<style data-mw-deduplicate="duplicate2">.Duplicate2 {}</style>
<link rel="mw-deduplicated-inline-style" href="mw-data:duplicate1">
<link rel="mw-deduplicated-inline-style" href="mw-data:duplicate2">
<style data-mw-not-deduplicate="duplicate1">.Duplicate1 {}</style>
<link rel="mw-deduplicated-inline-style" href="mw-data:duplicate1">
<style data-mw-deduplicate="duplicate3">.Duplicate1 {}</style>
<style>.Duplicate1 {}</style>
EOF
			],
			'Style deduplication disabled' => [
				[ 'deduplicateStyles' => false ], $dedupText, $dedupText
			],
		];
		// phpcs:enable
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::hasText
	 */
	public function testHasText() {
		$po = new ParserOutput( '' );
		$this->assertTrue( $po->hasText() );

		$po = new ParserOutput( null );
		$this->assertFalse( $po->hasText() );

		$po = new ParserOutput();
		$this->assertFalse( $po->hasText() );

		$po = new ParserOutput( '' );
		$this->assertTrue( $po->hasText() );

		$po = new ParserOutput( null );
		$po->setRawText( '' );
		$this->assertTrue( $po->hasText() );

		$po = new ParserOutput( 'foo' );
		$po->setRawText( null );
		$this->assertFalse( $po->hasText() );
	}

	/**
	 * This test aims at being replaced by its version in DefaultOutputPipelineFactoryTest when
	 * ParserOutput::getText gets deprecated.
	 * @covers \MediaWiki\Parser\ParserOutput::getText
	 */
	public function testGetText_failsIfNoText() {
		$po = new ParserOutput( null );

		$this->expectException( LogicException::class );
		$po->getText();
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::getRawText
	 */
	public function testGetRawText_failsIfNoText() {
		$po = new ParserOutput( null );

		$this->expectException( LogicException::class );
		$po->getRawText();
	}

	public static function provideMergeHtmlMetaDataFrom() {
		// title text ------------
		$a = new ParserOutput();
		$a->setTitleText( 'X' );
		$b = new ParserOutput();
		yield 'only left title text' => [ $a, $b, [ 'getTitleText' => 'X' ] ];

		$a = new ParserOutput();
		$b = new ParserOutput();
		$b->setTitleText( 'Y' );
		yield 'only right title text' => [ $a, $b, [ 'getTitleText' => 'Y' ] ];

		$a = new ParserOutput();
		$a->setTitleText( 'X' );
		$b = new ParserOutput();
		$b->setTitleText( 'Y' );
		yield 'left title text wins' => [ $a, $b, [ 'getTitleText' => 'X' ] ];

		// index policy ------------
		$a = new ParserOutput();
		$a->setIndexPolicy( 'index' );
		$b = new ParserOutput();
		yield 'only left index policy' => [ $a, $b, [ 'getIndexPolicy' => 'index' ] ];

		$a = new ParserOutput();
		$b = new ParserOutput();
		$b->setIndexPolicy( 'index' );
		yield 'only right index policy' => [ $a, $b, [ 'getIndexPolicy' => 'index' ] ];

		$a = new ParserOutput();
		$a->setIndexPolicy( 'noindex' );
		$b = new ParserOutput();
		$b->setIndexPolicy( 'index' );
		yield 'left noindex wins' => [ $a, $b, [ 'getIndexPolicy' => 'noindex' ] ];

		$a = new ParserOutput();
		$a->setIndexPolicy( 'index' );
		$b = new ParserOutput();
		$b->setIndexPolicy( 'noindex' );
		yield 'right noindex wins' => [ $a, $b, [ 'getIndexPolicy' => 'noindex' ] ];

		$crhCyrl = new Bcp47CodeValue( 'crh-cyrl' );

		$a = new ParserOutput();
		$a->setLanguage( $crhCyrl );
		$b = new ParserOutput();
		yield 'only left language' => [ $a, $b, [ 'getLanguage' => $crhCyrl ] ];

		$a = new ParserOutput();
		$b = new ParserOutput();
		$b->setLanguage( $crhCyrl );
		yield 'only right language' => [ $a, $b, [ 'getLanguage' => $crhCyrl ] ];

		// head items and friends ------------
		$a = new ParserOutput();
		$a->addHeadItem( '<foo1>' );
		$a->addHeadItem( '<bar1>', 'bar' );
		$a->addModules( [ 'test-module-a' ] );
		$a->addModuleStyles( [ 'test-module-styles-a' ] );
		$a->setJsConfigVar( 'test-config-var-a', 'a' );
		$a->appendJsConfigVar( 'test-config-var-c', 'abc' );
		$a->appendJsConfigVar( 'test-config-var-c', 'def' );
		$a->addExtraCSPStyleSrc( 'css.com' );
		$a->addExtraCSPStyleSrc( 'css2.com' );
		$a->addExtraCSPScriptSrc( 'js.com' );
		$a->addExtraCSPDefaultSrc( 'img.com' );

		$b = new ParserOutput();
		$b->setIndexPolicy( 'noindex' );
		$b->addHeadItem( '<foo2>' );
		$b->addHeadItem( '<bar2>', 'bar' );
		$b->addModules( [ 'test-module-b' ] );
		$b->addModuleStyles( [ 'test-module-styles-b' ] );
		$b->setJsConfigVar( 'test-config-var-b', 'b' );
		$b->setJsConfigVar( 'test-config-var-a', 'X' );
		$a->appendJsConfigVar( 'test-config-var-c', 'xyz' );
		$a->appendJsConfigVar( 'test-config-var-c', 'def' );
		$b->addExtraCSPStyleSrc( 'https://css.ca' );
		$b->addExtraCSPScriptSrc( 'jscript.com' );
		$b->addExtraCSPScriptSrc( 'vbscript.com' );
		$b->addExtraCSPDefaultSrc( 'img.com/foo.jpg' );

		// Note that overwriting test-config-var-a during the merge
		// (as this test case does) is deprecated and will eventually
		// throw an exception. However, at the moment it is still worth
		// testing this case to ensure backward compatibility. (T300307)
		yield 'head items and friends' => [ $a, $b, [
			'getHeadItems' => [
				'<foo1>',
				'<foo2>',
				'bar' => '<bar2>', // overwritten
			],
			'getModules' => [
				'test-module-a',
				'test-module-b',
			],
			'getModuleStyles' => [
				'test-module-styles-a',
				'test-module-styles-b',
			],
			'getJsConfigVars' => [
				'test-config-var-a' => 'X', // overwritten
				'test-config-var-b' => 'b',
				'test-config-var-c' => [ // merged safely
					'abc' => true, 'def' => true, 'xyz' => true,
				],
			],
			'getExtraCSPStyleSrcs' => [
				'css.com',
				'css2.com',
				'https://css.ca'
			],
			'getExtraCSPScriptSrcs' => [
				'js.com',
				'jscript.com',
				'vbscript.com'
			],
			'getExtraCSPDefaultSrcs' => [
				'img.com',
				'img.com/foo.jpg'
			]
		] ];

		// TOC ------------
		$a = new ParserOutput( '' );
		$a->setSections( [ [ 'fromtitle' => 'A1' ], [ 'fromtitle' => 'A2' ] ] );

		$b = new ParserOutput( '' );
		$b->setSections( [ [ 'fromtitle' => 'B1' ], [ 'fromtitle' => 'B2' ] ] );

		yield 'concat TOC' => [ $a, $b, [
			'getSections' => [
				SectionMetadata::fromLegacy( [ 'fromtitle' => 'A1' ] )->toLegacy(),
				SectionMetadata::fromLegacy( [ 'fromtitle' => 'A2' ] )->toLegacy(),
				SectionMetadata::fromLegacy( [ 'fromtitle' => 'B1' ] )->toLegacy(),
				SectionMetadata::fromLegacy( [ 'fromtitle' => 'B2' ] )->toLegacy()
			],
		] ];

		// Skin Control  ------------
		$a = new ParserOutput();
		$a->setNewSection( true );
		$a->setHideNewSection( true );
		$a->setNoGallery( true );
		$a->addWrapperDivClass( 'foo' );

		$a->setIndicator( 'foo', 'Foo!' );
		$a->setIndicator( 'bar', 'Bar!' );

		$a->setExtensionData( 'foo', 'Foo!' );
		$a->setExtensionData( 'bar', 'Bar!' );
		$a->appendExtensionData( 'bat', 'abc' );

		$b = new ParserOutput();
		$b->setNoGallery( true );
		$b->setEnableOOUI( true );
		$b->setPreventClickjacking( true );
		$a->addWrapperDivClass( 'bar' );

		$b->setIndicator( 'zoo', 'Zoo!' );
		$b->setIndicator( 'bar', 'Barrr!' );

		$b->setExtensionData( 'zoo', 'Zoo!' );
		$b->setExtensionData( 'bar', 'Barrr!' );
		$b->appendExtensionData( 'bat', 'xyz' );

		// Note that overwriting extension data during the merge
		// (as this test case does for 'bar') is deprecated and will eventually
		// throw an exception. However, at the moment it is still worth
		// testing this case to ensure backward compatibility. (T300981)
		yield 'skin control flags' => [ $a, $b, [
			'getNewSection' => true,
			'getHideNewSection' => true,
			'getNoGallery' => true,
			'getEnableOOUI' => true,
			'getPreventClickjacking' => true,
			'getIndicators' => [
				'foo' => 'Foo!',
				'bar' => 'Barrr!', // overwritten
				'zoo' => 'Zoo!',
			],
			'getWrapperDivClass' => 'foo bar',
			'$mExtensionData' => [
				'foo' => 'Foo!',
				'bar' => 'Barrr!', // overwritten
				'zoo' => 'Zoo!',
				// internal strategy key is exposed here because we're looking
				// at the raw property value, not using getExtensionData()
				'bat' => [ 'abc' => true, 'xyz' => true, '_mw-strategy' => 'union' ],
			],
		] ];
	}

	/**
	 * @dataProvider provideMergeHtmlMetaDataFrom
	 * @covers \MediaWiki\Parser\ParserOutput::mergeHtmlMetaDataFrom
	 *
	 * @param ParserOutput $a
	 * @param ParserOutput $b
	 * @param array $expected
	 */
	public function testMergeHtmlMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) {
		$a->mergeHtmlMetaDataFrom( $b );

		$this->assertFieldValues( $a, $expected );

		// test twice, to make sure the operation is idempotent (except for the TOC, see below)
		$a->mergeHtmlMetaDataFrom( $b );

		// XXX: TOC joining should get smarter. Can we make it idempotent as well?
		unset( $expected['getSections'] );

		$this->assertFieldValues( $a, $expected );
	}

	private function assertFieldValues( ParserOutput $po, $expected ) {
		$po = TestingAccessWrapper::newFromObject( $po );

		foreach ( $expected as $method => $value ) {
			$canonicalize = false;
			if ( $method[0] === '$' ) {
				$field = substr( $method, 1 );
				$actual = $po->__get( $field );
			} elseif ( str_contains( $method, '!' ) ) {
				[ $trimmedMethod, $ignore ] = explode( '!', $method, 2 );
				$args = $value['_args_'] ?? [];
				unset( $value['_args_'] );
				$actual = $po->__call( $trimmedMethod, $args );
			} else {
				$actual = $po->__call( $method, [] );
			}
			if ( $method === 'getJsConfigVars' ) {
				$canonicalize = true;
			}

			if ( $canonicalize ) {
				// order of entries isn't significant
				$this->assertEqualsCanonicalizing( $value, $actual, $method );
			} else {
				$this->assertEquals( $value, $actual, $method );
			}
		}
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::addLink
	 * @covers \MediaWiki\Parser\ParserOutput::getLinks
	 * @covers \MediaWiki\Parser\ParserOutput::getLinkList
	 */
	public function testAddLink() {
		$a = new ParserOutput();
		$a->addLink( Title::makeTitle( NS_MAIN, 'Kittens' ), 6 );
		$a->addLink( new TitleValue( NS_TALK, 'Kittens' ), 16 );
		$a->addLink( new TitleValue( NS_MAIN, 'Goats_786827346' ) );
		# fragments are stripped for local links
		$a->addLink( new TitleValue( NS_TALK, 'Puppies', 'Topic' ), 17 );

		$expected = [
			NS_MAIN => [ 'Kittens' => 6, 'Goats_786827346' => 0 ],
			NS_TALK => [ 'Kittens' => 16, 'Puppies' => 17 ]
		];
		$this->assertSame( $expected, $a->getLinks() );
		$expected = [
			[
				'link' => new TitleValue( NS_MAIN, 'Kittens' ),
				'pageid' => 6,
			],
			[
				'link' => new TitleValue( NS_MAIN, 'Goats_786827346' ),
				'pageid' => 0,
			],
			[
				'link' => new TitleValue( NS_TALK, 'Kittens' ),
				'pageid' => 16,
			],
			[
				'link' => new TitleValue( NS_TALK, 'Puppies' ),
				'pageid' => 17,
			],
		];
		$this->assertEquals( $expected, $a->getLinkList( ParserOutputLinkTypes::LOCAL ) );
	}

	public static function provideMergeTrackingMetaDataFrom() {
		// links ------------
		$a = new ParserOutput();
		$a->addLink( Title::makeTitle( NS_MAIN, 'Kittens' ), 6 );
		$a->addLink( new TitleValue( NS_TALK, 'Kittens' ), 16 );
		# fragments are stripped in local links
		$a->addLink( new TitleValue( NS_MAIN, 'Goats', 'Kids' ), 7 );

		$a->addTemplate( Title::makeTitle( NS_TEMPLATE, 'Goats' ), 107, 1107 );

		$a->addLanguageLink( new TitleValue( NS_MAIN, 'de', '', 'de' ) );
		# fragments are preserved in language links
		$a->addLanguageLink( new TitleValue( NS_MAIN, 'ru', 'ru', 'ru' ) );
		$a->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Kittens DE', '', 'de' ) );
		# fragments are stripped in interwiki links
		$a->addInterwikiLink( new TitleValue( NS_MAIN, 'Kittens RU', 'ru', 'ru' ) );
		$a->addExternalLink( 'https://kittens.wikimedia.test' );
		# fragments are preserved in external links
		$a->addExternalLink( 'https://goats.wikimedia.test#kids' );

		# fragments are stripped for categories (syntax is overloaded for sort)
		$a->addCategory( new TitleValue( NS_CATEGORY, 'Foo', 'bar' ), 'X' );
		# fragments are stripped for images
		$a->addImage( new TitleValue( NS_FILE, 'Billy.jpg', 'fragment' ), '20180101000013', 'DEAD' );
		# fragments are stripped for links to special pages
		$a->addLink( new TitleValue( NS_SPECIAL, 'Version', 'section' ) );

		$b = new ParserOutput();
		$b->addLink( Title::makeTitle( NS_MAIN, 'Goats' ), 7 );
		$b->addLink( Title::makeTitle( NS_TALK, 'Goats' ), 17 );
		$b->addLink( new TitleValue( NS_MAIN, 'Dragons' ), 8 );
		$b->addLink( new TitleValue( NS_FILE, 'Dragons.jpg' ), 28 );

		# fragments are stripped from template links
		$b->addTemplate( Title::makeTitle( NS_TEMPLATE, 'Dragons', 'red' ), 108, 1108 );
		$a->addTemplate( new TitleValue( NS_MAIN, 'Dragons', 'platinum' ), 118, 1118 );

		$b->addLanguageLink( new TitleValue( NS_MAIN, 'fr', '', 'fr' ) );
		$b->addLanguageLink( new TitleValue( NS_MAIN, 'ru', 'ru', 'ru' ) );
		$b->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Kittens FR', '', 'fr' ) );
		$b->addInterwikiLink( new TitleValue( NS_MAIN, 'Dragons RU', '', 'ru' ) );
		$b->addExternalLink( 'https://dragons.wikimedia.test' );
		$b->addExternalLink( 'https://goats.wikimedia.test#kids' );

		$b->addCategory( 'Bar', 'Y' );
		$b->addImage( new TitleValue( NS_FILE, 'Puff.jpg' ), '20180101000017', 'BEEF' );

		yield 'all kinds of links' => [ $a, $b, [
			'getLinks' => [
				NS_MAIN => [
					'Kittens' => 6,
					'Goats' => 7,
					'Dragons' => 8,
				],
				NS_TALK => [
					'Kittens' => 16,
					'Goats' => 17,
				],
				NS_FILE => [
					'Dragons.jpg' => 28,
				],
			],
			'getLinkList!LOCAL' => [
				'_args_' => [ ParserOutputLinkTypes::LOCAL ],
				[
					'link' => new TitleValue( NS_MAIN, 'Kittens' ),
					'pageid' => 6,
				],
				[
					'link' => new TitleValue( NS_MAIN, 'Goats' ),
					'pageid' => 7,
				],
				[
					'link' => new TitleValue( NS_MAIN, 'Dragons' ),
					'pageid' => 8,
				],
				[
					'link' => new TitleValue( NS_TALK, 'Kittens' ),
					'pageid' => 16,
				],
				[
					'link' => new TitleValue( NS_TALK, 'Goats' ),
					'pageid' => 17,
				],
				[
					'link' => new TitleValue( NS_FILE, 'Dragons.jpg' ),
					'pageid' => 28,
				],
			],
			'getTemplates' => [
				NS_MAIN => [
					'Dragons' => 118,
				],
				NS_TEMPLATE => [
					'Dragons' => 108,
					'Goats' => 107,
				],
			],
			'getTemplateIds' => [
				NS_MAIN => [
					'Dragons' => 1118,
				],
				NS_TEMPLATE => [
					'Dragons' => 1108,
					'Goats' => 1107,
				],
			],
			'getLinkList!TEMPLATE' => [
				'_args_' => [ ParserOutputLinkTypes::TEMPLATE ],
				[
					'link' => new TitleValue( NS_TEMPLATE, 'Goats' ),
					'pageid' => 107,
					'revid' => 1107,
				],
				[
					'link' => new TitleValue( NS_TEMPLATE, 'Dragons' ),
					'pageid' => 108,
					'revid' => 1108,
				],
				[
					'link' => new TitleValue( NS_MAIN, 'Dragons' ),
					'pageid' => 118,
					'revid' => 1118,
				],
			],
			'getLanguageLinks' => [ 'de:de', 'ru:ru#ru', 'fr:fr' ],
			'getLinkList!LANGUAGE' => [
				'_args_' => [ ParserOutputLinkTypes::LANGUAGE ],
				[
					'link' => new TitleValue( NS_MAIN, 'de', '', 'de' ),
				],
				[
					'link' => new TitleValue( NS_MAIN, 'ru', 'ru', 'ru' ),
				],
				[
					'link' => new TitleValue( NS_MAIN, 'fr', '', 'fr' ),
				],
			],
			'getInterwikiLinks' => [
				'de' => [ 'Kittens_DE' => 1 ],
				'ru' => [ 'Kittens_RU' => 1, 'Dragons_RU' => 1, ],
				'fr' => [ 'Kittens_FR' => 1 ],
			],
			'getLinkList!INTERWIKI' => [
				'_args_' => [ ParserOutputLinkTypes::INTERWIKI ],
				[
					'link' => new TitleValue( NS_MAIN, 'Kittens_DE', '', 'de' ),
				],
				[
					'link' => new TitleValue( NS_MAIN, 'Kittens_RU', '', 'ru' ),
				],
				[
					'link' => new TitleValue( NS_MAIN, 'Dragons_RU', '', 'ru' ),
				],
				[
					'link' => new TitleValue( NS_MAIN, 'Kittens_FR', '', 'fr' ),
				],
			],
			'getCategoryMap' => [ 'Foo' => 'X', 'Bar' => 'Y' ],
			'getLinkList!CATEGORY' => [
				'_args_' => [ ParserOutputLinkTypes::CATEGORY ],
				[
					'link' => new TitleValue( NS_CATEGORY, 'Foo' ),
					'sort' => 'X',
				],
				[
					'link' => new TitleValue( NS_CATEGORY, 'Bar' ),
					'sort' => 'Y',
				],
			],
			'getImages' => [ 'Billy.jpg' => 1, 'Puff.jpg' => 1 ],
			'getFileSearchOptions' => [
				'Billy.jpg' => [ 'time' => '20180101000013', 'sha1' => 'DEAD' ],
				'Puff.jpg' => [ 'time' => '20180101000017', 'sha1' => 'BEEF' ],
			],
			'getLinkList!MEDIA' => [
				'_args_' => [ ParserOutputLinkTypes::MEDIA ],
				[
					'link' => new TitleValue( NS_FILE, 'Billy.jpg' ),
					'time' => '20180101000013',
					'sha1' => 'DEAD',
				],
				[
					'link' => new TitleValue( NS_FILE, 'Puff.jpg' ),
					'time' => '20180101000017',
					'sha1' => 'BEEF',
				],
			],
			'getExternalLinks' => [
				'https://dragons.wikimedia.test' => 1,
				'https://kittens.wikimedia.test' => 1,
				'https://goats.wikimedia.test#kids' => 1,
			],
			'getLinkList!SPECIAL' => [
				'_args_' => [ ParserOutputLinkTypes::SPECIAL ],
				[
					'link' => new TitleValue( NS_SPECIAL, 'Version' ),
				],
			],
		] ];

		// properties ------------
		$a = new ParserOutput();

		$a->setPageProperty( 'foo', 'Foo!' );
		$a->setPageProperty( 'bar', 'Bar!' );

		$a->setExtensionData( 'foo', 'Foo!' );
		$a->setExtensionData( 'bar', 'Bar!' );
		$a->appendExtensionData( 'bat', 'abc' );

		$b = new ParserOutput();

		$b->setPageProperty( 'zoo', 'Zoo!' );
		$b->setPageProperty( 'bar', 'Barrr!' );

		$b->setExtensionData( 'zoo', 'Zoo!' );
		$b->setExtensionData( 'bar', 'Barrr!' );
		$b->appendExtensionData( 'bat', 'xyz' );

		// Note that overwriting extension data during the merge
		// (as this test case does for 'bar') is deprecated and will eventually
		// throw an exception. However, at the moment it is still worth
		// testing this case to ensure backward compatibility. (T300981)
		yield 'properties' => [ $a, $b, [
			'getPageProperties' => [
				'foo' => 'Foo!',
				'bar' => 'Barrr!', // overwritten
				'zoo' => 'Zoo!',
			],
			'$mExtensionData' => [
				'foo' => 'Foo!',
				'bar' => 'Barrr!', // overwritten
				'zoo' => 'Zoo!',
				// internal strategy key is exposed here because we're looking
				// at the raw property value, not using getExtensionData()
				'bat' => [ 'abc' => true, 'xyz' => true, '_mw-strategy' => 'union' ],
			],
		] ];
	}

	/**
	 * @dataProvider provideMergeTrackingMetaDataFrom
	 * @covers \MediaWiki\Parser\ParserOutput::mergeTrackingMetaDataFrom
	 *
	 * @param ParserOutput $a
	 * @param ParserOutput $b
	 * @param array $expected
	 */
	public function testMergeTrackingMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) {
		$a->mergeTrackingMetaDataFrom( $b );

		$this->assertFieldValues( $a, $expected );

		// test twice, to make sure the operation is idempotent
		$a->mergeTrackingMetaDataFrom( $b );

		$this->assertFieldValues( $a, $expected );
	}

	/**
	 * @dataProvider provideMergeTrackingMetaDataFrom
	 * @covers \MediaWiki\Parser\ParserOutput::collectMetadata
	 *
	 * @param ParserOutput $a
	 * @param ParserOutput $b
	 * @param array $expected
	 */
	public function testCollectMetaData( ParserOutput $a, ParserOutput $b, $expected ) {
		$b->collectMetadata( $a );

		$this->assertFieldValues( $a, $expected );
	}

	public function provideMergeInternalMetaDataFrom() {
		$this->filterDeprecated( '/^.*CacheTime::setCacheTime called with -1 as an argument/' );

		// flags & co
		$a = new ParserOutput();

		$a->addWarningMsg( 'duplicate-args-warning', 'A', 'B', 'C' );
		$a->addWarningMsg( 'template-loop-warning', 'D' );

		$a->setOutputFlag( 'foo' );
		$a->setOutputFlag( 'bar' );

		$a->recordOption( 'Foo' );
		$a->recordOption( 'Bar' );

		$b = new ParserOutput();

		$b->addWarningMsg( 'template-equals-warning' );
		$b->addWarningMsg( 'template-loop-warning', 'D' );

		$b->setOutputFlag( 'zoo' );
		$b->setOutputFlag( 'bar' );

		$b->recordOption( 'Zoo' );
		$b->recordOption( 'Bar' );

		yield 'flags' => [ $a, $b, [
			'getWarnings' => [
				wfMessage( 'duplicate-args-warning', 'A', 'B', 'C' )->text(),
				wfMessage( 'template-loop-warning', 'D' )->text(),
				wfMessage( 'template-equals-warning' )->text(),
			],
			'$mFlags' => [ 'foo' => true, 'bar' => true, 'zoo' => true ],
			'getUsedOptions' => [ 'Foo', 'Bar', 'Zoo' ],
		] ];

		// cache time
		$someTime = "20240207202040";
		$someLaterTime = "20240207202112";
		$a = new ParserOutput();
		$a->setCacheTime( $someTime );
		$b = new ParserOutput();
		yield 'only left cache time' => [ $a, $b, [ 'getCacheTime' => $someTime ] ];

		$a = new ParserOutput();
		$b = new ParserOutput();
		$b->setCacheTime( $someTime );
		yield 'only right cache time' => [ $a, $b, [ 'getCacheTime' => $someTime ] ];

		$a = new ParserOutput();
		$b = new ParserOutput();
		$a->setCacheTime( $someLaterTime );
		$b->setCacheTime( $someTime );
		yield 'left has later cache time' => [ $a, $b, [ 'getCacheTime' => $someLaterTime ] ];

		$a = new ParserOutput();
		$b = new ParserOutput();
		$a->setCacheTime( $someTime );
		$b->setCacheTime( $someLaterTime );
		yield 'right has later cache time' => [ $a, $b, [ 'getCacheTime' => $someLaterTime ] ];

		$a = new ParserOutput();
		$b = new ParserOutput();
		$a->setCacheTime( -1 );
		$b->setCacheTime( $someTime );
		yield 'left is uncacheable' => [ $a, $b, [ 'getCacheTime' => "-1" ] ];

		$a = new ParserOutput();
		$b = new ParserOutput();
		$a->setCacheTime( $someTime );
		$b->setCacheTime( -1 );
		yield 'right is uncacheable' => [ $a, $b, [ 'getCacheTime' => "-1" ] ];

		// timestamp ------------
		$a = new ParserOutput();
		$a->setRevisionTimestamp( '20180101000011' );
		$b = new ParserOutput();
		yield 'only left timestamp' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];

		$a = new ParserOutput();
		$b = new ParserOutput();
		$b->setRevisionTimestamp( '20180101000011' );
		yield 'only right timestamp' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];

		$a = new ParserOutput();
		$a->setRevisionTimestamp( '20180101000011' );
		$b = new ParserOutput();
		$b->setRevisionTimestamp( '20180101000001' );
		yield 'left timestamp wins' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];

		$a = new ParserOutput();
		$a->setRevisionTimestamp( '20180101000001' );
		$b = new ParserOutput();
		$b->setRevisionTimestamp( '20180101000011' );
		yield 'right timestamp wins' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];

		// speculative rev id ------------
		$a = new ParserOutput();
		$a->setSpeculativeRevIdUsed( 9 );
		$b = new ParserOutput();
		yield 'only left speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ];

		$a = new ParserOutput();
		$b = new ParserOutput();
		$b->setSpeculativeRevIdUsed( 9 );
		yield 'only right speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ];

		$a = new ParserOutput();
		$a->setSpeculativeRevIdUsed( 9 );
		$b = new ParserOutput();
		$b->setSpeculativeRevIdUsed( 9 );
		yield 'same speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ];

		// limit report (recursive max) ------------
		$a = new ParserOutput();

		$a->setLimitReportData( 'naive1', 7 );
		$a->setLimitReportData( 'naive2', 27 );

		$a->setLimitReportData( 'limitreport-simple1', 7 );
		$a->setLimitReportData( 'limitreport-simple2', 27 );

		$a->setLimitReportData( 'limitreport-pair1', [ 7, 9 ] );
		$a->setLimitReportData( 'limitreport-pair2', [ 27, 29 ] );

		$a->setLimitReportData( 'limitreport-more1', [ 7, 9, 1 ] );
		$a->setLimitReportData( 'limitreport-more2', [ 27, 29, 21 ] );

		$a->setLimitReportData( 'limitreport-only-a', 13 );

		$b = new ParserOutput();

		$b->setLimitReportData( 'naive1', 17 );
		$b->setLimitReportData( 'naive2', 17 );

		$b->setLimitReportData( 'limitreport-simple1', 17 );
		$b->setLimitReportData( 'limitreport-simple2', 17 );

		$b->setLimitReportData( 'limitreport-pair1', [ 17, 19 ] );
		$b->setLimitReportData( 'limitreport-pair2', [ 17, 19 ] );

		$b->setLimitReportData( 'limitreport-more1', [ 17, 19, 11 ] );
		$b->setLimitReportData( 'limitreport-more2', [ 17, 19, 11 ] );

		$b->setLimitReportData( 'limitreport-only-b', 23 );

		// first write wins
		yield 'limit report' => [ $a, $b, [
			'getLimitReportData' => [
				'naive1' => 7,
				'naive2' => 27,
				'limitreport-simple1' => 7,
				'limitreport-simple2' => 27,
				'limitreport-pair1' => [ 7, 9 ],
				'limitreport-pair2' => [ 27, 29 ],
				'limitreport-more1' => [ 7, 9, 1 ],
				'limitreport-more2' => [ 27, 29, 21 ],
				'limitreport-only-a' => 13,
			],
			'getLimitReportJSData' => [
				'naive1' => 7,
				'naive2' => 27,
				'limitreport' => [
					'simple1' => 7,
					'simple2' => 27,
					'pair1' => [ 'value' => 7, 'limit' => 9 ],
					'pair2' => [ 'value' => 27, 'limit' => 29 ],
					'more1' => [ 7, 9, 1 ],
					'more2' => [ 27, 29, 21 ],
					'only-a' => 13,
				],
			],
		] ];

		MWDebug::clearDeprecationFilters();
	}

	/**
	 * @dataProvider provideMergeInternalMetaDataFrom
	 * @covers \MediaWiki\Parser\ParserOutput::mergeInternalMetaDataFrom
	 *
	 * @param ParserOutput $a
	 * @param ParserOutput $b
	 * @param array $expected
	 */
	public function testMergeInternalMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) {
		$this->filterDeprecated( '/^.*CacheTime::setCacheTime called with -1 as an argument/' );
		$a->mergeInternalMetaDataFrom( $b );

		$this->assertFieldValues( $a, $expected );

		// test twice, to make sure the operation is idempotent
		$a->mergeInternalMetaDataFrom( $b );

		$this->assertFieldValues( $a, $expected );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::mergeInternalMetaDataFrom
	 * @covers \MediaWiki\Parser\ParserOutput::getTimes
	 * @covers \MediaWiki\Parser\ParserOutput::resetParseStartTime
	 */
	public function testMergeInternalMetaDataFrom_parseStartTime() {
		/** @var object $a */
		$a = new ParserOutput();
		$a = TestingAccessWrapper::newFromObject( $a );

		$a->resetParseStartTime();
		$aClocks = $a->mParseStartTime;

		$b = new ParserOutput();

		$a->mergeInternalMetaDataFrom( $b );
		$mergedClocks = $a->mParseStartTime;

		foreach ( $mergedClocks as $clock => $timestamp ) {
			$this->assertSame( $aClocks[$clock], $timestamp, $clock );
		}

		// try again, with times in $b also set, and later than $a's
		usleep( 1234 );

		/** @var object $b */
		$b = new ParserOutput();
		$b = TestingAccessWrapper::newFromObject( $b );

		$b->resetParseStartTime();

		$bClocks = $b->mParseStartTime;

		$a->mergeInternalMetaDataFrom( $b->object );
		$mergedClocks = $a->mParseStartTime;

		foreach ( $mergedClocks as $clock => $timestamp ) {
			$this->assertSame( $aClocks[$clock], $timestamp, $clock );
			$this->assertLessThanOrEqual( $bClocks[$clock], $timestamp, $clock );
		}

		// try again, with $a's times being later
		usleep( 1234 );
		$a->resetParseStartTime();
		$aClocks = $a->mParseStartTime;

		$a->mergeInternalMetaDataFrom( $b->object );
		$mergedClocks = $a->mParseStartTime;

		foreach ( $mergedClocks as $clock => $timestamp ) {
			$this->assertSame( $bClocks[$clock], $timestamp, $clock );
			$this->assertLessThanOrEqual( $aClocks[$clock], $timestamp, $clock );
		}

		// try again, with no times in $a set
		$a = new ParserOutput();
		$a = TestingAccessWrapper::newFromObject( $a );

		$a->mergeInternalMetaDataFrom( $b->object );
		$mergedClocks = $a->mParseStartTime;

		foreach ( $mergedClocks as $clock => $timestamp ) {
			$this->assertSame( $bClocks[$clock], $timestamp, $clock );
		}
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::mergeInternalMetaDataFrom
	 * @covers \MediaWiki\Parser\ParserOutput::getTimes
	 * @covers \MediaWiki\Parser\ParserOutput::resetParseStartTime
	 * @covers \MediaWiki\Parser\ParserOutput::recordTimeProfile
	 * @covers \MediaWiki\Parser\ParserOutput::getTimeProfile
	 */
	public function testMergeInternalMetaDataFrom_timeProfile() {
		/** @var object $a */
		$a = new ParserOutput();
		$a = TestingAccessWrapper::newFromObject( $a );

		$a->resetParseStartTime();
		usleep( 1234 );
		$a->recordTimeProfile();

		$aClocks = $a->mTimeProfile;

		// make sure a second call to recordTimeProfile has no effect
		usleep( 1234 );
		$a->recordTimeProfile();

		foreach ( $aClocks as $clock => $duration ) {
			$this->assertNotNull( $duration );
			$this->assertGreaterThan( 0, $duration );
			$this->assertSame( $aClocks[$clock], $a->getTimeProfile( $clock ) );
		}

		$b = new ParserOutput();

		$a->mergeInternalMetaDataFrom( $b );
		$mergedClocks = $a->mTimeProfile;

		foreach ( $mergedClocks as $clock => $duration ) {
			$this->assertSame( $aClocks[$clock], $duration, $clock );
		}

		// try again, with times in $b also set, and later than $a's
		$b->resetParseStartTime();
		usleep( 1234 );
		$b->recordTimeProfile();

		$b = TestingAccessWrapper::newFromObject( $b );
		$bClocks = $b->mTimeProfile;

		$a->mergeInternalMetaDataFrom( $b->object );
		$mergedClocks = $a->mTimeProfile;

		foreach ( $mergedClocks as $clock => $duration ) {
			$this->assertGreaterThanOrEqual( $aClocks[$clock], $duration, $clock );
			$this->assertGreaterThanOrEqual( $bClocks[$clock], $duration, $clock );
		}
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::getCacheTime
	 * @covers \MediaWiki\Parser\ParserOutput::setCacheTime
	 */
	public function testGetCacheTime() {
		$clock = MWTimestamp::convert( TS_UNIX, '20100101000000' );
		MWTimestamp::setFakeTime( static function () use ( &$clock ) {
			return $clock++;
		} );

		$po = new ParserOutput();
		$time = $po->getCacheTime();

		// Use current (fake) time by default. Ignore the last digit.
		// Subsequent calls must yield the exact same timestamp as the first.
		$this->assertStringStartsWith( '2010010100000', $time );
		$this->assertSame( $time, $po->getCacheTime() );

		// After setting, the getter must return the time that was set.
		$time = '20110606112233';
		$po->setCacheTime( $time );
		$this->assertSame( $time, $po->getCacheTime() );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::addExtraCSPScriptSrc
	 * @covers \MediaWiki\Parser\ParserOutput::addExtraCSPDefaultSrc
	 * @covers \MediaWiki\Parser\ParserOutput::addExtraCSPStyleSrc
	 * @covers \MediaWiki\Parser\ParserOutput::getExtraCSPScriptSrcs
	 * @covers \MediaWiki\Parser\ParserOutput::getExtraCSPDefaultSrcs
	 * @covers \MediaWiki\Parser\ParserOutput::getExtraCSPStyleSrcs
	 */
	public function testCSPSources() {
		$po = new ParserOutput;

		$this->assertEquals( [], $po->getExtraCSPScriptSrcs(), 'empty Script' );
		$this->assertEquals( [], $po->getExtraCSPStyleSrcs(), 'empty Style' );
		$this->assertEquals( [], $po->getExtraCSPDefaultSrcs(), 'empty Default' );

		$po->addExtraCSPScriptSrc( 'foo.com' );
		$po->addExtraCSPScriptSrc( 'bar.com' );
		$po->addExtraCSPDefaultSrc( 'baz.com' );
		$po->addExtraCSPStyleSrc( 'fred.com' );
		$po->addExtraCSPStyleSrc( 'xyzzy.com' );

		$this->assertEquals( [ 'foo.com', 'bar.com' ], $po->getExtraCSPScriptSrcs(), 'Script' );
		$this->assertEquals( [ 'baz.com' ], $po->getExtraCSPDefaultSrcs(), 'Default' );
		$this->assertEquals( [ 'fred.com', 'xyzzy.com' ], $po->getExtraCSPStyleSrcs(), 'Style' );
	}

	public function testOutputStrings() {
		$po = new ParserOutput;

		$this->assertEquals( [], $po->getOutputStrings( ParserOutputStringSets::MODULE ) );
		$this->assertEquals( [], $po->getOutputStrings( ParserOutputStringSets::MODULE_STYLE ) );
		$this->assertEquals( [], $po->getOutputStrings( ParserOutputStringSets::EXTRA_CSP_SCRIPT_SRC ) );
		$this->assertEquals( [], $po->getOutputStrings( ParserOutputStringSets::EXTRA_CSP_STYLE_SRC ) );
		$this->assertEquals( [], $po->getOutputStrings( ParserOutputStringSets::EXTRA_CSP_DEFAULT_SRC ) );

		$this->assertEquals( [], $po->getModules() );
		$this->assertEquals( [], $po->getModuleStyles() );
		$this->assertEquals( [], $po->getExtraCSPScriptSrcs() );
		$this->assertEquals( [], $po->getExtraCSPStyleSrcs() );
		$this->assertEquals( [], $po->getExtraCSPDefaultSrcs() );

		$po->appendOutputStrings( ParserOutputStringSets::MODULE, [ 'a' ] );
		$po->appendOutputStrings( ParserOutputStringSets::MODULE_STYLE, [ 'b' ] );
		$po->appendOutputStrings( ParserOutputStringSets::EXTRA_CSP_SCRIPT_SRC, [ 'foo.com', 'bar.com' ] );
		$po->appendOutputStrings( ParserOutputStringSets::EXTRA_CSP_DEFAULT_SRC, [ 'baz.com' ] );
		$po->appendOutputStrings( ParserOutputStringSets::EXTRA_CSP_STYLE_SRC, [ 'fred.com' ] );
		$po->appendOutputStrings( ParserOutputStringSets::EXTRA_CSP_STYLE_SRC, [ 'xyzzy.com' ] );

		$this->assertEquals( [ 'a' ], $po->getOutputStrings( ParserOutputStringSets::MODULE ) );
		$this->assertEquals( [ 'b' ], $po->getOutputStrings( ParserOutputStringSets::MODULE_STYLE ) );
		$this->assertEquals( [ 'foo.com', 'bar.com' ],
							 $po->getOutputStrings( ParserOutputStringSets::EXTRA_CSP_SCRIPT_SRC ) );
		$this->assertEquals( [ 'baz.com' ],
							 $po->getOutputStrings( ParserOutputStringSets::EXTRA_CSP_DEFAULT_SRC ) );
		$this->assertEquals( [ 'fred.com', 'xyzzy.com' ],
							 $po->getOutputStrings( ParserOutputStringSets::EXTRA_CSP_STYLE_SRC ) );

		$this->assertEquals( [ 'a' ], $po->getModules() );
		$this->assertEquals( [ 'b' ], $po->getModuleStyles() );
		$this->assertEquals( [ 'foo.com', 'bar.com' ], $po->getExtraCSPScriptSrcs() );
		$this->assertEquals( [ 'baz.com' ], $po->getExtraCSPDefaultSrcs() );
		$this->assertEquals( [ 'fred.com', 'xyzzy.com' ], $po->getExtraCSPStyleSrcs() );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::getCacheTime()
	 * @covers \MediaWiki\Parser\ParserOutput::setCacheTime()
	 */
	public function testCacheTime() {
		$po = new ParserOutput();

		// Should not have a cache time yet
		$this->assertFalse( $po->hasCacheTime() );
		// But calling ::get assigns a cache time
		$po->getCacheTime();
		$this->assertTrue( $po->hasCacheTime() );
		// Reset cache time
		$po->setCacheTime( "20240207202040" );
		$this->assertSame( "20240207202040", $po->getCacheTime() );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::getRenderId()
	 * @covers \MediaWiki\Parser\ParserOutput::setRenderId()
	 */
	public function testRenderId() {
		$po = new ParserOutput();

		// Should be null when unset
		$this->assertNull( $po->getRenderId() );

		// Sanity check for setter and getter
		$po->setRenderId( "TestRenderId" );
		$this->assertEquals( "TestRenderId", $po->getRenderId() );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::getRenderId()
	 */
	public function testRenderIdBackCompat() {
		$po = new ParserOutput();

		// Parser cache used to contain extension data under a different name
		$po->setExtensionData( 'parsoid-render-id', "1234/LegacyRenderId" );
		$this->assertEquals( "LegacyRenderId", $po->getRenderId() );
	}

	public function testSetFromParserOptions() {
		// parser output set from canonical parser options
		$pOptions = ParserOptions::newFromAnon();
		$pOutput = new ParserOutput;
		$pOutput->setFromParserOptions( $pOptions );
		$this->assertSame( 'mw-parser-output', $pOutput->getWrapperDivClass() );
		$this->assertFalse( $pOutput->getOutputFlag( ParserOutputFlags::IS_PREVIEW ) );
		$this->assertTrue( $pOutput->isCacheable() );
		$this->assertFalse( $pOutput->getOutputFlag( ParserOutputFlags::NO_SECTION_EDIT_LINKS ) );
		$this->assertFalse( $pOutput->getOutputFlag( ParserOutputFlags::COLLAPSIBLE_SECTIONS ) );

		// set the various parser options and verify in parser output
		$pOptions->setWrapOutputClass( 'test-wrapper' );
		$pOptions->setIsPreview( true );
		$pOptions->setSuppressSectionEditLinks();
		$pOptions->setCollapsibleSections();
		$pOutput = new ParserOutput;
		$pOutput->setFromParserOptions( $pOptions );
		$this->assertEquals( 'test-wrapper', $pOutput->getWrapperDivClass() );
		$this->assertTrue( $pOutput->getOutputFlag( ParserOutputFlags::IS_PREVIEW ) );
		$this->assertFalse( $pOutput->isCacheable() );
		$this->assertTrue( $pOutput->getOutputFlag( ParserOutputFlags::NO_SECTION_EDIT_LINKS ) );
		$this->assertTrue( $pOutput->getOutputFlag( ParserOutputFlags::COLLAPSIBLE_SECTIONS ) );
	}
}
PK       ! e[a  [a  ,  parser/ParserCacheSerializationTestCases.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use JsonSerializable;
use MediaWiki\Debug\MWDebug;
use MediaWiki\Json\JsonCodec;
use MediaWiki\Parser\CacheTime;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Tests\Json\JsonDeserializableSubClass;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiIntegrationTestCase;
use stdClass;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Tests\SerializationTestUtils;

/**
 * A collection of serialization test cases for parser cache.
 *
 * Contains a set of acceptance tests for CacheTime and ParserOutput objects.
 * The acceptance tests will be run on instances created by the current code,
 * as well as instances deserialized from stored serializations for various MW
 * versions.
 *
 * Since backwards compatibility for objects stored in ParserCache is necessary,
 * failure of a deserialization test most likely indicates an error in the code.
 * However, since the serialization format may change, thus if proper compatibility
 * logic was added but a serialization test is still failing, you might want to
 * update stored serialized data using validateParserCacheSerializationTestData.php
 * script. The same script should be run when more acceptance tests are added
 * to generate and save serialized object, which would be used for acceptance
 * deserialization tests.
 *
 * See:
 * https://www.mediawiki.org/wiki/Manual:Parser_cache/Serialization_compatibility
 *
 * @see SerializationTestTrait
 * @see SerializationTestUtils
 * @see ValidateParserCacheSerializationTestData
 */
abstract class ParserCacheSerializationTestCases {

	public const FAKE_TIME = 123456789;

	public const FAKE_CACHE_EXPIRY = 42;

	private const MOCK_EXT_DATA = [
		'boolean' => true,
		'null' => null,
		'number' => 42,
		'string' => 'string',
		'array' => [ 1, 2, 3 ],
		'map' => [ 'key' => 'value' ]
	];

	private const MOCK_FALSY_PROPERTIES = [
		'boolean' => false,
		'null' => null,
		'number' => 0,
		'string' => '',
		'numstring' => '0',
		'array' => [],
	];

	private const MOCK_BINARY_PROPERTIES = [
		'empty' => '',
		'\x00' => "\x00",
		'gzip' => "\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03\xcb\x48\xcd\xc9\xc9\x57\x28\xcf\x2f'
			. '\xca\x49\x01\x00\x85\x11\x4a\x0d\x0b\x00\x00\x00",
	];

	private const SECTIONS = [
		[
			'toclevel' => 0,
			'line' => 'heading_1',
			'level' => 1,
			'number' => '1.0',
			'index' => 'T-1',
			'fromtitle' => '',
			'byteoffset' => null,
			'anchor' => 'heading_1',
			'linkAnchor' => '#heading_1',
		],
		[
			'toclevel' => 1,
			'line' => 'heading_2',
			'level' => 2,
			'number' => '2.0',
			'index' => 'T-2',
			'fromtitle' => '',
			'byteoffset' => null,
			'anchor' => 'heading_2',
			'linkAnchor' => '#heading_2'
		],
	];

	private const CACHE_TIME = '20010419042521';

	/**
	 * Get acceptance test cases for CacheTime class.
	 * @see SerializationTestTrait::getTestInstancesAndAssertions()
	 * @return array[]
	 */
	public static function getCacheTimeTestCases(): array {
		$cacheTimeWithUsedOptions = new CacheTime();
		$cacheTimeWithUsedOptions->recordOptions( [ 'optA', 'optX' ] );

		$cacheTimeWithTime = new CacheTime();
		$cacheTimeWithTime->setCacheTime( self::CACHE_TIME );

		$cacheExpiry = 10;
		$cacheTimeWithExpiry = new CacheTime();
		$cacheTimeWithExpiry->updateCacheExpiry( $cacheExpiry );

		$cacheRevisionId = 1234;
		$cacheTimeWithRevId = new CacheTime();
		$cacheTimeWithRevId->setCacheRevisionId( $cacheRevisionId );

		return [
			'empty' => [
				'instance' => new CacheTime(),
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, CacheTime $object ) {
					$testCase->assertSame( self::FAKE_CACHE_EXPIRY, $object->getCacheExpiry() );
					$testCase->assertNull( $object->getCacheRevisionId() );
					$testCase->assertSame(
						MWTimestamp::convert( TS_MW, self::FAKE_TIME ),
						$object->getCacheTime()
					);
					// When the cacheRevisionId is not set, this method always returns true.
					$testCase->assertFalse( $object->isDifferentRevision( 29 ) );
					$testCase->assertFalse( $object->isDifferentRevision( 31 ) );
					$testCase->assertTrue( $object->isCacheable() );
					$testCase->assertSame(
						$object->getCacheTime(),
						MWTimestamp::convert( TS_MW, self::FAKE_TIME )
					);
				}
			],
			'usedOptions' => [
				'instance' => $cacheTimeWithUsedOptions,
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, CacheTime $object ) {
					$testCase->assertArrayEquals( [ 'optA', 'optX' ], $object->getUsedOptions() );
				}
			],
			'cacheTime' => [
				'instance' => $cacheTimeWithTime,
				'assertions' => static function (
					MediaWikiIntegrationTestCase $testCase, CacheTime $object
				) {
					$testCase->assertSame( self::CACHE_TIME, $object->getCacheTime() );
				}
			],
			'cacheExpiry' => [
				'instance' => $cacheTimeWithExpiry,
				'assertions' => static function (
					MediaWikiIntegrationTestCase $testCase, CacheTime $object
				) use ( $cacheExpiry ) {
					$testCase->assertSame( $cacheExpiry, $object->getCacheExpiry() );
				}
			],
			'cacheRevisionId' => [
				'instance' => $cacheTimeWithRevId,
				'assertions' => static function (
					MediaWikiIntegrationTestCase $testCase, CacheTime $object
				) use ( $cacheRevisionId ) {
					$testCase->assertSame( $cacheRevisionId, $object->getCacheRevisionId() );
				}
			]
		];
	}

	/**
	 * Get acceptance test cases for ParserOutput class.
	 * @see SerializationTestTrait::getTestInstancesAndAssertions()
	 * @return array[]
	 */
	public static function getParserOutputTestCases() {
		MWDebug::filterDeprecationForTest( '/::setPageProperty with (non-scalar|non-string|null) value/' );
		MWDebug::filterDeprecationForTest( '/::addLanguageLink without prefix/' );
		$parserOutputWithCacheTimeProps = new ParserOutput( 'CacheTime' );
		$parserOutputWithCacheTimeProps->setCacheTime( self::CACHE_TIME );
		$parserOutputWithCacheTimeProps->updateCacheExpiry( 10 );
		$parserOutputWithCacheTimeProps->setCacheRevisionId( 42 );

		$parserOutputWithUsedOptions = new ParserOutput( 'Dummy' );
		$parserOutputWithUsedOptions->recordOption( 'optA' );
		$parserOutputWithUsedOptions->recordOption( 'optX' );

		$parserOutputWithExtensionData = new ParserOutput( '' );
		foreach ( self::MOCK_EXT_DATA as $key => $value ) {
			$parserOutputWithExtensionData->setExtensionData( $key, $value );
		}

		$parserOutputWithCodecableExtensionData = new ParserOutput( '' );
		$parserOutputWithCodecableExtensionData->setExtensionData(
			'map',
			[
				'a' => new JsonDeserializableSubClass( 'super', 'sub' ),
				'b' => (object)[ 'r' => 2, 'd' => '2' ],
			]
		);

		$parserOutputWithProperties = new ParserOutput( '' );
		foreach ( self::MOCK_EXT_DATA as $key => $value ) {
			$parserOutputWithProperties->setPageProperty( $key, $value );
		}

		$parserOutputWithFalsyProperties = new ParserOutput( '' );
		foreach ( self::MOCK_FALSY_PROPERTIES as $key => $value ) {
			$parserOutputWithFalsyProperties->setPageProperty( $key, $value );
		}

		$parserOutputWithBinaryProperties = new ParserOutput( '' );
		foreach ( self::MOCK_BINARY_PROPERTIES as $key => $value ) {
			$parserOutputWithBinaryProperties->setPageProperty( $key, $value );
		}

		$parserOutputWithMetadata = new ParserOutput( '' );
		$parserOutputWithMetadata->setSpeculativeRevIdUsed( 42 );
		$parserOutputWithMetadata->addLanguageLink( Title::makeTitle( NS_MAIN, 'link1' ) );
		$parserOutputWithMetadata->addLanguageLink( Title::makeTItle( NS_MAIN, 'link2' ) );
		$parserOutputWithMetadata->addInterwikiLink( Title::makeTitle( NS_MAIN, 'interwiki1', '', 'enwiki' ) );
		$parserOutputWithMetadata->addInterwikiLink( Title::makeTitle( NS_MAIN, 'interwiki2', '', 'enwiki' ) );
		$parserOutputWithMetadata->addCategory( Title::makeTitle( NS_CATEGORY, 'category2' ), '1' );
		$parserOutputWithMetadata->addCategory( Title::makeTitle( NS_CATEGORY, 'category1' ), '2' );
		$parserOutputWithMetadata->setIndicator( 'indicator1', 'indicator1_value' );
		$parserOutputWithMetadata->setTitleText( 'title_text1' );
		$parserOutputWithMetadata->setSections( self::SECTIONS );
		$parserOutputWithMetadata->addLink( Title::makeTitle( NS_MAIN, 'Link1' ), 42 );
		$parserOutputWithMetadata->addLink( Title::makeTitle( NS_USER, 'Link2' ), 43 );
		$parserOutputWithMetadata->addTemplate(
			Title::makeTitle( NS_TEMPLATE, 'Template1' ),
			42,
			4242
		);
		$parserOutputWithMetadata->addImage(
			new TitleValue( NS_FILE, 'Image1' ),
			MWTimestamp::convert( TS_MW, 123456789 ),
			'test_sha1'
		);
		$parserOutputWithMetadata->addExternalLink( 'https://test.org' );
		$parserOutputWithMetadata->addHeadItem( 'head_item1', 'tag1' );
		$parserOutputWithMetadata->addModules( [ 'module1' ] );
		$parserOutputWithMetadata->addModuleStyles( [ 'module_style1' ] );
		$parserOutputWithMetadata->setJsConfigVar( 'key1', 'value1' );
		$parserOutputWithMetadata->addWarningMsg( 'rawmessage', 'warning1' );
		$parserOutputWithMetadata->setIndexPolicy( 'noindex' );
		$parserOutputWithMetadata->setRevisionTimestamp( MWTimestamp::convert( TS_MW, 987654321 ) );
		$parserOutputWithMetadata->setLimitReportData( 'limit_report_key1', 'value1' );
		$parserOutputWithMetadata->setEnableOOUI( true );
		$parserOutputWithMetadata->setHideNewSection( true );
		$parserOutputWithMetadata->setNewSection( true );
		$parserOutputWithMetadata->setOutputFlag( 'test' );

		// For compatibility with older serialized objects, clear out the
		// $mWarningMsgs array, which is not currently stored.
		// See T343050 for the steps required to remove this workaround in
		// the future.
		TestingAccessWrapper::newFromObject(
			$parserOutputWithMetadata
		)->mWarningMsgs = [];

		$parserOutputWithSections = new ParserOutput( '' );
		$parserOutputWithSections->setSections( self::SECTIONS );

		$parserOutputWithMetadataPost1_31 = new ParserOutput( '' );
		$parserOutputWithMetadataPost1_31->addWrapperDivClass( 'test_wrapper' );
		$parserOutputWithMetadataPost1_31->setSpeculativePageIdUsed( 4242 );
		$parserOutputWithMetadataPost1_31->setRevisionTimestampUsed(
			MWTimestamp::convert( TS_MW, 123456789 )
		);
		$parserOutputWithMetadataPost1_31->setRevisionUsedSha1Base36( 'test_hash' );
		$parserOutputWithMetadataPost1_31->setNoGallery( true );

		$parserOutputWithMetadataPost1_34 = new ParserOutput( '' );
		$parserOutputWithMetadataPost1_34->addExtraCSPStyleSrc( 'style1' );
		$parserOutputWithMetadataPost1_34->addExtraCSPDefaultSrc( 'default1' );
		$parserOutputWithMetadataPost1_34->addExtraCSPScriptSrc( 'script1' );
		$parserOutputWithMetadataPost1_34->addLink( Title::makeTitle( NS_SPECIAL, 'Link3' ) );

		MWDebug::clearDeprecationFilters();

		$testCases = [
			'empty' => [
				'instance' => new ParserOutput( '' ),
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, ParserOutput $object ) {
					// Empty CacheTime assertions
					self::getCacheTimeTestCases()['empty']['assertions']( $testCase, $object );
					// Empty string text is counted as having text.
					$testCase->assertTrue( $object->hasText() );

					$testCase->assertSame( '', $object->getRawText() );
					$testCase->assertSame( '', $object->getWrapperDivClass() );
					$testCase->assertNull( $object->getSpeculativeRevIdUsed() );
					$testCase->assertNull( $object->getSpeculativePageIdUsed() );
					$testCase->assertNull( $object->getRevisionTimestampUsed() );
					$testCase->assertNull( $object->getRevisionUsedSha1Base36() );
					$testCase->assertArrayEquals( [], $object->getLanguageLinks() );
					$testCase->assertArrayEquals( [], $object->getInterwikiLinks() );
					$testCase->assertArrayEquals( [], $object->getCategoryNames() );
					$testCase->assertArrayEquals( [], $object->getCategoryMap() );
					$testCase->assertArrayEquals( [], $object->getIndicators() );
					$testCase->assertSame( '', $object->getTitleText() );
					$testCase->assertArrayEquals( [], $object->getSections() );
					$testCase->assertArrayEquals( [], $object->getLinks() );
					$testCase->assertArrayEquals( [], $object->getLinksSpecial() );
					$testCase->assertArrayEquals( [], $object->getTemplates() );
					$testCase->assertArrayEquals( [], $object->getTemplateIds() );
					$testCase->assertArrayEquals( [], $object->getImages() );
					$testCase->assertArrayEquals( [], $object->getFileSearchOptions() );
					$testCase->assertArrayEquals( [], $object->getExternalLinks() );
					$testCase->assertFalse( $object->getNoGallery() );
					$testCase->assertArrayEquals( [], $object->getHeadItems() );
					$testCase->assertArrayEquals( [], $object->getModules() );
					$testCase->assertArrayEquals( [], $object->getModuleStyles() );
					$testCase->assertArrayEquals( [], $object->getJsConfigVars() );
					$testCase->assertArrayEquals( [], $object->getWarnings() );
					$testCase->assertSame( '', $object->getIndexPolicy() );
					$testCase->assertNull( $object->getRevisionTimestamp() );
					$testCase->assertArrayEquals( [], $object->getLimitReportData() );
					$testCase->assertArrayEquals( [], $object->getLimitReportJSData() );
					$testCase->assertFalse( $object->getEnableOOUI() );
					$testCase->assertArrayEquals( [], $object->getExtraCSPDefaultSrcs() );
					$testCase->assertArrayEquals( [], $object->getExtraCSPScriptSrcs() );
					$testCase->assertArrayEquals( [], $object->getExtraCSPStyleSrcs() );
					$testCase->assertFalse( $object->getHideNewSection() );
					$testCase->assertFalse( $object->getNewSection() );
					$testCase->assertFalse( $object->getDisplayTitle() );
					$testCase->assertFalse( $object->getOutputFlag( 'test' ) );
					$testCase->assertArrayEquals( [], $object->getAllFlags() );
					$testCase->assertNull( $object->getPageProperty( 'test_prop' ) );
					$testCase->assertArrayEquals( [], $object->getPageProperties() );
					$testCase->assertArrayEquals( [], $object->getUsedOptions() );
					$testCase->assertNull( $object->getExtensionData( 'test_ext_data' ) );
					$testCase->assertNull( $object->getTimeProfile( 'wall' ) );
				}
			],
			'cacheTime' => [
				'instance' => $parserOutputWithCacheTimeProps,
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, ParserOutput $object ) {
					$testCase->assertSame( self::CACHE_TIME, $object->getCacheTime() );
					$testCase->assertSame( 10, $object->getCacheExpiry() );
					$testCase->assertSame( 42, $object->getCacheRevisionId() );
				}
			],
			'text' => [
				'instance' => new ParserOutput( 'Lorem Ipsum' ),
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, ParserOutput $object ) {
					$testCase->assertTrue( $object->hasText() );
					$testCase->assertSame( 'Lorem Ipsum', $object->getRawText() );
				}
			],
			'usedOptions' => [
				'instance' => $parserOutputWithUsedOptions,
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, ParserOutput $object ) {
					$testCase->assertArrayEquals( [ 'optA', 'optX' ], $object->getUsedOptions() );
				}
			],
			'extensionData' => [
				'instance' => $parserOutputWithExtensionData,
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, ParserOutput $object ) {
					$testCase->assertSame( self::MOCK_EXT_DATA['boolean'], $object->getExtensionData( 'boolean' ) );
					$testCase->assertSame( self::MOCK_EXT_DATA['null'], $object->getExtensionData( 'null' ) );
					$testCase->assertSame( self::MOCK_EXT_DATA['number'], $object->getExtensionData( 'number' ) );
					$testCase->assertSame( self::MOCK_EXT_DATA['string'], $object->getExtensionData( 'string' ) );
					$testCase->assertArrayEquals( self::MOCK_EXT_DATA['array'], $object->getExtensionData( 'array' ) );
					$testCase->assertSame( self::MOCK_EXT_DATA['map'], $object->getExtensionData( 'map' ) );
				}
			],
			'codecableExtensionData' => [
				'instance' => $parserOutputWithCodecableExtensionData,
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, ParserOutput $object ) {
					$actual = $object->getExtensionData( 'map' );
					$testCase->assertIsArray( $actual );
					$testCase->assertArrayHasKey( 'a', $actual );
					$testCase->assertInstanceOf(
						JsonDeserializableSubClass::class, $actual['a']
					);
					$testCase->assertSame( 'super', $actual['a']->getSuperClassField() );
					$testCase->assertSame( 'sub', $actual['a']->getSubClassField() );
					$testCase->assertArrayHasKey( 'b', $actual );
					$testCase->assertInstanceOf(
						stdClass::class, $actual['b']
					);
					$testCase->assertSame(
						2, $actual['b']->r
					);
					$testCase->assertSame(
						'2', $actual['b']->d
					);
					$testCase->assertCount( 2, (array)$actual['b'] );
					$testCase->assertCount( 2, $actual );
				}
			],
			'pageProperties' => [
				'instance' => $parserOutputWithProperties,
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, ParserOutput $object ) {
					$testCase->assertSame( self::MOCK_EXT_DATA['boolean'], $object->getPageProperty( 'boolean' ) );
					$testCase->assertSame( self::MOCK_EXT_DATA['null'], $object->getPageProperty( 'null' ) );
					$testCase->assertSame( self::MOCK_EXT_DATA['number'], $object->getPageProperty( 'number' ) );
					$testCase->assertSame( self::MOCK_EXT_DATA['string'], $object->getPageProperty( 'string' ) );
					$testCase->assertArrayEquals( self::MOCK_EXT_DATA['array'], $object->getPageProperty( 'array' ) );
					$testCase->assertSame( self::MOCK_EXT_DATA['map'], $object->getPageProperty( 'map' ) );
					$testCase->assertArrayEquals( self::MOCK_EXT_DATA, $object->getPageProperties() );
				}
			],
			'binaryPageProperties' => [
				'instance' => $parserOutputWithBinaryProperties,
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, ParserOutput $object ) {
					$testCase->assertSame( self::MOCK_BINARY_PROPERTIES['empty'], $object->getPageProperty( 'empty' ) );
					$testCase->assertSame( self::MOCK_BINARY_PROPERTIES['\x00'], $object->getPageProperty( '\x00' ) );
					$testCase->assertSame( self::MOCK_BINARY_PROPERTIES['gzip'], $object->getPageProperty( 'gzip' ) );
					$testCase->assertArrayEquals( self::MOCK_BINARY_PROPERTIES, $object->getPageProperties() );
				}
			],
			'withMetadata' => [
				'instance' => $parserOutputWithMetadata,
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, ParserOutput $object ) {
					$testCase->assertSame( 42, $object->getSpeculativeRevIdUsed() );
					$testCase->assertArrayEquals( [ 'link1', 'link2' ], $object->getLanguageLinks() );
					$testCase->assertArrayEquals( [ 'enwiki' => [
						'interwiki1' => 1,
						'interwiki2' => 1
					] ], $object->getInterwikiLinks() );
					$testCase->assertArrayEquals( [ 'category1', 'category2' ], $object->getCategoryNames() );
					$testCase->assertArrayEquals( [
						'category1' => '2',
						'category2' => '1'
					], $object->getCategoryMap() );
					$testCase->assertArrayEquals( [ 'indicator1' => 'indicator1_value' ], $object->getIndicators() );
					$testCase->assertSame( 'title_text1', $object->getTitleText() );
					$testCase->assertArrayEquals( self::SECTIONS, $object->getSections() );
					$testCase->assertArrayEquals( [
						NS_MAIN => [ 'Link1' => 42 ],
						NS_USER => [ 'Link2' => 43 ]
					], $object->getLinks() );
					$testCase->assertArrayEquals( [
						NS_SPECIAL => [ 'Template1' => 42 ]
					], $object->getTemplates() );
					$testCase->assertArrayEquals( [
						NS_SPECIAL => [ 'Template1' => 4242 ]
					], $object->getTemplateIds() );
					$testCase->assertArrayEquals( [ 'Image1' => 1 ], $object->getImages() );
					$testCase->assertArrayEquals( [ 'Image1' => [
						'time' => MWTimestamp::convert( TS_MW, 123456789 ), 'sha1' => 'test_sha1'
					] ], $object->getFileSearchOptions() );
					$testCase->assertArrayEquals( [ 'https://test.com' => 1 ], $object->getExternalLinks() );
					$testCase->assertArrayEquals( [ 'tag1' => 'head_item1' ], $object->getHeadItems() );
					$testCase->assertArrayEquals( [ 'module1' ], $object->getModules() );
					$testCase->assertArrayEquals( [ 'module_style1' ], $object->getModuleStyles() );
					$testCase->assertArrayEquals( [ 'key1' => 'value1' ], $object->getJsConfigVars() );
					$testCase->assertArrayEquals( [ 'warning1' ], $object->getWarnings() );
					$testCase->assertSame( 'noindex', $object->getIndexPolicy() );
					$testCase->assertSame( MWTimestamp::convert( TS_MW, 987654321 ), $object->getRevisionTimestamp() );
					$testCase->assertArrayEquals(
						[ 'limit_report_key1' => 'value1' ],
						$object->getLimitReportData()
					);
					$testCase->assertArrayEquals(
						[ 'limit_report_key1' => 'value1' ],
						$object->getLimitReportJSData()
					);
					$testCase->assertTrue( $object->getEnableOOUI() );
					$testCase->assertTrue( $object->getHideNewSection() );
					$testCase->assertTrue( $object->getNewSection() );
					$testCase->assertTrue( $object->getOutputFlag( 'test' ) );
					$testCase->assertArrayEquals( [ 'test' ], $object->getAllFlags() );
				}
			],
			'withSections' => [
				'instance' => $parserOutputWithSections,
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, ParserOutput $object ) {
					$testCase->assertArrayEquals( self::SECTIONS, $object->getSections() );
				}
			],
			'withMetadataPost1_31' => [
				'instance' => $parserOutputWithMetadataPost1_31,
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, ParserOutput $object ) {
					$testCase->assertSame( 'test_wrapper', $object->getWrapperDivClass() );
					$testCase->assertSame( 4242, $object->getSpeculativePageIdUsed() );
					$testCase->assertSame(
						MWTimestamp::convert( TS_MW, 123456789 ),
						$object->getRevisionTimestampUsed()
					);
					$testCase->assertSame( 'test_hash', $object->getRevisionUsedSha1Base36() );
					$testCase->assertTrue( $object->getNoGallery() );
				}
			],
			'withMetadataPost1_34' => [
				'instance' => $parserOutputWithMetadataPost1_34,
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, ParserOutput $object ) {
					$testCase->assertArrayEquals( [ 'default1' ], $object->getExtraCSPDefaultSrcs() );
					$testCase->assertArrayEquals( [ 'script1' ], $object->getExtraCSPScriptSrcs() );
					$testCase->assertArrayEquals( [ 'style1' ], $object->getExtraCSPStyleSrcs() );
					$testCase->assertArrayEquals( [ 'Link3' => 1 ], $object->getLinksSpecial() );
				}
			],
			'withFalsyProperties' => [
				'instance' => $parserOutputWithFalsyProperties,
				'assertions' => static function ( MediaWikiIntegrationTestCase $testCase, ParserOutput $object ) {
					$testCase->assertSame(
						self::MOCK_FALSY_PROPERTIES['boolean'],
						$object->getPageProperty( 'boolean' )
					);
					$testCase->assertSame(
						self::MOCK_FALSY_PROPERTIES['null'],
						$object->getPageProperty( 'null' )
					);
					$testCase->assertSame(
						self::MOCK_FALSY_PROPERTIES['number'],
						$object->getPageProperty( 'number' )
					);
					$testCase->assertSame(
						self::MOCK_FALSY_PROPERTIES['string'],
						$object->getPageProperty( 'string' )
					);
					$testCase->assertSame(
						self::MOCK_FALSY_PROPERTIES['numstring'],
						$object->getPageProperty( 'numstring' )
					);
					$testCase->assertArrayEquals(
						self::MOCK_FALSY_PROPERTIES['array'],
						$object->getPageProperty( 'array' )
					);
				}
			],
		];
		// We don't serialize or restore parseStartTime any more, so
		// ensure that it is cleared in the instances we are going to
		// compare against.
		foreach ( $testCases as $name => $case ) {
			$case['instance']->clearParseStartTime();
		}
		return $testCases;
	}

	/**
	 * @param string $class the class name
	 * @return string[][] a list of supported serialization formats info
	 * in the following format:
	 *  'ext' => string file extension for stored serializations
	 *  'serializer' => callable to serialize objects
	 *  'deserializer' => callable to deserialize objects
	 */
	public static function getSupportedSerializationFormats( string $class ): array {
		$serializationFormats = [ [
			'ext' => 'serialized',
			'serializer' => 'serialize',
			'deserializer' => 'unserialize'
		] ];
		if ( is_subclass_of( $class, JsonSerializable::class ) ) {
			$jsonCodec = new JsonCodec();
			$serializationFormats[] = [
				'ext' => 'json',
				'serializer' => static function ( JsonSerializable $obj ) use ( $jsonCodec ) {
					return $jsonCodec->serialize( $obj );
				},
				'deserializer' => static function ( $data ) use ( $jsonCodec ) {
					return $jsonCodec->deserialize( $data );
				}
			];
		}
		// T374736: hack for old test cases
		foreach ( $serializationFormats as [ 'deserializer' => &$d ] ) {
			$oldDeserializer = $d;
			$d = static function ( $data ) use ( $oldDeserializer ) {
				MWDebug::filterDeprecationForTest( '/::addLanguageLink without prefix/' );
				return $oldDeserializer( $data );
			};
		}
		return $serializationFormats;
	}
}
PK       ! 9      parser/ParserPreloadTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use MediaWiki\Parser\Parser;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;

/**
 * Basic tests for Parser::getPreloadText
 * @author Antoine Musso
 *
 * @covers \MediaWiki\Parser\Parser
 * @covers \MediaWiki\Parser\StripState
 *
 * @covers \MediaWiki\Parser\Preprocessor_Hash
 * @covers \MediaWiki\Parser\PPDStack_Hash
 * @covers \MediaWiki\Parser\PPDStackElement_Hash
 * @covers \MediaWiki\Parser\PPDPart_Hash
 * @covers \MediaWiki\Parser\PPFrame_Hash
 * @covers \MediaWiki\Parser\PPTemplateFrame_Hash
 * @covers \MediaWiki\Parser\PPCustomFrame_Hash
 * @covers \MediaWiki\Parser\PPNode_Hash_Tree
 * @covers \MediaWiki\Parser\PPNode_Hash_Text
 * @covers \MediaWiki\Parser\PPNode_Hash_Array
 * @covers \MediaWiki\Parser\PPNode_Hash_Attr
 */
class ParserPreloadTest extends MediaWikiIntegrationTestCase {
	/**
	 * @var Parser
	 */
	private $testParser;
	/**
	 * @var ParserOptions
	 */
	private $testParserOptions;
	/**
	 * @var Title
	 */
	private $title;

	protected function setUp(): void {
		parent::setUp();
		$services = $this->getServiceContainer();

		$this->testParserOptions = ParserOptions::newFromUserAndLang( new User,
			$this->getServiceContainer()->getContentLanguage() );

		$this->testParser = $services->getParserFactory()->create();
		$this->testParser->setOptions( $this->testParserOptions );
		$this->testParser->clearState();

		$this->title = Title::makeTitle( NS_MAIN, 'Preload Test' );
	}

	public function testPreloadSimpleText() {
		$this->assertPreloaded( 'simple', 'simple' );
	}

	public function testPreloadedPreIsUnstripped() {
		$this->assertPreloaded(
			'<pre>monospaced</pre>',
			'<pre>monospaced</pre>',
			'<pre> in preloaded text must be unstripped (T29467)'
		);
	}

	public function testPreloadedNowikiIsUnstripped() {
		$this->assertPreloaded(
			'<nowiki>[[Dummy title]]</nowiki>',
			'<nowiki>[[Dummy title]]</nowiki>',
			'<nowiki> in preloaded text must be unstripped (T29467)'
		);
	}

	protected function assertPreloaded( $expected, $text, $msg = '' ) {
		$this->assertEquals(
			$expected,
			$this->testParser->getPreloadText(
				$text,
				$this->title,
				$this->testParserOptions
			),
			$msg
		);
	}
}
PK       ! &Q*  *    parser/MagicVariableTest.phpnu Iw        <?php

/**
 * This file is intended to test magic variables in the parser
 * It was inspired by Raymond & Matěj Grabovský commenting about r66200
 *
 * As of february 2011, it only tests some revisions and date related
 * magic variables.
 *
 * @author Antoine Musso
 * @copyright Copyright © 2011, Antoine Musso
 * @file
 */

namespace MediaWiki\Tests\Parser;

use MediaWiki\MainConfigNames;
use MediaWiki\Parser\Parser;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Database
 * @covers \MediaWiki\Parser\Parser::expandMagicVariable
 */
class MagicVariableTest extends MediaWikiIntegrationTestCase {
	/**
	 * @var Parser
	 */
	private $testParser = null;

	/** setup a basic parser object */
	protected function setUp(): void {
		parent::setUp();

		$services = $this->getServiceContainer();
		$contLang = $services->getLanguageFactory()->getLanguage( 'en' );
		$this->setService( 'ContentLanguage', $contLang );
		$this->overrideConfigValues( [
			MainConfigNames::LanguageCode => $contLang->getCode(),
			// NOTE: Europe/Stockholm DST applies Sun, Mar 26, 2023 2:00 - Sun, Oct 29, 2023 3:00AM
			MainConfigNames::Localtimezone => 'Europe/Stockholm',
			MainConfigNames::MiserMode => false,
			MainConfigNames::ParserCacheExpireTime => 86400 * 7,
		] );

		$this->testParser = $services->getParserFactory()->create();
		$this->testParser->setOptions( ParserOptions::newFromUserAndLang( new User, $contLang ) );

		# initialize parser output
		$this->testParser->clearState();

		# Needs a title to do magic word stuff
		$title = Title::makeTitle( NS_MAIN, 'Tests' );
		# Else it needs a db connection just to check if it's a redirect
		# (when deciding the page language).
		$title->mRedirect = false;

		$this->testParser->setTitle( $title );
	}

	/**
	 * @param int $num Upper limit for numbers
	 * @return array Array of strings naming numbers from 1 up to $num
	 */
	private static function createProviderUpTo( $num ) {
		$ret = [];
		for ( $i = 1; $i <= $num; $i++ ) {
			$ret[] = [ strval( $i ) ];
		}

		return $ret;
	}

	/**
	 * @return array Array of months numbers (as an integer)
	 */
	public static function provideMonths() {
		return self::createProviderUpTo( 12 );
	}

	/**
	 * @return array Array of days numbers (as an integer)
	 */
	public static function provideDays() {
		return self::createProviderUpTo( 31 );
	}

	# ############## TESTS #############################################
	# @todo FIXME:
	#  - those got copy pasted, we can probably make them cleaner
	#  - tests are lacking useful messages

	# day

	/** @dataProvider provideDays */
	public function testCurrentdayIsUnPadded( $day ) {
		$this->assertUnPadded( 'currentday', $day );
	}

	/** @dataProvider provideDays */
	public function testCurrentdaytwoIsZeroPadded( $day ) {
		$this->assertZeroPadded( 'currentday2', $day );
	}

	/** @dataProvider provideDays */
	public function testLocaldayIsUnPadded( $day ) {
		$this->assertUnPadded( 'localday', $day );
	}

	/** @dataProvider provideDays */
	public function testLocaldaytwoIsZeroPadded( $day ) {
		$this->assertZeroPadded( 'localday2', $day );
	}

	# month

	/** @dataProvider provideMonths */
	public function testCurrentmonthIsZeroPadded( $month ) {
		$this->assertZeroPadded( 'currentmonth', $month );
	}

	/** @dataProvider provideMonths */
	public function testCurrentmonthoneIsUnPadded( $month ) {
		$this->assertUnPadded( 'currentmonth1', $month );
	}

	/** @dataProvider provideMonths */
	public function testLocalmonthIsZeroPadded( $month ) {
		$this->assertZeroPadded( 'localmonth', $month );
	}

	/** @dataProvider provideMonths */
	public function testLocalmonthoneIsUnPadded( $month ) {
		$this->assertUnPadded( 'localmonth1', $month );
	}

	# revision day

	/** @dataProvider provideDays */
	public function testRevisiondayIsUnPadded( $day ) {
		$this->assertUnPadded( 'revisionday', $day );
	}

	/** @dataProvider provideDays */
	public function testRevisiondaytwoIsZeroPadded( $day ) {
		$this->assertZeroPadded( 'revisionday2', $day );
	}

	# revision month

	/** @dataProvider provideMonths */
	public function testRevisionmonthIsZeroPadded( $month ) {
		$this->assertZeroPadded( 'revisionmonth', $month );
	}

	/** @dataProvider provideMonths */
	public function testRevisionmonthoneIsUnPadded( $month ) {
		$this->assertUnPadded( 'revisionmonth1', $month );
	}

	public static function provideCurrentUnitTimestampWords() {
		return [
			// Afternoon
			[ 'currentmonth', '20200208153011', '02', 604800 ],
			[ 'currentmonth1', '20200208153011', '2', 604800 ],
			[ 'currentmonthname', '20200208153011', 'February', 604800 ],
			[ 'currentmonthnamegen', '20200208153011', 'February', 604800 ],
			[ 'currentmonthabbrev', '20200208153011', 'Feb', 604800 ],
			[ 'currentday', '20200208153011', '8', 30601 ],
			[ 'currentday2', '20200208153011', '08', 30601 ],
			[ 'currentdayname', '20200208153011', 'Saturday', 30601 ],
			[ 'currentyear', '20200208153011', '2020', 604800 ],
			[ 'currenthour', '20200208153011', '15', 1801 ],
			[ 'currentweek', '20200208153011', '6', 30601 ],
			[ 'currentdow', '20200208153011', '6', 30601 ],
			[ 'currenttime', '20200208153011', '15:30', 3600 ],
			// Night
			[ 'currentmonth', '20200208223011', '02', 604800 ],
			[ 'currentmonth1', '20200208223011', '2', 604800 ],
			[ 'currentmonthname', '20200208223011', 'February', 604800 ],
			[ 'currentmonthnamegen', '20200208223011', 'February', 604800 ],
			[ 'currentmonthabbrev', '20200208223011', 'Feb', 604800 ],
			[ 'currentday', '20200208223011', '8', 5401 ],
			[ 'currentday2', '20200208223011', '08', 5401 ],
			[ 'currentdayname', '20200208223011', 'Saturday', 5401 ],
			[ 'currentyear', '20200208223011', '2020', 604800 ],
			[ 'currenthour', '20200208223011', '22', 1801 ],
			[ 'currentweek', '20200208223011', '6', 5401 ],
			[ 'currentdow', '20200208223011', '6', 5401 ],
			[ 'currenttime', '20200208223011', '22:30', 3600 ]
		];
	}

	public static function provideLocalUnitTimestampWords() {
		// NOTE: Europe/Stockholm DST applies Sun, Mar 26, 2023 2:00 - Sun, Oct 29, 2023 3:00AM
		return [
			// Afternoon
			[ 'localmonth', '20200208153011', '02', 604800 ],
			[ 'localmonth1', '20200208153011', '2', 604800 ],
			[ 'localmonthname', '20200208153011', 'February', 604800 ],
			[ 'localmonthnamegen', '20200208153011', 'February', 604800 ],
			[ 'localmonthabbrev', '20200208153011', 'Feb', 604800 ],
			[ 'localday', '20200208153011', '8', 27001 ],
			[ 'localday2', '20200208153011', '08', 27001 ],
			[ 'localdayname', '20200208153011', 'Saturday', 27001 ],
			[ 'localyear', '20200208153011', '2020', 604800 ],
			[ 'localhour', '20200208153011', '16', 1801 ],
			[ 'localweek', '20200208153011', '6', 27001 ],
			[ 'localdow', '20200208153011', '6', 27001 ],
			[ 'localtime', '20200208153011', '16:30', 3600 ],
			// Night
			[ 'localmonth', '20200208223011', '02', 604800 ],
			[ 'localmonth1', '20200208223011', '2', 604800 ],
			[ 'localmonthname', '20200208223011', 'February', 604800 ],
			[ 'localmonthnamegen', '20200208223011', 'February', 604800 ],
			[ 'localmonthabbrev', '20200208223011', 'Feb', 604800 ],
			[ 'localday', '20200208223011', '8', 1801 ],
			[ 'localday2', '20200208223011', '08', 1801 ],
			[ 'localdayname', '20200208223011', 'Saturday', 1801 ],
			[ 'localyear', '20200208223011', '2020', 604800 ],
			[ 'localhour', '20200208223011', '23', 1801 ],
			[ 'localweek', '20200208223011', '6', 1801 ],
			[ 'localdow', '20200208223011', '6', 1801 ],
			[ 'localtime', '20200208223011', '23:30', 3600 ],
			// Late night / early morning
			[ 'localmonth', '20200208233011', '02', 604800 ],
			[ 'localmonth1', '20200208233011', '2', 604800 ],
			[ 'localmonthname', '20200208233011', 'February', 604800 ],
			[ 'localmonthnamegen', '20200208233011', 'February', 604800 ],
			[ 'localmonthabbrev', '20200208233011', 'Feb', 604800 ],
			[ 'localday', '20200208233011', '9', 84601 ],
			[ 'localday2', '20200208233011', '09', 84601 ],
			[ 'localdayname', '20200208233011', 'Sunday', 84601 ],
			[ 'localyear', '20200208233011', '2020', 604800 ],
			[ 'localhour', '20200208233011', '00', 1801 ],
			[ 'localweek', '20200208233011', '6', 84601 ],
			[ 'localdow', '20200208233011', '0', 84601 ],
			[ 'localtime', '20200208233011', '00:30', 3600 ]
		];
	}

	/**
	 * @param string $word
	 * @param string $ts
	 * @param string $expOutput
	 * @param int $expTTL
	 * @dataProvider provideCurrentUnitTimestampWords
	 * @dataProvider provideLocalUnitTimestampWords
	 */
	public function testCurrentUnitTimestampExpiry( $word, $ts, $expOutput, $expTTL ) {
		$this->setParserTimestamp( $ts );

		$this->assertMagic( $expOutput, $word );
		$this->assertSame( $expTTL, $this->testParser->getOutput()->getCacheExpiry() );
	}

	# ############## HELPERS ############################################

	/**
	 * assertion helper expecting a magic output which is zero padded
	 * @param string $magic
	 * @param string $value
	 */
	public function assertZeroPadded( $magic, $value ) {
		$this->assertMagicPadding( $magic, $value, '%02d' );
	}

	/**
	 * assertion helper expecting a magic output which is unpadded
	 * @param string $magic
	 * @param string $value
	 */
	public function assertUnPadded( $magic, $value ) {
		$this->assertMagicPadding( $magic, $value, '%d' );
	}

	/**
	 * Main assertion helper for magic variables padding
	 * @param string $magic Magic variable name
	 * @param mixed $value Month or day
	 * @param string $format Sprintf format for $value
	 */
	private function assertMagicPadding( $magic, $value, $format ) {
		# Initialize parser timestamp as year 2010 at 12h34 56s.
		# month and day are given by the caller ($value). Month < 12!
		if ( $value > 12 ) {
			$month = $value % 12;
		} else {
			$month = $value;
		}

		$this->setParserTimestamp(
			sprintf( '2010%02d%02d123456', $month, $value )
		);

		# please keep the following commented line of code. It helps debugging.
		// print "\nDEBUG (value $value):" . sprintf( '2010%02d%02d123456', $value, $value ) . "\n";

		# format expectation and test it
		$expected = sprintf( $format, $value );
		$this->assertMagic( $expected, $magic );
	}

	/**
	 * helper to set the parser timestamp and revision timestamp
	 * @param string $ts
	 */
	private function setParserTimestamp( $ts ) {
		$this->testParser->getOptions()->setTimestamp( $ts );
		TestingAccessWrapper::newFromObject( $this->testParser )->mRevisionTimestamp = $ts;
	}

	/**
	 * Assertion helper to test a magic variable output
	 * @param string|int $expected
	 * @param string $magic
	 */
	private function assertMagic( $expected, $magic ) {
		# Generate a message for the assertion
		$msg = sprintf( "Magic %s should be <%s:%s>",
			$magic,
			$expected,
			get_debug_type( $expected )
		);

		$this->assertSame(
			$expected,
			TestingAccessWrapper::newFromObject( $this->testParser )->expandMagicVariable( $magic ),
			$msg
		);
	}
}
PK       ! PY  Y  "  parser/PageBundleJsonTraitTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use MediaWiki\Parser\Parsoid\PageBundleJsonTrait;
use MediaWikiIntegrationTestCase;
use Wikimedia\Parsoid\Core\PageBundle;

/**
 * @covers \MediaWiki\Parser\Parsoid\PageBundleJsonTrait
 */
class PageBundleJsonTraitTest extends MediaWikiIntegrationTestCase {

	private const BUNDLE_DATA = [
		'html' => '<h1>woohoo</h1>',
		'parsoid' => [ 'metadata' => 'foo' ],
		'mw' => null,
		'version' => '1.0',
		'headers' => [ 'bar' => 'baz' ],
		'contentmodel' => 'default'
	];

	public function testNewPageBundleFromJson() {
		$trait = new class {
			use PageBundleJsonTrait {
				newPageBundleFromJson as public;
			}
		};
		$bundle = $trait->newPageBundleFromJson( self::BUNDLE_DATA );
		$this->assertInstanceOf( PageBundle::class, $bundle );
		$this->assertEquals( '<h1>woohoo</h1>', $bundle->html );
		$this->assertEquals( 'default', $bundle->contentmodel );
		$this->assertNull( $bundle->mw );
	}

	public function testJsonSerializePageBundle() {
		$trait = new class {
			use PageBundleJsonTrait {
				jsonSerializePageBundle as public;
			}
		};
		$bundle = new PageBundle( ...array_values( self::BUNDLE_DATA ) );
		$json = $trait->jsonSerializePageBundle( $bundle );
		$this->assertEquals( PageBundle::class, $json['_type_'] );
		$this->assertEquals( '<h1>woohoo</h1>', $json['html'] );
		$this->assertNull( $json['mw'] );
	}
}
PK       ! ,OZ    '  parser/Parsoid/LintErrorCheckerTest.phpnu Iw        <?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\MainConfigNames;
use MediaWiki\Parser\Parsoid\LintErrorChecker;
use MediaWiki\Registration\ExtensionRegistry;

/**
 * @group Parser
 * @group Database
 * @covers \MediaWiki\Parser\Parsoid\LintErrorChecker
 */
class LintErrorCheckerTest extends MediaWikiIntegrationTestCase {

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValue( MainConfigNames::ParsoidSettings, [
			'linting' => true
		] );
		$this->overrideConfigValue( 'LinterCategories', [
			// No hidden categories in default set up
		] );
	}

	/**
	 * Get a basic LintErrorChecker for testing with.
	 * @return LintErrorChecker
	 */
	protected function getLintErrorChecker() {
		$services = $this->getServiceContainer();
		$extReg = $this->createMock( ExtensionRegistry::class );
		$extReg->method( 'isLoaded' )->willReturnCallback( static function ( string $which ) {
			return $which == 'Linter';
		} );

		return new LintErrorChecker(
			$services->get( '_Parsoid' ),
			$services->getParsoidPageConfigFactory(),
			$services->getTitleFactory(),
			$extReg,
			$services->getMainConfig(),
		 );
	}

	/**
	 * @dataProvider provideCheck
	 */
	public function testCheck( $wikitext, $expected ) {
		$errors = $this->getLintErrorChecker()->check( $wikitext );
		$this->assertSame( $expected, $errors );
	}

	public static function provideCheck() {
			yield 'Perfect' => [ '<strong>Foo</strong>', [] ];
			yield 'Unclosed tag' => [
				'<strong>Foo',
				[
					[
						'type' => 'missing-end-tag',
						'dsr' => [ 0, 11, 8, 0 ],
						'templateInfo' => null,
						'params' => [
							'name' => 'strong',
							'inTable' => false,
						]
					]
				]
			];
	}

	public function testCheckSome() {
		// Take the same "Unclosed tag" test from above but disable the category
		$errors = $this->getLintErrorChecker()->checkSome( '<strong>Foo', [ 'missing-end-tag' ] );
		$this->assertSame( [], $errors );
	}

	/** Test when categories are diabled in $wgLinterCategories */
	public function testLinterCategory() {
		$input = '<font color="red">RED</font>';
		$errors = $this->getLintErrorChecker()->check( $input );
		$this->assertEquals( 'obsolete-tag', $errors[0]['type'] );

		// Now disable the category
		$this->overrideConfigValue( 'LinterCategories', [
			'obsolete-tag' => [ 'priority' => 'none' ],
		] );

		$errors = $this->getLintErrorChecker()->check( $input );
		$this->assertSame( [], $errors );
	}
}
PK       ! @!T    "  parser/CoreParserFunctionsTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use MediaWiki\Language\RawMessage;
use MediaWiki\Parser\CoreParserFunctions;
use MediaWiki\User\User;
use MediaWikiLangTestCase;

/**
 * @group Database
 * @covers \MediaWiki\Parser\CoreParserFunctions
 */
class CoreParserFunctionsTest extends MediaWikiLangTestCase {

	public function testGender() {
		$userOptionsManager = $this->getServiceContainer()->getUserOptionsManager();

		$username = 'Female*';
		$user = User::createNew( $username );
		$userOptionsManager->setOption( $user, 'gender', 'female' );
		$user->saveSettings();

		$msg = ( new RawMessage( '{{GENDER:' . $username . '|m|f|o}}' ) )->parse();
		$this->assertEquals( 'f', $msg, 'Works unescaped' );
		$escapedName = wfEscapeWikiText( $username );
		$msg2 = ( new RawMessage( '{{GENDER:' . $escapedName . '|m|f|o}}' ) )
			->parse();
		$this->assertEquals( 'f', $msg2, 'Works escaped' );
	}

	public static function provideTalkpagename() {
		yield [ 'Talk:Foo bar', 'foo_bar' ];
		yield [ 'Talk:Foo', ' foo ' ];
		yield [ 'Talk:Foo', 'Talk:Foo' ];
		yield [ 'User talk:Foo', 'User:foo' ];
		yield [ '', 'Special:Foo' ];
		yield [ '', '' ];
		yield [ '', ' ' ];
		yield [ '', '__' ];
		yield [ '', '#xyzzy' ];
		yield [ '', '#' ];
		yield [ '', ':' ];
		yield [ '', ':#' ];
		yield [ '', 'User:' ];
		yield [ '', 'User:#' ];
	}

	/**
	 * @dataProvider provideTalkpagename
	 */
	public function testTalkpagename( $expected, $title ) {
		$parser = $this->getServiceContainer()->getParser();

		$this->assertSame( $expected, CoreParserFunctions::talkpagename( $parser, $title ) );
	}

	public static function provideSubjectpagename() {
		yield [ 'Foo bar', 'Talk:foo_bar' ];
		yield [ 'Foo', ' Talk:foo ' ];
		yield [ 'User:Foo', 'User talk:foo' ];
		yield [ 'Special:Foo', 'Special:Foo' ];
		yield [ '', '' ];
		yield [ '', ' ' ];
		yield [ '', '__' ];
		yield [ '', '#xyzzy' ];
		yield [ '', '#' ];
		yield [ '', ':' ];
		yield [ '', ':#' ];
		yield [ '', 'Talk:' ];
		yield [ '', 'User talk:#' ];
		yield [ '', 'User:#' ];
	}

	/**
	 * @dataProvider provideSubjectpagename
	 */
	public function testSubjectpagename( $expected, $title ) {
		$parser = $this->getServiceContainer()->getParser();

		$this->assertSame( $expected, CoreParserFunctions::subjectpagename( $parser, $title ) );
	}

}
PK       ! X    #  GlobalFunctions/WfExpandUrlTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Utils\UrlUtils;

/**
 * @group GlobalFunctions
 * @covers ::wfExpandUrl
 */
class WfExpandUrlTest extends MediaWikiIntegrationTestCase {
	/**
	 * Same tests as the UrlUtils method to ensure they don't fall out of sync
	 * @dataProvider UrlUtilsProviders::provideExpand
	 */
	public function testWfExpandUrl( string $input, array $options,
		$defaultProto, string $expected
	) {
		$conf = [
			MainConfigNames::Server => $options[UrlUtils::SERVER] ?? null,
			MainConfigNames::CanonicalServer => $options[UrlUtils::CANONICAL_SERVER] ?? null,
			MainConfigNames::InternalServer => $options[UrlUtils::INTERNAL_SERVER] ?? null,
			MainConfigNames::HttpsPort => $options[UrlUtils::HTTPS_PORT] ?? 443,
		];
		$currentProto = $options[UrlUtils::FALLBACK_PROTOCOL];

		$this->overrideConfigValues( $conf );
		$this->setRequest( new FauxRequest( [], false, null, $currentProto ) );
		$this->assertEquals( $expected, wfExpandUrl( $input, $defaultProto ) );
	}
}
PK       ! g0  0    GlobalFunctions/GlobalTest.phpnu Iw        <?php

use MediaWiki\Logger\LegacyLogger;
use MediaWiki\MainConfigNames;

/**
 * @group Database
 * @group GlobalFunctions
 */
class GlobalTest extends MediaWikiIntegrationTestCase {
	protected function setUp(): void {
		parent::setUp();

		$this->overrideConfigValues( [
			MainConfigNames::UrlProtocols => [
				'http://',
				'https://',
				'mailto:',
				'//',
				'file://', # Non-default
			],
		] );
	}

	/**
	 * @dataProvider provideForWfArrayDiff2
	 * @covers ::wfArrayDiff2
	 */
	public function testWfArrayDiff2( $a, $b, $expected ) {
		$this->expectDeprecationAndContinue( '/wfArrayDiff2/' );
		$this->assertEquals(
			$expected, wfArrayDiff2( $a, $b )
		);
	}

	// @todo Provide more tests
	public static function provideForWfArrayDiff2() {
		// $a $b $expected
		return [
			[
				[ 'a', 'b' ],
				[ 'a', 'b' ],
				[],
			],
			[
				[ [ 'a' ], [ 'a', 'b', 'c' ] ],
				[ [ 'a' ], [ 'a', 'b' ] ],
				[ 1 => [ 'a', 'b', 'c' ] ],
			],
		];
	}

	/*
	 * Test cases for random functions could hypothetically fail,
	 * even though they shouldn't.
	 */

	/**
	 * @covers ::wfRandom
	 */
	public function testRandom() {
		$this->assertFalse(
			wfRandom() == wfRandom()
		);
	}

	/**
	 * @covers ::wfRandomString
	 */
	public function testRandomString() {
		$this->assertFalse(
			wfRandomString() == wfRandomString()
		);
		$this->assertSame( 10, strlen( wfRandomString( 10 ) ), 'length' );
		$this->assertSame( 1, preg_match( '/^[0-9a-f]+$/i', wfRandomString() ), 'pattern' );
	}

	/**
	 * @covers ::wfUrlencode
	 */
	public function testUrlencode() {
		$this->assertEquals(
			"%E7%89%B9%E5%88%A5:Contributions/Foobar",
			wfUrlencode( "\xE7\x89\xB9\xE5\x88\xA5:Contributions/Foobar" ) );
	}

	public static function provideArrayToCGI() {
		return [
			[ [], '' ], // empty
			[ [ 'foo' => 'bar' ], 'foo=bar' ], // string test
			[ [ 'foo' => '' ], 'foo=' ], // empty string test
			[ [ 'foo' => 1 ], 'foo=1' ], // number test
			[ [ 'foo' => true ], 'foo=1' ], // true test
			[ [ 'foo' => false ], '' ], // false test
			[ [ 'foo' => null ], '' ], // null test
			[ [ 'foo' => 'A&B=5+6@!"\'' ], 'foo=A%26B%3D5%2B6%40%21%22%27' ], // urlencoding test
			[
				[ 'foo' => 'bar', 'baz' => 'is', 'asdf' => 'qwerty' ],
				'foo=bar&baz=is&asdf=qwerty'
			], // multi-item test
			[ [ 'foo' => [ 'bar' => 'baz' ] ], 'foo%5Bbar%5D=baz' ],
			[
				[ 'foo' => [ 'bar' => 'baz', 'qwerty' => 'asdf' ] ],
				'foo%5Bbar%5D=baz&foo%5Bqwerty%5D=asdf'
			],
			[ [ 'foo' => [ 'bar', 'baz' ] ], 'foo%5B0%5D=bar&foo%5B1%5D=baz' ],
			[
				[ 'foo' => [ 'bar' => [ 'bar' => 'baz' ] ] ],
				'foo%5Bbar%5D%5Bbar%5D=baz'
			],
		];
	}

	/**
	 * @dataProvider provideArrayToCGI
	 * @covers ::wfArrayToCgi
	 */
	public function testArrayToCGI( $array, $result ) {
		$this->assertEquals( $result, wfArrayToCgi( $array ) );
	}

	/**
	 * @covers ::wfArrayToCgi
	 */
	public function testArrayToCGI2() {
		$this->assertEquals(
			"baz=bar&foo=bar",
			wfArrayToCgi(
				[ 'baz' => 'bar' ],
				[ 'foo' => 'bar', 'baz' => 'overridden value' ] ) );
	}

	public static function provideCgiToArray() {
		return [
			[ '', [] ], // empty
			[ 'foo=bar', [ 'foo' => 'bar' ] ], // string
			[ 'foo=', [ 'foo' => '' ] ], // empty string
			[ 'foo', [ 'foo' => '' ] ], // missing =
			[ 'foo=bar&qwerty=asdf', [ 'foo' => 'bar', 'qwerty' => 'asdf' ] ], // multiple value
			[ 'foo=A%26B%3D5%2B6%40%21%22%27', [ 'foo' => 'A&B=5+6@!"\'' ] ], // urldecoding test
			[ 'foo[bar]=baz', [ 'foo' => [ 'bar' => 'baz' ] ] ],
			[ 'foo%5Bbar%5D=baz', [ 'foo' => [ 'bar' => 'baz' ] ] ], // urldecoding test 2
			[
				'foo%5Bbar%5D=baz&foo%5Bqwerty%5D=asdf',
				[ 'foo' => [ 'bar' => 'baz', 'qwerty' => 'asdf' ] ]
			],
			[ 'foo%5B0%5D=bar&foo%5B1%5D=baz', [ 'foo' => [ 0 => 'bar', 1 => 'baz' ] ] ],
			[
				'foo%5Bbar%5D%5Bbar%5D=baz',
				[ 'foo' => [ 'bar' => [ 'bar' => 'baz' ] ] ]
			],
			[ 'foo[]=x&foo[]=y', [ 'foo' => [ '' => 'y' ] ] ], // implicit keys are NOT handled like in PHP (bug?)
			[ 'foo=x&foo[]=y', [ 'foo' => [ '' => 'y' ] ] ], // mixed value/array doesn't cause errors
		];
	}

	/**
	 * @dataProvider provideCgiToArray
	 * @covers ::wfCgiToArray
	 */
	public function testCgiToArray( $cgi, $result ) {
		$this->assertEquals( $result, wfCgiToArray( $cgi ) );
	}

	public static function provideCgiRoundTrip() {
		return [
			[ '' ],
			[ 'foo=bar' ],
			[ 'foo=' ],
			[ 'foo=bar&baz=biz' ],
			[ 'foo=A%26B%3D5%2B6%40%21%22%27' ],
			[ 'foo%5Bbar%5D=baz' ],
			[ 'foo%5B0%5D=bar&foo%5B1%5D=baz' ],
			[ 'foo%5Bbar%5D%5Bbar%5D=baz' ],
		];
	}

	/**
	 * @dataProvider provideCgiRoundTrip
	 * @covers ::wfArrayToCgi
	 */
	public function testCgiRoundTrip( $cgi ) {
		$this->assertEquals( $cgi, wfArrayToCgi( wfCgiToArray( $cgi ) ) );
	}

	/**
	 * @covers ::wfDebug
	 */
	public function testDebugFunctionTest() {
		$debugLogFile = $this->getNewTempFile();

		$this->overrideConfigValue( MainConfigNames::DebugLogFile, $debugLogFile );
		$this->setLogger( 'wfDebug', new LegacyLogger( 'wfDebug' ) );

		unlink( $debugLogFile );
		wfDebug( "This is a normal string" );
		$this->assertEquals( "This is a normal string\n", file_get_contents( $debugLogFile ) );

		unlink( $debugLogFile );
		wfDebug( "This is nöt an ASCII string" );
		$this->assertEquals( "This is nöt an ASCII string\n", file_get_contents( $debugLogFile ) );

		unlink( $debugLogFile );
		wfDebug( "\00305This has böth UTF and control chars\003" );
		$this->assertEquals(
			" 05This has böth UTF and control chars \n",
			file_get_contents( $debugLogFile )
		);

		unlink( $debugLogFile );
	}

	/**
	 * @covers ::wfClientAcceptsGzip
	 */
	public function testClientAcceptsGzipTest() {
		$settings = [
			'gzip' => true,
			'bzip' => false,
			'*' => false,
			'compress, gzip' => true,
			'gzip;q=1.0' => true,
			'foozip' => false,
			'foo*zip' => false,
			'gzip;q=abcde' => true, // is this REALLY valid?
			'gzip;q=12345678.9' => true,
			' gzip' => true,
		];

		if ( isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) {
			$old_server_setting = $_SERVER['HTTP_ACCEPT_ENCODING'];
		}

		foreach ( $settings as $encoding => $expect ) {
			$_SERVER['HTTP_ACCEPT_ENCODING'] = $encoding;

			$this->assertEquals( $expect, wfClientAcceptsGzip( true ),
				"'$encoding' => " . wfBoolToStr( $expect ) );
		}

		if ( isset( $old_server_setting ) ) {
			$_SERVER['HTTP_ACCEPT_ENCODING'] = $old_server_setting;
		}
	}

	/**
	 * @covers ::wfPercent
	 * @dataProvider provideWfPercentTest
	 */
	public function testWfPercentTest( float $input,
		string $expected,
		int $accuracy = 2,
		bool $round = true
	) {
		$this->assertSame( $expected, wfPercent( $input, $accuracy, $round ) );
	}

	public static function provideWfPercentTest() {
		return [
			[ 6 / 7, '0.86%', 2, false ],
			[ 3 / 3, '1%' ],
			[ 22 / 7, '3.14286%', 5 ],
			[ 3 / 6, '0.5%' ],
			[ 1 / 3, '0%', 0 ],
			[ 10 / 3, '0%', -1 ],
			[ 123.456, '120%', -1 ],
			[ 3 / 4 / 5, '0.1%', 1 ],
			[ 6 / 7 * 8, '6.8571428571%', 10 ],
		];
	}

	/**
	 * test @see wfShorthandToInteger()
	 * @dataProvider provideShorthand
	 * @covers ::wfShorthandToInteger
	 */
	public function testWfShorthandToInteger( $shorthand, $expected ) {
		$this->assertEquals( $expected,
			wfShorthandToInteger( $shorthand )
		);
	}

	public static function provideShorthand() {
		// Syntax: [ shorthand, expected integer ]
		return [
			# Null, empty ...
			[ '', -1 ],
			[ '  ', -1 ],
			[ null, -1 ],

			# Failures returns 0 :(
			[ 'ABCDEFG', 0 ],
			[ 'Ak', 0 ],

			# Int, strings with spaces
			[ 1, 1 ],
			[ ' 1 ', 1 ],
			[ 1023, 1023 ],
			[ ' 1023 ', 1023 ],

			# kilo, Mega, Giga
			[ '1k', 1024 ],
			[ '1K', 1024 ],
			[ '1m', 1024 * 1024 ],
			[ '1M', 1024 * 1024 ],
			[ '1g', 1024 * 1024 * 1024 ],
			[ '1G', 1024 * 1024 * 1024 ],

			# Negatives
			[ -1, -1 ],
			[ -500, -500 ],
			[ '-500', -500 ],
			[ '-1k', -1024 ],

			# Zeroes
			[ '0', 0 ],
			[ '0k', 0 ],
			[ '0M', 0 ],
			[ '0G', 0 ],
			[ '-0', 0 ],
			[ '-0k', 0 ],
			[ '-0M', 0 ],
			[ '-0G', 0 ],
		];
	}

	/**
	 * @covers ::wfMerge
	 */
	public function testMerge_worksWithLessParameters() {
		$this->markTestSkippedIfNoDiff3();

		$mergedText = null;
		$successfulMerge = wfMerge( "old1\n\nold2", "old1\n\nnew2", "new1\n\nold2", $mergedText );

		$mergedText = null;
		$conflictingMerge = wfMerge( 'old', 'old and mine', 'old and yours', $mergedText );

		$this->assertTrue( $successfulMerge );
		$this->assertFalse( $conflictingMerge );
	}

	/**
	 * @param string $old Text as it was in the database
	 * @param string $mine Text submitted while user was editing
	 * @param string $yours Text submitted by the user
	 * @param bool $expectedMergeResult Whether the merge should be a success
	 * @param string $expectedText Text after merge has been completed
	 * @param string $expectedMergeAttemptResult Diff3 output if conflicts occur
	 *
	 * @dataProvider provideMerge()
	 * @group medium
	 * @covers ::wfMerge
	 */
	public function testMerge(
		$old, $mine, $yours, $expectedMergeResult, $expectedText, $expectedMergeAttemptResult
	) {
		$this->markTestSkippedIfNoDiff3();

		$mergedText = null;
		$mergeAttemptResult = null;
		$isMerged = wfMerge( $old, $mine, $yours, $mergedText, $mergeAttemptResult );

		$msg = 'Merge should be a ';
		$msg .= $expectedMergeResult ? 'success' : 'failure';
		$this->assertEquals( $expectedMergeResult, $isMerged, $msg );
		$this->assertEquals( $expectedMergeAttemptResult, $mergeAttemptResult );

		if ( $isMerged ) {
			// Verify the merged text
			$this->assertEquals( $expectedText, $mergedText,
				'is merged text as expected?' );
		}
	}

	public static function provideMerge() {
		$EXPECT_MERGE_SUCCESS = true;
		$EXPECT_MERGE_FAILURE = false;

		return [
			// #0: clean merge
			[
				// old:
				"one one one\n" . // trimmed
					"\n" .
					"two two two",

				// mine:
				"one one one ONE ONE\n" .
					"\n" .
					"two two two\n", // with tailing whitespace

				// yours:
				"one one one\n" .
					"\n" .
					"two two TWO TWO", // trimmed

				// ok:
				$EXPECT_MERGE_SUCCESS,

				// result:
				"one one one ONE ONE\n" .
					"\n" .
					"two two TWO TWO\n", // note: will always end in a newline

				// mergeAttemptResult:
				"",
			],

			// #1: conflict, fail
			[
				// old:
				"one one one", // trimmed

				// mine:
				"one one one ONE ONE\n" .
					"\n" .
					"bla bla\n" .
					"\n", // with tailing whitespace

				// yours:
				"one one one\n" .
					"\n" .
					"two two", // trimmed

				$EXPECT_MERGE_FAILURE,

				// result:
				null,

				// mergeAttemptResult:
				"1,3c\n" .
				"one one one\n" .
				"\n" .
				"two two\n" .
				".\n",
			],
		];
	}

	/**
	 * Same tests as the UrlUtils method to ensure they don't fall out of sync
	 * @dataProvider UrlUtilsProviders::provideMatchesDomainList
	 * @covers ::wfMatchesDomainList
	 */
	public function testWfMatchesDomainList( $url, $domains, $expected ) {
		$actual = wfMatchesDomainList( $url, $domains );
		$this->assertEquals( $expected, $actual );
	}

	/**
	 * @covers ::wfMkdirParents
	 */
	public function testWfMkdirParents() {
		// Should not return true if file exists instead of directory
		$fname = $this->getNewTempFile();
		$this->assertFalse( @wfMkdirParents( $fname ) );
	}

	/**
	 * @dataProvider provideWfShellWikiCmdList
	 * @covers ::wfShellWikiCmd
	 */
	public function testWfShellWikiCmd( $script, $parameters, $options,
		$expected, $description
	) {
		if ( wfIsWindows() ) {
			// Approximation that's good enough for our purposes just now
			$expected = str_replace( "'", '"', $expected );
		}
		$actual = wfShellWikiCmd( $script, $parameters, $options );
		$this->assertEquals( $expected, $actual, $description );
	}

	public static function provideWfShellWikiCmdList() {
		global $wgPhpCli;

		return [
			[ 'eval.php', [ '--help', '--test' ], [],
				"'$wgPhpCli' 'eval.php' '--help' '--test'",
				"Called eval.php --help --test" ],
			[ 'eval.php', [ '--help', '--test space' ], [ 'php' => 'php5' ],
				"'php5' 'eval.php' '--help' '--test space'",
				"Called eval.php --help --test with php option" ],
			[ 'eval.php', [ '--help', '--test', 'X' ], [ 'wrapper' => 'MWScript.php' ],
				"'$wgPhpCli' 'MWScript.php' 'eval.php' '--help' '--test' 'X'",
				"Called eval.php --help --test with wrapper option" ],
			[
				'eval.php',
				[ '--help', '--test', 'y' ],
				[ 'php' => 'php5', 'wrapper' => 'MWScript.php' ],
				"'php5' 'MWScript.php' 'eval.php' '--help' '--test' 'y'",
				"Called eval.php --help --test with wrapper and php option"
			],
		];
	}

	/* @todo many more! */
}
PK       ! 9*^   ^     GlobalFunctions/READMEnu Iw        This directory hold tests for includes/GlobalFunctions.php file
which is a pile of functions.
PK       !  y  y  "  GlobalFunctions/WfParseUrlTest.phpnu Iw        <?php
/**
 * Copyright © 2013 Alexandre Emsenhuber
 *
 * 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\MainConfigNames;

/**
 * @group GlobalFunctions
 * @covers ::wfParseUrl
 */
class WfParseUrlTest extends MediaWikiIntegrationTestCase {
	protected function setUp(): void {
		parent::setUp();

		$this->overrideConfigValue( MainConfigNames::UrlProtocols, [
			'//',
			'http://',
			'https://',
			'file://',
			'mailto:',
			'news:',
		] );
	}

	/**
	 * Same tests as the UrlUtils method
	 * @dataProvider UrlUtilsProviders::provideParse
	 */
	public function testWfParseUrl( $url, $parts ) {
		$this->assertEquals(
			$parts,
			wfParseUrl( $url )
		);
	}
}
PK       ! \=W    )  GlobalFunctions/WfThumbIsStandardTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use Psr\Log\NullLogger;

/**
 * @group GlobalFunctions
 * @covers ::wfThumbIsStandard
 */
class WfThumbIsStandardTest extends MediaWikiIntegrationTestCase {

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

		$this->overrideConfigValues( [
			MainConfigNames::ThumbLimits => [
				100,
				401
			],
			MainConfigNames::ImageLimits => [
				[ 300, 225 ],
				[ 800, 600 ],
			],
		] );
	}

	public static function provideThumbParams() {
		return [
			// Thumb limits
			[
				'Standard thumb width',
				true,
				[ 'width' => 100 ],
			],
			[
				'Standard thumb width',
				true,
				[ 'width' => 401 ],
			],
			// wfThumbIsStandard should match Linker::processResponsiveImages
			// in its rounding behaviour.
			[
				'Standard thumb width (HiDPI 1.5x) - incorrect rounding',
				false,
				[ 'width' => 601 ],
			],
			[
				'Standard thumb width (HiDPI 1.5x)',
				true,
				[ 'width' => 602 ],
			],
			[
				'Standard thumb width (HiDPI 2x)',
				true,
				[ 'width' => 802 ],
			],
			[
				'Non-standard thumb width',
				false,
				[ 'width' => 300 ],
			],
			// Image limits
			// Note: Image limits are measured as pairs. Individual values
			// may be non-standard based on the aspect ratio.
			[
				'Standard image width/height pair',
				true,
				[ 'width' => 250, 'height' => 225 ],
			],
			[
				'Standard image width/height pair',
				true,
				[ 'width' => 667, 'height' => 600 ],
			],
			[
				'Standard image width where image does not fit aspect ratio',
				false,
				[ 'width' => 300 ],
			],
			[
				'Implicit width from image width/height pair aspect ratio fit',
				true,
				// 2000x1800 fit inside 300x225 makes w=250
				[ 'width' => 250 ],
			],
			[
				'Height-only is always non-standard',
				false,
				[ 'height' => 225 ],
			],
		];
	}

	/**
	 * @dataProvider provideThumbParams
	 */
	public function testIsStandard( $message, $expected, $params ) {
		$handlers = $this->getConfVar( MainConfigNames::ParserTestMediaHandlers );
		$this->setService(
			'MediaHandlerFactory',
			new MediaHandlerFactory( new NullLogger(), $handlers )
		);
		$this->assertSame(
			$expected,
			wfThumbIsStandard( new FakeDimensionFile( [ 2000, 1800 ], 'image/jpeg' ), $params ),
			$message
		);
	}
}
PK       ! <D    #  GlobalFunctions/WfShellExecTest.phpnu Iw        <?php

/**
 * @group GlobalFunctions
 * @covers ::wfShellExec
 */
class WfShellExecTest extends MediaWikiIntegrationTestCase {
	public function testT69870() {
		if ( wfIsWindows() ) {
			// T209159: Anonymous pipe under Windows does not support asynchronous read and write,
			// and the default buffer is too small (~4K), it is easy to be blocked.
			$this->markTestSkipped(
				'T209159: Anonymous pipe under Windows cannot withstand such a large amount of data'
			);
		}

		// Test several times because it involves a race condition that may randomly succeed or fail
		for ( $i = 0; $i < 10; $i++ ) {
			$output = wfShellExec( 'printf "%-333333s" "*"' );
			$this->assertEquals( 333333, strlen( $output ) );
		}
	}
}
PK       ! (35  5    config/ConfigFactoryTest.phpnu Iw        <?php

use MediaWiki\Config\Config;
use MediaWiki\Config\ConfigException;
use MediaWiki\Config\ConfigFactory;
use MediaWiki\Config\GlobalVarConfig;
use MediaWiki\Config\HashConfig;

/**
 * @covers \MediaWiki\Config\ConfigFactory
 */
class ConfigFactoryTest extends \MediaWikiIntegrationTestCase {

	public function testRegister() {
		$factory = new ConfigFactory();
		$factory->register( 'unittest', 'GlobalVarConfig::newInstance' );
		$this->assertInstanceOf( GlobalVarConfig::class, $factory->makeConfig( 'unittest' ) );
	}

	public function testRegisterInvalid() {
		$factory = new ConfigFactory();
		$this->expectException( InvalidArgumentException::class );
		$factory->register( 'invalid', 'Invalid callback' );
	}

	public function testRegisterInvalidInstance() {
		$factory = new ConfigFactory();
		$this->expectException( InvalidArgumentException::class );
		$factory->register( 'invalidInstance', (object)[] );
	}

	public function testRegisterInstance() {
		$config = GlobalVarConfig::newInstance();
		$factory = new ConfigFactory();
		$factory->register( 'unittest', $config );
		$this->assertSame( $config, $factory->makeConfig( 'unittest' ) );
	}

	public function testRegisterAgain() {
		$factory = new ConfigFactory();
		$factory->register( 'unittest', 'GlobalVarConfig::newInstance' );
		$config1 = $factory->makeConfig( 'unittest' );

		$factory->register( 'unittest', 'GlobalVarConfig::newInstance' );
		$config2 = $factory->makeConfig( 'unittest' );

		$this->assertNotSame( $config1, $config2 );
	}

	public function testSalvage() {
		$oldFactory = new ConfigFactory();
		$oldFactory->register( 'foo', 'GlobalVarConfig::newInstance' );
		$oldFactory->register( 'bar', 'GlobalVarConfig::newInstance' );
		$oldFactory->register( 'quux', 'GlobalVarConfig::newInstance' );

		// instantiate two of the three defined configurations
		$foo = $oldFactory->makeConfig( 'foo' );
		$bar = $oldFactory->makeConfig( 'bar' );
		$quux = $oldFactory->makeConfig( 'quux' );

		// define new config instance
		$newFactory = new ConfigFactory();
		$newFactory->register( 'foo', 'GlobalVarConfig::newInstance' );
		$newFactory->register( 'bar', static function () {
			return new HashConfig();
		} );

		// "foo" and "quux" are defined in the old and the new factory.
		// The old factory has instances for "foo" and "bar", but not "quux".
		$newFactory->salvage( $oldFactory );

		$newFoo = $newFactory->makeConfig( 'foo' );
		$this->assertSame( $foo, $newFoo, 'existing instance should be salvaged' );

		$newBar = $newFactory->makeConfig( 'bar' );
		$this->assertNotSame( $bar, $newBar, 'don\'t salvage if callbacks differ' );

		// the new factory doesn't have quux defined, so the quux instance should not be salvaged
		$this->expectException( ConfigException::class );
		$newFactory->makeConfig( 'quux' );
	}

	public function testGetConfigNames() {
		$factory = new ConfigFactory();
		$factory->register( 'foo', 'GlobalVarConfig::newInstance' );
		$factory->register( 'bar', new HashConfig() );

		$this->assertEquals( [ 'foo', 'bar' ], $factory->getConfigNames() );
	}

	public function testMakeConfigWithCallback() {
		$factory = new ConfigFactory();
		$factory->register( 'unittest', 'GlobalVarConfig::newInstance' );

		$conf = $factory->makeConfig( 'unittest' );
		$this->assertInstanceOf( Config::class, $conf );
		$this->assertSame( $conf, $factory->makeConfig( 'unittest' ) );
	}

	public function testMakeConfigWithObject() {
		$factory = new ConfigFactory();
		$conf = new HashConfig();
		$factory->register( 'test', $conf );
		$this->assertSame( $conf, $factory->makeConfig( 'test' ) );
	}

	public function testMakeConfigFallback() {
		$factory = new ConfigFactory();
		$factory->register( '*', 'GlobalVarConfig::newInstance' );
		$conf = $factory->makeConfig( 'unittest' );
		$this->assertInstanceOf( Config::class, $conf );
	}

	public function testMakeConfigWithNoBuilders() {
		$factory = new ConfigFactory();
		$this->expectException( ConfigException::class );
		$factory->makeConfig( 'nobuilderregistered' );
	}

	public function testMakeConfigWithInvalidCallback() {
		$factory = new ConfigFactory();
		$factory->register( 'unittest', static function () {
			return true; // Not a Config object
		} );
		$this->expectException( UnexpectedValueException::class );
		$factory->makeConfig( 'unittest' );
	}

	public function testGetDefaultInstance() {
		// NOTE: the global config factory returned here has been overwritten
		// for operation in test mode. It may not reflect LocalSettings.
		$factory = $this->getServiceContainer()->getConfigFactory();
		$this->assertInstanceOf( Config::class, $factory->makeConfig( 'main' ) );
	}

}
PK       !       config/LoggedServiceOptions.phpnu Iw        <?php

use MediaWiki\Config\ServiceOptions;

/**
 * Helper for TestAllServiceOptionsUsed.
 */
class LoggedServiceOptions extends ServiceOptions {
	/** @var array */
	private $accessLog;

	/**
	 * @param array &$accessLog Pass self::$serviceOptionsAccessLog from the class implementing
	 *   TestAllServiceOptionsUsed.
	 * @param string[] $keys
	 * @param mixed ...$args Forwarded to parent as-is.
	 */
	public function __construct( array &$accessLog, array $keys, ...$args ) {
		$this->accessLog = &$accessLog;
		if ( !$accessLog ) {
			$accessLog = [ $keys, [] ];
		}

		parent::__construct( $keys, ...$args );
	}

	/**
	 * @param string $key
	 * @return mixed
	 */
	public function get( $key ) {
		$this->accessLog[1][$key] = true;

		return parent::get( $key );
	}
}
PK       ! -     $  config/TestAllServiceOptionsUsed.phpnu Iw        <?php

/**
 * Use this trait to check that code run by tests accesses every key declared for this class'
 * ServiceOptions, e.g., in a CONSTRUCTOR_OPTIONS member const. To use this trait, you need to do
 * two things (other than use-ing it):
 *
 * 1) Don't use the regular ServiceOptions when constructing your objects, but rather
 * LoggedServiceOptions. These are used the same as ServiceOptions, except in the constructor, pass
 * self::$serviceOptionsAccessLog before the regular arguments.
 *
 * 2) Make a test that calls assertAllServiceOptionsUsed(). If some ServiceOptions keys are not yet
 * accessed in tests but actually are used by the class, pass their names as an argument.
 *
 * Currently we support only one ServiceOptions per test class.
 */
trait TestAllServiceOptionsUsed {
	/** @var array [ expected keys (as list), keys accessed so far (as dictionary) ] */
	private static $serviceOptionsAccessLog = [];

	/**
	 * @param string[] $expectedUnused Options that we know are not yet tested
	 */
	public function assertAllServiceOptionsUsed( array $expectedUnused = [] ) {
		$this->assertNotEmpty( self::$serviceOptionsAccessLog,
			'You need to pass LoggedServiceOptions to your class instead of ServiceOptions ' .
			'for TestAllServiceOptionsUsed to work.'
		);

		[ $expected, $actual ] = self::$serviceOptionsAccessLog;

		$expected = array_diff( $expected, $expectedUnused );

		$this->assertSame(
			[],
			array_diff( $expected, array_keys( $actual ) ),
			"Some ServiceOptions keys were not accessed in tests. If they really aren't used, " .
			"remove them from the class' option list. If they are used, add tests to cover them, " .
			"or ignore the problem for now by passing them to assertAllServiceOptionsUsed() in " .
			"its \$expectedUnused argument."
		);

		if ( $expectedUnused ) {
			$this->markTestIncomplete( 'Some ServiceOptions keys are not yet accessed by tests: ' .
				implode( ', ', $expectedUnused ) );
		}
	}
}
PK       ! s  s    config/GlobalVarConfigTest.phpnu Iw        <?php

use MediaWiki\Config\ConfigException;
use MediaWiki\Config\GlobalVarConfig;

/**
 * @covers \MediaWiki\Config\GlobalVarConfig
 */
class GlobalVarConfigTest extends MediaWikiIntegrationTestCase {

	public function testNewInstance() {
		$config = GlobalVarConfig::newInstance();
		$this->assertInstanceOf( GlobalVarConfig::class, $config );
		$this->setMwGlobals( 'wgBaz', 'somevalue' );
		// Check prefix is set to 'wg'
		$this->assertEquals( 'somevalue', $config->get( 'Baz' ) );
	}

	/**
	 * @dataProvider provideConstructor
	 */
	public function testConstructor( $prefix ) {
		$var = $prefix . 'GlobalVarConfigTest';
		$this->setMwGlobals( $var, 'testvalue' );
		$config = new GlobalVarConfig( $prefix );
		$this->assertInstanceOf( GlobalVarConfig::class, $config );
		$this->assertEquals( 'testvalue', $config->get( 'GlobalVarConfigTest' ) );
	}

	public static function provideConstructor() {
		return [
			[ 'wg' ],
			[ 'ef' ],
			[ 'smw' ],
			[ 'blahblahblahblah' ],
			[ '' ],
		];
	}

	public function testHas() {
		$this->setMwGlobals( 'wgGlobalVarConfigTestHas', 'testvalue' );
		$config = new GlobalVarConfig();
		$this->assertTrue( $config->has( 'GlobalVarConfigTestHas' ) );
		$this->assertFalse( $config->has( 'GlobalVarConfigTestNotHas' ) );
	}

	public static function provideGet() {
		$set = [
			'wgSomething' => 'default1',
			'wgFoo' => 'default2',
			'efVariable' => 'default3',
			'BAR' => 'default4',
		];

		foreach ( $set as $var => $value ) {
			$GLOBALS[$var] = $value;
		}

		return [
			[ 'Something', 'wg', 'default1' ],
			[ 'Foo', 'wg', 'default2' ],
			[ 'Variable', 'ef', 'default3' ],
			[ 'BAR', '', 'default4' ],
			[ 'ThisGlobalWasNotSetAbove', 'wg', false ]
		];
	}

	/**
	 * @dataProvider provideGet
	 * @param string $name
	 * @param string $prefix
	 * @param string $expected
	 */
	public function testGet( $name, $prefix, $expected ) {
		$config = new GlobalVarConfig( $prefix );
		if ( $expected === false ) {
			$this->expectException( ConfigException::class );
			$this->expectExceptionMessage( 'GlobalVarConfig::get: undefined option:' );
		}
		$this->assertEquals( $expected, $config->get( $name ) );
	}
}
PK       ! b      http/MWHttpRequestTest.phpnu Iw        <?php

use MediaWiki\MediaWikiServices;
use Wikimedia\Http\TelemetryHeadersInterface;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MWHttpRequest
 */
class MWHttpRequestTest extends PHPUnit\Framework\TestCase {

	/**
	 * Feeds URI to test a long regular expression in MWHttpRequest::isValidURI
	 */
	public static function provideURI() {
		/** Format: 'boolean expectation', 'URI to test', 'Optional message' */
		return [
			[ false, '¿non sens before!! http://a', 'Allow anything before URI' ],

			# (http|https) - only two schemes allowed
			[ true, 'http://www.example.org/' ],
			[ true, 'https://www.example.org/' ],
			[ true, 'http://www.example.org', 'URI without directory' ],
			[ true, 'http://a', 'Short name' ],
			[ true, 'http://étoile', 'Allow UTF-8 in hostname' ], # 'étoile' is french for 'star'
			[ false, '\\host\directory', 'CIFS share' ],
			[ false, 'gopher://host/dir', 'Reject gopher scheme' ],
			[ false, 'telnet://host', 'Reject telnet scheme' ],

			# :\/\/ - double slashes
			[ false, 'http//example.org', 'Reject missing colon in protocol' ],
			[ false, 'http:/example.org', 'Reject missing slash in protocol' ],
			[ false, 'http:example.org', 'Must have two slashes' ],
			# Following fail since hostname can be made of anything
			[ false, 'http:///example.org', 'Must have exactly two slashes, not three' ],

			# (\w+:{0,1}\w*@)? - optional user:pass
			[ true, 'http://user@host', 'Username provided' ],
			[ true, 'http://user:@host', 'Username provided, no password' ],
			[ true, 'http://user:pass@host', 'Username and password provided' ],

			# (\S+) - host part is made of anything not whitespaces
			// commented these out in order to remove @group Broken
			// @todo are these valid tests? if so, fix MWHttpRequest::isValidURI so it can handle them
			// [ false, 'http://!"èèè¿¿¿~~\'', 'hostname is made of any non whitespace' ],
			// [ false, 'http://exam:ple.org/', 'hostname cannot use colons!' ],

			# (:[0-9]+)? - port number
			[ true, 'http://example.org:80/' ],
			[ true, 'https://example.org:80/' ],
			[ true, 'http://example.org:443/' ],
			[ true, 'https://example.org:443/' ],

			# Part after the hostname is / or / with something else
			[ true, 'http://example/#' ],
			[ true, 'http://example/!' ],
			[ true, 'http://example/:' ],
			[ true, 'http://example/.' ],
			[ true, 'http://example/?' ],
			[ true, 'http://example/+' ],
			[ true, 'http://example/=' ],
			[ true, 'http://example/&' ],
			[ true, 'http://example/%' ],
			[ true, 'http://example/@' ],
			[ true, 'http://example/-' ],
			[ true, 'http://example//' ],
			[ true, 'http://example/&' ],

			# Fragment
			[ true, 'http://exam#ple.org', ], # This one is valid, really!
			[ true, 'http://example.org:80#anchor' ],
			[ true, 'http://example.org/?id#anchor' ],
			[ true, 'http://example.org/?#anchor' ],

			[ false, 'http://a ¿non !!sens after', 'Allow anything after URI' ],
		];
	}

	/**
	 * T29854 : MWHttpRequest::isValidURI is too lax
	 * @dataProvider provideURI
	 * @covers \MWHttpRequest::isValidURI
	 */
	public function testIsValidUri( $expect, $uri, $message = '' ) {
		$this->assertSame( $expect, MWHttpRequest::isValidURI( $uri ), $message );
	}

	public function testSetReverseProxy() {
		$req = TestingAccessWrapper::newFromObject(
			MediaWikiServices::getInstance()->getHttpRequestFactory()->create( 'https://example.org/path?query=string' )
		);
		$req->setReverseProxy( 'http://localhost:1234' );
		$this->assertSame( 'http://localhost:1234/path?query=string', $req->url );
		$this->assertSame( 'example.org', $req->reqHeaders['Host'] );
	}

	public function testItInjectsTelemetryHeaders() {
		$telemetry = $this->createMock( TelemetryHeadersInterface::class );
		$telemetry->expects( $this->once() )
			->method( 'getRequestHeaders' )
			->willReturn( [
				'X-Request-Id' => 'request_identifier',
				'tracestate' => 'tracestate_value',
				'traceparent' => 'traceparent_value',
			] );

		$httpRequest = $this->getMockForAbstractClass(
			MWHttpRequest::class,
			[
				'http://localhost/test',
				[
					'timeout' => 30,
					'connectTimeout' => 30
				]
			]
		);
		$httpRequest->addTelemetry( $telemetry );

		$accessWrapper = TestingAccessWrapper::newFromObject( $httpRequest );
		$requestHeaders = $accessWrapper->reqHeaders;

		$this->assertEquals( 'request_identifier', $requestHeaders['X-Request-Id'] );
		$this->assertEquals( 'tracestate_value', $requestHeaders['tracestate'] );
		$this->assertEquals( 'traceparent_value', $requestHeaders['traceparent'] );
	}
}
PK       ! T      http/GuzzleHttpRequestTest.phpnu Iw        <?php

use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;

/**
 * class for tests of GuzzleHttpRequest
 *
 * No actual requests are made herein - all external communications are mocked
 *
 * @covers \GuzzleHttpRequest
 * @covers \MWHttpRequest
 */
class GuzzleHttpRequestTest extends MediaWikiIntegrationTestCase {
	/** @var int[] */
	private $timeoutOptions = [
		'timeout' => 1,
		'connectTimeout' => 1
	];

	/**
	 * Placeholder url to use for various tests.  This is never contacted, but we must use
	 * a url of valid format to avoid validation errors.
	 * @var string
	 */
	protected $exampleUrl = 'http://www.example.test';

	/**
	 * Minimal example body text
	 * @var string
	 */
	protected $exampleBodyText = 'x';

	/**
	 * For accumulating callback data for testing
	 * @var string
	 */
	protected $bodyTextReceived = '';

	/**
	 * Callback: process a chunk of the result of a HTTP request
	 *
	 * @param mixed $req
	 * @param string $buffer
	 * @return int Number of bytes handled
	 */
	public function processHttpDataChunk( $req, $buffer ) {
		$this->bodyTextReceived .= $buffer;
		return strlen( $buffer );
	}

	public function testSuccess() {
		$handler = HandlerStack::create( new MockHandler( [ new Response( 200, [
			'status' => 200,
		], $this->exampleBodyText ) ] ) );
		$r = new GuzzleHttpRequest( $this->exampleUrl,
			[ 'handler' => $handler ] + $this->timeoutOptions );
		$r->execute();

		$this->assertEquals( 200, $r->getStatus() );
		$this->assertEquals( $this->exampleBodyText, $r->getContent() );
	}

	public function testSuccessConstructorCallback() {
		$this->bodyTextReceived = '';
		$handler = HandlerStack::create( new MockHandler( [ new Response( 200, [
			'status' => 200,
		], $this->exampleBodyText ) ] ) );
		$r = new GuzzleHttpRequest( $this->exampleUrl, [
			'callback' => [ $this, 'processHttpDataChunk' ],
			'handler' => $handler,
		] + $this->timeoutOptions );
		$r->execute();

		$this->assertEquals( 200, $r->getStatus() );
		$this->assertEquals( $this->exampleBodyText, $this->bodyTextReceived );
	}

	public function testSuccessSetCallback() {
		$this->bodyTextReceived = '';
		$handler = HandlerStack::create( new MockHandler( [ new Response( 200, [
			'status' => 200,
		], $this->exampleBodyText ) ] ) );
		$r = new GuzzleHttpRequest( $this->exampleUrl, [
			'handler' => $handler,
		] + $this->timeoutOptions );
		$r->setCallback( [ $this, 'processHttpDataChunk' ] );
		$r->execute();

		$this->assertEquals( 200, $r->getStatus() );
		$this->assertEquals( $this->exampleBodyText, $this->bodyTextReceived );
	}

	/**
	 * use a callback stream to pipe the mocked response data to our callback function
	 */
	public function testSuccessSink() {
		$this->bodyTextReceived = '';
		$handler = HandlerStack::create( new MockHandler( [ new Response( 200, [
			'status' => 200,
		], $this->exampleBodyText ) ] ) );
		$r = new GuzzleHttpRequest( $this->exampleUrl, [
			'handler' => $handler,
			'sink' => new MWCallbackStream( [ $this, 'processHttpDataChunk' ] ),
		] + $this->timeoutOptions );
		$r->execute();

		$this->assertEquals( 200, $r->getStatus() );
		$this->assertEquals( $this->exampleBodyText, $this->bodyTextReceived );
	}

	public function testBadUrl() {
		$r = new GuzzleHttpRequest( '', $this->timeoutOptions );
		$s = $r->execute();
		$this->assertSame( 0, $r->getStatus() );
		$this->assertStatusMessage( 'http-invalid-url', $s );
	}

	public function testConnectException() {
		$handler = HandlerStack::create( new MockHandler( [ new GuzzleHttp\Exception\ConnectException(
			'Mock Connection Exception', new Request( 'GET', $this->exampleUrl )
		) ] ) );
		$r = new GuzzleHttpRequest( $this->exampleUrl,
			[ 'handler' => $handler ] + $this->timeoutOptions );
		$s = $r->execute();
		$this->assertSame( 0, $r->getStatus() );
		$this->assertStatusMessage( 'http-request-error', $s );
	}

	public function testTimeout() {
		$handler = HandlerStack::create( new MockHandler( [ new GuzzleHttp\Exception\RequestException(
			'Connection timed out', new Request( 'GET', $this->exampleUrl )
		) ] ) );
		$r = new GuzzleHttpRequest( $this->exampleUrl,
			[ 'handler' => $handler ] + $this->timeoutOptions );
		$s = $r->execute();
		$this->assertSame( 0, $r->getStatus() );
		$this->assertStatusMessage( 'http-timed-out', $s );
	}

	public function testNotFound() {
		$handler = HandlerStack::create( new MockHandler( [ new Response( 404, [
			'status' => '404',
		] ) ] ) );
		$r = new GuzzleHttpRequest( $this->exampleUrl,
			[ 'handler' => $handler ] + $this->timeoutOptions );
		$s = $r->execute();
		$this->assertEquals( 404, $r->getStatus() );
		$this->assertStatusMessage( 'http-bad-status', $s );
	}

	/*
	 * Test of POST requests header
	 */
	public function testPostBody() {
		$container = [];
		$history = Middleware::history( $container );
		$stack = HandlerStack::create( new MockHandler( [ new Response() ] ) );
		$stack->push( $history );
		$client = new GuzzleHttpRequest( $this->exampleUrl, [
			'method' => 'POST',
			'handler' => $stack,
			'post' => 'key=value',
		] + $this->timeoutOptions );
		$client->execute();

		$request = $container[0]['request'];
		$this->assertEquals( 'POST', $request->getMethod() );
		$this->assertEquals( 'application/x-www-form-urlencoded',
			$request->getHeader( 'Content-Type' )[0] );
	}

	/**
	 * Test POSTed multipart request body with custom content type
	 */
	public function testPostBodyContentType() {
		$container = [];
		$history = Middleware::history( $container );
		$stack = HandlerStack::create( new MockHandler( [ new Response() ] ) );
		$stack->push( $history );
		$client = new GuzzleHttpRequest( $this->exampleUrl, [
				'method' => 'POST',
				'handler' => $stack,
				'postData' => new \GuzzleHttp\Psr7\MultipartStream( [ [
					'name' => 'a',
					'contents' => 'b'
				] ] ),
			] + $this->timeoutOptions );
		$client->setHeader( 'Content-Type', 'text/mwtest' );
		$client->execute();

		$request = $container[0]['request'];
		$this->assertEquals( 'text/mwtest',
			$request->getHeader( 'Content-Type' )[0] );
	}

	/*
	 * Test that cookies from CookieJar were sent in the outgoing request.
	 */
	public function testCookieSent() {
		$domain = parse_url( $this->exampleUrl, PHP_URL_HOST );
		$expectedCookies = [ 'cookie1' => 'value1', 'anothercookie' => 'secondvalue' ];
		$jar = new CookieJar;
		foreach ( $expectedCookies as $key => $val ) {
			$jar->setCookie( $key, $val, [ 'domain' => $domain ] );
		}

		$container = [];
		$history = Middleware::history( $container );
		$stack = HandlerStack::create( new MockHandler( [ new Response() ] ) );
		$stack->push( $history );
		$client = new GuzzleHttpRequest( $this->exampleUrl, [
			'method' => 'POST',
			'handler' => $stack,
			'post' => 'key=value',
		] + $this->timeoutOptions );
		$client->setCookieJar( $jar );
		$client->execute();

		$request = $container[0]['request'];
		$this->assertEquals( [ 'cookie1=value1; anothercookie=secondvalue' ],
			$request->getHeader( 'Cookie' ) );
	}

	/*
	 * Test that cookies returned by HTTP response were added back into the CookieJar.
	 */
	public function testCookieReceived() {
		$handler = HandlerStack::create( new MockHandler( [ new Response( 200, [
			'status' => 200,
			'Set-Cookie' => [ 'cookie1=value1', 'anothercookie=secondvalue' ]
		] ) ] ) );
		$r = new GuzzleHttpRequest( $this->exampleUrl,
			[ 'handler' => $handler ] + $this->timeoutOptions );
		$r->execute();

		$domain = parse_url( $this->exampleUrl, PHP_URL_HOST );
		$this->assertEquals( 'cookie1=value1; anothercookie=secondvalue',
			$r->getCookieJar()->serializeToHttpRequest( '/', $domain ) );
	}
}
PK       ! ֮9d  d  %  Permissions/PermissionManagerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\Permissions;

use Action;
use MediaWiki\Api\ApiMessage;
use MediaWiki\Block\BlockActionInfo;
use MediaWiki\Block\CompositeBlock;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\Restriction\ActionRestriction;
use MediaWiki\Block\Restriction\NamespaceRestriction;
use MediaWiki\Block\Restriction\PageRestriction;
use MediaWiki\Block\SystemBlock;
use MediaWiki\Cache\CacheKeyHelper;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Session\SessionId;
use MediaWiki\Tests\Session\TestUtils;
use MediaWiki\Tests\Unit\MockBlockTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWikiLangTestCase;
use stdClass;
use TestAllServiceOptionsUsed;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;

/**
 * For the pure unit tests, see \MediaWiki\Tests\Unit\Permissions\PermissionManagerTest.
 *
 * @group Database
 * @covers \MediaWiki\Permissions\PermissionManager
 */
class PermissionManagerTest extends MediaWikiLangTestCase {
	use TestAllServiceOptionsUsed;
	use MockBlockTrait;
	use TempUserTestTrait;

	protected string $userName;
	protected Title $title;
	protected User $user;
	protected User $anonUser;
	protected User $userUser;
	protected User $altUser;

	private const USER_TALK_PAGE = '<user talk page>';

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

		$localZone = 'UTC';
		$localOffset = date( 'Z' ) / 60;

		$this->overrideConfigValues( [
			MainConfigNames::BlockDisablesLogin => false,
			MainConfigNames::Localtimezone => $localZone,
			MainConfigNames::LocalTZoffset => $localOffset,
			MainConfigNames::ImplicitRights => [
				'limitabletest'
			],
			MainConfigNames::RevokePermissions => [
				'formertesters' => [
					'runtest' => true
				]
			],
			MainConfigNames::AvailableRights => [
				'test',
				'runtest',
				'writetest',
				'nukeworld',
				'modifytest',
				'editmyoptions',
				'editinterface',

				// Interface admin
				'editsitejs',
				'edituserjs',

				// Admin
				'delete',
				'undelete',
				'deletedhistory',
				'deletedtext',
			]
		] );

		$this->setGroupPermissions( 'unittesters', 'test', true );
		$this->setGroupPermissions( 'unittesters', 'runtest', true );
		$this->setGroupPermissions( 'unittesters', 'writetest', false );
		$this->setGroupPermissions( 'unittesters', 'nukeworld', false );

		$this->setGroupPermissions( 'testwriters', 'test', true );
		$this->setGroupPermissions( 'testwriters', 'writetest', true );
		$this->setGroupPermissions( 'testwriters', 'modifytest', true );

		$this->setGroupPermissions( '*', 'editmyoptions', true );

		$this->setGroupPermissions( 'deleted-viewer', 'deletedhistory', true );
		$this->setGroupPermissions( 'deleted-viewer', 'deletedtext', true );
		$this->setGroupPermissions( 'deleted-viewer', 'viewsuppressed', true );

		$this->setGroupPermissions( 'interface-admin', 'editinterface', true );
		$this->setGroupPermissions( 'interface-admin', 'editsitejs', true );
		$this->setGroupPermissions( 'interface-admin', 'edituserjs', true );
		$this->setGroupPermissions( 'sysop', 'editinterface', true );
		$this->setGroupPermissions( 'sysop', 'delete', true );
		$this->setGroupPermissions( 'sysop', 'undelete', true );
		$this->setGroupPermissions( 'sysop', 'deletedhistory', true );
		$this->setGroupPermissions( 'sysop', 'deletedtext', true );

		// Without this testUserBlock will use a non-English context on non-English MediaWiki
		// installations (because of how Title::checkUserBlock is implemented) and fail.
		RequestContext::resetMain();

		$this->userName = 'Useruser';
		$altUserName = 'Altuseruser';
		date_default_timezone_set( $localZone );

		/**
		 * TODO: We should provision title object(s) via providers not in here
		 * in order for us to avoid setting mInterwiki via reflection property.
		 */
		$this->title = Title::makeTitle( NS_MAIN, "Main Page" );
		if ( !isset( $this->userUser ) || !( $this->userUser instanceof User ) ) {
			$this->userUser = User::newFromName( $this->userName );

			if ( !$this->userUser->getId() ) {
				$this->userUser = User::createNew( $this->userName, [
					"email" => "test@example.com",
					"real_name" => "Test User" ] );
				$this->userUser->load();
			}

			$this->altUser = User::newFromName( $altUserName );
			if ( !$this->altUser->getId() ) {
				$this->altUser = User::createNew( $altUserName, [
					"email" => "alttest@example.com",
					"real_name" => "Test User Alt" ] );
				$this->altUser->load();
			}

			$this->anonUser = User::newFromId( 0 );

			$this->user = $this->userUser;
		}
	}

	protected function setTitle( $ns, $title = "Main_Page" ) {
		$this->title = Title::makeTitle( $ns, $title );
	}

	protected function setUser( $userName = null ) {
		if ( $userName === 'anon' ) {
			$this->user = $this->anonUser;
		} elseif ( $userName === null || $userName === $this->userName ) {
			$this->user = $this->userUser;
		} else {
			$this->user = $this->altUser;
		}
	}

	/**
	 * @dataProvider provideSpecialsAndNSPermissions
	 */
	public function testSpecialsAndNSPermissions(
		$namespace,
		$userPerms,
		$namespaceProtection,
		$expectedPermErrors,
		$expectedUserCan
	) {
		$this->setUser( $this->userName );
		$this->setTitle( $namespace );

		$this->mergeMwGlobalArrayValue( 'wgNamespaceProtection', $namespaceProtection );
		$this->overrideConfigValue(
			MainConfigNames::NamespaceProtection,
			$namespaceProtection + [ NS_MEDIAWIKI => 'editinterface' ]
		);

		$permissionManager = $this->getServiceContainer()->getPermissionManager();

		$this->overrideUserPermissions( $this->user, $userPerms );

		$this->assertEquals(
			$expectedPermErrors,
			$permissionManager->getPermissionErrors( 'bogus', $this->user, $this->title )
		);
		$this->assertSame(
			$expectedUserCan,
			$permissionManager->userCan( 'bogus', $this->user, $this->title )
		);
	}

	public static function provideSpecialsAndNSPermissions() {
		yield [
			'namespace' => NS_SPECIAL,
			'user permissions' => [],
			'namespace protection' => [],
			'expected permission errors' => [ [ 'badaccess-group0' ], [ 'ns-specialprotected' ] ],
			'user can' => false,
		];
		yield [
			'namespace' => NS_MAIN,
			'user permissions' => [ 'bogus' ],
			'namespace protection' => [],
			'expected permission errors' => [],
			'user can' => true,
		];
		yield [
			'namespace' => NS_MAIN,
			'user permissions' => [],
			'namespace protection' => [],
			'expected permission errors' => [ [ 'badaccess-group0' ] ],
			'user can' => false,
		];
		yield [
			'namespace' => NS_USER,
			'user permissions' => [],
			'namespace protection' => [ NS_USER => [ 'bogus' ] ],
			'expected permission errors' => [ [ 'badaccess-group0' ], [ 'namespaceprotected', 'User', 'bogus' ] ],
			'user can' => false,
		];
		yield [
			'namespace' => NS_MEDIAWIKI,
			'user permissions' => [ 'bogus' ],
			'namespace protection' => [],
			'expected permission errors' => [ [ 'protectedinterface', 'bogus' ] ],
			'user can' => false,
		];
		yield [
			'namespace' => NS_MAIN,
			'user permissions' => [ 'bogus' ],
			'namespace protection' => [],
			'expected permission errors' => [],
			'user can' => true,
		];
	}

	public function testCascadingSourcesRestrictions() {
		$this->setTitle( NS_MAIN, "Test page" );
		$this->overrideUserPermissions( $this->user, [ "edit", "bogus", 'createpage' ] );

		$rs = $this->getServiceContainer()->getRestrictionStore();
		$wrapper = TestingAccessWrapper::newFromObject( $rs );
		$wrapper->cache = [ CacheKeyHelper::getKeyForPage( $this->title ) => [
			'cascade_sources' => [
				[
					Title::makeTitle( NS_MAIN, "Bogus" ),
					Title::makeTitle( NS_MAIN, "UnBogus" )
				],
				[
					"bogus" => [ 'bogus', "sysop", "protect", "" ],
				],
				[
					Title::makeTitle( NS_MAIN, "Bogus" ),
					Title::makeTitle( NS_MAIN, "UnBogus" )
				],
				[]
			],
		] ];

		$permissionManager = $this->getServiceContainer()->getPermissionManager();

		$this->assertFalse( $permissionManager->userCan( 'bogus', $this->user, $this->title ) );
		$this->assertEquals( [
			[ "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ] ],
			$permissionManager->getPermissionErrors(
				'bogus', $this->user, $this->title ) );

		$this->assertTrue( $permissionManager->userCan( 'edit', $this->user, $this->title ) );
		$this->assertEquals(
			[],
			$permissionManager->getPermissionErrors( 'edit', $this->user, $this->title )
		);
	}

	public function testCascadingSourcesRestrictionsForFile() {
		$this->setTitle( NS_FILE, 'Test.jpg' );
		$this->overrideUserPermissions( $this->user, [ 'edit', 'move', 'upload', 'movefile', 'createpage' ] );

		$rs = $this->getServiceContainer()->getRestrictionStore();
		$wrapper = TestingAccessWrapper::newFromObject( $rs );
		$wrapper->cache = [ CacheKeyHelper::getKeyForPage( $this->title ) => [
				'cascade_sources' => [
					[
						Title::makeTitle( NS_MAIN, 'FileTemplate' ),
						Title::makeTitle( NS_MAIN, 'FileUser' )
					],
					[
						'edit' => [ 'sysop' ],
					],
					[
						Title::makeTitle( NS_MAIN, 'FileTemplate' )
					],
					[
						Title::makeTitle( NS_MAIN, 'FileUser' )
					]
				],
			] ];

		$permissionManager = $this->getServiceContainer()->getPermissionManager();

		$this->assertFalse( $permissionManager->userCan( 'upload', $this->user, $this->title ) );
		$this->assertEquals( [
			[ 'cascadeprotected', 2, "* [[:FileTemplate]]\n* [[:FileUser]]\n", 'upload' ] ],
			$permissionManager->getPermissionErrors( 'upload', $this->user, $this->title )
		);

		$this->assertFalse( $permissionManager->userCan( 'move', $this->user, $this->title ) );
		$this->assertEquals( [
			[ 'cascadeprotected', 2, "* [[:FileTemplate]]\n* [[:FileUser]]\n", 'move' ] ],
			$permissionManager->getPermissionErrors( 'move', $this->user, $this->title )
		);

		$this->assertFalse( $permissionManager->userCan( 'edit', $this->user, $this->title ) );
		$this->assertEquals( [
			[ 'cascadeprotected', 2, "* [[:FileTemplate]]\n* [[:FileUser]]\n", 'edit' ] ],
			$permissionManager->getPermissionErrors( 'edit', $this->user, $this->title )
		);
	}

	/**
	 * @dataProvider provideActionPermissions
	 */
	public function testActionPermissions(
		$namespace,
		$titleOverrides,
		$action,
		$userPerms,
		$expectedPermErrors,
		$expectedUserCan
	) {
		$this->setTitle( $namespace, "Test page" );

		$this->overrideUserPermissions( $this->user, $userPerms );

		$permissionManager = $this->getServiceContainer()->getPermissionManager();

		$rs = $this->getServiceContainer()->getRestrictionStore();
		$wrapper = TestingAccessWrapper::newFromObject( $rs );
		$wrapper->cache = [ CacheKeyHelper::getKeyForPage( $this->title ) => [
			'create_protection' => [
				'permission' => $titleOverrides['protectedPermission'] ?? '',
				'user' => $this->user->getId(),
				'expiry' => 'infinity',
				'reason' => 'test',
			],
			'has_cascading' => false,
			// XXX This is bogus, restrictions won't be empty if there's create protection
			'restrictions' => [],
		] ];

		if ( isset( $titleOverrides['interwiki'] ) ) {
			$reflectedTitle = TestingAccessWrapper::newFromObject( $this->title );
			$reflectedTitle->mInterwiki = $titleOverrides['interwiki'];
		}

		$this->assertEquals(
			$expectedPermErrors,
			$permissionManager->getPermissionErrors( $action, $this->user, $this->title )
		);
		$this->assertSame(
			$expectedUserCan,
			$permissionManager->userCan( $action, $this->user, $this->title )
		);
	}

	public static function provideActionPermissions() {
		// title overrides can include "protectedPermission" to override
		// $title->mTitleProtection['permission'], and "interwiki" to override
		// $title->mInterwiki, for the few cases those are needed
		yield [
			'namespace' => NS_MAIN,
			'title overrides' => [],
			'action' => 'create',
			'user permissions' => [ 'createpage' ],
			'expected permission errors' => [ [ 'titleprotected', 'Useruser', 'test' ] ],
			'user can' => false,
		];
		yield [
			'namespace' => NS_MAIN,
			'title overrides' => [ 'protectedPermission' => 'editprotected' ],
			'action' => 'create',
			'user permissions' => [ 'createpage', 'protect' ],
			'expected permission errors' => [ [ 'titleprotected', 'Useruser', 'test' ] ],
			'user can' => false,
		];
		yield [
			'namespace' => NS_MAIN,
			'title overrides' => [ 'protectedPermission' => 'editprotected' ],
			'action' => 'create',
			'user permissions' => [ 'createpage', 'editprotected' ],
			'expected permission errors' => [],
			'user can' => true,
		];
		yield [
			'namespace' => NS_MEDIA,
			'title overrides' => [],
			'action' => 'move',
			'user permissions' => [ 'move' ],
			'expected permission errors' => [ [ 'immobile-source-namespace', 'Media' ] ],
			'user can' => false,
		];
		yield [
			'namespace' => NS_HELP,
			'title overrides' => [],
			'action' => 'move',
			'user permissions' => [ 'move' ],
			'expected permission errors' => [],
			'user can' => true,
		];
		yield [
			'namespace' => NS_HELP,
			'title overrides' => [ 'interwiki' => 'no' ],
			'action' => 'move',
			'user permissions' => [ 'move' ],
			'expected permission errors' => [ [ 'immobile-source-page' ] ],
			'user can' => false,
		];
		yield [
			'namespace' => NS_MEDIA,
			'title overrides' => [],
			'action' => 'move-target',
			'user permissions' => [ 'move' ],
			'expected permission errors' => [ [ 'immobile-target-namespace', 'Media' ] ],
			'user can' => false,
		];
		yield [
			'namespace' => NS_HELP,
			'title overrides' => [],
			'action' => 'move-target',
			'user permissions' => [ 'move' ],
			'expected permission errors' => [],
			'user can' => true,
		];
		yield [
			'namespace' => NS_HELP,
			'title overrides' => [ 'interwiki' => 'no' ],
			'action' => 'move-target',
			'user permissions' => [ 'move' ],
			'expected permission errors' => [ [ 'immobile-target-page' ] ],
			'user can' => false,
		];
		yield [
			'namespace' => NS_MAIN,
			'title overrides' => [],
			'action' => 'edit',
			'user permissions' => [ 'createpage', 'edit' ],
			'expected permission errors' => [ [ 'titleprotected', 'Useruser', 'test' ] ],
			'user can' => false,
		];
		yield [
			'namespace' => NS_MAIN,
			'title overrides' => [],
			'action' => 'edit',
			'user permissions' => [ 'edit' ],
			'expected permission errors' => [ [ 'nocreate-loggedin' ] ],
			'user can' => false,
		];
	}

	public function testEditActionPermissionWithExistingPage() {
		$title = $this->getExistingTestPage( 'test page' )->getTitle();

		$permissionManager = $this->getServiceContainer()->getPermissionManager();

		$this->overrideUserPermissions( $this->user, [ 'edit' ] );

		$this->assertSame( [], $permissionManager->getPermissionErrors( 'edit', $this->user, $title ) );
		$this->assertTrue( $permissionManager->userCan( 'edit', $this->user, $title ) );
	}

	public function testAutocreatePermissionsHack() {
		$this->enableAutoCreateTempUser();
		$this->overrideConfigValue( MainConfigNames::GroupPermissions, [
			'*' => [ 'edit' => false ],
			'temp' => [ 'edit' => true, 'createpage' => true ],
		] );
		$services = $this->getServiceContainer();
		$permissionManager = $services->getPermissionManager();
		$user = $services->getUserFactory()->newAnonymous();
		$title = $this->getNonexistingTestPage()->getTitle();
		$this->assertNotEmpty(
			$permissionManager->getPermissionErrors(
				'edit',
				$user,
				$title
			)
		);
		$this->assertSame(
			[],
			$permissionManager->getPermissionErrors(
				'edit',
				$user,
				$title,
				PermissionManager::RIGOR_QUICK
			)
		);
	}

	/**
	 * @dataProvider provideTestCheckUserBlockActions
	 */
	public function testCheckUserBlockActions( $block, $restriction, $expected ) {
		$this->overrideConfigValues( [
			MainConfigNames::EmailConfirmToEdit => false,
			MainConfigNames::EnablePartialActionBlocks => true,
		] );

		if ( $restriction ) {
			$pageRestriction = new PageRestriction( 0, $this->title->getArticleID() );
			$pageRestriction->setTitle( $this->title );
			$block->setRestrictions( [ $pageRestriction ] );
		}

		$user = $this->createUserWithBlock( $block );

		$this->overrideUserPermissions( $user, [
			'createpage',
			'edit',
			'move',
			'rollback',
			'patrol',
			'upload',
			'purge'
		] );

		$permissionManager = $this->getServiceContainer()->getPermissionManager();

		// Check that user is blocked or unblocked from specific actions using getPermissionErrors
		foreach ( $expected as $action => $blocked ) {
			$expectedErrorCount = $blocked ? 1 : 0;
			$this->assertCount(
				$expectedErrorCount,
				$permissionManager->getPermissionErrors(
					$action,
					$user,
					$this->title
				),
				"Number of permission errors for action \"$action\""
			);
		}

		// Check that user is blocked or unblocked from specific actions using getApplicableBlock
		foreach ( $expected as $action => $blocked ) {
			$this->assertSame(
				$blocked,
				$permissionManager->getApplicableBlock(
					$action,
					$user,
					PermissionManager::RIGOR_FULL,
					$this->title,
					null
				) !== null,
				"Block returned by getApplicableBlock() for action \"$action\""
			);
		}

		// quickUserCan should ignore user blocks
		$this->assertTrue(
			$permissionManager->quickUserCan( 'move-target', $this->user, $this->title )
		);
	}

	/**
	 * Create a user that is blocked in global state
	 *
	 * @param array $options $block
	 * @return User
	 */
	private function createUserWithBlock( $options = [] ) {
		$newUser = new User();
		$newUser->setId( 12345 );
		$newUser->setName( 'BlockedUser' );

		$this->installMockBlockManager( $options, $newUser );
		return $newUser;
	}

	/**
	 * Regression test for T348451
	 */
	public function testGetApplicableBlockForSpecialPage() {
		$block = new DatabaseBlock( [
			'address' => '127.0.8.1',
			'by' => new UserIdentityValue( 100, 'TestUser' ),
			'auto' => true,
		] );

		$user = $this->createUserWithBlock( $block );
		$title = Title::makeTitle( NS_SPECIAL, 'Blankpage' );

		$this->overrideUserPermissions( $user, [
			'createpage',
			'edit',
		] );

		$permissionManager = $this->getServiceContainer()->getPermissionManager();

		// The block is applicable even if the target page is a special page
		// for which we cannot instantiate an Action object.
		$this->assertSame(
			$block,
			$permissionManager->getApplicableBlock(
				'edit',
				$user,
				PermissionManager::RIGOR_FULL,
				$title,
				null
			)
		);
	}

	/**
	 * Regression test for T350202
	 */
	public function testGetApplicableBlockForImplicitRight() {
		$block = new DatabaseBlock( [
			'address' => '127.0.8.1',
			'by' => new UserIdentityValue( 100, 'TestUser' ),
			'auto' => true,
		] );

		$user = $this->createUserWithBlock( $block );
		$title = Title::makeTitle( NS_MAIN, 'Test' );

		$permissionManager = $this->getServiceContainer()->getPermissionManager();

		// The block is not applicable because the purge permission is implicit.
		$this->assertNull(
			$permissionManager->getApplicableBlock(
				'purge',
				$user,
				PermissionManager::RIGOR_FULL,
				$title,
				null
			)
		);
	}

	public static function provideTestCheckUserBlockActions() {
		return [
			'Sitewide autoblock' => [
				new DatabaseBlock( [
					'address' => '127.0.8.1',
					'by' => new UserIdentityValue( 100, 'TestUser' ),
					'auto' => true,
				] ),
				false,
				[
					'edit' => true,
					'move-target' => true,
					'rollback' => true,
					'patrol' => true,
					'upload' => true,
					'purge' => false,
				]
			],
			'Sitewide block' => [
				new DatabaseBlock( [
					'address' => '127.0.8.1',
					'by' => new UserIdentityValue( 100, 'TestUser' ),
				] ),
				false,
				[
					'edit' => true,
					'move-target' => true,
					'rollback' => true,
					'patrol' => true,
					'upload' => true,
					'purge' => false,
				]
			],
			'Partial block without restriction against this page' => [
				new DatabaseBlock( [
					'address' => '127.0.8.1',
					'by' => new UserIdentityValue( 100, 'TestUser' ),
					'sitewide' => false,
				] ),
				false,
				[
					'edit' => false,
					'move-target' => false,
					'rollback' => false,
					'patrol' => false,
					'upload' => false,
					'purge' => false,
				]
			],
			'Partial block with restriction against this page' => [
				new DatabaseBlock( [
					'address' => '127.0.8.1',
					'by' => new UserIdentityValue( 100, 'TestUser' ),
					'sitewide' => false,
				] ),
				true,
				[
					'edit' => true,
					'move-target' => true,
					'rollback' => true,
					'patrol' => true,
					'upload' => false,
					'purge' => false,
				]
			],
			'Partial block with action restriction against uploading' => [
				( new DatabaseBlock( [
					'address' => '127.0.8.1',
					'by' => UserIdentityValue::newRegistered( 100, 'Test' ),
					'sitewide' => false,
				] ) )->setRestrictions( [
					new ActionRestriction( 0, BlockActionInfo::ACTION_UPLOAD )
				] ),
				false,
				[
					'edit' => false,
					'move-target' => false,
					'rollback' => false,
					'patrol' => false,
					'upload' => true,
					'purge' => false,
				]
			],
			'System block' => [
				new SystemBlock( [
					'address' => '127.0.8.1',
					'by' => 100,
					'systemBlock' => 'test',
				] ),
				false,
				[
					'edit' => true,
					'move-target' => true,
					'rollback' => true,
					'patrol' => true,
					'upload' => true,
					'purge' => false,
				]
			],
			'No block' => [
				null,
				false,
				[
					'edit' => false,
					'move-target' => false,
					'rollback' => false,
					'patrol' => false,
					'upload' => false,
					'purge' => false,
				]
			]
		];
	}

	/**
	 * A test of the filter() calls in getApplicableBlock()
	 */
	public function testGetApplicableBlockCompositeFilter() {
		$this->overrideConfigValues( [
			MainConfigNames::EnablePartialActionBlocks => true,
		] );
		$blockOptions = [
			'address' => '127.0.8.1',
			'by' => UserIdentityValue::newRegistered( 100, 'Test' ),
			'sitewide' => false,
		];

		$uploadBlock = new DatabaseBlock( $blockOptions );
		$uploadBlock->setRestrictions( [
			new ActionRestriction( 0, BlockActionInfo::ACTION_UPLOAD )
		] );

		$emailBlock = new DatabaseBlock(
			[
				'blockEmail' => true,
				'sitewide' => true
			] + $blockOptions
		);

		$page = $this->getExistingTestPage();
		$page2 = $this->getExistingTestPage( __FUNCTION__ . ' page2' );
		$pageBlock = new DatabaseBlock( $blockOptions );
		$pageBlock->setRestrictions( [
			new PageRestriction( 0, $page->getId() )
		] );

		$compositeBlock = new CompositeBlock( [
			'originalBlocks' => [
				$uploadBlock,
				$emailBlock,
				$pageBlock
			]
		] );
		$user = $this->createUserWithBlock( $compositeBlock );
		$permissionManager = $this->getServiceContainer()->getPermissionManager();

		// The email block, being a sitewide block with an additional
		// blockEmail option, also blocks upload.
		// assertEquals() gives nicer failure messages than assertSame().
		$this->assertEquals(
			[ $uploadBlock, $emailBlock ],
			$permissionManager->getApplicableBlock(
				'upload', $user, PermissionManager::RIGOR_FULL, null, null
			)->toArray()
		);

		// Emailing is only blocked by the email block
		$this->assertEquals(
			[ $emailBlock ],
			$permissionManager->getApplicableBlock(
				'sendemail', $user, PermissionManager::RIGOR_FULL, null, null
			)->toArray()
		);

		// As for upload, the email block applies to sitewide editing
		$this->assertEquals(
			[ $emailBlock, $pageBlock ],
			$permissionManager->getApplicableBlock(
				'edit', $user, PermissionManager::RIGOR_FULL, $page->getTitle(), null
			)->toArray()
		);

		// Test filtering by page -- we use $page2 so $pageBlock does not apply
		$this->assertEquals(
			[ $emailBlock ],
			$permissionManager->getApplicableBlock(
				'edit', $user, PermissionManager::RIGOR_FULL, $page2->getTitle(), null
			)->toArray()
		);
	}

	/**
	 * @dataProvider provideTestCheckUserBlockMessage
	 */
	public function testCheckUserBlockMessage( $blockType, $blockParams, $restriction, $expected ) {
		$this->overrideConfigValue(
			MainConfigNames::EmailConfirmToEdit, false
		);
		$block = new $blockType( array_merge( [
			'address' => '127.0.8.1',
			'by' => $this->user,
			'reason' => 'Test reason',
			'timestamp' => '20000101000000',
			'expiry' => 0,
		], $blockParams ) );

		if ( $restriction ) {
			$pageRestriction = new PageRestriction( 0, $this->title->getArticleID() );
			$pageRestriction->setTitle( $this->title );
			$block->setRestrictions( [ $pageRestriction ] );
		}

		$user = $this->createUserWithBlock( $block );
		$this->overrideUserPermissions( $user, [ 'edit', 'createpage' ] );

		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		$errors = $permissionManager->getPermissionErrors(
			'edit',
			$user,
			$this->title
		);

		$this->assertEquals(
			$expected['message'],
			$errors[0][0]
		);
	}

	public static function provideTestCheckUserBlockMessage() {
		return [
			'Sitewide autoblock' => [
				DatabaseBlock::class,
				[ 'auto' => true ],
				false,
				[
					'message' => 'autoblockedtext',
				],
			],
			'Sitewide block' => [
				DatabaseBlock::class,
				[],
				false,
				[
					'message' => 'blockedtext',
				],
			],
			'Partial block with restriction against this page' => [
				DatabaseBlock::class,
				[ 'sitewide' => false ],
				true,
				[
					'message' => 'blockedtext-partial',
				],
			],
			'System block' => [
				SystemBlock::class,
				[ 'systemBlock' => 'test' ],
				false,
				[
					'message' => 'systemblockedtext',
				],
			],
		];
	}

	/**
	 * @dataProvider provideTestCheckUserBlockEmailConfirmToEdit
	 */
	public function testCheckUserBlockEmailConfirmToEdit( $emailConfirmToEdit, $assertion ) {
		$this->overrideConfigValues( [
			MainConfigNames::EmailConfirmToEdit => $emailConfirmToEdit,
			MainConfigNames::EmailAuthentication => true,
		] );

		$this->overrideUserPermissions( $this->user, [
			'edit',
			'move',
		] );

		$permissionManager = $this->getServiceContainer()->getPermissionManager();

		$this->$assertion( [ 'confirmedittext' ],
			$permissionManager->getPermissionErrors( 'edit', $this->user, $this->title ) );

		// $wgEmailConfirmToEdit only applies to 'edit' action
		$this->assertEquals( [],
			$permissionManager->getPermissionErrors( 'move-target', $this->user, $this->title ) );
	}

	public static function provideTestCheckUserBlockEmailConfirmToEdit() {
		return [
			'User must confirm email to edit' => [
				true,
				'assertContains',
			],
			'User may edit without confirming email' => [
				false,
				'assertNotContains',
			],
		];
	}

	/**
	 * Determine that the passed-in permission does not get mixed up with
	 * an action of the same name.
	 */
	public function testCheckUserBlockActionPermission() {
		$tester = $this->createMock( Action::class );
		$tester->method( 'getName' )
			->willReturn( 'tester' );
		$tester->method( 'getRestriction' )
			->willReturn( 'test' );
		$tester->method( 'requiresUnblock' )
			->willReturn( false );
		$tester->method( 'requiresWrite' )
			->willReturn( false );
		$tester->method( 'needsReadRights' )
			->willReturn( false );

		$this->overrideConfigValues( [
			MainConfigNames::Actions => [
				'tester' => $tester,
			],
			MainConfigNames::GroupPermissions => [
				'*' => [
					'tester' => true,
				],
			],
		] );

		$user = $this->createUserWithBlock( new DatabaseBlock( [
			'address' => '127.0.8.1',
			'by' => $this->user,
		] ) );
		$this->assertCount( 1, $this->getServiceContainer()->getPermissionManager()
			->getPermissionErrors( 'tester', $user, $this->title )
		);
	}

	public function testBlockInstanceCache() {
		// First, check the user isn't blocked
		$user = $this->getMutableTestUser()->getUser();
		$ut = Title::makeTitle( NS_USER_TALK, $user->getName() );
		$this->assertNull( $user->getBlock( false ) );
		$this->assertFalse( $this->getServiceContainer()->getPermissionManager()
			->isBlockedFrom( $user, $ut ) );

		// Block the user
		$blocker = $this->getTestSysop()->getUser();
		$block = new DatabaseBlock( [
			'hideName' => true,
			'allowUsertalk' => false,
			'reason' => 'Because',
		] );
		$block->setTarget( $user );
		$block->setBlocker( $blocker );
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$res = $blockStore->insertBlock( $block );
		$this->assertTrue( (bool)$res['id'], 'Failed to insert block' );

		// Clear cache and confirm it loaded the block properly
		$user->clearInstanceCache();
		$this->assertInstanceOf( DatabaseBlock::class, $user->getBlock( false ) );
		$this->assertTrue( $this->getServiceContainer()->getPermissionManager()
			->isBlockedFrom( $user, $ut ) );

		// Unblock
		$blockStore->deleteBlock( $block );

		// Clear cache and confirm it loaded the not-blocked properly
		$user->clearInstanceCache();
		$this->assertNull( $user->getBlock( false ) );
		$this->assertFalse( $this->getServiceContainer()->getPermissionManager()
			->isBlockedFrom( $user, $ut ) );
	}

	/**
	 * @dataProvider provideIsBlockedFrom
	 * @param string|null $title Title to test.
	 * @param bool $expect Expected result from User::isBlockedFrom()
	 * @param array $options Additional test options:
	 *  - 'blockAllowsUTEdit': (bool, default true) Value for $wgBlockAllowsUTEdit
	 *  - 'allowUsertalk': (bool, default false) Passed to DatabaseBlock::__construct()
	 *  - 'pageRestrictions': (array|null) If non-empty, page restriction titles for the block.
	 */
	public function testIsBlockedFrom( $title, $expect, array $options = [] ) {
		$this->overrideConfigValue(
			MainConfigNames::BlockAllowsUTEdit,
			$options['blockAllowsUTEdit'] ?? true
		);

		$user = $this->getTestUser()->getUser();

		if ( $title === self::USER_TALK_PAGE ) {
			$title = $user->getTalkPage();
		} else {
			$title = Title::newFromText( $title );
		}

		$restrictions = [];
		foreach ( $options['pageRestrictions'] ?? [] as $pagestr ) {
			$page = $this->getExistingTestPage(
				$pagestr === self::USER_TALK_PAGE ? $user->getTalkPage() : $pagestr
			);
			$restrictions[] = new PageRestriction( 0, $page->getId() );
		}
		foreach ( $options['namespaceRestrictions'] ?? [] as $ns ) {
			$restrictions[] = new NamespaceRestriction( 0, $ns );
		}

		$block = new DatabaseBlock( [
			'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ),
			'allowUsertalk' => $options['allowUsertalk'] ?? false,
			'sitewide' => !$restrictions,
		] );
		$block->setTarget( $user );
		$block->setBlocker( $this->getTestSysop()->getUser() );
		if ( $restrictions ) {
			$block->setRestrictions( $restrictions );
		}
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockStore->insertBlock( $block );

		$this->assertSame( $expect, $this->getServiceContainer()->getPermissionManager()
			->isBlockedFrom( $user, $title ) );
	}

	public static function provideIsBlockedFrom() {
		return [
			'Sitewide block, basic operation' => [ 'Test page', true ],
			'Sitewide block, not allowing user talk' => [
				self::USER_TALK_PAGE, true, [
					'allowUsertalk' => false,
				]
			],
			'Sitewide block, allowing user talk' => [
				self::USER_TALK_PAGE, false, [
					'allowUsertalk' => true,
				]
			],
			'Sitewide block, allowing user talk but $wgBlockAllowsUTEdit is false' => [
				self::USER_TALK_PAGE, true, [
					'allowUsertalk' => true,
					'blockAllowsUTEdit' => false,
				]
			],
			'Partial block, blocking the page' => [
				'Test page', true, [
					'pageRestrictions' => [ 'Test page' ],
				]
			],
			'Partial block, not blocking the page' => [
				'Test page 2', false, [
					'pageRestrictions' => [ 'Test page' ],
				]
			],
			'Partial block, not allowing user talk but user talk page is not blocked' => [
				self::USER_TALK_PAGE, false, [
					'allowUsertalk' => false,
					'pageRestrictions' => [ 'Test page' ],
				]
			],
			'Partial block, allowing user talk but user talk page is blocked' => [
				self::USER_TALK_PAGE, true, [
					'allowUsertalk' => true,
					'pageRestrictions' => [ self::USER_TALK_PAGE ],
				]
			],
			'Partial block, user talk page is not blocked but $wgBlockAllowsUTEdit is false' => [
				self::USER_TALK_PAGE, false, [
					'allowUsertalk' => false,
					'pageRestrictions' => [ 'Test page' ],
					'blockAllowsUTEdit' => false,
				]
			],
			'Partial block, user talk page is blocked and $wgBlockAllowsUTEdit is false' => [
				self::USER_TALK_PAGE, true, [
					'allowUsertalk' => true,
					'pageRestrictions' => [ self::USER_TALK_PAGE ],
					'blockAllowsUTEdit' => false,
				]
			],
			'Partial user talk namespace block, not allowing user talk' => [
				self::USER_TALK_PAGE, true, [
					'allowUsertalk' => false,
					'namespaceRestrictions' => [ NS_USER_TALK ],
				]
			],
			'Partial user talk namespace block, allowing user talk' => [
				self::USER_TALK_PAGE, false, [
					'allowUsertalk' => true,
					'namespaceRestrictions' => [ NS_USER_TALK ],
				]
			],
			'Partial user talk namespace block, where $wgBlockAllowsUTEdit is false' => [
				self::USER_TALK_PAGE, true, [
					'allowUsertalk' => true,
					'namespaceRestrictions' => [ NS_USER_TALK ],
					'blockAllowsUTEdit' => false,
				]
			],
		];
	}

	public function testGetUserPermissions() {
		$user = $this->getTestUser( [ 'unittesters' ] )->getUser();
		$rights = $this->getServiceContainer()->getPermissionManager()
			->getUserPermissions( $user );
		$this->assertContains( 'runtest', $rights );
		$this->assertNotContains( 'writetest', $rights );
		$this->assertNotContains( 'modifytest', $rights );
		$this->assertNotContains( 'nukeworld', $rights );
	}

	public function testGetUserPermissionsHooks() {
		$user = $this->getTestUser( [ 'unittesters', 'testwriters' ] )->getUser();
		$userWrapper = TestingAccessWrapper::newFromObject( $user );

		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		$rights = $permissionManager->getUserPermissions( $user );
		$this->assertContains( 'test', $rights );
		$this->assertContains( 'runtest', $rights );
		$this->assertContains( 'writetest', $rights );
		$this->assertNotContains( 'nukeworld', $rights );

		// Add a hook manipluating the rights
		$this->setTemporaryHook( 'UserGetRights', static function ( $user, &$rights ) {
			$rights[] = 'nukeworld';
			$rights = array_diff( $rights, [ 'writetest' ] );
		} );

		$permissionManager->invalidateUsersRightsCache( $user );
		$rights = $permissionManager->getUserPermissions( $user );
		$this->assertContains( 'test', $rights );
		$this->assertContains( 'runtest', $rights );
		$this->assertNotContains( 'writetest', $rights );
		$this->assertContains( 'nukeworld', $rights );

		// Add a Session that limits rights. We're mocking a stdClass because the Session
		// class is final, and thus not mockable.
		$mock = $this->getMockBuilder( stdClass::class )
			->addMethods( [ 'getAllowedUserRights', 'deregisterSession', 'getSessionId' ] )
			->getMock();
		$mock->method( 'getAllowedUserRights' )->willReturn( [ 'test', 'writetest' ] );
		$mock->method( 'getSessionId' )->willReturn(
			new SessionId( str_repeat( 'X', 32 ) )
		);
		$session = TestUtils::getDummySession( $mock );
		$mockRequest = $this->getMockBuilder( FauxRequest::class )
			->onlyMethods( [ 'getSession' ] )
			->getMock();
		$mockRequest->method( 'getSession' )->willReturn( $session );
		$userWrapper->mRequest = $mockRequest;

		$this->resetServices();
		$rights = $this->getServiceContainer()
			->getPermissionManager()
			->getUserPermissions( $user );
		$this->assertContains( 'test', $rights );
		$this->assertNotContains( 'runtest', $rights );
		$this->assertNotContains( 'writetest', $rights );
		$this->assertNotContains( 'nukeworld', $rights );
	}

	public function testUserHasRight() {
		$permissionManager = $this->getServiceContainer()->getPermissionManager();

		$result = $permissionManager->userHasRight(
			$this->getTestUser( 'unittesters' )->getUser(),
			'test'
		);
		$this->assertTrue( $result, 'right was granted to group, so should be allowed' );

		$result = $permissionManager->userHasRight(
			$this->getTestUser( 'unittesters' )->getUser(),
			'limitabletest'
		);
		$this->assertTrue( $result, 'not granted, but listed as implicit' );

		$result = $permissionManager->userHasRight(
			$this->getTestUser( 'unittesters' )->getUser(),
			'mailpassword'
		);
		$this->assertTrue( $result, 'not granted, but has a limit, so should be allowed' );

		$result = $permissionManager->userHasRight(
			$this->getTestUser( 'unittesters' )->getUser(),
			'rollback'
		);
		$this->assertFalse( $result, 'not granted, has a limit but is listed as available, so should not be allowed' );

		$result = $permissionManager->userHasRight(
			$this->getTestUser( 'formertesters' )->getUser(),
			'runtest'
		);
		$this->assertFalse( $result, 'not granted, should not be allowed' );

		$result = $permissionManager->userHasRight(
			$this->getTestUser( 'formertesters' )->getUser(),
			''
		);
		$this->assertTrue( $result, 'empty action should always be granted' );
	}

	public function testIsEveryoneAllowed() {
		$permissionManager = $this->getServiceContainer()->getPermissionManager();

		$result = $permissionManager->isEveryoneAllowed( 'editmyoptions' );
		$this->assertTrue( $result );

		$result = $permissionManager->isEveryoneAllowed( 'test' );
		$this->assertFalse( $result );
	}

	public function testAddTemporaryUserRights() {
		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		$this->overrideUserPermissions( $this->user, [ 'read', 'edit' ] );

		$this->assertEquals( [ 'read', 'edit' ], $permissionManager->getUserPermissions( $this->user ) );
		$this->assertFalse( $permissionManager->userHasRight( $this->user, 'move' ) );

		$scope = $permissionManager->addTemporaryUserRights( $this->user, [ 'move', 'delete' ] );
		$this->assertEquals( [ 'read', 'edit', 'move', 'delete' ],
			$permissionManager->getUserPermissions( $this->user ) );
		$this->assertTrue( $permissionManager->userHasRight( $this->user, 'move' ) );

		$scope2 = $permissionManager->addTemporaryUserRights( $this->user, [ 'delete', 'upload' ] );
		$this->assertEquals( [ 'read', 'edit', 'move', 'delete', 'upload' ],
			$permissionManager->getUserPermissions( $this->user ) );

		ScopedCallback::consume( $scope );
		$this->assertEquals( [ 'read', 'edit', 'delete', 'upload' ],
			$permissionManager->getUserPermissions( $this->user ) );
		ScopedCallback::consume( $scope2 );
		$this->assertEquals( [ 'read', 'edit' ],
			$permissionManager->getUserPermissions( $this->user ) );
		$this->assertFalse( $permissionManager->userHasRight( $this->user, 'move' ) );

		( function () use ( $permissionManager ) {
			$scope = $permissionManager->addTemporaryUserRights( $this->user, 'move' );
			$this->assertTrue( $permissionManager->userHasRight( $this->user, 'move' ) );
		} )();
		$this->assertFalse( $permissionManager->userHasRight( $this->user, 'move' ) );
	}

	public static function provideGetRestrictionLevels() {
		return [
			'No namespace restriction' => [ [ '', 'autoconfirmed', 'sysop' ], NS_TALK ],
			'Restricted to autoconfirmed' => [ [ '', 'sysop' ], NS_MAIN ],
			'Restricted to sysop' => [ [ '' ], NS_USER ],
			'Restricted to someone in two groups' => [ [ '', 'sysop' ], 101 ],
			'No special permissions' => [
				[ '' ],
				NS_TALK,
				[]
			],
			'autoconfirmed' => [
				[ '', 'autoconfirmed' ],
				NS_TALK,
				[ 'autoconfirmed' ]
			],
			'autoconfirmed revoked' => [
				[ '' ],
				NS_TALK,
				[ 'autoconfirmed', 'noeditsemiprotected' ]
			],
			'sysop' => [
				[ '', 'autoconfirmed', 'sysop' ],
				NS_TALK,
				[ 'sysop' ]
			],
			'sysop with autoconfirmed revoked (a bit silly)' => [
				[ '', 'sysop' ],
				NS_TALK,
				[ 'sysop', 'noeditsemiprotected' ]
			],
		];
	}

	/**
	 * @dataProvider provideGetRestrictionLevels
	 */
	public function testGetRestrictionLevels( array $expected, $ns, ?array $userGroups = null ) {
		$this->overrideConfigValues( [
			MainConfigNames::GroupPermissions => [
				'*' => [ 'edit' => true ],
				'autoconfirmed' => [ 'editsemiprotected' => true ],
				'sysop' => [
					'editsemiprotected' => true,
					'editprotected' => true,
				],
				'privileged' => [ 'privileged' => true ],
			],
			MainConfigNames::RevokePermissions => [
				'noeditsemiprotected' => [ 'editsemiprotected' => true ],
			],
			MainConfigNames::NamespaceProtection => [
				NS_MAIN => 'autoconfirmed',
				NS_USER => 'sysop',
				101 => [ 'editsemiprotected', 'privileged' ],
			],
			MainConfigNames::RestrictionLevels => [ '', 'autoconfirmed', 'sysop' ],
			MainConfigNames::Autopromote => []
		] );
		$user = $userGroups === null ? null : $this->getTestUser( $userGroups )->getUser();
		$this->assertSame( $expected, $this->getServiceContainer()
			->getPermissionManager()
			->getNamespaceRestrictionLevels( $ns, $user ) );
	}

	public function testGetAllPermissions() {
		$this->overrideConfigValue( MainConfigNames::AvailableRights, [ 'test_right' ] );
		$this->assertContains(
			'test_right',
			$this->getServiceContainer()
				->getPermissionManager()
				->getAllPermissions()
		);
	}

	public function testAnonPermissionsNotClash() {
		$user1 = User::newFromName( 'User1' );
		$user2 = User::newFromName( 'User2' );
		$pm = $this->getServiceContainer()->getPermissionManager();
		$pm->overrideUserRightsForTesting( $user2, [] );
		$this->assertNotSame( $pm->getUserPermissions( $user1 ), $pm->getUserPermissions( $user2 ) );
	}

	public function testAnonPermissionsNotClashOneRegistered() {
		$user1 = User::newFromName( 'User1' );
		$user2 = $this->getTestSysop()->getUser();
		$pm = $this->getServiceContainer()->getPermissionManager();
		$this->assertNotSame( $pm->getUserPermissions( $user1 ), $pm->getUserPermissions( $user2 ) );
	}

	/**
	 * Test delete-redirect checks for Special:MovePage
	 */
	public function testDeleteRedirect() {
		$this->editPage( 'ExistentRedirect3', '#REDIRECT [[Existent]]' );
		$page = Title::makeTitle( NS_MAIN, 'ExistentRedirect3' );
		$pm = $this->getServiceContainer()->getPermissionManager();

		$user = $this->getTestUser()->getUser();

		$this->assertFalse( $pm->quickUserCan( 'delete-redirect', $user, $page ) );

		$pm->overrideUserRightsForTesting( $user, 'delete-redirect' );

		$this->assertTrue( $pm->quickUserCan( 'delete-redirect', $user, $page ) );
		$this->assertArrayEquals( [], $pm->getPermissionErrors( 'delete-redirect', $user, $page ) );
	}

	/**
	 * Ensure normal admins can view deleted javascript, but not restore it
	 * See T202989
	 */
	public function testSysopInterfaceAdminRights() {
		$interfaceAdmin = $this->getTestUser( [ 'interface-admin', 'sysop' ] )->getUser();
		$admin = $this->getTestSysop()->getUser();

		$permManager = $this->getServiceContainer()->getPermissionManager();
		$userJs = Title::makeTitle( NS_USER, 'Example/common.js' );

		$this->assertTrue( $permManager->userCan( 'delete', $admin, $userJs ) );
		$this->assertTrue( $permManager->userCan( 'delete', $interfaceAdmin, $userJs ) );
		$this->assertTrue( $permManager->userCan( 'deletedhistory', $admin, $userJs ) );
		$this->assertTrue( $permManager->userCan( 'deletedhistory', $interfaceAdmin, $userJs ) );
		$this->assertTrue( $permManager->userCan( 'deletedtext', $admin, $userJs ) );
		$this->assertTrue( $permManager->userCan( 'deletedtext', $interfaceAdmin, $userJs ) );
		$this->assertFalse( $permManager->userCan( 'undelete', $admin, $userJs ) );
		$this->assertTrue( $permManager->userCan( 'undelete', $interfaceAdmin, $userJs ) );
	}

	/**
	 * Ensure specific users can view deleted contents regardless of Namespace
	 * Protection, but not restore it
	 * See T362536
	 *
	 * @dataProvider provideDeletedViewerRights
	 */
	public function testDeletedViewerRights(
		$userGroup,
		$userPerms,
		$expectedUserCan
	) {
		$currentUser = $this->getTestUser( $userGroup )->getUser();
		$permManager = $this->getServiceContainer()->getPermissionManager();
		$targetPage = Title::makeTitle( NS_MEDIAWIKI, 'Example' );
		foreach ( $userPerms as $userPerm ) {
			$this->assertSame(
				$expectedUserCan,
				$permManager->userCan( $userPerm, $currentUser, $targetPage )
			);
		}
	}

	public static function provideDeletedViewerRights() {
		yield [
			'usergroup' => '*',
			'user permissions' => [
				'delete',
				'deletedhistory',
				'deletedtext',
				'suppressrevision',
				'undelete',
				'viewsuppressed'
			],
			'user can' => false
		];
		yield [
			'usergroup' => 'deleted-viewer',
			'user permissions' => [
				'delete',
				'suppressrevision',
				'undelete'
			],
			'user can' => false
		];
		yield [
			'usergroup' => 'deleted-viewer',
			'user permissions' => [
				'deletedhistory',
				'deletedtext',
				'viewsuppressed'
			],
			'user can' => true
		];
	}

	/**
	 * Regression test for T306358 -- proper page assertion when checking
	 * blocked status on a special page
	 */
	public function testBlockedFromNonProperPage() {
		$page = Title::makeTitle( NS_SPECIAL, 'Blankpage' );
		$pm = $this->getServiceContainer()->getPermissionManager();
		$user = $this->getMockBuilder( User::class )
			->onlyMethods( [ 'getBlock' ] )
			->getMock();
		$user->method( 'getBlock' )
			->willReturn( new DatabaseBlock( [
				'address' => '127.0.8.1',
				'by' => $this->user,
			] ) );
		$errors = $pm->getPermissionErrors( 'test', $user, $page );
		$this->assertNotEmpty( $errors );
	}

	/**
	 * Test interaction with $wgWhitelistRead.
	 *
	 * @dataProvider provideWhitelistRead
	 */
	public function testWhitelistRead( array $whitelist, string $title, bool $shouldAllow ) {
		$this->overrideConfigValues( [
			MainConfigNames::LanguageCode => 'es',
			MainConfigNames::WhitelistRead => $whitelist,
		] );
		$this->setGroupPermissions( '*', 'read', false );

		$title = Title::newFromText( $title );
		$pm = $this->getServiceContainer()->getPermissionManager();
		$errors = $pm->getPermissionErrors( 'read', new User, $title );
		if ( $shouldAllow ) {
			$this->assertSame( [], $errors );
		} else {
			$this->assertNotEmpty( $errors );
		}
	}

	public static function provideWhitelistRead() {
		yield 'no match' => [ [ 'Bar', 'Baz' ], 'Foo', false ];
		yield 'match' => [ [ 'Bar', 'Foo', 'Baz' ], 'Foo', true ];
		yield 'text form' => [ [ 'Foo bar' ], 'Foo_bar', true ];
		yield 'dbkey form' => [ [ 'Foo_bar' ], 'Foo bar', true ];
		yield 'local namespace' => [ [ 'Usuario:Foo' ], 'User:Foo', true ];
		yield 'legacy mainspace' => [ [ ':Foo' ], 'Foo', true ];
		yield 'local special' => [ [ 'Especial:Todas' ], 'Special:Allpages', true ];
	}

	/**
	 * @covers \MediaWiki\Permissions\PermissionManager::getPermissionErrors
	 */
	public function testGetPermissionErrors_ignoreErrors() {
		$hookCallback = static function ( $title, $user, $action, &$result ) {
			$result = [
				[ 'ignore', 'param' ],
				[ 'noignore', 'param' ],
				'ignore',
				'noignore',
				new Message( 'ignore' ),
			];
			return false;
		};
		$this->setTemporaryHook( 'getUserPermissionsErrors', $hookCallback );

		$pm = $this->getServiceContainer()->getPermissionManager();
		$errors = $pm->getPermissionErrors(
			'read',
			$this->user,
			$this->title,
			$pm::RIGOR_QUICK,
			[ 'ignore' ]
		);

		$this->assertSame( [
			[ 'noignore', 'param' ],
			[ 'noignore' ],
		], $errors );
	}

	/**
	 * @covers \MediaWiki\Permissions\PermissionManager::checkQuickPermissions
	 */
	public function testGetPermissionErrors_objectFromHookResult() {
		$msg = ApiMessage::create( 'mymessage', 'mymessagecode', [ 'mydata' => true ] );
		$this->setTemporaryHook(
			'TitleQuickPermissions',
			static function ( $hookTitle, $hookUser, $hookAction, &$errors, $doExpensiveQueries, $short ) use ( $msg ) {
				$errors[] = [ $msg ];
				return false;
			}
		);

		$pm = $this->getServiceContainer()->getPermissionManager();

		$errorsStatus = $pm->getPermissionStatus( 'create', $this->user, $this->title );
		$errorsArray = $pm->getPermissionErrors( 'create', $this->user, $this->title );

		$this->assertSame(
			[ $msg ],
			$errorsStatus->getMessages(),
			'getPermissionStatus() preserves ApiMessage objects'
		);
	}

	public function testShouldLimitPermissionsForBlockedUserWhenBlockDisablesLogin(): void {
		$this->overrideConfigValues( [
			MainConfigNames::BlockDisablesLogin => true,
			MainConfigNames::GroupPermissions => [
				'*' => [ 'edit' => true ],
				'user' => [ 'edit' => true, 'move' => true ],
				'sysop' => [ 'block' => true ],
			],
		] );

		$testUser = $this->getTestUser()->getUserIdentity();
		$this->blockUser( $testUser );

		$permissions = $this->getServiceContainer()->getPermissionManager()->getUserPermissions( $testUser );

		$this->assertSame( [ 'edit' ], $permissions );
	}

	public function testShouldLimitPermissionsForBlockedUserShouldAllowPermissionChecksInGetUserBlock(): void {
		$this->overrideConfigValues( [
			MainConfigNames::BlockDisablesLogin => true,
			MainConfigNames::GroupPermissions => [
				'*' => [ 'edit' => true ],
				'user' => [ 'edit' => true, 'move' => true ],
				'sysop' => [ 'block' => true ],
			],
		] );

		$testUser = $this->getTestUser()->getUserIdentity();
		$hookRan = false;

		$this->setTemporaryHook(
			'GetUserBlock',
			function ( UserIdentity $user ) use ( $testUser, &$hookRan ): void {
				if ( $user->equals( $testUser ) ) {
					// Trigger an arbitrary permissions check to verify that they do not cause an infinite loop
					// when BlockDisablesLogin = true (T384197).
					$this->getServiceContainer()->getPermissionManager()
						->userHasRight( $user, 'test' );

					$hookRan = true;
				}
			}
		);

		$testUser = $this->getTestUser()->getUserIdentity();
		$this->blockUser( $testUser );

		$permissions = $this->getServiceContainer()->getPermissionManager()->getUserPermissions( $testUser );

		$this->assertSame( [ 'edit' ], $permissions );
		$this->assertTrue( $hookRan );
	}

	/**
	 * Convenience function to block a given user.
	 * @param UserIdentity $user
	 * @return void
	 */
	private function blockUser( UserIdentity $user ): void {
		$status = $this->getServiceContainer()
			->getBlockUserFactory()
			->newBlockUser( $user, $this->getTestSysop()->getAuthority(), 'infinity' )
			->placeBlock();

		$this->assertStatusGood( $status );
	}
}
PK       ! sE3  E3    MediaWikiServicesTest.phpnu Iw        <?php

use MediaWiki\Config\Config;
use MediaWiki\Config\GlobalVarConfig;
use MediaWiki\Config\HashConfig;
use MediaWiki\Hook\MediaWikiServicesHook;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\StaticHookRegistry;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use Wikimedia\Services\DestructibleService;
use Wikimedia\Services\SalvageableService;

/**
 * @covers \MediaWiki\MediaWikiServices
 * @group Database
 * This test doesn't really make queries, but needs to be in the Database test to make sure
 * that storage isn't disabled on the original instance.
 */
class MediaWikiServicesTest extends MediaWikiIntegrationTestCase {
	private const DEPRECATED_SERVICES = [
		'BlockErrorFormatter',
		'ConfigRepository',
		'ConfiguredReadOnlyMode',
	];

	/** @var array */
	public static $mockServiceWiring = [];

	/**
	 * @return Config
	 */
	private function newTestConfig() {
		$globalConfig = new GlobalVarConfig();

		$testConfig = new HashConfig();
		$testConfig->set( MainConfigNames::ServiceWiringFiles, $globalConfig->get( MainConfigNames::ServiceWiringFiles ) );
		$testConfig->set( MainConfigNames::ConfigRegistry, $globalConfig->get( MainConfigNames::ConfigRegistry ) );
		$testConfig->set( MainConfigNames::Hooks, [] );

		return $testConfig;
	}

	/**
	 * @return MediaWikiServices
	 */
	private function newMediaWikiServices() {
		$config = $this->newTestConfig();
		$instance = new MediaWikiServices( $config );

		// Load the default wiring from the specified files.
		$wiringFiles = $config->get( MainConfigNames::ServiceWiringFiles );
		$instance->loadWiringFiles( $wiringFiles );

		return $instance;
	}

	private function newConfigWithMockWiring() {
		$config = new HashConfig;
		$config->set( MainConfigNames::ServiceWiringFiles, [ __DIR__ . '/MockServiceWiring.php' ] );
		return $config;
	}

	public function testGetInstance() {
		$services = MediaWikiServices::getInstance();
		$this->assertInstanceOf( MediaWikiServices::class, $services );
	}

	public function testForceGlobalInstance() {
		$newServices = $this->newMediaWikiServices();
		$oldServices = MediaWikiServices::forceGlobalInstance( $newServices );

		$this->assertInstanceOf( MediaWikiServices::class, $oldServices );
		$this->assertNotSame( $oldServices, $newServices );

		$theServices = MediaWikiServices::getInstance();
		$this->assertSame( $theServices, $newServices );

		MediaWikiServices::forceGlobalInstance( $oldServices );

		$theServices = MediaWikiServices::getInstance();
		$this->assertSame( $theServices, $oldServices );
	}

	public function testResetGlobalInstance() {
		$newServices = $this->newMediaWikiServices();
		$oldServices = MediaWikiServices::forceGlobalInstance( $newServices );

		$service1 = $this->createMock( SalvageableService::class );
		$service1->expects( $this->never() )
			->method( 'salvage' );

		$newServices->defineService(
			'Test',
			static function () use ( $service1 ) {
				return $service1;
			}
		);

		// force instantiation
		$newServices->getService( 'Test' );

		MediaWikiServices::resetGlobalInstance( $this->newTestConfig() );
		$theServices = MediaWikiServices::getInstance();

		$this->assertSame(
			$service1,
			$theServices->getService( 'Test' ),
			'service definition should survive reset'
		);

		$this->assertNotSame( $theServices, $newServices );
		$this->assertNotSame( $theServices, $oldServices );

		MediaWikiServices::forceGlobalInstance( $oldServices );
	}

	public function testResetGlobalInstance_quick() {
		$newServices = $this->newMediaWikiServices();
		$oldServices = MediaWikiServices::forceGlobalInstance( $newServices );

		$service1 = $this->createMock( SalvageableService::class );
		$service1->expects( $this->never() )
			->method( 'salvage' );

		$service2 = $this->createMock( SalvageableService::class );
		$service2->expects( $this->once() )
			->method( 'salvage' )
			->with( $service1 );

		// sequence of values the instantiator will return
		$instantiatorReturnValues = [
			$service1,
			$service2,
		];

		$newServices->defineService(
			'Test',
			static function () use ( &$instantiatorReturnValues ) {
				return array_shift( $instantiatorReturnValues );
			}
		);

		// force instantiation
		$newServices->getService( 'Test' );

		MediaWikiServices::resetGlobalInstance( $this->newTestConfig(), 'quick' );
		$theServices = MediaWikiServices::getInstance();

		$this->assertSame( $service2, $theServices->getService( 'Test' ) );

		$this->assertNotSame( $theServices, $newServices );
		$this->assertNotSame( $theServices, $oldServices );

		MediaWikiServices::forceGlobalInstance( $oldServices );
	}

	public function testResetGlobalInstance_T263925() {
		$newServices = $this->newMediaWikiServices();
		$oldServices = MediaWikiServices::forceGlobalInstance( $newServices );
		self::$mockServiceWiring = [
			'HookContainer' => function ( MediaWikiServices $services ) {
				return new HookContainer(
					new StaticHookRegistry(
						[],
						[
							'MediaWikiServices' => [
								[
									'handler' => [
										'name' => 'test',
										'factory' => static function () {
											return new class implements MediaWikiServicesHook {
												public function onMediaWikiServices( $services ) {
												}
											};
										}
									],
									'deprecated' => false,
									'extensionPath' => 'path'
								],
							]
						],
						[]
					),
					$this->createSimpleObjectFactory()
				);
			}
		];
		$newServices->redefineService( 'HookContainer',
			self::$mockServiceWiring['HookContainer'] );

		$newServices->getHookContainer()->run( 'MediaWikiServices', [ $newServices ] );
		MediaWikiServices::resetGlobalInstance( $this->newConfigWithMockWiring(), 'quick' );
		$this->assertTrue( true, 'expected no exception from above' );

		self::$mockServiceWiring = [];
		MediaWikiServices::forceGlobalInstance( $oldServices );
	}

	public function testDisableStorage() {
		$newServices = $this->newMediaWikiServices();
		$oldServices = MediaWikiServices::forceGlobalInstance( $newServices );

		$lbFactory = $this->createMock( \Wikimedia\Rdbms\LBFactorySimple::class );

		$newServices->redefineService(
			'DBLoadBalancerFactory',
			static function () use ( $lbFactory ) {
				return $lbFactory;
			}
		);

		$this->assertFalse( $newServices->isStorageDisabled() );

		$newServices->disableStorage(); // should destroy DBLoadBalancerFactory

		$this->assertTrue( $newServices->isStorageDisabled() );

		try {
			$newServices->getDBLoadBalancer()->getConnection( DB_REPLICA );
		} catch ( RuntimeException $ex ) {
			// ok, as expected
		}

		MediaWikiServices::forceGlobalInstance( $oldServices );
		$newServices->destroy();

		// This should work now.
		MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_REPLICA );

		// No exception was thrown, avoid being risky
		$this->assertTrue( true );
	}

	public function testResetChildProcessServices() {
		$newServices = $this->newMediaWikiServices();
		$oldServices = MediaWikiServices::forceGlobalInstance( $newServices );

		$service1 = $this->createMock( DestructibleService::class );
		$service1->expects( $this->once() )
			->method( 'destroy' );

		$service2 = $this->createMock( DestructibleService::class );
		$service2->expects( $this->never() )
			->method( 'destroy' );

		// sequence of values the instantiator will return
		$instantiatorReturnValues = [
			$service1,
			$service2,
		];

		$newServices->defineService(
			'Test',
			static function () use ( &$instantiatorReturnValues ) {
				return array_shift( $instantiatorReturnValues );
			}
		);

		// force the service to become active, so we can check that it does get destroyed
		$oldTestService = $newServices->getService( 'Test' );

		MediaWikiServices::resetChildProcessServices();
		$finalServices = MediaWikiServices::getInstance();

		$newTestService = $finalServices->getService( 'Test' );
		$this->assertNotSame( $oldTestService, $newTestService );

		MediaWikiServices::forceGlobalInstance( $oldServices );
	}

	public function testResetServiceForTesting() {
		$services = $this->newMediaWikiServices();
		$serviceCounter = 0;

		$services->defineService(
			'Test',
			function () use ( &$serviceCounter ) {
				$serviceCounter++;
				$service = $this->createMock( Wikimedia\Services\DestructibleService::class );
				$service->expects( $this->once() )->method( 'destroy' );
				return $service;
			}
		);

		// This should do nothing. In particular, it should not create a service instance.
		$services->resetServiceForTesting( 'Test' );
		$this->assertSame( 0, $serviceCounter, 'No service instance should be created yet.' );

		$oldInstance = $services->getService( 'Test' );
		$this->assertSame( 1, $serviceCounter, 'A service instance should exit now.' );

		// The old instance should be detached, and destroy() called.
		$services->resetServiceForTesting( 'Test' );
		$newInstance = $services->getService( 'Test' );

		$this->assertNotSame( $oldInstance, $newInstance );

		// Satisfy the expectation that destroy() is called also for the second service instance.
		$newInstance->destroy();
	}

	public function testResetServiceForTesting_noDestroy() {
		$services = $this->newMediaWikiServices();

		$services->defineService(
			'Test',
			function () {
				$service = $this->createMock( Wikimedia\Services\DestructibleService::class );
				$service->expects( $this->never() )->method( 'destroy' );
				return $service;
			}
		);

		$oldInstance = $services->getService( 'Test' );

		// The old instance should be detached, but destroy() not called.
		$services->resetServiceForTesting( 'Test', false );
		$newInstance = $services->getService( 'Test' );

		$this->assertNotSame( $oldInstance, $newInstance );
	}

	public function provideGetters() {
		$getServiceCases = self::provideGetService();
		$getterCases = [];

		// All getters should be named just like the service, with "get" added.
		foreach ( $getServiceCases as $name => $case ) {
			if ( $name[0] === '_' ) {
				// Internal service, no getter
				continue;
			}
			[ $service, $class ] = $case;
			$getterCases[$name] = [
				'get' . $service,
				$class,
				in_array( $service, self::DEPRECATED_SERVICES )
			];
		}

		return $getterCases;
	}

	/**
	 * @dataProvider provideGetters
	 */
	public function testGetters( $getter, $type, $isDeprecated = false ) {
		if ( $isDeprecated ) {
			$this->hideDeprecated( MediaWikiServices::class . "::$getter" );
		}

		// Test against the default instance, since the dummy will not know the default services.
		$services = MediaWikiServices::getInstance();
		$service = $services->$getter();
		$this->assertInstanceOf( $type, $service );
	}

	public static function provideGetService() {
		global $IP;
		$serviceList = require "$IP/includes/ServiceWiring.php";
		$ret = [];
		foreach ( $serviceList as $name => $callback ) {
			$fun = new ReflectionFunction( $callback );
			if ( !$fun->hasReturnType() ) {
				throw new LogicException( 'All service callbacks must have a return type defined, ' .
					"none found for $name" );
			}

			$returnType = $fun->getReturnType();
			$ret[$name] = [ $name, $returnType->getName() ];
		}
		return $ret;
	}

	/**
	 * @dataProvider provideGetService
	 */
	public function testGetService( $name, $type ) {
		// Test against the default instance, since the dummy will not know the default services.
		$services = MediaWikiServices::getInstance();

		$service = $services->getService( $name );
		$this->assertInstanceOf( $type, $service );
	}

	public function testDefaultServiceInstantiation() {
		// Check all services in the default instance, not a dummy instance!
		// Note that we instantiate all services here, including any that
		// were registered by extensions.
		$services = MediaWikiServices::getInstance();
		$names = $services->getServiceNames();

		foreach ( $names as $name ) {
			$this->assertTrue( $services->hasService( $name ) );
			$service = $services->getService( $name );
			$this->assertIsObject( $service );
		}
	}

	public function testDefaultServiceWiringServicesHaveTests() {
		global $IP;
		$testedServices = array_keys( self::provideGetService() );
		$allServices = array_keys( require "$IP/includes/ServiceWiring.php" );
		$this->assertEquals(
			[],
			array_diff( $allServices, $testedServices ),
			'The following services have not been added to MediaWikiServicesTest::provideGetService'
		);
	}

	public function testGettersAreSorted() {
		$methods = ( new ReflectionClass( MediaWikiServices::class ) )
			->getMethods( ReflectionMethod::IS_STATIC | ReflectionMethod::IS_PUBLIC );

		$names = array_map( static function ( $method ) {
			return $method->getName();
		}, $methods );
		$serviceNames = array_map( static function ( $name ) {
			return "get$name";
		}, array_keys( self::provideGetService() ) );
		$names = array_values( array_filter( $names, static function ( $name ) use ( $serviceNames ) {
			return in_array( $name, $serviceNames );
		} ) );

		$sortedNames = $names;
		natcasesort( $sortedNames );

		$this->assertSame( $sortedNames, $names,
			'Please keep service getters sorted alphabetically' );
	}
}
PK       ! GxY  Y    media/ExifBitmapTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @group Media
 * @covers \ExifBitmapHandler
 * @requires extension exif
 */
class ExifBitmapTest extends MediaWikiMediaTestCase {

	/**
	 * @var ExifBitmapHandler
	 */
	protected $handler;

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

		$this->overrideConfigValue( MainConfigNames::ShowEXIF, true );

		$this->handler = new ExifBitmapHandler;
	}

	public static function provideIsFileMetadataValid() {
		return [
			'old broken' => [
				ExifBitmapHandler::OLD_BROKEN_FILE,
				ExifBitmapHandler::METADATA_COMPATIBLE
			],
			'broken' => [
				ExifBitmapHandler::BROKEN_FILE,
				ExifBitmapHandler::METADATA_GOOD
			],
			'invalid' => [
				'Something Invalid Here.',
				ExifBitmapHandler::METADATA_BAD
			],
			'good' => [
				'a:16:{s:10:"ImageWidth";i:20;s:11:"ImageLength";i:20;s:13:"BitsPerSample";a:3:{i:0;i:8;i:1;i:8;i:2;i:8;}s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:16:"ImageDescription";s:17:"Created with GIMP";s:12:"StripOffsets";i:8;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:3;s:12:"RowsPerStrip";i:64;s:15:"StripByteCounts";i:238;s:11:"XResolution";s:19:"1207959552/16777216";s:11:"YResolution";s:19:"1207959552/16777216";s:19:"PlanarConfiguration";i:1;s:14:"ResolutionUnit";i:2;s:22:"MEDIAWIKI_EXIF_VERSION";i:2;}',
				ExifBitmapHandler::METADATA_GOOD
			],
			'old good' => [
				'a:16:{s:10:"ImageWidth";i:20;s:11:"ImageLength";i:20;s:13:"BitsPerSample";a:3:{i:0;i:8;i:1;i:8;i:2;i:8;}s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:16:"ImageDescription";s:17:"Created with GIMP";s:12:"StripOffsets";i:8;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:3;s:12:"RowsPerStrip";i:64;s:15:"StripByteCounts";i:238;s:11:"XResolution";s:19:"1207959552/16777216";s:11:"YResolution";s:19:"1207959552/16777216";s:19:"PlanarConfiguration";i:1;s:14:"ResolutionUnit";i:2;s:22:"MEDIAWIKI_EXIF_VERSION";i:1;}',
				ExifBitmapHandler::METADATA_COMPATIBLE
			],
			// Handle metadata from paged tiff handler (gotten via instant commons) gracefully.
			'paged tiff' => [
				'a:6:{s:9:"page_data";a:1:{i:1;a:5:{s:5:"width";i:643;s:6:"height";i:448;s:5:"alpha";s:4:"true";s:4:"page";i:1;s:6:"pixels";i:288064;}}s:10:"page_count";i:1;s:10:"first_page";i:1;s:9:"last_page";i:1;s:4:"exif";a:9:{s:10:"ImageWidth";i:643;s:11:"ImageLength";i:448;s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:4;s:12:"RowsPerStrip";i:50;s:19:"PlanarConfiguration";i:1;s:22:"MEDIAWIKI_EXIF_VERSION";i:1;}s:21:"TIFF_METADATA_VERSION";s:3:"1.4";}',
				ExifBitmapHandler::METADATA_BAD
			],

		];
	}

	/** @dataProvider provideIsFileMetadataValid */
	public function testIsFileMetadataValid( $serializedMetadata, $expected ) {
		$file = $this->getMockFileWithMetadata( $serializedMetadata );
		$res = $this->handler->isFileMetadataValid( $file );
		$this->assertEquals( $expected, $res );
	}

	public function testConvertMetadataLatest() {
		$metadata = [
			'foo' => [ 'First', 'Second', '_type' => 'ol' ],
			'MEDIAWIKI_EXIF_VERSION' => 2
		];
		$res = $this->handler->convertMetadataVersion( $metadata, 2 );
		$this->assertEquals( $metadata, $res );
	}

	public function testConvertMetadataToOld() {
		$metadata = [
			'foo' => [ 'First', 'Second', '_type' => 'ol' ],
			'bar' => [ 'First', 'Second', '_type' => 'ul' ],
			'baz' => [ 'First', 'Second' ],
			'fred' => 'Single',
			'MEDIAWIKI_EXIF_VERSION' => 2,
		];
		$expected = [
			'foo' => "\n#First\n#Second",
			'bar' => "\n*First\n*Second",
			'baz' => "\n*First\n*Second",
			'fred' => 'Single',
			'MEDIAWIKI_EXIF_VERSION' => 1,
		];
		$res = $this->handler->convertMetadataVersion( $metadata, 1 );
		$this->assertEquals( $expected, $res );
	}

	public function testConvertMetadataSoftware() {
		$metadata = [
			'Software' => [ [ 'GIMP', '1.1' ] ],
			'MEDIAWIKI_EXIF_VERSION' => 2,
		];
		$expected = [
			'Software' => 'GIMP (Version 1.1)',
			'MEDIAWIKI_EXIF_VERSION' => 1,
		];
		$res = $this->handler->convertMetadataVersion( $metadata, 1 );
		$this->assertEquals( $expected, $res );
	}

	public function testConvertMetadataSoftwareNormal() {
		$metadata = [
			'Software' => [ "GIMP 1.2", "vim" ],
			'MEDIAWIKI_EXIF_VERSION' => 2,
		];
		$expected = [
			'Software' => "\n*GIMP 1.2\n*vim",
			'MEDIAWIKI_EXIF_VERSION' => 1,
		];
		$res = $this->handler->convertMetadataVersion( $metadata, 1 );
		$this->assertEquals( $expected, $res );
	}
}
PK       ! n      media/FakeDimensionFile.phpnu Iw        <?php

use MediaWiki\Title\Title;

class FakeDimensionFile extends File {
	/** @var bool */
	public $mustRender = false;
	/** @var string */
	public $mime;
	/** @var int[] */
	public $dimensions;

	public function __construct( $dimensions, $mime = 'unknown/unknown' ) {
		parent::__construct( Title::makeTitle( NS_FILE, 'Test' ),
			new NullRepo( null ) );

		$this->dimensions = $dimensions;
		$this->mime = $mime;
	}

	public function getWidth( $page = 1 ) {
		return $this->dimensions[0];
	}

	public function getHeight( $page = 1 ) {
		return $this->dimensions[1];
	}

	public function mustRender() {
		return $this->mustRender;
	}

	public function getPath() {
		return '';
	}

	public function getMimeType() {
		return $this->mime;
	}
}
PK       ! *~=J  J    media/DjVuTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @group Media
 * @covers \DjVuHandler
 */
class DjVuTest extends MediaWikiMediaTestCase {

	/**
	 * @var DjVuHandler
	 */
	protected $handler;

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

		// cli tool setup
		$djvuSupport = new DjVuSupport();

		if ( !$djvuSupport->isEnabled() ) {
			$this->markTestSkipped(
			'This test needs the installation of the ddjvu, djvutoxml and djvudump tools' );
		}

		$this->overrideConfigValue( MainConfigNames::DjvuUseBoxedCommand, true );

		$this->handler = new DjVuHandler();
	}

	public function testGetSizeAndMetadata() {
		$info = $this->handler->getSizeAndMetadata(
			new TrivialMediaHandlerState, $this->filePath . '/LoremIpsum.djvu' );
		$this->assertSame( 2480, $info['width'] );
		$this->assertSame( 3508, $info['height'] );
		$this->assertIsArray( $info['metadata']['data'] );
	}

	public function testInvalidFile() {
		$this->assertEquals(
			[ 'metadata' => [ 'error' => 'Error extracting metadata' ] ],
			$this->handler->getSizeAndMetadata(
				new TrivialMediaHandlerState, $this->filePath . '/some-nonexistent-file' ),
			'Getting metadata for a nonexistent file should return false'
		);
	}

	public function testPageCount() {
		$file = $this->dataFile( 'LoremIpsum.djvu', 'image/x.djvu' );
		$this->assertEquals(
			5,
			$this->handler->pageCount( $file ),
			'Test file LoremIpsum.djvu should be detected as containing 5 pages'
		);
	}

	public function testGetPageDimensions() {
		$file = $this->dataFile( 'LoremIpsum.djvu', 'image/x.djvu' );
		$this->assertSame(
			[ 'width' => 2480, 'height' => 3508 ],
			$this->handler->getPageDimensions( $file, 1 ),
			'Page 1 of test file LoremIpsum.djvu should have a size of 2480 * 3508'
		);
	}

	public function testGetPageText() {
		$file = $this->dataFile( 'LoremIpsum.djvu', 'image/x.djvu' );
		$this->assertSame(
			// note: this also tests that the column/paragraph is detected and converted
			"Lorem ipsum \n\n1 \n",
			$this->handler->getPageText( $file, 1 ),
			"Text layer of page 1 of file LoremIpsum.djvu should be 'Lorem ipsum \n\n1 \n'"
		);
	}
}
PK       ! PAw	  w	    media/JpegPixelFormatTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Shell\Shell;

/**
 * Tests related to JPEG chroma subsampling via $wgJpegPixelFormat setting.
 *
 * @group Media
 * @group medium
 */
class JpegPixelFormatTest extends MediaWikiMediaTestCase {

	/**
	 * Mark this test as creating thumbnail files.
	 * @inheritDoc
	 */
	protected function createsThumbnails() {
		return true;
	}

	/**
	 * @dataProvider providePixelFormats
	 * @covers \BitmapHandler::imageMagickSubsampling
	 */
	public function testPixelFormatRendering( $sourceFile, $pixelFormat, $samplingFactor ) {
		global $wgUseImageMagick, $wgUseImageResize;
		if ( !$wgUseImageMagick ) {
			$this->markTestSkipped( "This test is only applicable when using ImageMagick thumbnailing" );
		}
		if ( !$wgUseImageResize ) {
			$this->markTestSkipped( "This test is only applicable when using thumbnailing" );
		}

		$fmtStr = var_export( $pixelFormat, true );
		$this->overrideConfigValue( MainConfigNames::JpegPixelFormat, $pixelFormat );

		$file = $this->dataFile( $sourceFile, 'image/jpeg' );

		$params = [
			'width' => 320,
		];
		$thumb = $file->transform( $params, File::RENDER_NOW | File::RENDER_FORCE );
		$this->assertTrue( !$thumb->isError(), "created JPEG thumbnail for pixel format $fmtStr" );

		$path = $thumb->getLocalCopyPath();
		$this->assertIsString( $path, "path returned for JPEG thumbnail for $fmtStr" );

		$result = Shell::command( 'identify',
			'-format',
			'%[jpeg:sampling-factor]',
			$path
		)->execute();
		$this->assertSame( 0,
			$result->getExitCode(),
			"ImageMagick's identify command should return success"
		);

		$expected = $samplingFactor;
		$actual = trim( $result->getStdout() );
		$this->assertEquals(
			$expected,
			$actual,
			"IM identify expects JPEG chroma subsampling \"$expected\" for $fmtStr"
		);
	}

	public static function providePixelFormats() {
		return [
			// From 4:4:4 source file
			[
				'yuv444.jpg',
				false,
				'1x1,1x1,1x1'
			],
			[
				'yuv444.jpg',
				'yuv444',
				'1x1,1x1,1x1'
			],
			[
				'yuv444.jpg',
				'yuv422',
				'2x1,1x1,1x1'
			],
			[
				'yuv444.jpg',
				'yuv420',
				'2x2,1x1,1x1'
			],
			// From 4:2:0 source file
			[
				'yuv420.jpg',
				false,
				'2x2,1x1,1x1'
			],
			[
				'yuv420.jpg',
				'yuv444',
				'1x1,1x1,1x1'
			],
			[
				'yuv420.jpg',
				'yuv422',
				'2x1,1x1,1x1'
			],
			[
				'yuv420.jpg',
				'yuv420',
				'2x2,1x1,1x1'
			]
		];
	}
}
PK       ! L$G       media/MediaWikiMediaTestCase.phpnu Iw        <?php

use MediaWiki\WikiMap\WikiMap;
use Wikimedia\FileBackend\FSFileBackend;

/**
 * Specificly for testing Media handlers. Sets up a FileRepo backend
 */
abstract class MediaWikiMediaTestCase extends MediaWikiIntegrationTestCase {

	/** @var FileRepo */
	protected $repo;
	/** @var FSFileBackend */
	protected $backend;
	/** @var string */
	protected $filePath;

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

		$this->filePath = $this->getFilePath();
		$containers = [ 'data' => $this->filePath ];
		if ( $this->createsThumbnails() ) {
			// We need a temp directory for the thumbnails
			// the container is named 'temp-thumb' because it is the
			// thumb directory for a repo named "temp".
			$containers['temp-thumb'] = $this->getNewTempDirectory();
		}

		$this->backend = new FSFileBackend( [
			'name' => 'localtesting',
			'wikiId' => WikiMap::getCurrentWikiId(),
			'containerPaths' => $containers,
			'tmpDirectory' => $this->getNewTempDirectory(),
			'obResetFunc' => static function () {
				// do nothing, we need the output buffer in tests
			}
		] );
		$this->repo = new FileRepo( $this->getRepoOptions() );
	}

	/**
	 * @return array Argument for FileRepo constructor
	 */
	protected function getRepoOptions() {
		return [
			'name' => 'temp',
			'url' => 'http://localhost/thumbtest',
			'backend' => $this->backend
		];
	}

	/**
	 * The result of this method will set the file path to use,
	 * as well as the protected member $filePath
	 *
	 * @return string Path where files are
	 */
	protected function getFilePath() {
		return __DIR__ . '/../../data/media/';
	}

	/**
	 * Will the test create thumbnails (and thus do we need to set aside
	 * a temporary directory for them?)
	 *
	 * Override this method if your test case creates thumbnails
	 *
	 * @return bool
	 */
	protected function createsThumbnails() {
		return false;
	}

	/**
	 * Utility function: Get a new file object for a file on disk but not actually in db.
	 *
	 * File must be in the path returned by getFilePath()
	 * @param string $name File name
	 * @param string|false $type MIME type [optional]
	 * @return UnregisteredLocalFile
	 */
	protected function dataFile( $name, $type = false ) {
		return new UnregisteredLocalFile( false, $this->repo,
			"mwstore://localtesting/data/$name", $type );
	}

	/**
	 * Get a mock LocalFile with the specified metadata, specified as a
	 * serialized string. The metadata-related methods will return this
	 * metadata. The behaviour of the other methods is undefined.
	 *
	 * @since 1.37
	 * @param string $metadata
	 * @return LocalFile
	 */
	protected function getMockFileWithMetadata( $metadata ) {
		return new class( $metadata ) extends LocalFile {
			public function __construct( $metadata ) {
				$this->loadMetadataFromString( $metadata );
				$this->dataLoaded = true;
			}
		};
	}

}
PK       ! 34      media/XCFHandlerTest.phpnu Iw        <?php

/**
 * @group Media
 */
class XCFHandlerTest extends MediaWikiMediaTestCase {

	/** @var XCFHandler */
	protected $handler;

	protected function setUp(): void {
		parent::setUp();
		$this->handler = new XCFHandler();
	}

	/**
	 * @param string $filename
	 * @param array $expected
	 * @dataProvider provideGetSizeAndMetadata
	 * @covers \XCFHandler::getSizeAndMetadata
	 */
	public function testGetSizeAndMetadata( $filename, $expected ) {
		$file = $this->dataFile( $filename, 'image/x-xcf' );
		$actual = $this->handler->getSizeAndMetadata( $file, $file->getLocalRefPath() );
		$this->assertSame( $expected, $actual );
	}

	public static function provideGetSizeAndMetadata() {
		return [
			[
				'80x60-2layers.xcf',
				[
					'width' => 80,
					'height' => 60,
					'bits' => 8,
					'metadata' => [
						'colorType' => 'truecolour-alpha',
					]
				],
			],
			[
				'80x60-RGB.xcf',
				[
					'width' => 80,
					'height' => 60,
					'bits' => 8,
					'metadata' => [
						'colorType' => 'truecolour-alpha',
					]
				],
			],
			[
				'80x60-Greyscale.xcf',
				[
					'width' => 80,
					'height' => 60,
					'bits' => 8,
					'metadata' => [
						'colorType' => 'greyscale-alpha',
					]
				]
			],
		];
	}

	/**
	 * @param string $metadata Serialized metadata
	 * @param int $expected One of the class constants of XCFHandler
	 * @dataProvider provideIsFileMetadataValid
	 * @covers \XCFHandler::isFileMetadataValid
	 */
	public function testIsFileMetadataValid( $metadata, $expected ) {
		$actual = $this->handler->isFileMetadataValid( $this->getMockFileWithMetadata( $metadata ) );
		$this->assertEquals( $expected, $actual );
	}

	public static function provideIsFileMetadataValid() {
		return [
			[ '', XCFHandler::METADATA_BAD ],
			[ serialize( [ 'error' => true ] ), XCFHandler::METADATA_GOOD ],
			[ false, XCFHandler::METADATA_BAD ],
			[ serialize( [ 'colorType' => 'greyscale-alpha' ] ), XCFHandler::METADATA_GOOD ],
		];
	}
}
PK       ! Ey  y    media/TiffTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @group Media
 * @requires extension exif
 */
class TiffTest extends MediaWikiIntegrationTestCase {
	private const FILE_PATH = __DIR__ . '/../../data/media/';

	/** @var TiffHandler */
	protected $handler;

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

		$this->overrideConfigValue( MainConfigNames::ShowEXIF, true );

		$this->handler = new TiffHandler;
	}

	/**
	 * @covers \TiffHandler::getSizeAndMetadata
	 */
	public function testInvalidFile() {
		$res = $this->handler->getSizeAndMetadata( null, self::FILE_PATH . 'README' );
		$this->assertEquals( [ 'metadata' => [ '_error' => ExifBitmapHandler::BROKEN_FILE ] ], $res );
	}

	/**
	 * @covers \TiffHandler::getSizeAndMetadata
	 */
	public function testTiffMetadataExtraction() {
		$res = $this->handler->getSizeAndMetadata( null, self::FILE_PATH . 'test.tiff' );

		$expected = [
			'width' => 20,
			'height' => 20,
			'metadata' => [
				'ImageWidth' => 20,
				'ImageLength' => 20,
				'BitsPerSample' => [
					0 => 8,
					1 => 8,
					2 => 8,
				],
				'Compression' => 5,
				'PhotometricInterpretation' => 2,
				'ImageDescription' => 'Created with GIMP',
				'StripOffsets' => 8,
				'Orientation' => 1,
				'SamplesPerPixel' => 3,
				'RowsPerStrip' => 64,
				'StripByteCounts' => 238,
				'XResolution' => '1207959552/16777216',
				'YResolution' => '1207959552/16777216',
				'PlanarConfiguration' => 1,
				'ResolutionUnit' => 2,
				'MEDIAWIKI_EXIF_VERSION' => 2,
			]
		];

		// Re-unserialize in case there are subtle differences between how versions
		// of php serialize stuff.
		$this->assertEquals( $expected, $res );
	}
}
PK       ! m     %  media/MediaHandlerIntegrationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @group Media
 */
class MediaHandlerIntegrationTest extends MediaWikiMediaTestCase {

	/**
	 * @covers \MediaHandler::formatTag
	 * @covers \MediaHandler::formatMetadataHelper
	 */
	public function testFormatMetadataHelper() {
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'en' );
		$testHandler = new class extends MediaHandler {
			public function formatMetadata( $image, $context = false ) {
				return $this->formatMetadataHelper( [
					'UnitTestOverride' => 'abc',
					'UnitTestDelete' => 'def',
					'UnitTestOther' => '1234.5678',
				], $context );
			}

			protected function formatTag( $key, $vals, $context = false ) {
				if ( $key === 'UnitTestOverride' ) {
					return 'Override';
				} elseif ( $key === 'UnitTestDelete' ) {
					return null;
				} else {
					return false;
				}
			}

			public function getParamMap() {
				throw new LogicException( 'should never get here' );
			}

			public function validateParam( $name, $value ) {
				throw new LogicException( 'should never get here' );
			}

			public function makeParamString( $params ) {
				throw new LogicException( 'should never get here' );
			}

			public function parseParamString( $str ) {
				throw new LogicException( 'should never get here' );
			}

			public function normaliseParams( $image, &$params ) {
				throw new LogicException( 'should never get here' );
			}

			public function getImageSize( $image, $path ) {
				throw new LogicException( 'should never get here' );
			}

			public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
				throw new LogicException( 'should never get here' );
			}
		};
		$file = $this->dataFile( 'Tux.svg', 'image/svg+xml' );
		$result = $testHandler->formatMetadata( $file );
		$this->assertEqualsCanonicalizing( [
			'visible' => [
			],
			'collapsed' => [
				[
					'id' => 'exif-unittestoverride',
					'name' => 'unittestoverride',
					// Note that formatTag overrode the formatted result here
					'value' => 'Override'
				],
				[
					'id' => 'exif-unittestother',
					'name' => 'unittestother',
					// Note that this value went through Language::formatNum()
					'value' => '1,234.5678'
				],
				// Note that unittestdelete is missing as expected
			],
		], $result );
	}
}
PK       ! ki    #  media/BitmapMetadataHandlerTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @group Media
 */
class BitmapMetadataHandlerTest extends MediaWikiIntegrationTestCase {
	private const FILE_PATH = __DIR__ . '/../../data/media/';

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

		$this->overrideConfigValue( MainConfigNames::ShowEXIF, false );
	}

	/**
	 * Test if having conflicting metadata values from different
	 * types of metadata, that the right one takes precedence.
	 *
	 * Basically the file has IPTC and XMP metadata, the
	 * IPTC should override the XMP, except for the multilingual
	 * translation (to en) where XMP should win.
	 * @covers \BitmapMetadataHandler::Jpeg
	 * @requires extension exif
	 */
	public function testMultilingualCascade() {
		$this->overrideConfigValue( MainConfigNames::ShowEXIF, true );

		$meta = BitmapMetadataHandler::Jpeg( self::FILE_PATH .
			'/Xmp-exif-multilingual_test.jpg' );

		$expected = [
			'x-default' => 'right(iptc)',
			'en' => 'right translation',
			'_type' => 'lang'
		];

		$this->assertArrayHasKey( 'ImageDescription', $meta,
			'Did not extract any ImageDescription info?!' );

		$this->assertEquals( $expected, $meta['ImageDescription'] );
	}

	/**
	 * Test for jpeg comments are being handled by
	 * BitmapMetadataHandler correctly.
	 *
	 * There's more extensive tests of comment extraction in
	 * JpegMetadataExtractorTests.php
	 * @covers \BitmapMetadataHandler::Jpeg
	 */
	public function testJpegComment() {
		$meta = BitmapMetadataHandler::Jpeg( self::FILE_PATH .
			'jpeg-comment-utf.jpg' );

		$this->assertEquals( 'UTF-8 JPEG Comment — ¼',
			$meta['JPEGFileComment'][0] );
	}

	/**
	 * Make sure a bad iptc block doesn't stop the other metadata
	 * from being extracted.
	 * @covers \BitmapMetadataHandler::Jpeg
	 */
	public function testBadIPTC() {
		$meta = BitmapMetadataHandler::Jpeg( self::FILE_PATH .
			'iptc-invalid-psir.jpg' );
		$this->assertEquals( 'Created with GIMP', $meta['JPEGFileComment'][0] );
	}

	/**
	 * @covers \BitmapMetadataHandler::Jpeg
	 */
	public function testIPTCDates() {
		$meta = BitmapMetadataHandler::Jpeg( self::FILE_PATH .
			'iptc-timetest.jpg' );

		// raw date is 2020:07:13 14:04:05+11:32
		$this->assertEquals( '2020:07:14 01:36:05', $meta['DateTimeDigitized'] );
		// raw date is 1997:03:02 03:01:02-03:00
		$this->assertEquals( '1997:03:02 00:01:02', $meta['DateTimeOriginal'] );

		$meta = BitmapMetadataHandler::Jpeg( self::FILE_PATH .
			'iptc-timetest-invalid.jpg' );

		// raw date is 1845:03:02 03:01:02-03:00
		$this->assertEquals( '1845:03:02 00:01:02', $meta['DateTimeOriginal'] );
		// raw date is 1942:07:13 25:05:02+00:00
		$this->assertSame( '1942:07:14 01:05:02', $meta['DateTimeDigitized'] );
	}

	/**
	 * XMP data should take priority over iptc data
	 * when hash has been updated, but not when
	 * the hash is wrong.
	 * @covers \BitmapMetadataHandler::addMetadata
	 * @covers \BitmapMetadataHandler::getMetadataArray
	 */
	public function testMerging() {
		$merger = new BitmapMetadataHandler();
		$merger->addMetadata( [ 'foo' => 'xmp' ], 'xmp-general' );
		$merger->addMetadata( [ 'bar' => 'xmp' ], 'xmp-general' );
		$merger->addMetadata( [ 'baz' => 'xmp' ], 'xmp-general' );
		$merger->addMetadata( [ 'fred' => 'xmp' ], 'xmp-general' );
		$merger->addMetadata( [ 'foo' => 'iptc (hash)' ], 'iptc-good-hash' );
		$merger->addMetadata( [ 'bar' => 'iptc (bad hash)' ], 'iptc-bad-hash' );
		$merger->addMetadata( [ 'baz' => 'iptc (bad hash)' ], 'iptc-bad-hash' );
		$merger->addMetadata( [ 'fred' => 'iptc (no hash)' ], 'iptc-no-hash' );
		$merger->addMetadata( [ 'baz' => 'exif' ], 'exif' );

		$actual = $merger->getMetadataArray();
		$expected = [
			'foo' => 'xmp',
			'bar' => 'iptc (bad hash)',
			'baz' => 'exif',
			'fred' => 'xmp',
		];
		$this->assertEquals( $expected, $actual );
	}

	/**
	 * @covers \BitmapMetadataHandler::png
	 */
	public function testPNGXMP() {
		$result = BitmapMetadataHandler::PNG( self::FILE_PATH . 'xmp.png' );
		$expected = [
			'width' => 50,
			'height' => 50,
			'frameCount' => 0,
			'loopCount' => 1,
			'duration' => 0,
			'bitDepth' => 1,
			'colorType' => 'index-coloured',
			'metadata' => [
				'SerialNumber' => '123456789',
				'_MW_PNG_VERSION' => 1,
			],
		];
		$this->assertEquals( $expected, $result );
	}

	/**
	 * @covers \BitmapMetadataHandler::png
	 */
	public function testPNGNative() {
		$result = BitmapMetadataHandler::PNG( self::FILE_PATH . 'Png-native-test.png' );
		$expected = 'http://example.com/url';
		$this->assertEquals( $expected, $result['metadata']['Identifier']['x-default'] );
	}

	/**
	 * @covers \BitmapMetadataHandler::getTiffByteOrder
	 */
	public function testTiffByteOrder() {
		$res = BitmapMetadataHandler::getTiffByteOrder( self::FILE_PATH . 'test.tiff' );
		$this->assertEquals( 'LE', $res );
	}
}
PK       ! itj|      media/JpegTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @group Media
 * @covers \JpegHandler
 * @requires extension exif
 */
class JpegTest extends MediaWikiMediaTestCase {
	/** @var JpegHandler */
	private $handler;

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

		$this->overrideConfigValue( MainConfigNames::ShowEXIF, true );

		$this->handler = new JpegHandler;
	}

	public function testInvalidFile() {
		$file = $this->dataFile( 'README', 'image/jpeg' );
		$res = $this->handler->getSizeAndMetadataWithFallback( $file, $this->filePath . 'README' );
		$this->assertEquals( [ '_error' => ExifBitmapHandler::BROKEN_FILE ], $res['metadata'] );
	}

	public function testJpegMetadataExtraction() {
		$file = $this->dataFile( 'test.jpg', 'image/jpeg' );
		$res = $this->handler->getSizeAndMetadataWithFallback( $file, $this->filePath . 'test.jpg' );
		$expected = [
			'ImageDescription' => 'Test file',
			'XResolution' => '72/1',
			'YResolution' => '72/1',
			'ResolutionUnit' => 2,
			'YCbCrPositioning' => 1,
			'JPEGFileComment' => [
				0 => 'Created with GIMP',
			],
			'MEDIAWIKI_EXIF_VERSION' => 2,
		];

		// Unserialize in case serialization format ever changes.
		$this->assertEquals( $expected, $res['metadata'] );
	}

	/**
	 * @covers \JpegHandler::getCommonMetaArray
	 */
	public function testGetIndependentMetaArray() {
		$file = $this->dataFile( 'test.jpg', 'image/jpeg' );
		$res = $this->handler->getCommonMetaArray( $file );
		$expected = [
			'ImageDescription' => 'Test file',
			'XResolution' => '72/1',
			'YResolution' => '72/1',
			'ResolutionUnit' => 2,
			'YCbCrPositioning' => 1,
			'JPEGFileComment' => [
				'Created with GIMP',
			],
		];

		$this->assertEquals( $expected, $res );
	}

	/**
	 * @dataProvider provideSwappingICCProfile
	 * @covers \JpegHandler::swapICCProfile
	 */
	public function testSwappingICCProfile(
		$sourceFilename, $controlFilename, $newProfileFilename, $oldProfileName
	) {
		global $wgExiftool;

		if ( !$wgExiftool || !is_file( $wgExiftool ) ) {
			$this->markTestSkipped( "Exiftool not installed, cannot test ICC profile swapping" );
		}

		$this->overrideConfigValue( MainConfigNames::UseTinyRGBForJPGThumbnails, true );

		$sourceFilepath = $this->filePath . $sourceFilename;
		$controlFilepath = $this->filePath . $controlFilename;
		$profileFilepath = $this->filePath . $newProfileFilename;
		$filepath = $this->getNewTempFile();

		copy( $sourceFilepath, $filepath );

		$this->handler->swapICCProfile(
			$filepath,
			[ 'sRGB', '-' ],
			[ $oldProfileName ],
			$profileFilepath
		);

		$this->assertEquals(
			sha1( file_get_contents( $filepath ) ),
			sha1( file_get_contents( $controlFilepath ) )
		);
	}

	public static function provideSwappingICCProfile() {
		return [
			// File with sRGB should end up with TinyRGB
			[
				'srgb.jpg',
				'tinyrgb.jpg',
				'tinyrgb.icc',
				'sRGB IEC61966-2.1'
			],
			// File with TinyRGB should be left unchanged
			[
				'tinyrgb.jpg',
				'tinyrgb.jpg',
				'tinyrgb.icc',
				'sRGB IEC61966-2.1'
			],
			// File without profile should end up with TinyRGB
			[
				'missingprofile.jpg',
				'tinyrgb.jpg',
				'tinyrgb.icc',
				'sRGB IEC61966-2.1'
			],
			// Non-sRGB file should be left untouched
			[
				'adobergb.jpg',
				'adobergb.jpg',
				'tinyrgb.icc',
				'sRGB IEC61966-2.1'
			]
		];
	}
}
PK       ! ZF~  ~    media/BitmapScalingTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @group Media
 */
class BitmapScalingTest extends MediaWikiIntegrationTestCase {

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

		$this->overrideConfigValues( [
			MainConfigNames::MaxImageArea => 1.25e7, // 3500x3500
			MainConfigNames::CustomConvertCommand => 'dummy', // Set so that we don't get client side rendering
		] );
	}

	/**
	 * @dataProvider provideNormaliseParams
	 * @covers \BitmapHandler::normaliseParams
	 */
	public function testNormaliseParams( $fileDimensions, $expectedParams, $params, $msg ) {
		$file = new FakeDimensionFile( $fileDimensions );
		$handler = new BitmapHandler;
		$valid = $handler->normaliseParams( $file, $params );
		$this->assertTrue( $valid );
		$this->assertEquals( $expectedParams, $params, $msg );
	}

	public static function provideNormaliseParams() {
		return [
			/* Regular resize operations */
			[
				[ 1024, 768 ],
				[
					'width' => 512, 'height' => 384,
					'physicalWidth' => 512, 'physicalHeight' => 384,
					'page' => 1, 'interlace' => false,
				],
				[ 'width' => 512 ],
				'Resizing with width set',
			],
			[
				[ 1024, 768 ],
				[
					'width' => 512, 'height' => 384,
					'physicalWidth' => 512, 'physicalHeight' => 384,
					'page' => 1, 'interlace' => false,
				],
				[ 'width' => 512, 'height' => 768 ],
				'Resizing with height set too high',
			],
			[
				[ 1024, 768 ],
				[
					'width' => 512, 'height' => 384,
					'physicalWidth' => 512, 'physicalHeight' => 384,
					'page' => 1, 'interlace' => false,
				],
				[ 'width' => 1024, 'height' => 384 ],
				'Resizing with height set',
			],

			/* Very tall images */
			[
				[ 1000, 100 ],
				[
					'width' => 5, 'height' => 1,
					'physicalWidth' => 5, 'physicalHeight' => 1,
					'page' => 1, 'interlace' => false,
				],
				[ 'width' => 5 ],
				'Very wide image',
			],

			[
				[ 100, 1000 ],
				[
					'width' => 1, 'height' => 10,
					'physicalWidth' => 1, 'physicalHeight' => 10,
					'page' => 1, 'interlace' => false,
				],
				[ 'width' => 1 ],
				'Very high image',
			],
			[
				[ 100, 1000 ],
				[
					'width' => 1, 'height' => 5,
					'physicalWidth' => 1, 'physicalHeight' => 10,
					'page' => 1, 'interlace' => false,
				],
				[ 'width' => 10, 'height' => 5 ],
				'Very high image with height set',
			],
			/* Max image area */
			[
				[ 4000, 4000 ],
				[
					'width' => 5000, 'height' => 5000,
					'physicalWidth' => 4000, 'physicalHeight' => 4000,
					'page' => 1, 'interlace' => false,
				],
				[ 'width' => 5000 ],
				'Bigger than max image size but doesn\'t need scaling',
			],
			/* Max interlace image area */
			[
				[ 4000, 4000 ],
				[
					'width' => 5000, 'height' => 5000,
					'physicalWidth' => 4000, 'physicalHeight' => 4000,
					'page' => 1, 'interlace' => false,
				],
				[ 'width' => 5000, 'interlace' => true ],
				'Interlace bigger than max interlace area',
			],
		];
	}

	/**
	 * @covers \BitmapHandler::doTransform
	 */
	public function testTooBigImage() {
		$file = new FakeDimensionFile( [ 4000, 4000 ] );
		$handler = new BitmapHandler;
		$params = [ 'width' => '3700' ]; // Still bigger than max size.
		$this->assertEquals( TransformTooBigImageAreaError::class,
			get_class( $handler->doTransform( $file, 'dummy path', '', $params ) ) );
	}

	/**
	 * @covers \BitmapHandler::doTransform
	 */
	public function testTooBigMustRenderImage() {
		$file = new FakeDimensionFile( [ 4000, 4000 ] );
		$file->mustRender = true;
		$handler = new BitmapHandler;
		$params = [ 'width' => '5000' ]; // Still bigger than max size.
		$this->assertEquals( TransformTooBigImageAreaError::class,
			get_class( $handler->doTransform( $file, 'dummy path', '', $params ) ) );
	}

	/**
	 * @covers \BitmapHandler::getImageArea
	 */
	public function testImageArea() {
		$file = new FakeDimensionFile( [ 7, 9 ] );
		$handler = new BitmapHandler;
		$this->assertEquals( 63, $handler->getImageArea( $file ) );
	}
}
PK       ! 8!      media/PNGHandlerTest.phpnu Iw        <?php

/**
 * @group Media
 */
class PNGHandlerTest extends MediaWikiMediaTestCase {

	/** @var PNGHandler */
	protected $handler;

	protected function setUp(): void {
		parent::setUp();
		$this->handler = new PNGHandler();
	}

	/**
	 * @return array Expected metadata for a broken file. This tests backwards
	 * compatibility with existing DB rows, so can't be changed.
	 */
	private function brokenFile() {
		return [ '_error' => '0' ];
	}

	/**
	 * @covers \PNGHandler::getSizeAndMetadata
	 */
	public function testInvalidFile() {
		$res = $this->handler->getSizeAndMetadata( null, $this->filePath . '/README' );
		$this->assertEquals(
			[
				'metadata' => $this->brokenFile()
			],
			$res );
	}

	/**
	 * @param string $filename Basename of the file to check
	 * @param bool $expected Expected result.
	 * @dataProvider provideIsAnimated
	 * @covers \PNGHandler::isAnimatedImage
	 */
	public function testIsAnimanted( $filename, $expected ) {
		$file = $this->dataFile( $filename, 'image/png' );
		$actual = $this->handler->isAnimatedImage( $file );
		$this->assertEquals( $expected, $actual );
	}

	public static function provideIsAnimated() {
		return [
			[ 'Animated_PNG_example_bouncing_beach_ball.png', true ],
			[ '1bit-png.png', false ],
		];
	}

	/**
	 * @param string $filename
	 * @param int $expected Total image area
	 * @dataProvider provideGetImageArea
	 * @covers \PNGHandler::getImageArea
	 */
	public function testGetImageArea( $filename, $expected ) {
		$file = $this->dataFile( $filename, 'image/png' );
		$actual = $this->handler->getImageArea( $file );
		$this->assertEquals( $expected, $actual );
	}

	public static function provideGetImageArea() {
		return [
			[ '1bit-png.png', 2500 ],
			[ 'greyscale-png.png', 2500 ],
			[ 'Png-native-test.png', 126000 ],
			[ 'Animated_PNG_example_bouncing_beach_ball.png', 10000 ],
		];
	}

	/**
	 * @param string $metadata Serialized metadata
	 * @param int $expected One of the class constants of PNGHandler
	 * @dataProvider provideIsMetadataValid
	 * @covers \PNGHandler::isFileMetadataValid
	 */
	public function testIsFileMetadataValid( $metadata, $expected ) {
		$actual = $this->handler->isFileMetadataValid( $this->getMockFileWithMetadata( $metadata ) );
		$this->assertEquals( $expected, $actual );
	}

	public static function provideIsMetadataValid() {
		return [
			[ '0', PNGHandler::METADATA_GOOD ],
			[ '', PNGHandler::METADATA_BAD ],
			[ 'a:0:{}', PNGHandler::METADATA_BAD ],
			[ 'Something invalid!', PNGHandler::METADATA_BAD ],
			[
				'a:6:{s:10:"frameCount";i:0;s:9:"loopCount";i:1;s:8:"duration";d:0;s:8:"bitDepth";i:8;s:9:"colorType";s:10:"truecolour";s:8:"metadata";a:1:{s:15:"_MW_PNG_VERSION";i:1;}}',
				PNGHandler::METADATA_GOOD
			],
		];
		// phpcs:enable
	}

	/**
	 * @param string $filename
	 * @param string $expected Serialized array
	 * @dataProvider provideGetSizeAndMetadata
	 * @covers \PNGHandler::getSizeAndMetadata
	 */
	public function testGetSizeAndMetadata( $filename, $expected ) {
		$file = $this->dataFile( $filename, 'image/png' );
		$actual = $this->handler->getSizeAndMetadata( $file, "$this->filePath/$filename" );
		$this->assertEquals( $expected, $actual );
	}

	public static function provideGetSizeAndMetadata() {
		return [
			[
				'rgb-na-png.png',
				[
					'width' => 50,
					'height' => 50,
					'bits' => 8,
					'metadata' => [
						'frameCount' => 0,
						'loopCount' => 1,
						'duration' => 0.0,
						'bitDepth' => 8,
						'colorType' => 'truecolour',
						'metadata' => [
							'_MW_PNG_VERSION' => 1,
						],
					],
				],
			],
			[
				'xmp.png',
				[
					'width' => 50,
					'height' => 50,
					'bits' => 1,
					'metadata' => [
						'frameCount' => 0,
						'loopCount' => 1,
						'duration' => 0.0,
						'bitDepth' => 1,
						'colorType' => 'index-coloured',
						'metadata' => [
							'SerialNumber' => '123456789',
							'_MW_PNG_VERSION' => 1,
						],
					]
				]
			],
		];
	}

	/**
	 * @param string $filename
	 * @param array $expected Expected standard metadata
	 * @dataProvider provideGetIndependentMetaArray
	 * @covers \PNGHandler::getCommonMetaArray
	 */
	public function testGetIndependentMetaArray( $filename, $expected ) {
		$file = $this->dataFile( $filename, 'image/png' );
		$actual = $this->handler->getCommonMetaArray( $file );
		$this->assertEquals( $expected, $actual );
	}

	public static function provideGetIndependentMetaArray() {
		return [
			[ 'rgb-na-png.png', [] ],
			[ 'xmp.png',
				[
					'SerialNumber' => '123456789',
				]
			],
		];
	}

	/**
	 * @param string $filename
	 * @param float $expectedLength
	 * @dataProvider provideGetLength
	 * @covers \PNGHandler::getLength
	 */
	public function testGetLength( $filename, $expectedLength ) {
		$file = $this->dataFile( $filename, 'image/png' );
		$actualLength = $file->getLength();
		$this->assertEqualsWithDelta( $expectedLength, $actualLength, 0.00001 );
	}

	public static function provideGetLength() {
		return [
			[ 'Animated_PNG_example_bouncing_beach_ball.png', 1.5 ],
			[ 'Png-native-test.png', 0.0 ],
			[ 'greyscale-png.png', 0.0 ],
			[ '1bit-png.png', 0.0 ],
		];
	}
}
PK       ! rB`*  `*    media/WebPHandlerTest.phpnu Iw        <?php

/**
 * @covers \WebPHandler
 */
class WebPHandlerTest extends MediaWikiIntegrationTestCase {

	/**
	 * @dataProvider provideTestExtractMetaData
	 */
	public function testExtractMetaData( $header, $expectedResult ) {
		$tempFileName = $this->getNewTempFile();

		// Put header into file
		file_put_contents( $tempFileName, $header );

		$this->assertEquals( $expectedResult, WebPHandler::extractMetadata( $tempFileName ) );
	}

	public static function provideTestExtractMetaData() {
		return [
			// Files from https://developers.google.com/speed/webp/gallery2
			[ "\x52\x49\x46\x46\x90\x68\x01\x00\x57\x45\x42\x50\x56\x50\x38\x4C\x83\x68\x01\x00\x2F\x8F\x01\x4B\x10\x8D\x38\x6C\xDB\x46\x92\xE0\xE0\x82\x7B\x6C",
				[ 'compression' => 'lossless', 'width' => 400, 'height' => 301 ] ],
			[ "\x52\x49\x46\x46\x64\x5B\x00\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\x8F\x01\x00\x2C\x01\x00\x41\x4C\x50\x48\xE5\x0E",
				[ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 400, 'height' => 301 ] ],
			[ "\x52\x49\x46\x46\xA8\x72\x00\x00\x57\x45\x42\x50\x56\x50\x38\x4C\x9B\x72\x00\x00\x2F\x81\x81\x62\x10\x8D\x40\x8C\x24\x39\x6E\x73\x73\x38\x01\x96",
				[ 'compression' => 'lossless', 'width' => 386, 'height' => 395 ] ],
			[ "\x52\x49\x46\x46\xE0\x42\x00\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\x81\x01\x00\x8A\x01\x00\x41\x4C\x50\x48\x56\x10",
				[ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 386, 'height' => 395 ] ],
			[ "\x52\x49\x46\x46\x70\x61\x02\x00\x57\x45\x42\x50\x56\x50\x38\x4C\x63\x61\x02\x00\x2F\x1F\xC3\x95\x10\x8D\xC8\x72\xDB\xC8\x92\x24\xD8\x91\xD9\x91",
				[ 'compression' => 'lossless', 'width' => 800, 'height' => 600 ] ],
			[ "\x52\x49\x46\x46\x1C\x1D\x01\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\x1F\x03\x00\x57\x02\x00\x41\x4C\x50\x48\x25\x8B",
				[ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 800, 'height' => 600 ] ],
			[ "\x52\x49\x46\x46\xFA\xC5\x00\x00\x57\x45\x42\x50\x56\x50\x38\x4C\xEE\xC5\x00\x00\x2F\xA4\x81\x28\x10\x8D\x40\x68\x24\xC9\x91\xA4\xAE\xF3\x97\x75",
				[ 'compression' => 'lossless', 'width' => 421, 'height' => 163 ] ],
			[ "\x52\x49\x46\x46\xF6\x5D\x00\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\xA4\x01\x00\xA2\x00\x00\x41\x4C\x50\x48\x38\x1A",
				[ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 421, 'height' => 163 ] ],
			[ "\x52\x49\x46\x46\xC4\x96\x01\x00\x57\x45\x42\x50\x56\x50\x38\x4C\xB8\x96\x01\x00\x2F\x2B\xC1\x4A\x10\x11\x87\x6D\xDB\x48\x12\xFC\x60\xB0\x83\x24",
				[ 'compression' => 'lossless', 'width' => 300, 'height' => 300 ] ],
			[ "\x52\x49\x46\x46\x0A\x11\x01\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\x2B\x01\x00\x2B\x01\x00\x41\x4C\x50\x48\x67\x6E",
				[ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 300, 'height' => 300 ] ],

			// Lossy files from https://developers.google.com/speed/webp/gallery1
			[ "\x52\x49\x46\x46\x68\x76\x00\x00\x57\x45\x42\x50\x56\x50\x38\x20\x5C\x76\x00\x00\xD2\xBE\x01\x9D\x01\x2A\x26\x02\x70\x01\x3E\xD5\x4E\x97\x43\xA2",
				[ 'compression' => 'lossy', 'width' => 550, 'height' => 368 ] ],
			[ "\x52\x49\x46\x46\xB0\xEC\x00\x00\x57\x45\x42\x50\x56\x50\x38\x20\xA4\xEC\x00\x00\xB2\x4B\x02\x9D\x01\x2A\x26\x02\x94\x01\x3E\xD1\x50\x96\x46\x26",
				[ 'compression' => 'lossy', 'width' => 550, 'height' => 404 ] ],
			[ "\x52\x49\x46\x46\x7A\x19\x03\x00\x57\x45\x42\x50\x56\x50\x38\x20\x6E\x19\x03\x00\xB2\xF8\x09\x9D\x01\x2A\x00\x05\xD0\x02\x3E\xAD\x46\x99\x4A\xA5",
				[ 'compression' => 'lossy', 'width' => 1280, 'height' => 720 ] ],
			[ "\x52\x49\x46\x46\x44\xB3\x02\x00\x57\x45\x42\x50\x56\x50\x38\x20\x38\xB3\x02\x00\x52\x57\x06\x9D\x01\x2A\x00\x04\x04\x03\x3E\xA5\x44\x96\x49\x26",
				[ 'compression' => 'lossy', 'width' => 1024, 'height' => 772 ] ],
			[ "\x52\x49\x46\x46\x02\x43\x01\x00\x57\x45\x42\x50\x56\x50\x38\x20\xF6\x42\x01\x00\x12\xC0\x05\x9D\x01\x2A\x00\x04\xF0\x02\x3E\x79\x34\x93\x47\xA4",
				[ 'compression' => 'lossy', 'width' => 1024, 'height' => 752 ] ],

			// Animated file from https://groups.google.com/a/chromium.org/d/topic/blink-dev/Y8tRC4mdQz8/discussion
			[ "\x52\x49\x46\x46\xD0\x0B\x02\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x12\x00\x00\x00\x3F\x01\x00\x3F\x01\x00\x41\x4E",
				[ 'compression' => 'unknown', 'animated' => true, 'transparency' => true, 'width' => 320, 'height' => 320 ] ],

			// Error cases
			[ '', false ],
			[ '                                    ', false ],
			[ 'RIFF                                ', false ],
			[ 'RIFF1234WEBP                        ', false ],
			[ 'RIFF1234WEBPVP8                     ', false ],
			[ 'RIFF1234WEBPVP8L                    ', false ],
		];
		// phpcs:enable
	}

	/**
	 * @dataProvider provideTestWithFileExtractMetaData
	 */
	public function testWithFileExtractMetaData( $filename, $expectedResult ) {
		$this->assertEquals( $expectedResult, WebPHandler::extractMetadata( $filename ) );
	}

	public static function provideTestWithFileExtractMetaData() {
		return [
			[ __DIR__ . '/../../data/media/2_webp_ll.webp',
				[
					'compression' => 'lossless',
					'width' => 386,
					'height' => 395
				]
			],
			[ __DIR__ . '/../../data/media/2_webp_a.webp',
				[
					'compression' => 'lossy',
					'animated' => false,
					'transparency' => true,
					'width' => 386,
					'height' => 395
				]
			],
		];
	}

	/**
	 * @dataProvider provideTestGetSizeAndMetadata
	 */
	public function testGetSizeAndMetadata( $path, $expectedResult ) {
		$handler = new WebPHandler();
		$this->assertEquals( $expectedResult, $handler->getSizeAndMetadata( null, $path ) );
	}

	public static function provideTestGetSizeAndMetadata() {
		return [
			// Public domain files from https://developers.google.com/speed/webp/gallery2
			[
				__DIR__ . '/../../data/media/2_webp_a.webp',
				[
					'width' => 386,
					'height' => 395,
					'metadata' => [
						'compression' => 'lossy',
						'animated' => false,
						'transparency' => true,
						'width' => 386,
						'height' => 395,
						'metadata' => [
							'_MW_WEBP_VERSION' => 2,
						],
					],
				]
			],
			[
				__DIR__ . '/../../data/media/2_webp_ll.webp',
				[
					'width' => 386,
					'height' => 395,
					'metadata' => [
						'compression' => 'lossless',
						'width' => 386,
						'height' => 395,
						'metadata' => [
							'_MW_WEBP_VERSION' => 2,
						],
					],
				]
			],
			[
				__DIR__ . '/../../data/media/webp_animated.webp',
				[
					'width' => 300,
					'height' => 225,
					'metadata' => [
						'compression' => 'unknown',
						'animated' => true,
						'transparency' => true,
						'width' => 300,
						'height' => 225,
						'metadata' => [
							'_MW_WEBP_VERSION' => 2,
						],
					],
				]
			],
			[
				__DIR__ . '/../../data/media/exif.webp',
				[
					'width' => 40,
					'height' => 10,
					'metadata' => [
						'compression' => 'lossy',
						'animated' => false,
						'transparency' => false,
						'width' => 40,
						'height' => 10,
						'metadata' => [
							'_MW_WEBP_VERSION' => 2,
						],
						'media-metadata' => [
							'GPSLatitude' => 88.51805555555555,
							'GPSLongitude' => -21.12357,
							'GPSAltitude' => -3.1415926530119025,
							'GPSDOP' => '5/1',
							'GPSVersionID' => '2.2.0.0'
						]
					]
				]
			],
			// Using non-standard "Exif\0\0" prefix
			[
				__DIR__ . '/../../data/media/exif-prefix.webp',
				[
					'width' => 40,
					'height' => 10,
					'metadata' => [
						'compression' => 'lossy',
						'animated' => false,
						'transparency' => false,
						'width' => 40,
						'height' => 10,
						'metadata' => [
							'_MW_WEBP_VERSION' => 2,
						],
						'media-metadata' => [
							'GPSLatitude' => 88.51805555555555,
							'GPSLongitude' => -21.12357,
							'GPSAltitude' => -3.1415926530119025,
							'GPSDOP' => '5/1',
							'GPSVersionID' => '2.2.0.0'
						]
					]
				]
			],
			// Using standard "xmp " fourcc
			[
				__DIR__ . '/../../data/media/xmp.webp',
				[
					'width' => 420,
					'height' => 300,
					'metadata' => [
						'compression' => 'lossy',
						'animated' => false,
						'transparency' => false,
						'width' => 420,
						'height' => 300,
						'metadata' => [
							'_MW_WEBP_VERSION' => 2,
						],
						'media-metadata' => [
							'ImageDescription' => [
								'x-default' => 'An example image',
								'en' => 'right translation',
								'_type' => 'lang'
							]
						]
					]
				]
			],
			// Using the "xmp\0" fourcc (not standard "xmp ").
			[
				__DIR__ . '/../../data/media/xmp-null.webp',
				[
					'width' => 420,
					'height' => 300,
					'metadata' => [
						'compression' => 'lossy',
						'animated' => false,
						'transparency' => false,
						'width' => 420,
						'height' => 300,
						'metadata' => [
							'_MW_WEBP_VERSION' => 2,
						],
						'media-metadata' => [
							'ImageDescription' => [
								'x-default' => 'Image with XMPnull byte fourcc',
								'en' => 'right translation',
								'_type' => 'lang'
							]
						]
					]
				]
			],
			// Containing both XMP and Exif
			[
				__DIR__ . '/../../data/media/xmp-exif.webp',
				[
					'width' => 420,
					'height' => 300,
					'metadata' => [
						'compression' => 'lossy',
						'animated' => false,
						'transparency' => false,
						'width' => 420,
						'height' => 300,
						'metadata' => [
							'_MW_WEBP_VERSION' => 2,
						],
						'media-metadata' => [
							'ImageDescription' => [
								'x-default' => 'right(iptc)',
								'en' => 'right translation',
								'_type' => 'lang'
							],
							'XResolution' => '72/1',
							'YResolution' => '72/1',
							'ResolutionUnit' => 2,
							'YCbCrPositioning' => 1,
						]
					]
				]
			],

			// Error cases
			[
				__FILE__,
				[ 'metadata' => [ '_error' => '0' ] ],
			],
		];
	}

	/**
	 * Tests the WebP MIME detection. This should really be a separate test, but sticking it
	 * here for now.
	 *
	 * @dataProvider provideTestGetMimeType
	 */
	public function testGuessMimeType( $path ) {
		$mime = $this->getServiceContainer()->getMimeAnalyzer();
		$this->assertEquals( 'image/webp', $mime->guessMimeType( $path, false ) );
	}

	public static function provideTestGetMimeType() {
		return [
				// Public domain files from https://developers.google.com/speed/webp/gallery2
				[ __DIR__ . '/../../data/media/2_webp_a.webp' ],
				[ __DIR__ . '/../../data/media/2_webp_ll.webp' ],
				[ __DIR__ . '/../../data/media/webp_animated.webp' ],
		];
	}
}

/* Python code to extract a header and convert to PHP format:
 * print '"%s"' % ''.implode( '\\x%02X' % ord(c) for c in urllib.urlopen(url).read(36) )
 */
PK       ! 7      media/ExifTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @group Media
 * @covers \Exif
 * @requires extension exif
 */
class ExifTest extends MediaWikiIntegrationTestCase {
	private const FILE_PATH = __DIR__ . '/../../data/media/';

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

		$this->overrideConfigValue( MainConfigNames::ShowEXIF, true );
	}

	public function testGPSExtraction() {
		$filename = self::FILE_PATH . 'exif-gps.jpg';
		$seg = JpegMetadataExtractor::segmentSplitter( $filename );
		$exif = new Exif( $filename, $seg['byteOrder'] );
		$data = $exif->getFilteredData();
		$expected = [
			'GPSLatitude' => 88.5180555556,
			'GPSLongitude' => -21.12357,
			'GPSAltitude' => -3.141592653,
			'GPSDOP' => '5/1',
			'GPSVersionID' => '2.2.0.0',
		];
		$this->assertEqualsWithDelta( $expected, $data, 0.0000000001 );
	}

	public function testUnicodeUserComment() {
		$filename = self::FILE_PATH . 'exif-user-comment.jpg';
		$seg = JpegMetadataExtractor::segmentSplitter( $filename );
		$exif = new Exif( $filename, $seg['byteOrder'] );
		$data = $exif->getFilteredData();

		$expected = [
			'UserComment' => 'test⁔comment',
		];
		$this->assertEquals( $expected, $data );
	}
}
PK       ! B    #  media/JpegMetadataExtractorTest.phpnu Iw        <?php
/**
 * @todo Could use a test of extended XMP segments. Hard to find programs that
 * create example files, and creating my own in vim probably wouldn't
 * serve as a very good "test". (Adobe photoshop probably creates such files
 * but it costs money). The implementation of it currently in MediaWiki is based
 * solely on reading the standard, without any real world test files.
 *
 * @group Media
 * @covers \JpegMetadataExtractor
 */
class JpegMetadataExtractorTest extends MediaWikiIntegrationTestCase {
	private const FILE_PATH = __DIR__ . '/../../data/media/';

	/**
	 * We also use this test to test padding bytes don't
	 * screw stuff up
	 *
	 * @param string $file Filename
	 *
	 * @dataProvider provideUtf8Comment
	 */
	public function testUtf8Comment( $file ) {
		$res = JpegMetadataExtractor::segmentSplitter( self::FILE_PATH . $file );
		$this->assertEquals( [ 'UTF-8 JPEG Comment — ¼' ], $res['COM'] );
	}

	public static function provideUtf8Comment() {
		return [
			[ 'jpeg-comment-utf.jpg' ],
			[ 'jpeg-padding-even.jpg' ],
			[ 'jpeg-padding-odd.jpg' ],
		];
	}

	/** The file is iso-8859-1, but it should get auto converted */
	public function testIso88591Comment() {
		$res = JpegMetadataExtractor::segmentSplitter( self::FILE_PATH . 'jpeg-comment-iso8859-1.jpg' );
		$this->assertEquals( [ 'ISO-8859-1 JPEG Comment - ¼' ], $res['COM'] );
	}

	/** Comment values that are non-textual (random binary junk) should not be shown.
	 * The example test file has a comment with a 0x5 byte in it which is a control character
	 * and considered binary junk for our purposes.
	 */
	public function testBinaryCommentStripped() {
		$res = JpegMetadataExtractor::segmentSplitter( self::FILE_PATH . 'jpeg-comment-binary.jpg' );
		$this->assertSame( [], $res['COM'] );
	}

	/* Very rarely a file can have multiple comments.
	 *   Order of comments is based on order inside the file.
	 */
	public function testMultipleComment() {
		$res = JpegMetadataExtractor::segmentSplitter( self::FILE_PATH . 'jpeg-comment-multiple.jpg' );
		$this->assertEquals( [ 'foo', 'bar' ], $res['COM'] );
	}

	public function testXMPExtraction() {
		$res = JpegMetadataExtractor::segmentSplitter( self::FILE_PATH . 'jpeg-xmp-psir.jpg' );
		$expected = file_get_contents( self::FILE_PATH . 'jpeg-xmp-psir.xmp' );
		$this->assertEquals( $expected, $res['XMP'] );
	}

	public function testPSIRExtraction() {
		$res = JpegMetadataExtractor::segmentSplitter( self::FILE_PATH . 'jpeg-xmp-psir.jpg' );
		$expected = '50686f746f73686f7020332e30003842494d04040000000'
			. '000181c02190004746573741c02190003666f6f1c020000020004';
		$this->assertEquals( $expected, bin2hex( $res['PSIR'][0] ) );
	}

	public function testXMPExtractionNullChar() {
		$res = JpegMetadataExtractor::segmentSplitter( self::FILE_PATH . 'jpeg-xmp-nullchar.jpg' );
		$expected = file_get_contents( self::FILE_PATH . 'jpeg-xmp-psir.xmp' );
		$this->assertEquals( $expected, $res['XMP'] );
	}

	public function testXMPExtractionAltAppId() {
		$res = JpegMetadataExtractor::segmentSplitter( self::FILE_PATH . 'jpeg-xmp-alt.jpg' );
		$expected = file_get_contents( self::FILE_PATH . 'jpeg-xmp-psir.xmp' );
		$this->assertEquals( $expected, $res['XMP'] );
	}

	public function testIPTCHashComparisionNoHash() {
		$segments = JpegMetadataExtractor::segmentSplitter( self::FILE_PATH . 'jpeg-xmp-psir.jpg' );
		$res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] );

		$this->assertEquals( 'iptc-no-hash', $res );
	}

	public function testIPTCHashComparisionBadHash() {
		$segments = JpegMetadataExtractor::segmentSplitter( self::FILE_PATH . 'jpeg-iptc-bad-hash.jpg' );
		$res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] );

		$this->assertEquals( 'iptc-bad-hash', $res );
	}

	public function testIPTCHashComparisionGoodHash() {
		$segments = JpegMetadataExtractor::segmentSplitter( self::FILE_PATH . 'jpeg-iptc-good-hash.jpg' );
		$res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] );

		$this->assertEquals( 'iptc-good-hash', $res );
	}

	public function testExifByteOrder() {
		$res = JpegMetadataExtractor::segmentSplitter( self::FILE_PATH . 'exif-user-comment.jpg' );
		$expected = 'BE';
		$this->assertEquals( $expected, $res['byteOrder'] );
	}

	public function testInfiniteRead() {
		// test file truncated right after a segment, which previously
		// caused an infinite loop looking for the next segment byte.
		// Should get past infinite loop and throw in StringUtils::unpack()
		$this->expectException( InvalidJpegException::class );
		$res = JpegMetadataExtractor::segmentSplitter( self::FILE_PATH . 'jpeg-segment-loop1.jpg' );
	}

	public function testInfiniteRead2() {
		// test file truncated after a segment's marker and size, which
		// would cause a seek past end of file. Seek past end of file
		// doesn't actually fail, but prevents further reading and was
		// devolving into the previous case (testInfiniteRead).
		$this->expectException( InvalidJpegException::class );
		$res = JpegMetadataExtractor::segmentSplitter( self::FILE_PATH . 'jpeg-segment-loop2.jpg' );
	}
}
PK       !       media/FormatMetadataTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Media
 * @requires extension exif
 */
class FormatMetadataTest extends MediaWikiMediaTestCase {

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

		$this->overrideConfigValues( [
			MainConfigNames::LanguageCode => 'en',
			MainConfigNames::ShowEXIF => true,
		] );
	}

	/**
	 * @covers \File::formatMetadata
	 */
	public function testInvalidDate() {
		$file = $this->dataFile( 'broken_exif_date.jpg', 'image/jpeg' );

		// Throws an error if bug hit
		$meta = $file->formatMetadata();
		$this->assertIsArray( $meta, 'Valid metadata extracted' );

		// Find date exif entry
		$this->assertArrayHasKey( 'visible', $meta );
		$dateIndex = null;
		foreach ( $meta['visible'] as $i => $data ) {
			if ( $data['id'] == 'exif-datetimeoriginal' ) {
				$dateIndex = $i;
			}
		}
		$this->assertNotNull( $dateIndex, 'Date entry exists in metadata' );
		$this->assertSame( '0000:01:00 00:02:27',
			$meta['visible'][$dateIndex]['value'],
			'File with invalid date metadata (T31471)' );
	}

	/**
	 * @dataProvider provideResolveMultivalueValue
	 * @covers \FormatMetadata::resolveMultivalueValue
	 */
	public function testResolveMultivalueValue( $input, $output ) {
		$formatMetadata = new FormatMetadata();
		$class = new ReflectionClass( FormatMetadata::class );
		$method = $class->getMethod( 'resolveMultivalueValue' );
		$method->setAccessible( true );
		$actualInput = $method->invoke( $formatMetadata, $input );
		$this->assertEquals( $output, $actualInput );
	}

	public static function provideResolveMultivalueValue() {
		return [
			'nonArray' => [
				'foo',
				'foo'
			],
			'multiValue' => [
				[ 'first', 'second', 'third', '_type' => 'ol' ],
				'first'
			],
			'noType' => [
				[ 'first', 'second', 'third' ],
				'first'
			],
			'typeFirst' => [
				[ '_type' => 'ol', 'first', 'second', 'third' ],
				'first'
			],
			'multilang' => [
				[
					'en' => 'first',
					'de' => 'Erste',
					'_type' => 'lang'
				],
				[
					'en' => 'first',
					'de' => 'Erste',
					'_type' => 'lang'
				],
			],
			'multilang-multivalue' => [
				[
					'en' => [ 'first', 'second' ],
					'de' => [ 'Erste', 'Zweite' ],
					'_type' => 'lang'
				],
				[
					'en' => 'first',
					'de' => 'Erste',
					'_type' => 'lang'
				],
			],
		];
	}

	/**
	 * @dataProvider provideGetFormattedData
	 * @covers \FormatMetadata::getFormattedData
	 */
	public function testGetFormattedData( $input, $output ) {
		$this->assertEquals( $output, FormatMetadata::getFormattedData( $input ) );
	}

	public static function provideGetFormattedData() {
		return [
			[
				[ 'Software' => 'Adobe Photoshop CS6 (Macintosh)' ],
				[ 'Software' => 'Adobe Photoshop CS6 (Macintosh)' ],
			],
			[
				[ 'Software' => [ 'FotoWare FotoStation' ] ],
				[ 'Software' => 'FotoWare FotoStation' ],
			],
			[
				[ 'Software' => [ [ 'Capture One PRO', '3.7.7' ] ] ],
				[ 'Software' => 'Capture One PRO (Version 3.7.7)' ],
			],
			[
				[ 'Software' => [ [ 'FotoWare ColorFactory', '' ] ] ],
				[ 'Software' => 'FotoWare ColorFactory (Version )' ],
			],
			[
				[ 'Software' => [ 'x-default' => 'paint.net 4.0.12', '_type' => 'lang' ] ],
				[ 'Software' => '<ul class="metadata-langlist">' .
						'<li class="mw-metadata-lang-default">' .
							'<span class="mw-metadata-lang-value">paint.net 4.0.12</span>' .
						"</li>\n" .
					'</ul>'
				],
			],
			[
				// https://phabricator.wikimedia.org/T178130
				// WebMHandler.php turns both 'muxingapp' & 'writingapp' to 'Software'
				[ 'Software' => [ [ 'Lavf57.25.100' ], [ 'Lavf57.25.100' ] ] ],
				[ 'Software' => "<ul><li>Lavf57.25.100</li>\n<li>Lavf57.25.100</li></ul>" ],
			],
		];
	}

	/**
	 * @covers \FormatMetadata::getPriorityLanguages
	 * @dataProvider provideGetPriorityLanguagesData
	 * @param string $language
	 * @param string[] $expected
	 */
	public function testGetPriorityLanguagesInternal_language_expect(
		string $language,
		array $expected
	): void {
		$formatMetadata = TestingAccessWrapper::newFromObject( new FormatMetadata() );
		$context = $formatMetadata->getContext();
		$context->setLanguage( $this->getServiceContainer()->getLanguageFactory()->getLanguage( $language ) );

		$x = $formatMetadata->getPriorityLanguages();
		$this->assertSame( $expected, $x );
	}

	public static function provideGetPriorityLanguagesData() {
		return [
			'LanguageMl' => [
				'ml',
				[ 'ml', 'en' ],
			],
			'LanguageEn' => [
				'en',
				[ 'en', 'en' ],
			],
			'LanguageQqx' => [
				'qqx',
				[ 'qqx', 'en' ],
			],
		];
	}
}
PK       ! [\      media/GIFHandlerTest.phpnu Iw        <?php

/**
 * @group Media
 */
class GIFHandlerTest extends MediaWikiMediaTestCase {

	/** @var GIFHandler */
	protected $handler;

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

		$this->handler = new GIFHandler();
	}

	/**
	 * @return array Unserialized metadata array for GIFHandler::BROKEN_FILE
	 */
	private function brokenFile(): array {
		return [ '_error' => 0 ];
	}

	/**
	 * @covers \GIFHandler::getSizeAndMetadata
	 */
	public function testInvalidFile() {
		$res = $this->handler->getSizeAndMetadata( null, $this->filePath . '/README' );
		$this->assertEquals( $this->brokenFile(), $res['metadata'] );
	}

	/**
	 * @param string $filename Basename of the file to check
	 * @param bool $expected Expected result.
	 * @dataProvider provideIsAnimated
	 * @covers \GIFHandler::isAnimatedImage
	 */
	public function testIsAnimated( $filename, $expected ) {
		$file = $this->dataFile( $filename, 'image/gif' );
		$actual = $this->handler->isAnimatedImage( $file );
		$this->assertEquals( $expected, $actual );
	}

	public static function provideIsAnimated() {
		return [
			[ 'animated.gif', true ],
			[ 'nonanimated.gif', false ],
		];
	}

	/**
	 * @param string $filename
	 * @param int $expected Total image area
	 * @dataProvider provideGetImageArea
	 * @covers \GIFHandler::getImageArea
	 */
	public function testGetImageArea( $filename, $expected ) {
		$file = $this->dataFile( $filename, 'image/gif' );
		$actual = $this->handler->getImageArea( $file, $file->getWidth(), $file->getHeight() );
		$this->assertEquals( $expected, $actual );
	}

	public static function provideGetImageArea() {
		return [
			[ 'animated.gif', 5400 ],
			[ 'nonanimated.gif', 1350 ],
		];
	}

	/**
	 * @param string $metadata Serialized metadata
	 * @param int $expected One of the class constants of GIFHandler
	 * @dataProvider provideIsFileMetadataValid
	 * @covers \GIFHandler::isFileMetadataValid
	 */
	public function testIsFileMetadataValid( $metadata, $expected ) {
		$file = $this->getMockFileWithMetadata( $metadata );
		$actual = $this->handler->isFileMetadataValid( $file );
		$this->assertEquals( $expected, $actual );
	}

	public static function provideIsFileMetadataValid() {
		return [
			[ '0', GIFHandler::METADATA_GOOD ],
			[ '', GIFHandler::METADATA_BAD ],
			[ 'a:0:{}', GIFHandler::METADATA_BAD ],
			[ 'Something invalid!', GIFHandler::METADATA_BAD ],
			[
				'a:4:{s:10:"frameCount";i:1;s:6:"looped";b:0;s:8:"duration";d:0.1000000000000000055511151231257827021181583404541015625;s:8:"metadata";a:2:{s:14:"GIFFileComment";a:1:{i:0;s:35:"GIF test file ⁕ Created with GIMP";}s:15:"_MW_GIF_VERSION";i:1;}}',
				GIFHandler::METADATA_GOOD
			],
		];
		// phpcs:enable
	}

	/**
	 * @param string $filename
	 * @param array $expected Unserialized metadata
	 * @dataProvider provideGetSizeAndMetadata
	 * @covers \GIFHandler::getSizeAndMetadata
	 */
	public function testGetSizeAndMetadata( $filename, $expected ) {
		$file = $this->dataFile( $filename, 'image/gif' );
		$actual = $this->handler->getSizeAndMetadata( $file, "$this->filePath/$filename" );
		$this->assertEquals( $expected, $actual );
	}

	public static function provideGetSizeAndMetadata() {
		return [
			[
				'nonanimated.gif',
				[
					'width' => 45,
					'height' => 30,
					'bits' => 1,
					'metadata' => [
						'frameCount' => 1,
						'looped' => false,
						'duration' => 0.1,
						'metadata' => [
							'GIFFileComment' => [ 'GIF test file ⁕ Created with GIMP' ],
							'_MW_GIF_VERSION' => 1,
						],
					],
				]
			],
			[
				'animated-xmp.gif',
				[
					'width' => 45,
					'height' => 30,
					'bits' => 1,
					'metadata' => [
						'frameCount' => 4,
						'looped' => true,
						'duration' => 2.4,
						'metadata' => [
							'Artist' => 'Bawolff',
							'ImageDescription' => [
								'x-default' => 'A file to test GIF',
								'_type' => 'lang',
							],
							'SublocationDest' => 'The interwebs',
							'GIFFileComment' => [
								0 => 'GIƒ·test·file',
							],
							'_MW_GIF_VERSION' => 1,
						],
					],
				],

			],
		];
		// phpcs:enable
	}

	/**
	 * @param string $filename
	 * @param string $expected Serialized array
	 * @dataProvider provideGetIndependentMetaArray
	 * @covers \GIFHandler::getCommonMetaArray
	 */
	public function testGetIndependentMetaArray( $filename, $expected ) {
		$file = $this->dataFile( $filename, 'image/gif' );
		$actual = $this->handler->getCommonMetaArray( $file );
		$this->assertEquals( $expected, $actual );
	}

	public static function provideGetIndependentMetaArray() {
		return [
			[ 'nonanimated.gif', [
				'GIFFileComment' => [
					'GIF test file ⁕ Created with GIMP',
				],
			] ],
			[ 'animated-xmp.gif',
				[
					'Artist' => 'Bawolff',
					'ImageDescription' => [
						'x-default' => 'A file to test GIF',
						'_type' => 'lang',
					],
					'SublocationDest' => 'The interwebs',
					'GIFFileComment' =>
					[
						'GIƒ·test·file',
					],
				]
			],
		];
	}

	/**
	 * @param string $filename
	 * @param float $expectedLength
	 * @dataProvider provideGetLength
	 * @covers \GIFHandler::getLength
	 */
	public function testGetLength( $filename, $expectedLength ) {
		$file = $this->dataFile( $filename, 'image/gif' );
		$actualLength = $file->getLength();
		$this->assertEqualsWithDelta( $expectedLength, $actualLength, 0.00001 );
	}

	public static function provideGetLength() {
		return [
			[ 'animated.gif', 2.4 ],
			[ 'animated-xmp.gif', 2.4 ],
			[ 'nonanimated', 0.0 ],
			[ 'Bishzilla_blink.gif', 1.4 ],
		];
	}
}
PK       ! i      media/ExifRotationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * Tests related to auto rotation.
 *
 * @group Media
 * @group medium
 *
 * @covers \BitmapHandler
 * @requires extension exif
 */
class ExifRotationTest extends MediaWikiMediaTestCase {

	/** @var BitmapHandler */
	private $handler;

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

		$this->handler = new BitmapHandler();

		$this->overrideConfigValues( [
			MainConfigNames::ShowEXIF => true,
			MainConfigNames::EnableAutoRotation => true,
		] );
	}

	/**
	 * Mark this test as creating thumbnail files.
	 * @inheritDoc
	 */
	protected function createsThumbnails() {
		return true;
	}

	/**
	 * @dataProvider provideFiles
	 */
	public function testMetadata( $name, $type, $info ) {
		if ( !$this->handler->canRotate() ) {
			$this->markTestSkipped( "This test needs a rasterizer that can auto-rotate." );
		}
		$file = $this->dataFile( $name, $type );
		$this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" );
		$this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" );
	}

	/**
	 * Same as before, but with auto-rotation set to auto.
	 *
	 * This sets scaler to image magick, which we should detect as
	 * supporting rotation.
	 * @dataProvider provideFiles
	 */
	public function testMetadataAutoRotate( $name, $type, $info ) {
		$this->overrideConfigValues( [
			MainConfigNames::EnableAutoRotation => null,
			MainConfigNames::UseImageMagick => true,
			MainConfigNames::UseImageResize => true,
		] );

		$file = $this->dataFile( $name, $type );
		$this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" );
		$this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" );
	}

	/**
	 * @dataProvider provideFiles
	 */
	public function testRotationRendering( $name, $type, $info, $thumbs ) {
		if ( !$this->handler->canRotate() ) {
			$this->markTestSkipped( "This test needs a rasterizer that can auto-rotate." );
		}
		foreach ( $thumbs as $size => $out ) {
			if ( preg_match( '/^(\d+)px$/', $size, $matches ) ) {
				$params = [
					'width' => $matches[1],
				];
			} elseif ( preg_match( '/^(\d+)x(\d+)px$/', $size, $matches ) ) {
				$params = [
					'width' => $matches[1],
					'height' => $matches[2]
				];
			} else {
				throw new InvalidArgumentException( 'bogus test data format ' . $size );
			}

			$file = $this->dataFile( $name, $type );
			$thumb = $file->transform( $params, File::RENDER_NOW | File::RENDER_FORCE );

			$this->assertEquals(
				$out[0],
				$thumb->getWidth(),
				"$name: thumb reported width check for $size"
			);
			$this->assertEquals(
				$out[1],
				$thumb->getHeight(),
				"$name: thumb reported height check for $size"
			);

			$gis = getimagesize( $thumb->getLocalCopyPath() );
			if ( $out[0] > $info['width'] ) {
				// Physical image won't be scaled bigger than the original.
				$this->assertEquals( $info['width'], $gis[0], "$name: thumb actual width check for $size" );
				$this->assertEquals( $info['height'], $gis[1], "$name: thumb actual height check for $size" );
			} else {
				$this->assertEquals( $out[0], $gis[0], "$name: thumb actual width check for $size" );
				$this->assertEquals( $out[1], $gis[1], "$name: thumb actual height check for $size" );
			}
		}
	}

	public static function provideFiles() {
		return [
			[
				'landscape-plain.jpg',
				'image/jpeg',
				[
					'width' => 160,
					'height' => 120,
				],
				[
					'80x60px' => [ 80, 60 ],
					'9999x80px' => [ 107, 80 ],
					'80px' => [ 80, 60 ],
					'60px' => [ 60, 45 ],
				]
			],
			[
				'portrait-rotated.jpg',
				'image/jpeg',
				[
					'width' => 120, // as rotated
					'height' => 160, // as rotated
				],
				[
					'80x60px' => [ 45, 60 ],
					'9999x80px' => [ 60, 80 ],
					'80px' => [ 80, 107 ],
					'60px' => [ 60, 80 ],
				]
			]
		];
	}

	/**
	 * Same as before, but with auto-rotation disabled.
	 * @dataProvider provideFilesNoAutoRotate
	 */
	public function testMetadataNoAutoRotate( $name, $type, $info ) {
		$this->overrideConfigValue( MainConfigNames::EnableAutoRotation, false );

		$file = $this->dataFile( $name, $type );
		$this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" );
		$this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" );
	}

	/**
	 * Same as before, but with auto-rotation set to auto and an image scaler that doesn't support it.
	 * @dataProvider provideFilesNoAutoRotate
	 */
	public function testMetadataAutoRotateUnsupported( $name, $type, $info ) {
		$this->overrideConfigValues( [
			MainConfigNames::EnableAutoRotation => null,
			MainConfigNames::UseImageResize => false,
		] );

		$file = $this->dataFile( $name, $type );
		$this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" );
		$this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" );
	}

	/**
	 * @dataProvider provideFilesNoAutoRotate
	 */
	public function testRotationRenderingNoAutoRotate( $name, $type, $info, $thumbs ) {
		$this->overrideConfigValue( MainConfigNames::EnableAutoRotation, false );

		foreach ( $thumbs as $size => $out ) {
			if ( preg_match( '/^(\d+)px$/', $size, $matches ) ) {
				$params = [
					'width' => $matches[1],
				];
			} elseif ( preg_match( '/^(\d+)x(\d+)px$/', $size, $matches ) ) {
				$params = [
					'width' => $matches[1],
					'height' => $matches[2]
				];
			} else {
				throw new InvalidArgumentException( 'bogus test data format ' . $size );
			}

			$file = $this->dataFile( $name, $type );
			$thumb = $file->transform( $params, File::RENDER_NOW | File::RENDER_FORCE );

			if ( $thumb->isError() ) {
				/** @var MediaTransformError $thumb */
				$this->fail( $thumb->toText() );
			}

			$this->assertEquals(
				$out[0],
				$thumb->getWidth(),
				"$name: thumb reported width check for $size"
			);
			$this->assertEquals(
				$out[1],
				$thumb->getHeight(),
				"$name: thumb reported height check for $size"
			);

			$gis = getimagesize( $thumb->getLocalCopyPath() );
			if ( $out[0] > $info['width'] ) {
				// Physical image won't be scaled bigger than the original.
				$this->assertEquals( $info['width'], $gis[0], "$name: thumb actual width check for $size" );
				$this->assertEquals( $info['height'], $gis[1], "$name: thumb actual height check for $size" );
			} else {
				$this->assertEquals( $out[0], $gis[0], "$name: thumb actual width check for $size" );
				$this->assertEquals( $out[1], $gis[1], "$name: thumb actual height check for $size" );
			}
		}
	}

	public static function provideFilesNoAutoRotate() {
		return [
			[
				'landscape-plain.jpg',
				'image/jpeg',
				[
					'width' => 160,
					'height' => 120,
				],
				[
					'80x60px' => [ 80, 60 ],
					'9999x80px' => [ 107, 80 ],
					'80px' => [ 80, 60 ],
					'60px' => [ 60, 45 ],
				]
			],
			[
				'portrait-rotated.jpg',
				'image/jpeg',
				[
					'width' => 160, // since not rotated
					'height' => 120, // since not rotated
				],
				[
					'80x60px' => [ 80, 60 ],
					'9999x80px' => [ 107, 80 ],
					'80px' => [ 80, 60 ],
					'60px' => [ 60, 45 ],
				]
			]
		];
	}

	private const TEST_WIDTH = 100;
	private const TEST_HEIGHT = 200;

	/**
	 * @dataProvider provideBitmapExtractPreRotationDimensions
	 */
	public function testBitmapExtractPreRotationDimensions( $rotation, $expected ) {
		$result = $this->handler->extractPreRotationDimensions( [
			'physicalWidth' => self::TEST_WIDTH,
			'physicalHeight' => self::TEST_HEIGHT,
		], $rotation );
		$this->assertEquals( $expected, $result );
	}

	public static function provideBitmapExtractPreRotationDimensions() {
		return [
			[
				0,
				[ self::TEST_WIDTH, self::TEST_HEIGHT ]
			],
			[
				90,
				[ self::TEST_HEIGHT, self::TEST_WIDTH ]
			],
			[
				180,
				[ self::TEST_WIDTH, self::TEST_HEIGHT ]
			],
			[
				270,
				[ self::TEST_HEIGHT, self::TEST_WIDTH ]
			],
		];
	}
}
PK       !       media/BmpHandlerTest.phpnu Iw        <?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
 * @since 1.42
 */

namespace MediaWiki\Tests\Media;

use BmpHandler;
use File;
use MediaHandlerState;
use MediaWikiMediaTestCase;

/**
 * @covers \BmpHandler
 * @group Media
 */
class BmpHandlerTest extends MediaWikiMediaTestCase {

	protected BmpHandler $handler;
	protected File $tempFile;

	protected function setUp(): void {
		parent::setUp();
		$this->handler = new BmpHandler();
		$this->tempFile = $this->createMock( File::class );
	}

	/**
	 * @covers \BmpHandler::mustRender
	 */
	public function testMustRender() {
		$this->assertTrue( $this->handler->mustRender( $this->tempFile ) );
	}

	/**
	 * @covers \BmpHandler::getThumbType
	 */
	public function testGetThumbType() {
		$this->assertEquals( [ 'png', 'image/png' ], $this->handler->getThumbType( 'bmp', 'image/bmp' ) );
	}

	/**
	 * @covers \BmpHandler::getSizeAndMetadata
	 * @dataProvider provideGetSizeAndMetadata
	 */
	public function testGetSizeAndMetadata( string $filename, array $expected ) {
		$stateMock = $this->createMock( MediaHandlerState::class );
		$res = $this->handler->getSizeAndMetadata( $stateMock, $filename );
		$this->assertEquals( $expected, $res );
	}

	public static function provideGetSizeAndMetadata(): array {
		return [
			[ __DIR__ . '/../../data/media/bmp-100x100.bmp', [ 'width' => 100, 'height' => 100 ] ],
			[ __DIR__ . '/../../data/media/bmp-200x150.bmp', [ 'width' => 200, 'height' => 150 ] ],
			[ __DIR__ . '/../../data/media/bmp-300x200.bmp', [ 'width' => 300, 'height' => 200 ] ],
		];
	}
}
PK       ! x)7  )7    media/SvgHandlerTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Media
 */
class SvgHandlerTest extends MediaWikiMediaTestCase {

	/**
	 * @covers \SvgHandler::getCommonMetaArray()
	 * @dataProvider provideGetIndependentMetaArray
	 *
	 * @param string $filename
	 * @param array $expected The expected independent metadata
	 */
	public function testGetIndependentMetaArray( $filename, $expected ) {
		$this->filePath = __DIR__ . '/../../data/media/';
		$this->overrideConfigValue( MainConfigNames::ShowEXIF, true );

		$file = $this->dataFile( $filename, 'image/svg+xml' );
		$handler = new SvgHandler();
		$res = $handler->getCommonMetaArray( $file );

		self::assertEquals( $expected, $res );
	}

	public static function provideGetIndependentMetaArray() {
		return [
			[ 'Tux.svg', [
				'ObjectName' => 'Tux',
				'ImageDescription' =>
					'For more information see: http://commons.wikimedia.org/wiki/Image:Tux.svg',
			] ],
			[ 'Wikimedia-logo.svg', [] ]
		];
	}

	/**
	 * @covers \SvgHandler::getMatchedLanguage()
	 * @dataProvider provideGetMatchedLanguage
	 *
	 * @param string $userPreferredLanguage
	 * @param array $svgLanguages
	 * @param string $expectedMatch
	 */
	public function testGetMatchedLanguage( $userPreferredLanguage, $svgLanguages, $expectedMatch ) {
		$handler = new SvgHandler();
		$match = $handler->getMatchedLanguage( $userPreferredLanguage, $svgLanguages );
		self::assertEquals( $expectedMatch, $match );
	}

	public static function provideGetMatchedLanguage() {
		return [
			'no match' => [
				'userPreferredLanguage' => 'en',
				'svgLanguages' => [ 'de-DE', 'zh', 'ga', 'fr', 'sr-Latn-ME' ],
				'expectedMatch' => null,
			],
			'no subtags' => [
				'userPreferredLanguage' => 'en',
				'svgLanguages' => [ 'de', 'zh', 'en', 'fr' ],
				'expectedMatch' => 'en',
			],
			'user no subtags, svg 1 subtag' => [
				'userPreferredLanguage' => 'en',
				'svgLanguages' => [ 'de-DE', 'en-GB', 'en-US', 'fr' ],
				'expectedMatch' => 'en-GB',
			],
			'user no subtags, svg >1 subtag' => [
				'userPreferredLanguage' => 'sr',
				'svgLanguages' => [ 'de-DE', 'sr-Cyrl-BA', 'sr-Latn-ME', 'en-US', 'fr' ],
				'expectedMatch' => 'sr-Cyrl-BA',
			],
			'user 1 subtag, svg no subtags' => [
				'userPreferredLanguage' => 'en-US',
				'svgLanguages' => [ 'de', 'en', 'en', 'fr' ],
				'expectedMatch' => null,
			],
			'user 1 subtag, svg 1 subtag' => [
				'userPreferredLanguage' => 'en-US',
				'svgLanguages' => [ 'de-DE', 'en-GB', 'en-US', 'fr' ],
				'expectedMatch' => 'en-US',
			],
			'user 1 subtag, svg >1 subtag' => [
				'userPreferredLanguage' => 'sr-Latn',
				'svgLanguages' => [ 'de-DE', 'sr-Cyrl-BA', 'sr-Latn-ME', 'fr' ],
				'expectedMatch' => 'sr-Latn-ME',
			],
			'user >1 subtag, svg >1 subtag' => [
				'userPreferredLanguage' => 'sr-Latn-ME',
				'svgLanguages' => [ 'de-DE', 'sr-Cyrl-BA', 'sr-Latn-ME', 'en-US', 'fr' ],
				'expectedMatch' => 'sr-Latn-ME',
			],
			'user >1 subtag, svg <=1 subtag' => [
				'userPreferredLanguage' => 'sr-Latn-ME',
				'svgLanguages' => [ 'de-DE', 'sr-Cyrl', 'sr-Latn', 'en-US', 'fr' ],
				'expectedMatch' => null,
			],
			'ensure case-insensitive' => [
				'userPreferredLanguage' => 'sr-latn',
				'svgLanguages' => [ 'de-DE', 'sr-Cyrl', 'sr-Latn-ME', 'en-US', 'fr' ],
				'expectedMatch' => 'sr-Latn-ME',
			],
			'deprecated MW code als' => [
				'userPreferredLanguage' => 'als',
				'svgLanguages' => [ 'en', 'als', 'gsw' ],
				'expectedMatch' => 'als',
			],
			'deprecated language code i-klingon' => [
				'userPreferredLanguage' => 'i-klingon',
				'svgLanguages' => [ 'i-klingon' ],
				'expectedMatch' => 'i-klingon',
			],
			'complex IETF language code' => [
				'userPreferredLanguage' => 'he',
				'svgLanguages' => [ 'he-IL-u-ca-hebrew-tz-jeruslm' ],
				'expectedMatch' => 'he-IL-u-ca-hebrew-tz-jeruslm',
			],
		];
	}

	/**
	 * @covers \SvgHandler::makeParamString()
	 * @dataProvider provideMakeParamString
	 *
	 * @param array $params
	 * @param string $expected
	 * @param string $message
	 */
	public function testMakeParamString( array $params, $expected, $message = '' ) {
		$handler = new SvgHandler();
		self::assertEquals( $expected, $handler->makeParamString( $params ), $message );
	}

	public static function provideMakeParamString() {
		return [
			[
				[],
				false,
				"Don't thumbnail without knowing width"
			],
			[
				[ 'lang' => 'ru' ],
				false,
				"Don't thumbnail without knowing width, even with lang"
			],
			[
				[ 'width' => 123, ],
				'123px',
				'Width in thumb'
			],
			[
				[ 'width' => 123, 'lang' => 'en' ],
				'123px',
				'Ignore lang=en'
			],
			[
				[ 'width' => 123, 'targetlang' => 'en' ],
				'123px',
				'Ignore targetlang=en'
			],
			[
				[ 'width' => 123, 'lang' => 'en', 'targetlang' => 'ru' ],
				'123px',
				"lang should override targetlang even if it's in English"
			],
			[
				[ 'width' => 123, 'targetlang' => 'en' ],
				'123px',
				'Ignore targetlang=en'
			],
			[
				[ 'width' => 123, 'lang' => 'en', 'targetlang' => 'en' ],
				'123px',
				'Ignore lang=targetlang=en'
			],
			[
				[ 'width' => 123, 'lang' => 'ru' ],
				'langru-123px',
				'Include lang in thumb'
			],
			[
				[ 'width' => 123, 'lang' => 'zh-Hans' ],
				'langzh-hans-123px',
				'Lowercase language codes',
			],
			[
				[ 'width' => 123, 'targetlang' => 'ru' ],
				'langru-123px',
				'Include targetlang in thumb'
			],
			[
				[ 'width' => 123, 'lang' => 'fr', 'targetlang' => 'sq' ],
				'langfr-123px',
				'lang should override targetlang'
			],
		];
	}

	/**
	 * @covers \SvgHandler::normaliseParamsInternal()
	 * @dataProvider provideNormaliseParamsInternal
	 */
	public function testNormaliseParamsInternal( $message,
		$width,
		$height,
		array $params,
		?array $paramsExpected = null
	) {
		$this->overrideConfigValue( MainConfigNames::SVGMaxSize, 1000 );

		/** @var SvgHandler $handler */
		$handler = TestingAccessWrapper::newFromObject( new SvgHandler() );

		$file = $this->getMockBuilder( File::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getWidth', 'getHeight', 'getMetadataArray', 'getHandler' ] )
			->getMock();

		$file->method( 'getWidth' )
			->willReturn( $width );
		$file->method( 'getHeight' )
			->willReturn( $height );
		$file->method( 'getMetadataArray' )
			->willReturn( [
				'version' => SvgHandler::SVG_METADATA_VERSION,
				'translations' => [
					'en' => SVGReader::LANG_FULL_MATCH,
					'ru' => SVGReader::LANG_FULL_MATCH,
				],
			] );
		$file->method( 'getHandler' )
			->willReturn( $handler );

		/** @var File $file */
		$params = $handler->normaliseParamsInternal( $file, $params );
		self::assertEquals( $paramsExpected, $params, $message );
	}

	public static function provideNormaliseParamsInternal() {
		return [
			[
				'No need to change anything',
				400, 500,
				[ 'physicalWidth' => 400, 'physicalHeight' => 500 ],
				[ 'physicalWidth' => 400, 'physicalHeight' => 500 ],
			],
			[
				'Resize horizontal image',
				2000, 1600,
				[ 'physicalWidth' => 2000, 'physicalHeight' => 1600, 'page' => 0 ],
				[ 'physicalWidth' => 1250, 'physicalHeight' => 1000, 'page' => 0 ],
			],
			[
				'Resize vertical image',
				1600, 2000,
				[ 'physicalWidth' => 1600, 'physicalHeight' => 2000, 'page' => 0 ],
				[ 'physicalWidth' => 1000, 'physicalHeight' => 1250, 'page' => 0 ],
			],
			[
				'Preserve targetlang present in the image',
				400, 500,
				[ 'physicalWidth' => 400, 'physicalHeight' => 500, 'targetlang' => 'en' ],
				[ 'physicalWidth' => 400, 'physicalHeight' => 500, 'targetlang' => 'en' ],
			],
			[
				'Preserve targetlang present in the image 2',
				400, 500,
				[ 'physicalWidth' => 400, 'physicalHeight' => 500, 'targetlang' => 'en' ],
				[ 'physicalWidth' => 400, 'physicalHeight' => 500, 'targetlang' => 'en' ],
			],
			[
				'Remove targetlang not present in the image',
				400, 500,
				[ 'physicalWidth' => 400, 'physicalHeight' => 500, 'targetlang' => 'de' ],
				[ 'physicalWidth' => 400, 'physicalHeight' => 500 ],
			],
			[
				'Remove targetlang not present in the image 2',
				400, 500,
				[ 'physicalWidth' => 400, 'physicalHeight' => 500, 'targetlang' => 'ru-UA' ],
				[ 'physicalWidth' => 400, 'physicalHeight' => 500 ],
			],
		];
	}

	/**
	 * @covers \SvgHandler::isEnabled()
	 * @dataProvider provideIsEnabled
	 *
	 * @param string $converter
	 * @param bool $expected
	 */
	public function testIsEnabled( $converter, $expected ) {
		$this->overrideConfigValues( [
			MainConfigNames::SVGConverter => $converter,
			MainConfigNames::SVGNativeRendering => false,
		] );

		$handler = new SvgHandler();
		self::assertEquals( $expected, $handler->isEnabled() );
	}

	public static function provideIsEnabled() {
		return [
			[ 'ImageMagick', true ],
			[ 'sodipodi', true ],
			[ 'invalid', false ],
		];
	}

	/**
	 * @covers \SvgHandler::getAvailableLanguages()
	 * @dataProvider provideAvailableLanguages
	 */
	public function testGetAvailableLanguages( array $metadata, array $expected ) {
		$metadata['version'] = SvgHandler::SVG_METADATA_VERSION;
		$file = $this->getMockBuilder( File::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getMetadataArray' ] )
			->getMock();
		$file->method( 'getMetadataArray' )
			->willReturn( $metadata );

		$handler = new SvgHandler();
		/** @var File $file */
		self::assertEquals( $expected, $handler->getAvailableLanguages( $file ) );
	}

	public static function provideAvailableLanguages() {
		return [
			[ [], [] ],
			[ [ 'translations' => [] ], [] ],
			[
				[
					'translations' => [
						'ru-RU' => SVGReader::LANG_PREFIX_MATCH
					]
				],
				[],
			],
			[
				[
					'translations' => [
						'en' => SVGReader::LANG_FULL_MATCH,
						'ru-RU' => SVGReader::LANG_PREFIX_MATCH,
						'ru' => SVGReader::LANG_FULL_MATCH,
						'fr-CA' => SVGReader::LANG_PREFIX_MATCH,
						'zh-Hans' => SVGReader::LANG_FULL_MATCH,
						'zh-Hans-TW' => SVGReader::LANG_FULL_MATCH,
						'he-IL-u-ca-hebrew-tz-jeruslm' => SVGReader::LANG_FULL_MATCH,
					],
				],
				[ 'en', 'ru', 'zh-hans', 'zh-hans-tw', 'he-il-u-ca-hebrew-tz-jeruslm' ],
			],
		];
	}

	/**
	 * @covers \SvgHandler::getLanguageFromParams()
	 * @dataProvider provideGetLanguageFromParams
	 *
	 * @param array $params
	 * @param string $expected
	 * @param string $message
	 */
	public function testGetLanguageFromParams( array $params, $expected, $message ) {
		/** @var SvgHandler $handler */
		$handler = TestingAccessWrapper::newFromObject( new SvgHandler() );
		self::assertEquals( $expected, $handler->getLanguageFromParams( $params ), $message );
	}

	public static function provideGetLanguageFromParams() {
		return [
			[ [], 'en', 'Default no language to en' ],
			[ [ 'preserve' => 'this' ], 'en', 'Default no language to en 2' ],
			[ [ 'preserve' => 'this', 'lang' => 'ru' ], 'ru', 'Language from lang' ],
			[ [ 'lang' => 'ru' ], 'ru', 'Language from lang 2' ],
			[ [ 'targetlang' => 'fr' ], 'fr', 'Language from targetlang' ],
			[ [ 'lang' => 'fr', 'targetlang' => 'de' ], 'fr', 'lang overrides targetlang' ],
		];
	}

	/**
	 * @covers \SvgHandler::parseParamString()
	 * @dataProvider provideParseParamString
	 *
	 * @param string $paramString
	 * @param array $expected
	 * @param string $message
	 * @return void
	 */
	public function testParseParamString( string $paramString, $expected, $message ) {
		/** @var SvgHandler $handler */
		$handler = TestingAccessWrapper::newFromObject( new SvgHandler() );
		$params = $handler->parseParamString( $paramString );
		self::assertSame( $expected, $params, $message );
		if ( $params === false ) {
			return;
		}
		foreach ( $expected as $key => $value ) {
			self::assertArrayHasKey( $key, $params, $message );
			self::assertEquals( $value, $params[$key], $message );
		}
	}

	public static function provideParseParamString() {
		return [
			[ '100px', [ 'width' => '100', 'lang' => 'en' ], 'Only width' ],
			[ 'langde-100px', [ 'width' => '100', 'lang' => 'de' ], 'German language and width' ],
			[ 'langzh-hans-100px', [ 'width' => '100', 'lang' => 'zh-hans' ], 'Chinese language and width' ],
			[ 'langzh-Hans-100px', false, 'Capitalized language code' ],
			[ 'langzh-TW-100px', false, 'Deprecated MW language code' ],
			[ 'langund-100px', [ 'width' => '100', 'lang' => 'und' ], 'Undetermined language code' ],
			[ 'langzh-%25-100px', false, 'Invalid IETF language code' ],
			[ 'langhe-il-u-ca-hebrew-tz-jeruslm-100px',
				[ 'width' => '100', 'lang' => 'he-il-u-ca-hebrew-tz-jeruslm' ],
				'Very complex IETF language code'
			],
		];
	}

	/**
	 * @covers \SvgHandler::allowRenderingByUserAgent()
	 * @dataProvider provideNativeSVGDataRendering
	 *
	 * @param string $filename of the file to test
	 * @param bool $svgEnabled value of MainConfigNames::SVGNativeRendering
	 * @param int $filesizeLimit value of MainConfigNames::SVGNativeRenderingSizeLimit
	 * @param bool $expected if the SVG is expected to be rendered natively by browser agent
	 * @return void
	 */
	public function testNativeSVGDataRendering( $filename, $svgEnabled, $filesizeLimit, $expected ) {
		$this->filePath = __DIR__ . '/../../data/media/';
		$this->overrideConfigValues( [
			MainConfigNames::SVGNativeRendering => $svgEnabled,
			MainConfigNames::SVGNativeRenderingSizeLimit => $filesizeLimit,
		] );

		$file = $this->dataFile( $filename, 'image/svg+xml' );
		$handler = new SvgHandler();
		self::assertEquals( $expected, $handler->allowRenderingByUserAgent( $file ) );
	}

	public static function provideNativeSVGDataRendering() {
		return [
			[ 'Tux.svg', false, 50 * 1024, false, 'SVG without Native rendering enabled' ],
			[ 'Tux.svg', true, 50 * 1024, true, 'SVG with Native rendering enforced' ],
			[ 'Tux.svg', true, 1, true, 'SVG with Native rendering enforced ignoring filesize limit' ],
			[ 'Tux.svg', 'partial', 223250, true, 'SVG with partial Native rendering' ],
			[ 'Tux.svg', 'partial', 1, false, 'SVG with partial Native rendering, where filesize is bigger than the limit' ],
			[ 'translated.svg', 'partial', null, false, 'SVG with translations should not be left to native rendering' ],
		];
	}
}
PK       ! AJ}  }  "  media/PNGMetadataExtractorTest.phpnu Iw        <?php

/**
 * @group Media
 * @covers \PNGMetadataExtractor
 */
class PNGMetadataExtractorTest extends MediaWikiIntegrationTestCase {
	private const FILE_PATH = __DIR__ . '/../../data/media/';

	/**
	 * Tests zTXt tag (compressed textual metadata)
	 * @requires extension zlib
	 */
	public function testPngNativetZtxt() {
		$meta = PNGMetadataExtractor::getMetadata( self::FILE_PATH .
			'Png-native-test.png' );
		$expected = "foo bar baz foo foo foo foof foo foo foo foo";
		$this->assertArrayHasKey( 'text', $meta );
		$meta = $meta['text'];
		$this->assertArrayHasKey( 'Make', $meta );
		$this->assertArrayHasKey( 'x-default', $meta['Make'] );

		$this->assertEquals( $expected, $meta['Make']['x-default'] );
	}

	/**
	 * Test tEXt tag (Uncompressed textual metadata)
	 */
	public function testPngNativeText() {
		$meta = PNGMetadataExtractor::getMetadata( self::FILE_PATH .
			'Png-native-test.png' );
		$expected = "Some long image desc";
		$this->assertArrayHasKey( 'text', $meta );
		$meta = $meta['text'];
		$this->assertArrayHasKey( 'ImageDescription', $meta );
		$this->assertArrayHasKey( 'x-default', $meta['ImageDescription'] );
		$this->assertArrayHasKey( '_type', $meta['ImageDescription'] );

		$this->assertEquals( $expected, $meta['ImageDescription']['x-default'] );
	}

	/**
	 * tEXt tags must be encoded iso-8859-1 (vs iTXt which are utf-8)
	 * Make sure non-ascii characters get converted properly
	 */
	public function testPngNativeTextNonAscii() {
		$meta = PNGMetadataExtractor::getMetadata( self::FILE_PATH .
			'Png-native-test.png' );

		// Note the Copyright symbol here is a utf-8 one
		// (aka \xC2\xA9) where in the file its iso-8859-1
		// encoded as just \xA9.
		$expected = "© 2010 Bawolff";

		$this->assertArrayHasKey( 'text', $meta );
		$meta = $meta['text'];
		$this->assertArrayHasKey( 'Copyright', $meta );
		$this->assertArrayHasKey( 'x-default', $meta['Copyright'] );

		$this->assertEquals( $expected, $meta['Copyright']['x-default'] );
	}

	/**
	 * Given a normal static PNG, check the animation metadata returned.
	 */
	public function testStaticPngAnimationMetadata() {
		$meta = PNGMetadataExtractor::getMetadata( self::FILE_PATH .
			'Png-native-test.png' );

		$this->assertSame( 0, $meta['frameCount'] );
		$this->assertSame( 1, $meta['loopCount'] );
		$this->assertSame( 0.0, $meta['duration'] );
	}

	/**
	 * Given an animated APNG image file
	 * check it gets animated metadata right.
	 */
	public function testApngAnimationMetadata() {
		$meta = PNGMetadataExtractor::getMetadata( self::FILE_PATH .
			'Animated_PNG_example_bouncing_beach_ball.png' );

		$this->assertEquals( 20, $meta['frameCount'] );
		// Note loop count of 0 = infinity
		$this->assertSame( 0, $meta['loopCount'] );
		$this->assertEqualsWithDelta( 1.5, $meta['duration'], 0.00001, '' );
	}

	public function testPngBitDepth8() {
		$meta = PNGMetadataExtractor::getMetadata( self::FILE_PATH .
			'Png-native-test.png' );

		$this->assertEquals( 8, $meta['bitDepth'] );
	}

	public function testPngBitDepth1() {
		$meta = PNGMetadataExtractor::getMetadata( self::FILE_PATH .
			'1bit-png.png' );
		$this->assertSame( 1, $meta['bitDepth'] );
	}

	public function testPngIndexColour() {
		$meta = PNGMetadataExtractor::getMetadata( self::FILE_PATH .
			'Png-native-test.png' );

		$this->assertEquals( 'index-coloured', $meta['colorType'] );
	}

	public function testPngRgbColour() {
		$meta = PNGMetadataExtractor::getMetadata( self::FILE_PATH .
			'rgb-png.png' );
		$this->assertEquals( 'truecolour-alpha', $meta['colorType'] );
	}

	public function testPngRgbNoAlphaColour() {
		$meta = PNGMetadataExtractor::getMetadata( self::FILE_PATH .
			'rgb-na-png.png' );
		$this->assertEquals( 'truecolour', $meta['colorType'] );
	}

	public function testPngGreyscaleColour() {
		$meta = PNGMetadataExtractor::getMetadata( self::FILE_PATH .
			'greyscale-png.png' );
		$this->assertEquals( 'greyscale-alpha', $meta['colorType'] );
	}

	public function testPngGreyscaleNoAlphaColour() {
		$meta = PNGMetadataExtractor::getMetadata( self::FILE_PATH .
			'greyscale-na-png.png' );
		$this->assertEquals( 'greyscale', $meta['colorType'] );
	}

	/**
	 * T286273 -- tEXt chunk replaced by null bytes
	 */
	public function testPngInvalidChunk() {
		$meta = PNGMetadataExtractor::getMetadata( self::FILE_PATH .
			'tEXt-invalid-masked.png' );
		$this->assertEquals( 10, $meta['width'] );
		$this->assertEquals( 10, $meta['height'] );
	}

	/**
	 * T286273 -- oversize chunk
	 */
	public function testPngOversizeChunk() {
		// Write a temporary file consisting of a normal PNG plus an extra tEXt chunk.
		// Try to hold the chunk in memory only once.
		$path = $this->getNewTempFile();
		copy( self::FILE_PATH . '1bit-png.png', $path );
		$chunkTypeAndData = "tEXtkey\0value" . str_repeat( '.', 10000000 );
		$crc = crc32( $chunkTypeAndData );
		$chunkLength = strlen( $chunkTypeAndData ) - 4;
		$file = fopen( $path, 'r+' );
		fseek( $file, -12, SEEK_END );
		$iend = fread( $file, 12 );
		fseek( $file, -12, SEEK_END );
		fwrite( $file, pack( 'N', $chunkLength ) );
		fwrite( $file, $chunkTypeAndData );
		fwrite( $file, pack( 'N', $crc ) );
		fwrite( $file, $iend );
		fclose( $file );

		// Extract the metadata
		$meta = PNGMetadataExtractor::getMetadata( $path );
		$this->assertEquals( 50, $meta['width'] );
		$this->assertEquals( 50, $meta['height'] );

		// Verify that the big chunk didn't end up in the metadata
		$this->assertLessThan( 100000, strlen( serialize( $meta ) ) );
	}

}
PK       ! +	=      media/Jpeg2000HandlerTest.phpnu Iw        <?php

/**
 * @covers \Jpeg2000Handler
 */
class Jpeg2000HandlerTest extends MediaWikiIntegrationTestCase {

	/**
	 * @dataProvider provideTestGetSizeAndMetadata
	 */
	public function testGetSizeAndMetadata( $path, $expectedResult ) {
		$handler = new Jpeg2000Handler();
		$this->assertEquals( $expectedResult, $handler->getSizeAndMetadata(
			new TrivialMediaHandlerState, $path ) );
	}

	public static function provideTestGetSizeAndMetadata() {
		return [
			[ __DIR__ . '/../../data/media/jpeg2000-lossless.jp2', [
				'width' => 100,
				'height' => 100,
				'bits' => 8,
			] ],
			[ __DIR__ . '/../../data/media/jpeg2000-lossy.jp2', [
				'width' => 100,
				'height' => 100,
				'bits' => 8,
			] ],
			[ __DIR__ . '/../../data/media/jpeg2000-alpha.jp2', [
				'width' => 100,
				'height' => 100,
				'bits' => 8,
			] ],
			[ __DIR__ . '/../../data/media/jpeg2000-profile.jpf', [
				'width' => 100,
				'height' => 100,
				'bits' => 8,
			] ],

			// Error cases
			[ __FILE__, [] ],
		];
	}
}
PK       ! (d      media/SVGReaderTest.phpnu Iw        <?php

/**
 * @group Media
 * @covers \SVGReader
 */
class SVGReaderTest extends \MediaWikiIntegrationTestCase {

	/**
	 * @dataProvider provideSvgFiles
	 */
	public function testGetMetadata( $infile, $expected ) {
		$this->assertMetadata( $infile, $expected );
	}

	/**
	 * @dataProvider provideSvgFilesWithXMLMetadata
	 */
	public function testGetXMLMetadata( $infile, $expected ) {
		$r = new XMLReader();
		$this->assertMetadata( $infile, $expected );
	}

	/**
	 * @dataProvider provideSvgUnits
	 */
	public function testScaleSVGUnit( $inUnit, $expected ) {
		$this->assertEquals(
			$expected,
			SVGReader::scaleSVGUnit( $inUnit ),
			'SVG unit conversion and scaling failure'
		);
	}

	private function assertMetadata( $infile, $expected ) {
		if ( $expected === false ) {
			$this->expectException( InvalidSVGException::class );
		}
		$svgReader = new SVGReader( $infile );
		$data = $svgReader->getMetadata();

		$this->assertEquals( $expected, $data, 'SVG metadata extraction test' );
	}

	public static function provideSvgFiles() {
		$base = __DIR__ . '/../../data/media';

		return [
			[
				"$base/Wikimedia-logo.svg",
				[
					'width' => 1024,
					'height' => 1024,
					'originalWidth' => '1024',
					'originalHeight' => '1024',
					'translations' => [],
				]
			],
			[
				"$base/QA_icon.svg",
				[
					'width' => 60,
					'height' => 60,
					'originalWidth' => '60',
					'originalHeight' => '60',
					'translations' => [],
				]
			],
			[
				"$base/Gtk-media-play-ltr.svg",
				[
					'width' => 60,
					'height' => 60,
					'originalWidth' => '60.0000000',
					'originalHeight' => '60.0000000',
					'translations' => [],
				]
			],
			[
				"$base/Toll_Texas_1.svg",
				// This file triggered T33719, needs entity expansion in the xmlns checks
				[
					'width' => 385,
					'height' => 385,
					'originalWidth' => '385',
					'originalHeight' => '385.0004883',
					'translations' => [],
				]
			],
			[
				"$base/Tux.svg",
				[
					'width' => 512,
					'height' => 594,
					'originalWidth' => '100%',
					'originalHeight' => '100%',
					'title' => 'Tux',
					'translations' => [],
					'description' => 'For more information see: http://commons.wikimedia.org/wiki/Image:Tux.svg',
				]
			],
			[
				"$base/Speech_bubbles.svg",
				[
					'width' => 669,
					'height' => 491,
					'originalWidth' => '17.7cm',
					'originalHeight' => '13cm',
					'translations' => [
						'de' => SVGReader::LANG_FULL_MATCH,
						'fr' => SVGReader::LANG_FULL_MATCH,
						'nl' => SVGReader::LANG_FULL_MATCH,
						'tlh-ca' => SVGReader::LANG_FULL_MATCH,
						'tlh' => SVGReader::LANG_PREFIX_MATCH
					],
				]
			],
			[
				"$base/Soccer_ball_animated.svg",
				[
					'width' => 150,
					'height' => 150,
					'originalWidth' => '150',
					'originalHeight' => '150',
					'animated' => true,
					'translations' => []
				],
			],
			[
				"$base/comma_separated_viewbox.svg",
				[
					'width' => 512,
					'height' => 594,
					'originalWidth' => '100%',
					'originalHeight' => '100%',
					'translations' => []
				],
			],
			[
				"$base/css-animated.svg",
				[
					'width' => 100,
					'height' => 100,
					'originalWidth' => '100',
					'originalHeight' => '100',
					'animated' => true,
					'translations' => []
				],
			],
		];
	}

	public static function provideSvgFilesWithXMLMetadata() {
		$base = __DIR__ . '/../../data/media';
		$metadata = '<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
      <ns4:Work xmlns:ns4="http://creativecommons.org/ns#" rdf:about="">
        <ns5:format xmlns:ns5="http://purl.org/dc/elements/1.1/">image/svg+xml</ns5:format>
        <ns5:type xmlns:ns5="http://purl.org/dc/elements/1.1/" rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
      </ns4:Work>
    </rdf:RDF>';
		// phpcs:enable

		$metadata = str_replace( "\r", '', $metadata ); // Windows compat
		return [
			[
				"$base/US_states_by_total_state_tax_revenue.svg",
				[
					'height' => 593,
					'metadata' => $metadata,
					'width' => 959,
					'originalWidth' => '958.69',
					'originalHeight' => '592.78998',
					'translations' => [],
				]
			],
		];
	}

	public static function provideSvgUnits() {
		return [
			[ '1', 1 ],
			[ '1.1', 1.1 ],
			[ '0.1', 0.1 ],
			[ '.1', 0.1 ],
			[ '1e2', 100 ],
			[ '1E2', 100 ],
			[ '+1', 1 ],
			[ '-1', -1 ],
			[ '-1.1', -1.1 ],
			[ '1e+2', 100 ],
			[ '1e-2', 0.01 ],
			[ '10px', 10 ],
			[ '10.2cm', 10.2 * 37.795275 ],
			[ '10pt', 10 * 1.333333 ],
			[ '10pc', 10 * 16 ],
			[ '10mm', 10 * 3.7795275 ],
			[ '10q', 10 * 0.944881 ],
			[ '10Q', 10 * 0.944881 ],
			[ '10cm', 10 * 37.795275 ],
			[ '10in', 10 * 96 ],
			[ '10em', 10 * 16 ],
			[ '10rem', 10 * 16 ],
			[ '10ex', 10 * 8 ],
			[ '10ch', 10 * 8 ],
			[ '10%', 51.2 ],
			[ '10 px', 10 ],
			// Invalid values
			[ '1e1.1', 10 ],
			[ '10bp', 10 ],
			[ 'p10', null ],
		];
	}
}
PK       ! A  A    media/ThumbnailImageTest.phpnu Iw        <?php

use Wikimedia\FileBackend\FileBackend;

/**
 * @group Media
 * @group medium
 *
 * @covers \ThumbnailImage
 * @covers \MediaTransformOutput
 */
class ThumbnailImageTest extends MediaWikiMediaTestCase {

	private function newFile( $name = 'Test.jpg' ) {
		return $this->dataFile( $name );
	}

	private function newThumbnail( $file = null, $url = null, $path = false, $parameters = [] ) {
		$file ??= $this->newFile();
		$path ??= 'thumb/a/ab/Test.jpg/123px-Test.jpg';
		$url ??= "https://example.com/w/images/$path";

		$parameters += [
			'width' => 200,
			'height' => 100,
		];

		return new ThumbnailImage( $file, $url, $path, $parameters );
	}

	public function testConstructor() {
		$file = $this->newFile();
		$path = 'thumb/a/ab/Test.jpg/123px-Test.jpg';
		$url = "https://example.com/w/images/$path";

		$parameters = [
			'width' => 300,
			'height' => 200,
		];

		$thumbnail = $this->newThumbnail(
			$file,
			$url,
			$path,
			$parameters
		);

		$this->assertSame( $file, $thumbnail->getFile() );
		$this->assertSame( $url, $thumbnail->getUrl() );
		$this->assertSame( $parameters['width'], $thumbnail->getWidth() );
		$this->assertSame( $parameters['height'], $thumbnail->getHeight() );
		$this->assertFalse( $thumbnail->isError() );
	}

	/**
	 * Check that we can stream data from a file system path
	 */
	public function testStreamFileWithStatus_fsPath() {
		$fsPath = $this->getFilePath() . 'test.jpg';
		$data = file_get_contents( $fsPath );
		$file = $this->newFile();

		// NOTE: We need the FileRepo in $file for the streamer option,
		// to prevent a real reset of the output buffer.
		$thumbnail = $this->newThumbnail( $file, null, $fsPath );

		ob_start();
		$thumbnail->streamFileWithStatus();
		$output = ob_get_clean();

		$this->assertSame( $data, $output );
	}

	/**
	 * Check that we can stream using the FileBackend
	 */
	public function testStreamFileWithStatus_thumbStoragePath() {
		$this->backend = $this->createNoOpMock( FileBackend::class, [ 'streamFile' ] );

		$this->backend->expects( $this->once() )
			->method( 'streamFile' )
			->wilLreturn( StatusValue::newGood() );

		$this->repo = new FileRepo( $this->getRepoOptions() );

		$file = $this->newFile( 'test.jpg' );
		$thumbnail = $this->newThumbnail(
			$file,
			$file->getThumbUrl(),
			$file->getThumbPath()
		);

		$thumbnail->streamFileWithStatus();

		// no assertion needed, we just expect streamFile() to be called.
	}

	/**
	 * Check that we don't explode if no file repo is known
	 */
	public function testStreamFileWithStatus_UnregisteredLocalFile() {
		// Use a non-existing file, so streaming will fail.
		// If streaming was successful, we'd generate real output, since
		// without a file backend, we have no way to disable a full reset
		// of output buffers.
		$fsPath = $this->getFilePath() . 'this does not exist';

		// No file repo or backend!
		$file = new UnregisteredLocalFile( false, false, $fsPath );
		$thumbnail = $this->newThumbnail( $file );

		// Check that streaming fails gracefully
		$status = $thumbnail->streamFileWithStatus();
		$this->assertStatusError( 'backend-fail-stream', $status );
	}

}
PK       ! _)    4  watchlist/WatchedItemQueryServiceIntegrationTest.phpnu Iw        <?php

use MediaWiki\Api\ApiUsageException;
use MediaWiki\MainConfigNames;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\Options\UserOptionsLookup;
use MediaWiki\User\User;

/**
 * @group Database
 *
 * @covers \MediaWiki\Watchlist\WatchedItemQueryService
 */
class WatchedItemQueryServiceIntegrationTest extends MediaWikiIntegrationTestCase {

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

		$this->overrideConfigValue( MainConfigNames::WatchlistExpiry, true );
	}

	public function testGetWatchedItemsForUser(): void {
		$store = $this->getServiceContainer()->getWatchedItemStore();
		$queryService = $this->getServiceContainer()->getWatchedItemQueryService();
		$user = self::getTestUser()->getUser();
		$initialCount = count( $store->getWatchedItemsForUser( $user ) );

		// Add two watched items, one of which is already expired, and check that only 1 is returned.
		$store->addWatch(
			$user,
			new TitleValue( 0, __METHOD__ . ' no expiry 1' )
		);
		$store->addWatch(
			$user,
			new TitleValue( 0, __METHOD__ . ' expired a week ago or in a week' ),
			'1 week ago'
		);
		$result1 = $queryService->getWatchedItemsForUser( $user );
		$this->assertCount( $initialCount + 1, $result1, "User ID: " . $user->getId() );

		// Add another of each type of item, and make sure the new results are as expected.
		$store->addWatch(
			$user,
			new TitleValue( 0, __METHOD__ . ' no expiry 2' )
		);
		$store->addWatch(
			$user,
			new TitleValue( 0, __METHOD__ . ' expired a week ago 2' ),
			'1 week ago'
		);
		$result2 = $queryService->getWatchedItemsForUser( $user );
		$this->assertCount( $initialCount + 2, $result2 );

		// Make one of the expired items permanent, and check again.
		$store->addWatch(
			$user,
			new TitleValue( 0, __METHOD__ . ' expired a week ago 2' ),
			'infinity'
		);
		$result3 = $queryService->getWatchedItemsForUser( $user );
		$this->assertCount( $initialCount + 3, $result3 );

		// Make the other expired item expire in a week's time, and make sure it appears in the list.
		$store->addWatch(
			$user,
			new TitleValue( 0, __METHOD__ . ' expired a week ago or in a week' ),
			'1 week'
		);
		$result4 = $queryService->getWatchedItemsForUser( $user );
		$this->assertCount( $initialCount + 4, $result4 );
	}

	public function testGetWatchedItemsForUserWithExpiriesDisabled() {
		$this->overrideConfigValue( MainConfigNames::WatchlistExpiry, false );
		$store = $this->getServiceContainer()->getWatchedItemStore();
		$queryService = $this->getServiceContainer()->getWatchedItemQueryService();
		$user = self::getTestUser()->getUser();
		$initialCount = count( $store->getWatchedItemsForUser( $user ) );
		$store->addWatch( $user, new TitleValue( 0, __METHOD__ ), '1 week ago' );
		$result = $queryService->getWatchedItemsForUser( $user );
		$this->assertCount( $initialCount + 1, $result );
	}

	public function testGetWatchedItemsWithRecentChangeInfo_watchlistExpiry(): void {
		$store = $this->getServiceContainer()->getWatchedItemStore();
		$queryService = $this->getServiceContainer()->getWatchedItemQueryService();
		$user = self::getTestUser()->getUser();
		$options = [];
		$startFrom = null;
		$initialCount = count( $queryService->getWatchedItemsWithRecentChangeInfo( $user,
				$options, $startFrom ) );

		// Add two watched items, one of which is already expired, and check that only 1 is returned.
		$userEditTarget1 = new TitleValue( 0, __METHOD__ . ' no expiry 1' );
		$this->editPage( $userEditTarget1, 'First revision' );
		$store->addWatch(
			$user,
			$userEditTarget1
		);

		$userEditTarget2 = new TitleValue( 0, __METHOD__ . ' expired a week ago or in a week' );
		$this->editPage( $userEditTarget2, 'First revision' );
		$store->addWatch(
			$user,
			$userEditTarget2,
			'1 week ago'
		);

		$result1 = $queryService->getWatchedItemsWithRecentChangeInfo( $user, $options, $startFrom );
		$this->assertCount( $initialCount + 1, $result1 );

		// Add another of each type of item, and make sure the new results are as expected.
		$userEditTarget3 = new TitleValue( 0, __METHOD__ . ' no expiry 2' );
		$this->editPage( $userEditTarget3, 'First revision' );
		$store->addWatch(
			$user,
			$userEditTarget3
		);

		$userEditTarget4 = new TitleValue( 0, __METHOD__ . ' expired a week ago 2' );
		$this->editPage( $userEditTarget4, 'First revision' );
		$store->addWatch(
			$user,
			$userEditTarget4,
			'1 week ago'
		);
		$result2 = $queryService->getWatchedItemsWithRecentChangeInfo( $user );
		$this->assertCount( $initialCount + 2, $result2 );

		// Make one of the expired items permanent, and check again.
		$store->addWatch(
			$user,
			$userEditTarget4,
			'infinity'
		);
		$result3 = $queryService->getWatchedItemsWithRecentChangeInfo( $user );
		$this->assertCount( $initialCount + 3, $result3 );

		// Make the other expired item expire in a week's time, and make sure it appears in the list.
		$store->addWatch(
			$user,
			$userEditTarget2,
			'1 week'
		);
		$result4 = $queryService->getWatchedItemsWithRecentChangeInfo( $user );
		$this->assertCount( $initialCount + 4, $result4 );
	}

	public static function invalidWatchlistTokenProvider() {
		return [
			[ 'wrongToken' ],
			[ '' ],
		];
	}

	/**
	 * @dataProvider invalidWatchlistTokenProvider
	 */
	public function testGetWatchedItemsWithRecentChangeInfo_watchlistOwnerAndInvalidToken( $token ) {
		// Moved from the Unit test because the ApiUsageException call creates a Message object
		// and down the line needs MediaWikiServices
		$user = $this->createNoOpMock(
			User::class,
			[ 'isRegistered', 'getId', 'useRCPatrol' ]
		);
		$user->method( 'isRegistered' )->willReturn( true );
		$user->method( 'getId' )->willReturn( 1 );
		$user->method( 'useRCPatrol' )->willReturn( true );

		$otherUser = $this->createNoOpMock(
			User::class,
			[ 'isRegistered', 'getId', 'useRCPatrol' ]
		);
		$otherUser->method( 'isRegistered' )->willReturn( true );
		$otherUser->method( 'getId' )->willReturn( 2 );
		$otherUser->method( 'useRCPatrol' )->willReturn( true );

		$userOptionsLookup = $this->createMock( UserOptionsLookup::class );
		$userOptionsLookup->expects( $this->once() )
			->method( 'getOption' )
			->with( $otherUser, 'watchlisttoken' )
			->willReturn( '0123456789abcdef' );

		$this->setService( 'UserOptionsLookup', $userOptionsLookup );

		$queryService = $this->getServiceContainer()->getWatchedItemQueryService();

		$this->expectException( ApiUsageException::class );
		$this->expectExceptionMessage( 'Incorrect watchlist token provided' );
		$queryService->getWatchedItemsWithRecentChangeInfo(
			$user,
			[ 'watchlistOwner' => $otherUser, 'watchlistOwnerToken' => $token ]
		);
	}
}
PK       ! jAB  AB  -  watchlist/WatchedItemStoreIntegrationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @author Addshore
 *
 * @group Database
 *
 * @covers \MediaWiki\Watchlist\WatchedItemStore
 */
class WatchedItemStoreIntegrationTest extends MediaWikiIntegrationTestCase {

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValues( [
			MainConfigNames::WatchlistExpiry => true,
			MainConfigNames::WatchlistExpiryMaxDuration => '6 months',
		] );
	}

	private function getUser(): UserIdentity {
		return new UserIdentityValue( 42, 'WatchedItemStoreIntegrationTestUser' );
	}

	public function testWatchAndUnWatchItem() {
		$user = $this->getUser();
		$title = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPage' );
		$store = $this->getServiceContainer()->getWatchedItemStore();
		// Cleanup after previous tests
		$store->removeWatch( $user, $title );
		$initialWatchers = $store->countWatchers( $title );
		$initialUserWatchedItems = $store->countWatchedItems( $user );

		$this->assertFalse(
			$store->isWatched( $user, $title ),
			'Page should not initially be watched'
		);
		$this->assertFalse( $store->isTempWatched( $user, $title ) );

		$store->addWatch( $user, $title );
		$this->assertTrue(
			$store->isWatched( $user, $title ),
			'Page should be watched'
		);
		$this->assertFalse(
			$store->isTempWatched( $user, $title ),
			'Page should not be temporarily watched'
		);
		$this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) );
		$watchedItemsForUser = $store->getWatchedItemsForUser( $user );
		$this->assertCount( $initialUserWatchedItems + 1, $watchedItemsForUser );
		$watchedItemsForUserHasExpectedItem = false;
		foreach ( $watchedItemsForUser as $watchedItem ) {
			if (
				$watchedItem->getUserIdentity()->equals( $user ) &&
				$watchedItem->getTarget() == $title->getTitleValue()
			) {
				$watchedItemsForUserHasExpectedItem = true;
			}
		}
		$this->assertTrue(
			$watchedItemsForUserHasExpectedItem,
			'getWatchedItemsForUser should contain the page'
		);
		$this->assertEquals( $initialWatchers + 1, $store->countWatchers( $title ) );
		$this->assertEquals(
			$initialWatchers + 1,
			$store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()]
		);
		$this->assertEquals(
			[ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialWatchers + 1 ] ],
			$store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 1 ] )
		);
		$this->assertEquals(
			[ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ],
			$store->countWatchersMultiple( [ $title ], [ 'minimumWatchers' => $initialWatchers + 2 ] )
		);
		$this->assertEquals(
			[ $title->getNamespace() => [ $title->getDBkey() => null ] ],
			$store->getNotificationTimestampsBatch( $user, [ $title ] )
		);

		$store->removeWatch( $user, $title );
		$this->assertFalse(
			$store->isWatched( $user, $title ),
			'Page should be unwatched'
		);
		$this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) );
		$watchedItemsForUser = $store->getWatchedItemsForUser( $user );
		$this->assertCount( $initialUserWatchedItems, $watchedItemsForUser );
		$watchedItemsForUserHasExpectedItem = false;
		foreach ( $watchedItemsForUser as $watchedItem ) {
			if (
				$watchedItem->getUserIdentity()->equals( $user ) &&
				$watchedItem->getTarget() == $title->getTitleValue()
			) {
				$watchedItemsForUserHasExpectedItem = true;
			}
		}
		$this->assertFalse(
			$watchedItemsForUserHasExpectedItem,
			'getWatchedItemsForUser should not contain the page'
		);
		$this->assertEquals( $initialWatchers, $store->countWatchers( $title ) );
		$this->assertEquals(
			$initialWatchers,
			$store->countWatchersMultiple( [ $title ] )[$title->getNamespace()][$title->getDBkey()]
		);
		$this->assertEquals(
			[ $title->getNamespace() => [ $title->getDBkey() => false ] ],
			$store->getNotificationTimestampsBatch( $user, [ $title ] )
		);
	}

	public function testWatchAndUnWatchItemWithExpiry(): void {
		$user = $this->getUser();
		$title = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPage' );
		$store = $this->getServiceContainer()->getWatchedItemStore();
		$initialUserWatchedItems = $store->countWatchedItems( $user );

		// Watch for a duration greater than the max ($wgWatchlistExpiryMaxDuration),
		// which should get changed to the max.
		$expiry = wfTimestamp( TS_MW, strtotime( '10 years' ) );
		$store->addWatch( $user, $title, $expiry );
		$this->assertLessThanOrEqual(
			wfTimestamp( TS_MW, strtotime( '6 months' ) ),
			$store->loadWatchedItem( $user, $title )->getExpiry()
		);

		// Valid expiry that's less than the max.
		$expiry = wfTimestamp( TS_MW, strtotime( '1 week' ) );

		$store->addWatch( $user, $title, $expiry );
		$this->assertSame(
			$expiry,
			$store->loadWatchedItem( $user, $title )->getExpiry()
		);
		$this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) );
		$this->assertTrue( $store->isTempWatched( $user, $title ) );

		// Invalid expiry, nothing should change.
		$exceptionThrown = false;
		try {
			$store->addWatch( $user, $title, 'invalid expiry' );
		} catch ( InvalidArgumentException $exception ) {
			$exceptionThrown = true;
			// Asserting watchedItem getExpiry stays unchanged
			$this->assertSame(
				$expiry,
				$store->loadWatchedItem( $user, $title )->getExpiry()
			);
			$this->assertSame(
				$initialUserWatchedItems + 1,
				$store->countWatchedItems( $user )
			);
		}
		$this->assertTrue( $exceptionThrown );

		// Changed to infinity, so expiry row should be removed.
		$store->addWatch( $user, $title, 'infinity' );
		$this->assertNull(
			$store->loadWatchedItem( $user, $title )->getExpiry()
		);
		$this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) );
		$this->assertFalse( $store->isTempWatched( $user, $title ) );

		// Updating to a valid expiry.
		$store->addWatch( $user, $title, '1 month' );
		$this->assertLessThanOrEqual(
			strtotime( '1 month' ),
			wfTimestamp(
				TS_UNIX,
				$store->loadWatchedItem( $user, $title )->getExpiry()
			)
		);
		$this->assertEquals( $initialUserWatchedItems + 1, $store->countWatchedItems( $user ) );

		// Expiry in the past, should not be considered watched.
		$store->addWatch( $user, $title, '20090101000000' );
		$this->assertEquals( $initialUserWatchedItems, $store->countWatchedItems( $user ) );

		// Test isWatch(), which would normally pull from the cache. In this case
		// the cache should bust and return false since the item has expired.
		$this->assertFalse( $store->isWatched( $user, $title ) );
		$this->assertFalse( $store->isTempWatched( $user, $title ) );
	}

	public function testWatchAndUnwatchMultipleWithExpiry(): void {
		$user = $this->getUser();
		$title1 = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPage1' );
		$title2 = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPage1' );
		$store = $this->getServiceContainer()->getWatchedItemStore();

		// Use a relative timestamp in the near future to ensure we don't exceed the max.
		// See testWatchAndUnWatchItemWithExpiry() for tests regarding the max duration.
		$timestamp = wfTimestamp( TS_MW, strtotime( '1 week' ) );
		$store->addWatchBatchForUser( $user, [ $title1, $title2 ], $timestamp );

		$this->assertSame(
			$timestamp,
			$store->loadWatchedItem( $user, $title1 )->getExpiry()
		);
		$this->assertSame(
			$timestamp,
			$store->loadWatchedItem( $user, $title2 )->getExpiry()
		);

		// Clear expiries.
		$store->addWatchBatchForUser( $user, [ $title1, $title2 ], 'infinity' );

		$this->assertNull(
			$store->loadWatchedItem( $user, $title1 )->getExpiry()
		);
		$this->assertNull(
			$store->loadWatchedItem( $user, $title2 )->getExpiry()
		);
	}

	public function testWatchBatchAndClearItems() {
		$user = $this->getUser();
		$title1 = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPage1' );
		$title2 = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPage2' );
		$store = $this->getServiceContainer()->getWatchedItemStore();

		$store->addWatchBatchForUser( $user, [ $title1, $title2 ] );

		$this->assertTrue( $store->isWatched( $user, $title1 ) );
		$this->assertTrue( $store->isWatched( $user, $title2 ) );

		$store->clearUserWatchedItems( $user );

		$this->assertFalse( $store->isWatched( $user, $title1 ) );
		$this->assertFalse( $store->isWatched( $user, $title2 ) );
	}

	public function testUpdateResetAndSetNotificationTimestamp() {
		$user = $this->getUser();
		$otherUser = new UserIdentityValue(
			$user->getId() + 1,
			$user->getName() . '_other'
		);
		$title = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPage' );
		$store = $this->getServiceContainer()->getWatchedItemStore();
		$store->addWatch( $user, $title );
		$this->assertNull( $store->loadWatchedItem( $user, $title )->getNotificationTimestamp() );
		$initialVisitingWatchers = $store->countVisitingWatchers( $title, '20150202020202' );
		$initialUnreadNotifications = $store->countUnreadNotifications( $user );

		$store->updateNotificationTimestamp( $otherUser, $title, '20150202010101' );
		$this->assertSame(
			'20150202010101',
			$store->loadWatchedItem( $user, $title )->getNotificationTimestamp()
		);
		$this->assertEquals(
			[ $title->getNamespace() => [ $title->getDBkey() => '20150202010101' ] ],
			$store->getNotificationTimestampsBatch( $user, [ $title ] )
		);
		$this->assertEquals(
			$initialVisitingWatchers - 1,
			$store->countVisitingWatchers( $title, '20150202020202' )
		);
		$this->assertEquals(
			$initialVisitingWatchers - 1,
			$store->countVisitingWatchersMultiple(
				[ [ $title, '20150202020202' ] ]
			)[$title->getNamespace()][$title->getDBkey()]
		);
		$this->assertEquals(
			$initialUnreadNotifications + 1,
			$store->countUnreadNotifications( $user )
		);
		$this->assertSame(
			true,
			$store->countUnreadNotifications( $user, $initialUnreadNotifications + 1 )
		);

		$this->assertTrue( $store->resetNotificationTimestamp( $user, $title ) );
		$this->assertNull( $store->getWatchedItem( $user, $title )->getNotificationTimestamp() );
		$this->assertEquals(
			[ $title->getNamespace() => [ $title->getDBkey() => null ] ],
			$store->getNotificationTimestampsBatch( $user, [ $title ] )
		);

		// Run the job queue
		$this->runJobs();

		$this->assertEquals(
			$initialVisitingWatchers,
			$store->countVisitingWatchers( $title, '20150202020202' )
		);
		$this->assertEquals(
			$initialVisitingWatchers,
			$store->countVisitingWatchersMultiple(
				[ [ $title, '20150202020202' ] ]
			)[$title->getNamespace()][$title->getDBkey()]
		);
		$this->assertEquals(
			[ 0 => [ 'WatchedItemStoreIntegrationTestPage' => $initialVisitingWatchers ] ],
			$store->countVisitingWatchersMultiple(
				[ [ $title, '20150202020202' ] ], $initialVisitingWatchers
			)
		);
		$this->assertEquals(
			[ 0 => [ 'WatchedItemStoreIntegrationTestPage' => 0 ] ],
			$store->countVisitingWatchersMultiple(
				[ [ $title, '20150202020202' ] ], $initialVisitingWatchers + 1
			)
		);

		// setNotificationTimestampsForUser specifying a title
		$this->assertTrue(
			$store->setNotificationTimestampsForUser( $user, '20100202020202', [ $title ] )
		);
		$this->assertSame(
			'20100202020202',
			$store->getWatchedItem( $user, $title )->getNotificationTimestamp()
		);

		// setNotificationTimestampsForUser not specifying a title
		// This will try to use a DeferredUpdate; disable that
		$mockCallback = static function ( $callback ) {
			$callback();
		};
		$scopedOverride = $store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
		$this->assertTrue(
			$store->setNotificationTimestampsForUser( $user, '20110202020202' )
		);
		// Because the operation above is normally deferred, it doesn't clear the cache
		// Clear the cache manually
		$wrappedStore = TestingAccessWrapper::newFromObject( $store );
		$wrappedStore->uncacheUser( $user );
		$this->assertSame(
			'20110202020202',
			$store->getWatchedItem( $user, $title )->getNotificationTimestamp()
		);
	}

	public function testDuplicateAllAssociatedEntries() {
		// Fake current time to be 2020-05-27T00:00:00Z
		ConvertibleTimestamp::setFakeTime( '20200527000000' );

		$user = $this->getUser();
		$titleOld = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPageOld' );
		$titleNew = Title::makeTitle( NS_MAIN, 'WatchedItemStoreIntegrationTestPageNew' );
		$store = $this->getServiceContainer()->getWatchedItemStore();
		$store->addWatch( $user, $titleOld->getSubjectPage(), '99990123000000' );
		$store->addWatch( $user, $titleOld->getTalkPage(), '99990123000000' );

		// Fetch stored expiry (may have changed due to wgWatchlistExpiryMaxDuration).
		// Note we use loadWatchedItem() instead of getWatchedItem() to bypass the process cache.
		$expectedExpiry = $store->loadWatchedItem( $user, $titleOld )->getExpiry();

		// Watch the new title with a different expiry, so that we can confirm
		// it gets replaced with the old title's expiry.
		$store->addWatch( $user, $titleNew->getSubjectPage(), '1 day' );
		$store->addWatch( $user, $titleNew->getTalkPage(), '1 day' );

		// Use the sysop test user as well on the old title, so we can test that
		// each user's respective expiry is correctly copied.
		$user2 = $this->getTestSysop()->getUser();
		$store->addWatch( $user2, $titleOld->getSubjectPage(), '1 week' );
		$store->addWatch( $user2, $titleOld->getTalkPage(), '1 week' );
		$expectedExpiry2 = $store->loadWatchedItem( $user2, $titleOld )->getExpiry();

		// Duplicate associated entries. This will try to use a DeferredUpdate; disable that.
		$mockCallback = static function ( $callback ) {
			$callback();
		};
		$store->overrideDeferredUpdatesAddCallableUpdateCallback( $mockCallback );
		$store->duplicateAllAssociatedEntries( $titleOld, $titleNew );

		$this->assertTrue( $store->isWatched( $user, $titleOld->getSubjectPage() ) );
		$this->assertTrue( $store->isWatched( $user, $titleOld->getTalkPage() ) );
		$this->assertTrue( $store->isWatched( $user, $titleNew->getSubjectPage() ) );
		$this->assertTrue( $store->isWatched( $user, $titleNew->getTalkPage() ) );

		$oldExpiry = $store->loadWatchedItem( $user, $titleOld )->getExpiry();
		$newExpiry = $store->loadWatchedItem( $user, $titleNew )->getExpiry();
		$this->assertSame( $expectedExpiry, $oldExpiry );
		$this->assertSame( $expectedExpiry, $newExpiry );

		// Same for $user2 and $expectedExpiry2
		$oldExpiry = $store->loadWatchedItem( $user2, $titleOld )->getExpiry();
		$newExpiry = $store->loadWatchedItem( $user2, $titleNew )->getExpiry();
		$this->assertSame( $expectedExpiry2, $oldExpiry );
		$this->assertSame( $expectedExpiry2, $newExpiry );
	}

	public function testRemoveExpired() {
		$store = $this->getServiceContainer()->getWatchedItemStore();

		// Clear out any expired rows, to start from a known point.
		$store->removeExpired( 10 );
		$this->assertSame( 0, $store->countExpired() );

		// Add three pages, two of which have already expired.
		$user = $this->getUser();
		$store->addWatch( $user, Title::makeTitle( NS_MAIN, 'P1' ), '2020-01-25' );
		$store->addWatch( $user, Title::makeTitle( NS_MAIN, 'P2' ), '20200101000000' );
		$store->addWatch( $user, Title::makeTitle( NS_MAIN, 'P3' ), '1 month' );

		// Test that they can be counted and removed correctly.
		$this->assertSame( 2, $store->countExpired() );
		$store->removeExpired( 1 );
		$this->assertSame( 1, $store->countExpired() );
	}

	public function testRemoveOrphanedExpired() {
		$store = $this->getServiceContainer()->getWatchedItemStore();
		// Clear out any expired rows, to start from a known point.
		$store->removeExpired( 10 );

		// Manually insert some orphaned non-expired rows.
		$orphanRows = [
			[ 'we_item' => '100000', 'we_expiry' => $this->getDb()->timestamp( '30300101000000' ) ],
			[ 'we_item' => '100001', 'we_expiry' => $this->getDb()->timestamp( '30300101000000' ) ],
		];
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'watchlist_expiry' )
			->rows( $orphanRows )
			->caller( __METHOD__ )
			->execute();
		$initialRowCount = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'watchlist_expiry' )
			->caller( __METHOD__ )->fetchRowCount();

		// Make sure the orphans aren't removed if it's not requested.
		$store->removeExpired( 10, false );
		$this->assertSame(
			$initialRowCount,
			$this->getDb()->newSelectQueryBuilder()
				->select( '*' )
				->from( 'watchlist_expiry' )
				->caller( __METHOD__ )->fetchRowCount()
		);

		// Make sure they are removed when requested.
		$store->removeExpired( 10, true );
		$this->assertSame(
			$initialRowCount - 2,
			$this->getDb()->newSelectQueryBuilder()
				->select( '*' )
				->from( 'watchlist_expiry' )
				->caller( __METHOD__ )->fetchRowCount()
		);
	}
}
PK       ! >    '  watchlist/ClearUserWatchlistJobTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\Watchlist\ClearUserWatchlistJob;

/**
 * @covers \MediaWiki\Watchlist\ClearUserWatchlistJob
 *
 * @group JobQueue
 * @group Database
 *
 * @license GPL-2.0-or-later
 * @author Addshore
 */
class ClearUserWatchlistJobTest extends MediaWikiIntegrationTestCase {
	private function getUser(): UserIdentity {
		return new UserIdentityValue( 42, 'ClearUserWatchlistJobTestUser' );
	}

	private function getWatchedItemStore() {
		return $this->getServiceContainer()->getWatchedItemStore();
	}

	public function testRun() {
		$user = $this->getUser();
		$watchedItemStore = $this->getWatchedItemStore();

		$watchedItemStore->addWatch( $user, new TitleValue( 0, 'A' ) );
		$watchedItemStore->addWatch( $user, new TitleValue( 1, 'A' ) );
		$watchedItemStore->addWatch( $user, new TitleValue( 0, 'B' ) );
		$watchedItemStore->addWatch( $user, new TitleValue( 1, 'B' ) );

		$maxId = $watchedItemStore->getMaxId();

		$watchedItemStore->addWatch( $user, new TitleValue( 0, 'C' ) );
		$watchedItemStore->addWatch( $user, new TitleValue( 1, 'C' ) );

		$this->overrideConfigValue( MainConfigNames::UpdateRowsPerQuery, 2 );

		$jobQueueGroup = $this->getServiceContainer()->getJobQueueGroup();
		$jobQueueGroup->push(
			new ClearUserWatchlistJob( [
				'userId' => $user->getId(), 'maxWatchlistId' => $maxId,
			] )
		);

		$this->assertSame( 1, $jobQueueGroup->getQueueSizes()['clearUserWatchlist'] );
		$this->assertEquals( 6, $watchedItemStore->countWatchedItems( $user ) );
		$this->runJobs( [ 'complete' => false ], [ 'maxJobs' => 1 ] );
		$this->assertSame( 1, $jobQueueGroup->getQueueSizes()['clearUserWatchlist'] );
		$this->assertEquals( 4, $watchedItemStore->countWatchedItems( $user ) );
		$this->runJobs( [ 'complete' => false ], [ 'maxJobs' => 1 ] );
		$this->assertSame( 1, $jobQueueGroup->getQueueSizes()['clearUserWatchlist'] );
		$this->assertEquals( 2, $watchedItemStore->countWatchedItems( $user ) );
		$this->runJobs( [ 'complete' => false ], [ 'maxJobs' => 1 ] );
		$this->assertSame( 0, $jobQueueGroup->getQueueSizes()['clearUserWatchlist'] );
		$this->assertEquals( 2, $watchedItemStore->countWatchedItems( $user ) );

		$this->assertTrue( $watchedItemStore->isWatched( $user, new TitleValue( 0, 'C' ) ) );
		$this->assertTrue( $watchedItemStore->isWatched( $user, new TitleValue( 1, 'C' ) ) );
	}

	public function testRunWithWatchlistExpiry() {
		// Set up.
		$this->overrideConfigValue( MainConfigNames::WatchlistExpiry, true );
		$user = $this->getUser();
		$watchedItemStore = $this->getWatchedItemStore();

		// Add two watched items, one with an expiry.
		$watchedItemStore->addWatch( $user, new TitleValue( 0, __METHOD__ . 'no expiry' ) );
		$watchedItemStore->addWatch( $user, new TitleValue( 0, __METHOD__ . 'has expiry' ), '1 week' );

		// Get the IDs of these items.
		$itemIds = $this->getDb()->newSelectQueryBuilder()
			->select( 'wl_id' )
			->from( 'watchlist' )
			->where( [ 'wl_user' => $user->getId() ] )
			->caller( __METHOD__ )->fetchFieldValues();

		// Clear the watchlist by running the job.
		$job = new ClearUserWatchlistJob( [
			'userId' => $user->getId(),
			'maxWatchlistId' => max( $itemIds ),
		] );
		$this->getServiceContainer()->getJobQueueGroup()->push( $job );
		$this->runJobs( [ 'complete' => false ], [ 'maxJobs' => 1 ] );

		// Confirm that there are now no expiry records.
		$watchedCount = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'watchlist_expiry' )
			->where( [ 'we_item' => $itemIds ] )
			->caller( __METHOD__ )->fetchRowCount();
		$this->assertSame( 0, $watchedCount );
	}
}
PK       ! |A|  |     search/SearchHighlighterTest.phpnu Iw        <?php

/**
 * @group Search
 */
class SearchHighlighterTest extends \MediaWikiIntegrationTestCase {
	/**
	 * @dataProvider provideHighlightSimple
	 * @covers \SearchHighlighter::highlightSimple
	 */
	public function testHighlightSimple( string $wikiText, string $searchTerm, string $expectedOutput, int $contextChars ) {
		$highlighter = new \SearchHighlighter( false );
		$actual = $highlighter->highlightSimple( $wikiText, [ $searchTerm ], 1, $contextChars );
		$this->assertEquals( $expectedOutput, $actual );
	}

	public static function provideHighlightSimple() {
		return [
			'no match' => [
				'this is a very simple text.',
				'cannotmatch',
				'',
				10
			],
			'match a single word at the end of the string' => [
				'this is a very simple text.',
				'text',
				"this is a very simple <span class=\"searchmatch\">text</span>.\n",
				40
			],
			'utf-8 sequences should not be broken' => [
				"text with long trailing UTF-8 sequences: " . str_repeat( "\u{1780}", 6 ) . ".",
				'text',
				"<span class=\"searchmatch\">text</span> with long trailing UTF-8 sequences: " . str_repeat( "\u{1780}", 5 ) . "\n",
				41
			],
		];
	}
}
PK       ! P9  9  .  search/ParserOutputSearchDataExtractorTest.phpnu Iw        <?php

use MediaWiki\Parser\ParserOutput;
use MediaWiki\Search\ParserOutputSearchDataExtractor;
use MediaWiki\Title\Title;

/**
 * @group Search
 * @covers \MediaWiki\Search\ParserOutputSearchDataExtractor
 * @group Database
 */
class ParserOutputSearchDataExtractorTest extends MediaWikiLangTestCase {

	public function testGetCategories() {
		$categories = [
			'Foo_bar' => 'Bar',
			'New_page' => ''
		];

		$parserOutput = new ParserOutput( '', [], $categories );

		$searchDataExtractor = new ParserOutputSearchDataExtractor();

		$this->assertEquals(
			[ 'Foo bar', 'New page' ],
			$searchDataExtractor->getCategories( $parserOutput )
		);
	}

	public function testGetExternalLinks() {
		$parserOutput = new ParserOutput();

		$parserOutput->addExternalLink( 'https://foo' );
		$parserOutput->addExternalLink( 'https://bar' );

		$searchDataExtractor = new ParserOutputSearchDataExtractor();

		$this->assertEquals(
			[ 'https://foo', 'https://bar' ],
			$searchDataExtractor->getExternalLinks( $parserOutput )
		);
	}

	public function testGetOutgoingLinks() {
		$parserOutput = new ParserOutput();

		$parserOutput->addLink( Title::makeTitle( NS_MAIN, 'Foo_bar' ), 1 );
		$parserOutput->addLink( Title::makeTitle( NS_HELP, 'Contents' ), 2 );

		$searchDataExtractor = new ParserOutputSearchDataExtractor();

		// this indexes links with db key
		$this->assertEquals(
			[ 'Foo_bar', 'Help:Contents' ],
			$searchDataExtractor->getOutgoingLinks( $parserOutput )
		);
	}

	public function testGetTemplates() {
		$title = Title::makeTitle( NS_TEMPLATE, 'Cite_news' );

		$parserOutput = new ParserOutput();
		$parserOutput->addTemplate( $title, 10, 100 );

		$searchDataExtractor = new ParserOutputSearchDataExtractor();

		$this->assertEquals(
			[ 'Template:Cite news' ],
			$searchDataExtractor->getTemplates( $parserOutput )
		);
	}

}
PK       !  v      search/SearchResultSetTest.phpnu Iw        <?php

use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Title\Title;

class SearchResultSetTest extends MediaWikiIntegrationTestCase {
	protected function setUp(): void {
		parent::setUp();
		$this->setService( 'RevisionLookup', $this->createMock( RevisionLookup::class ) );
	}

	/**
	 * @covers \SearchResultSet::getIterator
	 * @covers \BaseSearchResultSet::next
	 * @covers \BaseSearchResultSet::rewind
	 */
	public function testIterate() {
		$title = Title::makeTitle( NS_MAIN, __METHOD__ );
		$result = SearchResult::newFromTitle( $title );
		$resultSet = new MockSearchResultSet( [ $result ] );
		$this->assertSame( 1, $resultSet->numRows() );
		$count = 0;
		foreach ( $resultSet as $iterResult ) {
			$this->assertEquals( $result, $iterResult );
			$count++;
		}
		$this->assertSame( 1, $count );

		$this->hideDeprecated( 'BaseSearchResultSet::rewind' );
		$this->hideDeprecated( 'BaseSearchResultSet::next' );
		$resultSet->rewind();
		$count = 0;
		foreach ( $resultSet as $iterResult ) {
			$this->assertEquals( $result, $iterResult );
			$count++;
		}
		$this->assertSame( 1, $count );
	}

	/**
	 * @covers \SearchResultSetTrait::augmentResult
	 * @covers \SearchResultSetTrait::setAugmentedData
	 */
	public function testDelayedResultAugment() {
		$title = Title::makeTitle( NS_MAIN, __METHOD__ );
		$title->resetArticleID( 42 );
		$result = SearchResult::newFromTitle( $title );
		$resultSet = new MockSearchResultSet( [ $result ] );
		$resultSet->augmentResult( $result );
		$this->assertEquals( [], $result->getExtensionData() );
		$resultSet->setAugmentedData( 'foo', [
			$result->getTitle()->getArticleID() => 'bar'
		] );
		$this->assertEquals( [ 'foo' => 'bar' ], $result->getExtensionData() );
	}

	/**
	 * @covers \SearchResultSet::shrink
	 * @covers \SearchResultSet::count
	 * @covers \SearchResultSet::hasMoreResults
	 */
	public function testHasMoreResults() {
		$title = Title::makeTitle( NS_MAIN, __METHOD__ );
		$result = SearchResult::newFromTitle( $title );
		$resultSet = new MockSearchResultSet( array_fill( 0, 3, $result ) );
		$this->assertCount( 3, $resultSet );
		$this->assertFalse( $resultSet->hasMoreResults() );
		$resultSet->shrink( 3 );
		$this->assertFalse( $resultSet->hasMoreResults() );
		$resultSet->shrink( 2 );
		$this->assertTrue( $resultSet->hasMoreResults() );
	}

	/**
	 * @covers \SearchResultSet::shrink
	 */
	public function testShrink() {
		$title = Title::makeTitle( NS_MAIN, __METHOD__ );
		$results = array_fill( 0, 3, SearchResult::newFromTitle( $title ) );
		$resultSet = new MockSearchResultSet( $results );
		$this->assertCount( 3, $resultSet->extractResults() );
		$this->assertCount( 3, $resultSet->extractTitles() );
		$resultSet->shrink( 1 );
		$this->assertCount( 1, $resultSet->extractResults() );
		$this->assertCount( 1, $resultSet->extractTitles() );
	}
}
PK       ! 9`w    '  search/SearchNearMatchResultSetTest.phpnu Iw        <?php

use MediaWiki\Title\Title;

class SearchNearMatchResultSetTest extends PHPUnit\Framework\TestCase {
	/**
	 * @covers \SearchNearMatchResultSet::__construct
	 * @covers \SearchNearMatchResultSet::numRows
	 */
	public function testNumRows() {
		$resultSet = new SearchNearMatchResultSet( null );
		$this->assertSame( 0, $resultSet->numRows() );

		$resultSet = new SearchNearMatchResultSet( Title::newMainPage() );
		$this->assertSame( 1, $resultSet->numRows() );
	}
}
PK       ! \%  %  !  search/SearchEnginePrefixTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;

/**
 * @group Search
 * @group Database
 */
class SearchEnginePrefixTest extends MediaWikiLangTestCase {
	/**
	 * @var SearchEngine
	 */
	private $search;

	public function addDBDataOnce() {
		if ( !$this->isWikitextNS( NS_MAIN ) ) {
			// tests are skipped if NS_MAIN is not wikitext
			return;
		}

		$this->insertPage( 'Sandbox' );
		$this->insertPage( 'Bar' );
		$this->insertPage( 'Example' );
		$this->insertPage( 'Example Bar' );
		$this->insertPage( 'Example Foo' );
		$this->insertPage( 'Example Foo/Bar' );
		$this->insertPage( 'Example/Baz' );
		$this->insertPage( 'Sample' );
		$this->insertPage( 'Sample Ban' );
		$this->insertPage( 'Sample Eat' );
		$this->insertPage( 'Sample Who' );
		$this->insertPage( 'Sample Zoo' );
		$this->insertPage( 'Redirect test', '#REDIRECT [[Redirect Test]]' );
		$this->insertPage( 'Redirect Test' );
		$this->insertPage( 'Redirect Test Worse Result' );
		$this->insertPage( 'Redirect test2', '#REDIRECT [[Redirect Test2]]' );
		$this->insertPage( 'Redirect TEST2', '#REDIRECT [[Redirect Test2]]' );
		$this->insertPage( 'Redirect Test2' );
		$this->insertPage( 'Redirect Test2 Worse Result' );

		$this->insertPage( 'Talk:Sandbox' );
		$this->insertPage( 'Talk:Example' );

		$this->insertPage( 'User:Example' );
		$this->insertPage( 'Barcelona' );
		$this->insertPage( 'Barbara' );
		$this->insertPage( 'External' );
	}

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

		if ( !$this->isWikitextNS( NS_MAIN ) ) {
			$this->markTestSkipped( 'Main namespace does not support wikitext.' );
		}

		// Avoid special pages from extensions interferring with the tests
		$this->overrideConfigValues( [
			MainConfigNames::SpecialPages => [],
			MainConfigNames::Hooks => [],
		] );

		$this->search = $this->getServiceContainer()->newSearchEngine();
		$this->search->setNamespaces( [] );
	}

	protected function searchProvision( ?array $results = null ) {
		if ( $results === null ) {
			$this->overrideConfigValue( MainConfigNames::Hooks, [] );
		} else {
			$this->setTemporaryHook(
				'PrefixSearchBackend',
				static function ( $namespaces, $search, $limit, &$srchres ) use ( $results ) {
					$srchres = $results;
					return false;
				}
			);
		}
	}

	public static function provideSearch() {
		return [
			[ [
				'Empty string',
				'query' => '',
				'results' => [],
			] ],
			[ [
				'All invalid characters, effectively empty',
				'query' => '[',
				'results' => [],
			] ],
			[ [
				'Main namespace with title prefix',
				'query' => 'Sa',
				'results' => [
					'Sample',
					'Sample Ban',
					'Sample Eat',
				],
				// Third result when testing offset
				'offsetresult' => [
					'Sample Who',
				],
			] ],
			[ [
				'Some invalid characters',
				'query' => '[[Sa]]',
				'results' => [
					'Sample',
					'Sample Ban',
					'Sample Eat',
				],
				'offsetresult' => [ 'Sample Who' ],
			] ],
			[ [
				'Talk namespace prefix',
				'query' => 'Talk:',
				'results' => [
					'Talk:Example',
					'Talk:Sandbox',
				],
			] ],
			[ [
				'User namespace prefix',
				'query' => 'User:',
				'results' => [
					'User:Example',
				],
			] ],
			[ [
				'Special namespace prefix',
				'query' => 'Special:',
				'results' => [
					'Special:ActiveUsers',
					'Special:AllMessages',
					'Special:AllPages',
				],
				// Third result when testing offset
				'offsetresult' => [
					'Special:AncientPages',
				],
			] ],
			[ [
				'Special namespace with prefix',
				'query' => 'Special:Un',
				'results' => [
					'Special:Unblock',
					'Special:UncategorizedCategories',
					'Special:UncategorizedFiles',
				],
				// Third result when testing offset
				'offsetresult' => [
					'Special:UncategorizedPages',
				],
			] ],
			[ [
				'Special page name',
				'query' => 'Special:EditWatchlist',
				'results' => [
				],
			] ],
			[ [
				'Special page subpages',
				'query' => 'Special:EditWatchlist/',
				'results' => [
					'Special:EditWatchlist/clear',
					'Special:EditWatchlist/raw',
				],
			] ],
			[ [
				'Special page subpages with prefix',
				'query' => 'Special:EditWatchlist/cl',
				'results' => [
					'Special:EditWatchlist/clear',
				],
			] ],
		];
	}

	/**
	 * @dataProvider provideSearch
	 * @covers \SearchEngine::defaultPrefixSearch
	 */
	public function testSearch( array $case ) {
		$this->search->setLimitOffset( 3 );
		$results = $this->search->defaultPrefixSearch( $case['query'] );
		$results = array_map( static function ( Title $t ) {
			return $t->getPrefixedText();
		}, $results );

		$this->assertEquals(
			$case['results'],
			$results,
			$case[0]
		);
	}

	/**
	 * @dataProvider provideSearch
	 * @covers \SearchEngine::defaultPrefixSearch
	 */
	public function testSearchWithOffset( array $case ) {
		$this->search->setLimitOffset( 3, 1 );
		$results = $this->search->defaultPrefixSearch( $case['query'] );
		$results = array_map( static function ( Title $t ) {
			return $t->getPrefixedText();
		}, $results );

		// We don't expect the first result when offsetting
		array_shift( $case['results'] );
		// And sometimes we expect a different last result
		$expected = isset( $case['offsetresult'] ) ?
			array_merge( $case['results'], $case['offsetresult'] ) :
			$case['results'];

		$this->assertEquals(
			$expected,
			$results,
			$case[0]
		);
	}

	public static function provideSearchBackend() {
		return [
			[ [
				'Simple case',
				'provision' => [
					'Bar',
					'Barcelona',
					'Barbara',
				],
				'query' => 'Bar',
				'results' => [
					'Bar',
					'Barcelona',
					'Barbara',
				],
			] ],
			[ [
				'Exact match not in first result should be moved to the first result (T72958)',
				'provision' => [
					'Barcelona',
					'Bar',
					'Barbara',
				],
				'query' => 'Bar',
				'results' => [
					'Bar',
					'Barcelona',
					'Barbara',
				],
			] ],
			[ [
				'Exact match missing from results should be added as first result (T72958)',
				'provision' => [
					'Barcelona',
					'Barbara',
					'Bart',
				],
				'query' => 'Bar',
				'results' => [
					'Bar',
					'Barcelona',
					'Barbara',
				],
			] ],
			[ [
				'Exact match missing and not existing pages should be dropped',
				'provision' => [
					'Exile',
					'Exist',
					'External',
				],
				'query' => 'Ex',
				'results' => [
					'External',
				],
			] ],
			[ [
				"Exact match shouldn't override already found match if " .
					"exact is redirect and found isn't",
				'provision' => [
					// Target of the exact match is low in the list
					'Redirect Test Worse Result',
					'Redirect Test',
				],
				'query' => 'redirect test',
				'results' => [
					// Redirect target is pulled up and exact match isn't added
					'Redirect Test',
					'Redirect Test Worse Result',
				],
			] ],
			[ [
				"Exact match should override already found match if " .
					"both exact match and found match are redirect",
				'provision' => [
					// Another redirect to the same target as the exact match
					// is low in the list
					'Redirect Test2 Worse Result',
					'Redirect test2',
				],
				'query' => 'redirect TEST2',
				'results' => [
					// Found redirect is pulled to the top and exact match isn't
					// added
					'Redirect TEST2',
					'Redirect Test2 Worse Result',
				],
			] ],
			[ [
				"Exact match should override any already found matches that " .
					"are redirects to it",
				'provision' => [
					// Another redirect to the same target as the exact match
					// is low in the list
					'Redirect Test Worse Result',
					'Redirect test',
				],
				'query' => 'Redirect Test',
				'results' => [
					// Found redirect is pulled to the top and exact match isn't
					// added
					'Redirect Test',
					'Redirect Test Worse Result',
					'Redirect test',
				],
			] ],
			[ [
				"Extra results must not be returned",
				'provision' => [
					'Example',
					'Example Bar',
					'Example Foo',
					'Example Foo/Bar'
				],
				'query' => 'foo',
				'results' => [
					'Example',
					'Example Bar',
					'Example Foo',
				],
			] ],
		];
	}

	/**
	 * @dataProvider provideSearchBackend
	 * @covers \PrefixSearch::searchBackend
	 */
	public function testSearchBackend( array $case ) {
		$search = $this->mockSearchWithResults( $case['provision'] );
		$results = $search->completionSearch( $case['query'] );

		$results = $results->map( static function ( SearchSuggestion $s ) {
			return $s->getText();
		} );

		$this->assertEquals(
			$case['results'],
			$results,
			$case[0]
		);
	}

	public static function paginationProvider() {
		$res = [ 'Example', 'Example Bar', 'Example Foo', 'Example Foo/Bar' ];
		return [
			'With less than requested results no pagination' => [
				false, array_slice( $res, 0, 2 ),
			],
			'With same as requested results no pagination' => [
				false, array_slice( $res, 0, 3 ),
			],
			'With extra result returned offer pagination' => [
				true, $res,
			],
		];
	}

	/**
	 * @dataProvider paginationProvider
	 * @covers \SearchSuggestionSet::hasMoreResults
	 */
	public function testPagination( $hasMoreResults, $provision ) {
		$search = $this->mockSearchWithResults( $provision );
		$results = $search->completionSearch( 'irrelevant' );

		$this->assertEquals( $hasMoreResults, $results->hasMoreResults() );
	}

	private function mockSearchWithResults( $titleStrings, $limit = 3 ) {
		$search = $this->getMockBuilder( SearchEngine::class )
			->onlyMethods( [ 'completionSearchBackend' ] )->getMock();

		$return = SearchSuggestionSet::fromStrings( $titleStrings );

		$search->method( 'completionSearchBackend' )
			->willReturn( $return );

		$search->setLimitOffset( $limit );
		return $search;
	}
}
PK       ! 8!  8!    search/PrefixSearchTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;

/**
 * @group Search
 * @group Database
 * @covers \PrefixSearch
 */
class PrefixSearchTest extends MediaWikiLangTestCase {
	private const NS_NONCAP = 12346;

	public function addDBDataOnce() {
		if ( !$this->isWikitextNS( NS_MAIN ) ) {
			// tests are skipped if NS_MAIN is not wikitext
			return;
		}

		$this->insertPage( 'Sandbox' );
		$this->insertPage( 'Bar' );
		$this->insertPage( 'Example' );
		$this->insertPage( 'Example Bar' );
		$this->insertPage( 'Example Foo' );
		$this->insertPage( 'Example Foo/Bar' );
		$this->insertPage( 'Example/Baz' );
		$this->insertPage( 'Redirect test', '#REDIRECT [[Redirect Test]]' );
		$this->insertPage( 'Redirect Test' );
		$this->insertPage( 'Redirect Test Worse Result' );
		$this->insertPage( 'Redirect test2', '#REDIRECT [[Redirect Test2]]' );
		$this->insertPage( 'Redirect TEST2', '#REDIRECT [[Redirect Test2]]' );
		$this->insertPage( 'Redirect Test2' );
		$this->insertPage( 'Redirect Test2 Worse Result' );

		$this->insertPage( 'Talk:Sandbox' );
		$this->insertPage( 'Talk:Example' );

		$this->insertPage( 'User:Example' );

		$this->overrideConfigValues( [
			MainConfigNames::ExtraNamespaces => [ self::NS_NONCAP => 'NonCap' ],
			MainConfigNames::CapitalLinkOverrides => [ self::NS_NONCAP => false ],
		] );

		$this->insertPage( Title::makeTitle( self::NS_NONCAP, 'Bar' ) );
		$this->insertPage( Title::makeTitle( self::NS_NONCAP, 'Upper' ) );
		$this->insertPage( Title::makeTitle( self::NS_NONCAP, 'sandbox' ) );
	}

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

		if ( !$this->isWikitextNS( NS_MAIN ) ) {
			$this->markTestSkipped( 'Main namespace does not support wikitext.' );
		}

		// Avoid special pages from extensions interfering with the tests
		$this->overrideConfigValues( [
			MainConfigNames::SpecialPages => [],
			MainConfigNames::Hooks => [],
			MainConfigNames::ExtraNamespaces => [ self::NS_NONCAP => 'NonCap' ],
			MainConfigNames::CapitalLinkOverrides => [ self::NS_NONCAP => false ],
		] );
	}

	protected function searchProvision( ?array $results = null ) {
		if ( $results === null ) {
			$this->overrideConfigValue( MainConfigNames::Hooks, [] );
		} else {
			$this->setTemporaryHook(
				'PrefixSearchBackend',
				static function ( $namespaces, $search, $limit, &$srchres ) use ( $results ) {
					$srchres = $results;
					return false;
				}
			);
		}
	}

	public static function provideSearch() {
		return [
			[ [
				'Empty string',
				'query' => '',
				'results' => [],
			] ],
			[ [
				'Main namespace with title prefix',
				'query' => 'Ex',
				'results' => [
					'Example',
					'Example/Baz',
					'Example Bar',
				],
				// Third result when testing offset
				'offsetresult' => [
					'Example Foo',
				],
			] ],
			[ [
				'Talk namespace prefix',
				'query' => 'Talk:',
				'results' => [
					'Talk:Example',
					'Talk:Sandbox',
				],
			] ],
			[ [
				'User namespace prefix',
				'query' => 'User:',
				'results' => [
					'User:Example',
				],
			] ],
			[ [
				'Special namespace prefix',
				'query' => 'Special:',
				'results' => [
					'Special:ActiveUsers',
					'Special:AllMessages',
					'Special:AllPages',
				],
				// Third result when testing offset
				'offsetresult' => [
					'Special:AncientPages',
				],
			] ],
			[ [
				'Special namespace with prefix',
				'query' => 'Special:Un',
				'results' => [
					'Special:Unblock',
					'Special:UncategorizedCategories',
					'Special:UncategorizedFiles',
				],
				// Third result when testing offset
				'offsetresult' => [
					'Special:UncategorizedPages',
				],
			] ],
			[ [
				'Special page name',
				'query' => 'Special:EditWatchlist',
				'results' => [],
			] ],
			[ [
				'Special page subpages',
				'query' => 'Special:EditWatchlist/',
				'results' => [
					'Special:EditWatchlist/clear',
					'Special:EditWatchlist/raw',
				],
			] ],
			[ [
				'Special page subpages with prefix',
				'query' => 'Special:EditWatchlist/cl',
				'results' => [
					'Special:EditWatchlist/clear',
				],
			] ],
			[ [
				'Namespace with case sensitive first letter',
				'query' => 'NonCap:upper',
				'results' => []
			] ],
			[ [
				'Multinamespace search',
				'query' => 'B',
				'results' => [
					'Bar',
					'NonCap:Bar',
				],
				'namespaces' => [ NS_MAIN, self::NS_NONCAP ],
			] ],
			[ [
				'Multinamespace search with lowercase first letter',
				'query' => 'sand',
				'results' => [
					'Sandbox',
					'NonCap:sandbox',
				],
				'namespaces' => [ NS_MAIN, self::NS_NONCAP ],
			] ],
		];
	}

	/**
	 * @dataProvider provideSearch
	 * @covers \PrefixSearch::search
	 * @covers \PrefixSearch::searchBackend
	 */
	public function testSearch( array $case ) {
		$this->searchProvision( null );

		$namespaces = $case['namespaces'] ?? [];

		$searcher = new StringPrefixSearch;
		$results = $searcher->search( $case['query'], 3, $namespaces );
		$this->assertEquals(
			$case['results'],
			$results,
			$case[0]
		);
	}

	/**
	 * @dataProvider provideSearch
	 * @covers \PrefixSearch::search
	 * @covers \PrefixSearch::searchBackend
	 */
	public function testSearchWithOffset( array $case ) {
		$this->searchProvision( null );

		$namespaces = $case['namespaces'] ?? [];

		$searcher = new StringPrefixSearch;
		$results = $searcher->search( $case['query'], 3, $namespaces, 1 );

		// We don't expect the first result when offsetting
		array_shift( $case['results'] );
		// And sometimes we expect a different last result
		$expected = isset( $case['offsetresult'] ) ?
			array_merge( $case['results'], $case['offsetresult'] ) :
			$case['results'];

		$this->assertEquals(
			$expected,
			$results,
			$case[0]
		);
	}

	public static function provideSearchBackend() {
		return [
			[ [
				'Simple case',
				'provision' => [
					'Bar',
					'Barcelona',
					'Barbara',
				],
				'query' => 'Bar',
				'results' => [
					'Bar',
					'Barcelona',
					'Barbara',
				],
			] ],
			[ [
				'Exact match not on top (T72958)',
				'provision' => [
					'Barcelona',
					'Bar',
					'Barbara',
				],
				'query' => 'Bar',
				'results' => [
					'Bar',
					'Barcelona',
					'Barbara',
				],
			] ],
			[ [
				'Exact match missing (T72958)',
				'provision' => [
					'Barcelona',
					'Barbara',
					'Bart',
				],
				'query' => 'Bar',
				'results' => [
					'Bar',
					'Barcelona',
					'Barbara',
				],
			] ],
			[ [
				'Exact match missing and not existing',
				'provision' => [
					'Exile',
					'Exist',
					'External',
				],
				'query' => 'Ex',
				'results' => [
					'Exile',
					'Exist',
					'External',
				],
			] ],
			[ [
				"Exact match shouldn't override already found match if " .
					"exact is redirect and found isn't",
				'provision' => [
					// Target of the exact match is low in the list
					'Redirect Test Worse Result',
					'Redirect Test',
				],
				'query' => 'redirect test',
				'results' => [
					// Redirect target is pulled up and exact match isn't added
					'Redirect Test',
					'Redirect Test Worse Result',
				],
			] ],
			[ [
				"Exact match should override already found match if " .
					"both exact match and found match are redirect",
				'provision' => [
					// Another redirect to the same target as the exact match
					// is low in the list
					'Redirect Test2 Worse Result',
					'Redirect test2',
				],
				'query' => 'redirect TEST2',
				'results' => [
					// Found redirect is pulled to the top and exact match isn't
					// added
					'Redirect TEST2',
					'Redirect Test2 Worse Result',
				],
			] ],
			[ [
				"Exact match should override any already found matches that " .
					"are redirects to it",
				'provision' => [
					// Another redirect to the same target as the exact match
					// is low in the list
					'Redirect Test Worse Result',
					'Redirect test',
				],
				'query' => 'Redirect Test',
				'results' => [
					// Found redirect is pulled to the top and exact match isn't
					// added
					'Redirect Test',
					'Redirect Test Worse Result',
				],
			] ],
		];
	}

	/**
	 * @dataProvider provideSearchBackend
	 * @covers \PrefixSearch::searchBackend
	 */
	public function testSearchBackend( array $case ) {
		$this->filterDeprecated( '/Use of PrefixSearchBackend hook/' );
		$this->searchProvision( $case['provision'] );
		$searcher = new StringPrefixSearch;
		$results = $searcher->search( $case['query'], 3 );
		$this->assertEquals(
			$case['results'],
			$results,
			$case[0]
		);
	}
}
PK       ! Mq    ,  search/SearchResultThumbnailProviderTest.phpnu Iw        <?php

use MediaWiki\Search\SearchResultThumbnailProvider;
use MediaWiki\Tests\Rest\Handler\MediaTestTrait;
use MediaWiki\Title\Title;

class SearchResultThumbnailProviderTest extends MediaWikiIntegrationTestCase {
	use MediaTestTrait;

	/**
	 * List of titles to create.
	 */
	protected const TITLES = [
		'file' => [
			'id' => 1,
			'text' => 'File_1.jpg',
			'namespace' => NS_FILE,
		],
		'article_with_thumb' => [
			'id' => 2,
			'text' => 'Title_2',
			'namespace' => NS_MAIN,
		],
		'article_without_thumb' => [
			'id' => 3,
			'text' => 'Title_3',
			'namespace' => NS_MAIN,
		],
	];

	/**
	 * Map of page id to thumbnail page id, both of which are expected to be present in self::TITLES.
	 * This map will be used to build a mock response for the SearchResultProvideThumbnail hook.
	 */
	protected const HOOK_PROVIDED_THUMBNAILS_BY_ID = [
		self::TITLES['article_with_thumb']['id'] => self::TITLES['file']['id'],
	];

	/** @var SearchResultThumbnailProvider */
	private $thumbnailProvider;

	/** @var Title[] */
	private $titles = [];

	public static function articleThumbnailsProvider(): array {
		return [
			// assert that NS_FILE pages provide their own file
			[
				// page ids
				[ self::TITLES['file']['id'] ],
				// thumbnail ids
				[ self::TITLES['file']['id'] ],
				// size
				500
			],
			// assert that hook provides thumbnails for non-file pages
			[
				[ self::TITLES['article_with_thumb']['id'] ],
				[ self::TITLES['file']['id'] ],
				500
			],
			// assert thumbnail is missing when not NS_FILE & hook doesn't provide
			[
				[ self::TITLES['article_without_thumb']['id'] ],
				[],
				500
			],
			// assert that size is optional and defaults to something functional
			[
				[ self::TITLES['file']['id'] ],
				[ self::TITLES['file']['id'] ],
				null
			]
		];
	}

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

		// build mock titles based on descriptions in self::TITLES
		$this->titles = [];
		foreach ( self::TITLES as $data ) {
			$this->titles[$data['id']] = $this->makeMockTitle(
				$data['text'],
				[ 'id' => $data['id'], 'namespace' => $data['namespace'] ]
			);
		}

		// compile a mock repo with all NS_FILE pages known in self::TITLES
		$thumbnails = array_map(
			static fn ( Title $title ) => $title->getDBkey(),
			array_filter(
				$this->titles,
				static fn ( Title $title ) => $title->inNamespace( NS_FILE )
			)
		);
		$mockRepoGroup = $this->makeMockRepoGroup( $thumbnails );

		// create a hook that provides all thumbnails defined in HOOK_PROVIDED_THUMBNAILS_BY_ID
		$hookContainer = $this->createHookContainer( [
			'SearchResultProvideThumbnail' => function ( $pageIdentities, &$results, $size ) use ( $mockRepoGroup ) {
				foreach ( self::HOOK_PROVIDED_THUMBNAILS_BY_ID as $pageId => $thumbnailPageId ) {
					if ( !isset( $pageIdentities[$pageId] ) ) {
						// skip this thumbnail, it was not requested
						continue;
					}
					$articleTitle = $this->titles[$pageId];
					if ( !$articleTitle ) {
						throw new InvalidArgumentException(
							'self::HOOK_PROVIDED_THUMBNAILS_BY_ID key references a page missing from in self::TITLES'
						);
					}
					$thumbnailTitle = $this->titles[$thumbnailPageId];
					if ( !$thumbnailTitle ) {
						throw new InvalidArgumentException(
							'self::HOOK_PROVIDED_THUMBNAILS_BY_ID value references a page missing from in self::TITLES'
						);
					}
					$results[$pageId] = $this->thumbnailProvider->buildSearchResultThumbnailFromFile(
						$mockRepoGroup->findFile( $thumbnailTitle ),
						$size
					);
				}
			},
		] );
		$this->thumbnailProvider = new SearchResultThumbnailProvider( $mockRepoGroup, $hookContainer );
	}

	/**
	 * @dataProvider articleThumbnailsProvider
	 * @covers \MediaWiki\Search\SearchResultThumbnailProvider::getThumbnails
	 * @covers \MediaWiki\Search\SearchResultThumbnailProvider::buildSearchResultThumbnailFromFile
	 * @param int[] $pageIds
	 * @param int[] $thumbnailIds
	 * @param int|null $size
	 */
	public function testGetThumbnails( array $pageIds, array $thumbnailIds, ?int $size = null ) {
		$pageIdentities = array_intersect_key( $this->titles, array_fill_keys( $pageIds, null ) );

		$thumbnails = $this->thumbnailProvider->getThumbnails( $pageIdentities, $size );

		// confirm that titles for which there is no thumbnail are missing
		$missingThumbnails = array_diff_key( $pageIdentities, $thumbnails );
		foreach ( $missingThumbnails as $pageId => $pageIdentity ) {
			$this->assertArrayNotHasKey( $pageId, $thumbnails );
		}

		foreach ( $thumbnails as $pageId => $thumbnail ) {
			// confirm thumbnail matches what we expect
			$expectedName = $this->titles[$pageId]->inNamespace( NS_FILE )
				? $this->titles[$pageId]->getDBkey()
				: $this->titles[self::HOOK_PROVIDED_THUMBNAILS_BY_ID[$pageId]]->getDBkey();
			$this->assertEquals( $expectedName, $thumbnail->getName() );

			// confirm thumbnail dimensions
			$expectedSize = $size ?? SearchResultThumbnailProvider::THUMBNAIL_SIZE;
			$this->assertLessThanOrEqual( $expectedSize, $thumbnail->getWidth() );
		}
	}
}
PK       ! b2  2    search/TitleMatcherTest.phpnu Iw        <?php

use MediaWiki\Config\HashConfig;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\Search\TitleMatcher;
use MediaWiki\Title\Title;

/**
 * @covers \MediaWiki\Search\TitleMatcher
 * @group Database
 */
class TitleMatcherTest extends MediaWikiIntegrationTestCase {
	use LinkCacheTestTrait;

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

		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, false );
	}

	public static function nearMatchProvider() {
		return [
			'empty request returns nothing' => [ null, 'en', '', 'Near Match Test' ],
			'with a hash returns nothing' => [ null, 'en', '#near match test', 'Near Match Test' ],
			'wrong seach string returns nothing' => [
				null, 'en', ':', 'Near Match Test'
			],
			'default behaviour exact' => [
				'Near Match Test', 'en', 'Near Match Test', 'Near Match Test'
			],
			'default behaviour uppercased' => [
				'NEAR MATCH TEST', 'en', 'near match test', 'NEAR MATCH TEST'
			],
			'default behaviour first capitalized' => [
				'Near match test', 'en', 'near match test', 'Near match test'
			],
			'default behaviour capitalized' => [
				'Near Match Test', 'en', 'near match test', 'Near Match Test'
			],
			'default behaviour lowercased' => [
				'Near match test', 'en', 'NEAR MATCH TEST', 'Near match test'
			],
			'default behaviour hyphenated' => [
				'Near-Match-Test', 'en', 'near-match-test', 'Near-Match-Test'
			],
			'default behaviour quoted' => [
				'Near Match Test', 'en', '"Near Match Test"', 'Near Match Test'
			],
			'check language with variants direct' => [ 'Near', 'tg', 'near', 'Near' ],
			'check language with variants converted' => [ 'Near', 'tg', 'неар', 'Near' ],
			'no matching' => [ null, 'en', 'near match test', 'Far Match Test' ],
			// Special cases: files
			'file ok' => [ 'File:Example.svg', 'en', 'File:Example.svg', 'File:Example.svg' ],
			'file not ok' => [ null, 'en', 'File:Example_s.svg', 'File:Example.svg' ],
			// Special cases: users
			'user ok' => [ 'User:Superuser', 'en', 'User:Superuser', 'User:Superuser' ],
			'user ok even if no user' => [
				'User:SuperuserNew', 'en', 'User:SuperuserNew', 'User:Superuser'
			],
			'user search use by IP' => [
				'Special:Contributions/132.17.48.1', 'en', 'User:132.17.48.1', 'User:Superuser', true,
			],
			// Special cases: other content types
			'mediawiki ok even if no page' => [
				'MediaWiki:Add New Page', 'en', 'MediaWiki:Add New Page', 'MediaWiki:Add Old Page'
			],
			'Media ok' => [
				'File:Text', 'en', 'Media:Text', 'File:Text', true,
			],
			'Media not ok' => [
				null, 'en', 'Media:Text', 'Media:Text', true,
			],
		];
	}

	/**
	 * @param HashConfig $config
	 * @param string $langCode
	 *
	 * @return TitleMatcher
	 */
	private function getTitleMatcher( HashConfig $config, $langCode ): TitleMatcher {
		$services = $this->getServiceContainer();
		return new TitleMatcher(
			new ServiceOptions( TitleMatcher::CONSTRUCTOR_OPTIONS, $config ),
			$services->getLanguageFactory()->getLanguage( $langCode ),
			$services->getLanguageConverterFactory(),
			$services->getHookContainer(),
			$services->getWikiPageFactory(),
			$services->getUserNameUtils(),
			$services->getRepoGroup(),
			$services->getTitleFactory()
		);
	}

	/**
	 * @dataProvider nearMatchProvider
	 * @covers \MediaWiki\Search\TitleMatcher::getNearMatchInternal
	 * @covers \MediaWiki\Search\TitleMatcher::getNearMatch
	 */
	public function testNearMatch(
		$expected,
		$langCode,
		$searchterm,
		$titleText,
		$enableSearchContributorsByIP = false
	) {
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'en' );
		$this->addGoodLinkObject( 42, Title::newFromText( $titleText ) );

		$config = new HashConfig( [
			MainConfigNames::EnableSearchContributorsByIP => $enableSearchContributorsByIP,
		] );

		$matcher = $this->getTitleMatcher( $config, $langCode );
		$title = $matcher->getNearMatch( $searchterm );
		$this->assertEquals( $expected, $title === null ? null : (string)$title );
	}

	public static function hooksProvider() {
		return [
			'SearchGetNearMatchBefore' => [ 'SearchGetNearMatchBefore' ],
			'SearchAfterNoDirectMatch' => [ 'SearchAfterNoDirectMatch' ],
			'SearchGetNearMatch' => [ 'SearchGetNearMatch' ]
		];
	}

	/**
	 * @dataProvider hooksProvider
	 * @covers \MediaWiki\Search\TitleMatcher::getNearMatchInternal
	 * @covers \MediaWiki\Search\TitleMatcher::getNearMatch
	 */
	public function testNearMatch_Hooks( $hook ) {
		$config = new HashConfig( [
			MainConfigNames::EnableSearchContributorsByIP => false,
		] );

		$this->setTemporaryHook( $hook, static function ( $term, &$title ) {
			if ( $term === [ 'Hook' ] || $term === 'Hook' ) {
				$title = Title::makeTitle( NS_MAIN, 'TitleFromHook' );
				return false;
			}
			return null;
		} );

		$matcher = $this->getTitleMatcher( $config, 'en' );
		$title = $matcher->getNearMatch( 'Hook' );
		$this->assertEquals( 'TitleFromHook', $title );

		$this->assertNull( $matcher->getNearMatch( 'OtherHook' ) );
	}

	/**
	 * @covers \MediaWiki\Search\TitleMatcher::getNearMatchResultSet
	 */
	public function testGetNearMatchResultSet() {
		$this->addGoodLinkObject( 42, Title::makeTitle( NS_MAIN, "Test Link" ) );

		$config = new HashConfig( [
			MainConfigNames::EnableSearchContributorsByIP => false,
		] );

		$matcher = $this->getTitleMatcher( $config, 'en' );
		$result = $matcher->getNearMatchResultSet( 'Test Link' );
		$this->assertSame( 1, $result->numRows() );

		$result = $matcher->getNearMatchResultSet( 'Test Link Wrong' );
		$this->assertSame( 0, $result->numRows() );
	}

	protected function tearDown(): void {
		Title::clearCaches();
		parent::tearDown();
	}
}
PK       ! F&       search/SearchResultTraitTest.phpnu Iw        <?php

class SearchResultTraitTest extends MediaWikiIntegrationTestCase {
	/**
	 * @covers \SearchResultTrait::getExtensionData
	 * @covers \SearchResultTrait::setExtensionData
	 */
	public function testExtensionData() {
		$result = new class() {
			use SearchResultTrait;
		};
		$this->assertEquals( [], $result->getExtensionData(), 'starts empty' );

		$data = [ 'hello' => 'world' ];
		$result->setExtensionData( static function () use ( &$data ) {
			return $data;
		} );
		$this->assertEquals( $data, $result->getExtensionData(), 'can set extension data' );
		$data['this'] = 'that';
		$this->assertEquals( $data, $result->getExtensionData(), 'refetches from callback' );
	}

	/**
	 * @covers \SearchResultTrait::getExtensionData
	 * @covers \SearchResultTrait::setExtensionData
	 */
	public function testExtensionDataArrayBC() {
		$result = new class() {
			use SearchResultTrait;
		};
		$data = [ 'hello' => 'world' ];
		$this->hideDeprecated( 'SearchResultTrait::setExtensionData with array argument' );
		$this->assertEquals( [], $result->getExtensionData(), 'starts empty' );
		$result->setExtensionData( $data );
		$this->assertEquals( $data, $result->getExtensionData(), 'can set extension data' );
		$data['this'] = 'that';
		$this->assertNotEquals( $data, $result->getExtensionData(), 'shouldnt hold any reference' );

		$result->setExtensionData( $data );
		$this->assertEquals( $data, $result->getExtensionData(), 'can replace extension data' );
	}
}
PK       ! H@  @    search/SearchEngineTest.phpnu Iw        <?php

use MediaWiki\Content\WikitextContent;
use MediaWiki\MainConfigNames;

/**
 * @group Search
 * @group Database
 *
 * @covers \SearchEngine<extended>
 * @note Coverage will only ever show one of on of the Search* classes
 */
class SearchEngineTest extends MediaWikiLangTestCase {

	/**
	 * @var SearchEngine
	 */
	protected $search;

	/**
	 * Checks for database type & version.
	 * Will skip current test if DB does not support search.
	 */
	protected function setUp(): void {
		parent::setUp();

		// Search tests require MySQL or SQLite with FTS
		$dbType = $this->getDb()->getType();
		$dbSupported = ( $dbType === 'mysql' )
			|| ( $dbType === 'sqlite' && $this->getDb()->getFulltextSearchModule() == 'FTS3' );

		if ( !$dbSupported ) {
			$this->markTestSkipped( "MySQL or SQLite with FTS3 only" );
		}
		$dbProvider = $this->getServiceContainer()->getConnectionProvider();

		$searchType = SearchEngineFactory::getSearchEngineClass( $dbProvider );
		$this->overrideConfigValues( [
			MainConfigNames::SearchType => $searchType,
			MainConfigNames::CapitalLinks => true,
			MainConfigNames::CapitalLinkOverrides => [
				NS_CATEGORY => false // for testCompletionSearchMustRespectCapitalLinkOverrides
			],
		] );

		$this->search = new $searchType( $dbProvider );
		$this->search->setHookContainer( $this->getServiceContainer()->getHookContainer() );
	}

	protected function tearDown(): void {
		unset( $this->search );

		parent::tearDown();
	}

	public function addDBDataOnce() {
		if ( !$this->isWikitextNS( NS_MAIN ) ) {
			// @todo cover the case of non-wikitext content in the main namespace
			return;
		}

		// Reset the search type back to default - some extensions may have
		// overridden it.
		$this->overrideConfigValues( [
			MainConfigNames::SearchType => null,
			MainConfigNames::CapitalLinks => true,
			MainConfigNames::CapitalLinkOverrides => [
				NS_CATEGORY => false // for testCompletionSearchMustRespectCapitalLinkOverrides
			],
		] );

		$this->insertPage( 'Not_Main_Page', 'This is not a main page' );
		$this->insertPage(
			'Talk:Not_Main_Page',
			'This is not a talk page to the main page, see [[smithee]]'
		);
		$this->insertPage( 'Smithee', 'A smithee is one who smiths. See also [[Alan Smithee]]' );
		$this->insertPage( 'Talk:Smithee', 'This article sucks.' );
		$this->insertPage( 'Unrelated_page', 'Nothing in this page is about the S word.' );
		$this->insertPage( 'Another_page', 'This page also is unrelated.' );
		$this->insertPage( 'Help:Help', 'Help me!' );
		$this->insertPage( 'Thppt', 'Blah blah' );
		$this->insertPage( 'Alan_Smithee', 'yum' );
		$this->insertPage( 'Pages', 'are\'food' );
		$this->insertPage( 'HalfOneUp', 'AZ' );
		$this->insertPage( 'FullOneUp', 'ＡＺ' );
		$this->insertPage( 'HalfTwoLow', 'az' );
		$this->insertPage( 'FullTwoLow', 'ａｚ' );
		$this->insertPage( 'HalfNumbers', '1234567890' );
		$this->insertPage( 'FullNumbers', '１２３４５６７８９０' );
		$this->insertPage( 'DomainName', 'example.com' );
		$this->insertPage( 'DomainName', 'example.com' );
		$this->insertPage( 'Category:search is not Search', '' );
		$this->insertPage( 'Category:Search is not search', '' );
		$this->insertPage( 'Talk:1', 'Did you know titles can be numbers?' );
	}

	protected function fetchIds( $results ) {
		if ( !$this->isWikitextNS( NS_MAIN ) ) {
			$this->markTestIncomplete( __CLASS__ . " does no yet support non-wikitext content "
				. "in the main namespace" );
		}
		$this->assertIsObject( $results );

		$matches = [];
		foreach ( $results as $row ) {
			$matches[] = $row->getTitle()->getPrefixedText();
		}
		$results->free();
		# Search is not guaranteed to return results in a certain order;
		# sort them numerically so we will compare simply that we received
		# the expected matches.
		sort( $matches );

		return $matches;
	}

	public function testFullWidth() {
		// T303046
		$this->markTestSkippedIfDbType( 'sqlite' );
		$this->assertEquals(
			[ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
			$this->fetchIds( $this->search->searchText( 'AZ' ) ),
			"Search for normalized from Half-width Upper" );
		$this->assertEquals(
			[ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
			$this->fetchIds( $this->search->searchText( 'az' ) ),
			"Search for normalized from Half-width Lower" );
		$this->assertEquals(
			[ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
			$this->fetchIds( $this->search->searchText( 'ＡＺ' ) ),
			"Search for normalized from Full-width Upper" );
		$this->assertEquals(
			[ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
			$this->fetchIds( $this->search->searchText( 'ａｚ' ) ),
			"Search for normalized from Full-width Lower" );
	}

	public function testTextSearch() {
		// T303046
		$this->markTestSkippedIfDbType( 'sqlite' );
		$this->assertEquals(
			[ 'Smithee' ],
			$this->fetchIds( $this->search->searchText( 'smithee' ) ),
			"Plain search" );
	}

	public function testWildcardSearch() {
		// T303046
		$this->markTestSkippedIfDbType( 'sqlite' );
		$res = $this->search->searchText( 'smith*' );
		$this->assertEquals(
			[ 'Smithee' ],
			$this->fetchIds( $res ),
			"Search with wildcards" );

		$res = $this->search->searchText( 'smithson*' );
		$this->assertEquals(
			[],
			$this->fetchIds( $res ),
			"Search with wildcards must not find unrelated articles" );

		$res = $this->search->searchText( 'smith* smithee' );
		$this->assertEquals(
			[ 'Smithee' ],
			$this->fetchIds( $res ),
			"Search with wildcards can be combined with simple terms" );

		$res = $this->search->searchText( 'smith* "one who smiths"' );
		$this->assertEquals(
			[ 'Smithee' ],
			$this->fetchIds( $res ),
			"Search with wildcards can be combined with phrase search" );
	}

	public function testPhraseSearch() {
		// T303046
		$this->markTestSkippedIfDbType( 'sqlite' );
		$res = $this->search->searchText( '"smithee is one who smiths"' );
		$this->assertEquals(
			[ 'Smithee' ],
			$this->fetchIds( $res ),
			"Search a phrase" );

		$res = $this->search->searchText( '"smithee is who smiths"' );
		$this->assertEquals(
			[],
			$this->fetchIds( $res ),
			"Phrase search is not sloppy, search terms must be adjacent" );

		$res = $this->search->searchText( '"is smithee one who smiths"' );
		$this->assertEquals(
			[],
			$this->fetchIds( $res ),
			"Phrase search is ordered" );
	}

	public function testPhraseSearchHighlight() {
		// T303046
		$this->markTestSkippedIfDbType( 'sqlite' );
		$phrase = "smithee is one who smiths";
		$res = $this->search->searchText( "\"$phrase\"" );
		$match = $res->getIterator()->current();
		$snippet = 'A <span class="searchmatch">' . $phrase . '</span>';
		$this->assertStringStartsWith( $snippet,
			$match->getTextSnippet(),
			"Highlight a phrase search" );
	}

	public function testTextPowerSearch() {
		// T303046
		$this->markTestSkippedIfDbType( 'sqlite' );
		$this->search->setNamespaces( [ 0, 1, 4 ] );
		$this->assertEquals(
			[
				'Smithee',
				'Talk:Not Main Page',
			],
			$this->fetchIds( $this->search->searchText( 'smithee' ) ),
			"Power search" );
	}

	public function testTitleSearch() {
		// T303046
		$this->markTestSkippedIfDbType( 'sqlite' );
		$this->assertEquals(
			[
				'Alan Smithee',
				'Smithee',
			],
			$this->fetchIds( $this->search->searchTitle( 'smithee' ) ),
			"Title search" );
	}

	public function testTextTitlePowerSearch() {
		// T303046
		$this->markTestSkippedIfDbType( 'sqlite' );
		$this->search->setNamespaces( [ 0, 1, 4 ] );
		$this->assertEquals(
			[
				'Alan Smithee',
				'Smithee',
				'Talk:Smithee',
			],
			$this->fetchIds( $this->search->searchTitle( 'smithee' ) ),
			"Title power search" );
	}

	public static function provideCompletionSearchMustRespectCapitalLinkOverrides() {
		return [
			'Searching for "smithee" finds Smithee on NS_MAIN' => [
				'smithee',
				'Smithee',
				[ NS_MAIN ],
			],
			'Searching for "search is" will finds "search is not Search" on NS_CATEGORY' => [
				'search is',
				'Category:search is not Search',
				[ NS_CATEGORY ],
			],
			'Searching for "Search is" will finds "search is not Search" on NS_CATEGORY' => [
				'Search is',
				'Category:Search is not search',
				[ NS_CATEGORY ],
			],
			'Copy-pasted wikilinks with invalid characters will still find the page' => [
				'[[smithee]]',
				'Smithee',
				[ NS_MAIN ],
			],
			'Numeric title works (T365565)' => [
				'1',
				'Talk:1',
				[ NS_TALK ],
			]
		];
	}

	/**
	 * Test that the search query is not munged using wrong CapitalLinks setup
	 * (in other test that the default search backend can benefit from wgCapitalLinksOverride)
	 * Guard against regressions like T208255
	 * @dataProvider provideCompletionSearchMustRespectCapitalLinkOverrides
	 * @covers \SearchEngine::completionSearch
	 * @covers \PrefixSearch::defaultSearchBackend
	 * @param string $search
	 * @param string $expectedSuggestion
	 * @param int[] $namespaces
	 */
	public function testCompletionSearchMustRespectCapitalLinkOverrides(
		$search,
		$expectedSuggestion,
		array $namespaces
	) {
		$this->search->setNamespaces( $namespaces );
		$results = $this->search->completionSearch( $search );
		$this->assertSame( 1, $results->getSize() );
		$this->assertEquals( $expectedSuggestion, $results->getSuggestions()[0]->getText() );
	}

	/**
	 * @covers \SearchEngine::getSearchIndexFields
	 */
	public function testSearchIndexFields() {
		/**
		 * @var SearchEngine $mockEngine
		 */
		$mockEngine = $this->getMockBuilder( SearchEngine::class )
			->onlyMethods( [ 'makeSearchFieldMapping' ] )->getMock();

		$mockFieldBuilder = function ( $name, $type ) {
			$mockField =
				$this->getMockBuilder( SearchIndexFieldDefinition::class )->setConstructorArgs( [
					$name,
					$type,
				] )->getMock();

			$mockField->method( 'getMapping' )->willReturn( [
				'testData' => 'test',
				'name' => $name,
				'type' => $type,
			] );

			$mockField->method( 'merge' )->willReturnSelf();

			return $mockField;
		};

		$mockEngine->expects( $this->atLeastOnce() )
			->method( 'makeSearchFieldMapping' )
			->willReturnCallback( $mockFieldBuilder );

		// Not using mock since PHPUnit mocks do not work properly with references in params
		$this->setTemporaryHook( 'SearchIndexFields',
			static function ( &$fields, SearchEngine $engine ) use ( $mockFieldBuilder ) {
				$fields['testField'] =
					$mockFieldBuilder( "testField", SearchIndexField::INDEX_TYPE_TEXT );
				return true;
			} );
		$mockEngine->setHookContainer( $this->getServiceContainer()->getHookContainer() );

		$fields = $mockEngine->getSearchIndexFields();
		$this->assertArrayHasKey( 'language', $fields );
		$this->assertArrayHasKey( 'category', $fields );
		$this->assertInstanceOf( SearchIndexField::class, $fields['testField'] );

		$mapping = $fields['testField']->getMapping( $mockEngine );
		$this->assertArrayHasKey( 'testData', $mapping );
		$this->assertEquals( 'test', $mapping['testData'] );
	}

	public function hookSearchIndexFields( $mockFieldBuilder, &$fields, SearchEngine $engine ) {
		$fields['testField'] = $mockFieldBuilder( "testField", SearchIndexField::INDEX_TYPE_TEXT );
		return true;
	}

	public function testAugmentorSearch() {
		// T303046
		$this->markTestSkippedIfDbType( 'sqlite' );

		$this->search->setHookContainer(
			$this->createHookContainer( [ 'SearchResultsAugment' => [ $this, 'addAugmentors' ] ] )
		);

		$this->search->setNamespaces( [ 0, 1, 4 ] );
		$resultSet = $this->search->searchText( 'smithee' );
		$this->search->augmentSearchResults( $resultSet );
		foreach ( $resultSet as $result ) {
			$id = $result->getTitle()->getArticleID();
			$augmentData = "Result:$id:" . $result->getTitle()->getText();
			$augmentData2 = "Result2:$id:" . $result->getTitle()->getText();
			$this->assertEquals( [ 'testSet' => $augmentData, 'testRow' => $augmentData2 ],
				$result->getExtensionData() );
		}
	}

	public function addAugmentors( &$setAugmentors, &$rowAugmentors ) {
		$setAugmentor = $this->createMock( ResultSetAugmentor::class );
		$setAugmentor->expects( $this->once() )
			->method( 'augmentAll' )
			->willReturnCallback( static function ( ISearchResultSet $resultSet ) {
				$data = [];
				/** @var SearchResult $result */
				foreach ( $resultSet as $result ) {
					$id = $result->getTitle()->getArticleID();
					$data[$id] = "Result:$id:" . $result->getTitle()->getText();
				}
				return $data;
			} );
		$setAugmentors['testSet'] = $setAugmentor;

		$rowAugmentor = $this->createMock( ResultAugmentor::class );
		$rowAugmentor->expects( $this->exactly( 2 ) )
			->method( 'augment' )
			->willReturnCallback( static function ( SearchResult $result ) {
				$id = $result->getTitle()->getArticleID();
				return "Result2:$id:" . $result->getTitle()->getText();
			} );
		$rowAugmentors['testRow'] = $rowAugmentor;
	}

	public function testFiltersMissing() {
		$availableResults = [];
		$user = $this->getTestSysop()->getAuthority();
		foreach ( range( 0, 11 ) as $i ) {
			$title = "Search_Result_$i";
			$availableResults[] = $title;
			// pages not created must be filtered
			if ( $i % 2 == 0 ) {
				$this->editPage(
					$title,
					new WikitextContent( 'TestFiltersMissing content' ),
					'TestFiltersMissing summary',
					NS_MAIN,
					$user
				);
			}
		}
		MockCompletionSearchEngine::addMockResults( 'foo', $availableResults );

		$engine = new MockCompletionSearchEngine();
		$engine->setLimitOffset( 10, 0 );
		$engine->setHookContainer( $this->getServiceContainer()->getHookContainer() );
		$results = $engine->completionSearch( 'foo' );
		$this->assertEquals( 5, $results->getSize() );
		$this->assertTrue( $results->hasMoreResults() );

		$engine->setLimitOffset( 10, 10 );
		$results = $engine->completionSearch( 'foo' );
		$this->assertSame( 1, $results->getSize() );
		$this->assertFalse( $results->hasMoreResults() );
	}

	public static function provideDataForParseNamespacePrefix() {
		return [
			'noop' => [
				[
					'query' => 'foo',
				],
				false,
			],
			'empty' => [
				[
					'query' => '',
				],
				false,
			],
			'namespace prefix' => [
				[
					'query' => 'help:test',
				],
				[ 'test', [ NS_HELP ] ],
			],
			'accented namespace prefix with hook' => [
				[
					'query' => 'hélp:test',
					'withHook' => true,
				],
				[ 'test', [ NS_HELP ] ],
			],
			'accented namespace prefix without hook' => [
				[
					'query' => 'hélp:test',
					'withHook' => false,
				],
				false,
			],
			'all with all keyword allowed' => [
				[
					'query' => 'all:test',
					'withAll' => true,
				],
				[ 'test', null ],
			],
			'all with all keyword disallowed' => [
				[
					'query' => 'all:test',
					'withAll' => false,
				],
				false,
			],
			'ns only' => [
				[
					'query' => 'help:',
				],
				[ '', [ NS_HELP ] ],
			],
			'all only' => [
				[
					'query' => 'all:',
					'withAll' => true,
				],
				[ '', null ],
			],
			'all wins over namespace when first' => [
				[
					'query' => 'all:help:test',
					'withAll' => true,
				],
				[ 'help:test', null ],
			],
			'ns wins over all when first' => [
				[
					'query' => 'help:all:test',
					'withAll' => true,
				],
				[ 'all:test', [ NS_HELP ] ],
			],
		];
	}

	/**
	 * @dataProvider provideDataForParseNamespacePrefix
	 */
	public function testParseNamespacePrefix( array $params, $expected ) {
		$this->setTemporaryHook( 'PrefixSearchExtractNamespace', static function ( &$namespaces, &$query ) {
			if ( str_starts_with( $query, 'hélp:' ) ) {
				$namespaces = [ NS_HELP ];
				$query = substr( $query, strlen( 'hélp:' ) );
			}
			return false;
		} );
		$testSet = [];
		if ( isset( $params['withAll'] ) && isset( $params['withHook'] ) ) {
			$testSet[] = $params;
		} elseif ( isset( $params['withAll'] ) ) {
			$testSet[] = $params + [ 'withHook' => true ];
			$testSet[] = $params + [ 'withHook' => false ];
		} elseif ( isset( $params['withHook'] ) ) {
			$testSet[] = $params + [ 'withAll' => true ];
			$testSet[] = $params + [ 'withAll' => false ];
		} else {
			$testSet[] = $params + [ 'withAll' => true, 'withHook' => true ];
			$testSet[] = $params + [ 'withAll' => true, 'withHook' => false ];
			$testSet[] = $params + [ 'withAll' => false, 'withHook' => false ];
			$testSet[] = $params + [ 'withAll' => true, 'withHook' => false ];
		}

		foreach ( $testSet as $test ) {
			$actual = SearchEngine::parseNamespacePrefixes( $test['query'],
				$test['withAll'], $test['withHook'] );
			$this->assertEquals( $expected, $actual, 'with params: ' . print_r( $test, true ) );
		}
	}
}
PK       ! /:)  )  (  editpage/PreloadedContentBuilderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\EditPage;

use MediaWiki\EditPage\PreloadedContentBuilder;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\UltimateAuthority;
use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\EditPage\PreloadedContentBuilder
 * @group Database
 */
class PreloadedContentBuilderTest extends MediaWikiIntegrationTestCase {

	/** @var PreloadedContentBuilder */
	private $preloadedContentBuilder;

	protected function setUp(): void {
		$services = $this->getServiceContainer();

		// Needed for the 'Default preload section' test case due to use of wfMessage()
		$this->overrideConfigValue( MainConfigNames::UseDatabaseMessages, true );

		$this->preloadedContentBuilder = $services->getPreloadedContentBuilder();
	}

	public static function provideCases() {
		// title, preload, preloadParams, section, pages, expectedContent

		yield 'Non-existent page, no preload' =>
			[ 'Does-not-exist-asdfasdf', null, [], null, [],
				"" ];
		yield 'Non-existent page, preload' =>
			[ 'Does-not-exist-asdfasdf', 'Template:Preload', [], null, [ 'Template:Preload' => 'Preload' ],
				"Preload" ];
		yield 'Non-existent page, preload with parameters' =>
			[ 'Does-not-exist-asdfasdf', 'Template:Preloadparams', [ 'a', 'b' ], null, [ 'Template:Preloadparams' => 'Preload $1 $2' ],
				"Preload a b" ];

		yield 'Existing page content is ignored (it is not our responsibility)' =>
			[ 'Exists', null, [], null, [ 'Exists' => 'Hello' ],
				"" ];
		yield 'Existing page content is ignored (it is not our responsibility), preload' =>
			[ 'Exists', 'Template:Preload', [], null, [ 'Exists' => 'Hello', 'Template:Preload' => 'Preload' ],
				"Preload" ];

		yield 'Preload section' =>
			[ 'Exists', 'Template:Preload', [], 'new', [ 'Exists' => 'Hello', 'Template:Preload' => 'Preload' ],
				"Preload" ];
		yield 'Default preload section' =>
			[ 'Exists', null, [], 'new', [ 'Exists' => 'Hello', 'MediaWiki:Addsection-preload' => 'Preloadsection' ],
				"Preloadsection" ];

		yield 'Non-existent page in MediaWiki: namespace is prefilled with message' =>
			[ 'MediaWiki:View', null, [], null, [],
				"View" ];
		yield 'Non-existent page in MediaWiki: namespace for non-existent message' =>
			[ 'MediaWiki:Does-not-exist-asdfasdf', null, [], null, [],
				"" ];
		yield 'Non-existent page in MediaWiki: namespace does not support preload' =>
			[ 'MediaWiki:View', 'Template:Preload', [], null, [ 'Template:Preload' => 'Preload' ],
				"View" ];
		yield 'Non-existent message supports preload' =>
			[ 'MediaWiki:Does-not-exist-asdfasdf', 'Template:Preload', [], null, [ 'Template:Preload' => 'Preload' ],
				"Preload" ];

		yield 'JSON page in MediaWiki: namespace is prefilled with empty JSON' =>
			[ 'MediaWiki:Foo.json', null, [], null, [],
				"{}" ];

		yield 'Preload using Special:MyLanguage' =>
			[ 'Does-not-exist-asdfasdf', 'Special:MyLanguage/Template:Preload', [], null, [ 'Template:Preload' => 'Preload' ],
				"Preload" ];

		yield 'Preload using a localisation message' =>
			[ 'Does-not-exist-asdfasdf', 'MediaWiki:View', [], null, [],
				"View" ];
		yield 'Preload using a page in mediawiki namespace' =>
			[ 'Does-not-exist-asdfasdf', 'MediaWiki:For-preloading', [], null, [ 'MediaWiki:For-preloading' => '<noinclude>Noinclude</noinclude><includeonly>Includeonly</includeonly>' ],
				"Includeonly" ];

		yield 'Preload over redirect' =>
			[ 'Does-not-exist-asdfasdf', 'Template:Preload2', [], null, [ 'Template:Preload' => 'Preload', 'Template:Preload2' => '#REDIRECT[[Template:Preload]]' ],
				"Preload" ];
	}

	/**
	 * @dataProvider provideCases
	 */
	public function testGetPreloadedContent( $title, $preload, $preloadParams, $section, $pages, $expectedContent ) {
		foreach ( $pages as $page => $content ) {
			$this->editPage( $page, $content );
		}

		$content = $this->preloadedContentBuilder->getPreloadedContent(
			Title::newFromText( $title )->toPageIdentity(),
			new UltimateAuthority( $this->getTestUser()->getUser() ),
			$preload,
			$preloadParams,
			$section
		);

		$this->assertEquals( $expectedContent, $content->serialize() );
	}

}
PK       ! ^%W  W    editpage/TextboxBuilderTest.phpnu Iw        <?php
/**
 * Copyright (C) 2017 Kunal Mehta <legoktm@debian.org>
 *
 * 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.
 *
 */

namespace MediaWiki\Tests\EditPage;

use MediaWiki\EditPage\TextboxBuilder;
use MediaWiki\Language\Language;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Permissions\RestrictionStore;
use MediaWiki\Title\Title;
use MediaWiki\User\Options\StaticUserOptionsLookup;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;

/**
 * See also unit tests at \MediaWiki\Tests\Unit\EditPage\TextboxBuilderTest
 *
 * @covers \MediaWiki\EditPage\TextboxBuilder
 */
class TextboxBuilderTest extends MediaWikiIntegrationTestCase {

	public static function provideGetTextboxProtectionCSSClasses() {
		return [
			[
				[ '' ],
				[ 'isProtected' ],
				[],
			],
			[
				[ '', 'something' ],
				[],
				[],
			],
			[
				[ '', 'something' ],
				[ 'isProtected' ],
				[ 'mw-textarea-protected' ]
			],
			[
				[ '', 'something' ],
				[ 'isProtected', 'isSemiProtected' ],
				[ 'mw-textarea-sprotected' ],
			],
			[
				[ '', 'something' ],
				[ 'isProtected', 'isCascadeProtected' ],
				[ 'mw-textarea-protected', 'mw-textarea-cprotected' ],
			],
			[
				[ '', 'something' ],
				[ 'isProtected', 'isCascadeProtected', 'isSemiProtected' ],
				[ 'mw-textarea-sprotected', 'mw-textarea-cprotected' ],
			],
		];
	}

	/**
	 * @dataProvider provideGetTextboxProtectionCSSClasses
	 */
	public function testGetTextboxProtectionCSSClasses(
		$restrictionLevels,
		$protectionModes,
		$expected
	) {
		$this->overrideConfigValue(
			// set to trick PermissionManager::getNamespaceRestrictionLevels
			MainConfigNames::RestrictionLevels, $restrictionLevels
		);

		$mockRestrictionStore = $this->createMock( RestrictionStore::class );
		$pageIdValue = PageIdentityValue::localIdentity( 1, NS_MAIN, 'test' );

		$mockRestrictionStore->method(
			$this->logicalOr( ...array_map( [ $this, 'identicalTo' ], $protectionModes ) )
		)->willReturn( true );

		$this->setService( 'RestrictionStore', $mockRestrictionStore );

		$builder = new TextboxBuilder();
		$this->assertSame( $expected, $builder->getTextboxProtectionCSSClasses( $pageIdValue ) );
	}

	public function testBuildTextboxAttribs() {
		$user = UserIdentityValue::newRegistered( 42, 'Test' );
		$mockUserOptionsLookup = new StaticUserOptionsLookup( [
			'Test' => [ 'editfont' => 'monospace' ],
		] );
		$this->setService( 'UserOptionsLookup', $mockUserOptionsLookup );

		$enLanguage = $this->createMock( Language::class );
		$enLanguage->method( 'getHtmlCode' )->willReturn( 'en' );
		$enLanguage->method( 'getDir' )->willReturn( 'ltr' );

		$title = $this->createMock( Title::class );
		$title->method( 'getPageLanguage' )->willReturn( $enLanguage );

		$builder = new TextboxBuilder();
		$attribs = $builder->buildTextboxAttribs(
			'mw-textbox1',
			[ 'class' => 'foo bar', 'data-foo' => '123', 'rows' => 30 ],
			$user,
			$title
		);

		$this->assertIsArray( $attribs );
		// custom attrib showed up
		$this->assertArrayHasKey( 'data-foo', $attribs );
		// classes merged properly (string)
		$this->assertSame( 'foo bar mw-editfont-monospace', $attribs['class'] );
		// overrides in custom attrib worked
		$this->assertSame( 30, $attribs['rows'] );
		$this->assertSame( 'en', $attribs['lang'] );

		$attribs2 = $builder->buildTextboxAttribs(
			'mw-textbox2', [ 'class' => [ 'foo', 'bar' ] ], $user, $title
		);
		// classes merged properly (array)
		$this->assertSame( [ 'foo', 'bar', 'mw-editfont-monospace' ], $attribs2['class'] );

		$attribs3 = $builder->buildTextboxAttribs(
			'mw-textbox3', [], $user, $title
		);
		// classes ok when nothing to be merged
		$this->assertSame( 'mw-editfont-monospace', $attribs3['class'] );
	}
}
PK       ! ,]v  v    editpage/EditPageTest.phpnu Iw        <?php

use MediaWiki\Content\ContentHandler;
use MediaWiki\Content\TextContent;
use MediaWiki\Context\RequestContext;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\EditPage\EditPage;
use MediaWiki\MainConfigNames;
use MediaWiki\MainConfigSchema;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Status\Status;
use MediaWiki\Storage\EditResult;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use MediaWiki\Utils\MWTimestamp;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Editing
 * @group Database
 * @group medium
 */
class EditPageTest extends MediaWikiLangTestCase {

	use TempUserTestTrait;

	/** @var User[] */
	private static $editUsers;

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

		$this->overrideConfigValues( [
			MainConfigNames::ExtraNamespaces => [
				12312 => 'Dummy',
				12313 => 'Dummy_talk',
			],
			MainConfigNames::NamespaceContentModels => [ 12312 => 'testing' ],
			MainConfigNames::ContentHandlers =>
				[ 'testing' => 'DummyContentHandlerForTesting' ] +
				MainConfigSchema::getDefaultValue( MainConfigNames::ContentHandlers ),
		] );

		// Disable WAN cache to avoid edit conflicts in testUpdateNoMinor
		$this->setMainCache( CACHE_NONE );
	}

	public function addDBDataOnce() {
		$userFactory = $this->getServiceContainer()->getUserFactory();
		self::$editUsers = [
			'anon' => new User(),
			'UTSysop' => $userFactory->newFromName( 'UTSysop' ),
			'user' => $userFactory->newFromName( 'UTUser' ),
			'Adam' => $userFactory->newFromName( 'Adam' ),
			'Berta' => $userFactory->newFromName( 'Berta' ),
			'Elmo' => $userFactory->newFromName( 'Elmo' ),
		];

		foreach ( self::$editUsers as $key => $user ) {
			if ( $key !== 'anon' ) {
				$user->addToDatabase();
			}
		}

		$groupManager = $this->getServiceContainer()->getUserGroupManager();
		$groupManager->addUserToMultipleGroups(
			self::$editUsers['UTSysop'], [ 'sysop', 'bureaucrat' ] );
	}

	/**
	 * @dataProvider provideExtractSectionTitle
	 * @covers \MediaWiki\EditPage\EditPage::extractSectionTitle
	 */
	public function testExtractSectionTitle( $section, $title ) {
		$this->assertEquals(
			$title,
			TestingAccessWrapper::newFromClass( EditPage::class )->extractSectionTitle( $section )
		);
	}

	public static function provideExtractSectionTitle() {
		return [
			[
				"== Test ==\n\nJust a test section.",
				"Test"
			],
			[
				"An initial section, no header.",
				false
			],
			[
				"An initial section with a fake heder (T34617)\n\n== Test == ??\nwtf",
				false
			],
			[
				"== Section ==\nfollowed by a fake == Non-section == ??\nnoooo",
				"Section"
			],
			[
				"== Section== \t\r\n followed by whitespace (T37051)",
				'Section',
			],
		];
	}

	protected function forceRevisionDate( WikiPage $page, $timestamp ) {
		$dbw = $this->getDb();

		$dbw->newUpdateQueryBuilder()
			->update( 'revision' )
			->set( [ 'rev_timestamp' => $dbw->timestamp( $timestamp ) ] )
			->where( [ 'rev_id' => $page->getLatest() ] )
			->execute();

		$page->clear();
	}

	/**
	 * User input text is passed to rtrim() by edit page. This is a simple
	 * wrapper around assertEquals() which calls rrtrim() to normalize the
	 * expected and actual texts.
	 * @param string $expected
	 * @param string $actual
	 * @param string $msg
	 */
	protected function assertEditedTextEquals( $expected, $actual, $msg = '' ) {
		$this->assertEquals( rtrim( $expected ), rtrim( $actual ), $msg );
	}

	/**
	 * Performs an edit and checks the result.
	 *
	 * @param string|Title $title The title of the page to edit
	 * @param string|null $baseText Some text to create the page with before attempting the edit.
	 * @param string $userKey The user to perform the edit as.
	 * @param array $edit An array of request parameters used to define the edit to perform.
	 *              Some well known fields are:
	 *              * wpTextbox1: the text to submit
	 *              * wpSummary: the edit summary
	 *              * wpEditToken: the edit token (will be inserted if not provided)
	 *              * wpEdittime: timestamp of the edit's base revision (will be inserted
	 *                if not provided)
	 *              * editRevId: revision ID of the edit's base revision (optional)
	 *              * wpStarttime: timestamp when the edit started (will be inserted if not provided)
	 *              * wpSectionTitle: the section to edit
	 *              * wpMinoredit: mark as minor edit
	 *              * wpWatchthis: whether to watch the page
	 * @param int|null $expectedCode The expected result code (EditPage::AS_XXX constants).
	 *                  Set to null to skip the check.
	 * @param string|null $expectedText The text expected to be on the page after the edit.
	 *                  Set to null to skip the check.
	 * @param string|null $message An optional message to show along with any error message.
	 *
	 * @return WikiPage The page that was just edited, useful for getting the edit's rev_id, etc.
	 */
	protected function assertEdit( $title, $baseText, $userKey, array $edit,
		$expectedCode = null, $expectedText = null, $message = null
	) {
		if ( is_string( $title ) ) {
			$ns = $this->getDefaultWikitextNS();
			$title = Title::newFromText( $title, $ns );
		}
		$this->assertNotNull( $title );

		if ( !isset( self::$editUsers[$userKey] ) ) {
			$this->fail( "User $userKey not registered in addDBDataOnce" );
		}
		$user = self::$editUsers[$userKey];

		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();
		$page = $wikiPageFactory->newFromTitle( $title );

		if ( $baseText !== null ) {
			$content = ContentHandler::makeContent( $baseText, $title );
			$page->doUserEditContent( $content, $user, "base text for test" );
			$this->forceRevisionDate( $page, '20120101000000' );

			$page->clear();
			$content = $page->getContent();

			$this->assertInstanceOf( TextContent::class, $content );
			$currentText = $content->getText();

			# EditPage rtrim() the user input, so we alter our expected text
			# to reflect that.
			$this->assertEditedTextEquals( $baseText, $currentText );
		}

		if ( !isset( $edit['wpEditToken'] ) ) {
			$edit['wpEditToken'] = $user->getEditToken();
		}

		if ( !isset( $edit['wpEdittime'] ) && !isset( $edit['editRevId'] ) ) {
			$edit['wpEdittime'] = $page->exists() ? $page->getTimestamp() : '';
		}

		if ( !isset( $edit['wpStarttime'] ) ) {
			$edit['wpStarttime'] = wfTimestampNow();
		}

		if ( !isset( $edit['wpUnicodeCheck'] ) ) {
			$edit['wpUnicodeCheck'] = EditPage::UNICODE_CHECK;
		}

		$req = new FauxRequest( $edit, true ); // session ??

		$context = new RequestContext();
		$context->setRequest( $req );
		$context->setTitle( $title );
		$context->setUser( $user );
		$article = new Article( $title );
		$article->setContext( $context );
		$ep = new EditPage( $article );
		$ep->setContextTitle( $title );
		$ep->importFormData( $req );

		// this is where the edit happens!
		// Note: don't want to use EditPage::AttemptSave, because it messes with $wgOut
		// and throws exceptions like PermissionsError
		$status = $ep->attemptSave( $result );

		if ( $expectedCode !== null ) {
			// check edit code
			$this->assertEquals( $expectedCode, $status->value,
				"Expected result code mismatch. $message" );
		}

		$page = $wikiPageFactory->newFromTitle( $title );

		if ( $expectedText !== null ) {
			// check resulting page text
			$content = $page->getContent();
			$text = ( $content instanceof TextContent ) ? $content->getText() : '';

			# EditPage rtrim() the user input, so we alter our expected text
			# to reflect that.
			$this->assertEditedTextEquals( $expectedText, $text,
				"Expected article text mismatch. $message" );
		}

		return $page;
	}

	public static function provideCreatePages() {
		return [
			[ 'expected article being created',
				'EditPageTest_testCreatePage',
				'user',
				'Hello World!',
				EditPage::AS_SUCCESS_NEW_ARTICLE,
				'Hello World!'
			],
			[ 'expected article not being created if empty',
				'EditPageTest_testCreatePage',
				'user',
				'',
				EditPage::AS_BLANK_ARTICLE,
				null
			],
			[ 'expected MediaWiki: page being created',
				'MediaWiki:January',
				'UTSysop',
				'Not January',
				EditPage::AS_SUCCESS_NEW_ARTICLE,
				'Not January'
			],
			[ 'expected not-registered MediaWiki: page not being created if empty',
				'MediaWiki:EditPageTest_testCreatePage',
				'UTSysop',
				'',
				EditPage::AS_BLANK_ARTICLE,
				null
			],
			[ 'expected registered MediaWiki: page being created even if empty',
				'MediaWiki:January',
				'UTSysop',
				'',
				EditPage::AS_SUCCESS_NEW_ARTICLE,
				''
			],
			[ 'expected registered MediaWiki: page whose default content is empty'
					. ' not being created if empty',
				'MediaWiki:Ipb-default-expiry',
				'UTSysop',
				'',
				EditPage::AS_BLANK_ARTICLE,
				''
			],
			[ 'expected MediaWiki: page not being created if text equals default message',
				'MediaWiki:January',
				'UTSysop',
				'January',
				EditPage::AS_BLANK_ARTICLE,
				null
			],
			[ 'expected empty article being created',
				'EditPageTest_testCreatePage',
				'user',
				'',
				EditPage::AS_SUCCESS_NEW_ARTICLE,
				'',
				true
			],
		];
	}

	/**
	 * @dataProvider provideCreatePages
	 * @covers \MediaWiki\EditPage\EditPage
	 */
	public function testCreatePage(
		$desc, $pageTitle, $user, $editText, $expectedCode, $expectedText, $ignoreBlank = false
	) {
		$checkId = null;

		$this->setTemporaryHook(
			'PageSaveComplete',
			static function (
				WikiPage $page, UserIdentity $user, string $summary,
				int $flags, RevisionRecord $revisionRecord, EditResult $editResult
			) use ( &$checkId ) {
				$checkId = $revisionRecord->getId();
				// types/refs checked
			}
		);

		$edit = [ 'wpTextbox1' => $editText ];
		if ( $ignoreBlank ) {
			$edit['wpIgnoreBlankArticle'] = 1;
		}

		$page = $this->assertEdit( $pageTitle, null, $user, $edit, $expectedCode, $expectedText, $desc );

		if ( $expectedCode != EditPage::AS_BLANK_ARTICLE ) {
			$latest = $page->getLatest();
			$this->deletePage( $page );

			$this->assertGreaterThan( 0, $latest, "Page revision ID updated in object" );
			$this->assertEquals( $latest, $checkId, "Revision in Status for hook" );
		}
	}

	/**
	 * @dataProvider provideCreatePages
	 * @covers \MediaWiki\EditPage\EditPage
	 */
	public function testCreatePageTrx(
		$desc, $pageTitle, $user, $editText, $expectedCode, $expectedText, $ignoreBlank = false
	) {
		$checkIds = [];
		$this->setTemporaryHook(
			'PageSaveComplete',
			static function (
				WikiPage $page, UserIdentity $user, string $summary,
				int $flags, RevisionRecord $revisionRecord, EditResult $editResult
			) use ( &$checkIds ) {
				$checkIds[] = $revisionRecord->getId();
				// types/refs checked
			}
		);

		$this->getDb()->begin( __METHOD__ );

		$edit = [ 'wpTextbox1' => $editText ];
		if ( $ignoreBlank ) {
			$edit['wpIgnoreBlankArticle'] = 1;
		}

		$page = $this->assertEdit(
			$pageTitle, null, $user, $edit, $expectedCode, $expectedText, $desc );

		$pageTitle2 = (string)$pageTitle . '/x';
		$page2 = $this->assertEdit(
			$pageTitle2, null, $user, $edit, $expectedCode, $expectedText, $desc );

		$this->getDb()->commit( __METHOD__ );

		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount(), 'No deferred updates' );

		if ( $expectedCode != EditPage::AS_BLANK_ARTICLE ) {
			$latest = $page->getLatest();
			$this->deletePage( $page );

			$this->assertGreaterThan( 0, $latest, "Page #1 revision ID updated in object" );
			$this->assertEquals( $latest, $checkIds[0], "Revision #1 in Status for hook" );

			$latest2 = $page2->getLatest();
			$this->deletePage( $page2 );

			$this->assertGreaterThan( 0, $latest2, "Page #2 revision ID updated in object" );
			$this->assertEquals( $latest2, $checkIds[1], "Revision #2 in Status for hook" );
		}
	}

	/**
	 * @covers \MediaWiki\EditPage\EditPage
	 */
	public function testUpdatePage() {
		$checkIds = [];
		$this->setTemporaryHook(
			'PageSaveComplete',
			static function (
				WikiPage $page, UserIdentity $user, string $summary,
				int $flags, RevisionRecord $revisionRecord, EditResult $editResult
			) use ( &$checkIds ) {
				$checkIds[] = $revisionRecord->getId();
				// types/refs checked
			}
		);

		$text = "one";
		$edit = [
			'wpTextbox1' => $text,
			'wpSummary' => 'first update',
		];

		$page = $this->assertEdit( 'EditPageTest_testUpdatePage', "zero", 'user', $edit,
			EditPage::AS_SUCCESS_UPDATE, $text,
			"expected successful update with given text" );
		$this->assertGreaterThan( 0, $checkIds[0], "First event rev ID set" );

		$this->forceRevisionDate( $page, '20120101000000' );

		$text = "two";
		$edit = [
			'wpTextbox1' => $text,
			'wpSummary' => 'second update',
		];

		$this->assertEdit( 'EditPageTest_testUpdatePage', null, 'user', $edit,
			EditPage::AS_SUCCESS_UPDATE, $text,
			"expected successful update with given text" );
		$this->assertGreaterThan( 0, $checkIds[1], "Second edit hook rev ID set" );
		$this->assertGreaterThan( $checkIds[0], $checkIds[1], "Second event rev ID is higher" );
	}

	/**
	 * @covers \MediaWiki\EditPage\EditPage
	 */
	public function testUpdateNoMinor() {
		// Test that page creation can never be minor
		$edit = [
			'wpTextbox1' => 'testing',
			'wpSummary' => 'first update',
			'wpMinoredit' => 'minor'
		];

		$page = $this->assertEdit( 'EditPageTest_testUpdateNoMinor', null, 'user', $edit,
			EditPage::AS_SUCCESS_NEW_ARTICLE, 'testing', "expected successful update" );

		$this->assertFalse(
			$page->getRevisionRecord()->isMinor(),
			'page creation should not be minor'
		);

		// Test that anons can't make an update minor
		$this->forceRevisionDate( $page, '20120101000000' );

		$edit = [
			'wpTextbox1' => 'testing 2',
			'wpSummary' => 'second update',
			'wpMinoredit' => 'minor'
		];

		// Next assertion uses an anon editor, so disable temp accounts
		$this->disableAutoCreateTempUser();
		$page = $this->assertEdit( 'EditPageTest_testUpdateNoMinor', null, 'anon', $edit,
			EditPage::AS_SUCCESS_UPDATE, 'testing 2', "expected successful update" );

		$this->assertFalse(
			$page->getRevisionRecord()->isMinor(),
			'anon edit should not be minor'
		);

		// Test that users can make an update minor
		$this->forceRevisionDate( $page, '20120102000000' );

		$edit = [
			'wpTextbox1' => 'testing 3',
			'wpSummary' => 'third update',
			'wpMinoredit' => 'minor'
		];

		$page = $this->assertEdit( 'EditPageTest_testUpdateNoMinor', null, 'user', $edit,
			EditPage::AS_SUCCESS_UPDATE, 'testing 3', "expected successful update" );

		$this->assertTrue(
			$page->getRevisionRecord()->isMinor(),
			'users can make edits minor'
		);
	}

	/**
	 * @covers \MediaWiki\EditPage\EditPage
	 */
	public function testUpdatePageTrx() {
		$text = "one";
		$edit = [
			'wpTextbox1' => $text,
			'wpSummary' => 'first update',
		];

		$page = $this->assertEdit( 'EditPageTest_testTrxUpdatePage', "zero", 'user', $edit,
			EditPage::AS_SUCCESS_UPDATE, $text,
			"expected successful update with given text" );

		$this->forceRevisionDate( $page, '20120101000000' );

		$checkIds = [];
		$this->setTemporaryHook(
			'PageSaveComplete',
			static function (
				WikiPage $page, UserIdentity $user, string $summary,
				int $flags, RevisionRecord $revisionRecord, EditResult $editResult
			) use ( &$checkIds ) {
				$checkIds[] = $revisionRecord->getId();
				// types/refs checked
			}
		);

		$this->getDb()->begin( __METHOD__ );

		$text = "two";
		$edit = [
			'wpTextbox1' => $text,
			'wpSummary' => 'second update',
		];

		$this->assertEdit( 'EditPageTest_testTrxUpdatePage', null, 'user', $edit,
			EditPage::AS_SUCCESS_UPDATE, $text,
			"expected successful update with given text" );

		$text = "three";
		$edit = [
			'wpTextbox1' => $text,
			'wpSummary' => 'third update',
		];

		$this->assertEdit( 'EditPageTest_testTrxUpdatePage', null, 'user', $edit,
			EditPage::AS_SUCCESS_UPDATE, $text,
			"expected successful update with given text" );

		$this->getDb()->commit( __METHOD__ );

		$this->assertGreaterThan( 0, $checkIds[0], "First event rev ID set" );
		$this->assertGreaterThan( 0, $checkIds[1], "Second edit hook rev ID set" );
		$this->assertGreaterThan( $checkIds[0], $checkIds[1], "Second event rev ID is higher" );
	}

	public static function provideSectionEdit() {
		$title = 'EditPageTest_testSectionEdit';
		$title2 = Title::newFromText( __FUNCTION__ );
		$title2->setContentModel( CONTENT_MODEL_CSS );
		$text = 'Intro

== one ==
first section.

== two ==
second section.
';

		$sectionOne = '== one ==
hello
';

		$newSection = '== new section ==

hello
';

		$textWithNewSectionOne = preg_replace(
			'/== one ==.*== two ==/ms',
			"$sectionOne\n== two ==", $text
		);

		$textWithNewSectionAdded = "$text\n$newSection";

		return [
			[ # 0
				$title,
				$text,
				'',
				'hello',
				'replace all',
				'hello'
			],

			[ # 1
				$title,
				$text,
				'1',
				$sectionOne,
				'replace first section',
				$textWithNewSectionOne,
			],

			[ # 2
				$title,
				$text,
				'new',
				'hello',
				'new section',
				$textWithNewSectionAdded,
			],

			[ # 3 Section edit not supported
				$title2,
				$text,
				'1',
				'hello',
				'',
				'',
			],
		];
	}

	/**
	 * @dataProvider provideSectionEdit
	 * @covers \MediaWiki\EditPage\EditPage
	 */
	public function testSectionEdit( $title, $base, $section, $text, $summary, $expected ) {
		$edit = [
			'wpTextbox1' => $text,
			'wpSummary' => $summary,
			'wpSection' => $section,
		];

		$msg = "expected successful update of section";
		$result = EditPage::AS_SUCCESS_UPDATE;

		if ( $title instanceof Title ) {
			$result = null;
			$this->expectException( ErrorPageError::class );
		}
		$this->assertEdit( $title, $base, 'user', $edit, $result, $expected, $msg );
	}

	public static function provideConflictDetection() {
		yield 'no conflict detected' => [
			'Adam',
			[
				'wpEdittime' => 2, // use the second edit's time
				'editRevId' => 2, // use the second edit's revision ID
			],
			EditPage::AS_SUCCESS_UPDATE,
			'successful update expected'
		];

		yield 'conflict detected based on wpEdittime' => [
			'Adam',
			[
				'wpEdittime' => 1, // use the first edit's time
			],
			EditPage::AS_CONFLICT_DETECTED,
			'conflict expected'
		];

		yield 'conflict detected based on editRevId' => [
			'Adam',
			[
				'editRevId' => 1, // use the first edit's revision ID
			],
			EditPage::AS_CONFLICT_DETECTED,
			'conflict expected'
		];

		yield 'conflict based on wpEdittime ignored for same user' => [
			'Berta',
			[
				'wpEdittime' => 1, // use the first edit's time
			],
			EditPage::AS_SUCCESS_UPDATE,
			'successful update expected'
		];

		yield 'conflict detected based on editRevId even for same user' => [
			'Berta',
			[
				'editRevId' => 1, // use the first edit's revision ID
			],
			EditPage::AS_CONFLICT_DETECTED,
			'conflict expected'
		];
	}

	/**
	 * @dataProvider provideConflictDetection
	 * @covers \MediaWiki\EditPage\EditPage
	 */
	public function testConflictDetection( $editUser, $newEdit, $expectedCode, $message ) {
		// create page
		$ns = $this->getDefaultWikitextNS();
		$title = Title::newFromText( __METHOD__, $ns );
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();
		$page = $wikiPageFactory->newFromTitle( $title );

		if ( $page->exists() ) {
			$this->deletePage( $page, "clean slate for testing" );
		}

		$elmosEdit['wpTextbox1'] = 'Elmo\'s text';
		$bertasEdit['wpTextbox1'] = 'Berta\'s text';
		$newEdit['wpTextbox1'] = 'new text';

		$elmosEdit['wpSummary'] = 'Elmo\'s edit';
		$bertasEdit['wpSummary'] = 'Bertas\'s edit';
		$newEdit['wpSummary'] ??= 'new edit';

		// first edit: Elmo
		$page = $this->assertEdit( __METHOD__, null, 'Elmo', $elmosEdit,
			EditPage::AS_SUCCESS_NEW_ARTICLE, null, 'expected successful creation' );

		$this->forceRevisionDate( $page, '20120101000000' );
		$rev1 = $page->getRevisionRecord();

		// second edit: Berta
		$page = $this->assertEdit( __METHOD__, null, 'Berta', $bertasEdit,
			EditPage::AS_SUCCESS_UPDATE, null, 'expected successful update' );

		$this->forceRevisionDate( $page, '20120101111111' );
		$rev2 = $page->getRevisionRecord();

		if ( !empty( $newEdit['editRevId'] ) ) {
			$newEdit['editRevId'] = $newEdit['editRevId'] === 1 ? $rev1->getId() : $rev2->getId();
		}

		if ( !empty( $newEdit['wpEdittime'] ) ) {
			$newEdit['wpEdittime'] =
				$newEdit['wpEdittime'] === 1 ? $rev1->getTimestamp() : $rev2->getTimestamp();
		}

		// third edit
		$this->assertEdit( __METHOD__, null, $editUser, $newEdit,
			$expectedCode, null, $message );
	}

	public static function provideAutoMerge() {
		$tests = [];

		$tests[] = [ # 0: plain conflict
			"Elmo", # base edit user
			"one\n\ntwo\n\nthree\n",
			[ # adam's edit
				'wpTextbox1' => "ONE\n\ntwo\n\nthree\n",
			],
			[ # berta's edit
				'wpTextbox1' => "(one)\n\ntwo\n\nthree\n",
			],
			EditPage::AS_CONFLICT_DETECTED, # expected code
			"ONE\n\ntwo\n\nthree\n", # expected text
			'expected edit conflict', # message
		];

		$tests[] = [ # 1: successful merge
			"Elmo", # base edit user
			"one\n\ntwo\n\nthree\n",
			[ # adam's edit
				'wpStarttime' => 1,
				'wpTextbox1' => "ONE\n\ntwo\n\nthree\n",
			],
			[ # berta's edit
				'wpStarttime' => 2,
				'wpTextbox1' => "one\n\ntwo\n\nTHREE\n",
			],
			EditPage::AS_SUCCESS_UPDATE, # expected code
			"ONE\n\ntwo\n\nTHREE\n", # expected text
			'expected automatic merge', # message
		];

		$text = "Intro\n\n";
		$text .= "== first section ==\n\n";
		$text .= "one\n\ntwo\n\nthree\n\n";
		$text .= "== second section ==\n\n";
		$text .= "four\n\nfive\n\nsix\n\n";

		// extract the first section.
		$section = preg_replace( '/.*(== first section ==.*)== second section ==.*/sm', '$1', $text );

		// generate expected text after merge
		$expected = str_replace( 'one', 'ONE', str_replace( 'three', 'THREE', $text ) );

		$tests[] = [ # 2: merge in section
			"Elmo", # base edit user
			$text,
			[ # adam's edit
				'wpTextbox1' => str_replace( 'one', 'ONE', $section ),
				'wpSection' => '1'
			],
			[ # berta's edit
				'wpTextbox1' => str_replace( 'three', 'THREE', $section ),
				'wpSection' => '1'
			],
			EditPage::AS_SUCCESS_UPDATE, # expected code
			$expected, # expected text
			'expected automatic section merge', # message
		];

		// see whether it makes a difference who did the base edit
		$testsWithAdam = array_map( static function ( $test ) {
			$test[0] = 'Adam'; // change base edit user
			return $test;
		}, $tests );

		$testsWithBerta = array_map( static function ( $test ) {
			$test[0] = 'Berta'; // change base edit user
			return $test;
		}, $tests );

		return array_merge( $tests, $testsWithAdam, $testsWithBerta );
	}

	/**
	 * @dataProvider provideAutoMerge
	 * @covers \MediaWiki\EditPage\EditPage
	 */
	public function testAutoMerge( $baseUser, $text, $adamsEdit, $bertasEdit,
		$expectedCode, $expectedText, $message = null
	) {
		$this->markTestSkippedIfNoDiff3();

		// create page
		$ns = $this->getDefaultWikitextNS();
		$title = Title::makeTitle( $ns, 'EditPageTest_testAutoMerge' );
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();
		$page = $wikiPageFactory->newFromTitle( $title );

		if ( $page->exists() ) {
			$this->deletePage( $page, "clean slate for testing" );
		}

		$baseEdit = [
			'wpTextbox1' => $text,
		];

		$page = $this->assertEdit( 'EditPageTest_testAutoMerge', null,
			$baseUser, $baseEdit, null, null, __METHOD__ );

		$this->forceRevisionDate( $page, '20120101000000' );

		$edittime = $page->getTimestamp();
		$revId = $page->getLatest();

		$adamsEdit['wpSummary'] = 'Adam\'s edit';
		$bertasEdit['wpSummary'] = 'Bertas\'s edit';

		$adamsEdit['wpEdittime'] = $edittime;
		$bertasEdit['wpEdittime'] = $edittime;

		$adamsEdit['editRevId'] = $revId;
		$bertasEdit['editRevId'] = $revId;

		// first edit
		$this->assertEdit( 'EditPageTest_testAutoMerge', null, 'Adam', $adamsEdit,
			EditPage::AS_SUCCESS_UPDATE, null, "expected successful update" );

		// second edit
		$this->assertEdit( 'EditPageTest_testAutoMerge', null, 'Berta', $bertasEdit,
			$expectedCode, $expectedText, $message );
	}

	/**
	 * @depends testAutoMerge
	 * @covers \MediaWiki\EditPage\EditPage
	 */
	public function testCheckDirectEditingDisallowed_forNonTextContent() {
		$user = self::$editUsers['user'];

		$edit = [
			'wpTextbox1' => serialize( 'non-text content' ),
			'wpEditToken' => $user->getEditToken(),
			'wpEdittime' => '',
			'editRevId' => 0,
			'wpStarttime' => wfTimestampNow(),
			'wpUnicodeCheck' => EditPage::UNICODE_CHECK,
		];

		$this->expectException( MWException::class );
		$this->expectExceptionMessage( 'This content model is not supported: testing' );

		$this->doEditDummyNonTextPage( $edit );
	}

	/** @covers \MediaWiki\EditPage\EditPage */
	public function testShouldPreventChangingContentModelWhenUserCannotChangeModelForTitle() {
		$this->setTemporaryHook( 'getUserPermissionsErrors',
			static function ( Title $page, $user, $action, &$result ) {
				if ( $action === 'editcontentmodel' &&
					$page->getContentModel() === CONTENT_MODEL_WIKITEXT
				) {
					$result = false;

					return false;
				}
			} );

		$user = self::$editUsers['user'];

		$status = $this->doEditDummyNonTextPage( [
			'wpTextbox1' => 'some text',
			'wpEditToken' => $user->getEditToken(),
			'wpEdittime' => '',
			'editRevId' => 0,
			'wpStarttime' => wfTimestampNow(),
			'wpUnicodeCheck' => EditPage::UNICODE_CHECK,
			'model' => CONTENT_MODEL_WIKITEXT,
			'format' => CONTENT_FORMAT_WIKITEXT,
		] );

		$this->assertStatusNotOK( $status );
		$this->assertStatusValue( EditPage::AS_NO_CHANGE_CONTENT_MODEL, $status );
	}

	/** @covers \MediaWiki\EditPage\EditPage */
	public function testShouldPreventChangingContentModelWhenUserCannotEditTargetTitle() {
		$this->setTemporaryHook( 'getUserPermissionsErrors',
			static function ( Title $page, $user, $action, &$result ) {
				if ( $action === 'edit' && $page->getContentModel() === CONTENT_MODEL_WIKITEXT ) {
					$result = false;
					return false;
				}
			} );

		$user = $this->getTestUser()->getUser();

		$status = $this->doEditDummyNonTextPage( [
			'wpTextbox1' => 'some text',
			'wpEditToken' => $user->getEditToken(),
			'wpEdittime' => '',
			'editRevId' => 0,
			'wpStarttime' => wfTimestampNow(),
			'wpUnicodeCheck' => EditPage::UNICODE_CHECK,
			'model' => CONTENT_MODEL_WIKITEXT,
			'format' => CONTENT_FORMAT_WIKITEXT,
		] );

		$this->assertStatusNotOK( $status );
		$this->assertStatusValue( EditPage::AS_NO_CHANGE_CONTENT_MODEL, $status );
	}

	private function doEditDummyNonTextPage( array $edit ): Status {
		$title = Title::newFromText( 'Dummy:NonTextPageForEditPage' );

		$article = new Article( $title );
		$article->getContext()->setTitle( $title );
		$ep = new EditPage( $article );
		$ep->setContextTitle( $title );

		$req = new FauxRequest( $edit, true );
		$ep->importFormData( $req );

		return $ep->attemptSave( $result );
	}

	/**
	 * The watchlist expiry field should select the entered value on preview, rather than the
	 * calculated number of days till the expiry (as it shows on edit).
	 * @covers \MediaWiki\EditPage\EditPage::getCheckboxesDefinition()
	 * @dataProvider provideWatchlistExpiry()
	 */
	public function testWatchlistExpiry( $existingExpiry, $postVal, $selected, $options ) {
		// Set up config and fake current time.
		$this->overrideConfigValue( MainConfigNames::WatchlistExpiry, true );
		MWTimestamp::setFakeTime( '20200505120000' );
		$user = $this->getTestUser()->getUser();
		$this->assertTrue( $user->isRegistered() );

		// Create the EditPage.
		$title = Title::newFromText( __METHOD__ );
		$context = new RequestContext();
		$context->setUser( $user );
		$context->setTitle( $title );
		$article = new Article( $title );
		$article->setContext( $context );
		$ep = new EditPage( $article );
		$this->getServiceContainer()->getWatchlistManager()
			->setWatch( (bool)$existingExpiry, $user, $title, $existingExpiry );

		// Send the request.
		$req = new FauxRequest( [], true );
		$context->setRequest( $req );
		$req->getSession()->setUser( $user );
		$ep->importFormData( $req );
		$def = $ep->getCheckboxesDefinition( [ 'watch' => true, 'wpWatchlistExpiry' => $postVal ] )['wpWatchlistExpiry'];

		// Test selected and available options.
		$this->assertSame( $selected, $def['default'] );
		$dropdownOptions = [];
		foreach ( $def['options'] as $option ) {
			// Reformat dropdown options for easier test comparison.
			$dropdownOptions[] = $option['data'];
		}
		$this->assertSame( $options, $dropdownOptions );
	}

	public static function provideWatchlistExpiry() {
		$standardOptions = [ 'infinite', '1 week', '1 month', '3 months', '6 months', '1 year' ];
		return [
			'not watched, request nothing' => [
				'existingExpiry' => '',
				'postVal' => '',
				'selected' => 'infinite',
				'options' => $standardOptions,
			],
			'not watched' => [
				'existingExpiry' => '',
				'postVal' => '1 month',
				'result' => '1 month',
				'options' => $standardOptions,
			],
			'watched with current selected' => [
				'existingExpiry' => '2020-05-05T12:00:01Z',
				'postVal' => '2020-05-05T12:00:01Z',
				'result' => '2020-05-05T12:00:01Z',
				'options' => array_merge( [ '2020-05-05T12:00:01Z' ], $standardOptions ),
			],
			'watched with 1 week selected' => [
				'existingExpiry' => '2020-05-05T12:00:02Z',
				'postVal' => '1 week',
				'result' => '1 week',
				'options' => array_merge( [ '2020-05-05T12:00:02Z' ], $standardOptions ),
			],
		];
	}

	/**
	 * T277204
	 * @covers \MediaWiki\EditPage\EditPage
	 */
	public function testFalseyEditRevId() {
		$elmosEdit['wpTextbox1'] = 'Elmo\'s text';
		$bertasEdit['wpTextbox1'] = 'Berta\'s text';

		$elmosEdit['wpSummary'] = 'Elmo\'s edit';
		$bertasEdit['wpSummary'] = 'Bertas\'s edit';

		$bertasEdit['editRevId'] = 0;

		$this->assertEdit( __METHOD__,
			null, 'Elmo', $elmosEdit,
			EditPage::AS_SUCCESS_NEW_ARTICLE, null, 'expected successful creation' );

		// A successful update would probably be OK too. The important thing is
		// that it doesn't throw an exception.
		$this->assertEdit( __METHOD__, null, 'Berta', $bertasEdit,
			EditPage::AS_CONFLICT_DETECTED, null, 'expected successful update' );
	}

}
PK       ! J Q   Q  $  editpage/EditPageConstraintsTest.phpnu Iw        <?php

use MediaWiki\Content\ContentHandler;
use MediaWiki\Content\TextContent;
use MediaWiki\Context\RequestContext;
use MediaWiki\EditPage\EditPage;
use MediaWiki\EditPage\SpamChecker;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use Wikimedia\Rdbms\ReadOnlyMode;

/**
 * Integration tests for the various edit constraints, ensuring
 * that they result in failures as expected
 *
 * @covers \MediaWiki\EditPage\EditPage::internalAttemptSave
 * @covers \MediaWiki\EditPage\EditPage::internalAttemptSavePrivate
 *
 * @group Editing
 * @group Database
 * @group medium
 */
class EditPageConstraintsTest extends MediaWikiLangTestCase {

	use TempUserTestTrait;

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

		$contLang = $this->getServiceContainer()->getContentLanguage();
		$this->overrideConfigValues( [
			MainConfigNames::ExtraNamespaces => [
				12312 => 'Dummy',
				12313 => 'Dummy_talk',
			],
			MainConfigNames::NamespaceContentModels => [ 12312 => 'testing' ],
			MainConfigNames::LanguageCode => $contLang->getCode(),
		] );
		$this->mergeMwGlobalArrayValue(
			'wgContentHandlers',
			[ 'testing' => 'DummyContentHandlerForTesting' ]
		);
	}

	/**
	 * Based on method in EditPageTest
	 * Performs an edit and checks the result matches the expected failure code
	 *
	 * @param string|Title $title The title of the page to edit
	 * @param string|null $baseText Some text to create the page with before attempting the edit.
	 * @param User|null $user The user to perform the edit as.
	 * @param array $edit An array of request parameters used to define the edit to perform.
	 *              Some well known fields are:
	 *              * wpTextbox1: the text to submit
	 *              * wpSummary: the edit summary
	 *              * wpEditToken: the edit token (will be inserted if not provided)
	 *              * wpEdittime: timestamp of the edit's base revision (will be inserted
	 *                if not provided)
	 *              * editRevId: revision ID of the edit's base revision (optional)
	 *              * wpStarttime: timestamp when the edit started (will be inserted if not provided)
	 *              * wpSectionTitle: the section to edit
	 *              * wpMinorEdit: mark as minor edit
	 *              * wpWatchthis: whether to watch the page
	 * @param int $expectedCode The expected result code (EditPage::AS_XXX constants).
	 * @param string $message Message to show along with any error message.
	 *
	 * @return WikiPage The page that was just edited, useful for getting the edit's rev_id, etc.
	 */
	protected function assertEdit(
		$title,
		$baseText,
		?User $user,
		array $edit,
		$expectedCode,
		$message
	) {
		if ( is_string( $title ) ) {
			$ns = $this->getDefaultWikitextNS();
			$title = Title::newFromText( $title, $ns );
		}
		$this->assertNotNull( $title );

		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();
		$page = $wikiPageFactory->newFromTitle( $title );

		$user ??= $this->getTestUser()->getUser();

		if ( $baseText !== null ) {
			$content = ContentHandler::makeContent( $baseText, $title );
			$page->doUserEditContent( $content, $user, "base text for test" );

			// Set the latest timestamp back a while
			$dbw = $this->getDb();
			$dbw->newUpdateQueryBuilder()
				->update( 'revision' )
				->set( [ 'rev_timestamp' => $dbw->timestamp( '20120101000000' ) ] )
				->where( [ 'rev_id' => $page->getLatest() ] )
				->execute();
			$page->clear();

			$content = $page->getContent();
			$this->assertInstanceOf( TextContent::class, $content );
			$currentText = $content->getText();

			# EditPage rtrim() the user input, so we alter our expected text
			# to reflect that.
			$this->assertEquals(
				rtrim( $baseText ),
				rtrim( $currentText ),
				'page should have the text specified'
			);
		}

		if ( !isset( $edit['wpEditToken'] ) ) {
			$edit['wpEditToken'] = $user->getEditToken();
		}

		if ( !isset( $edit['wpEdittime'] ) && !isset( $edit['editRevId'] ) ) {
			$edit['wpEdittime'] = $page->exists() ? $page->getTimestamp() : '';
		}

		if ( !isset( $edit['wpStarttime'] ) ) {
			$edit['wpStarttime'] = wfTimestampNow();
		}

		if ( !isset( $edit['wpUnicodeCheck'] ) ) {
			$edit['wpUnicodeCheck'] = EditPage::UNICODE_CHECK;
		}

		$req = new FauxRequest( $edit, true ); // session ??

		$context = new RequestContext();
		$context->setRequest( $req );
		$context->setTitle( $title );
		$context->setUser( $user );
		$article = new Article( $title );
		$article->setContext( $context );
		$ep = new EditPage( $article );
		$ep->setContextTitle( $title );
		$ep->importFormData( $req );

		// this is where the edit happens!
		$status = $ep->attemptSave( $result );

		// check edit code
		$this->assertSame(
			$expectedCode,
			$status->value,
			"Expected result code mismatch. $message"
		);

		return $wikiPageFactory->newFromTitle( $title );
	}

	/** AccidentalRecreationConstraint integration */
	public function testAccidentalRecreationConstraint() {
		// Make sure it exists
		$this->getExistingTestPage( 'AccidentalRecreationConstraintPage' );

		// And now delete it, so that there is a deletion log
		$page = $this->getNonexistingTestPage( 'AccidentalRecreationConstraintPage' );
		$title = $page->getTitle();

		// Set the time of the deletion to be a specific time, so we can be sure to start the
		// edit before it. Since the constraint will query for the most recent timestamp,
		// update *all* deletion logs for the page to the same timestamp (1 January 2020)
		$dbw = $this->getDb();
		$dbw->newUpdateQueryBuilder()
			->update( 'logging' )
			->set( [ 'log_timestamp' => $dbw->timestamp( '20200101000000' ) ] )
			->where( [
				'log_namespace' => $title->getNamespace(),
				'log_title' => $title->getDBkey(),
				'log_type' => 'delete',
				'log_action' => 'delete',
			] )
			->caller( __METHOD__ )->execute();

		$user = $this->getTestUser()->getUser();

		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		// Needs edit rights to pass EditRightConstraint and reach AccidentalRecreationConstraint
		$permissionManager->overrideUserRightsForTesting( $user, [ 'edit' ] );

		// Started the edit on 1 January 2019, page was deleted on 1 January 2020
		$edit = [
			'wpTextbox1' => 'New content',
			'wpStarttime' => '20190101000000'
		];
		$this->assertEdit(
			$title,
			null,
			$user,
			$edit,
			EditPage::AS_ARTICLE_WAS_DELETED,
			'expected AS_ARTICLE_WAS_DELETED update'
		);
	}

	/** ExistingSectionEditConstraint integration */
	public function testExistingSectionEditConstraint() {
		// Require the summary
		$this->mergeMwGlobalArrayValue(
			'wgDefaultUserOptions',
			[ 'forceeditsummary' => 1 ]
		);

		$page = $this->getExistingTestPage( 'ExistingSectionEditConstraint page does exist' );
		$title = $page->getTitle();

		$user = $this->getTestUser()->getUser();

		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		// Needs edit rights to pass EditRightConstraint and reach NewSectionMissingSubjectConstraint
		$permissionManager->overrideUserRightsForTesting( $user, [ 'edit' ] );

		$edit = [
			'wpTextbox1' => 'New content, different from base content',
			'wpSummary' => 'SameAsAutoSummary',
			'wpAutoSummary' => md5( 'SameAsAutoSummary' )
		];
		$this->assertEdit(
			$title,
			'Base content, different from new content',
			$user,
			$edit,
			EditPage::AS_SUMMARY_NEEDED,
			'expected AS_SUMMARY_NEEDED update'
		);
	}

	/** ChangeTagsConstraint integration */
	public function testChangeTagsConstraint() {
		// Remove rights
		$this->mergeMwGlobalArrayValue(
			'wgRevokePermissions',
			[ 'user' => [ 'applychangetags' => true ] ]
		);
		$edit = [
			'wpTextbox1' => 'Text',
			'wpChangeTags' => 'tag-name'
		];
		$this->assertEdit(
			'EditPageTest_changeTagsConstraint',
			null,
			null,
			$edit,
			EditPage::AS_CHANGE_TAG_ERROR,
			'expected AS_CHANGE_TAG_ERROR update'
		);
	}

	/** ContentModelChangeConstraint integration */
	public function testContentModelChangeConstraint() {
		$user = $this->getTestUser()->getUser();
		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		// Needs edit rights to pass EditRightConstraint and reach ContentModelChangeConstraint
		$permissionManager->overrideUserRightsForTesting( $user, [ 'edit' ] );

		$edit = [
			'wpTextbox1' => 'New text goes here',
			'wpSummary' => 'Summary',
			'model' => CONTENT_MODEL_TEXT,
			'format' => CONTENT_FORMAT_TEXT,
		];

		$title = Title::makeTitle( NS_MAIN, 'Example' );
		$this->assertSame(
			CONTENT_MODEL_WIKITEXT,
			$title->getContentModel(),
			'title should start as wikitext content model'
		);

		$this->assertEdit(
			$title,
			'Base text',
			$user,
			$edit,
			EditPage::AS_NO_CHANGE_CONTENT_MODEL,
			'expected AS_NO_CHANGE_CONTENT_MODEL update'
		);
	}

	/** CreationPermissionConstraint integration */
	public function testCreationPermissionConstraint() {
		$page = $this->getNonexistingTestPage( 'CreationPermissionConstraint page does not exist' );
		$title = $page->getTitle();

		$user = $this->getTestUser()->getUser();
		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		// Needs edit rights to pass EditRightConstraint and reach CreationPermissionConstraint
		$permissionManager->overrideUserRightsForTesting( $user, [ 'edit' ] );

		$edit = [
			'wpTextbox1' => 'Page content',
			'wpSummary' => 'Summary'
		];
		$this->assertEdit(
			$title,
			null,
			$user,
			$edit,
			EditPage::AS_NO_CREATE_PERMISSION,
			'expected AS_NO_CREATE_PERMISSION creation'
		);
	}

	/** DefaultTextConstraint integration */
	public function testDefaultTextConstraint() {
		$page = $this->getNonexistingTestPage( 'DefaultTextConstraint page does not exist' );
		$title = $page->getTitle();

		$user = $this->getTestUser()->getUser();
		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		// Needs edit and createpage rights to pass EditRightConstraint and CreationPermissionConstraint
		$permissionManager->overrideUserRightsForTesting( $user, [ 'edit', 'createpage' ] );

		$edit = [
			'wpTextbox1' => '',
			'wpSummary' => 'Summary'
		];
		$this->assertEdit(
			$title,
			null,
			$user,
			$edit,
			EditPage::AS_BLANK_ARTICLE,
			'expected AS_BLANK_ARTICLE creation'
		);
	}

	/**
	 * EditFilterMergedContentHookConstraint integration
	 * @dataProvider provideTestEditFilterMergedContentHookConstraint
	 * @param bool $hookReturn
	 * @param ?int $statusValue
	 * @param bool $statusFatal
	 * @param int $expectedFailure
	 * @param string $expectedFailureStr
	 */
	public function testEditFilterMergedContentHookConstraint(
		bool $hookReturn,
		$statusValue,
		bool $statusFatal,
		int $expectedFailure,
		string $expectedFailureStr
	) {
		$this->setTemporaryHook(
			'EditFilterMergedContent',
			static function ( $context, $content, $status, $summary, $user, $minorEdit )
				use ( $hookReturn, $statusValue, $statusFatal )
			{
				if ( $statusValue !== null ) {
					$status->value = $statusValue;
				}
				if ( $statusFatal ) {
					$status->fatal( 'SomeErrorInTheHook' );
				}
				return $hookReturn;
			}
		);

		$user = $this->getTestUser()->getUser();
		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		// Needs edit and createpage rights to pass EditRightConstraint and CreationPermissionConstraint
		$permissionManager->overrideUserRightsForTesting( $user, [ 'edit', 'createpage' ] );

		$edit = [
			'wpTextbox1' => 'Text',
			'wpSummary' => 'Summary'
		];
		$this->assertEdit(
			'EditPageTest_testEditFilterMergedContentHookConstraint',
			null,
			$user,
			$edit,
			$expectedFailure,
			"expected $expectedFailureStr creation"
		);
	}

	public static function provideTestEditFilterMergedContentHookConstraint() {
		yield 'Hook returns false, status is good, no value set' => [
			false, null, false, EditPage::AS_HOOK_ERROR_EXPECTED, 'AS_HOOK_ERROR_EXPECTED'
		];
		yield 'Hook returns false, status is good, value set' => [
			false, 1234567, false, 1234567, 'custom value 1234567'
		];
		yield 'Hook returns false, status is not good' => [
			false, null, true, EditPage::AS_HOOK_ERROR_EXPECTED, 'AS_HOOK_ERROR_EXPECTED'
		];
		yield 'Hook returns true, status is not ok' => [
			true, null, true, EditPage::AS_HOOK_ERROR_EXPECTED, 'AS_HOOK_ERROR_EXPECTED'
		];
	}

	/**
	 * EditRightConstraint integration
	 * @dataProvider provideTestEditRightConstraint
	 * @param bool $anon
	 * @param int $expectedErrorCode
	 */
	public function testEditRightConstraint( $anon, $expectedErrorCode ) {
		if ( $anon ) {
			$this->disableAutoCreateTempUser();
			$user = $this->getServiceContainer()->getUserFactory()->newAnonymous( '127.0.0.1' );
		} else {
			$user = $this->getTestUser()->getUser();
		}
		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		$permissionManager->overrideUserRightsForTesting( $user, [] );

		$edit = [
			'wpTextbox1' => 'Page content',
			'wpSummary' => 'Summary'
		];
		$this->assertEdit(
			'EditPageTest_noEditRight',
			'base text',
			$user,
			$edit,
			$expectedErrorCode,
			'expected AS_READ_ONLY_PAGE_* update'
		);
	}

	public static function provideTestEditRightConstraint() {
		yield 'Anonymous user' => [ true, EditPage::AS_READ_ONLY_PAGE_ANON ];
		yield 'Registered user' => [ false, EditPage::AS_READ_ONLY_PAGE_LOGGED ];
	}

	/**
	 * ImageRedirectConstraint integration
	 * @dataProvider provideTestImageRedirectConstraint
	 * @param bool $anon
	 * @param int $expectedErrorCode
	 */
	public function testImageRedirectConstraint( $anon, $expectedErrorCode ) {
		if ( $anon ) {
			$this->disableAutoCreateTempUser();
			$user = $this->getServiceContainer()->getUserFactory()->newAnonymous( '127.0.0.1' );
		} else {
			$user = $this->getTestUser()->getUser();
		}

		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		// Needs edit rights to pass EditRightConstraint and reach ImageRedirectConstraint
		$permissionManager->overrideUserRightsForTesting( $user, [ 'edit' ] );

		$edit = [
			'wpTextbox1' => '#REDIRECT [[File:Example other file.jpg]]',
			'wpSummary' => 'Summary'
		];

		$title = Title::makeTitle( NS_FILE, 'Example.jpg' );
		$this->assertEdit(
			$title,
			null,
			$user,
			$edit,
			$expectedErrorCode,
			'expected AS_IMAGE_REDIRECT_* update'
		);
	}

	public static function provideTestImageRedirectConstraint() {
		yield 'Anonymous user' => [ true, EditPage::AS_IMAGE_REDIRECT_ANON ];
		yield 'Registered user' => [ false, EditPage::AS_IMAGE_REDIRECT_LOGGED ];
	}

	/** MissingCommentConstraint integration */
	public function testMissingCommentConstraint() {
		$page = $this->getExistingTestPage( 'MissingCommentConstraint page does exist' );
		$title = $page->getTitle();

		$user = $this->getTestUser()->getUser();

		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		// Needs edit rights to pass EditRightConstraint and reach MissingCommentConstraint
		$permissionManager->overrideUserRightsForTesting( $user, [ 'edit' ] );

		$edit = [
			'wpTextbox1' => '',
			'wpSection' => 'new',
			'wpSummary' => 'Summary'
		];
		$this->assertEdit(
			$title,
			null,
			$user,
			$edit,
			EditPage::AS_TEXTBOX_EMPTY,
			'expected AS_TEXTBOX_EMPTY update'
		);
	}

	/** NewSectionMissingSubjectConstraint integration */
	public function testNewSectionMissingSubjectConstraint() {
		// Require the summary
		$this->mergeMwGlobalArrayValue(
			'wgDefaultUserOptions',
			[ 'forceeditsummary' => 1 ]
		);

		$page = $this->getExistingTestPage( 'NewSectionMissingSubjectConstraint page does exist' );
		$title = $page->getTitle();

		$user = $this->getTestUser()->getUser();

		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		// Needs edit rights to pass EditRightConstraint and reach NewSectionMissingSubjectConstraint
		$permissionManager->overrideUserRightsForTesting( $user, [ 'edit' ] );

		$edit = [
			'wpTextbox1' => 'Comment',
			'wpSection' => 'new',
			'wpSummary' => ''
		];
		$this->assertEdit(
			$title,
			null,
			$user,
			$edit,
			EditPage::AS_SUMMARY_NEEDED,
			'expected AS_SUMMARY_NEEDED update'
		);
	}

	/** PageSizeConstraint integration */
	public function testPageSizeConstraintBeforeMerge() {
		// Max size: 1 kibibyte
		$this->overrideConfigValue( MainConfigNames::MaxArticleSize, 1 );

		$edit = [
			'wpTextbox1' => str_repeat( 'text', 1000 )
		];
		$this->assertEdit(
			'EditPageTest_pageSizeConstraintBeforeMerge',
			null,
			null,
			$edit,
			EditPage::AS_CONTENT_TOO_BIG,
			'expected AS_CONTENT_TOO_BIG update'
		);
	}

	/** PageSizeConstraint integration */
	public function testPageSizeConstraintAfterMerge() {
		// Max size: 1 kibibyte
		$this->overrideConfigValue( MainConfigNames::MaxArticleSize, 1 );

		$edit = [
			'wpSection' => 'new',
			'wpTextbox1' => str_repeat( 'b', 600 )
		];
		$this->assertEdit(
			'EditPageTest_pageSizeConstraintAfterMerge',
			str_repeat( 'a', 600 ),
			null,
			$edit,
			EditPage::AS_MAX_ARTICLE_SIZE_EXCEEDED,
			'expected AS_MAX_ARTICLE_SIZE_EXCEEDED update'
		);
	}

	/** ReadOnlyConstraint integration */
	public function testReadOnlyConstraint() {
		$readOnlyMode = $this->createMock( ReadOnlyMode::class );
		$readOnlyMode->method( 'isReadOnly' )->willReturn( true );
		$this->setService( 'ReadOnlyMode', $readOnlyMode );

		$edit = [
			'wpTextbox1' => 'Text goes here'
		];
		$this->assertEdit(
			'EditPageTest_readOnlyConstraint',
			null,
			null,
			$edit,
			EditPage::AS_READ_ONLY_PAGE,
			'expected AS_READ_ONLY_PAGE update'
		);
	}

	/** SelfRedirectConstraint integration */
	public function testSelfRedirectConstraint() {
		// Use a page that does not exist to be sure that it is not already a self redirect
		$page = $this->getNonexistingTestPage( 'SelfRedirectConstraint page does not exist' );
		$title = $page->getTitle();

		$edit = [
			'wpTextbox1' => '#REDIRECT [[SelfRedirectConstraint page does not exist]]',
			'wpSummary' => 'Redirect to self'
		];
		$this->assertEdit(
			$title,
			'zero',
			null,
			$edit,
			EditPage::AS_SELF_REDIRECT,
			'expected AS_SELF_REDIRECT update'
		);
	}

	/** SimpleAntiSpamConstraint integration */
	public function testSimpleAntiSpamConstraint() {
		$edit = [
			'wpTextbox1' => 'one',
			'wpSummary' => 'first update',
			'wpAntispam' => 'tatata'
		];
		$this->assertEdit(
			'EditPageTest_testUpdatePageSpamError',
			'zero',
			null,
			$edit,
			EditPage::AS_SPAM_ERROR,
			'expected AS_SPAM_ERROR update'
		);
	}

	/** SpamRegexConstraint integration */
	public function testSpamRegexConstraint() {
		$spamChecker = $this->createMock( SpamChecker::class );
		$spamChecker->method( 'checkContent' )
			->willReturnArgument( 0 );
		$spamChecker->method( 'checkSummary' )
			->willReturnArgument( 0 );
		$this->setService( 'SpamChecker', $spamChecker );

		$edit = [
			'wpTextbox1' => 'two',
			'wpSummary' => 'spam summary'
		];
		$this->assertEdit(
			'EditPageTest_testUpdatePageSpamRegexError',
			'zero',
			null,
			$edit,
			EditPage::AS_SPAM_ERROR,
			'expected AS_SPAM_ERROR update'
		);
	}

	/** UserBlockConstraint integration */
	public function testUserBlockConstraint() {
		$user = $this->createMock( User::class );
		$user->method( 'getName' )->willReturn( 'NameGoesHere' );
		$user->method( 'getId' )->willReturn( 12345 );

		$permissionManager = $this->createMock( PermissionManager::class );
		// Needs edit rights to pass EditRightConstraint and reach UserBlockConstraint
		$permissionManager->method( 'userHasRight' )->willReturn( true );
		$permissionManager->method( 'userCan' )->willReturn( true );

		// Not worried about the specifics of the method call, those are tested in
		// the UserBlockConstraintTest
		$permissionManager->method( 'isBlockedFrom' )->willReturn( true );

		$this->setService( 'PermissionManager', $permissionManager );

		$edit = [
			'wpTextbox1' => 'Page content',
			'wpSummary' => 'Summary'
		];
		$this->assertEdit(
			'EditPageTest_userBlocked',
			'base text',
			null,
			$edit,
			EditPage::AS_BLOCKED_PAGE_FOR_USER,
			'expected AS_BLOCKED_PAGE_FOR_USER update'
		);
	}

	/** UserRateLimitConstraint integration */
	public function testUserRateLimitConstraint() {
		$this->setTemporaryHook(
			'PingLimiter',
			static function ( $user, $action, &$result, $incrBy ) {
				// Always fail
				$result = true;
				return false;
			}
		);

		$edit = [
			'wpTextbox1' => 'Text goes here'
		];
		$this->assertEdit(
			'EditPageTest_userRateLimitConstraint',
			null,
			null,
			$edit,
			EditPage::AS_RATE_LIMITED,
			'expected AS_RATE_LIMITED update'
		);
	}

}
PK       ! {-    $  editpage/IntroMessageBuilderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\EditPage;

use File;
use FileRepo;
use MediaWiki\Config\HashConfig;
use MediaWiki\EditPage\IntroMessageBuilder;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\ProperPageIdentity;
use MediaWiki\Permissions\UltimateAuthority;
use MediaWiki\Title\Title;
use MediaWiki\User\TempUser\TempUserCreator;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;
use MockMessageLocalizer;
use RepoGroup;

/**
 * @covers \MediaWiki\EditPage\IntroMessageBuilder
 * @group Database
 */
class IntroMessageBuilderTest extends MediaWikiIntegrationTestCase {

	/** @var IntroMessageBuilder */
	private $introMessageBuilder;

	protected function setUp(): void {
		$services = $this->getServiceContainer();

		$config = new HashConfig( [
			MainConfigNames::AllowUserCss => true,
			MainConfigNames::AllowUserJs => true,
		] );

		$repoGroup = $this->createMock( RepoGroup::class );
		$repoGroup
			->method( 'findFile' )
			->willReturnCallback( function ( ProperPageIdentity $title ) {
				if ( $title->getDBkey() === 'Shared.png' || $title->getDBkey() === 'Shared-with-desc.png' ) {
					$file = $this->createMock( File::class );
					$file->method( 'isLocal' )->willReturn( false );
					$file->method( 'getDescriptionUrl' )->willReturn( 'https://example.com/' );
					$repo = $this->createMock( FileRepo::class );
					$repo->method( 'getDisplayName' )->willReturn( '' );
					$file->method( 'getRepo' )->willReturn( $repo );
					return $file;
				}
				return null;
			} );

		$tempUserCreator = $this->createMock( TempUserCreator::class );
		$tempUserCreator
			->method( 'isAutoCreateAction' )
			->willReturn( false );

		$userFactory = $this->createMock( UserFactory::class );
		$userFactory
			->method( 'newFromName' )
			->willReturnCallback( static function ( $name ) {
				$user = new User;
				$user->load(); // from 'defaults'
				$user->mName = $name;
				$user->mId = in_array( $name, [ 'Alice', 'Bob' ] ) ? 1 : 0;
				$user->mDataLoaded = true;
				return $user;
			} );

		$this->introMessageBuilder = new IntroMessageBuilder(
			$config,
			$services->getLinkRenderer(),
			$services->getPermissionManager(),
			$services->getUserNameUtils(),
			$tempUserCreator,
			$userFactory,
			$services->getRestrictionStore(),
			$services->getDatabaseBlockStore(),
			$services->getReadOnlyMode(),
			$services->getSpecialPageFactory(),
			$repoGroup,
			$services->getNamespaceInfo(),
			$services->getSkinFactory(),
			$services->getConnectionProvider(),
			$services->getUrlUtils()
		);
	}

	public static function provideCases() {
		// title, oldid, user, editIntro, pages, expectedMessage, expectedWrap
		$errorClass = 'cdx-message--error';
		$warningClass = 'cdx-message--warning';
		yield 'Main namespace has no default message' =>
			[ 'Hello', null, 'Alice', null, [ 'Hello' => '' ],
				[], null ];

		yield 'Logged-out warning' =>
			[ 'Hello', null, null, null, [ 'Hello' => '' ],
				[ "anoneditwarning" ], $warningClass ];

		// Code and message editing
		yield 'User JavaScript requires alert as well as code-specific message' =>
			[ 'User:Bob/common.js', null, 'Alice', null, [ 'User:Bob/common.js' => '' ],
				[ "userjsdangerous", "editpage-code-message" ], $errorClass ];

		yield 'Inform users that their JS is public and suggest guidelines' =>
			[ 'User:Bob/common.js', null, 'Bob', null, [ 'User:Bob/common.js' => '' ],
				[ "userjsispublic", "userjsdangerous", "editpage-code-message", "userjsyoucanpreview" ], $errorClass ];

		yield 'MediaWiki: namespace JSON requires alert' =>
			[ 'MediaWiki:Map.json', null, 'Alice', null, [],
				[ "editinginterface", "newarticletext" ], $errorClass ];

		yield 'MediaWiki: namespace message requires alert' =>
			[ 'MediaWiki:Does-not-exist-asdfasdf', null, 'Alice', null, [],
				[ "editinginterface", "newarticletext" ], $errorClass ];

		yield 'Translateable MediaWiki: namespace message links to Translatewiki' =>
			[ 'MediaWiki:View', null, 'Alice', null, [],
				[ "editinginterface", "translateinterface", "newarticletext" ], $errorClass ];

		// Files
		yield 'Neither shared not local file exists' =>
			[ 'File:Missing.png', null, 'Alice', null, [],
				[ "newarticletext" ], "mw-newarticletext" ];

		yield 'Shared file exists, local description does not exist' =>
			[ 'File:Shared.png', null, 'Alice', null, [],
				[ "sharedupload-desc-create", "newarticletext" ], "mw-sharedupload-desc-create" ];

		yield 'Shared file exists, local description exists' =>
			[ 'File:Shared-with-desc.png', null, 'Alice', null, [ 'File:Shared-with-desc.png' => 'Test' ],
				[ "sharedupload-desc-edit" ], "mw-sharedupload-desc-edit" ];

		// Users
		yield 'User does not exist' =>
			[ 'User:Foo', null, 'Alice', null, [],
				[ "userpage-userdoesnotexist", "newarticletext" ], "mw-newarticletext" ];

		yield 'User exists' =>
			[ 'User:Bob', null, 'Alice', null, [ 'User:Bob' => '' ],
				[], null ];

		yield 'IP user exists, I guess' =>
			[ 'User:1.2.3.4', null, 'Alice', null, [ 'User:1.2.3.4' => '' ],
				[], null ];

		// Editintro
		yield 'Default edit intro for missing page' =>
			[ 'Does-not-exist-asdfasdf', null, 'Alice', null, [],
				[ "newarticletext" ], "mw-newarticletext" ];

		yield 'The "editintro" parameter replaces the default edit intro' =>
			[ 'Does-not-exist-asdfasdf', null, 'Alice', 'Template:Editintro', [ 'Template:Editintro' => '(editintro)' ],
				[ "editintro" ], null ];

		// So many more cases to add...
	}

	/**
	 * @dataProvider provideCases
	 */
	public function testGetIntroMessages( $title, $oldid, $user, $editIntro, $pages, $expectedMessages, $expectedWrap ) {
		foreach ( $pages as $page => $content ) {
			$this->editPage( $page, $content );
		}

		if ( $user ) {
			$userObj = UserIdentityValue::newRegistered( 1, $user );
		} else {
			$userObj = UserIdentityValue::newAnonymous( '1.2.3.4' );
		}

		$parameters = [
			// These messages are always included, skip them to simplify expected values
			[ 'editnotice-notext', 'editpage-head-copy-warn' ],
			new MockMessageLocalizer( 'qqx' ),
			Title::newFromText( $title )->toPageIdentity(),
			null,
			new UltimateAuthority( $userObj ),
			$editIntro,
			null,
			false
		];

		$result = $this->introMessageBuilder->getIntroMessages( IntroMessageBuilder::LESS_FRAMES, ...$parameters );
		$resultLessFrames = implode( '', $result );
		$result = $this->introMessageBuilder->getIntroMessages( IntroMessageBuilder::MORE_FRAMES, ...$parameters );
		$resultMoreFrames = implode( '', $result );

		// Find anything that looks like a message in the output
		preg_match_all( '/[(](.+?)[):]/', $resultLessFrames, $matches, PREG_PATTERN_ORDER );
		$this->assertEquals( $expectedMessages, $matches[1], 'Messages (less frames)' );
		if ( $expectedWrap !== null ) {
			$this->assertStringNotContainsString( $expectedWrap, $resultLessFrames, 'No frames (less frames)' );
		}

		preg_match_all( '/[(](.+?)[):]/', $resultMoreFrames, $matches, PREG_PATTERN_ORDER );
		$this->assertEquals( $expectedMessages, $matches[1], 'Messages (more frames)' );
		if ( $expectedWrap !== null ) {
			$this->assertStringContainsString( $expectedWrap, $resultMoreFrames, 'Frames (more frames)' );
		}

		if ( $expectedWrap == null ) {
			$this->assertEquals( $resultLessFrames, $resultMoreFrames, 'No frames' );
		}
	}

}
PK       ! L    #  externalstore/ExternalStoreTest.phpnu Iw        <?php

class ExternalStoreTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers \ExternalStore::fetchFromURL
	 */
	public function testExternalFetchFromURL_noExternalStores() {
		$this->setService(
			'ExternalStoreFactory',
			new ExternalStoreFactory( [], [], 'test-id' )
		);

		$this->assertFalse(
			ExternalStore::fetchFromURL( 'ForTesting://cluster1/200' ),
			'Deny if wgExternalStores is not set to a non-empty array'
		);
	}

	public static function provideFetchFromURLWithStore() {
		yield [ 'Hello', 'ForTesting://cluster1/200', 'Allow ForTesting://cluster1/200' ];
		yield [ 'Hello', 'ForTesting://cluster1/300/0', 'Allow ForTesting://cluster1/300/0' ];

		// cases for r68900
		yield [ false, 'ftp.example.org', 'Deny domain ftp.example.org' ];
		yield [ false, '/example.txt', 'Deny path /example.txt' ];
		yield [ false, 'http://', 'Deny protocol http://' ];
	}

	/**
	 * @covers \ExternalStore::fetchFromURL
	 * @dataProvider provideFetchFromURLWithStore
	 */
	public function testExternalFetchFromURL_someExternalStore( $expect, $url, $msg ) {
		$this->setService(
			'ExternalStoreFactory',
			new ExternalStoreFactory( [ 'ForTesting' ], [ 'ForTesting://cluster1' ], 'test-id' )
		);

		$this->assertSame( $expect, ExternalStore::fetchFromURL( $url ), $msg );
	}
}
PK       ! E    *  externalstore/ExternalStoreFactoryTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use Wikimedia\FileBackend\MemoryFileBackend;
use Wikimedia\Rdbms\LBFactory;
use Wikimedia\Rdbms\LoadBalancer;

/**
 * @covers \ExternalStoreFactory
 * @covers \ExternalStoreAccess
 */
class ExternalStoreFactoryTest extends MediaWikiIntegrationTestCase {

	public function testExternalStoreFactory_noStores1() {
		$factory = new ExternalStoreFactory( [], [], 'test-id' );
		$this->expectException( ExternalStoreException::class );
		$factory->getStore( 'ForTesting' );
	}

	public function testExternalStoreFactory_noStores2() {
		$factory = new ExternalStoreFactory( [], [], 'test-id' );
		$this->expectException( ExternalStoreException::class );
		$factory->getStore( 'foo' );
	}

	public static function provideStoreNames() {
		yield 'Same case as construction' => [ 'ForTesting' ];
		yield 'All lower case' => [ 'fortesting' ];
		yield 'All upper case' => [ 'FORTESTING' ];
		yield 'Mix of cases' => [ 'FOrTEsTInG' ];
	}

	/**
	 * @dataProvider provideStoreNames
	 */
	public function testExternalStoreFactory_someStore_protoMatch( $proto ) {
		$factory = new ExternalStoreFactory( [ 'ForTesting' ], [], 'test-id' );
		$store = $factory->getStore( $proto );
		$this->assertInstanceOf( ExternalStoreForTesting::class, $store );
	}

	/**
	 * @dataProvider provideStoreNames
	 */
	public function testExternalStoreFactory_someStore_noProtoMatch( $proto ) {
		$factory = new ExternalStoreFactory( [ 'SomeOtherClassName' ], [], 'test-id' );
		$this->expectException( ExternalStoreException::class );
		$factory->getStore( $proto );
	}

	/**
	 * @covers \ExternalStoreFactory::getProtocols
	 * @covers \ExternalStoreFactory::getWriteBaseUrls
	 * @covers \ExternalStoreFactory::getStore
	 */
	public function testStoreFactoryBasic() {
		$active = [ 'memory', 'mwstore' ];
		$defaults = [ 'memory://cluster1', 'memory://cluster2', 'mwstore://memstore1' ];
		$esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' );
		$this->overrideConfigValue( MainConfigNames::FileBackends, [
			[
				'name' => 'memstore1',
				'class' => MemoryFileBackend::class,
				'domain' => 'its-all-in-your-head',
				'readOnly' => 'reason is a lie',
				'lockManager' => 'nullLockManager'
			]
		] );

		$this->assertEquals( $active, $esFactory->getProtocols() );
		$this->assertEquals( $defaults, $esFactory->getWriteBaseUrls() );

		/** @var ExternalStoreMemory $store */
		$store = $esFactory->getStore( 'memory' );
		$this->assertInstanceOf( ExternalStoreMemory::class, $store );
		$this->assertFalse( $store->isReadOnly( 'cluster1' ), "Location is writable" );
		$this->assertFalse( $store->isReadOnly( 'cluster2' ), "Location is writable" );

		$mwStore = $esFactory->getStore( 'mwstore' );
		$this->assertTrue( $mwStore->isReadOnly( 'memstore1' ), "Location is read-only" );

		$lb = $this->createMock( LoadBalancer::class );
		$lb->method( 'getReadOnlyReason' )->willReturn( 'Locked' );
		$lbFactory = $this->createMock( LBFactory::class );
		$lbFactory->method( 'getExternalLB' )->willReturn( $lb );

		$this->setService( 'DBLoadBalancerFactory', $lbFactory );

		$active = [ 'db', 'mwstore' ];
		$defaults = [ 'db://clusterX' ];
		$esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' );
		$this->assertEquals( $active, $esFactory->getProtocols() );
		$this->assertEquals( $defaults, $esFactory->getWriteBaseUrls() );

		$store->clear();
	}

	/**
	 * @covers \ExternalStoreFactory::getStoreForUrl
	 * @covers \ExternalStoreFactory::getStoreLocationFromUrl
	 */
	public function testStoreFactoryReadWrite() {
		$active = [ 'memory' ]; // active store types
		$defaults = [ 'memory://cluster1', 'memory://cluster2' ];
		$esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' );
		$access = new ExternalStoreAccess( $esFactory );

		/** @var ExternalStoreMemory $storeLocal */
		$storeLocal = $esFactory->getStore( 'memory' );
		/** @var ExternalStoreMemory $storeOther */
		$storeOther = $esFactory->getStore( 'memory', [ 'domain' => 'other' ] );
		$this->assertInstanceOf( ExternalStoreMemory::class, $storeLocal );
		$this->assertInstanceOf( ExternalStoreMemory::class, $storeOther );

		$v1 = wfRandomString();
		$v2 = wfRandomString();
		$v3 = wfRandomString();

		$this->assertFalse( $storeLocal->fetchFromURL( 'memory://cluster1/1' ) );

		$url1 = 'memory://cluster1/1';
		$this->assertEquals(
			$url1,
			$esFactory->getStoreForUrl( 'memory://cluster1' )
				->store( $esFactory->getStoreLocationFromUrl( 'memory://cluster1' ), $v1 )
		);
		$this->assertEquals(
			$v1,
			$esFactory->getStoreForUrl( 'memory://cluster1/1' )
				->fetchFromURL( 'memory://cluster1/1' )
		);
		$this->assertEquals( $v1, $storeLocal->fetchFromURL( 'memory://cluster1/1' ) );

		$url2 = $access->insert( $v2 );
		$url3 = $access->insert( $v3, [ 'domain' => 'other' ] );
		$this->assertNotFalse( $url2 );
		$this->assertNotFalse( $url3 );
		// There is only one active store type
		$this->assertEquals( $v2, $storeLocal->fetchFromURL( $url2 ) );
		$this->assertEquals( $v3, $storeOther->fetchFromURL( $url3 ) );
		$this->assertFalse( $storeOther->fetchFromURL( $url2 ) );
		$this->assertFalse( $storeLocal->fetchFromURL( $url3 ) );

		$res = $access->fetchFromURLs( [ $url1, $url2, $url3 ] );
		$this->assertEquals( [ $url1 => $v1, $url2 => $v2, $url3 => false ], $res, "Local-only" );

		$storeLocal->clear();
		$storeOther->clear();
	}
}
PK       !  %Tw  w  )  externalstore/ExternalStoreAccessTest.phpnu Iw        <?php

use MediaWiki\Tests\Unit\DummyServicesTrait;
use PHPUnit\Framework\MockObject\MockObject;

/**
 * @covers \ExternalStoreAccess
 */
class ExternalStoreAccessTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;

	public function testBasic() {
		$active = [ 'memory' ];
		$defaults = [ 'memory://cluster1', 'memory://cluster2' ];
		$esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' );
		$access = new ExternalStoreAccess( $esFactory );

		$this->assertFalse( $access->isReadOnly() );

		/** @var ExternalStoreMemory $store */
		$store = $esFactory->getStore( 'memory' );
		$this->assertInstanceOf( ExternalStoreMemory::class, $store );

		$location = $esFactory->getStoreLocationFromUrl( 'memory://cluster1' );
		$this->assertFalse( $store->isReadOnly( $location ) );
	}

	/**
	 * @covers \ExternalStoreAccess::isReadOnly
	 */
	public function testReadOnly() {
		/** @var  ExternalStoreMedium|MockObject $medium */
		$medium = $this->createMock( ExternalStoreMedium::class );

		$medium->method( 'isReadOnly' )->willReturn( true );

		/** @var  ExternalStoreFactory|MockObject $esFactory */
		$esFactory = $this->createMock( ExternalStoreFactory::class );

		$esFactory->method( 'getWriteBaseUrls' )->willReturn( [ 'test:' ] );
		$esFactory->method( 'getStoreForUrl' )->willReturn( $medium );
		$esFactory->method( 'getStoreLocationFromUrl' )->willReturn( 'dummy' );

		$access = new ExternalStoreAccess( $esFactory );
		$this->assertTrue( $access->isReadOnly() );

		$this->setService( 'ReadOnlyMode', $this->getDummyReadOnlyMode( 'Some absurd reason' ) );
		$this->expectException( ReadOnlyError::class );
		$access->insert( 'Lorem Ipsum' );
	}

	/**
	 * @covers \ExternalStoreAccess::fetchFromURL
	 * @covers \ExternalStoreAccess::fetchFromURLs
	 * @covers \ExternalStoreAccess::insert
	 */
	public function testReadWrite() {
		$active = [ 'memory' ]; // active store types
		$defaults = [ 'memory://cluster1', 'memory://cluster2' ];
		$esFactory = new ExternalStoreFactory( $active, $defaults, 'db-prefix' );
		$access = new ExternalStoreAccess( $esFactory );

		/** @var ExternalStoreMemory $storeLocal */
		$storeLocal = $esFactory->getStore( 'memory' );
		/** @var ExternalStoreMemory $storeOther */
		$storeOther = $esFactory->getStore( 'memory', [ 'domain' => 'other' ] );
		$this->assertInstanceOf( ExternalStoreMemory::class, $storeLocal );
		$this->assertInstanceOf( ExternalStoreMemory::class, $storeOther );

		$v1 = wfRandomString();
		$v2 = wfRandomString();
		$v3 = wfRandomString();

		$this->assertFalse( $storeLocal->fetchFromURL( 'memory://cluster1/1' ) );

		$url1 = 'memory://cluster1/1';
		$this->assertEquals(
			$url1,
			$esFactory->getStoreForUrl( 'memory://cluster1' )
				->store( $esFactory->getStoreLocationFromUrl( 'memory://cluster1' ), $v1 )
		);
		$this->assertEquals(
			$v1,
			$esFactory->getStoreForUrl( 'memory://cluster1/1' )
				->fetchFromURL( 'memory://cluster1/1' )
		);
		$this->assertEquals( $v1, $storeLocal->fetchFromURL( 'memory://cluster1/1' ) );

		$url2 = $access->insert( $v2 );
		$url3 = $access->insert( $v3, [ 'domain' => 'other' ] );
		$this->assertNotFalse( $url2 );
		$this->assertNotFalse( $url3 );
		// There is only one active store type
		$this->assertEquals( $v2, $storeLocal->fetchFromURL( $url2 ) );
		$this->assertEquals( $v3, $storeOther->fetchFromURL( $url3 ) );
		$this->assertFalse( $storeOther->fetchFromURL( $url2 ) );
		$this->assertFalse( $storeLocal->fetchFromURL( $url3 ) );

		$res = $access->fetchFromURLs( [ $url1, $url2, $url3 ] );
		$this->assertEquals( [ $url1 => $v1, $url2 => $v2, $url3 => false ], $res, "Local-only" );

		$storeLocal->clear();
		$storeOther->clear();
	}
}
PK       ! u'O  O  )  externalstore/ExternalStoreForTesting.phpnu Iw        <?php

class ExternalStoreForTesting {

	/** @var array */
	protected $data = [
		'cluster1' => [
			'200' => 'Hello',
			'300' => [
				'Hello', 'World',
			],
			// gzip string below generated with gzdeflate( 'AAAABBAAA' )
			'12345' => "sttttr\002\022\000",
		],
	];

	/**
	 * Fetch data from given URL
	 * @param string $url An url of the form FOO://cluster/id or FOO://cluster/id/itemid.
	 * @return mixed
	 */
	public function fetchFromURL( $url ) {
		// Based on ExternalStoreDB
		$path = explode( '/', $url );
		$cluster = $path[2];
		$id = $path[3];
		$itemID = $path[4] ?? false;

		if ( !isset( $this->data[$cluster][$id] ) ) {
			return null;
		}

		if ( $itemID !== false
			&& is_array( $this->data[$cluster][$id] )
			&& isset( $this->data[$cluster][$id][$itemID] )
		) {
			return $this->data[$cluster][$id][$itemID];
		}

		return $this->data[$cluster][$id];
	}

	public function store( $location, $data ) {
		$itemId = mt_rand( 500, 1000 );
		$this->data[$location][$itemId] = $data;
		return "ForTesting://$location/$itemId";
	}

	public function isReadOnly() {
		return false;
	}

}
PK       ! k<w  w    upload/UploadBaseTest.phpnu Iw        <?php

use MediaWiki\Interwiki\ClassicInterwikiLookup;
use MediaWiki\MainConfigNames;
use Wikimedia\Mime\XmlTypeCheck;

/**
 * @group Upload
 */
class UploadBaseTest extends MediaWikiIntegrationTestCase {

	protected const UPLOAD_PATH = "/tests/phpunit/data/upload/";

	/** @var UploadTestHandler */
	protected $upload;

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

		$this->upload = new UploadTestHandler;

		$this->overrideConfigValue(
			MainConfigNames::InterwikiCache,
			ClassicInterwikiLookup::buildCdbHash( [
				// no entries, no interwiki prefixes
			] )
		);
	}

	/**
	 * First checks the return code
	 * of UploadBase::getTitle() and then the actual returned title
	 *
	 * @dataProvider provideTestTitleValidation
	 * @covers \UploadBase::getTitle
	 */
	public function testTitleValidation( $srcFilename, $dstFilename, $code, $msg ) {
		/* Check the result code */
		$this->assertEquals( $code,
			$this->upload->testTitleValidation( $srcFilename ),
			"$msg code" );

		/* If we expect a valid title, check the title itself. */
		if ( $code == UploadBase::OK ) {
			$this->assertEquals( $dstFilename,
				$this->upload->getTitle()->getText(),
				"$msg text" );
		}
	}

	/**
	 * Test various forms of valid and invalid titles that can be supplied.
	 */
	public static function provideTestTitleValidation() {
		return [
			/* Test a valid title */
			[ 'ValidTitle.jpg', 'ValidTitle.jpg', UploadBase::OK,
				'upload valid title' ],
			/* A title with a slash */
			[ 'A/B.jpg', 'A-B.jpg', UploadBase::OK,
				'upload title with slash' ],
			/* A title with illegal char */
			[ 'A:B.jpg', 'A-B.jpg', UploadBase::OK,
				'upload title with colon' ],
			/* Stripping leading File: prefix */
			[ 'File:C.jpg', 'C.jpg', UploadBase::OK,
				'upload title with File prefix' ],
			/* Test illegal suggested title (r94601) */
			[ '%281%29.JPG', null, UploadBase::ILLEGAL_FILENAME,
				'illegal title for upload' ],
			/* A title without extension */
			[ 'A', null, UploadBase::FILETYPE_MISSING,
				'upload title without extension' ],
			/* A title with no basename */
			[ '.jpg', null, UploadBase::MIN_LENGTH_PARTNAME,
				'upload title without basename' ],
			/* A title that is longer than 255 bytes */
			[ str_repeat( 'a', 255 ) . '.jpg', null, UploadBase::FILENAME_TOO_LONG,
				'upload title longer than 255 bytes' ],
			/* A title that is longer than 240 bytes */
			[ str_repeat( 'a', 240 ) . '.jpg', null, UploadBase::FILENAME_TOO_LONG,
				'upload title longer than 240 bytes' ],
		];
	}

	/**
	 * Test the upload verification functions
	 * @covers \UploadBase::verifyUpload
	 */
	public function testVerifyUpload() {
		/* Setup with zero file size */
		$this->upload->initializePathInfo( '', '', 0 );
		$result = $this->upload->verifyUpload();
		$this->assertEquals( UploadBase::EMPTY_FILE,
			$result['status'],
			'upload empty file' );
	}

	// Helper used to create an empty file of size $size.
	private function createFileOfSize( $size ) {
		$filename = $this->getNewTempFile();

		$fh = fopen( $filename, 'w' );
		ftruncate( $fh, $size );
		fclose( $fh );

		return $filename;
	}

	/**
	 * @covers \UploadBase::verifyUpload
	 *
	 * test uploading a 100 bytes file with $wgMaxUploadSize = 100
	 *
	 * This method should be abstracted so we can test different settings.
	 */
	public function testMaxUploadSize() {
		$this->overrideConfigValues( [
			MainConfigNames::MaxUploadSize => 100,
			MainConfigNames::FileExtensions => [
				'txt',
			],
		] );

		$filename = $this->createFileOfSize( 100 );
		$this->upload->initializePathInfo( basename( $filename ) . '.txt', $filename, 100 );
		$result = $this->upload->verifyUpload();

		$this->assertEquals(
			[ 'status' => UploadBase::OK ],
			$result
		);
	}

	/**
	 * @covers \UploadBase::checkSvgScriptCallback
	 * @dataProvider provideCheckSvgScriptCallback
	 */
	public function testCheckSvgScriptCallback( $svg, $wellFormed, $filterMatch, $message ) {
		[ $formed, $match ] = $this->upload->checkSvgString( $svg );
		$this->assertSame( $wellFormed, $formed, $message . " (well-formed)" );
		$this->assertSame( $filterMatch, $match, $message . " (filter match)" );
	}

	public static function provideCheckSvgScriptCallback() {
		return [
			// html5sec SVG vectors
			[
				'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
				true, /* SVG is well formed */
				true, /* Evil SVG detected */
				'Script tag in svg (http://html5sec.org/#47)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg"><g onload="javascript:alert(1)"></g></svg>',
				true,
				true,
				'SVG with onload property (http://html5sec.org/#11)'
			],
			[
				'<svg onload="javascript:alert(1)" xmlns="http://www.w3.org/2000/svg"></svg>',
				true,
				true,
				'SVG with onload property (http://html5sec.org/#65)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
   ><defs><inkscape:path-effect svg:onload="javascript:alert(1)" /></defs></svg>',
				true,
				true,
				'SVG with svg:onload on a non-svg element (probably not a thing)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg"> <a xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="javascript:alert(1)"><rect width="1000" height="1000" fill="white"/></a> </svg>',
				true,
				true,
				'SVG with javascript xlink (http://html5sec.org/#87)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="data:application/xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj4KPGRlZnM+CjxjaXJjbGUgaWQ9InRlc3QiIHI9IjUwIiBjeD0iMTAwIiBjeT0iMTAwIiBzdHlsZT0iZmlsbDogI0YwMCI+CjxzZXQgYXR0cmlidXRlTmFtZT0iZmlsbCIgYXR0cmlidXRlVHlwZT0iQ1NTIiBvbmJlZ2luPSdhbGVydChkb2N1bWVudC5jb29raWUpJwpvbmVuZD0nYWxlcnQoIm9uZW5kIiknIHRvPSIjMDBGIiBiZWdpbj0iMXMiIGR1cj0iNXMiIC8+CjwvY2lyY2xlPgo8L2RlZnM+Cjx1c2UgeGxpbms6aHJlZj0iI3Rlc3QiLz4KPC9zdmc+#test"/> </svg>',
				true,
				true,
				'SVG with Opera image xlink (http://html5sec.org/#88 - c)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">  <animation xlink:href="javascript:alert(1)"/> </svg>',
				true,
				true,
				'SVG with Opera animation xlink (http://html5sec.org/#88 - a)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">  <animation xlink:href="data:text/xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' onload=\'alert(1)\'%3E%3C/svg%3E"/> </svg>',
				true,
				true,
				'SVG with Opera animation xlink (http://html5sec.org/#88 - b)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">  <image xlink:href="data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' onload=\'alert(1)\'%3E%3C/svg%3E"/> </svg>',
				true,
				true,
				'SVG with Opera image xlink (http://html5sec.org/#88 - c)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">  <foreignObject xlink:href="javascript:alert(1)"/> </svg>',
				true,
				true,
				'SVG with Opera foreignObject xlink (http://html5sec.org/#88 - d)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">  <foreignObject xlink:href="data:text/xml,%3Cscript xmlns=\'http://www.w3.org/1999/xhtml\'%3Ealert(1)%3C/script%3E"/> </svg>',
				true,
				true,
				'SVG with Opera foreignObject xlink (http://html5sec.org/#88 - e)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg"> <set attributeName="onmouseover" to="alert(1)"/> </svg>',
				true,
				true,
				'SVG with event handler set (http://html5sec.org/#89 - a)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg"> <animate attributeName="onunload" to="alert(1)"/> </svg>',
				true,
				true,
				'SVG with event handler animate (http://html5sec.org/#89 - a)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg"> <handler xmlns:ev="http://www.w3.org/2001/xml-events" ev:event="load">alert(1)</handler> </svg>',
				true,
				true,
				'SVG with element handler (http://html5sec.org/#94)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <feImage> <set attributeName="xlink:href" to="data:image/svg+xml;charset=utf-8;base64, PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxzY3JpcHQ%2BYWxlcnQoMSk8L3NjcmlwdD48L3N2Zz4NCg%3D%3D"/> </feImage> </svg>',
				true,
				true,
				'SVG with href to data: url (http://html5sec.org/#95)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" id="foo"> <x xmlns="http://www.w3.org/2001/xml-events" event="load" observer="foo" handler="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Chandler%20xml%3Aid%3D%22bar%22%20type%3D%22application%2Fecmascript%22%3E alert(1) %3C%2Fhandler%3E%0A%3C%2Fsvg%3E%0A#bar"/> </svg>',
				true,
				true,
				'SVG with Tiny handler (http://html5sec.org/#104)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg"> <a id="x"><rect fill="white" width="1000" height="1000"/></a> <rect fill="white" style="clip-path:url(test3.svg#a);fill:url(#b);filter:url(#c);marker:url(#d);mask:url(#e);stroke:url(#f);"/> </svg>',
				true,
				true,
				'SVG with new CSS styles properties (http://html5sec.org/#109)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg"> <a id="x"><rect fill="white" width="1000" height="1000"/></a> <rect clip-path="url(test3.svg#a)" /> </svg>',
				true,
				true,
				'SVG with new CSS styles properties as attributes'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg"> <a id="x"> <rect fill="white" width="1000" height="1000"/> </a> <rect fill="url(http://html5sec.org/test3.svg#a)" /> </svg>',
				true,
				true,
				'SVG with new CSS styles properties as attributes (2)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg"> <path d="M0,0" style="marker-start:url(test4.svg#a)"/> </svg>',
				true,
				true,
				'SVG with path marker-start (http://html5sec.org/#110)'
			],
			[
				'<?xml version="1.0"?> <?xml-stylesheet type="text/xml" href="#stylesheet"?> <!DOCTYPE doc [ <!ATTLIST xsl:stylesheet id ID #REQUIRED>]> <svg xmlns="http://www.w3.org/2000/svg"> <xsl:stylesheet id="stylesheet" version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:template match="/"> <iframe xmlns="http://www.w3.org/1999/xhtml" src="javascript:alert(1)"></iframe> </xsl:template> </xsl:stylesheet> <circle fill="red" r="40"></circle> </svg>',
				false,
				true,
				'SVG with embedded stylesheet (http://html5sec.org/#125)'
			],
			[
				'<?xml version="1.0"?> <?xml-stylesheet type="text/xml" href="#stylesheet"?> <svg xmlns="http://www.w3.org/2000/svg"> <xsl:stylesheet id="stylesheet" version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:template match="/"> <iframe xmlns="http://www.w3.org/1999/xhtml" src="javascript:alert(1)"></iframe> </xsl:template> </xsl:stylesheet> <circle fill="red" r="40"></circle> </svg>',
				true,
				true,
				'SVG with embedded stylesheet no doctype'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" id="x"> <listener event="load" handler="#y" xmlns="http://www.w3.org/2001/xml-events" observer="x"/> <handler id="y">alert(1)</handler> </svg>',
				true,
				true,
				'SVG with handler attribute (http://html5sec.org/#127)'
			],
			[
				// Haven't found a browser that accepts this particular example, but we
				// don't want to allow embeded svgs, ever
				'<svg> <image style=\'filter:url("data:image/svg+xml;charset=utf-8;base64, PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxzY3JpcHQ/YWxlcnQoMSk8L3NjcmlwdD48L3N2Zz4NCg==")\' /> </svg>',
				true,
				true,
				'SVG with image filter via style (http://html5sec.org/#129)'
			],
			[
				// This doesn't seem possible without embedding the svg, but just in case
				'<svg> <a xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="?"> <circle r="400"></circle> <animate attributeName="xlink:href" begin="0" from="javascript:alert(1)" to="" /> </a></svg>',
				true,
				true,
				'SVG with animate from (http://html5sec.org/#137)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <a><text y="1em">Click me</text> <animate attributeName="xlink:href" values="javascript:alert(\'Bang!\')" begin="0s" dur="0.1s" fill="freeze" /> </a></svg>',
				true,
				true,
				'SVG with animate xlink:href (http://html5sec.org/#137)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" xmlns:y="http://www.w3.org/1999/xlink"> <a y:href="#"> <text y="1em">Click me</text> <animate attributeName="y:href" values="javascript:alert(\'Bang!\')" begin="0s" dur="0.1s" fill="freeze" /> </a> </svg>',
				true,
				true,
				'SVG with animate y:href (http://html5sec.org/#137)'
			],

			// Other hostile SVG's
			[
				'<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg xmlns:xlink="http://www.w3.org/1999/xlink"> <image xlink:href="https://upload.wikimedia.org/wikipedia/commons/3/34/Bahnstrecke_Zeitz-Camburg_1930.png" /> </svg>',
				true,
				true,
				'SVG with non-local image href (T67839)'
			],
			[
				'<?xml version="1.0" ?> <?xml-stylesheet type="text/xsl" href="/w/index.php?title=User:Jeeves/test.xsl&amp;action=raw&amp;format=xml" ?> <svg> <height>50</height> <width>100</width> </svg>',
				true,
				true,
				'SVG with remote stylesheet (T59550)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" viewbox="-1 -1 15 15"> <rect y="0" height="13" width="12" stroke="#179" rx="1" fill="#2ac"/> <text x="1.5" y="11" font-family="courier" stroke="white" font-size="16"><![CDATA[B]]></text> <iframe xmlns="http://www.w3.org/1999/xhtml" srcdoc="&#x3C;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3E;&#x61;&#x6C;&#x65;&#x72;&#x74;&#x28;&#x27;&#x58;&#x53;&#x53;&#x45;&#x44;&#x20;&#x3D;&#x3E;&#x20;&#x44;&#x6F;&#x6D;&#x61;&#x69;&#x6E;&#x28;&#x27;&#x2B;&#x74;&#x6F;&#x70;&#x2E;&#x64;&#x6F;&#x63;&#x75;&#x6D;&#x65;&#x6E;&#x74;&#x2E;&#x64;&#x6F;&#x6D;&#x61;&#x69;&#x6E;&#x2B;&#x27;&#x29;&#x27;&#x29;&#x3B;&#x3C;&#x2F;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3E;"></iframe> </svg>',
				true,
				true,
				'SVG with rembeded iframe (T62771)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" viewBox="6 3 177 153" xmlns:xlink="http://www.w3.org/1999/xlink"> <style>@import url("https://fonts.googleapis.com/css?family=Bitter:700&amp;text=WebPlatform.org");</style> <g transform="translate(-.5,-.5)"> <text fill="#474747" x="95" y="150" text-anchor="middle" font-family="Bitter" font-size="20" font-weight="bold">WebPlatform.org</text> </g> </svg>',
				true,
				true,
				'SVG with @import in style element (T71008)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" viewBox="6 3 177 153" xmlns:xlink="http://www.w3.org/1999/xlink"> <style>@import url("https://fonts.googleapis.com/css?family=Bitter:700&amp;text=WebPlatform.org");<foo/></style> <g transform="translate(-.5,-.5)"> <text fill="#474747" x="95" y="150" text-anchor="middle" font-family="Bitter" font-size="20" font-weight="bold">WebPlatform.org</text> </g> </svg>',
				true,
				true,
				'SVG with @import in style element and child element (T71008#c11)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg" viewBox="6 3 177 153" xmlns:xlink="http://www.w3.org/1999/xlink"> <style>@imporT "https://fonts.googleapis.com/css?family=Bitter:700&amp;text=WebPlatform.org";</style> <g transform="translate(-.5,-.5)"> <text fill="#474747" x="95" y="150" text-anchor="middle" font-family="Bitter" font-size="20" font-weight="bold">WebPlatform.org</text> </g> </svg>',
				true,
				true,
				'SVG with case-insensitive @import in style element (bug T85349)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg"> <rect width="100" height="100" style="background-image:url(https://www.google.com/images/srpr/logo11w.png)"/> </svg>',
				true,
				true,
				'SVG with remote background image (T71008)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg"> <rect width="100" height="100" style="background-image:\55rl(https://www.google.com/images/srpr/logo11w.png)"/> </svg>',
				true,
				true,
				'SVG with remote background image, encoded (T71008)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg"> <style> #a { background-image:\55rl(\'https://www.google.com/images/srpr/logo11w.png\'); } </style> <rect width="100" height="100" id="a"/> </svg>',
				true,
				true,
				'SVG with remote background image, in style element (T71008)'
			],
			[
				// This currently doesn't seem to work in any browsers, but in case
				// https://www.w3.org/TR/css3-images/ is implemented for SVG files
				'<svg xmlns="http://www.w3.org/2000/svg"> <rect width="100" height="100" style="background-image:image(\'sprites.svg#xywh=40,0,20,20\')"/> </svg>',
				true,
				true,
				'SVG with remote background image using image() (T71008)'
			],
			[
				// As reported by Cure53
				'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <a xlink:href="data:text/html;charset=utf-8;base64, PHNjcmlwdD5hbGVydChkb2N1bWVudC5kb21haW4pPC9zY3JpcHQ%2BDQo%3D"> <circle r="400" fill="red"></circle> </a> </svg>',
				true,
				true,
				'SVG with data:text/html link target (firefox only)'
			],
			[
				'<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!ENTITY lol "lol"> <!ENTITY lol2 "&#x3C;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3E;&#x61;&#x6C;&#x65;&#x72;&#x74;&#x28;&#x27;&#x58;&#x53;&#x53;&#x45;&#x44;&#x20;&#x3D;&#x3E;&#x20;&#x27;&#x2B;&#x64;&#x6F;&#x63;&#x75;&#x6D;&#x65;&#x6E;&#x74;&#x2E;&#x64;&#x6F;&#x6D;&#x61;&#x69;&#x6E;&#x29;&#x3B;&#x3C;&#x2F;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3E;"> ]> <svg xmlns="http://www.w3.org/2000/svg" width="68" height="68" viewBox="-34 -34 68 68" version="1.1"> <circle cx="0" cy="0" r="24" fill="#c8c8c8"/> <text x="0" y="0" fill="black">&lol2;</text> </svg>',
				false,
				true,
				'SVG with encoded script tag in internal entity (reported by Beyond Security)'
			],
			[
				'<?xml version="1.0"?> <!DOCTYPE svg [ <!ENTITY foo SYSTEM "file:///etc/passwd"> ]> <svg xmlns="http://www.w3.org/2000/svg" version="1.1"> <desc>&foo;</desc> <rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:1;stroke:rgb(0,0,2)" /> </svg>',
				false,
				false,
				'SVG with external entity'
			],
			[
				// The base64 = <script>alert(1)</script>. If for some reason
				// entities actually do get loaded, this should trigger
				// filterMatch to be true. So this test verifies that we
				// are not loading external entities.
				'<?xml version="1.0"?> <!DOCTYPE svg [ <!ENTITY foo SYSTEM "data:text/plain;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pgo="> ]> <svg xmlns="http://www.w3.org/2000/svg" version="1.1"> <desc>&foo;</desc> <rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:1;stroke:rgb(0,0,2)" /> </svg>',
				false,
				false, /* False verifies entities aren't getting loaded */
				'SVG with data: uri external entity'
			],
			[
				"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"> <g> <a xlink:href=\"javascript:alert('1&#10;https://google.com')\"> <rect width=\"300\" height=\"100\" style=\"fill:rgb(0,0,255);stroke-width:1;stroke:rgb(0,0,2)\" /> </a> </g> </svg>",
				true,
				true,
				'SVG with javascript <a> link with newline (T122653)'
			],
			// Test good, but strange files that we want to allow
			[
				'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <g> <a xlink:href="http://en.wikipedia.org/wiki/Main_Page"> <path transform="translate(0,496)" id="path6706" d="m 112.09375,107.6875 -5.0625,3.625 -4.3125,5.03125 -0.46875,0.5 -4.09375,3.34375 -9.125,5.28125 -8.625,-3.375 z" style="fill:#cccccc;fill-opacity:1;stroke:#6e6e6e;stroke-width:0.69999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;display:inline" /> </a> </g> </svg>',
				true,
				false,
				'SVG with <a> link to a remote site'
			],
			[
				'<svg> <defs> <filter id="filter6226" x="-0.93243687" width="2.8648737" y="-0.24250539" height="1.4850108"> <feGaussianBlur stdDeviation="3.2344681" id="feGaussianBlur6228" /> </filter> <clipPath id="clipPath2436"> <path d="M 0,0 L 0,0 L 0,0 L 0,0 z" id="path2438" /> </clipPath> </defs> <g clip-path="url(#clipPath2436)" id="g2460"> <text id="text2466"> <tspan>12345</tspan> </text> </g> <path style="fill:#346733;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:1, 1;stroke-dashoffset:0;filter:url(\'#filter6226\');fill-opacity:1;opacity:0.79807692" d="M 236.82371,332.63732 C 236.92217,332.63732 z" id="path5618" /> </svg>',
				true,
				false,
				'SVG with local urls, including filter: in style'
			],
			[
				'<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE x [<!ATTLIST image x:href CDATA "data:image/png,foo" onerror CDATA "alert(\'XSSED = \'+document.domain)" onload CDATA "alert(\'XSSED = \'+document.domain)"> ]> <svg xmlns:h="http://www.w3.org/1999/xhtml" xmlns:x="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"> <image /> </svg>',
				false,
				false,
				'SVG with evil default attribute values'
			],
			[
				'<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg SYSTEM "data:application/xml-dtd;base64,PCFET0NUWVBFIHN2ZyBbPCFBVFRMSVNUIGltYWdlIHg6aHJlZiBDREFUQSAiZGF0YTppbWFnZS9wbmcsZm9vIiBvbmVycm9yIENEQVRBICJhbGVydCgnWFNTRUQgPSAnK2RvY3VtZW50LmRvbWFpbikiIG9ubG9hZCBDREFUQSAiYWxlcnQoJ1hTU0VEID0gJytkb2N1bWVudC5kb21haW4pIj4gXT4K"><svg xmlns:x="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"> <image /> </svg>',
				true,
				true,
				'SVG with an evil external dtd'
			],
			[
				'<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//FOO/bar" "http://example.com"><svg></svg>',
				true,
				true,
				'SVG with random public doctype'
			],
			[
				'<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg SYSTEM \'http://example.com/evil.dtd\' ><svg></svg>',
				true,
				true,
				'SVG with random SYSTEM doctype'
			],
			[
				'<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY % foo "bar" >] ><svg></svg>',
				false,
				false,
				'SVG with parameter entity'
			],
			[
				'<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY foo "bar%a;" ] ><svg></svg>',
				false,
				false,
				'SVG with entity referencing parameter entity'
			],
			[
				'<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY foo "bar0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"> ] ><svg></svg>',
				false,
				false,
				'SVG with long entity'
			],
			[
				'<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY  foo \'"Hi", said bob\'> ] ><svg><g>&foo;</g></svg>',
				true,
				false,
				'SVG with apostrophe quote entity'
			],
			[
				'<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg [<!ENTITY name "Bob"><!ENTITY  foo \'"Hi", said &name;.\'> ] ><svg><g>&foo;</g></svg>',
				false,
				false,
				'SVG with recursive entity',
			],
			[
				'<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd" [ <!ATTLIST svg xmlns:xlink CDATA #FIXED "http://www.w3.org/1999/xlink"> ]> <svg width="417pt" height="366pt"
 viewBox="0.00 0.00 417.00 366.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"></svg>',
				true, /* well-formed */
				false, /* filter-hit */
				'GraphViz-esque svg with #FIXED xlink ns (Should be allowed)'
			],
			[
				'<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd" [ <!ATTLIST svg xmlns:xlink CDATA #FIXED "http://www.w3.org/1999/xlink2"> ]> <svg width="417pt" height="366pt"
 viewBox="0.00 0.00 417.00 366.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"></svg>',
				false,
				false,
				'GraphViz ATLIST exception should match exactly'
			],
			[
				'<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!-- Comment-here --> <!ENTITY foo "#ff6666">]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
				true,
				false,
				'DTD with comments (Should be allowed)'
			],
			[
				'<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!-- invalid--comment  --> <!ENTITY foo "#ff6666">]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
				false,
				false,
				'DTD with invalid comment'
			],
			[
				'<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!-- invalid ---> <!ENTITY foo "#ff6666">]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
				false,
				false,
				'DTD with invalid comment 2'
			],
			[
				'<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!ENTITY bar "&foo;"> <!ENTITY foo "#ff6666">]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
				true,
				false,
				'DTD with aliased entities (Should be allowed)'
			],
			[
				'<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!ENTITY bar \'&foo;\'> <!ENTITY foo \'#ff6666\'>]><svg xmlns="http://www.w3.org/2000/svg"></svg>',
				true,
				false,
				'DTD with aliased entities apos (Should be allowed)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg"><g filter="url( \'#foo\' )"></g></svg>',
				true,
				false,
				'SVG with local filter (T69044)'
			],
			[
				'<svg xmlns="http://www.w3.org/2000/svg"><g filter="url( http://example.com/#foo )"></g></svg>',
				true,
				true,
				'SVG with non-local filter (T69044)'
			],

		];
		// phpcs:enable
	}

	/**
	 * @covers \UploadBase::detectScriptInSvg
	 * @dataProvider provideDetectScriptInSvg
	 */
	public function testDetectScriptInSvg( $svg, $expected, $message ) {
		// This only checks some weird cases, most tests are in testCheckSvgScriptCallback() above
		$result = $this->upload->detectScriptInSvg( $svg, false );
		$this->assertSame( $expected, $result, $message );
	}

	public static function provideDetectScriptInSvg() {
		global $IP;
		return [
			[
				$IP . self::UPLOAD_PATH . "buggynamespace-original.svg",
				false,
				'SVG with a weird but valid namespace definition created by Adobe Illustrator'
			],
			[
				$IP . self::UPLOAD_PATH . "buggynamespace-okay.svg",
				false,
				'SVG with a namespace definition created by Adobe Illustrator and mangled by Inkscape'
			],
			[
				$IP . self::UPLOAD_PATH . "buggynamespace-okay2.svg",
				false,
				'SVG with a namespace definition created by Adobe Illustrator and mangled by Inkscape (twice)'
			],
			[
				$IP . self::UPLOAD_PATH . "inkscape-only-selected.svg",
				false,
				'SVG with an inkscape only-selected attribute'
			],
			[
				$IP . self::UPLOAD_PATH . "buggynamespace-bad.svg",
				[ 'uploadscriptednamespace', 'i' ],
				'SVG with a namespace definition using an undefined entity'
			],
			[
				$IP . self::UPLOAD_PATH . "buggynamespace-evilhtml.svg",
				[ 'uploadscriptednamespace', 'http://www.w3.org/1999/xhtml' ],
				'SVG with an html namespace encoded as an entity'
			],
		];
	}

	/**
	 * @covers \UploadBase::checkXMLEncodingMissmatch
	 * @dataProvider provideCheckXMLEncodingMissmatch
	 */
	public function testCheckXMLEncodingMissmatch( $fileContents, $evil ) {
		$filename = $this->getNewTempFile();
		file_put_contents( $filename, $fileContents );
		$this->assertSame( $evil, UploadBase::checkXMLEncodingMissmatch( $filename ) );
	}

	public static function provideCheckXMLEncodingMissmatch() {
		return [
			[ '<?xml version="1.0" encoding="utf-7"?><svg></svg>', true ],
			[ '<?xml version="1.0" encoding="utf-8"?><svg></svg>', false ],
			[ '<?xml version="1.0" encoding="WINDOWS-1252"?><svg></svg>', false ],
			[ '<?xml version="1.0" encoding="us-ascii"?><svg></svg>', false ],
		];
	}

	/**
	 * @covers \UploadBase::detectScript
	 * @dataProvider provideDetectScript
	 */
	public function testDetectScript( $filename, $mime, $extension, $expected, $message ) {
		$result = $this->upload->detectScript( $filename, $mime, $extension );
		$this->assertSame( $expected, $result, $message );
	}

	public static function provideDetectScript() {
		global $IP;
		return [
			[
				$IP . self::UPLOAD_PATH . "png-plain.png",
				'image/png',
				'png',
				false,
				'PNG with no suspicious things in it; should pass.'
			],
			[
				$IP . self::UPLOAD_PATH . "png-embedded-breaks-ie5.png",
				'image/png',
				'png',
				true,
				'PNG with embedded data that IE5/6 interprets as HTML; should be rejected.'
			],
			[
				$IP . self::UPLOAD_PATH . "jpeg-a-href-in-metadata.jpg",
				'image/jpeg',
				'jpeg',
				false,
				'JPEG with innocuous HTML in metadata from a flickr photo; should pass (T27707).',
			],
		];
	}
}

class UploadTestHandler extends UploadBase {
	public function initializeFromRequest( &$request ) {
	}

	public function testTitleValidation( $name ) {
		$this->mTitle = false;
		$this->mDesiredDestName = $name;
		$this->mTitleError = UploadBase::OK;
		$this->getTitle();

		return $this->mTitleError;
	}

	/**
	 * Almost the same as UploadBase::detectScriptInSvg, except it's
	 * public, works on an xml string instead of filename, and returns
	 * the result instead of interpreting them.
	 * @param string $svg
	 * @return array
	 */
	public function checkSvgString( $svg ) {
		$check = new XmlTypeCheck(
			$svg,
			[ $this, 'checkSvgScriptCallback' ],
			false,
			[
				'processing_instruction_handler' => [ UploadBase::class, 'checkSvgPICallback' ],
				'external_dtd_handler' => [ UploadBase::class, 'checkSvgExternalDTD' ],
			]
		);
		return [ $check->wellFormed, $check->filterMatch ];
	}

	/**
	 * Same as parent function, but override visibility to 'public'.
	 * @inheritDoc
	 */
	public function detectScriptInSvg( $filename, $partial ) {
		return parent::detectScriptInSvg( $filename, $partial );
	}
}
PK       ! .q  q    upload/UploadStashTest.phpnu Iw        <?php

use MediaWiki\Request\FauxRequest;

/**
 * @group Database
 *
 * @covers \UploadStash
 */
class UploadStashTest extends MediaWikiIntegrationTestCase {
	/**
	 * @var string
	 */
	private $tmpFile;

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

		$this->tmpFile = $this->getNewTempFile();
		file_put_contents( $this->tmpFile, "\x00" );
	}

	public static function provideInvalidRequests() {
		return [
			'Check failure on bad wpFileKey' =>
				[ new FauxRequest( [ 'wpFileKey' => 'foo' ] ) ],
			'Check failure on bad wpSessionKey' =>
				[ new FauxRequest( [ 'wpSessionKey' => 'foo' ] ) ],
		];
	}

	/**
	 * @dataProvider provideInvalidRequests
	 */
	public function testValidRequestWithInvalidRequests( $request ) {
		$this->assertFalse( UploadFromStash::isValidRequest( $request ) );
	}

	public static function provideValidRequests() {
		return [
			'Check good wpFileKey' =>
				[ new FauxRequest( [ 'wpFileKey' => 'testkey-test.test' ] ) ],
			'Check good wpSessionKey' =>
				[ new FauxRequest( [ 'wpFileKey' => 'testkey-test.test' ] ) ],
			'Check key precedence' =>
				[ new FauxRequest( [
					'wpFileKey' => 'testkey-test.test',
					'wpSessionKey' => 'foo'
				] ) ],
		];
	}

	/**
	 * @dataProvider provideValidRequests
	 */
	public function testValidRequestWithValidRequests( $request ) {
		$this->assertTrue( UploadFromStash::isValidRequest( $request ) );
	}
}
PK       ! %  %    upload/UploadFromUrlTest.phpnu Iw        <?php

use MediaWiki\Api\ApiUsageException;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\File\FileDeleteForm;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use Wikimedia\TestingAccessWrapper;

/**
 * @group large
 * @group Upload
 * @group Database
 *
 * @covers \UploadFromUrl
 */
class UploadFromUrlTest extends ApiTestCase {
	use MockHttpTrait;

	/** @var User */
	private $user;

	protected function setUp(): void {
		parent::setUp();
		$this->user = $this->getTestSysop()->getUser();

		$this->overrideConfigValues( [
			MainConfigNames::EnableUploads => true,
			MainConfigNames::AllowCopyUploads => true,
		] );
		$this->setGroupPermissions( 'sysop', 'upload_by_url', true );

		if ( $this->getServiceContainer()->getRepoGroup()->getLocalRepo()
			->newFile( 'UploadFromUrlTest.png' )->exists()
		) {
			$this->deleteFile( 'UploadFromUrlTest.png' );
		}

		$this->installMockHttp();
	}

	/**
	 * Ensure that the job queue is empty before continuing
	 */
	public function testClearQueue() {
		$jobQueueGroup = $this->getServiceContainer()->getJobQueueGroup();
		$job = $jobQueueGroup->pop();
		while ( $job ) {
			$job = $jobQueueGroup->pop();
		}
		$this->assertFalse( $job );
	}

	public function testIsAllowedHostEmpty() {
		$this->overrideConfigValues( [
			MainConfigNames::CopyUploadsDomains => [],
			MainConfigNames::CopyUploadAllowOnWikiDomainConfig => false,
		] );

		$this->assertTrue( UploadFromUrl::isAllowedHost( 'https://foo.bar' ) );
	}

	public function testIsAllowedHostDirectMatch() {
		$this->overrideConfigValues( [
			MainConfigNames::CopyUploadsDomains => [
				'foo.baz',
				'bar.example.baz',
			],
			MainConfigNames::CopyUploadAllowOnWikiDomainConfig => false,
		] );

		$this->assertFalse( UploadFromUrl::isAllowedHost( 'https://example.com' ) );

		$this->assertTrue( UploadFromUrl::isAllowedHost( 'https://foo.baz' ) );
		$this->assertFalse( UploadFromUrl::isAllowedHost( 'https://.foo.baz' ) );

		$this->assertFalse( UploadFromUrl::isAllowedHost( 'https://example.baz' ) );
		$this->assertTrue( UploadFromUrl::isAllowedHost( 'https://bar.example.baz' ) );
	}

	public function testIsAllowedHostLastWildcard() {
		$this->overrideConfigValues( [
			MainConfigNames::CopyUploadsDomains => [
				'*.baz',
			],
			MainConfigNames::CopyUploadAllowOnWikiDomainConfig => false,
		] );

		$this->assertFalse( UploadFromUrl::isAllowedHost( 'https://baz' ) );
		$this->assertFalse( UploadFromUrl::isAllowedHost( 'https://foo.example' ) );
		$this->assertFalse( UploadFromUrl::isAllowedHost( 'https://foo.example.baz' ) );
		$this->assertFalse( UploadFromUrl::isAllowedHost( 'https://foo/bar.baz' ) );

		$this->assertTrue( UploadFromUrl::isAllowedHost( 'https://foo.baz' ) );
		$this->assertFalse( UploadFromUrl::isAllowedHost( 'https://subdomain.foo.baz' ) );
	}

	public function testIsAllowedHostWildcardInMiddle() {
		$this->overrideConfigValues( [
			MainConfigNames::CopyUploadsDomains => [
				'foo.*.baz',
			],
			MainConfigNames::CopyUploadAllowOnWikiDomainConfig => false,
		] );

		$this->assertFalse( UploadFromUrl::isAllowedHost( 'https://foo.baz' ) );
		$this->assertFalse( UploadFromUrl::isAllowedHost( 'https://foo.bar.bar.baz' ) );
		$this->assertFalse( UploadFromUrl::isAllowedHost( 'https://foo.bar.baz.baz' ) );
		$this->assertFalse( UploadFromUrl::isAllowedHost( 'https://foo.com/.baz' ) );

		$this->assertTrue( UploadFromUrl::isAllowedHost( 'https://foo.example.baz' ) );
		$this->assertTrue( UploadFromUrl::isAllowedHost( 'https://foo.bar.baz' ) );
	}

	public function testOnWikiDomainConfigEnabled() {
		$this->overrideConfigValues( [
			MainConfigNames::CopyUploadsDomains => [ 'example.com' ],
			MainConfigNames::CopyUploadAllowOnWikiDomainConfig => true,
		] );

		$messageContent = "example.org # this is a comment\n# this too is commented foo.example.com\nexample.net";
		$mock = $this->createMock( MessageCache::class );
		$mock->method( 'get' )->willReturn( $messageContent );
		$this->setService( 'MessageCache', $mock );

		$this->assertEquals(
			[ 'example.com', 'example.org', 'example.net' ],
			TestingAccessWrapper::newFromClass( UploadFromUrl::class )->getAllowedHosts()
		);

		$this->assertTrue( UploadFromUrl::isAllowedHost( 'https://example.com' ) );
		$this->assertTrue( UploadFromUrl::isAllowedHost( 'https://example.org' ) );
		$this->assertFalse( UploadFromUrl::isAllowedHost( 'https://foo.example.com' ) );
	}

	public function testOnWikiDomainConfigDisabled() {
		$this->overrideConfigValues( [
			MainConfigNames::CopyUploadsDomains => [ 'example.com' ],
			MainConfigNames::CopyUploadAllowOnWikiDomainConfig => false,
		] );

		$mock = $this->createMock( MessageCache::class );
		$mock->expects( $this->never() )->method( 'get' );
		$this->setService( 'MessageCache', $mock );

		$this->assertEquals(
			[ 'example.com' ],
			TestingAccessWrapper::newFromClass( UploadFromUrl::class )->getAllowedHosts()
		);

		$this->assertTrue( UploadFromUrl::isAllowedHost( 'https://example.com' ) );
		$this->assertFalse( UploadFromUrl::isAllowedHost( 'https://example.org' ) );
	}

	/**
	 * @depends testClearQueue
	 */
	public function testSetupUrlDownload( $data ) {
		$token = $this->user->getEditToken();
		$exception = false;

		try {
			$this->doApiRequest( [
				'action' => 'upload',
			] );
		} catch ( ApiUsageException $e ) {
			$exception = true;
			$this->assertApiErrorCode( 'missingparam', $e );
		}
		$this->assertTrue( $exception, "Got exception" );

		$exception = false;
		try {
			$this->doApiRequest( [
				'action' => 'upload',
				'token' => $token,
			], $data );
		} catch ( ApiUsageException $e ) {
			$exception = true;
			$this->assertApiErrorCode( 'missingparam', $e );
		}
		$this->assertTrue( $exception, "Got exception" );

		$exception = false;
		try {
			$this->doApiRequest( [
				'action' => 'upload',
				'url' => 'http://www.example.com/test.png',
				'token' => $token,
			], $data );
		} catch ( ApiUsageException $e ) {
			$exception = true;
			$this->assertApiErrorCode( 'nofilename', $e );
		}
		$this->assertTrue( $exception, "Got exception" );

		$this->getServiceContainer()->getUserGroupManager()->removeUserFromGroup( $this->user, 'sysop' );
		$exception = false;
		try {
			$this->doApiRequest( [
				'action' => 'upload',
				'url' => 'http://www.example.com/test.png',
				'filename' => 'UploadFromUrlTest.png',
				'token' => $token,
			], $data );
		} catch ( ApiUsageException $e ) {
			$this->assertApiErrorCode( 'permissiondenied', $e );
			$exception = true;
		}
		$this->assertTrue( $exception, "Got exception" );
	}

	private function assertUploadOk( UploadBase $upload ) {
		$verificationResult = $upload->verifyUpload();

		if ( $verificationResult['status'] !== UploadBase::OK ) {
			$this->fail(
				'Upload verification returned ' . $upload->getVerificationErrorCode(
					$verificationResult['status']
				)
			);
		}
	}

	/**
	 * @depends testClearQueue
	 */
	public function testSyncDownload( $data ) {
		$file = __DIR__ . '/../../data/upload/png-plain.png';
		$this->installMockHttp( file_get_contents( $file ) );

		$this->getServiceContainer()->getUserGroupManager()->addUserToGroup( $this->user, 'users' );
		$data = $this->doApiRequestWithToken( [
			'action' => 'upload',
			'filename' => 'UploadFromUrlTest.png',
			'url' => 'http://upload.wikimedia.org/wikipedia/mediawiki/b/bc/Wiki.png',
			'ignorewarnings' => true,
		], $data );

		$this->assertEquals( 'Success', $data[0]['upload']['result'] );
		$this->deleteFile( 'UploadFromUrlTest.png' );

		return $data;
	}

	protected function deleteFile( $name ) {
		$t = Title::newFromText( $name, NS_FILE );
		$this->assertTrue( $t->exists(), "File '$name' exists" );

		if ( $t->exists() ) {
			$file = $this->getServiceContainer()->getRepoGroup()
				->findFile( $name, [ 'ignoreRedirect' => true ] );
			$empty = "";
			FileDeleteForm::doDelete( $t, $file, $empty, "none", true, $this->user );
			$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $t );
			$this->deletePage( $page );
		}
		$t = Title::newFromText( $name, NS_FILE );

		$this->assertFalse( $t->exists(), "File '$name' was deleted" );
	}

	public function testUploadFromUrl() {
		$file = __DIR__ . '/../../data/upload/png-plain.png';
		$this->installMockHttp( file_get_contents( $file ) );

		$upload = new UploadFromUrl();
		$upload->initialize( 'Test.png', 'http://www.example.com/test.png' );
		$status = $upload->fetchFile();

		$this->assertStatusOK( $status );
		$this->assertUploadOk( $upload );
	}

	public function testUploadFromUrlWithRedirect() {
		$file = __DIR__ . '/../../data/upload/png-plain.png';
		$this->installMockHttp( [
			// First response is a redirect
			$this->makeFakeHttpRequest(
				'Blaba',
				302,
				[ 'Location' => 'http://static.example.com/files/test.png' ]
			),
			// Second response is a file
			$this->makeFakeHttpRequest(
				file_get_contents( $file )
			),
		] );

		$upload = new UploadFromUrl();
		$upload->initialize( 'Test.png', 'http://www.example.com/test.png' );
		$status = $upload->fetchFile();

		$this->assertStatusOK( $status );
		$this->assertUploadOk( $upload );
	}

	public function testUploadFromUrlCacheKey() {
		// Test we get back a properly formatted sha1 key out
		$key = UploadFromUrl::getCacheKey( [ 'filename' => 'test.png', 'url' => 'https://example.com/example.png' ] );
		$this->assertNotEmpty( $key );
		$this->assertMatchesRegularExpression( "/^[0-9a-f]{40}$/", $key );
	}

	public function testUploadFromUrlCacheKeyMissingParam() {
		$this->assertSame( "", UploadFromUrl::getCacheKey( [] ) );
	}

}
PK       ! P%  %    import/ImportTest.phpnu Iw        <?php

use MediaWiki\Title\ForeignTitle;
use MediaWiki\Title\Title;
use MediaWiki\User\User;

/**
 * Test class for Import methods.
 *
 * @group Database
 *
 * @author Sebastian Brückner < sebastian.brueckner@student.hpi.uni-potsdam.de >
 */
class ImportTest extends MediaWikiLangTestCase {

	/**
	 * @covers \WikiImporter
	 * @dataProvider getUnknownTagsXML
	 * @param string $xml
	 * @param string $text
	 * @param string $title
	 */
	public function testUnknownXMLTags( $xml, $text, $title ) {
		$source = new ImportStringSource( $xml );
		$services = $this->getServiceContainer();

		$importer = $services->getWikiImporterFactory()->getWikiImporter( $source, $this->getTestSysop()->getAuthority() );

		$importer->doImport();
		$title = Title::newFromText( $title );
		$this->assertTrue( $title->exists() );

		$this->assertEquals(
			$services->getWikiPageFactory()->newFromTitle( $title )->getContent()->getText(),
			$text
		);
	}

	public function getUnknownTagsXML() {
		return [
			[
				<<< EOF
<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.10/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.10/ http://www.mediawiki.org/xml/export-0.10.xsd" version="0.10" xml:lang="en">
  <page unknown="123" dontknow="533">
    <title>TestImportPage</title>
    <unknowntag>Should be ignored</unknowntag>
    <ns>0</ns>
    <id unknown="123" dontknow="533">14</id>
    <revision>
      <id unknown="123" dontknow="533">15</id>
      <unknowntag>Should be ignored</unknowntag>
      <timestamp>2016-01-03T11:18:43Z</timestamp>
      <contributor>
        <unknowntag>Should be ignored</unknowntag>
        <username unknown="123" dontknow="533">Admin</username>
        <id>1</id>
      </contributor>
      <model>wikitext</model>
      <format>text/x-wiki</format>
      <text xml:space="preserve" bytes="0">noitazinagro tseb eht si ikiWaideM</text>
      <sha1>phoiac9h4m842xq45sp7s6u21eteeq1</sha1>
      <unknowntag>Should be ignored</unknowntag>
    </revision>
  </page>
  <unknowntag>Should be ignored</unknowntag>
</mediawiki>
EOF
				,
				'noitazinagro tseb eht si ikiWaideM',
				'TestImportPage'
			]
		];
		// phpcs:enable
	}

	/**
	 * @covers \WikiImporter::handlePage
	 * @dataProvider getRedirectXML
	 * @param string $xml
	 * @param string|null $redirectTitle
	 */
	public function testHandlePageContainsRedirect( $xml, $redirectTitle ) {
		$source = new ImportStringSource( $xml );

		$redirect = null;
		$callback = static function ( Title $title, ForeignTitle $foreignTitle, $revCount,
			$sRevCount, $pageInfo ) use ( &$redirect ) {
			if ( array_key_exists( 'redirect', $pageInfo ) ) {
				$redirect = $pageInfo['redirect'];
			}
		};

		$importer = $this->getServiceContainer()
			->getWikiImporterFactory()
			->getWikiImporter( $source, $this->getTestSysop()->getAuthority() );
		$importer->setPageOutCallback( $callback );
		$importer->doImport();

		$this->assertEquals( $redirectTitle, $redirect );
	}

	public function getRedirectXML() {
		return [
			[
				<<< EOF
<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.10/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.10/ http://www.mediawiki.org/xml/export-0.10.xsd" version="0.10" xml:lang="en">
	<page>
		<title>Test</title>
		<ns>0</ns>
		<id>21</id>
		<redirect title="Test22"/>
		<revision>
			<id>20</id>
			<timestamp>2014-05-27T10:00:00Z</timestamp>
			<contributor>
				<username>Admin</username>
				<id>10</id>
			</contributor>
			<comment>Admin moved page [[Test]] to [[Test22]]</comment>
			<model>wikitext</model>
			<format>text/x-wiki</format>
			<text xml:space="preserve" bytes="20">#REDIRECT [[Test22]]</text>
			<sha1>tq456o9x3abm7r9ozi6km8yrbbc56o6</sha1>
		</revision>
	</page>
</mediawiki>
EOF
			,
				'Test22'
			],
			[
				<<< EOF
<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.9/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.9/ http://www.mediawiki.org/xml/export-0.9.xsd" version="0.9" xml:lang="en">
	<page>
		<title>Test</title>
		<ns>0</ns>
		<id>42</id>
		<revision>
			<id>421</id>
			<timestamp>2014-05-27T11:00:00Z</timestamp>
			<contributor>
				<username>Admin</username>
				<id>10</id>
			</contributor>
			<text xml:space="preserve" bytes="4">Abcd</text>
			<sha1>n7uomjq96szt60fy5w3x7ahf7q8m8rh</sha1>
			<model>wikitext</model>
			<format>text/x-wiki</format>
		</revision>
	</page>
</mediawiki>
EOF
			,
				null
			],
		];
		// phpcs:enable
	}

	/**
	 * @covers \WikiImporter::handleSiteInfo
	 * @dataProvider getSiteInfoXML
	 * @param string $xml
	 * @param array|null $namespaces
	 */
	public function testSiteInfoContainsNamespaces( $xml, $namespaces ) {
		$source = new ImportStringSource( $xml );

		$importNamespaces = null;
		$callback = static function ( array $siteinfo, $innerImporter ) use ( &$importNamespaces ) {
			$importNamespaces = $siteinfo['_namespaces'];
		};

		$importer = $this->getServiceContainer()
			->getWikiImporterFactory()
			->getWikiImporter( $source, $this->getTestSysop()->getAuthority() );
		$importer->setSiteInfoCallback( $callback );
		$importer->doImport();

		$this->assertEquals( $importNamespaces, $namespaces );
	}

	public function getSiteInfoXML() {
		return [
			[
				<<< EOF
<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.10/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.10/ http://www.mediawiki.org/xml/export-0.10.xsd" version="0.10" xml:lang="en">
  <siteinfo>
    <namespaces>
      <namespace key="-2" case="first-letter">Media</namespace>
      <namespace key="-1" case="first-letter">Special</namespace>
      <namespace key="0" case="first-letter" />
      <namespace key="1" case="first-letter">Talk</namespace>
      <namespace key="2" case="first-letter">User</namespace>
      <namespace key="3" case="first-letter">User talk</namespace>
      <namespace key="100" case="first-letter">Portal</namespace>
      <namespace key="101" case="first-letter">Portal talk</namespace>
    </namespaces>
  </siteinfo>
</mediawiki>
EOF
			,
				[
					'-2' => 'Media',
					'-1' => 'Special',
					'0' => '',
					'1' => 'Talk',
					'2' => 'User',
					'3' => 'User talk',
					'100' => 'Portal',
					'101' => 'Portal talk',
				]
			],
		];
		// phpcs:enable
	}

	/**
	 * @dataProvider provideUnknownUserHandling
	 * @covers \WikiImporter::setUsernamePrefix
	 * @covers \MediaWiki\User\ExternalUserNames::addPrefix
	 * @covers \MediaWiki\User\ExternalUserNames::applyPrefix
	 * @param bool $assign
	 * @param bool $create
	 */
	public function testUnknownUserHandling( $assign, $create ) {
		$hookId = -99;
		$this->setTemporaryHook(
			'ImportHandleUnknownUser',
			function ( $name ) use ( $assign, $create, &$hookId ) {
				if ( !$assign ) {
					$this->fail( 'ImportHandleUnknownUser was called unexpectedly' );
				}

				$this->assertEquals( 'UserDoesNotExist', $name );
				if ( $create ) {
					$user = User::createNew( $name );
					$this->assertNotNull( $user );
					$hookId = $user->getId();
					return false;
				}
				return true;
			}
		);

		$user = $this->getTestUser()->getUser();

		$n = ( $assign ? 1 : 0 ) + ( $create ? 2 : 0 );
		$source = new ImportStringSource( <<<EOF
<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.10/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.10/ http://www.mediawiki.org/xml/export-0.10.xsd" version="0.10" xml:lang="en">
  <page>
    <title>TestImportPage</title>
    <ns>0</ns>
    <id>14</id>
    <revision>
      <id>15</id>
      <timestamp>2016-01-01T0$n:00:00Z</timestamp>
      <contributor>
        <username>UserDoesNotExist</username>
        <id>1</id>
      </contributor>
      <model>wikitext</model>
      <format>text/x-wiki</format>
      <text xml:space="preserve" bytes="3">foo</text>
      <sha1>1e6gpc3ehk0mu2jqu8cg42g009s796b</sha1>
    </revision>
    <revision>
      <id>16</id>
      <timestamp>2016-01-01T0$n:00:01Z</timestamp>
      <contributor>
        <username>{$user->getName()}</username>
        <id>{$user->getId()}</id>
      </contributor>
      <model>wikitext</model>
      <format>text/x-wiki</format>
      <text xml:space="preserve" bytes="3">bar</text>
      <sha1>bjhlo6dxh5wivnszm93u4b78fheiy4t</sha1>
    </revision>
  </page>
</mediawiki>
EOF
		);
		// phpcs:enable

		$services = $this->getServiceContainer();
		$importer = $services->getWikiImporterFactory()->getWikiImporter( $source, $this->getTestSysop()->getAuthority() );

		$importer->setUsernamePrefix( 'Xxx', $assign );
		$importer->doImport();

		$db = $this->getDb();
		$row = $services->getRevisionStore()->newSelectQueryBuilder( $db )
			->where( [ 'rev_timestamp' => $db->timestamp( "201601010{$n}0000" ) ] )
			->caller( __METHOD__ )->fetchRow();
		$this->assertSame(
			$assign && $create ? 'UserDoesNotExist' : 'Xxx>UserDoesNotExist',
			$row->rev_user_text
		);
		$this->assertSame( $assign && $create ? $hookId : 0, (int)$row->rev_user );

		$row = $services->getRevisionStore()->newSelectQueryBuilder( $db )
			->where( [ 'rev_timestamp' => $db->timestamp( "201601010{$n}0001" ) ] )
			->caller( __METHOD__ )->fetchRow();
		$this->assertSame( ( $assign ? '' : 'Xxx>' ) . $user->getName(), $row->rev_user_text );
		$this->assertSame( $assign ? $user->getId() : 0, (int)$row->rev_user );
	}

	public static function provideUnknownUserHandling() {
		return [
			'no assign' => [ false, false ],
			'assign, no create' => [ true, false ],
			'assign, create' => [ true, true ],
		];
	}

}
PK       ! ,#  #  ,  import/ImportableOldRevisionImporterTest.phpnu Iw        <?php

use MediaWiki\Content\ContentHandler;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use Psr\Log\NullLogger;

/**
 * @group Database
 * @covers \ImportableOldRevisionImporter
 */
class ImportableOldRevisionImporterTest extends MediaWikiIntegrationTestCase {
	use TempUserTestTrait;

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

		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'tag1' );
	}

	public static function provideTestCases() {
		yield [ [] ];
		yield [ [ "tag1" ] ];
	}

	/**
	 * @dataProvider provideTestCases
	 */
	public function testImport( $expectedTags ) {
		$services = $this->getServiceContainer();

		$title = Title::newFromText( __CLASS__ . rand() );
		$revision = new WikiRevision();
		$revision->setTitle( $title );
		$revision->setTags( $expectedTags );
		$content = ContentHandler::makeContent( 'dummy edit', $title );
		$revision->setContent( SlotRecord::MAIN, $content );

		$importer = new ImportableOldRevisionImporter(
			true,
			new NullLogger(),
			$services->getConnectionProvider(),
			$services->getRevisionStoreFactory()->getRevisionStoreForImport(),
			$services->getSlotRoleRegistry(),
			$services->getWikiPageFactory(),
			$services->getPageUpdaterFactory(),
			$services->getUserFactory()
		);
		$result = $importer->import( $revision );
		$this->assertTrue( $result );

		$tags = $this->getServiceContainer()->getChangeTagsStore()->getTags(
			$this->getDb(),
			null,
			$title->getLatestRevID()
		);
		$this->assertSame( $expectedTags, $tags );
	}

	/**
	 * @covers \MediaWiki\Revision\RevisionStoreFactory::getRevisionStoreForImport
	 * @covers \MediaWiki\User\ActorMigration::newMigrationForImport
	 * @covers \MediaWiki\User\ActorStoreFactory::getActorStoreForImport
	 * @covers \MediaWiki\User\ActorStoreFactory::getActorNormalizationForImport
	 * @dataProvider provideTestCases
	 */
	public function testImportWithTempAccountsEnabled( $expectedTags ) {
		$this->enableAutoCreateTempUser();
		$this->testImport( $expectedTags );
	}
}
PK       ! !%      import/ImportExportTest.phpnu Iw        <?php

use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Tests\Maintenance\DumpAsserter;
use MediaWiki\Title\Title;
use Wikimedia\Rdbms\IDBAccessObject;
use Wikimedia\Rdbms\SelectQueryBuilder;

/**
 * Import/export round trip test.
 *
 * @group Database
 * @covers \WikiExporter
 * @covers \WikiImporter
 */
class ImportExportTest extends MediaWikiLangTestCase {

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

		$slotRoleRegistry = $this->getServiceContainer()->getSlotRoleRegistry();

		if ( !$slotRoleRegistry->isDefinedRole( 'ImportExportTest' ) ) {
			$slotRoleRegistry->defineRoleWithModel( 'ImportExportTest', CONTENT_MODEL_WIKITEXT );
		}
	}

	/**
	 * @param string $schemaVersion
	 *
	 * @return WikiExporter
	 */
	private function getExporter( string $schemaVersion ) {
		$exporter = $this->getServiceContainer()
			->getWikiExporterFactory()
			->getWikiExporter( $this->getDb(), WikiExporter::FULL );
		$exporter->setSchemaVersion( $schemaVersion );
		return $exporter;
	}

	/**
	 * @param ImportSource $source
	 *
	 * @return WikiImporter
	 */
	private function getImporter( ImportSource $source ) {
		$config = new HashConfig( [
			MainConfigNames::MaxArticleSize => 2048,
		] );
		$services = $this->getServiceContainer();
		$importer = new WikiImporter(
			$source,
			$this->getTestSysop()->getAuthority(),
			$config,
			$services->getHookContainer(),
			$services->getContentLanguage(),
			$services->getNamespaceInfo(),
			$services->getTitleFactory(),
			$services->getWikiPageFactory(),
			$services->getWikiRevisionUploadImporter(),
			$services->getContentHandlerFactory(),
			$services->getSlotRoleRegistry()
		);
		return $importer;
	}

	/**
	 * @param string $testName
	 *
	 * @return string[]
	 */
	private function getFilesToImport( string $testName ) {
		return glob( __DIR__ . "/../../data/import/$testName.import-*.xml" );
	}

	/**
	 * @param string $name
	 * @param string $schemaVersion
	 *
	 * @return string path of the dump file
	 */
	protected function getDumpTemplatePath( $name, $schemaVersion ) {
		return __DIR__ . "/../../data/import/$name.$schemaVersion.xml";
	}

	/**
	 * @param string $prefix
	 * @param string[] $keys
	 *
	 * @return string[]
	 */
	private function getUniqueNames( string $prefix, array $keys ) {
		$names = [];

		foreach ( $keys as $k ) {
			$names[$k] = "$prefix-$k-" . wfRandomString( 6 );
		}

		return $names;
	}

	/**
	 * @param string $xmlData
	 * @param string[] $pageTitles
	 *
	 * @return string
	 */
	private function injectPageTitles( string $xmlData, array $pageTitles ) {
		$keys = array_map( static function ( $name ) {
			return "{{{$name}_title}}";
		}, array_keys( $pageTitles ) );

		return str_replace(
			$keys,
			array_values( $pageTitles ),
			$xmlData
		);
	}

	/**
	 * @param Title $title
	 *
	 * @return RevisionRecord[]
	 */
	private function getRevisions( Title $title ) {
		$store = $this->getServiceContainer()->getRevisionStore();
		$queryBuilder = $store->newSelectQueryBuilder( $this->getDb() )
			->joinComment()
			->where( [ 'rev_page' => $title->getArticleID() ] )
			->orderBy( 'rev_id', SelectQueryBuilder::SORT_ASC );

		$rows = $queryBuilder->caller( __METHOD__ )->fetchResultSet();

		$status = $store->newRevisionsFromBatch( $rows );
		return $status->getValue();
	}

	/**
	 * @param string[] $pageTitles
	 *
	 * @return string[]
	 */
	private function getPageInfoVars( array $pageTitles ) {
		$vars = [];
		foreach ( $pageTitles as $name => $page ) {
			$title = Title::newFromText( $page );

			if ( !$title->exists( IDBAccessObject::READ_LATEST ) ) {
				// map only existing pages, since only they can appear in a dump
				continue;
			}

			$vars[ $name . '_pageid' ] = $title->getArticleID();
			$vars[ $name . '_title' ] = $title->getPrefixedDBkey();
			$vars[ $name . '_namespace' ] = $title->getNamespace();

			$n = 1;
			$revisions = $this->getRevisions( $title );
			foreach ( $revisions as $rev ) {
				$revkey = "{$name}_rev" . $n++;

				$vars[ $revkey . '_id' ] = $rev->getId();
				$vars[ $revkey . '_userid' ] = $rev->getUser()->getId();
			}
		}

		return $vars;
	}

	/**
	 * @param string $schemaVersion
	 * @return string[]
	 */
	private function getSiteVars( $schemaVersion ) {
		global $wgSitename, $wgDBname, $wgCapitalLinks;

		$vars = [];
		$vars['mw_version'] = MW_VERSION;
		$vars['schema_version'] = $schemaVersion;

		$vars['site_name'] = $wgSitename;
		$vars['project_namespace'] =
			$this->getServiceContainer()->getTitleFormatter()->getNamespaceName(
				NS_PROJECT,
				'Dummy'
			);
		$vars['site_db'] = $wgDBname;
		$vars['site_case'] = $wgCapitalLinks ? 'first-letter' : 'case-sensitive';
		$vars['site_base'] = Title::newMainPage()->getCanonicalURL();
		$vars['site_language'] = $this->getServiceContainer()->getContentLanguage()->getHtmlCode();

		return $vars;
	}

	public static function provideImportExport() {
		foreach ( XmlDumpWriter::$supportedSchemas as $schemaVersion ) {
			yield [ 'Basic', $schemaVersion ];
			yield [ 'Dupes', $schemaVersion ];
			yield [ 'Slots', $schemaVersion ];
			yield [ 'Interleaved', $schemaVersion ];
			yield [ 'InterleavedMulti', $schemaVersion ];
			yield [ 'MissingMainContentModel', $schemaVersion ];
			yield [ 'MissingSlotContentModel', $schemaVersion ];
		}
	}

	/**
	 * @dataProvider provideImportExport
	 */
	public function testImportExport( $testName, $schemaVersion ) {
		$asserter = new DumpAsserter( $schemaVersion );

		$filesToImport = $this->getFilesToImport( $testName );
		$fileToExpect = $this->getDumpTemplatePath( "$testName.expected", $schemaVersion );
		$siteInfoExpect = $this->getDumpTemplatePath( 'SiteInfo', $schemaVersion );

		$pageKeys = [ 'page1', 'page2', 'page3', 'page4', ];
		$pageTitles = $this->getUniqueNames( $testName, $pageKeys );

		// import each file
		foreach ( $filesToImport as $fileName ) {
			$xmlData = file_get_contents( $fileName );
			$xmlData = $this->injectPageTitles( $xmlData, $pageTitles );

			$source = new ImportStringSource( $xmlData );
			$importer = $this->getImporter( $source );
			$importer->doImport();
		}

		// write dump
		$exporter = $this->getExporter( $schemaVersion );

		$tmpFile = $this->getNewTempFile();
		$buffer = new DumpFileOutput( $tmpFile );

		$exporter->setOutputSink( $buffer );
		$exporter->openStream();
		$exporter->pagesByName( $pageTitles );
		$exporter->closeStream();

		// determine expected variable values
		$vars = array_merge(
			$this->getSiteVars( $schemaVersion ),
			$this->getPageInfoVars( $pageTitles )
		);

		foreach ( $vars as $key => $value ) {
			$asserter->setVarMapping( $key, $value );
		}

		$dumpData = file_get_contents( $tmpFile );
		$this->assertNotEmpty( $dumpData, 'Dump XML' );

		// check dump content
		$asserter->assertDumpStart( $tmpFile, $siteInfoExpect );
		$asserter->assertDOM( $fileToExpect );
		$asserter->assertDumpEnd();
	}

}
PK       ! $xY  Y    import/ImportFailureTest.phpnu Iw        <?php

use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;

/**
 * Import failure test.
 *
 * @group Database
 * @covers \WikiImporter
 */
class ImportFailureTest extends MediaWikiLangTestCase {

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

		$slotRoleRegistry = $this->getServiceContainer()->getSlotRoleRegistry();

		if ( !$slotRoleRegistry->isDefinedRole( 'ImportFailureTest' ) ) {
			$slotRoleRegistry->defineRoleWithModel( 'ImportFailureTest', CONTENT_MODEL_WIKITEXT );
		}
	}

	/**
	 * @param ImportSource $source
	 * @return WikiImporter
	 */
	private function getImporter( ImportSource $source ) {
		$config = new HashConfig( [
			MainConfigNames::MaxArticleSize => 2048,
		] );
		$services = $this->getServiceContainer();
		$importer = new WikiImporter(
			$source,
			$this->getTestSysop()->getAuthority(),
			$config,
			$services->getHookContainer(),
			$services->getContentLanguage(),
			$services->getNamespaceInfo(),
			$services->getTitleFactory(),
			$services->getWikiPageFactory(),
			$services->getWikiRevisionUploadImporter(),
			$services->getContentHandlerFactory(),
			$services->getSlotRoleRegistry()
		);
		return $importer;
	}

	/**
	 * @param string $testName
	 *
	 * @return string
	 */
	private function getFileToImport( string $testName ) {
		return __DIR__ . "/../../data/import/$testName.xml";
	}

	/**
	 * @param string $prefix
	 * @param string[] $keys
	 *
	 * @return string[]
	 */
	private function getUniqueNames( string $prefix, array $keys ) {
		$names = [];

		foreach ( $keys as $k ) {
			$names[$k] = "$prefix-$k-" . wfRandomString( 6 );
		}

		return $names;
	}

	/**
	 * @param string $xmlData
	 * @param string[] $pageTitles
	 *
	 * @return string
	 */
	private function injectPageTitles( string $xmlData, array $pageTitles ) {
		$keys = array_map( static function ( $name ) {
			return "{{{$name}_title}}";
		}, array_keys( $pageTitles ) );

		return str_replace(
			$keys,
			array_values( $pageTitles ),
			$xmlData
		);
	}

	public static function provideImportFailure() {
		yield [ 'BadXML', RuntimeException::class, '/^XML error at line 3: Opening and ending tag mismatch:.*$/' ];
		yield [ 'MissingMediaWikiTag', UnexpectedValueException::class, "/^Expected '<mediawiki>' tag, got .*$/" ];
		yield [ 'MissingMainTextField', InvalidArgumentException::class, '/^Missing text field in import.$/' ];
		yield [ 'MissingSlotTextField', InvalidArgumentException::class, '/^Missing text field in import.$/' ];
		yield [ 'MissingSlotRole', RuntimeException::class, '/^Missing role for imported slot.$/' ];
		yield [ 'UndefinedSlotRole', RuntimeException::class, '/^Undefined slot role .*$/' ];
		yield [ 'UndefinedContentModel', MWUnknownContentModelException::class, '/not registered on this wiki/' ];
	}

	/**
	 * @dataProvider provideImportFailure
	 */
	public function testImportFailure( $testName, $exceptionName, $exceptionMessage ) {
		$fileName = $this->getFileToImport( $testName );

		$pageKeys = [ 'page1', 'page2', 'page3', 'page4', ];
		$pageTitles = $this->getUniqueNames( $testName, $pageKeys );

		$xmlData = file_get_contents( $fileName );
		$xmlData = $this->injectPageTitles( $xmlData, $pageTitles );

		$source = new ImportStringSource( $xmlData );
		$importer = $this->getImporter( $source );
		$this->expectException( $exceptionName );
		$this->expectExceptionMessageMatches( $exceptionMessage );
		$importer->doImport();
	}
}
PK       ! T	m  m  -  import/ImportTemporaryUserIntegrationTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @group Database
 * @coversNothing
 */
class ImportTemporaryUserIntegrationTest extends MediaWikiIntegrationTestCase {
	use TempUserTestTrait;

	/**
	 * Test that category membership changes in RC function for imported anonymous revisions
	 * when temporary users are enabled (T373318).
	 */
	public function testShouldSuccessfullyUpdateCategoryMembershipInRecentChanges(): void {
		$this->enableAutoCreateTempUser();
		$this->overrideConfigValue( MainConfigNames::RCWatchCategoryMembership, true );

		$referenceTime = wfTimestampNow();
		ConvertibleTimestamp::setFakeTime( $referenceTime );

		$this->importTestData();
		$this->runJobs();

		$rc = RecentChange::newFromConds( [
			'rc_namespace' => NS_CATEGORY,
			'rc_title' => 'Test',
			'rc_source' => RecentChange::SRC_CATEGORIZE
		], __METHOD__ );

		$this->assertSame( '192.0.2.14', $rc->getPerformerIdentity()->getName() );
		$this->assertSame( $referenceTime, $rc->getAttribute( 'rc_timestamp' ) );
	}

	private function importTestData(): void {
		$file = __DIR__ . '/../../data/import/ImportAnonUserTest.xml';

		$importStreamSource = ImportStreamSource::newFromFile( $file );

		$this->assertTrue(
			$importStreamSource->isGood(),
			"Import source {$file} failed"
		);

		$importer = $this->getServiceContainer()
			->getWikiImporterFactory()
			->getWikiImporter( $importStreamSource->value, $this->getTestSysop()->getAuthority() );
		$importer->setDebug( true );

		$context = RequestContext::getMain();
		$context->setUser( $this->getTestUser()->getUser() );
		$reporter = new ImportReporter(
			$importer,
			false,
			'',
			false,
			$context
		);

		$reporter->open();
		$importer->doImport();
		$this->assertStatusGood( $reporter->close() );
	}
}
PK       ! ՙd	  d	  )  import/ImportLinkCacheIntegrationTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * Integration test that checks import success and
 * LinkCache integration.
 *
 * @group large
 * @group Database
 * @covers \ImportStreamSource
 * @covers \ImportReporter
 *
 * @author mwjames
 */
class ImportLinkCacheIntegrationTest extends MediaWikiIntegrationTestCase {

	/** @var Status */
	private $importStreamSource;

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

		$file = dirname( __DIR__ ) . '/../data/import/ImportLinkCacheIntegrationTest.xml';

		$this->importStreamSource = ImportStreamSource::newFromFile( $file );

		if ( !$this->importStreamSource->isGood() ) {
			$this->fail( "Import source for {$file} failed" );
		}
	}

	public function testImportForImportSource() {
		$this->doImport( $this->importStreamSource );

		// Imported title
		$loremIpsum = Title::makeTitle( NS_MAIN, 'Lorem ipsum' );

		$this->assertSame(
			$loremIpsum->getArticleID(),
			$loremIpsum->getArticleID( IDBAccessObject::READ_LATEST )
		);

		$categoryLoremIpsum = Title::makeTitle( NS_CATEGORY, 'Lorem ipsum' );

		$this->assertSame(
			$categoryLoremIpsum->getArticleID(),
			$categoryLoremIpsum->getArticleID( IDBAccessObject::READ_LATEST )
		);
	}

	/**
	 * @depends testImportForImportSource
	 */
	public function testReImportForImportSource() {
		$this->doImport( $this->importStreamSource );

		// ReImported title
		$loremIpsum = Title::makeTitle( NS_MAIN, 'Lorem ipsum' );

		$this->assertSame(
			$loremIpsum->getArticleID(),
			$loremIpsum->getArticleID( IDBAccessObject::READ_LATEST )
		);

		$categoryLoremIpsum = Title::makeTitle( NS_CATEGORY, 'Lorem ipsum' );

		$this->assertSame(
			$categoryLoremIpsum->getArticleID(),
			$categoryLoremIpsum->getArticleID( IDBAccessObject::READ_LATEST )
		);
	}

	private function doImport( $importStreamSource ) {
		$importer = $this->getServiceContainer()
			->getWikiImporterFactory()
			->getWikiImporter( $importStreamSource->value, $this->getTestSysop()->getAuthority() );
		$importer->setDebug( true );

		$context = RequestContext::getMain();
		$context->setUser( $this->getTestUser()->getUser() );
		$reporter = new ImportReporter(
			$importer,
			false,
			'',
			false,
			$context
		);

		$reporter->open();
		$importer->doImport();
		$this->assertStatusGood( $reporter->close() );
	}

}
PK       ! Ѿ      Rest/RequestFromGlobalsTest.phpnu Iw        <?php

use GuzzleHttp\Psr7\UploadedFile;
use MediaWiki\Rest\RequestFromGlobals;

// phpcs:disable MediaWiki.Usage.SuperGlobalsUsage.SuperGlobals

/**
 * @covers \MediaWiki\Rest\RequestFromGlobals
 */
class RequestFromGlobalsTest extends MediaWikiIntegrationTestCase {

	private RequestFromGlobals $reqFromGlobals;

	/**
	 * @var array
	 */
	private $oldServer;

	protected function setUp(): void {
		parent::setUp();
		$this->reqFromGlobals = new RequestFromGlobals();

		$this->oldServer = $_SERVER;
	}

	protected function tearDown(): void {
		$_SERVER = $this->oldServer;
		parent::tearDown();
	}

	/**
	 * @dataProvider provideGetMethod
	 */
	public function testGetMethod( $serverVars, $expected ) {
		$this->setServerVars( $serverVars );
		$this->assertEquals( $expected, $this->reqFromGlobals->getMethod() );
	}

	public static function provideGetMethod() {
		return [
			[
				[
					'REQUEST_METHOD' => 'POST'
				],
				'POST',
			],
			[
				[],
				'GET',
			],
			[
				[ 'REQUEST_METHOD' => 'get' ],
				'GET',
			],
		];
	}

	public function testGetUri() {
		$this->setServerVars( [
			'REQUEST_URI' => '/test.php'
		] );

		$this->assertEquals( '/test.php', $this->reqFromGlobals->getUri() );
	}

	public function testGetUri2() {
		$this->setServerVars( [
			'REQUEST_URI' => ':8434/test.php/page/1:1',
		] );

		$this->assertEquals( '/test.php/page/1:1', $this->reqFromGlobals->getUri() );
	}

	public function testGetUri3() {
		$this->setServerVars( [
			'REQUEST_URI' => '/w/rest.php/sandbox.semantic-mediawiki.org:8142/v3/page/html/Berlin',
		] );

		$this->assertEquals(
			'/w/rest.php/sandbox.semantic-mediawiki.org:8142/v3/page/html/Berlin',
			$this->reqFromGlobals->getUri()
		);
	}

	/**
	 * @dataProvider provideGetProtocolVersion
	 */
	public function testGetProtocolVersion( $serverVars, $expected ) {
		$this->setServerVars( $serverVars );
		$this->assertEquals( $expected, $this->reqFromGlobals->getProtocolVersion() );
	}

	public static function provideGetProtocolVersion() {
		return [
			[
				[
					'SERVER_PROTOCOL' => 'HTTP/2'
				],
				2
			],
			[
				[],
				1.1
			]
		];
	}

	public function testGetHeaders() {
		$this->setServerVars( [
			'HTTP_HOST' => '[::1]',
			'CONTENT_LENGTH' => 6,
			'CONTENT_TYPE' => 'application/json',
			'CONTENT_MD5' => 'rL0Y20zC+Fzt72VPzMSk2A==',
		] );

		$this->assertEquals( [
			'Host' => [ '[::1]' ],
			'Content-Length' => [ 6 ],
			'Content-Type' => [ 'application/json' ],
			'Content-Md5' => [ 'rL0Y20zC+Fzt72VPzMSk2A==' ],
		], $this->reqFromGlobals->getHeaders() );
	}

	public function testGetHeaderKeyIsCaseInsensitive() {
		$cacheControl = 'private, must-revalidate, max-age=0';
		$this->setServerVars( [ 'HTTP_CACHE_CONTROL' => $cacheControl ] );

		$this->assertSame( [ $cacheControl ], $this->reqFromGlobals->getHeader( 'Cache-Control' ) );
		$this->assertSame( [ $cacheControl ], $this->reqFromGlobals->getHeader( 'cache-control' ) );
	}

	public function testGetBody() {
		$this->setServerVars( [
			'REQUEST_METHOD' => 'POST',
			'HTTP_ACCEPT' => 'text/html'
		] );

		$this->assertSame( '', $this->reqFromGlobals->getBody()->getContents() );
	}

	public function testGetServerParams() {
		$serverVars = [
			'SERVER_NAME' => 'www.mediawiki.org',
			'SERVER_PROTOCOL' => 'HTTP/1.1',
			'REQUEST_METHOD' => 'POST',
			'HTTP_HOST' => 'www.mediawiki.org',
			'HTTP_ACCEPT' => 'text/html',
			'REMOTE_PORT' => '1234',
			'SCRIPT_NAME' => '/index.php',
		];
		$this->setServerVars( $serverVars );

		$expectedServerParams = $this->reqFromGlobals->getServerParams();

		$diffs = array_diff_assoc( $expectedServerParams, $serverVars );

		$this->assertCount( 2, $diffs );
		$this->assertArrayHasKey( 'REQUEST_TIME_FLOAT', $diffs );
		$this->assertArrayHasKey( 'REQUEST_TIME', $diffs );
	}

	public function testGetCookieParams() {
		$_COOKIE = [
			'testcookie' => true
		];

		$this->assertEquals( [ 'testcookie' => true ], $this->reqFromGlobals->getCookieParams() );
	}

	public function testGetQueryParams() {
		$query = [
			[
				'title' => 'foo',
				'action' => 'query'
			]
		];
		$_GET = $query;

		$this->assertEquals( $query, $this->reqFromGlobals->getQueryParams() );
	}

	public function testGetUploadedFiles() {
		$_FILES = [
			'file' => [
				'name' => 'Foo.txt',
				'type' => 'text/plain',
				'tmp_name' => '/tmp/foobar',
				'error' => UPLOAD_ERR_OK,
				'size' => 20,
			]
		];

		$this->assertEquals(
			[
				'file' => new UploadedFile(
					'/tmp/foobar',
					20, UPLOAD_ERR_OK,
					'Foo.txt',
					'text/plain'
				)
			],
			$this->reqFromGlobals->getUploadedFiles()
		);
	}

	public function testGetPostParams() {
		$form = [
			'token' => '983yh4edji',
			'action' => 'login'
		];
		$_POST = $form;

		$this->assertEquals( $form, $this->reqFromGlobals->getPostParams() );
	}

	protected function setServerVars( $vars ) {
		// Don't remove vars which should be available in all SAPI.
		if ( !isset( $vars['REQUEST_TIME_FLOAT'] ) ) {
			$vars['REQUEST_TIME_FLOAT'] = $_SERVER['REQUEST_TIME_FLOAT'];
		}
		if ( !isset( $vars['REQUEST_TIME'] ) ) {
			$vars['REQUEST_TIME'] = $_SERVER['REQUEST_TIME'];
		}
		$_SERVER = $vars;
	}
}
PK       ! 0Ny
  y
    Rest/EntryPointTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest;

use GuzzleHttp\Psr7\Stream;
use GuzzleHttp\Psr7\Uri;
use MediaWiki\MainConfigNames;
use MediaWiki\Rest\EntryPoint;
use MediaWiki\Rest\Handler;
use MediaWiki\Rest\RequestData;
use MediaWiki\Rest\RequestInterface;
use MediaWiki\Tests\MockEnvironment;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Rest\EntryPoint
 */
class EntryPointTest extends MediaWikiIntegrationTestCase {
	use RestTestTrait;

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

		$this->overrideConfigValue( MainConfigNames::RestPath, '/rest' );
	}

	private function createRouter( RequestInterface $request ) {
		return $this->newRouter( [
			'request' => $request
		] );
	}

	/**
	 * @param RequestData $request
	 * @param MockEnvironment $env
	 *
	 * @return EntryPoint
	 */
	private function getEntryPoint( RequestData $request, MockEnvironment $env ): EntryPoint {
		$entryPoint = new EntryPoint(
			$request,
			$env->makeFauxContext(),
			$env,
			$this->getServiceContainer()
		);

		$entryPoint->setRouter( $this->createRouter( $request ) );
		return $entryPoint;
	}

	public static function mockHandlerHeader() {
		return new class extends Handler {
			public function execute() {
				$response = $this->getResponseFactory()->create();
				$response->setHeader( 'Foo', 'Bar' );
				return $response;
			}
		};
	}

	public function testHeader() {
		$uri = '/rest/mock/v1/EntryPoint/header';
		$request = new RequestData( [ 'uri' => new Uri( $uri ) ] );

		$env = new MockEnvironment();
		$env->setRequestInfo( $uri );

		$entryPoint = $this->getEntryPoint(
			$request,
			$env
		);

		$entryPoint->enableOutputCapture();
		$entryPoint->run();
		$env->assertHeaderValue( 'Bar', 'Foo' );
		$env->assertStatusCode( 200 );
	}

	public static function mockHandlerBodyRewind() {
		return new class extends Handler {
			public function execute() {
				$response = $this->getResponseFactory()->create();
				$stream = new Stream( fopen( 'php://memory', 'w+' ) );
				$stream->write( 'hello' );
				$response->setBody( $stream );
				return $response;
			}
		};
	}

	/**
	 * Make sure EntryPoint rewinds a seekable body stream before reading.
	 */
	public function testBodyRewind() {
		$uri = '/rest/mock/v1/EntryPoint/bodyRewind';
		$request = new RequestData( [ 'uri' => new Uri( $uri ) ] );

		$env = new MockEnvironment();
		$env->setRequestInfo( $uri );

		$entryPoint = $this->getEntryPoint(
			$request,
			$env
		);

		$entryPoint->enableOutputCapture();
		$entryPoint->run();

		// NOTE: MediaWikiEntryPoint::doPostOutputShutdown flushes all output buffers
		$this->assertStringContainsString( 'hello', $entryPoint->getCapturedOutput() );
	}

}
PK       ! UGJ  J  %  Storage/NameTableStoreFactoryTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Storage;

use MediaWiki\Logger\LoggerFactory;
use MediaWiki\Storage\NameTableStore;
use MediaWiki\Storage\NameTableStoreFactory;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\Rdbms\ILBFactory;
use Wikimedia\Rdbms\ILoadBalancer;

/**
 * @covers \MediaWiki\Storage\NameTableStoreFactory
 * @group Database
 */
class NameTableStoreFactoryTest extends MediaWikiIntegrationTestCase {
	/**
	 * @param string $localDomain
	 * @return MockObject|ILoadBalancer
	 */
	private function getMockLoadBalancer( $localDomain ) {
		$mock = $this->createMock( ILoadBalancer::class );

		$mock->method( 'getLocalDomainID' )
			->willReturn( $localDomain );

		return $mock;
	}

	/**
	 * @param string $expectedWiki
	 * @return MockObject|ILBFactory
	 */
	private function getMockLoadBalancerFactory( $expectedWiki ) {
		$mock = $this->createMock( ILBFactory::class );

		$lbFactory = $this->getServiceContainer()->getDBLoadBalancerFactory();
		$localDomain = $lbFactory->getLocalDomainID();

		$mock->method( 'getLocalDomainID' )->willReturn( $localDomain );

		$mock->expects( $this->once() )
			->method( 'getMainLB' )
			->with( $expectedWiki )
			->willReturnCallback( function ( $domain ) use ( $localDomain ) {
				return $this->getMockLoadBalancer( $localDomain );
			} );

		return $mock;
	}

	public static function provideTestGet() {
		return [
			[
				'change_tag_def',
				false,
				false,
			],
			[
				'content_models',
				false,
				false,
			],
			[
				'slot_roles',
				false,
				false,
			],
			[
				'change_tag_def',
				'test7245',
				'test7245',
			],
		];
	}

	/** @dataProvider provideTestGet */
	public function testGet( $tableName, $wiki, $expectedWiki ) {
		$services = $this->getServiceContainer();
		$wiki2 = ( $wiki === false )
			? $services->getDBLoadBalancerFactory()->getLocalDomainID()
			: $wiki;
		$names = new NameTableStoreFactory(
			$this->getMockLoadBalancerFactory( $expectedWiki ),
			$services->getMainWANObjectCache(),
			LoggerFactory::getInstance( 'NameTableStoreFactory' )
		);

		$table = $names->get( $tableName, $wiki );
		$table2 = $names->get( $tableName, $wiki2 );
		$this->assertSame( $table, $table2 );
		$this->assertInstanceOf( NameTableStore::class, $table );
	}

	/*
	 * The next three integration tests verify that the schema information is correct by loading
	 * the relevant information from the database.
	 */

	public function testIntegratedGetChangeTagDef() {
		$services = $this->getServiceContainer();
		$factory = $services->getNameTableStoreFactory();
		$store = $factory->getChangeTagDef();
		$this->assertIsArray( $store->getMap() );
	}

	public function testIntegratedGetContentModels() {
		$services = $this->getServiceContainer();
		$factory = $services->getNameTableStoreFactory();
		$store = $factory->getContentModels();
		$this->assertIsArray( $store->getMap() );
	}

	public function testIntegratedGetSlotRoles() {
		$services = $this->getServiceContainer();
		$factory = $services->getNameTableStoreFactory();
		$store = $factory->getSlotRoles();
		$this->assertIsArray( $store->getMap() );
	}
}
PK       ! ï-  -    Storage/PageUpdaterTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Storage;

use LogicException;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\Content;
use MediaWiki\Content\TextContent;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Json\FormatJson;
use MediaWiki\Message\Message;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Revision\RenderedRevision;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Status\Status;
use MediaWiki\Storage\EditResult;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use MediaWikiIntegrationTestCase;
use RecentChange;
use WikiPage;

/**
 * @covers \MediaWiki\Storage\PageUpdater
 * @group Database
 */
class PageUpdaterTest extends MediaWikiIntegrationTestCase {

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

		$slotRoleRegistry = $this->getServiceContainer()->getSlotRoleRegistry();

		if ( !$slotRoleRegistry->isDefinedRole( 'aux' ) ) {
			$slotRoleRegistry->defineRoleWithModel(
				'aux',
				CONTENT_MODEL_WIKITEXT
			);
		}

		if ( !$slotRoleRegistry->isDefinedRole( 'derivedslot' ) ) {
			$slotRoleRegistry->defineRoleWithModel(
				'derivedslot',
				CONTENT_MODEL_WIKITEXT,
				[],
				true
			);
		}
	}

	private function getDummyTitle( $method ) {
		return Title::newFromText( $method, $this->getDefaultWikitextNS() );
	}

	/**
	 * @param int $revId
	 *
	 * @return null|RecentChange
	 */
	private function getRecentChangeFor( $revId ) {
		$qi = RecentChange::getQueryInfo();
		$row = $this->getDb()->newSelectQueryBuilder()
			->queryInfo( $qi )
			->where( [ 'rc_this_oldid' => $revId ] )
			->caller( __METHOD__ )
			->fetchRow();

		return $row ? RecentChange::newFromRow( $row ) : null;
	}

	/**
	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
	 * @covers \WikiPage::newPageUpdater()
	 */
	public function testCreatePage() {
		$user = $this->getTestUser()->getUser();
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();

		$title = $this->getDummyTitle( __METHOD__ );
		$page = $wikiPageFactory->newFromTitle( $title );
		$updater = $page->newPageUpdater( $user );

		$oldStats = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'site_stats' )
			->where( '1=1' )
			->fetchRow();

		$this->assertFalse( $updater->wasCommitted(), 'wasCommitted' );

		$updater->addTag( 'foo' );
		$updater->addTags( [ 'bar', 'qux' ] );

		$tags = $updater->getExplicitTags();
		sort( $tags );
		$this->assertSame( [ 'bar', 'foo', 'qux' ], $tags, 'getExplicitTags' );

		// TODO: MCR: test additional slots
		$content = new TextContent( 'Lorem Ipsum' );
		$updater->setContent( SlotRecord::MAIN, $content );

		$parent = $updater->grabParentRevision();

		$this->assertNull( $parent, 'getParentRevision' );
		$this->assertFalse( $updater->wasCommitted(), 'wasCommitted' );

		// TODO: test that hasEditConflict() grabs the parent revision
		$this->assertFalse( $updater->hasEditConflict( 0 ), 'hasEditConflict' );
		$this->assertTrue( $updater->hasEditConflict( 1 ), 'hasEditConflict' );

		// TODO: test failure with EDIT_UPDATE
		// TODO: test EDIT_BOT, etc
		$updater->setFlags( EDIT_MINOR );
		$summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
		$rev = $updater->saveRevision( $summary );

		$this->assertNotNull( $rev );
		$this->assertSame( 0, $rev->getParentId() );
		$this->assertSame( $summary->text, $rev->getComment( RevisionRecord::RAW )->text );
		$this->assertSame( $user->getName(), $rev->getUser( RevisionRecord::RAW )->getName() );

		$this->assertTrue( $updater->wasCommitted(), 'wasCommitted()' );
		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
		$this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
		$this->assertTrue( $updater->isNew(), 'isNew()' );
		$this->assertTrue( $updater->wasRevisionCreated(), 'wasRevisionCreated()' );
		$this->assertNotNull( $updater->getNewRevision(), 'getNewRevision()' );
		$this->assertInstanceOf(
			RevisionRecord::class,
			$updater->getStatus()->getNewRevision()
		);

		// check the EditResult object
		$this->assertFalse( $updater->getEditResult()->getOriginalRevisionId(),
			'EditResult::getOriginalRevisionId()' );
		$this->assertSame( 0, $updater->getEditResult()->getUndidRevId(),
			'EditResult::getUndidRevId()' );
		$this->assertTrue( $updater->getEditResult()->isNew(), 'EditResult::isNew()' );
		$this->assertFalse( $updater->getEditResult()->isRevert(), 'EditResult::isRevert()' );

		$rev = $updater->getNewRevision();
		$revContent = $rev->getContent( SlotRecord::MAIN );
		$this->assertSame( 'Lorem Ipsum', $revContent->serialize(), 'revision content' );
		$this->assertTrue( $rev->isMinor(), 'RevisionRecord::isMinor()' );

		// were the WikiPage and Title objects updated?
		$this->assertTrue( $page->exists(), 'WikiPage::exists()' );
		$this->assertTrue( $title->exists(), 'Title::exists()' );
		$this->assertSame( $rev->getId(), $page->getLatest(), 'WikiPage::getLatest()' );
		$this->assertNotNull( $page->getRevisionRecord(), 'WikiPage::getRevisionRecord()' );

		// re-load
		$page2 = $wikiPageFactory->newFromTitle( $title );
		$this->assertTrue( $page2->exists(), 'WikiPage::exists()' );
		$this->assertSame( $rev->getId(), $page2->getLatest(), 'WikiPage::getLatest()' );
		$this->assertNotNull( $page2->getRevisionRecord(), 'WikiPage::getRevisionRecord()' );

		// Check RC entry
		$rc = $this->getRecentChangeFor( $rev->getId() );
		$this->assertNotNull( $rc, 'RecentChange' );

		// check site stats - this asserts that derived data updates where run.
		$stats = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'site_stats' )
			->where( '1=1' )
			->fetchRow();
		$this->assertSame( $oldStats->ss_total_pages + 1, (int)$stats->ss_total_pages );
		$this->assertSame( $oldStats->ss_total_edits + 1, (int)$stats->ss_total_edits );

		// re-edit with same content - should be a "null-edit"
		$updater = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, $content );

		$summary = CommentStoreComment::newUnsavedComment( 're-edit' );
		$rev = $updater->saveRevision( $summary );
		$status = $updater->getStatus();

		$this->assertNull( $rev, 'getNewRevision()' );
		$this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
		$this->assertFalse( $updater->wasRevisionCreated(), 'wasRevisionCreated' );
		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
		$this->assertStatusWarning( 'edit-no-change', $status );
	}

	/**
	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
	 * @covers \WikiPage::newPageUpdater()
	 */
	public function testUpdatePage() {
		$user = $this->getTestUser()->getUser();

		$title = $this->getDummyTitle( __METHOD__ );
		$this->insertPage( $title );
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();

		$page = $wikiPageFactory->newFromTitle( $title );
		$parentId = $page->getLatest();

		$updater = $page->newPageUpdater( $user );

		$oldStats = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'site_stats' )
			->where( '1=1' )
			->fetchRow();

		$updater->setOriginalRevisionId( 7 );

		$this->assertFalse( $updater->hasEditConflict( $parentId ), 'hasEditConflict' );
		$this->assertTrue( $updater->hasEditConflict( $parentId - 1 ), 'hasEditConflict' );
		$this->assertTrue( $updater->hasEditConflict( 0 ), 'hasEditConflict' );

		// TODO: MCR: test additional slots
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );

		// Check that prepareUpdate() does not fail, and the flag is applied.
		$updater->prepareUpdate( EDIT_MINOR );

		// TODO: test all flags for saveRevision()!
		$summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
		$rev = $updater->saveRevision( $summary );

		$this->assertNotNull( $rev );
		$this->assertSame( $parentId, $rev->getParentId() );
		$this->assertSame( $summary->text, $rev->getComment( RevisionRecord::RAW )->text );
		$this->assertSame( $user->getName(), $rev->getUser( RevisionRecord::RAW )->getName() );

		$this->assertTrue( $updater->wasCommitted(), 'wasCommitted()' );
		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
		$this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
		$this->assertFalse( $updater->isNew(), 'isNew()' );
		$this->assertNotNull( $updater->getNewRevision(), 'getNewRevision()' );
		$this->assertInstanceOf(
			RevisionRecord::class,
			$updater->getStatus()->getNewRevision()
		);
		$this->assertTrue( $updater->wasRevisionCreated(), 'wasRevisionCreated()' );

		// check the EditResult object
		$this->assertSame( 7, $updater->getEditResult()->getOriginalRevisionId(),
			'EditResult::getOriginalRevisionId()' );
		$this->assertSame( 0, $updater->getEditResult()->getUndidRevId(),
			'EditResult::getUndidRevId()' );
		$this->assertFalse( $updater->getEditResult()->isNew(), 'EditResult::isNew()' );
		$this->assertFalse( $updater->getEditResult()->isRevert(), 'EditResult::isRevert()' );

		// TODO: Test null revision (with different user): new revision!

		$rev = $updater->getNewRevision();
		$revContent = $rev->getContent( SlotRecord::MAIN );
		$this->assertSame( 'Lorem Ipsum', $revContent->serialize(), 'revision content' );
		$this->assertTrue( $rev->isMinor(), 'RevisionRecord::isMinor()' );

		// were the WikiPage and Title objects updated?
		$this->assertTrue( $page->exists(), 'WikiPage::exists()' );
		$this->assertTrue( $title->exists(), 'Title::exists()' );
		$this->assertSame( $rev->getId(), $page->getLatest(), 'WikiPage::getLatest()' );
		$this->assertNotNull( $page->getRevisionRecord(), 'WikiPage::getRevisionRecord()' );

		// re-load
		$page2 = $wikiPageFactory->newFromTitle( $title );
		$this->assertTrue( $page2->exists(), 'WikiPage::exists()' );
		$this->assertSame( $rev->getId(), $page2->getLatest(), 'WikiPage::getLatest()' );
		$this->assertNotNull( $page2->getRevisionRecord(), 'WikiPage::getRevisionRecord()' );

		// Check RC entry
		$rc = $this->getRecentChangeFor( $rev->getId() );
		$this->assertNotNull( $rc, 'RecentChange' );

		// re-edit
		$updater = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new TextContent( 'dolor sit amet' ) );

		$summary = CommentStoreComment::newUnsavedComment( 're-edit' );
		$updater->saveRevision( $summary );
		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
		$this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
		$this->assertNotNull( $updater->getNewRevision(), 'getNewRevision()' );

		$topRevisionId = $updater->getNewRevision()->getId();

		// perform a null edit
		$updater = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new TextContent( 'dolor sit amet' ) );
		$summary = CommentStoreComment::newUnsavedComment( 'null edit' );
		$updater->saveRevision( $summary );

		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
		$this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
		$this->assertFalse( $updater->wasRevisionCreated(), 'wasRevisionCreated()' );
		$this->assertTrue(
			$updater->getEditResult()->isNullEdit(),
			'getEditResult()->isNullEdit()'
		);
		$this->assertSame(
			$topRevisionId,
			$updater->getEditResult()->getOriginalRevisionId(),
			'getEditResult()->getOriginalRevisionId()'
		);

		// check site stats - this asserts that derived data updates where run.
		$stats = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'site_stats' )
			->where( '1=1' )
			->fetchRow();
		$this->assertNotNull( $stats, 'site_stats' );
		$this->assertSame( $oldStats->ss_total_pages + 0, (int)$stats->ss_total_pages );
		$this->assertSame( $oldStats->ss_total_edits + 2, (int)$stats->ss_total_edits );
	}

	public function testSetForceEmptyRevisionSetsOriginalRevisionId() {
		$user = $this->getTestUser()->getUser();
		$title = $this->getDummyTitle( __METHOD__ );
		$this->insertPage( $title );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$parentId = $page->getLatest();
		$updater = $page->newPageUpdater( $user );
		$updater->setForceEmptyRevision( true );
		// Saving without changing the content should now create a new revisiopns
		$summary = CommentStoreComment::newUnsavedComment( 'dummy revision' );
		$rev = $updater->saveRevision( $summary );
		$status = $updater->getStatus();
		$this->assertNotNull( $rev, 'getNewRevision()' );
		$this->assertNotNull( $updater->getNewRevision(), 'getNewRevision()' );
		$this->assertNotSame( $parentId, $rev->getId(), 'new revision ID' );
		$this->assertTrue( $updater->wasRevisionCreated(), 'wasRevisionCreated' );
		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
		$this->assertStatusGood( $status );
		// Setting setForceEmptyRevision causes the original revision to be set.
		$this->assertEquals( $parentId, $updater->getEditResult()->getOriginalRevisionId() );
	}

	public function testSetForceEmptyRevisionCausesSaveToFailWithChangedContent() {
		$user = $this->getTestUser()->getUser();
		$title = $this->getDummyTitle( __METHOD__ );
		$this->insertPage( $title );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$updater = $page->newPageUpdater( $user );
		$updater->setForceEmptyRevision( true );
		// Setting setForceEmptyRevision causes saveRevision() to fail if the content is changed.
		// The positive case with setForceEmptyRevision() causing a new revision to be created
		// is tested
		$this->expectException( LogicException::class );
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Changed Content' ) );
		$summary = CommentStoreComment::newUnsavedComment( 'dummy revision' );
		$updater->saveRevision( $summary );
	}

	public function testRevert() {
		// Setup a page with some edits
		$page = $this->getExistingTestPage( __METHOD__ );

		$user = $this->getTestUser()->getUser();

		$summary = CommentStoreComment::newUnsavedComment( '1' );
		$updater = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new TextContent( '1' ) );
		$updater->saveRevision( $summary );
		$revId1 = $updater->getNewRevision()->getId();

		$summary = CommentStoreComment::newUnsavedComment( '2' );
		$updater = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new TextContent( '2' ) );
		$updater->saveRevision( $summary );
		$revId2 = $updater->getNewRevision()->getId();

		// Perform a rollback
		$updater = $page->newPageUpdater( $this->getTestSysop()->getUser() )
			->setContent( SlotRecord::MAIN, new TextContent( '1' ) )
			->markAsRevert( EditResult::REVERT_ROLLBACK, $revId2, $revId1 );
		$summary = CommentStoreComment::newUnsavedComment( 'revert' );
		$updater->saveRevision( $summary );

		// Do some basic assertions on PageUpdater
		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
		$this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );

		$editResult = $updater->getEditResult();
		$this->assertNotNull( $editResult, 'getEditResult()' );
		$this->assertTrue( $editResult->isRevert(), 'EditResult::isRevert()' );
		$this->assertTrue( $editResult->isExactRevert(), 'EditResult::isExactRevert()' );
		$this->assertSame(
			$revId1,
			$editResult->getOriginalRevisionId(),
			'EditResult::getOriginalRevisionId()'
		);
		$this->assertSame(
			EditResult::REVERT_ROLLBACK,
			$editResult->getRevertMethod(),
			'EditResult::getRevertMethod()'
		);
		$this->assertSame(
			$revId2,
			$editResult->getOldestRevertedRevisionId(),
			'EditResult::getOldestRevertedRevisionId()'
		);
		$this->assertSame(
			$revId2,
			$editResult->getNewestRevertedRevisionId(),
			'EditResult::getNewestRevertedRevisionId()'
		);

		// Ensure all deferred updates are run
		DeferredUpdates::doUpdates();

		// Retrieve the mw-rollback change tag and verify it
		$newRevId = $updater->getNewRevision()->getId();
		$this->newSelectQueryBuilder()
			->select( 'ct_params' )
			->from( 'change_tag' )
			->where( [ 'ct_rev_id' => $newRevId ] )
			->assertFieldValue( FormatJson::encode( $editResult ) );
	}

	/**
	 * Creates a revision in the database.
	 *
	 * @param WikiPage $page
	 * @param string|Message|CommentStoreComment $summary
	 * @param null|string|Content $content
	 *
	 * @return RevisionRecord|null
	 */
	private function createRevision( WikiPage $page, $summary, $content = null ) {
		$user = $this->getTestUser()->getUser();

		$comment = CommentStoreComment::newUnsavedComment( $summary );

		if ( !$content instanceof Content ) {
			$content = new TextContent( $content ?? $summary );
		}

		return $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, $content )
			->saveRevision( $comment );
	}

	/**
	 * Verify that MultiContentSave hook is called by saveRevision() with correct parameters.
	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
	 */
	public function testMultiContentSaveHook() {
		$user = $this->getTestUser()->getUser();

		$title = $this->getDummyTitle( __METHOD__ );

		// TODO: MCR: test additional slots
		$slots = [
			SlotRecord::MAIN => new TextContent( 'Lorem Ipsum' )
		];

		// start editing non-existing page
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$updater = $page->newPageUpdater( $user );
		foreach ( $slots as $slot => $content ) {
			$updater->setContent( $slot, $content );
		}

		$summary = CommentStoreComment::newUnsavedComment( 'Just a test' );

		$expected = [
			'user' => $user,
			'title' => $title,
			'slots' => $slots,
			'summary' => $summary
		];
		$hookFired = false;
		$this->setTemporaryHook( 'MultiContentSave',
			function ( RenderedRevision $renderedRevision, UserIdentity $user,
				$summary, $flags, Status $hookStatus
			) use ( &$hookFired, $expected ) {
				$hookFired = true;

				$this->assertSame( $expected['summary'], $summary );
				$this->assertSame( EDIT_NEW, $flags );

				$this->assertTrue(
					$expected['title']->isSamePageAs( $renderedRevision->getRevision()->getPage() )
				);
				$this->assertTrue(
					$expected['title']->isSameLinkAs( $renderedRevision->getRevision()->getPageAsLinkTarget() )
				);

				$slots = $renderedRevision->getRevision()->getSlots();
				foreach ( $expected['slots'] as $slot => $content ) {
					$this->assertSame( $content, $slots->getSlot( $slot )->getContent() );
				}

				// Don't abort this edit.
				return true;
			}
		);

		$rev = $updater->saveRevision( $summary );
		$this->assertTrue( $hookFired, "MultiContentSave hook wasn't called." );
		$this->assertNotNull( $rev,
			"MultiContentSave returned true, but revision wasn't created." );
	}

	/**
	 * Verify that MultiContentSave hook can abort saveRevision() by returning false.
	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
	 */
	public function testMultiContentSaveHookAbort() {
		$user = $this->getTestUser()->getUser();
		$title = $this->getDummyTitle( __METHOD__ );

		// start editing non-existing page
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$updater = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );

		$summary = CommentStoreComment::newUnsavedComment( 'Just a test' );

		$expectedError = 'aborted-by-test-hook';
		$this->setTemporaryHook( 'MultiContentSave',
			static function ( RenderedRevision $renderedRevision, UserIdentity $user,
				$summary, $flags, Status $hookStatus
			) use ( $expectedError ) {
				$hookStatus->fatal( $expectedError );

				// Returning false should disallow saveRevision() to continue saving this revision.
				return false;
			}
		);

		$rev = $updater->saveRevision( $summary );
		$this->assertNull( $rev,
			"MultiContentSave returned false, but revision was still created." );

		$status = $updater->getStatus();
		$this->assertStatusError( $expectedError, $status,
			"MultiContentSave returned false, but Status is not fatal." );
	}

	/**
	 * @covers \MediaWiki\Storage\PageUpdater::grabParentRevision()
	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
	 */
	public function testCompareAndSwapFailure() {
		$user = $this->getTestUser()->getUser();

		$title = $this->getDummyTitle( __METHOD__ );
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();

		// start editing non-existing page
		$page = $wikiPageFactory->newFromTitle( $title );
		$updater = $page->newPageUpdater( $user );
		$updater->grabParentRevision();

		// create page concurrently
		$concurrentPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$this->createRevision( $concurrentPage, __METHOD__ . '-one' );

		// try creating the page - should trigger CAS failure.
		$summary = CommentStoreComment::newUnsavedComment( 'create?!' );
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
		$updater->saveRevision( $summary );
		$status = $updater->getStatus();

		$this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
		$this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
		$this->assertStatusError( 'edit-already-exists', $status, 'edit-conflict' );

		// start editing existing page
		$page = $wikiPageFactory->newFromTitle( $title );
		$updater = $page->newPageUpdater( $user );
		$updater->grabParentRevision();

		// update page concurrently
		$concurrentPage = $wikiPageFactory->newFromTitle( $title );
		$this->createRevision( $concurrentPage, __METHOD__ . '-two' );

		// try creating the page - should trigger CAS failure.
		$summary = CommentStoreComment::newUnsavedComment( 'edit?!' );
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'dolor sit amet' ) );
		$updater->saveRevision( $summary );
		$status = $updater->getStatus();

		$this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
		$this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
		$this->assertStatusError( 'edit-conflict', $status, 'edit-conflict' );
	}

	/**
	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
	 */
	public function testFailureOnEditFlags() {
		$user = $this->getTestUser()->getUser();

		$title = $this->getDummyTitle( __METHOD__ );

		// start editing non-existing page
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$updater = $page->newPageUpdater( $user );

		// update with EDIT_UPDATE flag should fail
		$summary = CommentStoreComment::newUnsavedComment( 'udpate?!' );
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
		$updater->saveRevision( $summary, EDIT_UPDATE );
		$status = $updater->getStatus();

		$this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
		$this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
		$this->assertStatusError( 'edit-gone-missing', $status, 'edit-gone-missing' );

		// create the page
		$this->createRevision( $page, __METHOD__ );

		// update with EDIT_NEW flag should fail
		$summary = CommentStoreComment::newUnsavedComment( 'create?!' );
		$updater = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new TextContent( 'dolor sit amet' ) );
		$updater->saveRevision( $summary, EDIT_NEW );
		$status = $updater->getStatus();

		$this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
		$this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
		$this->assertStatusError( 'edit-already-exists', $status, 'edit-already-exists' );
	}

	/**
	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
	 */
	public function testFailureOnBadContentModel() {
		$user = $this->getTestUser()->getUser();

		$title = $this->getDummyTitle( __METHOD__ );

		// start editing non-existing page
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		// plain text content should fail in aux slot (the main slot doesn't care)
		$updater = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new TextContent( 'Main Content' ) )
			->setContent( 'aux', new TextContent( 'Aux Content' ) );

		$summary = CommentStoreComment::newUnsavedComment( 'udpate?!' );
		$updater->saveRevision( $summary, EDIT_UPDATE );
		$status = $updater->getStatus();

		$this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
		$this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
		$this->assertStatusError( 'content-not-allowed-here', $status );
	}

	public static function provideSetRcPatrolStatus( $patrolled ) {
		yield [ RecentChange::PRC_UNPATROLLED ];
		yield [ RecentChange::PRC_AUTOPATROLLED ];
	}

	/**
	 * @dataProvider provideSetRcPatrolStatus
	 * @covers \MediaWiki\Storage\PageUpdater::setRcPatrolStatus()
	 */
	public function testSetRcPatrolStatus( $patrolled ) {
		$revisionStore = $this->getServiceContainer()->getRevisionStore();

		$user = $this->getTestUser()->getUser();

		$title = $this->getDummyTitle( __METHOD__ );

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$summary = CommentStoreComment::newUnsavedComment( 'Lorem ipsum ' . $patrolled );
		$rev = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum ' . $patrolled ) )
			->setRcPatrolStatus( $patrolled )
			->saveRevision( $summary );

		$rc = $revisionStore->getRecentChange( $rev );
		$this->assertEquals( $patrolled, $rc->getAttribute( 'rc_patrolled' ) );
	}

	/**
	 * @covers \MediaWiki\Storage\PageUpdater::makeNewRevision()
	 */
	public function testStalePageID() {
		$user = $this->getTestUser()->getUser();

		$title = $this->getDummyTitle( __METHOD__ );
		$summary = CommentStoreComment::newUnsavedComment( 'testing...' );
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();

		// Create page
		$page = $wikiPageFactory->newFromTitle( $title );
		$updater = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new TextContent( 'Content 1' ) );
		$updater->saveRevision( $summary, EDIT_NEW );
		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );

		// Create a clone of $title and $page.
		$title = Title::makeTitle( $title->getNamespace(), $title->getDBkey() );
		$page = $wikiPageFactory->newFromTitle( $title );

		// start editing existing page using bad page ID
		$updater = $page->newPageUpdater( $user );
		$updater->grabParentRevision();

		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Content 2' ) );

		// Force the article ID to something invalid,
		// to emulate confusion due to a page move.
		$title->resetArticleID( 886655 );

		@$updater->saveRevision( $summary, EDIT_UPDATE );

		$this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
	}

	/**
	 * @covers \MediaWiki\Storage\PageUpdater::inheritSlot()
	 * @covers \MediaWiki\Storage\PageUpdater::setContent()
	 */
	public function testInheritSlot() {
		$user = $this->getTestUser()->getUser();

		$title = $this->getDummyTitle( __METHOD__ );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$updater = $page->newPageUpdater( $user );
		$summary = CommentStoreComment::newUnsavedComment( 'one' );
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
		$rev1 = $updater->saveRevision( $summary, EDIT_NEW );

		$updater = $page->newPageUpdater( $user );
		$summary = CommentStoreComment::newUnsavedComment( 'two' );
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Foo Bar' ) );
		$rev2 = $updater->saveRevision( $summary, EDIT_UPDATE );

		$updater = $page->newPageUpdater( $user );
		$summary = CommentStoreComment::newUnsavedComment( 'three' );
		$updater->inheritSlot( $rev1->getSlot( SlotRecord::MAIN ) );
		$rev3 = $updater->saveRevision( $summary, EDIT_UPDATE );

		$this->assertNotSame( $rev1->getId(), $rev3->getId() );
		$this->assertNotSame( $rev2->getId(), $rev3->getId() );

		$main1 = $rev1->getSlot( SlotRecord::MAIN );
		$main3 = $rev3->getSlot( SlotRecord::MAIN );

		$this->assertNotSame( $main1->getRevision(), $main3->getRevision() );
		$this->assertSame( $main1->getAddress(), $main3->getAddress() );
		$this->assertTrue( $main1->getContent()->equals( $main3->getContent() ) );
	}

	/**
	 * @covers \MediaWiki\Storage\PageUpdater::setSlot()
	 * @covers \MediaWiki\Storage\PageUpdater::updateRevision()
	 */
	public function testUpdatingDerivedSlot() {
		$user = $this->getTestUser()->getUser();
		$title = $this->getDummyTitle( __METHOD__ );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$updater = $page->newPageUpdater( $user );
		$summary = CommentStoreComment::newUnsavedComment( 'one' );
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
		$updater->saveRevision( $summary, EDIT_NEW );

		$updater = $page->newPageUpdater( $user );
		$content = new WikitextContent( 'A' );
		$derived = SlotRecord::newDerived( 'derivedslot', $content );
		$updater->setSlot( $derived );
		$updater->updateRevision();

		$status = $updater->getStatus();
		$this->assertStatusGood( $status );
		$rev = $status->getNewRevision();
		$slot = $rev->getSlot( 'derivedslot' );
		$this->assertTrue( $slot->getContent()->equals( $content ) );
	}

	/**
	 * @covers \MediaWiki\Storage\PageUpdater::setSlot()
	 * @covers \MediaWiki\Storage\PageUpdater::updateRevision()
	 */
	public function testUpdatingDerivedSlotCurrentRevision() {
		$user = $this->getTestUser()->getUser();
		$title = $this->getDummyTitle( __METHOD__ );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$updater = $page->newPageUpdater( $user );
		$summary = CommentStoreComment::newUnsavedComment( 'one' );
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
		$rev1 = $updater->saveRevision( $summary, EDIT_NEW );

		$updater = $page->newPageUpdater( $user );
		$content = new WikitextContent( 'A' );
		$derived = SlotRecord::newDerived( 'derivedslot', $content );
		$updater->setSlot( $derived );
		$updater->updateRevision( $rev1->getId( $rev1->getWikiId() ) );

		$rev2 = $updater->getStatus()->getNewRevision();
		$slot = $rev2->getSlot( 'derivedslot' );
		$this->assertTrue( $slot->getContent()->equals( $content ) );
	}

	/**
	 * @covers \MediaWiki\Storage\PageUpdater::setSlot()
	 * @covers \MediaWiki\Storage\PageUpdater::updateRevision()
	 */
	public function testUpdatingDerivedSlotOldRevision() {
		$user = $this->getTestUser()->getUser();
		$title = $this->getDummyTitle( __METHOD__ );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$updater = $page->newPageUpdater( $user );
		$summary = CommentStoreComment::newUnsavedComment( 'one' );
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
		$rev1 = $updater->saveRevision( $summary, EDIT_NEW );

		$updater = $page->newPageUpdater( $user );
		$summary = CommentStoreComment::newUnsavedComment( 'two' );
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Something different' ) );
		$updater->saveRevision( $summary, EDIT_UPDATE );

		$updater = $page->newPageUpdater( $user );
		$content = new WikitextContent( 'A' );
		$derived = SlotRecord::newDerived( 'derivedslot', $content );
		$updater->setSlot( $derived );
		$updater->updateRevision( $rev1->getId( $rev1->getWikiId() ) );

		$rev3 = $updater->getStatus()->getNewRevision();
		$slot = $rev3->getSlot( 'derivedslot' );
		$this->assertTrue( $slot->getContent()->equals( $content ) );
	}

	// TODO: MCR: test adding multiple slots, inheriting parent slots, and removing slots.

	public function testSetUseAutomaticEditSummaries() {
		$this->setContentLang( 'qqx' );
		$user = $this->getTestUser()->getUser();

		$title = $this->getDummyTitle( __METHOD__ );
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();
		$page = $wikiPageFactory->newFromTitle( $title );

		$updater = $page->newPageUpdater( $user )
			->setUseAutomaticEditSummaries( true )
			->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );

		// empty comment triggers auto-summary
		$summary = CommentStoreComment::newUnsavedComment( '' );
		$updater->saveRevision( $summary, EDIT_AUTOSUMMARY );

		$rev = $updater->getNewRevision();
		$comment = $rev->getComment( RevisionRecord::RAW );
		$this->assertSame( '(autosumm-new: Lorem Ipsum)', $comment->text, 'comment text' );

		// check that this also works when blanking the page
		$updater = $page->newPageUpdater( $user )
			->setUseAutomaticEditSummaries( true )
			->setContent( SlotRecord::MAIN, new TextContent( '' ) );

		$summary = CommentStoreComment::newUnsavedComment( '' );
		$updater->saveRevision( $summary, EDIT_AUTOSUMMARY );

		$rev = $updater->getNewRevision();
		$comment = $rev->getComment( RevisionRecord::RAW );
		$this->assertSame( '(autosumm-blank)', $comment->text, 'comment text' );

		// check that we can also disable edit-summaries
		$title2 = $this->getDummyTitle( __METHOD__ . '/2' );
		$page2 = $wikiPageFactory->newFromTitle( $title2 );

		$updater = $page2->newPageUpdater( $user )
			->setUseAutomaticEditSummaries( false )
			->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );

		$summary = CommentStoreComment::newUnsavedComment( '' );
		$updater->saveRevision( $summary, EDIT_AUTOSUMMARY );

		$rev = $updater->getNewRevision();
		$comment = $rev->getComment( RevisionRecord::RAW );
		$this->assertSame( '', $comment->text, 'comment text should still be blank' );

		// check that we don't do auto.summaries without the EDIT_AUTOSUMMARY flag
		$updater = $page2->newPageUpdater( $user )
			->setUseAutomaticEditSummaries( true )
			->setContent( SlotRecord::MAIN, new TextContent( '' ) );

		$summary = CommentStoreComment::newUnsavedComment( '' );
		$updater->saveRevision( $summary, 0 );

		$rev = $updater->getNewRevision();
		$comment = $rev->getComment( RevisionRecord::RAW );
		$this->assertSame( '', $comment->text, 'comment text' );
	}

	public static function provideSetUsePageCreationLog() {
		yield [ true, [ [ 'create', 'create' ] ] ];
		yield [ false, [] ];
	}

	/**
	 * @dataProvider provideSetUsePageCreationLog
	 */
	public function testSetUsePageCreationLog( $use, $expected ) {
		$user = $this->getTestUser()->getUser();

		$title = $this->getDummyTitle( __METHOD__ . ( $use ? '_logged' : '_unlogged' ) );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$summary = CommentStoreComment::newUnsavedComment( 'cmt' );
		$updater = $page->newPageUpdater( $user )
			->setUsePageCreationLog( $use )
			->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
		$updater->saveRevision( $summary, EDIT_NEW );

		$rev = $updater->getNewRevision();
		$this->newSelectQueryBuilder()
			->select( [ 'log_type', 'log_action' ] )
			->from( 'logging' )
			->where( [ 'log_page' => $rev->getPageId() ] )
			->assertResultSet( $expected );
	}

	public static function provideMagicWords() {
		yield 'PAGEID' => [
			'Test {{PAGEID}} Test',
			static function ( RevisionRecord $rev ) {
				return $rev->getPageId();
			}
		];

		yield 'REVISIONID' => [
			'Test {{REVISIONID}} Test',
			static function ( RevisionRecord $rev ) {
				return $rev->getId();
			}
		];

		yield 'REVISIONUSER' => [
			'Test {{REVISIONUSER}} Test',
			static function ( RevisionRecord $rev ) {
				return $rev->getUser()->getName();
			}
		];

		yield 'REVISIONTIMESTAMP' => [
			'Test {{REVISIONTIMESTAMP}} Test',
			static function ( RevisionRecord $rev ) {
				return $rev->getTimestamp();
			}
		];

		yield 'subst:REVISIONUSER' => [
			'Test {{subst:REVISIONUSER}} Test',
			static function ( RevisionRecord $rev ) {
				return $rev->getUser()->getName();
			},
			'subst'
		];

		yield 'subst:PAGENAME' => [
			'Test {{subst:PAGENAME}} Test',
			static function ( RevisionRecord $rev ) {
				return 'PageUpdaterTest::testMagicWords';
			},
			'subst'
		];
	}

	/**
	 * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
	 *
	 * Integration test for PageUpdater, DerivedPageDataUpdater, RevisionRenderer
	 * and RenderedRevision, that ensures that magic words depending on revision meta-data
	 * are handled correctly. Note that each magic word needs to be tested separately,
	 * to assert correct behavior for each "vary" flag in the ParserOutput.
	 *
	 * @dataProvider provideMagicWords
	 */
	public function testMagicWords( $wikitext, $callback, $subst = false ) {
		$user = User::newFromName( 'A user for ' . __METHOD__ );
		$user->addToDatabase();

		$title = $this->getDummyTitle( __METHOD__ . '-' . $this->getName() );
		$this->insertPage( $title );

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$updater = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new \MediaWiki\Content\WikitextContent( $wikitext ) );

		$summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
		$rev = $updater->saveRevision( $summary, EDIT_UPDATE );

		if ( !$rev ) {
			$this->fail( $updater->getStatus()->getWikiText() );
		}

		$expected = strval( $callback( $rev ) );

		$output = $page->getParserOutput( ParserOptions::newFromAnon() );
		$html = $output->getRawText();
		$text = $rev->getContent( SlotRecord::MAIN )->serialize();

		if ( $subst ) {
			$this->assertStringContainsString( $expected, $text, 'In Wikitext' );
		}

		$this->assertStringContainsString( $expected, $html, 'In HTML' );
	}

	public function testChangeTagsSuppressRecentChange() {
		$page = PageIdentityValue::localIdentity( 0, NS_MAIN, __METHOD__ );
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();
		$revision = $this->getServiceContainer()
			->getPageUpdaterFactory()
			->newPageUpdater(
				$wikiPageFactory->newFromTitle( $page ),
				$this->getTestUser()->getUser()
			)
			->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) )
			->addTag( 'foo' )
			->setFlags( EDIT_SUPPRESS_RC )
			->saveRevision( CommentStoreComment::newUnsavedComment( 'Comment' ) );
		$this->assertArrayEquals(
			[ 'foo' ],
			$this->getServiceContainer()->getChangeTagsStore()->getTags(
				$this->getDb(), null, $revision->getId()
			)
		);

		$revision2 = $this->getServiceContainer()
			->getPageUpdaterFactory()
			->newPageUpdater(
				$wikiPageFactory->newFromTitle( $page ),
				$this->getTestUser()->getUser()
			)
			->setContent( SlotRecord::MAIN, new TextContent( 'Other content' ) )
			->addTag( 'bar' )
			->setFlags( EDIT_SUPPRESS_RC )
			->saveRevision( CommentStoreComment::newUnsavedComment( 'Comment' ) );
		$this->assertArrayEquals(
			[ 'bar' ],
			$this->getServiceContainer()->getChangeTagsStore()->getTags(
				$this->getDb(), null, $revision2->getId()
			)
		);
	}

	/**
	 * @covers \MediaWiki\Storage\PageUpdater::prepareUpdate()
	 * @covers \WikiPage::getCurrentUpdate()
	 */
	public function testPrepareUpdate() {
		$user = $this->getTestUser()->getUser();

		$title = $this->getDummyTitle( __METHOD__ );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$updater = $page->newPageUpdater( $user );

		$this->assertSame( $page->getCurrentUpdate(), $updater->prepareUpdate() );
	}

	/**
	 * @covers \MediaWiki\Storage\PageUpdater::preventChange
	 * @covers \MediaWiki\Storage\PageUpdater::doModify
	 * @covers \MediaWiki\Storage\PageUpdater::isChange
	 */
	public function testPreventChange_modify() {
		$user = $this->getTestUser()->getUser();
		$title = $this->getDummyTitle( __METHOD__ );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$updater = $page->newPageUpdater( $user );

		// Creation
		$summary = CommentStoreComment::newUnsavedComment( 'one' );
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
		$updater->prepareUpdate();
		$rev = $updater->saveRevision( $summary, EDIT_NEW );
		$this->assertInstanceOf( RevisionRecord::class, $rev );

		// Null edit
		$updater = $page->newPageUpdater( $user );
		$summary = CommentStoreComment::newUnsavedComment( 'one' );
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
		$updater->prepareUpdate();
		$updater->preventChange();
		$this->assertFalse( $updater->isChange() );
		$rev = $updater->saveRevision( $summary );
		$this->assertNull( $rev );

		// Prevented edit
		$updater = $page->newPageUpdater( $user );
		$summary = CommentStoreComment::newUnsavedComment( 'one' );
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'dolor sit amet' ) );
		$updater->prepareUpdate();
		$updater->preventChange();
		$this->expectException( LogicException::class );
		$updater->saveRevision( $summary );
	}

	/**
	 * @covers \MediaWiki\Storage\PageUpdater::preventChange
	 * @covers \MediaWiki\Storage\PageUpdater::doCreate
	 * @covers \MediaWiki\Storage\PageUpdater::isChange
	 */
	public function testPreventChange_create() {
		$user = $this->getTestUser()->getUser();
		$title = $this->getDummyTitle( __METHOD__ );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$updater = $page->newPageUpdater( $user );
		$summary = CommentStoreComment::newUnsavedComment( 'one' );
		$updater->setContent( SlotRecord::MAIN, new TextContent( 'Lorem ipsum' ) );
		$updater->prepareUpdate();
		$updater->preventChange();
		$this->assertTrue( $updater->isChange() );
		$this->expectException( LogicException::class );
		$updater->saveRevision( $summary, EDIT_NEW );
	}

	public function testUpdateAuthor() {
		$title = $this->getDummyTitle( __METHOD__ );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$user = new User;
		$user->setName( 'PageUpdaterTest' );
		$updater = $page->newPageUpdater( $user );
		$summary = CommentStoreComment::newUnsavedComment( 'one' );
		$updater->setContent( SlotRecord::MAIN, new TextContent( '~~~~' ) );

		$user = User::createNew( $user->getName() );
		$updater->updateAuthor( $user );
		$rev = $updater->saveRevision( $summary, EDIT_NEW );
		$this->assertGreaterThan( 0, $rev->getUser()->getId() );
	}
}
PK       ! 
N  N    Storage/SqlBlobStoreTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Storage;

use ConcatenatedGzipHistoryBlob;
use ExternalStoreAccess;
use ExternalStoreFactory;
use InvalidArgumentException;
use MediaWiki\Storage\BadBlobException;
use MediaWiki\Storage\BlobAccessException;
use MediaWiki\Storage\SqlBlobStore;
use MediaWikiIntegrationTestCase;
use StatusValue;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Rdbms\LoadBalancer;

/**
 * @group Database
 * @covers \MediaWiki\Storage\SqlBlobStore
 */
class SqlBlobStoreTest extends MediaWikiIntegrationTestCase {

	/**
	 * @param WANObjectCache|null $cache
	 * @param ExternalStoreAccess|null $extStore
	 * @return SqlBlobStore
	 */
	public function getBlobStore(
		?WANObjectCache $cache = null,
		?ExternalStoreAccess $extStore = null
	) {
		$services = $this->getServiceContainer();

		$store = new SqlBlobStore(
			$services->getDBLoadBalancer(),
			$extStore ?: $services->getExternalStoreAccess(),
			$cache ?: $services->getMainWANObjectCache()
		);

		return $store;
	}

	public function testGetSetCompressRevisions() {
		$store = $this->getBlobStore();
		$this->assertFalse( $store->getCompressBlobs() );
		$store->setCompressBlobs( true );
		$this->assertTrue( $store->getCompressBlobs() );
	}

	public function testGetSetLegacyEncoding() {
		$store = $this->getBlobStore();
		$this->assertFalse( $store->getLegacyEncoding() );
		$store->setLegacyEncoding( 'foo' );
		$this->assertSame( 'foo', $store->getLegacyEncoding() );
	}

	public function testGetSetCacheExpiry() {
		$store = $this->getBlobStore();
		$this->assertSame( 604800, $store->getCacheExpiry() );
		$store->setCacheExpiry( 12 );
		$this->assertSame( 12, $store->getCacheExpiry() );
	}

	public function testGetSetUseExternalStore() {
		$store = $this->getBlobStore();
		$this->assertFalse( $store->getUseExternalStore() );
		$store->setUseExternalStore( true );
		$this->assertTrue( $store->getUseExternalStore() );
	}

	private function makeObjectBlob( $text ) {
		$obj = new ConcatenatedGzipHistoryBlob();
		$obj->setText( $text );
		return serialize( $obj );
	}

	public function provideDecompress() {
		yield '(no legacy encoding), empty in empty out' => [ false, '', [], '' ];
		yield '(no legacy encoding), string in string out' => [ false, 'A', [], 'A' ];
		yield '(no legacy encoding), error flag -> false' => [ false, 'X', [ 'error' ], false ];
		yield '(no legacy encoding), string in with gzip flag returns string' => [
			// gzip string below generated with gzdeflate( 'AAAABBAAA' )
			false, "sttttr\002\022\000", [ 'gzip' ], 'AAAABBAAA',
		];
		yield '(no legacy encoding), string in with object flag returns false' => [
			// gzip string below generated with serialize( 'JOJO' )
			false, "s:4:\"JOJO\";", [ 'object' ], false,
		];

		yield '(no legacy encoding), serialized object in with object flag returns string' => [
			false,
			$this->makeObjectBlob( 'HHJJDDFF' ),
			[ 'object' ],
			'HHJJDDFF',
		];
		yield '(no legacy encoding), serialized object in with object & gzip flag returns string' => [
			false,
			gzdeflate( $this->makeObjectBlob( '8219JJJ840' ) ),
			[ 'object', 'gzip' ],
			'8219JJJ840',
		];
		yield '(ISO-8859-1 encoding), string in string out' => [
			'ISO-8859-1',
			iconv( 'utf-8', 'ISO-8859-1', "1®Àþ1" ),
			[],
			'1®Àþ1',
		];
		yield '(ISO-8859-1 encoding), serialized object in with gzip flags returns string' => [
			'ISO-8859-1',
			gzdeflate( iconv( 'utf-8', 'ISO-8859-1', "4®Àþ4" ) ),
			[ 'gzip' ],
			'4®Àþ4',
		];
		yield '(ISO-8859-1 encoding), serialized object in with object flags returns string' => [
			'ISO-8859-1',
			$this->makeObjectBlob( iconv( 'utf-8', 'ISO-8859-1', "3®Àþ3" ) ),
			[ 'object' ],
			'3®Àþ3',
		];
		yield '(ISO-8859-1 encoding), serialized object in with object & gzip flags returns string' => [
			'ISO-8859-1',
			gzdeflate( $this->makeObjectBlob( iconv( 'utf-8', 'ISO-8859-1', "2®Àþ2" ) ) ),
			[ 'gzip', 'object' ],
			'2®Àþ2',
		];
		yield 'T184749 (windows-1252 encoding), string in string out' => [
			'windows-1252',
			iconv( 'utf-8', 'windows-1252', "sammansättningar" ),
			[],
			'sammansättningar',
		];
		yield 'T184749 (windows-1252 encoding), string in string out with gzip' => [
			'windows-1252',
			gzdeflate( iconv( 'utf-8', 'windows-1252', "sammansättningar" ) ),
			[ 'gzip' ],
			'sammansättningar',
		];
	}

	/**
	 * @dataProvider provideDecompress
	 * @param string|bool $legacyEncoding
	 * @param mixed $data
	 * @param array $flags
	 * @param mixed $expected
	 */
	public function testDecompressData( $legacyEncoding, $data, $flags, $expected ) {
		$store = $this->getBlobStore();

		if ( $legacyEncoding ) {
			$store->setLegacyEncoding( $legacyEncoding );
		}

		$this->assertSame(
			$expected,
			$store->decompressData( $data, $flags )
		);
	}

	public function testCompressRevisionTextUtf8() {
		$store = $this->getBlobStore();
		$row = (object)[ 'old_text' => "Wiki est l'\xc3\xa9cole superieur !" ];
		$row->old_flags = $store->compressData( $row->old_text );
		$this->assertStringContainsString( 'utf-8', $row->old_flags,
			"Flags should contain 'utf-8'" );
		$this->assertStringNotContainsString( 'gzip', $row->old_flags,
			"Flags should not contain 'gzip'" );
		$this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !",
			$row->old_text, "Direct check" );
	}

	/**
	 * @requires extension zlib
	 */
	public function testCompressRevisionTextUtf8Gzip() {
		$store = $this->getBlobStore();
		$store->setCompressBlobs( true );

		$row = (object)[ 'old_text' => "Wiki est l'\xc3\xa9cole superieur !" ];
		$row->old_flags = $store->compressData( $row->old_text );
		$this->assertStringContainsString( 'utf-8', $row->old_flags,
			"Flags should contain 'utf-8'" );
		$this->assertStringContainsString( 'gzip', $row->old_flags,
			"Flags should contain 'gzip'" );
		$this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !",
			gzinflate( $row->old_text ), "Direct check" );
	}

	public static function provideBlobs() {
		yield [ '' ];
		yield [ 'someText' ];
		yield [ "söme\ntäxt" ];
	}

	public function testSimpleStoreGetBlobKnownBad() {
		$store = $this->getBlobStore();
		$this->expectException( BadBlobException::class );
		$store->getBlob( 'bad:lost?bug=T12345' );
	}

	/**
	 * @param string $blob
	 * @dataProvider provideBlobs
	 */
	public function testSimpleStoreGetBlobSimpleRoundtrip( $blob ) {
		$store = $this->getBlobStore();
		$address = $store->storeBlob( $blob );
		$this->assertSame( $blob, $store->getBlob( $address ) );
	}

	public function testSimpleStorageGetBlobBatchSimpleEmpty() {
		$store = $this->getBlobStore();
		$this->assertArrayEquals(
			[],
			$store->getBlobBatch( [] )->getValue()
		);
	}

	/**
	 * @param string $blob
	 * @dataProvider provideBlobs
	 */
	public function testSimpleStorageGetBlobBatchSimpleRoundtrip( $blob ) {
		$store = $this->getBlobStore();
		$addresses = [
			$store->storeBlob( $blob ),
			$store->storeBlob( $blob . '1' )
		];
		$this->assertArrayEquals(
			array_combine( $addresses, [ $blob, $blob . '1' ] ),
			$store->getBlobBatch( $addresses )->getValue()
		);
	}

	public function testCachingConsistency() {
		$cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
		$store = $this->getBlobStore( $cache );

		$addrA = $store->storeBlob( 'A' );
		$addrB = $store->storeBlob( 'B' );
		$addrC = $store->storeBlob( 'C' );
		$addrD = $store->storeBlob( 'D' );
		$addrX = 'tt:0';

		$dataZ = "söme\ntäxt!";
		$addrZ = $store->storeBlob( $dataZ );

		$this->assertArrayEquals(
			[ $addrA => 'A', $addrC => 'C', $addrX => null ],
			$store->getBlobBatch( [ $addrA, $addrC, $addrX ] )->getValue(),
			false, true
		);

		$this->assertEquals( 'A', $store->getBlob( $addrA ) );
		$this->assertEquals( 'B', $store->getBlob( $addrB ) );
		$this->assertEquals( 'C', $store->getBlob( $addrC ) );

		$this->assertArrayEquals(
			[ $addrB => 'B', $addrC => 'C', $addrD => 'D' ],
			$store->getBlobBatch( [ $addrB, $addrC, $addrD ] )->getValue(),
			false, true
		);

		$this->assertEquals( $dataZ, $store->getBlob( $addrZ ) );

		$this->assertArrayEquals(
			[ $addrA => 'A', $addrZ => $dataZ ],
			$store->getBlobBatch( [ $addrA, $addrZ ] )->getValue(),
			false, true
		);
	}

	public function testSimpleStorageNonExistentBlob() {
		$this->expectException( BlobAccessException::class );
		$store = $this->getBlobStore();
		$store->getBlob( 'tt:this_will_not_exist' );
	}

	public function testSimpleStorageNonExistentBlobBatch() {
		$store = $this->getBlobStore();
		$result = $store->getBlobBatch( [
			'tt:this_will_not_exist',
			'tt:0',
			'tt:-1',
			'tt:10000',
			'bla:1001'
		] );
		$resultBlobs = $result->getValue();
		$expected = [
			'tt:this_will_not_exist' => null,
			'tt:0' => null,
			'tt:-1' => null,
			'tt:10000' => null,
			'bla:1001' => null
		];

		ksort( $expected );
		ksort( $resultBlobs );
		$this->assertSame( $expected, $resultBlobs );

		$this->assertStatusMessagesExactly(
			StatusValue::newGood()
				->warning( 'internalerror', 'Bad blob address: tt:this_will_not_exist. Use findBadBlobs.php to remedy.' )
				->warning( 'internalerror', 'Bad blob address: tt:0. Use findBadBlobs.php to remedy.' )
				->warning( 'internalerror', 'Bad blob address: tt:-1. Use findBadBlobs.php to remedy.' )
				->warning( 'internalerror', 'Unknown blob address schema: bla. Use findBadBlobs.php to remedy.' )
				->warning( 'internalerror', 'Unable to fetch blob at tt:10000. Use findBadBlobs.php to remedy.' ),
			$result
		);
	}

	public function testSimpleStoragePartialNonExistentBlobBatch() {
		$store = $this->getBlobStore();
		$address = $store->storeBlob( 'test_data' );
		$result = $store->getBlobBatch( [ $address, 'tt:this_will_not_exist_too' ] );
		$resultBlobs = $result->getValue();
		$expected = [
			$address => 'test_data',
			'tt:this_will_not_exist_too' => null
		];

		ksort( $expected );
		ksort( $resultBlobs );
		$this->assertSame( $expected, $resultBlobs );
		$this->assertStatusMessagesExactly(
			StatusValue::newGood()
				->warning( 'internalerror', 'Bad blob address: tt:this_will_not_exist_too. Use findBadBlobs.php to remedy.' ),
			$result
		);
	}

	/**
	 * @dataProvider provideBlobs
	 */
	public function testSimpleStoreGetBlobSimpleRoundtripWindowsLegacyEncoding( $blob ) {
		$store = $this->getBlobStore();
		$store->setLegacyEncoding( 'windows-1252' );
		$address = $store->storeBlob( $blob );
		$this->assertSame( $blob, $store->getBlob( $address ) );
	}

	/**
	 * @dataProvider provideBlobs
	 */
	public function testSimpleStoreGetBlobSimpleRoundtripWindowsLegacyEncodingGzip( $blob ) {
		// FIXME: fails under postgres - T298692
		$this->markTestSkippedIfDbType( 'postgres' );
		$store = $this->getBlobStore();
		$store->setLegacyEncoding( 'windows-1252' );
		$store->setCompressBlobs( true );
		$address = $store->storeBlob( $blob );
		$this->assertSame( $blob, $store->getBlob( $address ) );
	}

	public static function provideGetTextIdFromAddress() {
		yield [ 'tt:17', 17 ];
		yield [ 'xy:17', null ];
		yield [ 'xy:xyzzy', null ];
	}

	/**
	 * @dataProvider provideGetTextIdFromAddress
	 */
	public function testGetTextIdFromAddress( $address, $textId ) {
		$store = $this->getBlobStore();
		$this->assertSame( $textId, $store->getTextIdFromAddress( $address ) );
	}

	public static function provideGetTextIdFromAddressInvalidArgumentException() {
		yield [ 'tt:xy' ];
		yield [ 'tt:0' ];
		yield [ 'tt:' ];
		yield [ 'xy' ];
		yield [ '' ];
	}

	/**
	 * @dataProvider provideGetTextIdFromAddressInvalidArgumentException
	 */
	public function testGetTextIdFromAddressInvalidArgumentException( $address ) {
		$this->expectException( InvalidArgumentException::class );
		$store = $this->getBlobStore();
		$store->getTextIdFromAddress( $address );
	}

	public function testMakeAddressFromTextId() {
		$this->assertSame( 'tt:17', SqlBlobStore::makeAddressFromTextId( 17 ) );
	}

	public static function providerSplitBlobAddress() {
		yield [ 'tt:123', 'tt', '123', [] ];
		yield [ 'bad:foo?x=y', 'bad', 'foo', [ 'x' => 'y' ] ];
		yield [ 'http://test.com/foo/bar?a=b', 'http', 'test.com/foo/bar', [ 'a' => 'b' ] ];
	}

	/**
	 * @dataProvider providerSplitBlobAddress
	 */
	public function testSplitBlobAddress( $address, $schema, $id, $parameters ) {
		$this->assertSame( 'tt:17', SqlBlobStore::makeAddressFromTextId( 17 ) );
	}

	public static function provideExpandBlob() {
		yield 'Generic test' => [
			'This is a goat of revision text.',
			'old_flags' => '',
			'old_text' => 'This is a goat of revision text.',
		];
	}

	/**
	 * @dataProvider provideExpandBlob
	 */
	public function testExpandBlob( $expected, $flags, $raw ) {
		$blobStore = $this->getBlobStore();
		$this->assertEquals(
			$expected,
			$blobStore->expandBlob( $raw, explode( ',', $flags ) )
		);
	}

	public static function provideExpandBlobWithZlibExtension() {
		yield 'Generic gzip test' => [
			'This is a small goat of revision text.',
			'old_flags' => 'gzip',
			'old_text' => gzdeflate( 'This is a small goat of revision text.' ),
		];
	}

	/**
	 * @dataProvider provideExpandBlobWithZlibExtension
	 * @requires extension zlib
	 */
	public function testGetRevisionWithZlibExtension( $expected, $flags, $raw ) {
		$blobStore = $this->getBlobStore();
		$this->assertEquals(
			$expected,
			$blobStore->expandBlob( $raw, explode( ',', $flags ) )
		);
	}

	public static function provideExpandBlobWithZlibExtension_badData() {
		yield 'Generic gzip test' => [
			'old_flags' => 'gzip',
			'old_text' => 'DEAD BEEF',
		];
	}

	/**
	 * @dataProvider provideExpandBlobWithZlibExtension_badData
	 * @requires extension zlib
	 */
	public function testGetRevisionWithZlibExtension_badData( $flags, $raw ) {
		$blobStore = $this->getBlobStore();

		$this->assertFalse(
			@$blobStore->expandBlob( $raw, explode( ',', $flags ) )
		);
	}

	public static function provideExpandBlobWithLegacyEncoding() {
		yield 'Utf8Native' => [
			"Wiki est l'\xc3\xa9cole superieur !",
			'iso-8859-1',
			'old_flags' => 'utf-8',
			'old_text' => "Wiki est l'\xc3\xa9cole superieur !",
		];
		yield 'Utf8Legacy' => [
			"Wiki est l'\xc3\xa9cole superieur !",
			'iso-8859-1',
			'old_flags' => '',
			'old_text' => "Wiki est l'\xe9cole superieur !",
		];
	}

	/**
	 * @dataProvider provideExpandBlobWithLegacyEncoding
	 */
	public function testGetRevisionWithLegacyEncoding( $expected, $encoding, $flags, $raw ) {
		$blobStore = $this->getBlobStore();
		$blobStore->setLegacyEncoding( $encoding );

		$this->assertEquals(
			$expected,
			$blobStore->expandBlob( $raw, explode( ',', $flags ) )
		);
	}

	public static function provideExpandBlobWithGzipAndLegacyEncoding() {
		/**
		 * WARNING!
		 * Do not set the external flag!
		 * Otherwise, getRevisionText will hit the live database (if ExternalStore is enabled)!
		 */
		yield 'Utf8NativeGzip' => [
			"Wiki est l'\xc3\xa9cole superieur !",
			'iso-8859-1',
			'old_flags' => 'gzip,utf-8',
			'old_text' => gzdeflate( "Wiki est l'\xc3\xa9cole superieur !" ),
		];
		yield 'Utf8LegacyGzip' => [
			"Wiki est l'\xc3\xa9cole superieur !",
			'iso-8859-1',
			'old_flags' => 'gzip',
			'old_text' => gzdeflate( "Wiki est l'\xe9cole superieur !" ),
		];
	}

	/**
	 * @dataProvider provideExpandBlobWithGzipAndLegacyEncoding
	 * @requires extension zlib
	 */
	public function testGetRevisionWithGzipAndLegacyEncoding( $expected, $encoding, $flags, $raw ) {
		$blobStore = $this->getBlobStore();
		$blobStore->setLegacyEncoding( $encoding );

		$this->assertEquals(
			$expected,
			$blobStore->expandBlob( $raw, explode( ',', $flags ) )
		);
	}

	public static function provideTestGetRevisionText_returnsDecompressedTextFieldWhenNotExternal() {
		yield 'Just text' => [
			'old_flags' => '',
			'old_text' => 'SomeText',
			'SomeText'
		];
		// gzip string below generated with gzdeflate( 'AAAABBAAA' )
		yield 'gzip text' => [
			'old_flags' => 'gzip',
			'old_text' => "sttttr\002\022\000",
			'AAAABBAAA'
		];
	}

	/**
	 * @dataProvider provideTestGetRevisionText_returnsDecompressedTextFieldWhenNotExternal
	 */
	public function testGetRevisionText_returnsDecompressedTextFieldWhenNotExternal(
		$flags,
		$raw,
		$expected
	) {
		$blobStore = $this->getBlobStore();
		$this->assertSame( $expected, $blobStore->expandBlob( $raw, $flags ) );
	}

	public static function provideTestGetRevisionText_external_returnsFalseWhenNotEnoughUrlParts() {
		yield 'Just some text' => [ 'someNonUrlText' ];
		yield 'No second URL part' => [ 'someProtocol://' ];
	}

	/**
	 * @dataProvider provideTestGetRevisionText_external_returnsFalseWhenNotEnoughUrlParts
	 */
	public function testGetRevisionText_external_returnsFalseWhenNotEnoughUrlParts(
		$text
	) {
		$blobStore = $this->getBlobStore();
		$this->assertFalse(
			$blobStore->expandBlob(
				$text,
				[ 'external' ]
			)
		);
	}

	public function testGetRevisionText_external_noOldId() {
		$this->setService(
			'ExternalStoreFactory',
			new ExternalStoreFactory( [ 'ForTesting' ], [ 'ForTesting://cluster1' ], 'test-id' )
		);
		$blobStore = $this->getBlobStore();
		$this->assertSame(
			'AAAABBAAA',
			$blobStore->expandBlob(
				'ForTesting://cluster1/12345',
				[ 'external', 'gzip' ]
			)
		);
	}

	private function getWANObjectCache() {
		return new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
	}

	public function testGetRevisionText_external_oldId() {
		$cache = $this->getWANObjectCache();
		$this->setService( 'MainWANObjectCache', $cache );

		$this->setService(
			'ExternalStoreFactory',
			new ExternalStoreFactory( [ 'ForTesting' ], [ 'ForTesting://cluster1' ], 'test-id' )
		);

		$lb = $this->createMock( LoadBalancer::class );
		$access = $this->getServiceContainer()->getExternalStoreAccess();

		$blobStore = new SqlBlobStore( $lb, $access, $cache );

		$this->assertSame(
			'AAAABBAAA',
			$blobStore->expandBlob(
				'ForTesting://cluster1/12345',
				'external,gzip',
				'tt:7777'
			)
		);

		$cacheKey = $cache->makeGlobalKey(
			'SqlBlobStore-blob',
			$lb->getLocalDomainID(),
			'tt:7777'
		);
		$this->assertSame( 'AAAABBAAA', $cache->get( $cacheKey ) );
	}

	public function testGetRevisionText_external_oldId_direct_access() {
		$cache = $this->getWANObjectCache();
		$this->setService( 'MainWANObjectCache', $cache );

		$this->setService(
			'ExternalStoreFactory',
			new ExternalStoreFactory( [ 'ForTesting' ], [ 'ForTesting://cluster1' ], 'test-id' )
		);

		$lb = $this->createMock( LoadBalancer::class );
		$access = $this->getServiceContainer()->getExternalStoreAccess();

		$blobStore = new SqlBlobStore( $lb, $access, $cache );

		$this->assertSame(
			'AAAABBAAA',
			$blobStore->getBlob( 'es:ForTesting://cluster1/12345?flags=external,gzip' )
		);

		$cacheKey = $cache->makeGlobalKey(
			'SqlBlobStore-blob',
			$lb->getLocalDomainID(),
			// See ExternalStoreForTesting for the path
			'es:ForTesting://cluster1/12345?flags=external,gzip'
		);
		$this->assertSame( 'AAAABBAAA', $cache->get( $cacheKey ) );
	}

	public static function provideTestGetRevisionText_external_oldId_direct_store() {
		yield 'no compression' => [ false ];
		yield 'compression' => [ true ];
	}

	/**
	 * @dataProvider provideTestGetRevisionText_external_oldId_direct_store
	 */
	public function testGetRevisionText_external_oldId_direct_store( bool $compression ) {
		$cache = $this->getWANObjectCache();
		$this->setService( 'MainWANObjectCache', $cache );

		$this->setService(
			'ExternalStoreFactory',
			new ExternalStoreFactory( [ 'ForTesting' ], [ 'ForTesting://cluster1' ], 'test-id' )
		);

		$lb = $this->createMock( LoadBalancer::class );
		$access = $this->getServiceContainer()->getExternalStoreAccess();

		$blobStore = new SqlBlobStore( $lb, $access, $cache );
		$blobStore->setUseExternalStore( true );
		$blobStore->setCompressBlobs( $compression );
		$id = $blobStore->storeBlob( 'A very unique text' );
		$this->assertStringStartsWith( 'es:ForTesting://cluster1/', $id );

		$this->assertSame(
			'A very unique text',
			$blobStore->getBlob( $id )
		);

		$cacheKey = $cache->makeGlobalKey(
			'SqlBlobStore-blob',
			$lb->getLocalDomainID(),
			$id
		);
		$this->assertSame( 'A very unique text', $cache->get( $cacheKey ) );
	}
}
PK       ! ڬG    &  Storage/DerivedPageDataUpdaterTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Storage;

use DummyContentHandlerForTesting;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Content\Content;
use MediaWiki\Content\ContentHandler;
use MediaWiki\Content\JavaScriptContent;
use MediaWiki\Content\TextContent;
use MediaWiki\Content\TextContentHandler;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Content\WikitextContentHandler;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Deferred\LinksUpdate\LinksUpdate;
use MediaWiki\Deferred\MWCallableUpdate;
use MediaWiki\Edit\ParsoidRenderID;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\ParserOutputAccess;
use MediaWiki\Parser\ParserCacheFactory;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\MutableRevisionSlots;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\DerivedPageDataUpdater;
use MediaWiki\Storage\EditResult;
use MediaWiki\Storage\EditResultCache;
use MediaWiki\Storage\RevisionSlotsUpdate;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiIntegrationTestCase;
use MockTitleTrait;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\Rdbms\Platform\ISQLPlatform;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Timestamp\ConvertibleTimestamp;
use WikiPage;

/**
 * @group Database
 *
 * @covers \MediaWiki\Storage\DerivedPageDataUpdater
 */
class DerivedPageDataUpdaterTest extends MediaWikiIntegrationTestCase {
	use MockTitleTrait;

	/**
	 * @param string $title
	 *
	 * @return Title
	 */
	private function getTitle( $title ) {
		return Title::makeTitleSafe( $this->getDefaultWikitextNS(), $title );
	}

	/**
	 * @param string|Title $title
	 *
	 * @return WikiPage
	 */
	private function getPage( $title ) {
		$title = ( $title instanceof Title ) ? $title : $this->getTitle( $title );

		return $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
	}

	/**
	 * @param string|Title|WikiPage $page
	 * @param RevisionRecord|null $rec
	 * @param User|null $user
	 *
	 * @return DerivedPageDataUpdater
	 */
	private function getDerivedPageDataUpdater(
		$page, ?RevisionRecord $rec = null, ?User $user = null
	) {
		if ( is_string( $page ) || $page instanceof Title ) {
			$page = $this->getPage( $page );
		}

		$page = TestingAccessWrapper::newFromObject( $page );
		return $page->getDerivedDataUpdater( $user, $rec );
	}

	/**
	 * Creates a revision in the database.
	 *
	 * @param WikiPage $page
	 * @param string|Message|CommentStoreComment $summary
	 * @param null|string|Content|Content[] $content
	 * @param User|null $user
	 *
	 * @return RevisionRecord|null
	 */
	private function createRevision( WikiPage $page, $summary, $content = null, $user = null ) {
		$user ??= $this->getTestUser()->getUser();
		$comment = CommentStoreComment::newUnsavedComment( $summary );

		if ( $content === null || is_string( $content ) ) {
			$content = new WikitextContent( $content ?? $summary );
		}

		if ( !is_array( $content ) ) {
			$content = [ SlotRecord::MAIN => $content ];
		}

		$this->getDerivedPageDataUpdater( $page ); // flush cached instance before.

		$updater = $page->newPageUpdater( $user );

		foreach ( $content as $role => $c ) {
			$updater->setContent( $role, $c );
		}

		$rev = $updater->saveRevision( $comment );
		if ( !$updater->wasSuccessful() ) {
			$this->fail( $updater->getStatus()->getWikiText() );
		}

		$this->getDerivedPageDataUpdater( $page ); // flush cached instance after.
		return $rev;
	}

	// TODO: test setArticleCountMethod() and isCountable();
	// TODO: test isRedirect() and wasRedirect()

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOptions()
	 */
	public function testGetCanonicalParserOptions() {
		$user = $this->getTestUser()->getUser();
		$page = $this->getPage( __METHOD__ );

		$parentRev = $this->createRevision( $page, 'first' );

		$mainContent = new WikitextContent( 'Lorem ipsum' );

		$update = new RevisionSlotsUpdate();
		$update->modifyContent( SlotRecord::MAIN, $mainContent );
		$updater = $this->getDerivedPageDataUpdater( $page );
		$updater->prepareContent( $user, $update, false );

		$options1 = $updater->getCanonicalParserOptions();
		$this->assertSame( $this->getServiceContainer()->getContentLanguage(),
			$options1->getUserLangObj() );

		$speculativeId = $options1->getSpeculativeRevId();
		$this->assertSame( $parentRev->getId() + 1, $speculativeId );

		$rev = $this->makeRevision(
			$page->getTitle(),
			$update,
			$user,
			$parentRev->getId() + 7,
			$parentRev->getId()
		);
		$updater->prepareUpdate( $rev );

		$options2 = $updater->getCanonicalParserOptions();

		$currentRev = $options2->getCurrentRevisionRecordCallback()( $page->getTitle() );
		$this->assertSame( $rev->getId(), $currentRev->getId() );
	}

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::grabCurrentRevision()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
	 */
	public function testGrabCurrentRevision() {
		$page = $this->getPage( __METHOD__ );

		$updater0 = $this->getDerivedPageDataUpdater( $page );
		$this->assertNull( $updater0->grabCurrentRevision() );
		$this->assertFalse( $updater0->pageExisted() );

		$rev1 = $this->createRevision( $page, 'first' );
		$updater1 = $this->getDerivedPageDataUpdater( $page );
		$this->assertSame( $rev1->getId(), $updater1->grabCurrentRevision()->getId() );
		$this->assertFalse( $updater0->pageExisted() );
		$this->assertTrue( $updater1->pageExisted() );

		$rev2 = $this->createRevision( $page, 'second' );
		$updater2 = $this->getDerivedPageDataUpdater( $page );
		$this->assertSame( $rev1->getId(), $updater1->grabCurrentRevision()->getId() );
		$this->assertSame( $rev2->getId(), $updater2->grabCurrentRevision()->getId() );
	}

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareContent()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isContentPrepared()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlots()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawSlot()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawContent()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getModifiedSlotRoles()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getTouchedSlotRoles()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
	 */
	public function testPrepareContent() {
		$slotRoleRegistry = $this->getServiceContainer()->getSlotRoleRegistry();
		if ( !$slotRoleRegistry->isDefinedRole( 'aux' ) ) {
			$slotRoleRegistry->defineRoleWithModel(
				'aux',
				CONTENT_MODEL_WIKITEXT
			);
		}

		$sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
		$updater = $this->getDerivedPageDataUpdater( __METHOD__ );

		$this->assertFalse( $updater->isContentPrepared() );

		// TODO: test stash
		// TODO: MCR: Test multiple slots. Test slot removal.
		$mainContent = new WikitextContent( 'first [[main]] ~~~' );
		$auxContent = new WikitextContent( 'inherited ~~~ content' );
		$auxSlot = SlotRecord::newSaved(
			10, 7, 'tt:7',
			SlotRecord::newUnsaved( 'aux', $auxContent )
		);

		$update = new RevisionSlotsUpdate();
		$update->modifyContent( SlotRecord::MAIN, $mainContent );
		$update->modifySlot( SlotRecord::newInherited( $auxSlot ) );
		// TODO: MCR: test removing slots!

		$updater->prepareContent( $sysop, $update, false );

		// second be ok to call again with the same params
		$updater->prepareContent( $sysop, $update, false );

		$this->assertNull( $updater->grabCurrentRevision() );
		$this->assertTrue( $updater->isContentPrepared() );
		$this->assertFalse( $updater->isUpdatePrepared() );
		$this->assertFalse( $updater->pageExisted() );
		$this->assertTrue( $updater->isCreation() );
		$this->assertTrue( $updater->isChange() );
		$this->assertFalse( $updater->isContentDeleted() );

		$this->assertNotNull( $updater->getRevision() );
		$this->assertNotNull( $updater->getRenderedRevision() );

		$this->assertEquals( [ SlotRecord::MAIN, 'aux' ], $updater->getSlots()->getSlotRoles() );
		$this->assertEquals( [ SlotRecord::MAIN ], array_keys( $updater->getSlots()->getOriginalSlots() ) );
		$this->assertEquals( [ 'aux' ], array_keys( $updater->getSlots()->getInheritedSlots() ) );
		$this->assertEquals( [ SlotRecord::MAIN, 'aux' ], $updater->getModifiedSlotRoles() );
		$this->assertEquals( [ SlotRecord::MAIN, 'aux' ], $updater->getTouchedSlotRoles() );

		$mainSlot = $updater->getRawSlot( SlotRecord::MAIN );
		$this->assertInstanceOf( SlotRecord::class, $mainSlot );
		$this->assertStringNotContainsString(
			'~~~',
			$mainSlot->getContent()->serialize(),
			'PST should apply.'
		);
		$this->assertStringContainsString( $sysop->getName(), $mainSlot->getContent()->serialize() );

		$auxSlot = $updater->getRawSlot( 'aux' );
		$this->assertInstanceOf( SlotRecord::class, $auxSlot );
		$this->assertStringContainsString(
			'~~~',
			$auxSlot->getContent()->serialize(),
			'No PST should apply.'
		);

		$mainOutput = $updater->getSlotParserOutput( SlotRecord::MAIN );
		$text = $mainOutput->getRawText();
		$this->assertStringContainsString( 'first', $text );
		$this->assertStringContainsString( '<a ', $text );
		$this->assertNotEmpty( $mainOutput->getLinks() );

		$canonicalOutput = $updater->getCanonicalParserOutput();
		$text = $canonicalOutput->getRawText();
		$this->assertStringContainsString( 'first', $text );
		$this->assertStringContainsString( '<a ', $text );
		$this->assertStringContainsString( 'inherited ', $text );
		$this->assertNotEmpty( $canonicalOutput->getLinks() );
	}

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareContent()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
	 */
	public function testPrepareContentInherit() {
		$sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
		$page = $this->getPage( __METHOD__ );

		$mainContent1 = new WikitextContent( 'first [[main]] ({{REVISIONUSER}}) #~~~#' );
		$mainContent2 = new WikitextContent( 'second ({{subst:REVISIONUSER}}) #~~~#' );

		$rev = $this->createRevision( $page, 'first', $mainContent1 );
		$mainContent1 = $rev->getContent( SlotRecord::MAIN ); // get post-pst content
		$userName = $rev->getUser()->getName();
		$sysopName = $sysop->getName();

		$update = new RevisionSlotsUpdate();
		$update->modifyContent( SlotRecord::MAIN, $mainContent1 );
		$updater1 = $this->getDerivedPageDataUpdater( $page );
		$updater1->prepareContent( $sysop, $update, false );

		$this->assertNotNull( $updater1->grabCurrentRevision() );
		$this->assertTrue( $updater1->isContentPrepared() );
		$this->assertTrue( $updater1->pageExisted() );
		$this->assertFalse( $updater1->isCreation() );
		$this->assertFalse( $updater1->isChange() );

		$this->assertNotNull( $updater1->getRevision() );
		$this->assertNotNull( $updater1->getRenderedRevision() );

		// parser-output for null-edit uses the original author's name
		$html = $updater1->getRenderedRevision()->getRevisionParserOutput()->getRawText();
		$this->assertStringNotContainsString( $sysopName, $html, '{{REVISIONUSER}}' );
		$this->assertStringNotContainsString( '{{REVISIONUSER}}', $html, '{{REVISIONUSER}}' );
		$this->assertStringNotContainsString( '~~~', $html, 'signature ~~~' );
		$this->assertStringContainsString( '(' . $userName . ')', $html, '{{REVISIONUSER}}' );
		$this->assertStringContainsString( '>' . $userName . '<', $html, 'signature ~~~' );

		// prepare forced dummy revision
		$emptyUpdate = new RevisionSlotsUpdate();
		$updater1 = $this->getDerivedPageDataUpdater( $page );
		$updater1->setForceEmptyRevision( true );
		$updater1->prepareContent( $sysop, $emptyUpdate, false );

		// dummy revision inherits slots, but not revision ID
		$mainSlot0 = $rev->getSlot( SlotRecord::MAIN );
		$dummyRev = $updater1->getRevision();
		$dummySlot = $dummyRev->getSlot( SlotRecord::MAIN );
		$this->assertSame( $mainSlot0->getAddress(), $dummySlot->getAddress() );
		$this->assertNull( $dummyRev->getId() );
		$this->assertSame( $rev->getId(), $dummyRev->getParentId() );

		// prepare non-null
		$update = new RevisionSlotsUpdate();
		$update->modifyContent( SlotRecord::MAIN, $mainContent2 );
		$updater2 = $this->getDerivedPageDataUpdater( $page );
		$updater2->prepareContent( $sysop, $update, false );

		// non-null edit use the new user name in PST
		$pstText = $updater2->getSlots()->getContent( SlotRecord::MAIN )->serialize();
		$this->assertStringNotContainsString(
			'{{subst:REVISIONUSER}}',
			$pstText,
			'{{subst:REVISIONUSER}}'
		);
		$this->assertStringNotContainsString( '~~~', $pstText, 'signature ~~~' );
		$this->assertStringContainsString( '(' . $sysopName . ')', $pstText, '{{subst:REVISIONUSER}}' );
		$this->assertStringContainsString( ':' . $sysopName . '|', $pstText, 'signature ~~~' );

		$this->assertFalse( $updater2->isCreation() );
		$this->assertTrue( $updater2->isChange() );
	}

	// TODO: test failure of prepareContent() when called again...
	// - with different user
	// - with different update
	// - after calling prepareUpdate()

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isUpdatePrepared()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlots()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawSlot()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawContent()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getModifiedSlotRoles()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getTouchedSlotRoles()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
	 */
	public function testPrepareUpdate() {
		$page = $this->getPage( __METHOD__ );

		$mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
		$rev1 = $this->createRevision( $page, 'first', $mainContent1 );
		$updater1 = $this->getDerivedPageDataUpdater( $page, $rev1 );

		$options = []; // TODO: test *all* the options...
		$updater1->prepareUpdate( $rev1, $options );

		$this->assertTrue( $updater1->isUpdatePrepared() );
		$this->assertTrue( $updater1->isContentPrepared() );
		$this->assertTrue( $updater1->isCreation() );
		$this->assertTrue( $updater1->isChange() );
		$this->assertFalse( $updater1->isContentDeleted() );

		$this->assertNotNull( $updater1->getRevision() );
		$this->assertNotNull( $updater1->getRenderedRevision() );

		$this->assertEquals( [ SlotRecord::MAIN ], $updater1->getSlots()->getSlotRoles() );
		$this->assertEquals( [ SlotRecord::MAIN ], array_keys( $updater1->getSlots()->getOriginalSlots() ) );
		$this->assertEquals( [], array_keys( $updater1->getSlots()->getInheritedSlots() ) );
		$this->assertEquals( [ SlotRecord::MAIN ], $updater1->getModifiedSlotRoles() );
		$this->assertEquals( [ SlotRecord::MAIN ], $updater1->getTouchedSlotRoles() );

		// TODO: MCR: test multiple slots, test slot removal!

		$this->assertInstanceOf( SlotRecord::class, $updater1->getRawSlot( SlotRecord::MAIN ) );
		$this->assertStringNotContainsString(
			'~~~~',
			$updater1->getRawContent( SlotRecord::MAIN )->serialize()
		);

		$mainOutput = $updater1->getSlotParserOutput( SlotRecord::MAIN );
		$mainText = $mainOutput->getRawText();
		$this->assertStringContainsString( 'first', $mainText );
		$this->assertStringContainsString( '<a ', $mainText );
		$this->assertNotEmpty( $mainOutput->getLinks() );

		$canonicalOutput = $updater1->getCanonicalParserOutput();
		$canonicalText = $canonicalOutput->getRawText();
		$this->assertStringContainsString( 'first', $canonicalText );
		$this->assertStringContainsString( '<a ', $canonicalText );
		$this->assertNotEmpty( $canonicalOutput->getLinks() );

		$mainContent2 = new WikitextContent( 'second' );
		$rev2 = $this->createRevision( $page, 'second', $mainContent2 );
		$updater2 = $this->getDerivedPageDataUpdater( $page, $rev2 );

		$options = []; // TODO: test *all* the options...
		$updater2->prepareUpdate( $rev2, $options );

		$this->assertFalse( $updater2->isCreation() );
		$this->assertTrue( $updater2->isChange() );

		$canonicalOutput = $updater2->getCanonicalParserOutput();
		$this->assertStringContainsString( 'second', $canonicalOutput->getRawText() );
	}

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
	 */
	public function testPrepareUpdateReusesParserOutput() {
		$user = $this->getTestUser()->getUser();
		$page = $this->getPage( __METHOD__ );

		$mainContent1 = new WikitextContent( 'first [[main]] ~~~' );

		$update = new RevisionSlotsUpdate();
		$update->modifyContent( SlotRecord::MAIN, $mainContent1 );
		$updater = $this->getDerivedPageDataUpdater( $page );
		$updater->prepareContent( $user, $update, false );

		$mainOutput = $updater->getSlotParserOutput( SlotRecord::MAIN );
		$canonicalOutput = $updater->getCanonicalParserOutput();

		$rev = $this->createRevision( $page, 'first', $mainContent1 );

		$options = []; // TODO: test *all* the options...
		$updater->prepareUpdate( $rev, $options );

		$this->assertTrue( $updater->isUpdatePrepared() );
		$this->assertTrue( $updater->isContentPrepared() );

		$this->assertSame( $mainOutput, $updater->getSlotParserOutput( SlotRecord::MAIN ) );
		$this->assertSame( $canonicalOutput, $updater->getCanonicalParserOutput() );
	}

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
	 */
	public function testPrepareUpdateOutputReset() {
		$user = $this->getTestUser()->getUser();
		$page = $this->getPage( __METHOD__ );

		$mainContent1 = new WikitextContent( 'first --{{REVISIONID}}--' );

		$update = new RevisionSlotsUpdate();
		$update->modifyContent( SlotRecord::MAIN, $mainContent1 );
		$updater = $this->getDerivedPageDataUpdater( $page );
		$updater->prepareContent( $user, $update, false );

		$mainOutput = $updater->getSlotParserOutput( SlotRecord::MAIN );
		$canonicalOutput = $updater->getCanonicalParserOutput();

		// prevent optimization on matching speculative ID
		$mainOutput->setSpeculativeRevIdUsed( 0 );
		$canonicalOutput->setSpeculativeRevIdUsed( 0 );

		$rev = $this->createRevision( $page, 'first', $mainContent1 );

		$options = []; // TODO: test *all* the options...
		$updater->prepareUpdate( $rev, $options );

		$this->assertTrue( $updater->isUpdatePrepared() );
		$this->assertTrue( $updater->isContentPrepared() );

		// ParserOutput objects should have been flushed.
		$this->assertNotSame( $mainOutput, $updater->getSlotParserOutput( SlotRecord::MAIN ) );
		$this->assertNotSame( $canonicalOutput, $updater->getCanonicalParserOutput() );

		$html = $updater->getCanonicalParserOutput()->getRawText();
		$this->assertStringContainsString( '--' . $rev->getId() . '--', $html );

		// TODO: MCR: ensure that when the main slot uses {{REVISIONID}} but another slot is
		// updated, the main slot is still re-rendered!
	}

	// TODO: test failure of prepareUpdate() when called again with a different revision
	// TODO: test failure of prepareUpdate() on inconsistency with prepareContent.

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getPreparedEdit()
	 */
	public function testGetPreparedEditAfterPrepareContent() {
		$user = $this->getTestUser()->getUser();

		$mainContent = new WikitextContent( 'first [[main]] ~~~' );
		$update = new RevisionSlotsUpdate();
		$update->modifyContent( SlotRecord::MAIN, $mainContent );

		$updater = $this->getDerivedPageDataUpdater( __METHOD__ );
		$updater->prepareContent( $user, $update, false );

		$canonicalOutput = $updater->getCanonicalParserOutput();

		$preparedEdit = $updater->getPreparedEdit();
		$this->assertSame( $canonicalOutput->getCacheTime(), $preparedEdit->timestamp );
		$this->assertSame( $canonicalOutput, $preparedEdit->output );
		$this->assertSame( $mainContent, $preparedEdit->newContent );
		$this->assertSame( $updater->getRawContent( SlotRecord::MAIN ), $preparedEdit->pstContent );
		$this->assertSame( $updater->getCanonicalParserOptions(), $preparedEdit->popts );
		$this->assertSame( null, $preparedEdit->revid );
	}

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getPreparedEdit()
	 */
	public function testGetPreparedEditAfterPrepareUpdate() {
		$clock = MWTimestamp::convert( TS_UNIX, '20100101000000' );
		MWTimestamp::setFakeTime( static function () use ( &$clock ) {
			return $clock++;
		} );

		$page = $this->getPage( __METHOD__ );

		$mainContent = new WikitextContent( 'first [[main]] ~~~' );
		$update = new MutableRevisionSlots();
		$update->setContent( SlotRecord::MAIN, $mainContent );

		$rev = $this->createRevision( $page, __METHOD__ );

		$updater = $this->getDerivedPageDataUpdater( $page );
		$updater->prepareUpdate( $rev );

		$canonicalOutput = $updater->getCanonicalParserOutput();

		$preparedEdit = $updater->getPreparedEdit();
		$this->assertSame( $canonicalOutput->getCacheTime(), $preparedEdit->timestamp );
		$this->assertSame( $canonicalOutput, $preparedEdit->output );
		$this->assertSame( $updater->getRawContent( SlotRecord::MAIN ), $preparedEdit->pstContent );
		$this->assertSame( $updater->getCanonicalParserOptions(), $preparedEdit->popts );
		$this->assertSame( $rev->getId(), $preparedEdit->revid );
	}

	public function testGetSecondaryDataUpdatesAfterPrepareContent() {
		$user = $this->getTestUser()->getUser();
		$page = $this->getPage( __METHOD__ );
		$this->createRevision( $page, __METHOD__ );

		$mainContent1 = new WikitextContent( 'first' );

		$update = new RevisionSlotsUpdate();
		$update->modifyContent( SlotRecord::MAIN, $mainContent1 );
		$updater = $this->getDerivedPageDataUpdater( $page );
		$updater->prepareContent( $user, $update, false );

		$dataUpdates = $updater->getSecondaryDataUpdates();

		$this->assertNotEmpty( $dataUpdates );

		$linksUpdates = array_filter( $dataUpdates, static function ( $du ) {
			return $du instanceof LinksUpdate;
		} );
		$this->assertCount( 1, $linksUpdates );
	}

	public function testAvoidSecondaryDataUpdatesOnNonHTMLContentHandlers() {
		$this->overrideConfigValue(
			MainConfigNames::ContentHandlers,
			[
				CONTENT_MODEL_WIKITEXT => [
					'class' => WikitextContentHandler::class,
					'services' => [
						'TitleFactory',
						'ParserFactory',
						'GlobalIdGenerator',
						'LanguageNameUtils',
						'LinkRenderer',
						'MagicWordFactory',
						'ParsoidParserFactory',
					],
				],
				'testing' => DummyContentHandlerForTesting::class,
			]
		);

		$user = $this->getTestUser()->getUser();
		$page = $this->getPage( __METHOD__ );
		$this->createRevision( $page, __METHOD__ );

		$contentHandler = new DummyContentHandlerForTesting( 'testing' );
		$mainContent1 = $contentHandler->unserializeContent( serialize( 'first' ) );
		$update = new RevisionSlotsUpdate();
		$pcache = $this->getServiceContainer()->getParserCache();
		$pcache->deleteOptionsKey( $page );
		$rev = $this->createRevision( $page, 'first', $mainContent1 );

		// Run updates
		$update->modifyContent( SlotRecord::MAIN, $mainContent1 );
		$updater = $this->getDerivedPageDataUpdater( $page );
		$updater->prepareContent( $user, $update, false );
		$dataUpdates = $updater->getSecondaryDataUpdates();
		$updater->prepareUpdate( $rev );
		$updater->doUpdates();

		// Links updates should be triggered
		$this->assertNotEmpty( $dataUpdates );
		$linksUpdates = array_filter( $dataUpdates, static function ( $du ) {
			return $du instanceof LinksUpdate;
		} );
		$this->assertCount( 1, $linksUpdates );

		// Parser cache should not be populated.
		$cached = $pcache->get( $page, $updater->getCanonicalParserOptions() );
		$this->assertFalse( $cached );
	}

	public function testGetSecondaryDataUpdatesDeleted() {
		$user = $this->getTestUser()->getUser();
		$page = $this->getPage( __METHOD__ );
		$this->createRevision( $page, __METHOD__ );

		$mainContent1 = new WikitextContent( 'first' );

		$update = new RevisionSlotsUpdate();
		$update->modifyContent( SlotRecord::MAIN, $mainContent1 );
		$updater = $this->getDerivedPageDataUpdater( $page );
		$updater->prepareContent( $user, $update, false );

		// Test that nothing happens if the page was deleted in the meantime
		// This can happen when started by the job queue
		$this->deletePage( $page );

		$dataUpdates = $updater->getSecondaryDataUpdates();

		$this->assertSame( [], $dataUpdates );
	}

	/**
	 * @param string $name
	 *
	 * @return ContentHandler
	 */
	private function defineMockContentModelForUpdateTesting( $name ) {
		/** @var ContentHandler|MockObject $handler */
		$handler = $this->getMockBuilder( TextContentHandler::class )
			->setConstructorArgs( [ $name ] )
			->onlyMethods(
				[ 'getSecondaryDataUpdates', 'getDeletionUpdates', 'unserializeContent' ]
			)
			->getMock();

		$dataUpdate = new MWCallableUpdate( 'time', "$name data update" );

		$deletionUpdate = new MWCallableUpdate( 'time', "$name deletion update" );

		$handler->method( 'getSecondaryDataUpdates' )->willReturn( [ $dataUpdate ] );
		$handler->method( 'getDeletionUpdates' )->willReturn( [ $deletionUpdate ] );
		$handler->method( 'unserializeContent' )->willReturnCallback(
			function ( $text ) use ( $handler ) {
				return $this->createMockContent( $handler, $text );
			}
		);

		$this->mergeMwGlobalArrayValue(
			'wgContentHandlers', [
				$name => static function () use ( $handler ){
					return $handler;
				}
			]
		);

		return $handler;
	}

	/**
	 * @param ContentHandler $handler
	 * @param string $text
	 *
	 * @return Content
	 */
	private function createMockContent( ContentHandler $handler, $text ) {
		/** @var Content|MockObject $content */
		$content = $this->getMockBuilder( TextContent::class )
			->setConstructorArgs( [ $text ] )
			->onlyMethods( [ 'getModel', 'getContentHandler' ] )
			->getMock();

		$content->method( 'getModel' )->willReturn( $handler->getModelID() );
		$content->method( 'getContentHandler' )->willReturn( $handler );

		return $content;
	}

	public function testGetSecondaryDataUpdatesWithSlotRemoval() {
		$m1 = $this->defineMockContentModelForUpdateTesting( 'M1' );
		$a1 = $this->defineMockContentModelForUpdateTesting( 'A1' );
		$m2 = $this->defineMockContentModelForUpdateTesting( 'M2' );

		$role = 'dpdu-test-a1';
		$slotRoleRegistry = $this->getServiceContainer()->getSlotRoleRegistry();
		$slotRoleRegistry->defineRoleWithModel(
			$role,
			$a1->getModelID()
		);

		// pin the service instance for this test
		$this->setService( 'SlotRoleRegistry', $slotRoleRegistry );

		$mainContent1 = $this->createMockContent( $m1, 'main 1' );
		$auxContent1 = $this->createMockContent( $a1, 'aux 1' );
		$mainContent2 = $this->createMockContent( $m2, 'main 2' );

		$user = $this->getTestUser()->getUser();
		$page = $this->getPage( __METHOD__ );
		$this->createRevision(
			$page,
			__METHOD__,
			[ SlotRecord::MAIN => $mainContent1, $role => $auxContent1 ]
		);

		$update = new RevisionSlotsUpdate();
		$update->modifyContent( SlotRecord::MAIN, $mainContent2 );
		$update->removeSlot( $role );

		$page = $this->getPage( __METHOD__ );
		$updater = $this->getDerivedPageDataUpdater( $page );
		$updater->prepareContent( $user, $update, false );

		$dataUpdates = $updater->getSecondaryDataUpdates();

		$this->assertNotEmpty( $dataUpdates );

		$updateNames = array_map( static function ( $du ) {
			return $du instanceof MWCallableUpdate ? $du->getOrigin() : get_class( $du );
		}, $dataUpdates );

		$this->assertContains( LinksUpdate::class, $updateNames );
		$this->assertContains( 'A1 deletion update', $updateNames );
		$this->assertContains( 'M2 data update', $updateNames );
		$this->assertNotContains( 'M1 data update', $updateNames );
	}

	/**
	 * Creates a dummy MutableRevisionRecord without touching the database.
	 *
	 * @param PageIdentity $title
	 * @param string|Content|Content[]|RevisionSlotsUpdate $update
	 * @param UserIdentity|null $user
	 * @param string $comment
	 * @param int $id
	 * @param int $parentId
	 *
	 * @return MutableRevisionRecord
	 */
	private function makeRevision(
		PageIdentity $title,
		$update,
		?UserIdentity $user = null,
		$comment = "testing",
		$id = 0,
		$parentId = 0
	) {
		$rev = new MutableRevisionRecord( $title );

		if ( $update instanceof RevisionSlotsUpdate ) {
			$rev->applyUpdate( $update );
		} else {
			if ( is_string( $update ) ) {
				$update = new WikitextContent( $update );
			}

			if ( !is_array( $update ) ) {
				$update = [ SlotRecord::MAIN => $update ];
			}

			foreach ( $update as $role => $content ) {
				$rev->setContent( $role, $content );
			}
		}

		if ( !$user ) {
			$user = $this->getTestUser()->getUser();
		}

		$rev->setUser( $user );
		$rev->setComment( CommentStoreComment::newUnsavedComment( $comment ) );
		$rev->setPageId( $title->getId() );
		$rev->setParentId( $parentId );

		if ( $id ) {
			$rev->setId( $id );
		}

		return $rev;
	}

	public function provideIsReusableFor() {
		$title = PageIdentityValue::localIdentity( 1234, NS_MAIN, __CLASS__ );

		$user1 = new UserIdentityValue( 111, 'Alice' );
		$user2 = new UserIdentityValue( 222, 'Bob' );

		$content1 = new WikitextContent( 'one' );
		$content2 = new WikitextContent( 'two' );

		$update1 = new RevisionSlotsUpdate();
		$update1->modifyContent( SlotRecord::MAIN, $content1 );

		$update1b = new RevisionSlotsUpdate();
		$update1b->modifyContent( 'xyz', $content1 );

		$update2 = new RevisionSlotsUpdate();
		$update2->modifyContent( SlotRecord::MAIN, $content2 );

		$rev1 = $this->makeRevision( $title, $update1, $user1, 'rev1', 11 );
		$rev1b = $this->makeRevision( $title, $update1b, $user1, 'rev1', 11 );

		$rev2 = $this->makeRevision( $title, $update2, $user1, 'rev2', 12 );
		$rev2x = $this->makeRevision( $title, $update2, $user2, 'rev2', 12 );
		$rev2y = $this->makeRevision( $title, $update2, $user1, 'rev2', 122 );

		yield 'any' => [
			'$prepUser' => null,
			'$prepRevision' => null,
			'$prepUpdate' => null,
			'$forUser' => null,
			'$forRevision' => null,
			'$forUpdate' => null,
			'$forParent' => null,
			'$isReusable' => true,
		];
		yield 'for any' => [
			'$prepUser' => $user1,
			'$prepRevision' => $rev1,
			'$prepUpdate' => $update1,
			'$forUser' => null,
			'$forRevision' => null,
			'$forUpdate' => null,
			'$forParent' => null,
			'$isReusable' => true,
		];
		yield 'unprepared' => [
			'$prepUser' => null,
			'$prepRevision' => null,
			'$prepUpdate' => null,
			'$forUser' => $user1,
			'$forRevision' => $rev1,
			'$forUpdate' => $update1,
			'$forParent' => 0,
			'$isReusable' => true,
		];
		yield 'match prepareContent' => [
			'$prepUser' => $user1,
			'$prepRevision' => null,
			'$prepUpdate' => $update1,
			'$forUser' => $user1,
			'$forRevision' => null,
			'$forUpdate' => $update1,
			'$forParent' => 0,
			'$isReusable' => true,
		];
		yield 'match prepareUpdate' => [
			'$prepUser' => null,
			'$prepRevision' => $rev1,
			'$prepUpdate' => null,
			'$forUser' => $user1,
			'$forRevision' => $rev1,
			'$forUpdate' => null,
			'$forParent' => 0,
			'$isReusable' => true,
		];
		yield 'match all' => [
			'$prepUser' => $user1,
			'$prepRevision' => $rev1,
			'$prepUpdate' => $update1,
			'$forUser' => $user1,
			'$forRevision' => $rev1,
			'$forUpdate' => $update1,
			'$forParent' => 0,
			'$isReusable' => true,
		];
		yield 'mismatch prepareContent update' => [
			'$prepUser' => $user1,
			'$prepRevision' => null,
			'$prepUpdate' => $update1,
			'$forUser' => $user1,
			'$forRevision' => null,
			'$forUpdate' => $update1b,
			'$forParent' => 0,
			'$isReusable' => false,
		];
		yield 'mismatch prepareContent user' => [
			'$prepUser' => $user1,
			'$prepRevision' => null,
			'$prepUpdate' => $update1,
			'$forUser' => $user2,
			'$forRevision' => null,
			'$forUpdate' => $update1,
			'$forParent' => 0,
			'$isReusable' => false,
		];
		yield 'mismatch prepareContent parent' => [
			'$prepUser' => $user1,
			'$prepRevision' => null,
			'$prepUpdate' => $update1,
			'$forUser' => $user1,
			'$forRevision' => null,
			'$forUpdate' => $update1,
			'$forParent' => 7,
			'$isReusable' => false,
		];
		yield 'mismatch prepareUpdate revision update' => [
			'$prepUser' => null,
			'$prepRevision' => $rev1,
			'$prepUpdate' => null,
			'$forUser' => null,
			'$forRevision' => $rev1b,
			'$forUpdate' => null,
			'$forParent' => 0,
			'$isReusable' => false,
		];
		yield 'mismatch prepareUpdate revision id' => [
			'$prepUser' => null,
			'$prepRevision' => $rev2,
			'$prepUpdate' => null,
			'$forUser' => null,
			'$forRevision' => $rev2y,
			'$forUpdate' => null,
			'$forParent' => 0,
			'$isReusable' => false,
		];
	}

	/**
	 * @dataProvider provideIsReusableFor
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isReusableFor()
	 */
	public function testIsReusableFor(
		?UserIdentity $prepUser,
		?RevisionRecord $prepRevision,
		?RevisionSlotsUpdate $prepUpdate,
		?UserIdentity $forUser,
		?RevisionRecord $forRevision,
		?RevisionSlotsUpdate $forUpdate,
		$forParent,
		$isReusable
	) {
		$updater = $this->getDerivedPageDataUpdater( __METHOD__ );

		if ( $prepUpdate ) {
			$updater->prepareContent( $prepUser, $prepUpdate, false );
		}

		if ( $prepRevision ) {
			$updater->prepareUpdate( $prepRevision );
		}

		$this->assertSame(
			$isReusable,
			$updater->isReusableFor( $forUser, $forRevision, $forUpdate, $forParent )
		);
	}

	/**
	 * * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCountable
	 */
	public function testIsCountableNotContentPage() {
		$updater = $this->getDerivedPageDataUpdater(
			Title::makeTitle( NS_TALK, 'Main_Page' )
		);
		self::assertFalse( $updater->isCountable() );
	}

	public static function provideIsCountable() {
		yield 'deleted revision' => [
			'$articleCountMethod' => 'any',
			'$wikitextContent' => 'Test',
			'$revisionVisibility' => RevisionRecord::SUPPRESSED_ALL,
			'$isCountable' => false
		];
		yield 'redirect' => [
			'$articleCountMethod' => 'any',
			'$wikitextContent' => '#REDIRECT [[Main_Page]]',
			'$revisionVisibility' => 0,
			'$isCountable' => false
		];
		yield 'no links count method any' => [
			'$articleCountMethod' => 'any',
			'$wikitextContent' => 'Test',
			'$revisionVisibility' => 0,
			'$isCountable' => true
		];
		yield 'no links count method link' => [
			'$articleCountMethod' => 'link',
			'$wikitextContent' => 'Test',
			'$revisionVisibility' => 0,
			'$isCountable' => false
		];
		yield 'with links count method link' => [
			'$articleCountMethod' => 'link',
			'$wikitextContent' => '[[Test]]',
			'$revisionVisibility' => 0,
			'$isCountable' => true
		];
	}

	/**
	 * @dataProvider provideIsCountable
	 *
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCountable
	 */
	public function testIsCountable(
		$articleCountMethod,
		$wikitextContent,
		$revisionVisibility,
		$isCountable
	) {
		$this->overrideConfigValue( MainConfigNames::ArticleCountMethod, $articleCountMethod );
		$title = $this->getTitle( 'Main_Page' );
		$content = new WikitextContent( $wikitextContent );
		$update = new RevisionSlotsUpdate();
		$update->modifyContent( SlotRecord::MAIN, $content );
		$revision = $this->makeRevision( $title, $update, User::newFromName( 'Alice' ), 'rev1', 13 );
		$revision->setVisibility( $revisionVisibility );
		$updater = $this->getDerivedPageDataUpdater( $title );
		$updater->prepareUpdate( $revision );
		self::assertSame( $isCountable, $updater->isCountable() );
	}

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCountable
	 */
	public function testIsCountableNoModifiedSlots() {
		$page = $this->getPage( __METHOD__ );
		$content = [ SlotRecord::MAIN => new WikitextContent( '[[Test]]' ) ];
		$rev = $this->createRevision( $page, 'first', $content );
		$nullRevision = MutableRevisionRecord::newFromParentRevision( $rev );
		$nullRevision->setId( 14 );
		$updater = $this->getDerivedPageDataUpdater( $page, $nullRevision );
		$updater->prepareUpdate( $nullRevision );
		$this->assertTrue( $updater->isCountable() );
	}

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
	 */
	public function testDoUpdates() {
		$page = $this->getPage( __METHOD__ );

		$content = [ SlotRecord::MAIN => new WikitextContent( 'first [[main]]' ) ];

		$content['aux'] = new WikitextContent( 'Aux [[Nix]]' );

		$slotRoleRegistry = $this->getServiceContainer()->getSlotRoleRegistry();
		if ( !$slotRoleRegistry->isDefinedRole( 'aux' ) ) {
			$slotRoleRegistry->defineRoleWithModel(
				'aux',
				CONTENT_MODEL_WIKITEXT
			);
		}

		$rev = $this->createRevision( $page, 'first', $content );
		$pageId = $page->getId();

		$oldStats = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'site_stats' )
			->where( '1=1' )
			->fetchRow();
		$this->getDb()->newDeleteQueryBuilder()
			->deleteFrom( 'pagelinks' )
			->where( ISQLPlatform::ALL_ROWS )
			->caller( __METHOD__ )
			->execute();

		$pcache = $this->getServiceContainer()->getParserCache();
		$pcache->deleteOptionsKey( $page );

		$updater = $this->getDerivedPageDataUpdater( $page, $rev );
		$updater->setArticleCountMethod( 'link' );

		$options = []; // TODO: test *all* the options...
		$updater->prepareUpdate( $rev, $options );

		$updater->doUpdates();

		// links table update
		$pageLinks = $this->getDb()->newSelectQueryBuilder()
			->select( 'lt_title' )
			->from( 'pagelinks' )
			->join( 'linktarget', null, 'pl_target_id=lt_id' )
			->where( [ 'pl_from' => $pageId ] )
			->orderBy( [ 'lt_namespace', 'lt_title' ] )
			->caller( __METHOD__ )->fetchResultSet();

		$pageLinksRow = $pageLinks->fetchObject();
		$this->assertIsObject( $pageLinksRow );
		$this->assertSame( 'Main', $pageLinksRow->lt_title );

		$pageLinksRow = $pageLinks->fetchObject();
		$this->assertIsObject( $pageLinksRow );
		$this->assertSame( 'Nix', $pageLinksRow->lt_title );

		// parser cache update
		$cached = $pcache->get( $page, $updater->getCanonicalParserOptions() );
		$this->assertIsObject( $cached );
		$expected = $updater->getCanonicalParserOutput();
		$expected->clearParseStartTime();
		$this->assertEquals( $expected, $cached );

		// site stats
		$stats = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'site_stats' )
			->where( '1=1' )
			->fetchRow();
		$this->assertSame( $oldStats->ss_total_pages + 1, (int)$stats->ss_total_pages );
		$this->assertSame( $oldStats->ss_total_edits + 1, (int)$stats->ss_total_edits );
		$this->assertSame( $oldStats->ss_good_articles + 1, (int)$stats->ss_good_articles );

		// TODO: MCR: test data updates for additional slots!
		// TODO: test update for edit without page creation
		// TODO: test message cache purge
		// TODO: test module cache purge
		// TODO: test CDN purge
		// TODO: test newtalk update
		// TODO: test search update
		// TODO: test site stats good_articles while turning the page into (or back from) a redir.
		// TODO: test category membership update (with setRcWatchCategoryMembership())
	}

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
	 */
	public function testDoUpdatesCacheSaveDeferral_canonical() {
		$page = $this->getPage( __METHOD__ );

		// Case where user has canonical parser options
		$content = [ SlotRecord::MAIN => new WikitextContent( 'rev ID ver #1: {{REVISIONID}}' ) ];
		$rev = $this->createRevision( $page, 'first', $content );
		$pcache = $this->getServiceContainer()->getParserCache();
		$pcache->deleteOptionsKey( $page );

		$this->getDb()->startAtomic( __METHOD__ ); // let deferred updates queue up

		$updater = $this->getDerivedPageDataUpdater( $page, $rev );
		$updater->prepareUpdate( $rev, [] );
		$updater->doUpdates();

		$this->assertGreaterThan( 0, DeferredUpdates::pendingUpdatesCount(), 'Pending updates' );
		$this->assertNotFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) );

		$this->getDb()->endAtomic( __METHOD__ ); // run deferred updates

		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount(), 'No pending updates' );
	}

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates()
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
	 */
	public function testDoUpdatesCacheSaveDeferral_noncanonical() {
		$page = $this->getPage( __METHOD__ );

		// Case where user does not have canonical parser options
		$user = $this->getMutableTestUser()->getUser();
		$services = $this->getServiceContainer();
		$userOptionsManager = $services->getUserOptionsManager();
		$userOptionsManager->setOption(
			$user,
			'thumbsize',
			$userOptionsManager->getOption( $user, 'thumbsize' ) + 1
		);
		$content = [ SlotRecord::MAIN => new WikitextContent( 'rev ID ver #2: {{REVISIONID}}' ) ];
		$rev = $this->createRevision( $page, 'first', $content, $user );
		$pcache = $services->getParserCache();
		$pcache->deleteOptionsKey( $page );

		$this->getDb()->startAtomic( __METHOD__ ); // let deferred updates queue up

		$updater = $this->getDerivedPageDataUpdater( $page, $rev, $user );
		$updater->prepareUpdate( $rev, [] );
		$updater->doUpdates();

		$this->assertGreaterThan( 1, DeferredUpdates::pendingUpdatesCount(), 'Pending updates' );
		$this->assertFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) );

		$this->getDb()->endAtomic( __METHOD__ ); // run deferred updates

		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount(), 'No pending updates' );
		$this->assertNotFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) );
	}

	public static function provideEnqueueRevertedTagUpdateJob() {
		return [
			'approved' => [ true, 1 ],
			'not approved' => [ false, 0 ]
		];
	}

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::maybeEnqueueRevertedTagUpdateJob
	 * @dataProvider provideEnqueueRevertedTagUpdateJob
	 */
	public function testEnqueueRevertedTagUpdateJob( bool $approved, int $queueSize ) {
		$page = $this->getPage( __METHOD__ );

		$content = [ SlotRecord::MAIN => new WikitextContent( '1' ) ];
		$rev = $this->createRevision( $page, '', $content );
		$editResult = new EditResult(
			false,
			10,
			EditResult::REVERT_ROLLBACK,
			11,
			12,
			true,
			false,
			[ 'mw-rollback' ]
		);

		$updater = $this->getDerivedPageDataUpdater( $page, $rev );

		$updater->prepareUpdate( $rev, [
			'editResult' => $editResult,
			'approved' => $approved
		] );
		$updater->doUpdates();

		$services = $this->getServiceContainer();
		$editResultCache = new EditResultCache(
			$services->getMainObjectStash(),
			$services->getConnectionProvider(),
			new ServiceOptions(
				EditResultCache::CONSTRUCTOR_OPTIONS,
				[ MainConfigNames::RCMaxAge => BagOStuff::TTL_MONTH ]
			)
		);

		if ( $approved ) {
			$this->assertNull(
				$editResultCache->get( $rev->getId() ),
				'EditResult should not be cached when the revert is approved'
			);
		} else {
			$this->assertEquals(
				$editResult,
				$editResultCache->get( $rev->getId() ),
				'EditResult should be cached when the revert is not approved'
			);
		}

		$jobQueueGroup = $this->getServiceContainer()->getJobQueueGroup();
		$jobQueue = $jobQueueGroup->get( 'revertedTagUpdate' );
		$this->assertSame(
			$queueSize,
			$jobQueue->getSize()
		);
	}

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
	 * @covers \ParsoidCachePrewarmJob::doParsoidCacheUpdate()
	 */
	public function testDoParserCacheUpdate() {
		$this->overrideConfigValue(
			MainConfigNames::ParsoidCacheConfig,
			[
				'CacheThresholdTime' => 0.0,
				'WarmParsoidParserCache' => true, // enable caching
			]
		);

		$slotRoleRegistry = $this->getServiceContainer()->getSlotRoleRegistry();
		if ( !$slotRoleRegistry->isDefinedRole( 'aux' ) ) {
			$slotRoleRegistry->defineRoleWithModel(
				'aux',
				CONTENT_MODEL_WIKITEXT
			);
		}

		// Create page
		$page = $this->getPage( __METHOD__ );
		ConvertibleTimestamp::setFakeTime( '2022-01-01T00:01:00Z' );
		$this->createRevision( $page, 'Dummy' );

		// Assert cache update after edit ----------
		$parserCacheFactory = $this->getServiceContainer()->getParserCacheFactory();
		$parserCache = $parserCacheFactory->getParserCache( ParserCacheFactory::DEFAULT_NAME );
		$parsoidCache = $parserCacheFactory->getParserCache( "parsoid-" . ParserCacheFactory::DEFAULT_NAME );

		$parserCache->deleteOptionsKey( $page );
		$parsoidCache->deleteOptionsKey( $page );

		$user = $this->getTestUser()->getUser();

		ConvertibleTimestamp::setFakeTime( '2022-01-01T00:02:00Z' );
		$updater = $page->newPageUpdater( $user );
		$updater->setContent( SlotRecord::MAIN, new WikitextContent( 'first [[Main]]' ) );
		$updater->setContent( 'aux', new WikitextContent( 'Aux [[Nix]]' ) );
		$rev = $updater->saveRevision( CommentStoreComment::newUnsavedComment( 'testing' ) );

		// run all the jobs
		ConvertibleTimestamp::setFakeTime( '2022-01-01T00:03:00Z' );
		$this->runJobs();

		// Parsoid cache should have an entry
		$parserOptions = ParserOptions::newFromAnon();
		$parserOptions->setUseParsoid();
		$parsoidCached = $parsoidCache->get( $page, $parserOptions, true );
		$this->assertIsObject( $parsoidCached );
		$this->assertStringContainsString( 'first', $parsoidCached->getRawText() );

		// The parsoid parser output is generated during runJobs(), after the last call to setFakeTime().
		$this->assertGreaterThan( $rev->getTimestamp(), $parsoidCached->getCacheTime() );
		$this->assertSame( $rev->getId(), $parsoidCached->getCacheRevisionId() );

		// Check that ParsoidRenderID::newFromParserOutput() doesn't throw,
		// so we know that $parsoidCached is valid.
		ParsoidRenderID::newFromParserOutput( $parsoidCached );

		// The cached ParserOutput should not use the revision timestamp
		// Create nwe ParserOptions object since we setUseParsoid() above
		$parserOptions = ParserOptions::newFromAnon();
		$cached = $parserCache->get( $page, $parserOptions, true );
		$this->assertIsObject( $cached );
		$this->assertNotSame( $parsoidCached, $cached );
		$this->assertStringContainsString( 'first', $cached->getRawText() );

		// The regular parser output is generated immediately during saveRevision(),
		// so it uses the same timestamp as the revision.
		$this->assertSame( $rev->getTimestamp(), $cached->getCacheTime() );
		$this->assertSame( $rev->getId(), $cached->getCacheRevisionId() );

		// Emulate forced update of an old revision ----------
		ConvertibleTimestamp::setFakeTime( '2022-01-01T00:04:00Z' );
		$parserCache->deleteOptionsKey( $page );

		$updater = $this->getDerivedPageDataUpdater( $page );
		$updater->prepareUpdate( $rev );

		// Force the page timestamp, so we notice whether ParserOutput::getTimestamp
		// or ParserOutput::getCacheTime are used.
		$page->setTimestamp( $rev->getTimestamp() );
		$updater->doParserCacheUpdate();

		// The cached ParserOutput should not use the revision timestamp
		$cached = $parserCache->get( $page, $updater->getCanonicalParserOptions(), true );
		$this->assertIsObject( $cached );
		$expected = $updater->getCanonicalParserOutput();
		$expected->clearParseStartTime();
		$this->assertEquals( $expected, $cached );

		$this->assertGreaterThan( $rev->getTimestamp(), $cached->getCacheTime() );
		$this->assertSame( $rev->getId(), $cached->getCacheRevisionId() );
	}

	/**
	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
	 * @covers \ParsoidCachePrewarmJob::doParsoidCacheUpdate()
	 */
	public function testDoParserCacheUpdateForJavaScriptContent() {
		$this->overrideConfigValue(
			MainConfigNames::ParsoidCacheConfig,
			[
				'CacheThresholdTime' => 0.0,
				'WarmParsoidParserCache' => true, // enable caching
			]
		);

		$page = $this->getPage( __METHOD__ );
		$this->createRevision( $page, 'Dummy' );

		$user = $this->getTestUser()->getUser();

		$update = new RevisionSlotsUpdate();
		$update->modifyContent( SlotRecord::MAIN, new JavaScriptContent( '{ first: "main"; }' ) );

		// Emulate update after edit ----------
		$parserCacheFactory = $this->getServiceContainer()->getParserCacheFactory();
		$parserCache = $parserCacheFactory->getParserCache( ParserCacheFactory::DEFAULT_NAME );
		$parsoidCache = $parserCacheFactory->getParserCache( ParserOutputAccess::PARSOID_PCACHE_NAME );

		$parserCache->deleteOptionsKey( $page );
		$parsoidCache->deleteOptionsKey( $page );

		$rev = $this->makeRevision( $page->getTitle(), $update, $user, 'rev', null );
		$rev->setTimestamp( '20100101000000' );
		$rev->setParentId( $page->getLatest() );

		$updater = $this->getDerivedPageDataUpdater( $page );
		$updater->prepareContent( $user, $update, false );

		$rev->setId( 1107 );
		$updater->prepareUpdate( $rev );

		// Force the page timestamp, so we notice whether ParserOutput::getTimestamp
		// or ParserOutput::getCacheTime are used.
		// Also ensure $page->getLatest() returns the correct revision ID, so the parser
		// cache doesn't get confused.
		TestingAccessWrapper::newFromObject( $page )->setLastEdit( $rev );
		$updater->doParserCacheUpdate();

		// The cached ParserOutput should not use the revision timestamp
		$cached = $parserCache->get( $page, $updater->getCanonicalParserOptions(), true );
		$this->assertIsObject( $cached );
	}

	/**
	 * Helper for testTemplateUpdate
	 *
	 * @param WikiPage $page
	 * @param string $content
	 */
	private function editAndUpdate( $page, $content ) {
		$this->createRevision( $page, $content );
		$this->getServiceContainer()->resetServiceForTesting( 'BacklinkCacheFactory' );
		$this->runJobs();
	}

	/**
	 * Regression test for T368006
	 */
	public function testTemplateUpdate() {
		$clock = MWTimestamp::convert( TS_UNIX, '20100101000000' );
		MWTimestamp::setFakeTime( static function () use ( &$clock ) {
			return $clock++;
		} );

		$template = $this->getPage( 'Template:TestTemplateUpdate' );
		$page = $this->getPage( 'TestTemplateUpdate' );
		$this->editAndUpdate( $template, '1' );
		$this->editAndUpdate( $page, '{{TestTemplateUpdate}}' );
		$oldTouched = $page->getTouched();
		$page->clear();

		$this->editAndUpdate( $template, '2' );
		$newTouched = $page->getTouched();
		$this->assertGreaterThan( $oldTouched, $newTouched );
	}

}
PK       ! n2@  @  -  Storage/PageUpdaterFactoryIntegrationTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Storage;

use MediaWiki\Content\ContentHandler;
use MediaWiki\Content\TextContent;
use MediaWiki\Revision\SlotRecord;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Storage\PageUpdaterFactory
 * @group Database
 */
class PageUpdaterFactoryIntegrationTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers \WikiPage::newPageUpdater
	 */
	public function testNewPageUpdater() {
		$page = $this->getExistingTestPage();
		$title = $page->getTitle();

		$user = $this->getTestUser()->getUserIdentity();

		/** @var TextContent $content */
		$content = ContentHandler::makeContent(
			"[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam "
			. " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.",
			$title,
			CONTENT_MODEL_WIKITEXT
		);

		$factory = $this->getServiceContainer()->getPageUpdaterFactory();
		$updater = $factory->newPageUpdater( $page, $user );
		$updater->setContent( SlotRecord::MAIN, $content );
		$update = $updater->prepareUpdate();

		/** @var TextContent $pstContent */
		$pstContent = $update->getRawContent( SlotRecord::MAIN );
		$this->assertSame( $content->getText(), $pstContent->getText() );

		$pout = $update->getCanonicalParserOutput();
		$this->assertStringContainsString( 'dolor sit amet', $pout->getRawText() );
	}

}
PK       ! I[,  ,    Storage/NameTableStoreTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Storage;

use MediaWiki\Storage\NameTableAccessException;
use MediaWiki\Storage\NameTableStore;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\NullLogger;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\ObjectCache\EmptyBagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\InsertQueryBuilder;
use Wikimedia\Rdbms\LoadBalancer;
use Wikimedia\Rdbms\SelectQueryBuilder;
use Wikimedia\TestingAccessWrapper;

/**
 * @author Addshore
 * @group Database
 * @covers \MediaWiki\Storage\NameTableStore
 */
class NameTableStoreTest extends MediaWikiIntegrationTestCase {

	private function populateTable( $values ) {
		if ( !$values ) {
			return;
		}
		$insertValues = [];
		foreach ( $values as $name ) {
			$insertValues[] = [ 'role_name' => $name ];
		}
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'slot_roles' )
			->rows( $insertValues )
			->caller( __METHOD__ )
			->execute();
	}

	private function getHashWANObjectCache( $cacheBag ) {
		return new WANObjectCache( [ 'cache' => $cacheBag ] );
	}

	/**
	 * @param IDatabase $db
	 * @return LoadBalancer
	 */
	private function getMockLoadBalancer( $db ) {
		$mock = $this->createMock( LoadBalancer::class );
		$mock->method( 'getConnection' )->willReturn( $db );
		return $mock;
	}

	/**
	 * @param int $insertCalls
	 * @param int $selectCalls
	 * @param int $selectFieldCalls
	 *
	 * @return MockObject&IDatabase
	 */
	private function getProxyDb( $insertCalls, $selectCalls, $selectFieldCalls ) {
		$proxiedMethods = [
			'select' => $selectCalls,
			'insert' => $insertCalls,
			'selectField' => $selectFieldCalls,
			'affectedRows' => null,
			'insertId' => null,
			'getSessionLagStatus' => null,
			'onTransactionPreCommitOrIdle' => null,
			'doAtomicSection' => null,
			'begin' => null,
			'rollback' => null,
			'commit' => null,
		];
		$mock = $this->createMock( IDatabase::class );
		foreach ( $proxiedMethods as $method => $count ) {
			$mock->expects( is_int( $count ) ? $this->exactly( $count ) : $this->any() )
				->method( $method )
				->willReturnCallback( function ( ...$args ) use ( $method ) {
					return $this->getDb()->$method( ...$args );
				} );
		}
		$mock->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $mock ) );
		$mock->method( 'newInsertQueryBuilder' )->willReturnCallback( static fn () => new InsertQueryBuilder( $mock ) );
		return $mock;
	}

	private function getNameTableSqlStore(
		BagOStuff $cacheBag,
		$insertCalls,
		$selectCalls,
		$selectFieldCalls,
		$normalizationCallback = null,
		$insertCallback = null
	) {
		return new NameTableStore(
			$this->getMockLoadBalancer(
				$this->getProxyDb( $insertCalls, $selectCalls, $selectFieldCalls )
			),
			$this->getHashWANObjectCache( $cacheBag ),
			new NullLogger(),
			'slot_roles', 'role_id', 'role_name',
			$normalizationCallback,
			false,
			$insertCallback
		);
	}

	public static function provideGetAndAcquireId() {
		return [
			'no wancache, empty table' =>
				[ new EmptyBagOStuff(), true, 1, [], 'foo', 1 ],
			'no wancache, one matching value' =>
				[ new EmptyBagOStuff(), false, 1, [ 'foo' ], 'foo', 1 ],
			'no wancache, one not matching value' =>
				[ new EmptyBagOStuff(), true, 1, [ 'bar' ], 'foo', 2 ],
			'no wancache, multiple, one matching value' =>
				[ new EmptyBagOStuff(), false, 1, [ 'foo', 'bar' ], 'bar', 2 ],
			'no wancache, multiple, no matching value' =>
				[ new EmptyBagOStuff(), true, 1, [ 'foo', 'bar' ], 'baz', 3 ],
			'wancache, empty table' =>
				[ new HashBagOStuff(), true, 1, [], 'foo', 1 ],
			'wancache, one matching value' =>
				[ new HashBagOStuff(), false, 1, [ 'foo' ], 'foo', 1 ],
			'wancache, one not matching value' =>
				[ new HashBagOStuff(), true, 1, [ 'bar' ], 'foo', 2 ],
			'wancache, multiple, one matching value' =>
				[ new HashBagOStuff(), false, 1, [ 'foo', 'bar' ], 'bar', 2 ],
			'wancache, multiple, no matching value' =>
				[ new HashBagOStuff(), true, 1, [ 'foo', 'bar' ], 'baz', 3 ],
		];
	}

	/**
	 * @dataProvider provideGetAndAcquireId
	 * @param BagOStuff $cacheBag to use in the WANObjectCache service
	 * @param bool $needsInsert Does the value we are testing need to be inserted?
	 * @param int $selectCalls Number of times the select DB method will be called
	 * @param string[] $existingValues to be added to the db table
	 * @param string $name name to acquire
	 * @param int $expectedId the id we expect the name to have
	 */
	public function testGetAndAcquireId(
		$cacheBag,
		$needsInsert,
		$selectCalls,
		$existingValues,
		$name,
		$expectedId
	) {
		$this->populateTable( $existingValues );
		$store = $this->getNameTableSqlStore( $cacheBag, (int)$needsInsert, $selectCalls, 0 );

		// Some names will not initially exist
		try {
			$result = $store->getId( $name );
			$this->assertSame( $expectedId, $result );
		} catch ( NameTableAccessException $e ) {
			if ( $needsInsert ) {
				$this->assertTrue( true ); // Expected exception
			} else {
				$this->fail( 'Did not expect an exception, but got one: ' . $e->getMessage() );
			}
		}

		// All names should return their id here
		$this->assertSame( $expectedId, $store->acquireId( $name ) );

		// acquireId inserted these names, so now everything should exist with getId
		$this->assertSame( $expectedId, $store->getId( $name ) );

		// calling getId again will also still work, and not result in more selects
		$this->assertSame( $expectedId, $store->getId( $name ) );
	}

	public static function provideTestGetAndAcquireIdNameNormalization() {
		yield [ 'A', 'a', 'strtolower' ];
		yield [ 'b', 'B', 'strtoupper' ];
		yield [ 'X', 'X', static fn ( $name ) => $name ];
		yield [ 'ZZ', 'ZZ-a', static fn ( $name ) => "$name-a" ];
	}

	/**
	 * @dataProvider provideTestGetAndAcquireIdNameNormalization
	 */
	public function testGetAndAcquireIdNameNormalization(
		$nameIn,
		$nameOut,
		$normalizationCallback
	) {
		$store = $this->getNameTableSqlStore(
			new EmptyBagOStuff(),
			1,
			1,
			0,
			$normalizationCallback
		);
		$acquiredId = $store->acquireId( $nameIn );
		$this->assertSame( $nameOut, $store->getName( $acquiredId ) );
	}

	public static function provideGetName() {
		return [
			[ new HashBagOStuff(), 3, 2 ],
			[ new EmptyBagOStuff(), 3, 3 ],
		];
	}

	/**
	 * @dataProvider provideGetName
	 */
	public function testGetName( BagOStuff $cacheBag, $insertCalls, $selectCalls ) {
		$now = microtime( true );
		$cacheBag->setMockTime( $now );
		// Check for operations to in-memory cache (IMC) and persistent cache (PC)
		$store = $this->getNameTableSqlStore( $cacheBag, $insertCalls, $selectCalls, 0 );

		// Get 1 ID and make sure getName returns correctly
		$fooId = $store->acquireId( 'foo' ); // regen PC, set IMC, update IMC, tombstone PC
		$now += 0.01;
		$this->assertSame( 'foo', $store->getName( $fooId ) ); // use IMC
		$now += 0.01;

		// Get another ID and make sure getName returns correctly
		$barId = $store->acquireId( 'bar' ); // update IMC, tombstone PC
		$now += 0.01;
		$this->assertSame( 'bar', $store->getName( $barId ) ); // use IMC
		$now += 0.01;

		// Blitz the cache and make sure it still returns
		TestingAccessWrapper::newFromObject( $store )->tableCache = null; // clear IMC
		$this->assertSame( 'foo', $store->getName( $fooId ) ); // regen interim PC, set IMC
		$this->assertSame( 'bar', $store->getName( $barId ) ); // use IMC

		// Blitz the cache again and get another ID and make sure getName returns correctly
		TestingAccessWrapper::newFromObject( $store )->tableCache = null; // clear IMC
		$bazId = $store->acquireId( 'baz' ); // set IMC using interim PC, update IMC, tombstone PC
		$now += 0.01;
		$this->assertSame( 'baz', $store->getName( $bazId ) ); // uses IMC
		$this->assertSame( 'baz', $store->getName( $bazId ) ); // uses IMC
	}

	public function testGetName_masterFallback() {
		$store = $this->getNameTableSqlStore( new EmptyBagOStuff(), 1, 2, 0 );

		// Insert a new name
		$fooId = $store->acquireId( 'foo' );

		// Empty the process cache, getCachedTable() will now return this empty array
		TestingAccessWrapper::newFromObject( $store )->tableCache = [];

		// getName should fallback to master, which is why we assert 2 selectCalls above
		$this->assertSame( 'foo', $store->getName( $fooId ) );
	}

	public function testGetMap_empty() {
		$this->populateTable( [] );
		$store = $this->getNameTableSqlStore( new HashBagOStuff(), 0, 1, 0 );
		$table = $store->getMap();
		$this->assertSame( [], $table );
	}

	public function testGetMap_twoValues() {
		$this->populateTable( [ 'foo', 'bar' ] );
		$store = $this->getNameTableSqlStore( new HashBagOStuff(), 0, 1, 0 );

		// We are using a cache, so 2 calls should only result in 1 select on the db
		$store->getMap();
		$table = $store->getMap();

		$expected = [ 1 => 'foo', 2 => 'bar' ];
		$this->assertSame( $expected, $table );
		// Make sure the table returned is the same as the cached table
		$this->assertSame( $expected, TestingAccessWrapper::newFromObject( $store )->tableCache );
	}

	public function testReloadMap() {
		$this->populateTable( [ 'foo' ] );
		$store = $this->getNameTableSqlStore( new HashBagOStuff(), 0, 2, 0 );

		// force load
		$this->assertCount( 1, $store->getMap() );

		// add more stuff to the table, so the cache gets out of sync
		$this->populateTable( [ 'bar' ] );

		$expected = [ 1 => 'foo', 2 => 'bar' ];
		$this->assertSame( $expected, $store->reloadMap() );
		$this->assertSame( $expected, $store->getMap() );
	}

	public function testCacheRaceCondition() {
		$wanHashBag = new HashBagOStuff();
		$store1 = $this->getNameTableSqlStore( $wanHashBag, 1, 1, 0 );
		$store2 = $this->getNameTableSqlStore( $wanHashBag, 1, 0, 0 );
		$store3 = $this->getNameTableSqlStore( $wanHashBag, 2, 0, 2 );

		// Cache the current table in the instances we will use
		// This simulates multiple requests running simultaneously
		$store1->getMap();
		$store2->getMap();
		$store3->getMap();

		// Store 2 separate names using different instances
		$fooId = $store1->acquireId( 'foo' );
		$barId = $store2->acquireId( 'bar' );

		// Each of these instances should be aware of what they have inserted
		$this->assertSame( $fooId, $store1->acquireId( 'foo' ) );
		$this->assertSame( $barId, $store2->acquireId( 'bar' ) );

		// A new store should be able to get both of these new Ids
		// Note: before there was a race condition here where acquireId( 'bar' ) would update the
		//       cache with data missing the 'foo' key that it was not aware of
		$store4 = $this->getNameTableSqlStore( $wanHashBag, 0, 1, 0 );
		$this->assertSame( $fooId, $store4->getId( 'foo' ) );
		$this->assertSame( $barId, $store4->getId( 'bar' ) );

		// If a store with old cached data tries to acquire these we will get the same ids.
		$this->assertSame( $fooId, $store3->acquireId( 'foo' ) );
		$this->assertSame( $barId, $store3->acquireId( 'bar' ) );
	}

	public function testGetAndAcquireIdInsertCallback() {
		// Postgres does not allow to specify the SERIAL column on insert to fake an id
		$this->markTestSkippedIfDbType( 'postgres' );

		$store = $this->getNameTableSqlStore(
			new EmptyBagOStuff(),
			1,
			1,
			0,
			null,
			static function ( $insertFields ) {
				$insertFields['role_id'] = 7251;
				return $insertFields;
			}
		);
		$this->assertSame( 7251, $store->acquireId( 'A' ) );
	}
}
PK       ! Tb."  "  #  Storage/RevisionSlotsUpdateTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Storage;

use MediaWiki\Content\Content;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Revision\MutableRevisionSlots;
use MediaWiki\Revision\RevisionAccessException;
use MediaWiki\Revision\RevisionSlots;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\RevisionSlotsUpdate;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Storage\RevisionSlotsUpdate
 */
class RevisionSlotsUpdateTest extends MediaWikiIntegrationTestCase {

	public static function provideNewFromRevisionSlots() {
		$slotA = SlotRecord::newUnsaved( 'A', new WikitextContent( 'A' ) );
		$slotB = SlotRecord::newUnsaved( 'B', new WikitextContent( 'B' ) );
		$slotC = SlotRecord::newUnsaved( 'C', new WikitextContent( 'C' ) );

		$slotB2 = SlotRecord::newUnsaved( 'B', new WikitextContent( 'B2' ) );

		$parentSlots = new RevisionSlots( [
			'A' => $slotA,
			'B' => $slotB,
			'C' => $slotC,
		] );

		$newSlots = new RevisionSlots( [
			'A' => $slotA,
			'B' => $slotB2,
		] );

		yield [ $newSlots, null, [ 'A', 'B' ], [] ];
		yield [ $newSlots, $parentSlots, [ 'B' ], [ 'C' ] ];
	}

	/**
	 * @dataProvider provideNewFromRevisionSlots
	 */
	public function testNewFromRevisionSlots(
		RevisionSlots $newSlots,
		?RevisionSlots $parentSlots,
		array $modified,
		array $removed
	) {
		$update = RevisionSlotsUpdate::newFromRevisionSlots( $newSlots, $parentSlots );

		$this->assertEquals( $modified, $update->getModifiedRoles() );
		$this->assertEquals( $removed, $update->getRemovedRoles() );

		foreach ( $modified as $role ) {
			$this->assertSame( $newSlots->getSlot( $role ), $update->getModifiedSlot( $role ) );
		}
	}

	public static function provideNewFromContent() {
		$slotA = SlotRecord::newUnsaved( 'A', new WikitextContent( 'A' ) );
		$slotB = SlotRecord::newUnsaved( 'B', new WikitextContent( 'B' ) );
		$slotC = SlotRecord::newUnsaved( 'C', new WikitextContent( 'C' ) );

		$parentSlots = new RevisionSlots( [
			'A' => $slotA,
			'B' => $slotB,
			'C' => $slotC,
		] );

		$newContent = [
			'A' => new WikitextContent( 'A' ),
			'B' => new WikitextContent( 'B2' ),
		];

		yield [ $newContent, null, [ 'A', 'B' ] ];
		yield [ $newContent, $parentSlots, [ 'B' ] ];
	}

	/**
	 * @dataProvider provideNewFromContent
	 */
	public function testNewFromContent(
		array $newContent,
		?RevisionSlots $parentSlots = null,
		array $modified = []
	) {
		$update = RevisionSlotsUpdate::newFromContent( $newContent, $parentSlots );

		$this->assertEquals( $modified, $update->getModifiedRoles() );
		$this->assertSame( [], $update->getRemovedRoles() );
	}

	public function testConstructor() {
		$update = new RevisionSlotsUpdate();

		$this->assertSame( [], $update->getModifiedRoles() );
		$this->assertSame( [], $update->getRemovedRoles() );

		$slotA = SlotRecord::newUnsaved( 'A', new WikitextContent( 'A' ) );
		$update = new RevisionSlotsUpdate( [ 'A' => $slotA ] );

		$this->assertEquals( [ 'A' ], $update->getModifiedRoles() );
		$this->assertSame( [], $update->getRemovedRoles() );

		$update = new RevisionSlotsUpdate( [ 'A' => $slotA ], [ 'X' ] );

		$this->assertEquals( [ 'A' ], $update->getModifiedRoles() );
		$this->assertEquals( [ 'X' ], $update->getRemovedRoles() );
	}

	public function testModifySlot() {
		$slots = new RevisionSlotsUpdate();

		$this->assertSame( [], $slots->getModifiedRoles() );
		$this->assertSame( [], $slots->getRemovedRoles() );

		$slotA = SlotRecord::newUnsaved( 'some', new WikitextContent( 'A' ) );
		$slots->modifySlot( $slotA );
		$this->assertTrue( $slots->isModifiedSlot( 'some' ) );
		$this->assertFalse( $slots->isRemovedSlot( 'some' ) );
		$this->assertSame( $slotA, $slots->getModifiedSlot( 'some' ) );
		$this->assertSame( [ 'some' ], $slots->getModifiedRoles() );
		$this->assertSame( [], $slots->getRemovedRoles() );

		$slotB = SlotRecord::newUnsaved( 'other', new WikitextContent( 'B' ) );
		$slots->modifySlot( $slotB );
		$this->assertTrue( $slots->isModifiedSlot( 'other' ) );
		$this->assertFalse( $slots->isRemovedSlot( 'other' ) );
		$this->assertSame( $slotB, $slots->getModifiedSlot( 'other' ) );
		$this->assertSame( [ 'some', 'other' ], $slots->getModifiedRoles() );
		$this->assertSame( [], $slots->getRemovedRoles() );

		// modify slot A again
		$slots->modifySlot( $slotA );
		$this->assertArrayEquals( [ 'some', 'other' ], $slots->getModifiedRoles() );

		// remove modified slot
		$slots->removeSlot( 'some' );
		$this->assertSame( [ 'other' ], $slots->getModifiedRoles() );
		$this->assertSame( [ 'some' ], $slots->getRemovedRoles() );

		// modify removed slot
		$slots->modifySlot( $slotA );
		$this->assertArrayEquals( [ 'some', 'other' ], $slots->getModifiedRoles() );
		$this->assertSame( [], $slots->getRemovedRoles() );
	}

	public function testRemoveSlot() {
		$slots = new RevisionSlotsUpdate();

		$slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
		$slots->modifySlot( $slotA );

		$this->assertSame( [ SlotRecord::MAIN ], $slots->getModifiedRoles() );

		$slots->removeSlot( SlotRecord::MAIN );
		$slots->removeSlot( 'other' );
		$this->assertSame( [], $slots->getModifiedRoles() );
		$this->assertSame( [ SlotRecord::MAIN, 'other' ], $slots->getRemovedRoles() );
		$this->assertTrue( $slots->isRemovedSlot( SlotRecord::MAIN ) );
		$this->assertTrue( $slots->isRemovedSlot( 'other' ) );
		$this->assertFalse( $slots->isModifiedSlot( SlotRecord::MAIN ) );

		// removing the same slot again should not trigger an error
		$slots->removeSlot( SlotRecord::MAIN );

		// getting a slot after removing it should fail
		$this->expectException( RevisionAccessException::class );
		$slots->getModifiedSlot( SlotRecord::MAIN );
	}

	public function testGetModifiedRoles() {
		$slots = new RevisionSlotsUpdate( [], [ 'xyz' ] );

		$this->assertSame( [], $slots->getModifiedRoles() );

		$slots->modifyContent( SlotRecord::MAIN, new WikitextContent( 'A' ) );
		$slots->modifyContent( 'foo', new WikitextContent( 'Foo' ) );
		$this->assertSame( [ SlotRecord::MAIN, 'foo' ], $slots->getModifiedRoles() );

		$slots->removeSlot( SlotRecord::MAIN );
		$this->assertSame( [ 'foo' ], $slots->getModifiedRoles() );
	}

	public function testGetRemovedRoles() {
		$slotA = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) );
		$slots = new RevisionSlotsUpdate( [ $slotA ] );

		$this->assertSame( [], $slots->getRemovedRoles() );

		$slots->removeSlot( SlotRecord::MAIN, new WikitextContent( 'A' ) );
		$slots->removeSlot( 'foo', new WikitextContent( 'Foo' ) );

		$this->assertSame( [ SlotRecord::MAIN, 'foo' ], $slots->getRemovedRoles() );

		$slots->modifyContent( SlotRecord::MAIN, new WikitextContent( 'A' ) );
		$this->assertSame( [ 'foo' ], $slots->getRemovedRoles() );
	}

	public static function provideHasSameUpdates() {
		$fooX = SlotRecord::newUnsaved( 'x', new WikitextContent( 'Foo' ) );
		$barZ = SlotRecord::newUnsaved( 'z', new WikitextContent( 'Bar' ) );

		$a = new RevisionSlotsUpdate();
		$a->modifySlot( $fooX );
		$a->modifySlot( $barZ );
		$a->removeSlot( 'Q' );

		$a2 = new RevisionSlotsUpdate();
		$a2->modifySlot( $fooX );
		$a2->modifySlot( $barZ );
		$a2->removeSlot( 'Q' );

		$b = new RevisionSlotsUpdate();
		$b->modifySlot( $barZ );
		$b->removeSlot( 'Q' );

		$c = new RevisionSlotsUpdate();
		$c->modifySlot( $fooX );
		$c->modifySlot( $barZ );

		yield 'same instance' => [ $a, $a, true ];
		yield 'same udpates' => [ $a, $a2, true ];

		yield 'different modified' => [ $a, $b, false ];
		yield 'different removed' => [ $a, $c, false ];
	}

	/**
	 * @dataProvider provideHasSameUpdates
	 */
	public function testHasSameUpdates( RevisionSlotsUpdate $a, RevisionSlotsUpdate $b, $same ) {
		$this->assertSame( $same, $a->hasSameUpdates( $b ) );
		$this->assertSame( $same, $b->hasSameUpdates( $a ) );
	}

	/**
	 * @param string $role
	 * @param Content $content
	 * @return SlotRecord
	 */
	private function newSavedSlot( $role, Content $content ) {
		return SlotRecord::newSaved( 7, 7, 'xyz', SlotRecord::newUnsaved( $role, $content ) );
	}

	public function testApplyUpdate() {
		/** @var SlotRecord[] $parentSlots */
		$parentSlots = [
			'X' => $this->newSavedSlot( 'X', new WikitextContent( 'X' ) ),
			'Y' => $this->newSavedSlot( 'Y', new WikitextContent( 'Y' ) ),
			'Z' => $this->newSavedSlot( 'Z', new WikitextContent( 'Z' ) ),
		];
		$slots = MutableRevisionSlots::newFromParentRevisionSlots( $parentSlots );
		$update = RevisionSlotsUpdate::newFromContent( [
			'A' => new WikitextContent( 'A' ),
			'Y' => new WikitextContent( 'yyy' ),
		] );

		$update->removeSlot( 'Z' );

		$update->apply( $slots );
		$this->assertSame( [ 'X', 'Y', 'A' ], $slots->getSlotRoles() );
		$this->assertSame( $update->getModifiedSlot( 'A' ), $slots->getSlot( 'A' ) );
		$this->assertSame( $update->getModifiedSlot( 'Y' ), $slots->getSlot( 'Y' ) );
	}

}
PK       ! I:dP  P  "  logging/UploadLogFormatterTest.phpnu Iw        <?php

/**
 * @covers \UploadLogFormatter
 */
class UploadLogFormatterTest extends LogFormatterTestCase {

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideUploadLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'upload',
					'action' => 'upload',
					'comment' => 'upload comment',
					'namespace' => NS_FILE,
					'title' => 'File.png',
					'params' => [
						'img_sha1' => 'hash',
						'img_timestamp' => '20150101000000',
					],
				],
				[
					'text' => 'User uploaded File:File.png',
					'api' => [
						'img_sha1' => 'hash',
						'img_timestamp' => '2015-01-01T00:00:00Z',
					],
				],
			],

			// Old format without params
			[
				[
					'type' => 'upload',
					'action' => 'upload',
					'comment' => 'upload comment',
					'namespace' => NS_FILE,
					'title' => 'File.png',
					'params' => [],
				],
				[
					'text' => 'User uploaded File:File.png',
					'api' => [],
				],
			],
		];
	}

	/**
	 * @dataProvider provideUploadLogDatabaseRows
	 */
	public function testUploadLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideOverwriteLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'upload',
					'action' => 'overwrite',
					'comment' => 'upload comment',
					'namespace' => NS_FILE,
					'title' => 'File.png',
					'params' => [
						'img_sha1' => 'hash',
						'img_timestamp' => '20150101000000',
					],
				],
				[
					'text' => 'User uploaded a new version of File:File.png',
					'api' => [
						'img_sha1' => 'hash',
						'img_timestamp' => '2015-01-01T00:00:00Z',
					],
				],
			],

			// Old format without params
			[
				[
					'type' => 'upload',
					'action' => 'overwrite',
					'comment' => 'upload comment',
					'namespace' => NS_FILE,
					'title' => 'File.png',
					'params' => [],
				],
				[
					'text' => 'User uploaded a new version of File:File.png',
					'api' => [],
				],
			],
		];
	}

	/**
	 * @dataProvider provideOverwriteLogDatabaseRows
	 */
	public function testOverwriteLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideRevertLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'upload',
					'action' => 'revert',
					'comment' => 'upload comment',
					'namespace' => NS_FILE,
					'title' => 'File.png',
					'params' => [
						'img_sha1' => 'hash',
						'img_timestamp' => '20150101000000',
					],
				],
				[
					'text' => 'User reverted File:File.png to an old version',
					'api' => [
						'img_sha1' => 'hash',
						'img_timestamp' => '2015-01-01T00:00:00Z',
					],
				],
			],

			// Old format without params
			[
				[
					'type' => 'upload',
					'action' => 'revert',
					'comment' => 'upload comment',
					'namespace' => NS_FILE,
					'title' => 'File.png',
					'params' => [],
				],
				[
					'text' => 'User reverted File:File.png to an old version',
					'api' => [],
				],
			],
		];
	}

	/**
	 * @dataProvider provideRevertLogDatabaseRows
	 */
	public function testRevertLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}
}
PK       ! V&2NO  NO  "  logging/DeleteLogFormatterTest.phpnu Iw        <?php

/**
 * @covers \DeleteLogFormatter
 */
class DeleteLogFormatterTest extends LogFormatterTestCase {

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideDeleteLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'delete',
					'action' => 'delete',
					'comment' => 'delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [],
				],
				[
					'text' => 'User deleted page Page',
					'api' => [],
				],
			],

			// Legacy format
			[
				[
					'type' => 'delete',
					'action' => 'delete',
					'comment' => 'delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [],
				],
				[
					'legacy' => true,
					'text' => 'User deleted page Page',
					'api' => [],
				],
			],
		];
	}

	/**
	 * @dataProvider provideDeleteLogDatabaseRows
	 */
	public function testDeleteLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideRestoreLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'delete',
					'action' => 'restore',
					'comment' => 'delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						':assoc:count' => [
							'revisions' => 2,
							'files' => 1,
						],
					],
				],
				[
					'text' => 'User undeleted page Page (2 revisions and 1 file)',
					'api' => [
						'count' => [
							'revisions' => 2,
							'files' => 1,
						],
					],
				],
			],

			// Legacy format without counts
			[
				[
					'type' => 'delete',
					'action' => 'restore',
					'comment' => 'delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [],
				],
				[
					'text' => 'User undeleted page Page',
					'api' => [],
				],
			],

			// Legacy format
			[
				[
					'type' => 'delete',
					'action' => 'restore',
					'comment' => 'delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [],
				],
				[
					'legacy' => true,
					'text' => 'User undeleted page Page',
					'api' => [],
				],
			],
		];
	}

	/**
	 * @dataProvider provideRestoreLogDatabaseRows
	 */
	public function testRestoreLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideRevisionLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'delete',
					'action' => 'revision',
					'comment' => 'delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'4::type' => 'archive',
						'5::ids' => [ '1', '3', '4' ],
						'6::ofield' => '1',
						'7::nfield' => '2',
					],
				],
				[
					'text' => 'User changed visibility of 3 revisions on page Page: edit summary '
						. 'hidden and content unhidden',
					'api' => [
						'type' => 'archive',
						'ids' => [ '1', '3', '4' ],
						'old' => [
							'bitmask' => 1,
							'content' => true,
							'comment' => false,
							'user' => false,
							'restricted' => false,
						],
						'new' => [
							'bitmask' => 2,
							'content' => false,
							'comment' => true,
							'user' => false,
							'restricted' => false,
						],
					],
				],
			],

			// Legacy format
			[
				[
					'type' => 'delete',
					'action' => 'revision',
					'comment' => 'delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'archive',
						'1,3,4',
						'ofield=1',
						'nfield=2',
					],
				],
				[
					'legacy' => true,
					'text' => 'User changed visibility of 3 revisions on page Page: edit summary '
						. 'hidden and content unhidden',
					'api' => [
						'type' => 'archive',
						'ids' => [ '1', '3', '4' ],
						'old' => [
							'bitmask' => 1,
							'content' => true,
							'comment' => false,
							'user' => false,
							'restricted' => false,
						],
						'new' => [
							'bitmask' => 2,
							'content' => false,
							'comment' => true,
							'user' => false,
							'restricted' => false,
						],
					],
				],
			],
			// Legacy format pre-T20361, the changes part of the comment
			[
				[
					'type' => 'delete',
					'action' => 'revision',
					'comment' => 'edit summary hidden and content unhidden: delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'archive',
						'1,3,4',
					],
				],
				[
					'legacy' => true,
					'text' => 'User changed visibility of revisions on page Page',
					'api' => [
						'type' => 'archive',
						'ids' => [ '1', '3', '4' ],
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideRevisionLogDatabaseRows
	 */
	public function testRevisionLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideEventLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'delete',
					'action' => 'event',
					'comment' => 'delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'4::ids' => [ '1', '3', '4' ],
						'5::ofield' => '1',
						'6::nfield' => '2',
					],
				],
				[
					'text' => 'User changed visibility of 3 log events on Page: edit summary hidden '
						. 'and content unhidden',
					'api' => [
						'type' => 'logging',
						'ids' => [ '1', '3', '4' ],
						'old' => [
							'bitmask' => 1,
							'content' => true,
							'comment' => false,
							'user' => false,
							'restricted' => false,
						],
						'new' => [
							'bitmask' => 2,
							'content' => false,
							'comment' => true,
							'user' => false,
							'restricted' => false,
						],
					],
				],
			],

			// Legacy format
			[
				[
					'type' => 'delete',
					'action' => 'event',
					'comment' => 'delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'1,3,4',
						'ofield=1',
						'nfield=2',
					],
				],
				[
					'legacy' => true,
					'text' => 'User changed visibility of 3 log events on Page: edit summary hidden '
						. 'and content unhidden',
					'api' => [
						'type' => 'logging',
						'ids' => [ '1', '3', '4' ],
						'old' => [
							'bitmask' => 1,
							'content' => true,
							'comment' => false,
							'user' => false,
							'restricted' => false,
						],
						'new' => [
							'bitmask' => 2,
							'content' => false,
							'comment' => true,
							'user' => false,
							'restricted' => false,
						],
					],
				],
			],

			// Legacy format pre-T20361, the changes part of the comment
			[
				[
					'type' => 'delete',
					'action' => 'event',
					'comment' => 'edit summary hidden and content unhidden: delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'1,3,4',
					],
				],
				[
					'legacy' => true,
					'text' => 'User changed visibility of log events on Page',
					'api' => [
						'type' => 'logging',
						'ids' => [ '1', '3', '4' ],
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideEventLogDatabaseRows
	 */
	public function testEventLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideSuppressRevisionLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'suppress',
					'action' => 'revision',
					'comment' => 'Suppress comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'4::type' => 'archive',
						'5::ids' => [ '1', '3', '4' ],
						'6::ofield' => '1',
						'7::nfield' => '10',
					],
				],
				[
					'text' => 'User secretly changed visibility of 3 revisions on page Page: edit '
						. 'summary hidden, content unhidden and applied restrictions to administrators',
					'api' => [
						'type' => 'archive',
						'ids' => [ '1', '3', '4' ],
						'old' => [
							'bitmask' => 1,
							'content' => true,
							'comment' => false,
							'user' => false,
							'restricted' => false,
						],
						'new' => [
							'bitmask' => 10,
							'content' => false,
							'comment' => true,
							'user' => false,
							'restricted' => true,
						],
					],
				],
			],

			// Legacy format
			[
				[
					'type' => 'suppress',
					'action' => 'revision',
					'comment' => 'Suppress comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'archive',
						'1,3,4',
						'ofield=1',
						'nfield=10',
					],
				],
				[
					'legacy' => true,
					'text' => 'User secretly changed visibility of 3 revisions on page Page: edit '
						. 'summary hidden, content unhidden and applied restrictions to administrators',
					'api' => [
						'type' => 'archive',
						'ids' => [ '1', '3', '4' ],
						'old' => [
							'bitmask' => 1,
							'content' => true,
							'comment' => false,
							'user' => false,
							'restricted' => false,
						],
						'new' => [
							'bitmask' => 10,
							'content' => false,
							'comment' => true,
							'user' => false,
							'restricted' => true,
						],
					],
				],
			],

			// Legacy format pre-T20361, the changes part of the comment
			[
				[
					'type' => 'suppress',
					'action' => 'revision',
					'comment' => 'edit summary hidden, content unhidden and applied restrictions to administrators: '
						. 'Suppress comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'archive',
						'1,3,4',
					],
				],
				[
					'legacy' => true,
					'text' => 'User secretly changed visibility of revisions on page Page',
					'api' => [
						'type' => 'archive',
						'ids' => [ '1', '3', '4' ],
					],
				],
			],
		];
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideSuppressRevisionLogDatabaseRowsNonPrivileged() {
		return [
			// Current format
			[
				[
					'type' => 'suppress',
					'action' => 'revision',
					'comment' => 'Suppress comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'4::type' => 'archive',
						'5::ids' => [ '1', '3', '4' ],
						'6::ofield' => '1',
						'7::nfield' => '10',
					],
				],
				[
					'text' => '(username removed) (log details removed)',
					'api' => [
						'type' => 'archive',
						'ids' => [ '1', '3', '4' ],
						'old' => [
							'bitmask' => 1,
							'content' => true,
							'comment' => false,
							'user' => false,
							'restricted' => false,
						],
						'new' => [
							'bitmask' => 10,
							'content' => false,
							'comment' => true,
							'user' => false,
							'restricted' => true,
						],
					],
				],
			],

			// Legacy format
			[
				[
					'type' => 'suppress',
					'action' => 'revision',
					'comment' => 'Suppress comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'archive',
						'1,3,4',
						'ofield=1',
						'nfield=10',
					],
				],
				[
					'legacy' => true,
					'text' => '(username removed) (log details removed)',
					'api' => [
						'type' => 'archive',
						'ids' => [ '1', '3', '4' ],
						'old' => [
							'bitmask' => 1,
							'content' => true,
							'comment' => false,
							'user' => false,
							'restricted' => false,
						],
						'new' => [
							'bitmask' => 10,
							'content' => false,
							'comment' => true,
							'user' => false,
							'restricted' => true,
						],
					],
				],
			],

			// Legacy format pre-T20361, the changes part of the comment
			[
				[
					'type' => 'suppress',
					'action' => 'revision',
					'comment' => 'Suppress comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'archive',
						'1,3,4',
					],
				],
				[
					'legacy' => true,
					'text' => '(username removed) (log details removed)',
					'api' => [
						'type' => 'archive',
						'ids' => [ '1', '3', '4' ],
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideSuppressRevisionLogDatabaseRowsNonPrivileged
	 */
	public function testSuppressRevisionLogDatabaseRowsNonPrivileged( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideSuppressEventLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'suppress',
					'action' => 'event',
					'comment' => 'Suppress comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'4::ids' => [ '1', '3', '4' ],
						'5::ofield' => '1',
						'6::nfield' => '10',
					],
				],
				[
					'text' => 'User secretly changed visibility of 3 log events on Page: edit '
						. 'summary hidden, content unhidden and applied restrictions to administrators',
					'api' => [
						'type' => 'logging',
						'ids' => [ '1', '3', '4' ],
						'old' => [
							'bitmask' => 1,
							'content' => true,
							'comment' => false,
							'user' => false,
							'restricted' => false,
						],
						'new' => [
							'bitmask' => 10,
							'content' => false,
							'comment' => true,
							'user' => false,
							'restricted' => true,
						],
					],
				],
			],

			// Legacy formats
			[
				[
					'type' => 'suppress',
					'action' => 'event',
					'comment' => 'Suppress comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'1,3,4',
						'ofield=1',
						'nfield=10',
					],
				],
				[
					'legacy' => true,
					'text' => 'User secretly changed visibility of 3 log events on Page: edit '
						. 'summary hidden, content unhidden and applied restrictions to administrators',
					'api' => [
						'type' => 'logging',
						'ids' => [ '1', '3', '4' ],
						'old' => [
							'bitmask' => 1,
							'content' => true,
							'comment' => false,
							'user' => false,
							'restricted' => false,
						],
						'new' => [
							'bitmask' => 10,
							'content' => false,
							'comment' => true,
							'user' => false,
							'restricted' => true,
						],
					],
				],
			],

			// Legacy format pre-T20361, the changes part of the comment
			[
				[
					'type' => 'delete',
					'action' => 'revision',
					'comment' => 'Old rows might lack ofield/nfield (T224815)',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'oldid',
						'1234',
					],
				],
				[
					'legacy' => true,
					'text' => 'User changed visibility of revisions on page Page',
					'api' => [
						'type' => 'oldid',
						'ids' => [ '1234' ],
					],
				],
			]
		];
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideSuppressEventLogDatabaseRowsNonPrivileged() {
		return [
			// Current format
			[
				[
					'type' => 'suppress',
					'action' => 'event',
					'comment' => 'Suppress comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'4::ids' => [ '1', '3', '4' ],
						'5::ofield' => '1',
						'6::nfield' => '10',
					],
				],
				[
					'text' => '(username removed) (log details removed)',
					'api' => [
						'type' => 'logging',
						'ids' => [ '1', '3', '4' ],
						'old' => [
							'bitmask' => 1,
							'content' => true,
							'comment' => false,
							'user' => false,
							'restricted' => false,
						],
						'new' => [
							'bitmask' => 10,
							'content' => false,
							'comment' => true,
							'user' => false,
							'restricted' => true,
						],
					],
				],
			],

			// Legacy format
			[
				[
					'type' => 'suppress',
					'action' => 'event',
					'comment' => 'Suppress comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'1,3,4',
						'ofield=1',
						'nfield=10',
					],
				],
				[
					'legacy' => true,
					'text' => '(username removed) (log details removed)',
					'api' => [
						'type' => 'logging',
						'ids' => [ '1', '3', '4' ],
						'old' => [
							'bitmask' => 1,
							'content' => true,
							'comment' => false,
							'user' => false,
							'restricted' => false,
						],
						'new' => [
							'bitmask' => 10,
							'content' => false,
							'comment' => true,
							'user' => false,
							'restricted' => true,
						],
					],
				],
			],

			// Legacy format pre-T20361, the changes part of the comment
			[
				[
					'type' => 'suppress',
					'action' => 'event',
					'comment' => 'Suppress comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'1,3,4',
					],
				],
				[
					'legacy' => true,
					'text' => '(username removed) (log details removed)',
					'api' => [
						'type' => 'logging',
						'ids' => [ '1', '3', '4' ],
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideSuppressEventLogDatabaseRowsNonPrivileged
	 */
	public function testSuppressEventLogDatabaseRowsNonPrivileged( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideSuppressDeleteLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'suppress',
					'action' => 'delete',
					'comment' => 'delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [],
				],
				[
					'text' => 'User suppressed page Page',
					'api' => [],
				],
			],

			// Legacy format
			[
				[
					'type' => 'suppress',
					'action' => 'delete',
					'comment' => 'delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [],
				],
				[
					'legacy' => true,
					'text' => 'User suppressed page Page',
					'api' => [],
				],
			],
		];
	}

	/**
	 * @dataProvider provideSuppressRevisionLogDatabaseRows
	 * @dataProvider provideSuppressEventLogDatabaseRows
	 * @dataProvider provideSuppressDeleteLogDatabaseRows
	 */
	public function testSuppressLogDatabaseRows( $row, $extra ) {
		$this->setGroupPermissions(
			[
				'oversight' => [
					'viewsuppressed' => true,
					'suppressionlog' => true,
				],
			]
		);
		$this->doTestLogFormatter( $row, $extra, [ 'oversight' ] );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideSuppressDeleteLogDatabaseRowsNonPrivileged() {
		return [
			// Current format
			[
				[
					'type' => 'suppress',
					'action' => 'delete',
					'comment' => 'delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [],
				],
				[
					'text' => '(username removed) (log details removed)',
					'api' => [],
				],
			],

			// Legacy format
			[
				[
					'type' => 'suppress',
					'action' => 'delete',
					'comment' => 'delete comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [],
				],
				[
					'legacy' => true,
					'text' => '(username removed) (log details removed)',
					'api' => [],
				],
			],
		];
	}

	/**
	 * @dataProvider provideSuppressDeleteLogDatabaseRowsNonPrivileged
	 */
	public function testSuppressDeleteLogDatabaseRowsNonPrivileged( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}
}
PK       ! ="    "  logging/RightsLogFormatterTest.phpnu Iw        <?php

use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\LBFactory;

/**
 * @covers \RightsLogFormatter
 */
class RightsLogFormatterTest extends LogFormatterTestCase {

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

		$db = $this->createNoOpMock( IDatabase::class, [ 'getInfinity' ] );
		$db->method( 'getInfinity' )->willReturn( 'infinity' );
		$lbFactory = $this->createMock( LBFactory::class );
		$lbFactory->method( 'getReplicaDatabase' )->willReturn( $db );
		$this->setService( 'DBLoadBalancerFactory', $lbFactory );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideRightsLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'rights',
					'action' => 'rights',
					'comment' => 'rights comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'User',
					'params' => [
						'4::oldgroups' => [],
						'5::newgroups' => [ 'sysop', 'bureaucrat' ],
						'oldmetadata' => [],
						'newmetadata' => [
							[ 'expiry' => null ],
							[ 'expiry' => '20160101123456' ]
						],
					],
				],
				[
					'text' => 'Sysop changed group membership for User from (none) to '
						. 'bureaucrat (temporary, until 12:34, 1 January 2016) and administrator',
					'api' => [
						'oldgroups' => [],
						'newgroups' => [ 'sysop', 'bureaucrat' ],
						'oldmetadata' => [],
						'newmetadata' => [
							[ 'group' => 'sysop', 'expiry' => 'infinity' ],
							[ 'group' => 'bureaucrat', 'expiry' => '2016-01-01T12:34:56Z' ],
						],
					],
				],
			],

			// Previous format (oldgroups and newgroups as arrays, no metadata)
			[
				[
					'type' => 'rights',
					'action' => 'rights',
					'comment' => 'rights comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'User',
					'params' => [
						'4::oldgroups' => [],
						'5::newgroups' => [ 'sysop', 'bureaucrat' ],
					],
				],
				[
					'text' => 'Sysop changed group membership for User from (none) to '
						. 'administrator and bureaucrat',
					'api' => [
						'oldgroups' => [],
						'newgroups' => [ 'sysop', 'bureaucrat' ],
						'oldmetadata' => [],
						'newmetadata' => [
							[ 'group' => 'sysop', 'expiry' => 'infinity' ],
							[ 'group' => 'bureaucrat', 'expiry' => 'infinity' ],
						],
					],
				],
			],

			// Legacy format (oldgroups and newgroups as numeric-keyed strings)
			[
				[
					'type' => 'rights',
					'action' => 'rights',
					'comment' => 'rights comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'User',
					'params' => [
						'',
						'sysop, bureaucrat',
					],
				],
				[
					'legacy' => true,
					'text' => 'Sysop changed group membership for User from (none) to '
						. 'administrator and bureaucrat',
					'api' => [
						'oldgroups' => [],
						'newgroups' => [ 'sysop', 'bureaucrat' ],
						'oldmetadata' => [],
						'newmetadata' => [
							[ 'group' => 'sysop', 'expiry' => 'infinity' ],
							[ 'group' => 'bureaucrat', 'expiry' => 'infinity' ],
						],
					],
				],
			],

			// Really old entry
			[
				[
					'type' => 'rights',
					'action' => 'rights',
					'comment' => 'rights comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'User',
					'params' => [],
				],
				[
					'legacy' => true,
					'text' => 'Sysop changed group membership for User',
					'api' => [],
				],
			],
		];
	}

	/**
	 * @dataProvider provideRightsLogDatabaseRows
	 */
	public function testRightsLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideAutopromoteLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'rights',
					'action' => 'autopromote',
					'comment' => 'rights comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Sysop',
					'params' => [
						'4::oldgroups' => [ 'sysop' ],
						'5::newgroups' => [ 'sysop', 'bureaucrat' ],
					],
				],
				[
					'text' => 'Sysop was automatically promoted from administrator to '
						. 'administrator and bureaucrat',
					'api' => [
						'oldgroups' => [ 'sysop' ],
						'newgroups' => [ 'sysop', 'bureaucrat' ],
						'oldmetadata' => [
							[ 'group' => 'sysop', 'expiry' => 'infinity' ],
						],
						'newmetadata' => [
							[ 'group' => 'sysop', 'expiry' => 'infinity' ],
							[ 'group' => 'bureaucrat', 'expiry' => 'infinity' ],
						],
					],
				],
			],

			// Legacy format
			[
				[
					'type' => 'rights',
					'action' => 'autopromote',
					'comment' => 'rights comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Sysop',
					'params' => [
						'sysop',
						'sysop, bureaucrat',
					],
				],
				[
					'legacy' => true,
					'text' => 'Sysop was automatically promoted from administrator to '
						. 'administrator and bureaucrat',
					'api' => [
						'oldgroups' => [ 'sysop' ],
						'newgroups' => [ 'sysop', 'bureaucrat' ],
						'oldmetadata' => [
							[ 'group' => 'sysop', 'expiry' => 'infinity' ],
						],
						'newmetadata' => [
							[ 'group' => 'sysop', 'expiry' => 'infinity' ],
							[ 'group' => 'bureaucrat', 'expiry' => 'infinity' ],
						],
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideAutopromoteLogDatabaseRows
	 */
	public function testAutopromoteLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}
}
PK       ! ɞX  X    logging/LogFormatterTest.phpnu Iw        <?php

use MediaWiki\Api\ApiResult;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\Linker\Linker;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Permissions\SimpleAuthority;
use MediaWiki\RCFeed\IRCColourfulRCFeedFormatter;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentityValue;

/**
 * @group Database
 */
class LogFormatterTest extends MediaWikiLangTestCase {
	/** @var array */
	private static $oldExtMsgFiles;

	/**
	 * @var User
	 */
	protected $user;

	/**
	 * @var Title
	 */
	protected $title;

	/**
	 * @var RequestContext
	 */
	protected $context;

	/**
	 * @var Title
	 */
	protected $target;

	/**
	 * @var string
	 */
	protected $user_comment;

	public static function setUpBeforeClass(): void {
		parent::setUpBeforeClass();

		global $wgExtensionMessagesFiles;
		self::$oldExtMsgFiles = $wgExtensionMessagesFiles;
		$wgExtensionMessagesFiles['LogTests'] = __DIR__ . '/LogTests.i18n.php';
	}

	public static function tearDownAfterClass(): void {
		global $wgExtensionMessagesFiles;
		$wgExtensionMessagesFiles = self::$oldExtMsgFiles;

		parent::tearDownAfterClass();
	}

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

		$this->overrideConfigValues( [
			MainConfigNames::LogTypes => [ 'phpunit' ],
			MainConfigNames::LogActionsHandlers => [ 'phpunit/test' => LogFormatter::class,
				'phpunit/param' => LogFormatter::class ],
		] );

		$this->user = User::newFromName( 'Testuser' );
		$this->title = Title::makeTitle( NS_MAIN, 'SomeTitle' );
		$this->target = Title::makeTitle( NS_MAIN, 'TestTarget' );

		$this->context = new RequestContext();
		$this->context->setUser( $this->user );
		$this->context->setTitle( $this->title );
		$this->context->setLanguage( RequestContext::getMain()->getLanguage() );

		$this->user_comment = '<User comment about action>';
	}

	public function newLogEntry( $action, $params ) {
		$logEntry = new ManualLogEntry( 'phpunit', $action );
		$logEntry->setPerformer( $this->user );
		$logEntry->setTarget( $this->title );
		$logEntry->setComment( 'A very good reason' );

		$logEntry->setParameters( $params );

		return $logEntry;
	}

	/**
	 * @covers \LogFormatter::setShowUserToolLinks
	 */
	public function testNormalLogParams() {
		$entry = $this->newLogEntry( 'test', [] );
		$formatter = $this->getServiceContainer()->getLogFormatterFactory()->newFromEntry( $entry );
		$formatter->setContext( $this->context );

		$formatter->setShowUserToolLinks( false );
		$paramsWithoutTools = $formatter->getMessageParametersForTesting();

		$formatter2 = $this->getServiceContainer()->getLogFormatterFactory()->newFromEntry( $entry );
		$formatter2->setContext( $this->context );
		$formatter2->setShowUserToolLinks( true );
		$paramsWithTools = $formatter2->getMessageParametersForTesting();

		$userLink = Linker::userLink(
			$this->user->getId(),
			$this->user->getName()
		);

		$userTools = Linker::userToolLinksRedContribs(
			$this->user->getId(),
			$this->user->getName(),
			$this->user->getEditCount(),
			false
		);

		$titleLink = Linker::link( $this->title, null, [], [] );

		// $paramsWithoutTools and $paramsWithTools should be only different
		// in index 0
		$this->assertEquals( $paramsWithoutTools[1], $paramsWithTools[1] );
		$this->assertEquals( $paramsWithoutTools[2], $paramsWithTools[2] );

		$this->assertEquals( Message::rawParam( $userLink ), $paramsWithoutTools[0] );
		$this->assertEquals( Message::rawParam( $userLink . $userTools ), $paramsWithTools[0] );

		$this->assertEquals( $this->user->getName(), $paramsWithoutTools[1] );

		$this->assertEquals( Message::rawParam( $titleLink ), $paramsWithoutTools[2] );
	}

	/**
	 * @covers \LogFormatter::getActionText
	 */
	public function testLogParamsTypeRaw() {
		$params = [ '4:raw:raw' => Linker::link( $this->title, null, [], [] ) ];
		$expected = Linker::link( $this->title, null, [], [] );

		$entry = $this->newLogEntry( 'param', $params );
		$formatter = $this->getServiceContainer()->getLogFormatterFactory()->newFromEntry( $entry );
		$formatter->setContext( $this->context );

		$logParam = $formatter->getActionText();

		$this->assertEquals( $expected, $logParam );
	}

	/**
	 * @covers \LogFormatter::getActionText
	 */
	public function testLogParamsTypeMsg() {
		$params = [ '4:msg:msg' => 'log-description-phpunit' ];
		$expected = wfMessage( 'log-description-phpunit' )->text();

		$entry = $this->newLogEntry( 'param', $params );
		$formatter = $this->getServiceContainer()->getLogFormatterFactory()->newFromEntry( $entry );
		$formatter->setContext( $this->context );

		$logParam = $formatter->getActionText();

		$this->assertEquals( $expected, $logParam );
	}

	/**
	 * @covers \LogFormatter::getActionText
	 */
	public function testLogParamsTypeMsgContent() {
		$params = [ '4:msg-content:msgContent' => 'log-description-phpunit' ];
		$expected = wfMessage( 'log-description-phpunit' )->inContentLanguage()->text();

		$entry = $this->newLogEntry( 'param', $params );
		$formatter = $this->getServiceContainer()->getLogFormatterFactory()->newFromEntry( $entry );
		$formatter->setContext( $this->context );

		$logParam = $formatter->getActionText();

		$this->assertEquals( $expected, $logParam );
	}

	/**
	 * @covers \LogFormatter::getActionText
	 */
	public function testLogParamsTypeNumber() {
		global $wgLang;

		$params = [ '4:number:number' => 123456789 ];
		$expected = $wgLang->formatNum( 123456789 );

		$entry = $this->newLogEntry( 'param', $params );
		$formatter = $this->getServiceContainer()->getLogFormatterFactory()->newFromEntry( $entry );
		$formatter->setContext( $this->context );

		$logParam = $formatter->getActionText();

		$this->assertEquals( $expected, $logParam );
	}

	/**
	 * @covers \LogFormatter::getActionText
	 */
	public function testLogParamsTypeUserLink() {
		$params = [ '4:user-link:userLink' => $this->user->getName() ];
		$expected = Linker::userLink(
			$this->user->getId(),
			$this->user->getName()
		);

		$entry = $this->newLogEntry( 'param', $params );
		$formatter = $this->getServiceContainer()->getLogFormatterFactory()->newFromEntry( $entry );
		$formatter->setContext( $this->context );

		$logParam = $formatter->getActionText();

		$this->assertEquals( $expected, $logParam );
	}

	/**
	 * @covers \LogFormatter::getActionText
	 */
	public function testLogParamsTypeUserLink_empty() {
		$params = [ '4:user-link:userLink' => ':' ];

		$entry = $this->newLogEntry( 'param', $params );
		$formatter = $this->getServiceContainer()->getLogFormatterFactory()->newFromEntry( $entry );

		$this->context->setLanguage( 'qqx' );
		$formatter->setContext( $this->context );

		$logParam = $formatter->getActionText();
		$this->assertStringContainsString( '(empty-username)', $logParam );
	}

	/**
	 * @covers \LogFormatter::getActionText
	 */
	public function testLogParamsTypeTitleLink() {
		$params = [ '4:title-link:titleLink' => $this->title->getText() ];
		$expected = Linker::link( $this->title, null, [], [] );

		$entry = $this->newLogEntry( 'param', $params );
		$formatter = $this->getServiceContainer()->getLogFormatterFactory()->newFromEntry( $entry );
		$formatter->setContext( $this->context );

		$logParam = $formatter->getActionText();

		$this->assertEquals( $expected, $logParam );
	}

	/**
	 * @covers \LogFormatter::getActionText
	 */
	public function testLogParamsTypePlain() {
		$params = [ '4:plain:plain' => 'Some plain text' ];
		$expected = 'Some plain text';

		$entry = $this->newLogEntry( 'param', $params );
		$formatter = $this->getServiceContainer()->getLogFormatterFactory()->newFromEntry( $entry );
		$formatter->setContext( $this->context );

		$logParam = $formatter->getActionText();

		$this->assertEquals( $expected, $logParam );
	}

	/**
	 * @covers \LogFormatter::getPerformerElement
	 * @dataProvider provideLogElement
	 */
	public function testGetPerformerElement( $deletedFlag, $allowedAction ) {
		$entry = $this->newLogEntry( 'param', [] );
		$entry->setPerformer( new UserIdentityValue( 1328435, 'Test' ) );
		if ( $deletedFlag !== 'none' ) {
			$entry->setDeleted(
				LogPage::DELETED_USER |
					( $deletedFlag === 'suppressed' ? LogPage::DELETED_RESTRICTED : 0 )
			);
		}

		$context = new DerivativeContext( $this->context );
		if ( $allowedAction !== 'none' ) {
			$context->setAuthority( new SimpleAuthority(
				$this->context->getUser(),
				[ $deletedFlag === 'suppressed' ? 'suppressrevision' : 'deletedhistory' ]
			) );
		}

		$formatter = $this->getServiceContainer()->getLogFormatterFactory()->newFromEntry( $entry );
		$formatter->setContext( $context );
		if ( $allowedAction === 'view-for-user' ) {
			$formatter->setAudience( LogFormatter::FOR_THIS_USER );
		}

		$element = $formatter->getPerformerElement();
		if ( $allowedAction === 'none' ||
			( $deletedFlag !== 'none' && $allowedAction === 'view-public' )
		) {
			$this->assertStringNotContainsString( 'User:Test', $element );
		} else {
			$this->assertStringContainsString( 'User:Test', $element );
		}

		if ( $deletedFlag === 'none' ) {
			$this->assertStringNotContainsString( 'history-deleted', $element );
		} else {
			$this->assertStringContainsString( 'history-deleted', $element );
		}
		if ( $deletedFlag === 'suppressed' ) {
			$this->assertStringContainsString( 'mw-history-suppressed', $element );
		} else {
			$this->assertStringNotContainsString( 'mw-history-suppressed', $element );
		}
	}

	/**
	 * @covers \LogFormatter::getComment
	 * @dataProvider provideLogElement
	 */
	public function testLogComment( $deletedFlag, $allowedAction ) {
		$entry = $this->newLogEntry( 'test', [] );
		if ( $deletedFlag !== 'none' ) {
			$entry->setDeleted(
				LogPage::DELETED_COMMENT |
					( $deletedFlag === 'suppressed' ? LogPage::DELETED_RESTRICTED : 0 )
			);
		}

		$context = new DerivativeContext( $this->context );
		if ( $allowedAction !== 'none' ) {
			$context->setAuthority( new SimpleAuthority(
				$this->context->getUser(),
				[ $deletedFlag === 'suppressed' ? 'suppressrevision' : 'deletedhistory' ]
			) );
		}

		$formatter = $this->getServiceContainer()->getLogFormatterFactory()->newFromEntry( $entry );
		$formatter->setContext( $context );
		if ( $allowedAction === 'view-for-user' ) {
			$formatter->setAudience( LogFormatter::FOR_THIS_USER );
		}

		$expectedComment = ltrim( $this->getServiceContainer()->getCommentFormatter()->formatBlock( $entry->getComment() ) );
		$comment = $formatter->getComment();

		if ( $allowedAction === 'none' ||
			( $deletedFlag !== 'none' && $allowedAction === 'view-public' )
		) {
			$this->assertStringNotContainsString( $expectedComment, $comment );
		} else {
			$this->assertStringContainsString( $expectedComment, $comment );
		}
		if ( $deletedFlag === 'none' ) {
			$this->assertStringNotContainsString( 'history-deleted', $comment );
		} else {
			$this->assertStringContainsString( 'history-deleted', $comment );
		}
		if ( $deletedFlag === 'suppressed' ) {
			$this->assertStringContainsString( 'mw-history-suppressed', $comment );
		} else {
			$this->assertStringNotContainsString( 'mw-history-suppressed', $comment );
		}
	}

	public static function provideLogElement() {
		return [
			[ 'none', 'view' ],
			[ 'deleted', 'none' ],
			[ 'deleted', 'view-for-user' ],
			[ 'deleted', 'view-public' ],
			[ 'suppressed', 'none' ],
			[ 'suppressed', 'view-for-user' ],
			[ 'suppressed', 'view-public' ],
		];
	}

	/**
	 * @dataProvider provideApiParamFormatting
	 * @covers \LogFormatter::formatParametersForApi
	 * @covers \LogFormatter::formatParameterValueForApi
	 */
	public function testApiParamFormatting( $key, $value, $expected ) {
		$entry = $this->newLogEntry( 'param', [ $key => $value ] );
		$formatter = $this->getServiceContainer()->getLogFormatterFactory()->newFromEntry( $entry );
		$formatter->setContext( $this->context );

		ApiResult::setIndexedTagName( $expected, 'param' );
		ApiResult::setArrayType( $expected, 'assoc' );

		$this->assertEquals( $expected, $formatter->formatParametersForApi() );
	}

	public static function provideApiParamFormatting() {
		return [
			[ 0, 'value', [ 'value' ] ],
			[ 'named', 'value', [ 'named' => 'value' ] ],
			[ '::key', 'value', [ 'key' => 'value' ] ],
			[ '4::key', 'value', [ 'key' => 'value' ] ],
			[ '4:raw:key', 'value', [ 'key' => 'value' ] ],
			[ '4:plain:key', 'value', [ 'key' => 'value' ] ],
			[ '4:bool:key', '1', [ 'key' => true ] ],
			[ '4:bool:key', '0', [ 'key' => false ] ],
			[ '4:number:key', '123', [ 'key' => 123 ] ],
			[ '4:number:key', '123.5', [ 'key' => 123.5 ] ],
			[ '4:array:key', [], [ 'key' => [ ApiResult::META_TYPE => 'array' ] ] ],
			[ '4:assoc:key', [], [ 'key' => [ ApiResult::META_TYPE => 'assoc' ] ] ],
			[ '4:kvp:key', [], [ 'key' => [ ApiResult::META_TYPE => 'kvp' ] ] ],
			[ '4:timestamp:key', '20150102030405', [ 'key' => '2015-01-02T03:04:05Z' ] ],
			[ '4:msg:key', 'parentheses', [
				'key_key' => 'parentheses',
				'key_text' => wfMessage( 'parentheses' )->text(),
			] ],
			[ '4:msg-content:key', 'parentheses', [
				'key_key' => 'parentheses',
				'key_text' => wfMessage( 'parentheses' )->inContentLanguage()->text(),
			] ],
			[ '4:title:key', 'project:foo', [
				'key_ns' => NS_PROJECT,
				'key_title' => Title::makeTitle( NS_PROJECT, 'Foo' )->getFullText(),
			] ],
			[ '4:title-link:key', 'project:foo', [
				'key_ns' => NS_PROJECT,
				'key_title' => Title::makeTitle( NS_PROJECT, 'Foo' )->getFullText(),
			] ],
			[ '4:title-link:key', '<invalid>', [
				'key_ns' => NS_SPECIAL,
				'key_title' => SpecialPage::getTitleFor( 'Badtitle', '<invalid>' )->getFullText(),
			] ],
			[ '4:user:key', 'foo', [ 'key' => 'Foo' ] ],
			[ '4:user-link:key', 'foo', [ 'key' => 'Foo' ] ],
		];
	}

	/**
	 * The testIrcMsgForAction* tests are supposed to cover the hacky
	 * LogFormatter::getIRCActionText / T36508
	 *
	 * Third parties bots listen to those messages. They are clever enough
	 * to fetch the i18n messages from the wiki and then analyze the IRC feed
	 * to reverse engineer the $1, $2 messages.
	 * One thing bots cannot detect is when MediaWiki change the meaning of
	 * a message like what happened when we deployed 1.19. $1 became the user
	 * performing the action which broke basically all bots around.
	 *
	 * Should cover the following log actions (which are most commonly used by bots):
	 * - block/block
	 * - block/unblock
	 * - block/reblock
	 * - delete/delete
	 * - delete/restore
	 * - newusers/create
	 * - newusers/create2
	 * - newusers/autocreate
	 * - move/move
	 * - move/move_redir
	 * - protect/protect
	 * - protect/modifyprotect
	 * - protect/unprotect
	 * - protect/move_prot
	 * - upload/upload
	 * - merge/merge
	 * - import/upload
	 * - import/interwiki
	 *
	 * As well as the following Auto Edit Summaries:
	 * - blank
	 * - replace
	 * - rollback
	 * - undo
	 */

	/**
	 * @covers \LogFormatter::getIRCActionComment
	 * @covers \LogFormatter::getIRCActionText
	 */
	public function testIrcMsgForLogTypeBlock() {
		$sep = $this->context->msg( 'colon-separator' )->text();

		# block/block
		$this->assertIRCComment(
			$this->context->msg( 'blocklogentry', 'SomeTitle', 'duration', '(flags)' )->plain()
			. $sep . $this->user_comment,
			'block', 'block',
			[
				'5::duration' => 'duration',
				'6::flags' => 'flags',
			],
			$this->user_comment
		);
		# block/block - legacy
		$this->assertIRCComment(
			$this->context->msg( 'blocklogentry', 'SomeTitle', 'duration', '(flags)' )->plain()
			. $sep . $this->user_comment,
			'block', 'block',
			[
				'duration',
				'flags',
			],
			$this->user_comment,
			'',
			true
		);
		# block/unblock
		$this->assertIRCComment(
			$this->context->msg( 'unblocklogentry', 'SomeTitle' )->plain() . $sep . $this->user_comment,
			'block', 'unblock',
			[],
			$this->user_comment
		);
		# block/reblock
		$this->assertIRCComment(
			$this->context->msg( 'reblock-logentry', 'SomeTitle', 'duration', '(flags)' )->plain()
			. $sep . $this->user_comment,
			'block', 'reblock',
			[
				'5::duration' => 'duration',
				'6::flags' => 'flags',
			],
			$this->user_comment
		);
	}

	/**
	 * @covers \LogFormatter::getIRCActionComment
	 * @covers \LogFormatter::getIRCActionText
	 */
	public function testIrcMsgForLogTypeDelete() {
		$sep = $this->context->msg( 'colon-separator' )->text();

		# delete/delete
		$this->assertIRCComment(
			$this->context->msg( 'deletedarticle', 'SomeTitle' )->plain() . $sep . $this->user_comment,
			'delete', 'delete',
			[],
			$this->user_comment
		);

		# delete/restore
		$this->assertIRCComment(
			$this->context->msg( 'undeletedarticle', 'SomeTitle' )->plain() . $sep . $this->user_comment,
			'delete', 'restore',
			[],
			$this->user_comment
		);
	}

	/**
	 * @covers \LogFormatter::getIRCActionComment
	 * @covers \LogFormatter::getIRCActionText
	 */
	public function testIrcMsgForLogTypeNewusers() {
		$this->assertIRCComment(
			'New user account',
			'newusers', 'newusers',
			[]
		);
		$this->assertIRCComment(
			'New user account',
			'newusers', 'create',
			[]
		);
		$this->assertIRCComment(
			'created new account SomeTitle',
			'newusers', 'create2',
			[]
		);
		$this->assertIRCComment(
			'Account created automatically',
			'newusers', 'autocreate',
			[]
		);
	}

	/**
	 * @covers \LogFormatter::getIRCActionComment
	 * @covers \LogFormatter::getIRCActionText
	 */
	public function testIrcMsgForLogTypeMove() {
		$move_params = [
			'4::target' => $this->target->getPrefixedText(),
			'5::noredir' => 0,
		];
		$sep = $this->context->msg( 'colon-separator' )->text();

		# move/move
		$this->assertIRCComment(
			$this->context->msg( '1movedto2', 'SomeTitle', 'TestTarget' )
				->plain() . $sep . $this->user_comment,
			'move', 'move',
			$move_params,
			$this->user_comment
		);

		# move/move_redir
		$this->assertIRCComment(
			$this->context->msg( '1movedto2_redir', 'SomeTitle', 'TestTarget' )
				->plain() . $sep . $this->user_comment,
			'move', 'move_redir',
			$move_params,
			$this->user_comment
		);
	}

	/**
	 * @covers \LogFormatter::getIRCActionComment
	 * @covers \LogFormatter::getIRCActionText
	 */
	public function testIrcMsgForLogTypePatrol() {
		# patrol/patrol
		$this->assertIRCComment(
			$this->context->msg( 'patrol-log-line', 'revision 777', '[[SomeTitle]]', '' )->plain(),
			'patrol', 'patrol',
			[
				'4::curid' => '777',
				'5::previd' => '666',
				'6::auto' => 0,
			]
		);
	}

	/**
	 * @covers \LogFormatter::getIRCActionComment
	 * @covers \LogFormatter::getIRCActionText
	 */
	public function testIrcMsgForLogTypeProtect() {
		$protectParams = [
			'4::description' => '[edit=sysop] (indefinite) ‎[move=sysop] (indefinite)'
		];
		$sep = $this->context->msg( 'colon-separator' )->text();

		# protect/protect
		$this->assertIRCComment(
			$this->context->msg( 'protectedarticle', 'SomeTitle ' . $protectParams['4::description'] )
				->plain() . $sep . $this->user_comment,
			'protect', 'protect',
			$protectParams,
			$this->user_comment
		);

		# protect/unprotect
		$this->assertIRCComment(
			$this->context->msg( 'unprotectedarticle', 'SomeTitle' )->plain() . $sep . $this->user_comment,
			'protect', 'unprotect',
			[],
			$this->user_comment
		);

		# protect/modify
		$this->assertIRCComment(
			$this->context->msg(
				'modifiedarticleprotection',
				'SomeTitle ' . $protectParams['4::description']
			)->plain() . $sep . $this->user_comment,
			'protect', 'modify',
			$protectParams,
			$this->user_comment
		);

		# protect/move_prot
		$this->assertIRCComment(
			$this->context->msg( 'movedarticleprotection', 'SomeTitle', 'OldTitle' )
				->plain() . $sep . $this->user_comment,
			'protect', 'move_prot',
			[
				'4::oldtitle' => 'OldTitle'
			],
			$this->user_comment
		);
	}

	/**
	 * @covers \LogFormatter::getIRCActionComment
	 * @covers \LogFormatter::getIRCActionText
	 */
	public function testIrcMsgForLogTypeUpload() {
		$sep = $this->context->msg( 'colon-separator' )->text();

		# upload/upload
		$this->assertIRCComment(
			$this->context->msg( 'uploadedimage', 'SomeTitle' )->plain() . $sep . $this->user_comment,
			'upload', 'upload',
			[],
			$this->user_comment
		);

		# upload/overwrite
		$this->assertIRCComment(
			$this->context->msg( 'overwroteimage', 'SomeTitle' )->plain() . $sep . $this->user_comment,
			'upload', 'overwrite',
			[],
			$this->user_comment
		);
	}

	/**
	 * @covers \LogFormatter::getIRCActionComment
	 * @covers \LogFormatter::getIRCActionText
	 */
	public function testIrcMsgForLogTypeMerge() {
		$sep = $this->context->msg( 'colon-separator' )->text();

		# merge/merge
		$this->assertIRCComment(
			$this->context->msg( 'pagemerge-logentry', 'SomeTitle', 'Dest', 'timestamp' )->plain()
			. $sep . $this->user_comment,
			'merge', 'merge',
			[
				'4::dest' => 'Dest',
				'5::mergepoint' => 'timestamp',
			],
			$this->user_comment
		);
	}

	/**
	 * @covers \LogFormatter::getIRCActionComment
	 * @covers \LogFormatter::getIRCActionText
	 */
	public function testIrcMsgForLogTypeImport() {
		$sep = $this->context->msg( 'colon-separator' )->text();

		# import/upload
		$msg = $this->context->msg( 'import-logentry-upload', 'SomeTitle' )->plain() .
			$sep .
			$this->user_comment;
		$this->assertIRCComment(
			$msg,
			'import', 'upload',
			[],
			$this->user_comment
		);

		# import/interwiki
		$msg = $this->context->msg( 'import-logentry-interwiki', 'SomeTitle' )->plain() .
			$sep .
			$this->user_comment;
		$this->assertIRCComment(
			$msg,
			'import', 'interwiki',
			[],
			$this->user_comment
		);
	}

	/**
	 * @param string $expected Expected IRC text without colors codes
	 * @param string $type Log type (move, delete, suppress, patrol ...)
	 * @param string $action A log type action
	 * @param array $params
	 * @param string|null $comment A comment for the log action
	 * @param string $msg
	 * @param bool $legacy
	 */
	protected function assertIRCComment( $expected, $type, $action, $params,
		$comment = null, $msg = '', $legacy = false
	) {
		$logEntry = new ManualLogEntry( $type, $action );
		$logEntry->setPerformer( $this->user );
		$logEntry->setTarget( $this->title );
		if ( $comment !== null ) {
			$logEntry->setComment( $comment );
		}
		$logEntry->setParameters( $params );
		$logEntry->setLegacy( $legacy );

		$formatter = $this->getServiceContainer()->getLogFormatterFactory()->newFromEntry( $logEntry );
		$formatter->setContext( $this->context );

		// Apply the same transformation as done in IRCColourfulRCFeedFormatter::getLine for rc_comment
		$ircRcComment = IRCColourfulRCFeedFormatter::cleanupForIRC( $formatter->getIRCActionComment() );

		$this->assertEquals(
			$expected,
			$ircRcComment,
			$msg
		);
	}

}
PK       ! Cy;2  2    logging/LogTests.i18n.phpnu Iw        <?php
/**
 * Internationalisation file for log tests.
 *
 * @file
 */

$messages = [];

$messages['en'] = [
	'log-name-phpunit' => 'PHPUnit-log',
	'log-description-phpunit' => 'Log for PHPUnit-tests',
	'logentry-phpunit-test' => '$1 {{GENDER:$2|tests}} with page $3',
	'logentry-phpunit-param' => '$4',
];
PK       ! 2?  2?  !  logging/BlockLogFormatterTest.phpnu Iw        <?php

use MediaWiki\Title\TitleValue;

/**
 * @covers \BlockLogFormatter
 */
class BlockLogFormatterTest extends LogFormatterTestCase {

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideBlockLogDatabaseRows() {
		return [
			// Current log format
			[
				[
					'type' => 'block',
					'action' => 'block',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'5::duration' => 'infinity',
						'6::flags' => 'anononly',
					],
				],
				[
					'text' => 'Sysop blocked Logtestuser with an expiration time of indefinite'
						. ' (anonymous users only)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
					],
					'preload' => [ new TitleValue( NS_USER_TALK, 'Logtestuser' ) ],
				],
			],

			// Old log format with one of the 4 values for 'infinity'
			[
				[
					'type' => 'block',
					'action' => 'block',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'5::duration' => 'infinite',
						'6::flags' => 'anononly',
					],
				],
				[
					'text' => 'Sysop blocked Logtestuser with an expiration time of indefinite'
						. ' (anonymous users only)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
					],
					'preload' => [ new TitleValue( NS_USER_TALK, 'Logtestuser' ) ],
				],
			],

			// With blank page title (T224811)
			[
				[
					'type' => 'block',
					'action' => 'block',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => '',
					'params' => [],
				],
				[
					'text' => 'Sysop blocked (no username available) '
						. 'with an expiration time of indefinite',
					'api' => [
						'duration' => 'infinity',
						'flags' => [],
					],
					'preload' => [],
				],
			],

			// Old legacy log
			[
				[
					'type' => 'block',
					'action' => 'block',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'infinite',
						'anononly',
					],
				],
				[
					'legacy' => true,
					'text' => 'Sysop blocked Logtestuser with an expiration time of indefinite'
						. ' (anonymous users only)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
					],
				],
			],

			// Old legacy log without flag
			[
				[
					'type' => 'block',
					'action' => 'block',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'infinite',
					],
				],
				[
					'legacy' => true,
					'text' => 'Sysop blocked Logtestuser with an expiration time of indefinite',
					'api' => [
						'duration' => 'infinity',
						'flags' => [],
					],
				],
			],

			// Very old legacy log without duration
			[
				[
					'type' => 'block',
					'action' => 'block',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [],
				],
				[
					'legacy' => true,
					'text' => 'Sysop blocked Logtestuser with an expiration time of indefinite',
					'api' => [
						'duration' => 'infinity',
						'flags' => [],
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideBlockLogDatabaseRows
	 */
	public function testBlockLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideReblockLogDatabaseRows() {
		return [
			// Current log format
			[
				[
					'type' => 'block',
					'action' => 'reblock',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'5::duration' => 'infinite',
						'6::flags' => 'anononly',
					],
				],
				[
					'text' => 'Sysop changed block settings for Logtestuser with an expiration time of'
						. ' indefinite (anonymous users only)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
					],
				],
			],

			// Old log
			[
				[
					'type' => 'block',
					'action' => 'reblock',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'infinite',
						'anononly',
					],
				],
				[
					'legacy' => true,
					'text' => 'Sysop changed block settings for Logtestuser with an expiration time of'
						. ' indefinite (anonymous users only)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
					],
				],
			],

			// Older log without flag
			[
				[
					'type' => 'block',
					'action' => 'reblock',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'infinite',
					]
				],
				[
					'legacy' => true,
					'text' => 'Sysop changed block settings for Logtestuser with an expiration time of indefinite',
					'api' => [
						'duration' => 'infinity',
						'flags' => [],
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideReblockLogDatabaseRows
	 */
	public function testReblockLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideUnblockLogDatabaseRows() {
		return [
			// Current log format
			[
				[
					'type' => 'block',
					'action' => 'unblock',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [],
				],
				[
					'text' => 'Sysop unblocked Logtestuser',
					'api' => [],
				],
			],
		];
	}

	/**
	 * @dataProvider provideUnblockLogDatabaseRows
	 */
	public function testUnblockLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideSuppressBlockLogDatabaseRows() {
		return [
			// Current log format
			[
				[
					'type' => 'suppress',
					'action' => 'block',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'5::duration' => 'infinite',
						'6::flags' => 'anononly',
					],
				],
				[
					'text' => 'Sysop blocked Logtestuser with an expiration time of indefinite'
						. ' (anonymous users only)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
					],
				],
			],

			// legacy log
			[
				[
					'type' => 'suppress',
					'action' => 'block',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'infinite',
						'anononly',
					],
				],
				[
					'legacy' => true,
					'text' => 'Sysop blocked Logtestuser with an expiration time of indefinite'
						. ' (anonymous users only)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
					],
				],
			],
		];
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideSuppressBlockLogDatabaseRowsNonPrivileged() {
		return [
			// Current log format
			[
				[
					'type' => 'suppress',
					'action' => 'block',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'5::duration' => 'infinite',
						'6::flags' => 'anononly',
					],
				],
				[
					'text' => '(username removed) (log details removed)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
					],
				],
			],

			// legacy log
			[
				[
					'type' => 'suppress',
					'action' => 'block',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'infinite',
						'anononly',
					],
				],
				[
					'legacy' => true,
					'text' => '(username removed) (log details removed)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideSuppressBlockLogDatabaseRowsNonPrivileged
	 */
	public function testSuppressBlockLogDatabaseRowsNonPrivileged( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideSuppressReblockLogDatabaseRows() {
		return [
			// Current log format
			[
				[
					'type' => 'suppress',
					'action' => 'reblock',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'5::duration' => 'infinite',
						'6::flags' => 'anononly',
					],
				],
				[
					'text' => 'Sysop changed block settings for Logtestuser with an expiration time of'
						. ' indefinite (anonymous users only)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
					],
				],
			],

			// Legacy format
			[
				[
					'type' => 'suppress',
					'action' => 'reblock',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'infinite',
						'anononly',
					],
				],
				[
					'legacy' => true,
					'text' => 'Sysop changed block settings for Logtestuser with an expiration time of'
						. ' indefinite (anonymous users only)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideSuppressBlockLogDatabaseRows
	 * @dataProvider provideSuppressReblockLogDatabaseRows
	 */
	public function testSuppressBlockLogDatabaseRows( $row, $extra ) {
		$this->setGroupPermissions(
			[
				'oversight' => [
					'viewsuppressed' => true,
					'suppressionlog' => true,
				],
			]
		);
		$this->doTestLogFormatter( $row, $extra, [ 'oversight' ] );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideSuppressReblockLogDatabaseRowsNonPrivileged() {
		return [
			// Current log format
			[
				[
					'type' => 'suppress',
					'action' => 'reblock',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'5::duration' => 'infinite',
						'6::flags' => 'anononly',
					],
				],
				[
					'text' => '(username removed) (log details removed)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
					],
				],
			],

			// Legacy format
			[
				[
					'type' => 'suppress',
					'action' => 'reblock',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'infinite',
						'anononly',
					],
				],
				[
					'legacy' => true,
					'text' => '(username removed) (log details removed)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideSuppressReblockLogDatabaseRowsNonPrivileged
	 */
	public function testSuppressReblockLogDatabaseRowsNonPrivileged( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	public static function providePartialBlockLogDatabaseRows() {
		return [
			[
				[
					'type' => 'block',
					'action' => 'block',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'5::duration' => 'infinite',
						'6::flags' => 'anononly',
						'7::restrictions' => [ 'pages' => [ 'User:Test1', 'Main Page' ] ],
						'sitewide' => false,
					],
				],
				[
					'text' => 'Sysop blocked Logtestuser from the pages User:Test1 and Main Page'
						. ' with an expiration time of indefinite (anonymous users only)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
						'restrictions' => [
							'pages' => [
								[
									'page_ns' => 2,
									'page_title' => 'User:Test1',
								], [
									'page_ns' => 0,
									'page_title' => 'Main Page',
								],
							],
						],
						'sitewide' => false,
					],
				],
			],
			[
				[
					'type' => 'block',
					'action' => 'block',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'5::duration' => 'infinite',
						'6::flags' => 'anononly',
						'7::restrictions' => [
							'namespaces' => [ NS_USER ],
						],
						'sitewide' => false,
					],
				],
				[
					'text' => 'Sysop blocked Logtestuser from the namespace User'
						. ' with an expiration time of indefinite (anonymous users only)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
						'restrictions' => [
							'namespaces' => [ NS_USER ],
						],
						'sitewide' => false,
					],
				],
			],
			[
				[
					'type' => 'block',
					'action' => 'block',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'5::duration' => 'infinite',
						'6::flags' => 'anononly',
						'7::restrictions' => [
							'pages' => [ 'Main Page' ],
							'namespaces' => [ NS_USER, NS_MAIN ],
						],
						'sitewide' => false,
					],
				],
				[
					'text' => 'Sysop blocked Logtestuser from the page Main Page and the'
						. ' namespaces User and (Main) with an expiration time of indefinite'
						. ' (anonymous users only)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
						'restrictions' => [
							'pages' => [
								[
									'page_ns' => 0,
									'page_title' => 'Main Page',
								],
							],
							'namespaces' => [ NS_USER, NS_MAIN ],
						],
						'sitewide' => false,
					],
				],
			],
			[
				[
					'type' => 'block',
					'action' => 'block',
					'comment' => 'Block comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'Logtestuser',
					'params' => [
						'5::duration' => 'infinite',
						'6::flags' => 'anononly',
						'sitewide' => false,
					],
				],
				[
					'text' => 'Sysop blocked Logtestuser from specified non-editing actions'
						. ' with an expiration time of indefinite (anonymous users only)',
					'api' => [
						'duration' => 'infinity',
						'flags' => [ 'anononly' ],
						'sitewide' => false,
					],
				],
			],
		];
	}

	/**
	 * @dataProvider providePartialBlockLogDatabaseRows
	 */
	public function testPartialBlockLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}
}
PK       ! V-"    $  logging/NewUsersLogFormatterTest.phpnu Iw        <?php

/**
 * @covers \NewUsersLogFormatter
 * @group Database
 */
class NewUsersLogFormatterTest extends LogFormatterTestCase {

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

		// Register LogHandler, see $wgNewUserLog in Setup.php
		$this->mergeMwGlobalArrayValue( 'wgLogActionsHandlers', [
			'newusers/newusers' => NewUsersLogFormatter::class,
			'newusers/create' => NewUsersLogFormatter::class,
			'newusers/create2' => NewUsersLogFormatter::class,
			'newusers/byemail' => NewUsersLogFormatter::class,
			'newusers/autocreate' => NewUsersLogFormatter::class,
		] );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideNewUsersLogDatabaseRows() {
		return [
			// Only old logs
			[
				[
					'type' => 'newusers',
					'action' => 'newusers',
					'comment' => 'newusers comment',
					'user' => 0,
					'user_text' => 'New user',
					'namespace' => NS_USER,
					'title' => 'New user',
					'params' => [],
				],
				[
					'legacy' => true,
					'text' => 'User account New user was created',
					'api' => [],
				],
			],
		];
	}

	/**
	 * @dataProvider provideNewUsersLogDatabaseRows
	 */
	public function testNewUsersLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideCreateLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'newusers',
					'action' => 'create',
					'comment' => 'newusers comment',
					'user' => 0,
					'user_text' => 'New user',
					'namespace' => NS_USER,
					'title' => 'New user',
					'params' => [
						'4::userid' => 1,
					],
				],
				[
					'text' => 'User account New user was created',
					'api' => [
						'userid' => 1,
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideCreateLogDatabaseRows
	 */
	public function testCreateLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideCreate2LogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'newusers',
					'action' => 'create2',
					'comment' => 'newusers comment',
					'user' => 0,
					'user_text' => 'User',
					'namespace' => NS_USER,
					'title' => 'UTSysop'
				],
				[
					'text' => 'User account UTSysop was created by User'
				],
			],
		];
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideByemailLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'newusers',
					'action' => 'byemail',
					'comment' => 'newusers comment',
					'user' => 0,
					'user_text' => 'Sysop',
					'namespace' => NS_USER,
					'title' => 'UTSysop'
				],
				[
					'text' => 'User account UTSysop was created by Sysop and password was sent by email'
				],
			],
		];
	}

	/**
	 * @dataProvider provideCreate2LogDatabaseRows
	 * @dataProvider provideByemailLogDatabaseRows
	 */
	public function testCreate2LogDatabaseRows( $row, $extra ) {
		// Make UTSysop user and use its user_id (sequence does not reset to 1 for postgres)
		$user = static::getTestSysop()->getUser();
		$row['params']['4::userid'] = $user->getId();
		$extra['api']['userid'] = $user->getId();
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideAutocreateLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'newusers',
					'action' => 'autocreate',
					'comment' => 'newusers comment',
					'user' => 0,
					'user_text' => 'New user',
					'namespace' => NS_USER,
					'title' => 'New user',
					'params' => [
						'4::userid' => 1,
					],
				],
				[
					'text' => 'User account New user was created automatically',
					'api' => [
						'userid' => 1,
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideAutocreateLogDatabaseRows
	 */
	public function testAutocreateLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}
}
PK       ! h%Ax  x  (  logging/ContentModelLogFormatterTest.phpnu Iw        <?php

/**
 * @covers \ContentModelLogFormatter
 */
class ContentModelLogFormatterTest extends LogFormatterTestCase {
	public static function provideContentModelLogDatabaseRows() {
		return [
			[
				[
					'type' => 'contentmodel',
					'action' => 'new',
					'comment' => 'new content model comment',
					'namespace' => NS_MAIN,
					'title' => 'ContentModelPage',
					'params' => [
						'5::newModel' => 'testcontentmodel',
					],
				],
				[
					'text' => 'User created the page ContentModelPage ' .
						'using a non-default content model ' .
						'"testcontentmodel"',
					'api' => [
						'newModel' => 'testcontentmodel',
					],
				],
			],
			[
				[
					'type' => 'contentmodel',
					'action' => 'change',
					'comment' => 'change content model comment',
					'namespace' => NS_MAIN,
					'title' => 'ContentModelPage',
					'params' => [
						'4::oldmodel' => 'wikitext',
						'5::newModel' => 'testcontentmodel',
					],
				],
				[
					'text' => 'User changed the content model of the page ' .
						'ContentModelPage from "wikitext" to ' .
						'"testcontentmodel"',
					'api' => [
						'oldmodel' => 'wikitext',
						'newModel' => 'testcontentmodel',
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideContentModelLogDatabaseRows
	 */
	public function testContentModelLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}
}
PK       ! Z      $  logging/PageLangLogFormatterTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\MainConfigSchema;

/**
 * @covers \PageLangLogFormatter
 */
class PageLangLogFormatterTest extends LogFormatterTestCase {

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

		// Clear all hooks to disable cldr extension
		$this->clearHooks();

		// Register LogHandler, see $wgPageLanguageUseDB in Setup.php
		$this->overrideConfigValue(
			MainConfigNames::LogActionsHandlers,
			MainConfigSchema::getDefaultValue( MainConfigNames::LogActionsHandlers ) +
			[
				'pagelang/pagelang' => [
					'class' => PageLangLogFormatter::class,
					'services' => [
						'LanguageNameUtils',
					]
				]
			]
		);
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function providePageLangLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'pagelang',
					'action' => 'pagelang',
					'comment' => 'page lang comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'4::oldlanguage' => 'en',
						'5::newlanguage' => 'de[def]',
					],
				],
				[
					'text' => 'User changed the language of Page from English (en) to Deutsch (de) [default]',
					'api' => [
						'oldlanguage' => 'en',
						'newlanguage' => 'de[def]'
					],
				],
			],
		];
	}

	/**
	 * @dataProvider providePageLangLogDatabaseRows
	 */
	public function testPageLangLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}
}
PK       ! p
  
  "  logging/ImportLogFormatterTest.phpnu Iw        <?php

use MediaWiki\Tests\Unit\DummyServicesTrait;

/**
 * @covers \ImportLogFormatter
 */
class ImportLogFormatterTest extends LogFormatterTestCase {
	use DummyServicesTrait;

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideUploadLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'import',
					'action' => 'upload',
					'comment' => 'upload comment',
					'namespace' => NS_MAIN,
					'title' => 'ImportPage',
					'params' => [
						'4:number:count' => '1',
					],
				],
				[
					'text' => 'User imported ImportPage by file upload (1 revision)',
					'api' => [
						'count' => 1,
					],
				],
			],

			// old format - without details
			[
				[
					'type' => 'import',
					'action' => 'upload',
					'comment' => '1 revision: import comment',
					'namespace' => NS_MAIN,
					'title' => 'ImportPage',
					'params' => [],
				],
				[
					'text' => 'User imported ImportPage by file upload',
					'api' => [],
				],
			],
		];
	}

	/**
	 * @dataProvider provideUploadLogDatabaseRows
	 */
	public function testUploadLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideInterwikiLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'import',
					'action' => 'interwiki',
					'comment' => 'interwiki comment',
					'namespace' => NS_MAIN,
					'title' => 'ImportPage',
					'params' => [
						'4:number:count' => '1',
						'5:title-link:interwiki' => 'importiw:PageImport',
					],
				],
				[
					'text' => 'User imported ImportPage from importiw:PageImport (1 revision)',
					'api' => [
						'count' => 1,
						'interwiki_ns' => 0,
						'interwiki_title' => 'importiw:PageImport',
					],
				],
			],

			// old format - without details
			[
				[
					'type' => 'import',
					'action' => 'interwiki',
					'comment' => '1 revision from importiw:PageImport: interwiki comment',
					'namespace' => NS_MAIN,
					'title' => 'ImportPage',
					'params' => [],
				],
				[
					'text' => 'User imported ImportPage from another wiki',
					'api' => [],
				],
			],
		];
	}

	/**
	 * @dataProvider provideInterwikiLogDatabaseRows
	 */
	public function testInterwikiLogDatabaseRows( $row, $extra ) {
		// Setup importiw: as interwiki prefix
		$interwikiLookup = $this->getDummyInterwikiLookup( [ 'importiw' ] );
		$this->setService( 'InterwikiLookup', $interwikiLookup );

		$this->doTestLogFormatter( $row, $extra );
	}
}
PK       ! O8       logging/MoveLogFormatterTest.phpnu Iw        <?php

/**
 * @covers \MoveLogFormatter
 */
class MoveLogFormatterTest extends LogFormatterTestCase {

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideMoveLogDatabaseRows() {
		return [
			// Current format - with redirect
			[
				[
					'type' => 'move',
					'action' => 'move',
					'comment' => 'move comment with redirect',
					'namespace' => NS_MAIN,
					'title' => 'OldPage',
					'params' => [
						'4::target' => 'NewPage',
						'5::noredir' => '0',
					],
				],
				[
					'text' => 'User moved page OldPage to NewPage',
					'api' => [
						'target_ns' => 0,
						'target_title' => 'NewPage',
						'suppressredirect' => false,
					],
				],
			],

			// Current format - without redirect
			[
				[
					'type' => 'move',
					'action' => 'move',
					'comment' => 'move comment',
					'namespace' => NS_MAIN,
					'title' => 'OldPage',
					'params' => [
						'4::target' => 'NewPage',
						'5::noredir' => '1',
					],
				],
				[
					'text' => 'User moved page OldPage to NewPage without leaving a redirect',
					'api' => [
						'target_ns' => 0,
						'target_title' => 'NewPage',
						'suppressredirect' => true,
					],
				],
			],

			// legacy format - with redirect
			[
				[
					'type' => 'move',
					'action' => 'move',
					'comment' => 'move comment',
					'namespace' => NS_MAIN,
					'title' => 'OldPage',
					'params' => [
						'NewPage',
						'',
					],
				],
				[
					'legacy' => true,
					'text' => 'User moved page OldPage to NewPage',
					'api' => [
						'target_ns' => 0,
						'target_title' => 'NewPage',
						'suppressredirect' => false,
					],
				],
			],

			// legacy format - without redirect
			[
				[
					'type' => 'move',
					'action' => 'move',
					'comment' => 'move comment',
					'namespace' => NS_MAIN,
					'title' => 'OldPage',
					'params' => [
						'NewPage',
						'1',
					],
				],
				[
					'legacy' => true,
					'text' => 'User moved page OldPage to NewPage without leaving a redirect',
					'api' => [
						'target_ns' => 0,
						'target_title' => 'NewPage',
						'suppressredirect' => true,
					],
				],
			],

			// old format without flag for redirect suppression
			[
				[
					'type' => 'move',
					'action' => 'move',
					'comment' => 'move comment',
					'namespace' => NS_MAIN,
					'title' => 'OldPage',
					'params' => [
						'NewPage',
					],
				],
				[
					'legacy' => true,
					'text' => 'User moved page OldPage to NewPage',
					'api' => [
						'target_ns' => 0,
						'target_title' => 'NewPage',
						'suppressredirect' => false,
					],
				],
			],

			// row with invalid title (T370396)
			[
				[
					'type' => 'move',
					'action' => 'move',
					'comment' => 'comment',
					'namespace' => NS_TALK,
					'title' => 'OldPage',
					'params' => [
						'4::target' => 'Talk:Help:NewPage',
						'5::noredir' => '0',
					],
				],
				[
					'text' => 'User moved page Talk:OldPage to Invalid title',
					'api' => [
						'target_ns' => -1,
						'target_title' => 'Special:Badtitle/Talk:Help:NewPage',
						'suppressredirect' => false,
					],
					'preload' => [ /* empty, do not try to preload the bad title */ ],
				],
			],
		];
	}

	/**
	 * @dataProvider provideMoveLogDatabaseRows
	 */
	public function testMoveLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideMoveRedirLogDatabaseRows() {
		return [
			// Current format - with redirect
			[
				[
					'type' => 'move',
					'action' => 'move_redir',
					'comment' => 'move comment with redirect',
					'namespace' => NS_MAIN,
					'title' => 'OldPage',
					'params' => [
						'4::target' => 'NewPage',
						'5::noredir' => '0',
					],
				],
				[
					'text' => 'User moved page OldPage to NewPage over redirect',
					'api' => [
						'target_ns' => 0,
						'target_title' => 'NewPage',
						'suppressredirect' => false,
					],
				],
			],

			// Current format - without redirect
			[
				[
					'type' => 'move',
					'action' => 'move_redir',
					'comment' => 'move comment',
					'namespace' => NS_MAIN,
					'title' => 'OldPage',
					'params' => [
						'4::target' => 'NewPage',
						'5::noredir' => '1',
					],
				],
				[
					'text' => 'User moved page OldPage to NewPage over a redirect without leaving a redirect',
					'api' => [
						'target_ns' => 0,
						'target_title' => 'NewPage',
						'suppressredirect' => true,
					],
				],
			],

			// legacy format - with redirect
			[
				[
					'type' => 'move',
					'action' => 'move_redir',
					'comment' => 'move comment',
					'namespace' => NS_MAIN,
					'title' => 'OldPage',
					'params' => [
						'NewPage',
						'',
					],
				],
				[
					'legacy' => true,
					'text' => 'User moved page OldPage to NewPage over redirect',
					'api' => [
						'target_ns' => 0,
						'target_title' => 'NewPage',
						'suppressredirect' => false,
					],
				],
			],

			// legacy format - without redirect
			[
				[
					'type' => 'move',
					'action' => 'move_redir',
					'comment' => 'move comment',
					'namespace' => NS_MAIN,
					'title' => 'OldPage',
					'params' => [
						'NewPage',
						'1',
					],
				],
				[
					'legacy' => true,
					'text' => 'User moved page OldPage to NewPage over a redirect without leaving a redirect',
					'api' => [
						'target_ns' => 0,
						'target_title' => 'NewPage',
						'suppressredirect' => true,
					],
				],
			],

			// old format without flag for redirect suppression
			[
				[
					'type' => 'move',
					'action' => 'move_redir',
					'comment' => 'move comment',
					'namespace' => NS_MAIN,
					'title' => 'OldPage',
					'params' => [
						'NewPage',
					],
				],
				[
					'legacy' => true,
					'text' => 'User moved page OldPage to NewPage over redirect',
					'api' => [
						'target_ns' => 0,
						'target_title' => 'NewPage',
						'suppressredirect' => false,
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideMoveRedirLogDatabaseRows
	 */
	public function testMoveRedirLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}
}
PK       ! 5(       logging/DatabaseLogEntryTest.phpnu Iw        <?php

use MediaWiki\User\ActorStore;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\Rdbms\IReadableDatabase;

/**
 * @group Database
 */
class DatabaseLogEntryTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers \DatabaseLogEntry::newFromId
	 * @covers \DatabaseLogEntry::getSelectQueryData
	 *
	 * @dataProvider provideNewFromId
	 *
	 * @param int $id
	 * @param array $selectFields
	 * @param string[]|null $row
	 * @param string[]|null $expectedFields
	 */
	public function testNewFromId( $id,
		array $selectFields,
		?array $row = null,
		?array $expectedFields = null
	) {
		$row = $row ? (object)$row : null;
		$db = $this->createMock( IReadableDatabase::class );
		$db->expects( self::once() )
			->method( 'selectRow' )
			->with( $selectFields['tables'],
				$selectFields['fields'],
				$selectFields['conds'],
				'DatabaseLogEntry::newFromId',
				$selectFields['options'],
				$selectFields['join_conds']
			)
			->will( self::returnValue( $row ) );

		/** @var IReadableDatabase $db */
		$logEntry = DatabaseLogEntry::newFromId( $id, $db );

		if ( !$expectedFields ) {
			self::assertNull( $logEntry, "Expected no log entry returned for id=$id" );
		} else {
			self::assertEquals( $id, $logEntry->getId() );
			self::assertEquals( $expectedFields['type'], $logEntry->getType() );
			self::assertEquals( $expectedFields['comment'], $logEntry->getComment() );
		}
	}

	public static function provideNewFromId() {
		$newTables = [
			'tables' => [
				'logging',
				'comment_log_comment' => 'comment',
				'logging_actor' => 'actor',
				'user' => 'user',
			],
			'fields' => [
				'log_id',
				'log_type',
				'log_action',
				'log_timestamp',
				'log_namespace',
				'log_title',
				'log_params',
				'log_deleted',
				'user_id',
				'user_name',
				'log_comment_text' => 'comment_log_comment.comment_text',
				'log_comment_data' => 'comment_log_comment.comment_data',
				'log_comment_cid' => 'comment_log_comment.comment_id',
				'log_user' => 'logging_actor.actor_user',
				'log_user_text' => 'logging_actor.actor_name',
				'log_actor',
			],
			'options' => [],
			'join_conds' => [
				'user' => [ 'LEFT JOIN', 'user_id=logging_actor.actor_user' ],
				'comment_log_comment' => [ 'JOIN', 'comment_log_comment.comment_id = log_comment_id' ],
				'logging_actor' => [ 'JOIN', 'actor_id=log_actor' ],
			],
		];
		return [
			[
				0,
				$newTables + [ 'conds' => [ 'log_id' => 0 ] ],
				null,
				null
			],
			[
				123,
				$newTables + [ 'conds' => [ 'log_id' => 123 ] ],
				[
					'log_id' => 123,
					'log_type' => 'foobarize',
					'log_comment_text' => 'test!',
					'log_comment_data' => null,
				],
				[ 'type' => 'foobarize', 'comment' => 'test!' ]
			],
			[
				567,
				$newTables + [ 'conds' => [ 'log_id' => 567 ] ],
				[
					'log_id' => 567,
					'log_type' => 'foobarize',
					'log_comment_text' => 'test!',
					'log_comment_data' => null,
				],
				[ 'type' => 'foobarize', 'comment' => 'test!' ]
			],
		];
	}

	public static function provideGetPerformerIdentity() {
		yield 'registered actor' => [
			'actor_row_fields' => [
				'user_id' => 42,
				'log_user_text' => 'Testing',
				'log_actor' => 24,
			],
			UserIdentityValue::newRegistered( 42, 'Testing' ),
		];
		yield 'anon actor' => [
			'actor_row_fields' => [
				'log_user_text' => '127.0.0.1',
				'log_actor' => 24,
			],
			UserIdentityValue::newAnonymous( '127.0.0.1' ),
		];
		yield 'unknown actor' => [
			'actor_row_fields' => [],
			new UserIdentityValue( 0, ActorStore::UNKNOWN_USER_NAME ),
		];
	}

	/**
	 * @dataProvider provideGetPerformerIdentity
	 * @covers \DatabaseLogEntry::getPerformerIdentity
	 */
	public function testGetPerformer( array $actorRowFields, UserIdentity $expected ) {
		$logEntry = DatabaseLogEntry::newFromRow( [
			'log_id' => 1,
		] + $actorRowFields );
		$performer = $logEntry->getPerformerIdentity();
		$this->assertTrue( $expected->equals( $performer ) );
	}
}
PK       ! Y(1  1  #  logging/ProtectLogFormatterTest.phpnu Iw        <?php

use MediaWiki\Cache\LinkCache;
use MediaWiki\Context\RequestContext;
use MediaWiki\Linker\LinkRendererFactory;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleFactory;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\LBFactory;

/**
 * @covers \ProtectLogFormatter
 */
class ProtectLogFormatterTest extends LogFormatterTestCase {

	use MockAuthorityTrait;

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

		$db = $this->createNoOpMock( IDatabase::class, [ 'getInfinity' ] );
		$db->method( 'getInfinity' )->willReturn( 'infinity' );
		$lbFactory = $this->createMock( LBFactory::class );
		$lbFactory->method( 'getReplicaDatabase' )->willReturn( $db );
		$this->setService( 'DBLoadBalancerFactory', $lbFactory );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideProtectLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'protect',
					'action' => 'protect',
					'comment' => 'protect comment',
					'namespace' => NS_MAIN,
					'title' => 'ProtectPage',
					'params' => [
						'4::description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'5:bool:cascade' => false,
						'details' => [
							[
								'type' => 'edit',
								'level' => 'sysop',
								'expiry' => 'infinity',
								'cascade' => false,
							],
							[
								'type' => 'move',
								'level' => 'sysop',
								'expiry' => 'infinity',
								'cascade' => false,
							],
						],
					],
				],
				[
					'text' => 'User protected ProtectPage [Edit=Allow only administrators] ' .
						'(indefinite) [Move=Allow only administrators] (indefinite)',
					'api' => [
						'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'cascade' => false,
						'details' => [
							[
								'type' => 'edit',
								'level' => 'sysop',
								'expiry' => 'infinite',
								'cascade' => false,
							],
							[
								'type' => 'move',
								'level' => 'sysop',
								'expiry' => 'infinite',
								'cascade' => false,
							],
						],
					],
				],
			],

			// Current format with cascade
			[
				[
					'type' => 'protect',
					'action' => 'protect',
					'comment' => 'protect comment',
					'namespace' => NS_MAIN,
					'title' => 'ProtectPage',
					'params' => [
						'4::description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'5:bool:cascade' => true,
						'details' => [
							[
								'type' => 'edit',
								'level' => 'sysop',
								'expiry' => 'infinity',
								'cascade' => true,
							],
							[
								'type' => 'move',
								'level' => 'sysop',
								'expiry' => 'infinity',
								'cascade' => false,
							],
						],
					],
				],
				[
					'text' => 'User protected ProtectPage [Edit=Allow only administrators] ' .
						'(indefinite) [Move=Allow only administrators] (indefinite) [cascading]',
					'api' => [
						'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'cascade' => true,
						'details' => [
							[
								'type' => 'edit',
								'level' => 'sysop',
								'expiry' => 'infinite',
								'cascade' => true,
							],
							[
								'type' => 'move',
								'level' => 'sysop',
								'expiry' => 'infinite',
								'cascade' => false,
							],
						],
					],
				],
			],

			// Legacy format
			[
				[
					'type' => 'protect',
					'action' => 'protect',
					'comment' => 'protect comment',
					'namespace' => NS_MAIN,
					'title' => 'ProtectPage',
					'params' => [
						'[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'',
					],
				],
				[
					'legacy' => true,
					'text' => 'User protected ProtectPage [edit=sysop] (indefinite)[move=sysop] (indefinite)',
					'api' => [
						'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'cascade' => false,
					],
				],
			],

			// Legacy format with cascade
			[
				[
					'type' => 'protect',
					'action' => 'protect',
					'comment' => 'protect comment',
					'namespace' => NS_MAIN,
					'title' => 'ProtectPage',
					'params' => [
						'[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'cascade',
					],
				],
				[
					'legacy' => true,
					'text' => 'User protected ProtectPage [edit=sysop] ' .
						'(indefinite)[move=sysop] (indefinite) [cascading]',
					'api' => [
						'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'cascade' => true,
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideProtectLogDatabaseRows
	 */
	public function testProtectLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideModifyLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'protect',
					'action' => 'modify',
					'comment' => 'protect comment',
					'namespace' => NS_MAIN,
					'title' => 'ProtectPage',
					'params' => [
						'4::description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'5:bool:cascade' => false,
						'details' => [
							[
								'type' => 'edit',
								'level' => 'sysop',
								'expiry' => 'infinity',
								'cascade' => false,
							],
							[
								'type' => 'move',
								'level' => 'sysop',
								'expiry' => 'infinity',
								'cascade' => false,
							],
						],
					],
				],
				[
					'text' => 'User changed protection settings for ProtectPage ' .
						'[Edit=Allow only administrators] ' .
						'(indefinite) [Move=Allow only administrators] (indefinite)',
					'api' => [
						'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'cascade' => false,
						'details' => [
							[
								'type' => 'edit',
								'level' => 'sysop',
								'expiry' => 'infinite',
								'cascade' => false,
							],
							[
								'type' => 'move',
								'level' => 'sysop',
								'expiry' => 'infinite',
								'cascade' => false,
							],
						],
					],
				],
			],

			// Current format with cascade
			[
				[
					'type' => 'protect',
					'action' => 'modify',
					'comment' => 'protect comment',
					'namespace' => NS_MAIN,
					'title' => 'ProtectPage',
					'params' => [
						'4::description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'5:bool:cascade' => true,
						'details' => [
							[
								'type' => 'edit',
								'level' => 'sysop',
								'expiry' => 'infinity',
								'cascade' => true,
							],
							[
								'type' => 'move',
								'level' => 'sysop',
								'expiry' => 'infinity',
								'cascade' => false,
							],
						],
					],
				],
				[
					'text' => 'User changed protection settings for ProtectPage ' .
						'[Edit=Allow only administrators] (indefinite) ' .
						'[Move=Allow only administrators] (indefinite) [cascading]',
					'api' => [
						'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'cascade' => true,
						'details' => [
							[
								'type' => 'edit',
								'level' => 'sysop',
								'expiry' => 'infinite',
								'cascade' => true,
							],
							[
								'type' => 'move',
								'level' => 'sysop',
								'expiry' => 'infinite',
								'cascade' => false,
							],
						],
					],
				],
			],

			// Legacy format
			[
				[
					'type' => 'protect',
					'action' => 'modify',
					'comment' => 'protect comment',
					'namespace' => NS_MAIN,
					'title' => 'ProtectPage',
					'params' => [
						'[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'',
					],
				],
				[
					'legacy' => true,
					'text' => 'User changed protection settings for ProtectPage ' .
						'[edit=sysop] (indefinite)[move=sysop] (indefinite)',
					'api' => [
						'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'cascade' => false,
					],
				],
			],

			// Legacy format with cascade
			[
				[
					'type' => 'protect',
					'action' => 'modify',
					'comment' => 'protect comment',
					'namespace' => NS_MAIN,
					'title' => 'ProtectPage',
					'params' => [
						'[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'cascade',
					],
				],
				[
					'legacy' => true,
					'text' => 'User changed protection settings for ProtectPage ' .
						'[edit=sysop] (indefinite)[move=sysop] (indefinite) [cascading]',
					'api' => [
						'description' => '[edit=sysop] (indefinite)[move=sysop] (indefinite)',
						'cascade' => true,
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideModifyLogDatabaseRows
	 */
	public function testModifyLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideUnprotectLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'protect',
					'action' => 'unprotect',
					'comment' => 'unprotect comment',
					'namespace' => NS_MAIN,
					'title' => 'ProtectPage',
					'params' => [],
				],
				[
					'text' => 'User removed protection from ProtectPage',
					'api' => [],
				],
			],
		];
	}

	/**
	 * @dataProvider provideUnprotectLogDatabaseRows
	 */
	public function testUnprotectLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideMoveProtLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'protect',
					'action' => 'move_prot',
					'comment' => 'Move comment',
					'namespace' => NS_MAIN,
					'title' => 'NewPage',
					'params' => [
						'4::oldtitle' => 'OldPage',
					],
				],
				[
					'text' => 'User moved protection settings from OldPage to NewPage',
					'api' => [
						'oldtitle_ns' => 0,
						'oldtitle_title' => 'OldPage',
					],
				],
			],

			// Legacy format
			[
				[
					'type' => 'protect',
					'action' => 'move_prot',
					'comment' => 'Move comment',
					'namespace' => NS_MAIN,
					'title' => 'NewPage',
					'params' => [
						'OldPage',
					],
				],
				[
					'legacy' => true,
					'text' => 'User moved protection settings from OldPage to NewPage',
					'api' => [
						'oldtitle_ns' => 0,
						'oldtitle_title' => 'OldPage',
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideMoveProtLogDatabaseRows
	 */
	public function testMoveProtLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}

	public static function provideGetActionLinks() {
		yield [
			[ 'protect' ],
			true
		];
		yield [
			[],
			false
		];
	}

	/**
	 * @param string[] $permissions
	 * @param bool $shouldMatch
	 * @dataProvider provideGetActionLinks
	 * @covers \ProtectLogFormatter::getActionLinks
	 */
	public function testGetActionLinks( array $permissions, $shouldMatch ) {
		RequestContext::resetMain();
		$user = $this->mockUserAuthorityWithPermissions( new UserIdentityValue( 42, __METHOD__ ), $permissions );
		$row = $this->expandDatabaseRow( [
			'type' => 'protect',
			'action' => 'unprotect',
			'comment' => 'unprotect comment',
			'namespace' => NS_MAIN,
			'title' => 'ProtectPage',
			'params' => [],
		], false );
		$context = new RequestContext();
		$context->setAuthority( $user );
		$context->setLanguage( 'en' );
		$formatter = $this->getServiceContainer()->getLogFormatterFactory()->newFromRow( $row );
		$formatter->setContext( $context );
		$titleFactory = $this->createMock( TitleFactory::class );
		$titleFactory->method( 'makeTitle' )->willReturnCallback( static function ( ...$params ) {
			$ret = Title::makeTitle( ...$params );
			$ret->resetArticleID( 0 );
			return $ret;
		} );
		$this->setService( 'TitleFactory', $titleFactory );
		$formatter->setLinkRenderer( ( new LinkRendererFactory(
			$this->getServiceContainer()->getTitleFormatter(),
			$this->createMock( LinkCache::class ),
			$this->getServiceContainer()->getSpecialPageFactory(),
			$this->getServiceContainer()->getHookContainer()
		) )->create() );
		if ( $shouldMatch ) {
			$this->assertStringMatchesFormat(
				'%Aaction=protect%A', $formatter->getActionLinks() );
		} else {
			$this->assertStringNotMatchesFormat(
				'%Aaction=protect%A', $formatter->getActionLinks() );
		}
	}
}
PK       ! }U  U  !  logging/MergeLogFormatterTest.phpnu Iw        <?php

/**
 * @covers \MergeLogFormatter
 */
class MergeLogFormatterTest extends LogFormatterTestCase {

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function provideMergeLogDatabaseRows() {
		return [
			// Current format with a revid
			[
				[
					'type' => 'merge',
					'action' => 'merge',
					'comment' => 'Merge comment',
					'namespace' => NS_MAIN,
					'title' => 'OldPage',
					'params' => [
						'4::dest' => 'NewPage',
						'5::mergepoint' => '20140804160710',
						'6::mergerevid' => '1234'
					],
				],
				[
					'text' => 'User merged OldPage into NewPage (revisions up to 16:07, 4 August 2014)',
					'api' => [
						'mergerevid' => '1234',
						'dest_ns' => 0,
						'dest_title' => 'NewPage',
						'mergepoint' => '2014-08-04T16:07:10Z',
					],
				],
			],
			// Same format without a revid
			[
				[
					'type' => 'merge',
					'action' => 'merge',
					'comment' => 'Merge comment',
					'namespace' => NS_MAIN,
					'title' => 'OldPage',
					'params' => [
						'4::dest' => 'NewPage',
						'5::mergepoint' => '20140804160710',
					],
				],
				[
					'text' => 'User merged OldPage into NewPage (revisions up to 16:07, 4 August 2014)',
					'api' => [
						'dest_ns' => 0,
						'dest_title' => 'NewPage',
						'mergepoint' => '2014-08-04T16:07:10Z',
					],
				],
			],

			// Legacy format
			[
				[
					'type' => 'merge',
					'action' => 'merge',
					'comment' => 'merge comment',
					'namespace' => NS_MAIN,
					'title' => 'OldPage',
					'params' => [
						'NewPage',
						'20140804160710',
					],
				],
				[
					'legacy' => true,
					'text' => 'User merged OldPage into NewPage (revisions up to 16:07, 4 August 2014)',
					'api' => [
						'dest_ns' => 0,
						'dest_title' => 'NewPage',
						'mergepoint' => '2014-08-04T16:07:10Z',
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideMergeLogDatabaseRows
	 */
	public function testMergeLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}
}
PK       ! 4kW	  	  "  logging/PatrolLogFormatterTest.phpnu Iw        <?php

/**
 * @covers \PatrolLogFormatter
 */
class PatrolLogFormatterTest extends LogFormatterTestCase {

	/**
	 * Provide different rows from the logging table to test
	 * for backward compatibility.
	 * Do not change the existing data, just add a new database row
	 */
	public static function providePatrolLogDatabaseRows() {
		return [
			// Current format
			[
				[
					'type' => 'patrol',
					'action' => 'patrol',
					'comment' => 'patrol comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'4::curid' => 2,
						'5::previd' => 1,
						'6::auto' => 0,
					],
				],
				[
					'text' => 'User marked revision 2 of page Page patrolled',
					'api' => [
						'curid' => 2,
						'previd' => 1,
						'auto' => false,
					],
				],
			],

			// Current format - autopatrol
			[
				[
					'type' => 'patrol',
					'action' => 'patrol',
					'comment' => 'patrol comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'4::curid' => 2,
						'5::previd' => 1,
						'6::auto' => 1,
					],
				],
				[
					'text' => 'User automatically marked revision 2 of page Page patrolled',
					'api' => [
						'curid' => 2,
						'previd' => 1,
						'auto' => true,
					],
				],
			],

			// Legacy format
			[
				[
					'type' => 'patrol',
					'action' => 'patrol',
					'comment' => 'patrol comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'2',
						'1',
						'0',
					],
				],
				[
					'legacy' => true,
					'text' => 'User marked revision 2 of page Page patrolled',
					'api' => [
						'curid' => 2,
						'previd' => 1,
						'auto' => false,
					],
				],
			],

			// Legacy format - autopatrol
			[
				[
					'type' => 'patrol',
					'action' => 'patrol',
					'comment' => 'patrol comment',
					'namespace' => NS_MAIN,
					'title' => 'Page',
					'params' => [
						'2',
						'1',
						'1',
					],
				],
				[
					'legacy' => true,
					'text' => 'User automatically marked revision 2 of page Page patrolled',
					'api' => [
						'curid' => 2,
						'previd' => 1,
						'auto' => true,
					],
				],
			],
		];
	}

	/**
	 * @dataProvider providePatrolLogDatabaseRows
	 */
	public function testPatrolLogDatabaseRows( $row, $extra ) {
		$this->doTestLogFormatter( $row, $extra );
	}
}
PK       ! 7g	  	     logging/LogFormatterTestCase.phpnu Iw        <?php

use MediaWiki\Cache\GenderCache;
use MediaWiki\Cache\LinkCache;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Context\RequestContext;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\Page\ExistingPageRecord;
use MediaWiki\Page\PageStore;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\User\UserFactory;
use Wikimedia\IPUtils;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Stats\StatsFactory;
use Wikimedia\TestingAccessWrapper;

/**
 * @since 1.26
 */
abstract class LogFormatterTestCase extends MediaWikiLangTestCase {
	use MockAuthorityTrait;

	public function doTestLogFormatter( $row, $extra, $userGroups = [] ) {
		RequestContext::resetMain();
		$row = $this->expandDatabaseRow( $row, $this->isLegacy( $extra ) );

		$services = $this->getServiceContainer();
		$userGroups = (array)$userGroups;
		$userRights = $services->getGroupPermissionsLookup()->getGroupPermissions( $userGroups );
		$context = new RequestContext();
		$authority = $this->mockRegisteredAuthorityWithPermissions( $userRights );
		$context->setAuthority( $authority );
		$context->setLanguage( 'en' );

		$formatter = $services->getLogFormatterFactory()->newFromRow( $row );
		$formatter->setContext( $context );

		// Create a LinkRenderer without LinkCache to avoid DB access
		$realLinkRenderer = new LinkRenderer(
			$services->getTitleFormatter(),
			$this->createMock( LinkCache::class ),
			$services->getSpecialPageFactory(),
			$services->getHookContainer(),
			new ServiceOptions(
				LinkRenderer::CONSTRUCTOR_OPTIONS,
				$services->getMainConfig(),
				[ 'renderForComment' => false ]
			)
		);
		// Then create a mock LinkRenderer that proxies makeLink calls to the original LinkRenderer, but assumes
		// that all links are known to bypass DB access in Title::exists().
		$linkRenderer = $this->createMock( LinkRenderer::class );
		$linkRenderer->method( 'makeLink' )
			->willReturnCallback(
				static function ( $target, $text = null, $extra = [], $query = [] ) use ( $realLinkRenderer ) {
					return $realLinkRenderer->makeKnownLink( $target, $text, $extra, $query );
				}
			);
		$formatter->setLinkRenderer( $linkRenderer );
		$this->setService( 'LinkRenderer', $linkRenderer );

		// Create a mock PageStore where all pages are existing, in case any calls to Title::exists are not
		// caught by the mocks above.
		$pageStore = $this->getMockBuilder( PageStore::class )
			->onlyMethods( [ 'getPageByName' ] )
			->setConstructorArgs( [
				new ServiceOptions( PageStore::CONSTRUCTOR_OPTIONS, $services->getMainConfig() ),
				$this->createNoOpMock( ILoadBalancer::class ),
				$services->getNamespaceInfo(),
				$services->getTitleParser(),
				null,
				StatsFactory::newNull()
			] )
			->getMock();
		$pageStore->method( 'getPageByName' )
			->willReturn( $this->createMock( ExistingPageRecord::class ) );
		$this->setService( 'PageStore', $pageStore );

		// Create a mock UserFactory where all registered users are created with ID and name and where loading of
		// other fields is prevented, to avoid DB access.
		$origUserFactory = $services->getUserFactory();
		$userFactory = $this->createMock( UserFactory::class );
		$userFactory->method( 'newFromName' )
			->willReturnCallback( static function ( $name, $validation ) use ( $origUserFactory ) {
				$ret = $origUserFactory->newFromName( $name, $validation );
				if ( !$ret ) {
					return $ret;
				}
				$userID = IPUtils::isIPAddress( $name ) ? 0 : 42;
				$ret = TestingAccessWrapper::newFromObject( $ret );
				$ret->mId = $userID;
				$ret->mLoadedItems = true;
				return $ret->object;
			} );
		$userFactory->method( 'newFromId' )->willReturnCallback( [ $origUserFactory, 'newFromId' ] );
		$userFactory->method( 'newAnonymous' )->willReturnCallback( [ $origUserFactory, 'newAnonymous' ] );
		$userFactory->method( 'newFromUserIdentity' )
			->willReturnCallback( [ $origUserFactory, 'newFromUserIdentity' ] );
		$this->setService( 'UserFactory', $userFactory );

		// Replace gender cache to avoid gender DB lookups
		$genderCache = $this->createMock( GenderCache::class );
		$genderCache->method( 'getGenderOf' )->willReturn( 'unknown' );
		$this->setService( 'GenderCache', $genderCache );

		$this->assertEquals(
			$extra['text'],
			self::removeSomeHtml( $formatter->getActionText() ),
			'Action text is equal to expected text'
		);

		$this->assertSame( // ensure types and array key order
			$extra['api'],
			self::removeApiMetaData( $formatter->formatParametersForApi() ),
			'Api log params is equal to expected array'
		);

		if ( isset( $extra['preload'] ) ) {
			$this->assertArrayEquals(
				$this->getLinkTargetsAsStrings( $extra['preload'] ),
				$this->getLinkTargetsAsStrings(
					$formatter->getPreloadTitles()
				)
			);
		}
	}

	private function getLinkTargetsAsStrings( array $linkTargets ) {
		return array_map( static function ( LinkTarget $t ) {
			return $t->getInterwiki() . ':' . $t->getNamespace() . ':'
				. $t->getDBkey() . '#' . $t->getFragment();
		}, $linkTargets );
	}

	protected function isLegacy( $extra ) {
		return isset( $extra['legacy'] ) && $extra['legacy'];
	}

	protected function expandDatabaseRow( $data, $legacy ) {
		return [
			// no log_id because no insert in database
			'log_type' => $data['type'],
			'log_action' => $data['action'],
			'log_timestamp' => $data['timestamp'] ?? wfTimestampNow(),
			'log_user' => $data['user'] ?? 42,
			'log_user_text' => $data['user_text'] ?? 'User',
			'log_actor' => $data['actor'] ?? 24,
			'log_namespace' => $data['namespace'] ?? NS_MAIN,
			'log_title' => $data['title'] ?? 'Main_Page',
			'log_page' => $data['page'] ?? 0,
			'log_comment_text' => $data['comment'] ?? '',
			'log_comment_data' => null,
			'log_params' => $legacy
				? LogPage::makeParamBlob( $data['params'] )
				: LogEntryBase::makeParamBlob( $data['params'] ),
			'log_deleted' => $data['deleted'] ?? 0,
		];
	}

	protected static function removeSomeHtml( $html ) {
		$html = str_replace( '&quot;', '"', $html );
		$html = preg_replace( '/\xE2\x80[\x8E\x8F]/', '', $html ); // Strip lrm/rlm
		return trim( strip_tags( $html ) );
	}

	protected static function removeApiMetaData( $val ) {
		if ( is_array( $val ) ) {
			unset( $val['_element'] );
			unset( $val['_type'] );
			foreach ( $val as $key => $value ) {
				$val[$key] = self::removeApiMetaData( $value );
			}
		}
		return $val;
	}
}
PK       ! g2B?  B?    user/ActorMigrationTest.phpnu Iw        <?php

use MediaWiki\User\ActorMigration;
use MediaWiki\User\ActorMigrationBase;
use MediaWiki\User\ActorStore;
use MediaWiki\User\ActorStoreFactory;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\Rdbms\IMaintainableDatabase;

/**
 * @group Database
 * @covers \MediaWiki\User\ActorMigration
 * @covers \MediaWiki\User\ActorMigrationBase
 */
class ActorMigrationTest extends MediaWikiLangTestCase {

	/** @var int */
	protected static $amId = 0;

	private const STAGES_BY_NAME = [
		'old' => SCHEMA_COMPAT_WRITE_OLD | SCHEMA_COMPAT_READ_OLD,
		'read-old' => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_OLD,
		'read-new' => SCHEMA_COMPAT_WRITE_BOTH | SCHEMA_COMPAT_READ_NEW,
		'new' => SCHEMA_COMPAT_WRITE_NEW | SCHEMA_COMPAT_READ_NEW
	];

	protected function getSchemaOverrides( IMaintainableDatabase $db ) {
		return [
			'scripts' => [
				__DIR__ . '/ActorMigrationTest.sql',
			],
			'drop' => [],
			'create' => [ 'actormigration1', 'actormigration2' ],
			'alter' => [],
		];
	}

	private function getMigration( $stage, $actorStoreFactory = null ) {
		$mwServices = $this->getServiceContainer();
		return new ActorMigrationBase(
			[
				'am2_xxx' => [
					'textField' => 'am2_xxx_text',
					'actorField' => 'am2_xxx_actor'
				],
			],
			$stage,
			$actorStoreFactory ?? $mwServices->getActorStoreFactory()
		);
	}

	private static function makeActorCases( $inputs, $expected ) {
		foreach ( $expected as $inputName => $expectedCases ) {
			foreach ( $expectedCases as [ $stages, $expected ] ) {
				foreach ( $stages as $stage ) {
					$cases[$inputName . ', ' . $stage] = array_merge(
						[ $stage ],
						$inputs[$inputName],
						[ $expected ]
					);
				}
			}
		}
		return $cases;
	}

	/**
	 * @dataProvider provideConstructor
	 * @param int $stage
	 * @param string|null $exceptionMsg
	 */
	public function testConstructor( $stage, $exceptionMsg ) {
		try {
			$m = $this->getMigration( $stage );
			if ( $exceptionMsg !== null ) {
				$this->fail( 'Expected exception not thrown' );
			}
			$this->assertInstanceOf( ActorMigrationBase::class, $m );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( $exceptionMsg, $ex->getMessage() );
		}
	}

	public static function provideConstructor() {
		return [
			[ 0, '$stage must include a write mode' ],
			[ SCHEMA_COMPAT_READ_OLD, '$stage must include a write mode' ],
			[ SCHEMA_COMPAT_READ_NEW, '$stage must include a write mode' ],

			[ SCHEMA_COMPAT_WRITE_OLD, '$stage must include a read mode' ],
			[ SCHEMA_COMPAT_OLD, null ],
			[ SCHEMA_COMPAT_WRITE_OLD | SCHEMA_COMPAT_READ_BOTH, 'Cannot read multiple schemas' ],

			[ SCHEMA_COMPAT_WRITE_NEW, '$stage must include a read mode' ],
			[
				SCHEMA_COMPAT_WRITE_NEW | SCHEMA_COMPAT_READ_OLD,
				'Cannot read the old schema without also writing it'
			],
		];
	}

	/**
	 * @dataProvider provideGetJoin
	 * @param string $stageName
	 * @param string $key
	 * @param array $expect
	 */
	public function testGetJoin( $stageName, $key, $expect ) {
		$stage = self::STAGES_BY_NAME[$stageName];
		$m = $this->getMigration( $stage );
		$result = $m->getJoin( $key );
		$this->assertEquals( $expect, $result );
	}

	public static function provideGetJoin() {
		$inputs = [
			'Simple table' => [ 'am1_user' ],
			'Special name' => [ 'am2_xxx' ],
		];
		$expected = [
			'Simple table' => [
				[
					[ 'old', 'read-old' ],
					[
						'tables' => [],
						'fields' => [
							'am1_user' => 'am1_user',
							'am1_user_text' => 'am1_user_text',
							'am1_actor' => 'NULL',
						],
						'joins' => [],
					]
				],
				[
					[ 'read-new', 'new' ],
					[
						'tables' => [ 'actor_am1_user' => 'actor' ],
						'fields' => [
							'am1_user' => 'actor_am1_user.actor_user',
							'am1_user_text' => 'actor_am1_user.actor_name',
							'am1_actor' => 'am1_actor',
						],
						'joins' => [
							'actor_am1_user' => [ 'JOIN', 'actor_am1_user.actor_id = am1_actor' ],
						],
					],
				],
			],

			'Special name' => [
				[
					[ 'old', 'read-old' ],
					[
						'tables' => [],
						'fields' => [
							'am2_xxx' => 'am2_xxx',
							'am2_xxx_text' => 'am2_xxx_text',
							'am2_xxx_actor' => 'NULL',
						],
						'joins' => [],
					],
				],
				[
					[ 'read-new', 'new' ],
					[
						'tables' => [ 'actor_am2_xxx' => 'actor' ],
						'fields' => [
							'am2_xxx' => 'actor_am2_xxx.actor_user',
							'am2_xxx_text' => 'actor_am2_xxx.actor_name',
							'am2_xxx_actor' => 'am2_xxx_actor',
						],
						'joins' => [
							'actor_am2_xxx' => [ 'JOIN', 'actor_am2_xxx.actor_id = am2_xxx_actor' ],
						],
					],
				],
			],
		];

		return self::makeActorCases( $inputs, $expected );
	}

	private const ACTORS = [
		[ 1, 'User1', 11 ],
		[ 2, 'User2', 12 ],
		[ 0, '192.168.12.34', 34 ],
	];

	private static function findRow( $table, $index, $value ) {
		foreach ( $table as $row ) {
			if ( $row[$index] === $value ) {
				return $row;
			}
		}

		return null;
	}

	/**
	 * @return ActorStore
	 */
	private function getMockActorStore() {
		/** @var MockObject|ActorStore $mock */
		$mock = $this->createNoOpMock( ActorStore::class, [ 'findActorId' ] );

		$mock->method( 'findActorId' )
			->willReturnCallback( static function ( UserIdentity $user ) {
				$row = self::findRow( self::ACTORS, 1, $user->getName() );
				return $row ? $row[2] : null;
			} );

		return $mock;
	}

	/**
	 * @return ActorStoreFactory
	 */
	private function getMockActorStoreFactory() {
		$store = $this->getMockActorStore();

		/** @var MockObject|ActorStoreFactory $mock */
		$mock = $this->createNoOpMock( ActorStoreFactory::class, [ 'getActorNormalization' ] );

		$mock->method( 'getActorNormalization' )
			->willReturn( $store );

		return $mock;
	}

	/**
	 * @dataProvider provideGetWhere
	 * @param string $stageName
	 * @param string $key
	 * @param UserIdentity|UserIdentity[]|null|false $users
	 * @param bool $useId
	 * @param array $expect
	 */
	public function testGetWhere( $stageName, $key, $users, $useId, $expect ) {
		$stage = self::STAGES_BY_NAME[$stageName];
		if ( !isset( $expect['conds'] ) ) {
			$expect['conds'] = '(' . implode( ') OR (', $expect['orconds'] ) . ')';
		}

		$m = $this->getMigration( $stage, $this->getMockActorStoreFactory() );
		$result = $m->getWhere( $this->getDb(), $key, $users, $useId );
		$this->assertEquals( $expect, $result );
	}

	public static function provideGetWhere() {
		$genericUser = new UserIdentityValue( 1, 'User1' );
		$complicatedUsers = [
			new UserIdentityValue( 1, 'User1' ),
			new UserIdentityValue( 2, 'User2' ),
			new UserIdentityValue( 3, 'User3' ),
			new UserIdentityValue( 0, '192.168.12.34' ),
			new UserIdentityValue( 0, '192.168.12.35' ),
			// test handling of non-normalized IPv6 IP
			new UserIdentityValue( 0, '2600:1004:b14a:5ddd:3ebe:bba4:bfba:f37e' ),
		];

		$inputs = [
			'Simple table' => [ 'am1_user', $genericUser, true ],
			'Special name' => [ 'am2_xxx', $genericUser, true ],
			'Multiple users' => [ 'am1_user', $complicatedUsers, true ],
			'Multiple users, no use ID' => [ 'am1_user', $complicatedUsers, false ],
			'Empty $users' => [ 'am1_user', [], true ],
			'Null $users' => [ 'am1_user', null, true ],
			'False $users' => [ 'am1_user', false, true ],
		];

		$expected = [
			'Simple table' => [
				[
					[ 'old', 'read-old' ],
					[
						'tables' => [],
						'orconds' => [ 'userid' => "am1_user = 1" ],
						'joins' => [],
					]
				], [
					[ 'read-new', 'new' ],
					[
						'tables' => [],
						'orconds' => [ 'newactor' => "am1_actor = 11" ],
						'joins' => [],
					],
				],
			],

			'Special name' => [
				[
					[ 'old', 'read-old' ],
					[
						'tables' => [],
						'orconds' => [ 'userid' => "am2_xxx = 1" ],
						'joins' => [],
					],
				], [
					[ 'read-new', 'new' ],
					[
						'tables' => [],
						'orconds' => [ 'newactor' => "am2_xxx_actor = 11" ],
						'joins' => [],
					],
				],
			],

			'Multiple users' => [
				[
					[ 'old', 'read-old' ],
					[
						'tables' => [],
						'orconds' => [
							'userid' => "am1_user IN (1,2,3) ",
							'username' => "am1_user_text IN ('192.168.12.34','192.168.12.35',"
								. "'2600:1004:B14A:5DDD:3EBE:BBA4:BFBA:F37E') "
						],
						'joins' => [],
					]
				], [
					[ 'read-new', 'new' ],
					[
						'tables' => [],
						'orconds' => [ 'newactor' => "am1_actor IN (11,12,34) " ],
						'joins' => [],
					]
				]
			],

			'Multiple users, no use ID' => [
				[
					[ 'old', 'read-old' ],
					[
						'tables' => [],
						'orconds' => [
							'username' => "am1_user_text IN ('User1','User2','User3','192.168.12.34',"
								. "'192.168.12.35','2600:1004:B14A:5DDD:3EBE:BBA4:BFBA:F37E') "
						],
						'joins' => [],
					],
				], [
					[ 'read-new', 'new' ],
					[
						'tables' => [],
						'orconds' => [ 'newactor' => "am1_actor IN (11,12,34) " ],
						'joins' => [],
					]
				]
			],

			'Empty $users' => [ [
				[ 'old', 'read-old', 'read-new', 'new' ],
				[
					'tables' => [],
					'conds' => '1=0',
					'orconds' => [],
					'joins' => [],
				],
			] ],

			'Null $users' => [ [
				[ 'old', 'read-old', 'read-new', 'new' ],
				[
					'tables' => [],
					'conds' => '1=0',
					'orconds' => [],
					'joins' => [],
				],
			] ],

			'False $users' => [ [
				[ 'old', 'read-old', 'read-new', 'new' ],
				[
					'tables' => [],
					'conds' => '1=0',
					'orconds' => [],
					'joins' => [],
				],
			] ],
		];

		return self::makeActorCases( $inputs, $expected );
	}

	/**
	 * @dataProvider provideStages
	 */
	public function testGetWhere_exception( $stage ) {
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage(
			'ActorMigrationBase::getWhere: Value for $users must be a UserIdentity or array, got string'
		);

		$m = $this->getMigration( $stage );
		$m->getWhere( $this->getDb(), 'am1_user', 'Foo' );
	}

	/**
	 * @dataProvider provideInsertRoundTrip
	 * @param string $table
	 * @param string $key
	 * @param string $pk
	 */
	public function testInsertRoundTrip( $table, $key, $pk ) {
		$u = $this->getTestUser()->getUser();
		$user = new UserIdentityValue( $u->getId(), $u->getName() );

		$stageNames = array_flip( self::STAGES_BY_NAME );

		$stages = [
			'old' => [
				'old',
				'read-old',
			],
			'read-old' => [
				'old',
				'read-old',
			],
			'read-new' => [
				'read-new',
				'new'
			],
			'new' => [
				'read-new',
				'new'
			],
		];

		$nameKey = $key . '_text';
		$actorKey = ( $key === 'am2_xxx' ? $key : substr( $key, 0, -5 ) ) . '_actor';

		foreach ( $stages as $writeStageName => $possibleReadStages ) {
			$writeStage = self::STAGES_BY_NAME[$writeStageName];
			$w = $this->getMigration( $writeStage );

			$fields = $w->getInsertValues( $this->getDb(), $key, $user );

			if ( $writeStage & SCHEMA_COMPAT_WRITE_OLD ) {
				$this->assertSame( $user->getId(), $fields[$key],
					"old field, stage={$stageNames[$writeStage]}" );
				$this->assertSame( $user->getName(), $fields[$nameKey],
					"old field, stage={$stageNames[$writeStage]}" );
			} else {
				$this->assertArrayNotHasKey( $key, $fields, "old field, stage={$stageNames[$writeStage]}" );
				$this->assertArrayNotHasKey( $nameKey, $fields, "old field, stage={$stageNames[$writeStage]}" );
			}
			if ( $writeStage & SCHEMA_COMPAT_WRITE_NEW ) {
				$this->assertArrayHasKey( $actorKey, $fields,
					"new field, stage={$stageNames[$writeStage]}" );
			} else {
				$this->assertArrayNotHasKey( $actorKey, $fields,
					"new field, stage={$stageNames[$writeStage]}" );
			}

			$id = ++self::$amId;
			$this->getDb()->newInsertQueryBuilder()
				->insertInto( $table )
				->row( [ $pk => $id ] + $fields )
				->caller( __METHOD__ )
				->execute();

			foreach ( $possibleReadStages as $readStageName ) {
				$readStage = self::STAGES_BY_NAME[$readStageName];
				$r = $this->getMigration( $readStage );

				$queryInfo = $r->getJoin( $key );
				$row = $this->getDb()->newSelectQueryBuilder()
					->queryInfo( $queryInfo )
					->from( $table )
					->where( [ $pk => $id ] )
					->caller( __METHOD__ )
					->fetchRow();

				$this->assertSame( $user->getId(), (int)$row->$key,
					"w={$stageNames[$writeStage]}, r={$stageNames[$readStage]}, id" );
				$this->assertSame( $user->getName(), $row->$nameKey,
					"w={$stageNames[$writeStage]}, r={$stageNames[$readStage]}, name" );
			}
		}
	}

	public static function provideInsertRoundTrip() {
		return [
			'normal' => [ 'actormigration1', 'am1_user', 'am1_id' ],
			'special name' => [ 'actormigration2', 'am2_xxx', 'am2_id' ],
		];
	}

	public static function provideStages() {
		$cases = [];
		foreach ( self::STAGES_BY_NAME as $name => $stage ) {
			$cases[$name] = [ $stage ];
		}
		return $cases;
	}

	/**
	 * @dataProvider provideStages
	 * @param int $stage
	 */
	public function testInsertUserIdentity( $stage ) {
		$user = $this->getMutableTestUser()->getUser();
		$userIdentity = new UserIdentityValue( $user->getId(), $user->getName() );

		$m = $this->getMigration( $stage );
		$fields = $m->getInsertValues( $this->getDb(), 'am1_user', $userIdentity );
		$id = ++self::$amId;
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'actormigration1' )
			->row( [ 'am1_id' => $id ] + $fields )
			->caller( __METHOD__ )
			->execute();

		$qi = $m->getJoin( 'am1_user' );
		$row = $this->getDb()->newSelectQueryBuilder()
			->queryInfo( $qi )
			->from( 'actormigration1' )
			->where( [ 'am1_id' => $id ] )
			->caller( __METHOD__ )
			->fetchRow();
		$this->assertSame( $user->getId(), (int)$row->am1_user );
		$this->assertSame( $user->getName(), $row->am1_user_text );
		$this->assertSame(
			( $stage & SCHEMA_COMPAT_READ_NEW ) ? $user->getActorId() : 0,
			(int)$row->am1_actor
		);

		$m = $this->getMigration( $stage );
		$fields = $m->getInsertValues( $this->getDb(), 'dummy_user', $userIdentity );
		if ( $stage & SCHEMA_COMPAT_WRITE_OLD ) {
			$this->assertSame( $user->getId(), $fields['dummy_user'] );
			$this->assertSame( $user->getName(), $fields['dummy_user_text'] );
		} else {
			$this->assertArrayNotHasKey( 'dummy_user', $fields );
			$this->assertArrayNotHasKey( 'dummy_user_text', $fields );
		}
		if ( $stage & SCHEMA_COMPAT_WRITE_NEW ) {
			$this->assertSame( $user->getActorId(), $fields['dummy_actor'] );
		} else {
			$this->assertArrayNotHasKey( 'dummy_actor', $fields );
		}
	}

	public function testNewMigration() {
		$m = ActorMigration::newMigration();
		$this->assertInstanceOf( ActorMigration::class, $m );
		$this->assertSame( $m, ActorMigration::newMigration() );
	}

	/**
	 * @dataProvider provideIsAnon
	 * @param string $stage
	 * @param string $isAnon
	 * @param string $isNotAnon
	 */
	public function testIsAnon( $stage, $isAnon, $isNotAnon ) {
		$numericStage = self::STAGES_BY_NAME[$stage];
		$m = $this->getMigration( $numericStage );
		$this->assertSame( $isAnon, $m->isAnon( 'foo' ) );
		$this->assertSame( $isNotAnon, $m->isNotAnon( 'foo' ) );
	}

	public static function provideIsAnon() {
		return [
			'old' => [ 'old', 'foo = 0', 'foo != 0' ],
			'read-old' => [ 'read-old', 'foo = 0', 'foo != 0' ],
			'read-new' => [ 'read-new', 'foo IS NULL', 'foo IS NOT NULL' ],
			'new' => [ 'new', 'foo IS NULL', 'foo IS NOT NULL' ],
		];
	}

	public function testCheckDeprecation() {
		$m = new class(
			[
				'soft' => [
					'deprecatedVersion' => null,
				],
				'hard' => [
					'deprecatedVersion' => '1.34',
				],
				'gone' => [
					'removedVersion' => '1.34',
				],
			],
			SCHEMA_COMPAT_NEW,
			$this->getServiceContainer()->getActorStoreFactory()
		) extends ActorMigrationBase {
			public function checkDeprecationForTest( $key ) {
				$this->checkDeprecation( $key );
			}
		};

		$this->hideDeprecated( 'MediaWiki\User\ActorMigrationBase for \'hard\'' );

		$m->checkDeprecationForTest( 'valid' );
		$m->checkDeprecationForTest( 'soft' );
		$m->checkDeprecationForTest( 'hard' );
		try {
			$m->checkDeprecationForTest( 'gone' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Use of MediaWiki\User\ActorMigrationBase for \'gone\' was removed in MediaWiki 1.34',
				$ex->getMessage()
			);
		}
	}

}
PK       ! 2۳5\  \    user/ActorMigrationTest.sqlnu Iw        -- These are carefully crafted to work in all three supported databases

CREATE TABLE /*_*/actormigration1 (
  am1_id integer not null,
  am1_user integer,
  am1_user_text varchar(200),
  am1_actor integer
);

CREATE TABLE /*_*/actormigration2 (
  am2_id integer not null,
  am2_xxx integer,
  am2_xxx_text varchar(200),
  am2_xxx_actor integer
);
PK       ! Z1"  "  (  user/TalkPageNotificationManagerTest.phpnu Iw        <?php

use MediaWiki\Config\HashConfig;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\TalkPageNotificationManager;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\Utils\MWTimestamp;
use PHPUnit\Framework\AssertionFailedError;

/**
 * @covers \MediaWiki\User\TalkPageNotificationManager
 * @group Database
 */
class TalkPageNotificationManagerTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;

	private function editUserTalk( UserIdentity $user, string $text ): RevisionRecord {
		// UserIdentity doesn't have getUserPage/getTalkPage, but we can easily recreate
		// it, and its easier than needing to depend on a full user object
		$userTalk = Title::makeTitle( NS_USER_TALK, $user->getName() );
		$status = $this->editPage(
			$userTalk,
			$text,
			'',
			NS_MAIN,
			$this->getTestSysop()->getUser()
		);
		$this->assertStatusGood( $status, 'create revision of user talk' );
		return $status->getNewRevision();
	}

	private function getManager(
		bool $disableAnonTalk = false,
		bool $isReadOnly = false,
		?RevisionLookup $revisionLookup = null
	) {
		$services = $this->getServiceContainer();
		return new TalkPageNotificationManager(
			new ServiceOptions(
				TalkPageNotificationManager::CONSTRUCTOR_OPTIONS,
				new HashConfig( [
					MainConfigNames::DisableAnonTalk => $disableAnonTalk
				] )
			),
			$services->getConnectionProvider(),
			$this->getDummyReadOnlyMode( $isReadOnly ),
			$revisionLookup ?? $services->getRevisionLookup(),
			$this->createHookContainer(),
			$services->getUserFactory()
		);
	}

	public static function provideUserHasNewMessages() {
		yield 'Registered user' => [ UserIdentityValue::newRegistered( 123, 'MyName' ) ];
		yield 'Anonymous user' => [ UserIdentityValue::newAnonymous( '1.2.3.4' ) ];
	}

	/**
	 * @dataProvider provideUserHasNewMessages
	 * @covers \MediaWiki\User\TalkPageNotificationManager::userHasNewMessages
	 * @covers \MediaWiki\User\TalkPageNotificationManager::setUserHasNewMessages
	 * @covers \MediaWiki\User\TalkPageNotificationManager::clearInstanceCache
	 * @covers \MediaWiki\User\TalkPageNotificationManager::removeUserHasNewMessages
	 */
	public function testUserHasNewMessages( UserIdentity $user ) {
		$manager = $this->getManager();
		$this->assertFalse( $manager->userHasNewMessages( $user ),
			'Should be false before updated' );
		$revRecord = $this->editUserTalk( $user, __METHOD__ );
		$manager->setUserHasNewMessages( $user, $revRecord );
		$this->assertTrue( $manager->userHasNewMessages( $user ),
			'Should be true after updated' );
		$manager->clearInstanceCache( $user );
		$this->assertTrue( $manager->userHasNewMessages( $user ),
			'Should be true after cache cleared' );
		$manager->removeUserHasNewMessages( $user );
		$this->assertFalse( $manager->userHasNewMessages( $user ),
			'Should be false after updated' );
		$manager->clearInstanceCache( $user );
		$this->assertFalse( $manager->userHasNewMessages( $user ),
			'Should be false after cache cleared' );
		$manager->setUserHasNewMessages( $user, null );
		$this->assertTrue( $manager->userHasNewMessages( $user ),
			'Should be true after updated' );
		$manager->removeUserHasNewMessages( $user );
		$this->assertFalse( $manager->userHasNewMessages( $user ),
			'Should be false after updated' );
	}

	/**
	 * @covers \MediaWiki\User\TalkPageNotificationManager::userHasNewMessages
	 * @covers \MediaWiki\User\TalkPageNotificationManager::setUserHasNewMessages
	 */
	public function testUserHasNewMessagesDisabledAnon() {
		$user = new UserIdentityValue( 0, '1.2.3.4' );
		$revRecord = $this->editUserTalk( $user, __METHOD__ );
		$manager = $this->getManager( true );
		$this->assertFalse( $manager->userHasNewMessages( $user ),
			'New anon should have no new messages' );
		$manager->setUserHasNewMessages( $user, $revRecord );
		$this->assertFalse( $manager->userHasNewMessages( $user ),
			'Must not set new messages for anon if disabled' );
		$manager->clearInstanceCache( $user );
		$this->assertFalse( $manager->userHasNewMessages( $user ),
			'Must not set to database if anon messages disabled' );
	}

	/**
	 * @covers \MediaWiki\User\TalkPageNotificationManager::getLatestSeenMessageTimestamp
	 */
	public function testGetLatestSeenMessageTimestamp() {
		$user = $this->getTestUser()->getUser();
		$firstRev = $this->editUserTalk( $user, __METHOD__ . ' 1' );
		$secondRev = $this->editUserTalk( $user, __METHOD__ . ' 2' );
		$manager = $this->getManager();
		$manager->setUserHasNewMessages( $user, $secondRev );
		$this->assertSame( $firstRev->getTimestamp(), $manager->getLatestSeenMessageTimestamp( $user ) );
	}

	/**
	 * @covers \MediaWiki\User\TalkPageNotificationManager::getLatestSeenMessageTimestamp
	 */
	public function testGetLatestSeenMessageTimestampOutOfOrderRevision() {
		$user = $this->getTestUser()->getUser();
		$firstRev = $this->editUserTalk( $user, __METHOD__ . ' 1' );
		$secondRev = $this->editUserTalk( $user, __METHOD__ . ' 2' );
		$thirdRev = $this->editUserTalk( $user, __METHOD__ . ' 3' );
		$veryOldTimestamp = MWTimestamp::convert( TS_MW, 1 );
		$mockOldRev = $this->createMock( RevisionRecord::class );
		$mockOldRev->method( 'getTimestamp' )
			->willReturn( $veryOldTimestamp );
		$mockRevLookup = $this->getMockForAbstractClass( RevisionLookup::class );
		$mockRevLookup->method( 'getPreviousRevision' )
			->willReturnCallback( static function ( RevisionRecord $rev )
				use ( $firstRev, $secondRev, $thirdRev, $mockOldRev )
			{
				if ( $rev === $secondRev ) {
					return $firstRev;
				}
				if ( $rev === $thirdRev ) {
					return $mockOldRev;
				}
				throw new AssertionFailedError(
					'RevisionLookup::getPreviousRevision called with wrong rev ' . $rev->getId()
				);
			} );
		$manager = $this->getManager( false, false, $mockRevLookup );
		$manager->setUserHasNewMessages( $user, $thirdRev );
		$this->assertSame( $veryOldTimestamp, $manager->getLatestSeenMessageTimestamp( $user ) );
		$manager->setUserHasNewMessages( $user, $secondRev );
		$this->assertSame( $veryOldTimestamp, $manager->getLatestSeenMessageTimestamp( $user ) );
	}

	/**
	 * @covers \MediaWiki\User\TalkPageNotificationManager::getLatestSeenMessageTimestamp
	 */
	public function testGetLatestSeenMessageTimestampNoNewMessages() {
		$user = $this->getTestUser()->getUser();
		$manager = $this->getManager();
		$this->assertNull( $manager->getLatestSeenMessageTimestamp( $user ),
			'Must be null if no new messages' );
	}

	/**
	 * @covers \MediaWiki\User\TalkPageNotificationManager::userHasNewMessages
	 * @covers \MediaWiki\User\TalkPageNotificationManager::setUserHasNewMessages
	 * @covers \MediaWiki\User\TalkPageNotificationManager::removeUserHasNewMessages
	 */
	public function testDoesNotCrashOnReadOnly() {
		$user = $this->getTestUser()->getUser();
		$this->editUserTalk( $user, __METHOD__ );

		$manager = $this->getManager( false, true );
		$this->assertTrue( $manager->userHasNewMessages( $user ) );
		$manager->removeUserHasNewMessages( $user );
		$this->assertFalse( $manager->userHasNewMessages( $user ) );
	}

	/**
	 * @covers \MediaWiki\User\TalkPageNotificationManager::clearForPageView
	 */
	public function testClearForPageView() {
		$user = $this->getTestUser()->getUser();
		$title = $user->getTalkPage();
		$revision = new MutableRevisionRecord( $title );
		$revision->setPageId( 100 );
		$revision->setId( 101 );
		$manager = $this->getManager();
		$manager->setUserHasNewMessages( $user );
		$this->assertTrue( $manager->userHasNewMessages( $user ) );

		// DB should have the notification
		$this->newSelectQueryBuilder()
			->select( 'user_id' )
			->from( 'user_newtalk' )
			->where( [ 'user_id' => $user->getId() ] )
			->assertFieldValue( $user->getId() );

		$this->getDb()->startAtomic( __METHOD__ ); // let deferred updates queue up

		$updateCountBefore = DeferredUpdates::pendingUpdatesCount();
		$manager->clearForPageView( $user, $revision );
		// Cache should already be updated
		$this->assertFalse( $manager->userHasNewMessages( $user ) );

		$updateCountAfter = DeferredUpdates::pendingUpdatesCount();
		$this->assertGreaterThan( $updateCountBefore, $updateCountAfter, 'An update should have been queued' );

		$this->getDb()->endAtomic( __METHOD__ ); // run deferred updates
		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount(), 'No pending updates' );

		// Notification should have been deleted from the DB
		$this->newSelectQueryBuilder()
			->select( 'user_id' )
			->from( 'user_newtalk' )
			->where( [ 'user_id' => $user->getId() ] )
			->assertEmptyResult();
	}

}
PK       ! gI  I     user/UserGroupMembershipTest.phpnu Iw        <?php

use MediaWiki\User\UserGroupMembership;

class UserGroupMembershipTest extends MediaWikiIntegrationTestCase {

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

		$this->setGroupPermissions(
			[
				'unittesters' => [
					'runtest' => true,
				],
				'testwriters' => [
					'writetest' => true,
				]
			]
		);
	}

	public static function provideInstantiationValidation() {
		return [
			[ 1, null, null, 1, null, null ],
			[ 1, 'test', null, 1, 'test', null ],
			[ 1, 'test', '12345', 1, 'test', '12345' ]
		];
	}

	/**
	 * @dataProvider provideInstantiationValidation
	 * @covers \MediaWiki\User\UserGroupMembership
	 */
	public function testInstantiation( $userId, $group, $expiry, $userId_, $group_, $expiry_ ) {
		$ugm = new UserGroupMembership( $userId, $group, $expiry );
		$this->assertSame(
			$userId_,
			$ugm->getUserId()
		);
		$this->assertSame(
			$group_,
			$ugm->getGroup()
		);
		$this->assertSame(
			$expiry_,
			$ugm->getExpiry()
		);
	}

	/**
	 * @covers \MediaWiki\User\UserGroupMembership::equals
	 */
	public function testComparison() {
		$ugm1 = new UserGroupMembership( 1, 'test', '67890' );
		$ugm2 = new UserGroupMembership( 1, 'test', '67890' );
		$ugm3 = new UserGroupMembership( 1, 'fail', '67890' );
		$ugm4 = new UserGroupMembership( 1, 'fail', '12345' );
		$this->assertTrue( $ugm1->equals( $ugm2 ) );
		$this->assertTrue( $ugm2->equals( $ugm1 ) );
		$this->assertFalse( $ugm1->equals( $ugm3 ) );
		$this->assertFalse( $ugm2->equals( $ugm3 ) );
		$this->assertFalse( $ugm3->equals( $ugm1 ) );
		// Ensure expiry is ignored
		$this->assertTrue( $ugm3->equals( $ugm4 ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupMembership::isExpired
	 */
	public function testIsExpired() {
		$ts = wfTimestamp( TS_MW, time() - 100 );
		$ugm = new UserGroupMembership( 1, null, $ts );
		$this->assertTrue(
			$ugm->isExpired()
		);
		$ts = wfTimestamp( TS_MW, time() + 100 );
		$ugm = new UserGroupMembership( 1, null, $ts );
		$this->assertFalse(
			$ugm->isExpired()
		);
		$ugm = new UserGroupMembership( 1, null, null );
		$this->assertFalse(
			$ugm->isExpired()
		);
	}

}
PK       ! lķ  ķ    user/UserGroupManagerTest.phpnu Iw        <?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
 */

namespace MediaWiki\Tests\User;

use InvalidArgumentException;
use LogEntryBase;
use LogicException;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Config\SiteConfiguration;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\SimpleAuthority;
use MediaWiki\Request\WebRequest;
use MediaWiki\Session\PHPSessionHandler;
use MediaWiki\Session\SessionManager;
use MediaWiki\User\TempUser\RealTempUserConfig;
use MediaWiki\User\User;
use MediaWiki\User\UserEditTracker;
use MediaWiki\User\UserGroupManager;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Rule\InvokedCount;
use TestLogger;
use Wikimedia\Assert\PreconditionException;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * @covers \MediaWiki\User\UserGroupManager
 * @group Database
 */
class UserGroupManagerTest extends MediaWikiIntegrationTestCase {

	private const GROUP = 'user_group_manager_test_group';

	/** @var string */
	private $expiryTime;

	/**
	 * @param array $configOverrides
	 * @param UserEditTracker|null $userEditTrackerOverride
	 * @param callable|null $callback
	 * @return UserGroupManager
	 */
	private function getManager(
		array $configOverrides = [],
		?UserEditTracker $userEditTrackerOverride = null,
		?callable $callback = null
	): UserGroupManager {
		$services = $this->getServiceContainer();
		return new UserGroupManager(
			new ServiceOptions(
				UserGroupManager::CONSTRUCTOR_OPTIONS,
				$configOverrides,
				[
					MainConfigNames::AddGroups => [],
					MainConfigNames::AutoConfirmAge => 0,
					MainConfigNames::AutoConfirmCount => 0,
					MainConfigNames::Autopromote => [
						'autoconfirmed' => [ APCOND_EDITCOUNT, 0 ]
					],
					MainConfigNames::AutopromoteOnce => [],
					MainConfigNames::GroupPermissions => [
						self::GROUP => [
							'runtest' => true,
						]
					],
					MainConfigNames::GroupsAddToSelf => [],
					MainConfigNames::GroupsRemoveFromSelf => [],
					MainConfigNames::ImplicitGroups => [ '*', 'user', 'autoconfirmed' ],
					MainConfigNames::RemoveGroups => [],
					MainConfigNames::RevokePermissions => [],
				],
				$services->getMainConfig()
			),
			$services->getReadOnlyMode(),
			$services->getDBLoadBalancerFactory(),
			$services->getHookContainer(),
			$userEditTrackerOverride ?? $services->getUserEditTracker(),
			$services->getGroupPermissionsLookup(),
			$services->getJobQueueGroup(),
			new TestLogger(),
			new RealTempUserConfig( [
				'enabled' => true,
				'expireAfterDays' => null,
				'actions' => [ 'edit' ],
				'serialProvider' => [ 'type' => 'local' ],
				'serialMapping' => [ 'type' => 'plain-numeric' ],
				'matchPattern' => '*Unregistered $1',
				'genPattern' => '*Unregistered $1'
			] ),
			$callback ? [ $callback ] : []
		);
	}

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

		$this->expiryTime = wfTimestamp( TS_MW, time() + 100500 );
		$this->clearHooks();
	}

	/**
	 * Returns a callable that must be called exactly $invokedCount times.
	 * @param InvokedCount $invokedCount
	 * @return callable|MockObject
	 */
	private function countPromise( $invokedCount ) {
		$mockHandler = $this->getMockBuilder( \stdClass::class )
			->addMethods( [ '__invoke' ] )
			->getMock();
		$mockHandler->expects( $invokedCount )
			->method( '__invoke' );
		return $mockHandler;
	}

	/**
	 * @param UserGroupManager $manager
	 * @param UserIdentity $user
	 * @param string $group
	 * @param string|null $expiry
	 */
	private function assertMembership(
		UserGroupManager $manager,
		UserIdentity $user,
		string $group,
		?string $expiry = null
	) {
		$this->assertContains( $group, $manager->getUserGroups( $user ) );
		$memberships = $manager->getUserGroupMemberships( $user );
		$this->assertArrayHasKey( $group, $memberships );
		$membership = $memberships[$group];
		$this->assertSame( $group, $membership->getGroup() );
		$this->assertSame( $user->getId(), $membership->getUserId() );
		$this->assertSame( $expiry, $membership->getExpiry() );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::newGroupMembershipFromRow
	 */
	public function testNewGroupMembershipFromRow() {
		$row = new \stdClass();
		$row->ug_user = '1';
		$row->ug_group = __METHOD__;
		$row->ug_expiry = null;
		$membership = $this->getManager()->newGroupMembershipFromRow( $row );
		$this->assertSame( 1, $membership->getUserId() );
		$this->assertSame( __METHOD__, $membership->getGroup() );
		$this->assertNull( $membership->getExpiry() );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::newGroupMembershipFromRow
	 */
	public function testNewGroupMembershipFromRowExpiring() {
		$row = new \stdClass();
		$row->ug_user = '1';
		$row->ug_group = __METHOD__;
		$row->ug_expiry = $this->expiryTime;
		$membership = $this->getManager()->newGroupMembershipFromRow( $row );
		$this->assertSame( 1, $membership->getUserId() );
		$this->assertSame( __METHOD__, $membership->getGroup() );
		$this->assertSame( $this->expiryTime, $membership->getExpiry() );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::getUserImplicitGroups
	 */
	public function testGetImplicitGroups() {
		$manager = $this->getManager();
		$user = $this->getTestUser( 'unittesters' )->getUser();
		$this->assertArrayEquals(
			[ '*', 'user', 'autoconfirmed' ],
			$manager->getUserImplicitGroups( $user )
		);

		$user = $this->getTestUser( [ 'bureaucrat', 'test' ] )->getUser();
		$this->assertArrayEquals(
			[ '*', 'user', 'autoconfirmed' ],
			$manager->getUserImplicitGroups( $user )
		);

		$this->assertTrue(
			$manager->addUserToGroup( $user, self::GROUP ),
			'added user to group'
		);
		$this->assertArrayEquals(
			[ '*', 'user', 'autoconfirmed' ],
			$manager->getUserImplicitGroups( $user )
		);

		$user = User::newFromName( 'UTUser1' );
		$this->assertSame( [ '*' ], $manager->getUserImplicitGroups( $user ) );

		$manager = $this->getManager( [ MainConfigNames::Autopromote => [
			'dummy' => APCOND_EMAILCONFIRMED
		] ] );
		$user = $this->getTestUser()->getUser();
		$this->assertArrayEquals(
			[ '*', 'user' ],
			$manager->getUserImplicitGroups( $user )
		);
		$this->assertArrayEquals(
			[ '*', 'user' ],
			$manager->getUserEffectiveGroups( $user )
		);
		$user->confirmEmail();
		$this->assertArrayEquals(
			[ '*', 'user', 'dummy' ],
			$manager->getUserImplicitGroups( $user, IDBAccessObject::READ_NORMAL, true )
		);
		$this->assertArrayEquals(
			[ '*', 'user', 'dummy' ],
			$manager->getUserEffectiveGroups( $user )
		);

		$user = $this->getTestUser( [ 'dummy' ] )->getUser();
		$user->confirmEmail();
		$this->assertArrayEquals(
			[ '*', 'user', 'dummy' ],
			$manager->getUserImplicitGroups( $user )
		);

		$user = new User;
		$user->setName( '*Unregistered 1234' );
		$this->assertArrayEquals(
			[ '*', 'temp' ],
			$manager->getUserImplicitGroups( $user )
		);
	}

	public static function provideGetEffectiveGroups() {
		yield [ [], [ '*', 'user', 'autoconfirmed' ] ];
		yield [ [ 'bureaucrat', 'test' ], [ '*', 'user', 'autoconfirmed', 'bureaucrat', 'test' ] ];
		yield [ [ 'autoconfirmed', 'test' ], [ '*', 'user', 'autoconfirmed', 'test' ] ];
	}

	/**
	 * @dataProvider provideGetEffectiveGroups
	 * @covers \MediaWiki\User\UserGroupManager::getUserEffectiveGroups
	 */
	public function testGetEffectiveGroups( $userGroups, $effectiveGroups ) {
		$manager = $this->getManager();
		$user = $this->getTestUser( $userGroups )->getUser();
		$this->assertArrayEquals( $effectiveGroups, $manager->getUserEffectiveGroups( $user ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::getUserEffectiveGroups
	 */
	public function testGetEffectiveGroupsHook() {
		$manager = $this->getManager();
		$user = $this->getTestUser()->getUser();
		$this->setTemporaryHook(
			'UserEffectiveGroups',
			function ( UserIdentity $hookUser, array &$groups ) use ( $user ) {
				$this->assertTrue( $hookUser->equals( $user ) );
				$groups[] = 'from_hook';
			}
		);
		$this->assertContains( 'from_hook', $manager->getUserEffectiveGroups( $user ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::addUserToGroup
	 * @covers \MediaWiki\User\UserGroupManager::getUserGroups
	 * @covers \MediaWiki\User\UserGroupManager::getUserGroupMemberships
	 */
	public function testAddUserToGroup() {
		$manager = $this->getManager();
		$user = $this->getMutableTestUser()->getUser();

		$result = $manager->addUserToGroup( $user, self::GROUP );
		$this->assertTrue( $result );
		$this->assertMembership( $manager, $user, self::GROUP );
		$manager->clearCache( $user );
		$this->assertMembership( $manager, $user, self::GROUP );

		// try updating without allowUpdate. Should fail
		$result = $manager->addUserToGroup( $user, self::GROUP, $this->expiryTime );
		$this->assertFalse( $result );

		// now try updating with allowUpdate
		$result = $manager->addUserToGroup( $user, self::GROUP, $this->expiryTime, true );
		$this->assertTrue( $result );
		$this->assertMembership( $manager, $user, self::GROUP, $this->expiryTime );
		$manager->clearCache( $user );
		$this->assertMembership( $manager, $user, self::GROUP, $this->expiryTime );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::addUserToGroup
	 */
	public function testAddUserToGroupReadonly() {
		$user = $this->getTestUser()->getUser();
		$this->getServiceContainer()->getReadOnlyMode()->setReason( 'TEST' );
		$manager = $this->getManager();
		$this->assertFalse( $manager->addUserToGroup( $user, 'test' ) );
		$this->assertNotContains( 'test', $manager->getUserGroups( $user ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::addUserToGroup
	 */
	public function testAddUserToGroupAnon() {
		$manager = $this->getManager();
		$anon = new UserIdentityValue( 0, 'Anon' );
		$this->expectException( InvalidArgumentException::class );
		$manager->addUserToGroup( $anon, 'test' );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::addUserToGroup
	 */
	public function testAddUserToGroupHookAbort() {
		$manager = $this->getManager();
		$user = $this->getTestUser()->getUser();
		$originalGroups = $manager->getUserGroups( $user );
		$this->setTemporaryHook(
			'UserAddGroup',
			function ( UserIdentity $hookUser ) use ( $user ) {
				$this->assertTrue( $hookUser->equals( $user ) );
				return false;
			}
		);
		$this->assertFalse( $manager->addUserToGroup( $user, 'test_group' ) );
		$this->assertArrayEquals( $originalGroups, $manager->getUserGroups( $user ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::addUserToGroup
	 */
	public function testAddUserToGroupHookModify() {
		$manager = $this->getManager();
		$user = $this->getTestUser()->getUser();
		$this->setTemporaryHook(
			'UserAddGroup',
			function ( UserIdentity $hookUser, &$group, &$hookExp ) use ( $user ) {
				$this->assertTrue( $hookUser->equals( $user ) );
				$this->assertSame( self::GROUP, $group );
				$this->assertSame( $this->expiryTime, $hookExp );
				$group = 'from_hook';
				$hookExp = null;
				return true;
			}
		);
		$this->assertTrue( $manager->addUserToGroup( $user, self::GROUP, $this->expiryTime ) );
		$this->assertContains( 'from_hook', $manager->getUserGroups( $user ) );
		$this->assertNotContains( self::GROUP, $manager->getUserGroups( $user ) );
		$this->assertNull( $manager->getUserGroupMemberships( $user )['from_hook']->getExpiry() );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::addUserToMultipleGroups
	 */
	public function testAddUserToMultipleGroups() {
		$manager = $this->getManager();
		$user = $this->getMutableTestUser()->getUser();

		$manager->addUserToMultipleGroups( $user, [ self::GROUP, self::GROUP . '1' ] );
		$this->assertMembership( $manager, $user, self::GROUP );
		$this->assertMembership( $manager, $user, self::GROUP . '1' );

		$anon = new UserIdentityValue( 0, 'Anon' );
		$this->expectException( InvalidArgumentException::class );
		$manager->addUserToMultipleGroups( $anon, [ self::GROUP, self::GROUP . '1' ] );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::getUserGroupMemberships
	 */
	public function testGetUserGroupMembershipsForAnon() {
		$manager = $this->getManager();
		$anon = new UserIdentityValue( 0, 'Anon' );

		$this->assertSame( [], $manager->getUserGroupMemberships( $anon ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::getUserFormerGroups
	 */
	public function testGetUserFormerGroupsForAnon() {
		$manager = $this->getManager();
		$anon = new UserIdentityValue( 0, 'Anon' );

		$this->assertSame( [], $manager->getUserFormerGroups( $anon ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::removeUserFromGroup
	 * @covers \MediaWiki\User\UserGroupManager::getUserFormerGroups
	 * @covers \MediaWiki\User\UserGroupManager::getUserGroups
	 * @covers \MediaWiki\User\UserGroupManager::getUserGroupMemberships
	 */
	public function testRemoveUserFromGroup() {
		$manager = $this->getManager();
		$user = $this->getMutableTestUser( [ self::GROUP ] )->getUser();
		$this->assertMembership( $manager, $user, self::GROUP );

		$result = $manager->removeUserFromGroup( $user, self::GROUP );
		$this->assertTrue( $result );
		$this->assertNotContains( self::GROUP,
			$manager->getUserGroups( $user ) );
		$this->assertArrayNotHasKey( self::GROUP,
			$manager->getUserGroupMemberships( $user ) );
		$this->assertContains( self::GROUP,
			$manager->getUserFormerGroups( $user ) );
		$manager->clearCache( $user );
		$this->assertNotContains( self::GROUP,
			$manager->getUserGroups( $user ) );
		$this->assertArrayNotHasKey( self::GROUP,
			$manager->getUserGroupMemberships( $user ) );
		$this->assertContains( self::GROUP,
			$manager->getUserFormerGroups( $user ) );
		$this->assertContains( self::GROUP,
			$manager->getUserFormerGroups( $user ) ); // From cache
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::removeUserFromGroup
	 */
	public function testRemoveUserToGroupHookAbort() {
		$manager = $this->getManager();
		$user = $this->getTestUser( [ self::GROUP ] )->getUser();
		$originalGroups = $manager->getUserGroups( $user );
		$this->setTemporaryHook(
			'UserRemoveGroup',
			function ( UserIdentity $hookUser ) use ( $user ) {
				$this->assertTrue( $hookUser->equals( $user ) );
				return false;
			}
		);
		$this->assertFalse( $manager->removeUserFromGroup( $user, self::GROUP ) );
		$this->assertArrayEquals( $originalGroups, $manager->getUserGroups( $user ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::removeUserFromGroup
	 */
	public function testRemoveUserFromGroupHookModify() {
		$manager = $this->getManager();
		$user = $this->getTestUser( [ self::GROUP, 'from_hook' ] )->getUser();
		$this->setTemporaryHook(
			'UserRemoveGroup',
			function ( UserIdentity $hookUser, &$group ) use ( $user ) {
				$this->assertTrue( $hookUser->equals( $user ) );
				$this->assertSame( self::GROUP, $group );
				$group = 'from_hook';
				return true;
			}
		);
		$this->assertTrue( $manager->removeUserFromGroup( $user, self::GROUP ) );
		$this->assertNotContains( 'from_hook', $manager->getUserGroups( $user ) );
		$this->assertContains( self::GROUP, $manager->getUserGroups( $user ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::removeUserFromGroup
	 */
	public function testRemoveUserFromGroupReadOnly() {
		$user = $this->getTestUser( [ 'test' ] )->getUser();
		$this->getServiceContainer()->getReadOnlyMode()->setReason( 'TEST' );
		$manager = $this->getManager();
		$this->assertFalse( $manager->removeUserFromGroup( $user, 'test' ) );
		$this->assertContains( 'test', $manager->getUserGroups( $user ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::removeUserFromGroup
	 */
	public function testRemoveUserFromGroupAnon() {
		$manager = $this->getManager();
		$anon = new UserIdentityValue( 0, 'Anon' );
		$this->expectException( InvalidArgumentException::class );
		$manager->removeUserFromGroup( $anon, 'test' );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::removeUserFromGroup
	 */
	public function testRemoveUserFromGroupCallback() {
		$user = $this->getTestUser( [ 'test' ] )->getUser();
		$calledCount = 0;
		$callback = function ( UserIdentity $callbackUser ) use ( $user, &$calledCount ) {
			$this->assertTrue( $callbackUser->equals( $user ) );
			$calledCount += 1;
		};
		$manager = $this->getManager( [], null, $callback );
		$this->assertTrue( $manager->removeUserFromGroup( $user, 'test' ) );
		$this->assertNotContains( 'test', $manager->getUserGroups( $user ) );
		$this->assertSame( 1, $calledCount );
		$this->assertFalse( $manager->removeUserFromGroup( $user, 'test' ) );
		$this->assertSame( 1, $calledCount );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::purgeExpired
	 */
	public function testPurgeExpired() {
		$manager = $this->getManager();
		$user = $this->getTestUser()->getUser();
		$expiryInPast = wfTimestamp( TS_MW, time() - 100500 );
		$this->assertTrue(
			$manager->addUserToGroup( $user, 'expired', $expiryInPast ),
			'can add expired group'
		);
		$manager->purgeExpired();
		$this->assertNotContains( 'expired', $manager->getUserGroups( $user ) );
		$this->assertArrayNotHasKey( 'expired', $manager->getUserGroupMemberships( $user ) );
		$this->assertContains( 'expired', $manager->getUserFormerGroups( $user ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::purgeExpired
	 */
	public function testPurgeExpiredReadOnly() {
		$this->getServiceContainer()->getReadOnlyMode()->setReason( 'TEST' );
		$manager = $this->getManager();
		$this->assertFalse( $manager->purgeExpired() );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::listAllGroups
	 */
	public function testGetAllGroups() {
		$manager = $this->getManager( [
			MainConfigNames::GroupPermissions => [
				__METHOD__ => [ 'test' => true ],
				'implicit' => [ 'test' => true ]
			],
			MainConfigNames::RevokePermissions => [
				'revoked' => [ 'test' => true ]
			],
			MainConfigNames::ImplicitGroups => [ 'implicit' ]
		] );
		$this->assertArrayEquals( [ __METHOD__, 'revoked' ], $manager->listAllGroups() );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::listAllImplicitGroups
	 */
	public function testGetAllImplicitGroups() {
		$manager = $this->getManager( [ MainConfigNames::ImplicitGroups => [ __METHOD__ ] ] );
		$this->assertArrayEquals( [ __METHOD__ ], $manager->listAllImplicitGroups() );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::loadGroupMembershipsFromArray
	 */
	public function testLoadGroupMembershipsFromArray() {
		$manager = $this->getManager();
		$user = $this->getTestUser()->getUser();
		$row = new \stdClass();
		$row->ug_user = $user->getId();
		$row->ug_group = 'test';
		$row->ug_expiry = null;
		$manager->loadGroupMembershipsFromArray( $user, [ $row ], IDBAccessObject::READ_NORMAL );
		$memberships = $manager->getUserGroupMemberships( $user );
		$this->assertCount( 1, $memberships );
		$this->assertArrayHasKey( 'test', $memberships );
		$this->assertSame( $user->getId(), $memberships['test']->getUserId() );
		$this->assertSame( 'test', $memberships['test']->getGroup() );
	}

	public function provideGetUserAutopromoteEmailConfirmed() {
		$successUserMock = $this->createNoOpMock(
			User::class, [ 'getEmail', 'getEmailAuthenticationTimestamp', 'isTemp', 'assertWiki' ]
		);
		$successUserMock->method( 'assertWiki' )->willReturn( true );
		$successUserMock->expects( $this->once() )
			->method( 'getEmail' )
			->willReturn( 'test@test.com' );
		$successUserMock->expects( $this->once() )
			->method( 'getEmailAuthenticationTimestamp' )
			->willReturn( wfTimestampNow() );
		yield 'Successful autopromote' => [
			true, $successUserMock, [ 'test_autoconfirmed' ]
		];
		$emailAuthMock = $this->createNoOpMock( User::class, [ 'getEmail', 'isTemp', 'assertWiki' ] );
		$emailAuthMock->method( 'assertWiki' )->willReturn( true );
		$emailAuthMock->expects( $this->once() )
			->method( 'getEmail' )
			->willReturn( 'test@test.com' );
		yield 'wgEmailAuthentication is false' => [
			false, $emailAuthMock, [ 'test_autoconfirmed' ]
		];
		$invalidEmailMock = $this->createNoOpMock( User::class, [ 'getEmail', 'isTemp', 'assertWiki' ] );
		$invalidEmailMock->method( 'assertWiki' )->willReturn( true );
		$invalidEmailMock
			->expects( $this->once() )
			->method( 'getEmail' )
			->willReturn( 'INVALID!' );
		yield 'Invalid email' => [
			true, $invalidEmailMock, []
		];
		$nullTimestampMock = $this->createNoOpMock(
			User::class, [ 'getEmail', 'getEmailAuthenticationTimestamp', 'isTemp', 'assertWiki' ]
		);
		$nullTimestampMock->method( 'assertWiki' )->willReturn( true );
		$nullTimestampMock->expects( $this->once() )
			->method( 'getEmail' )
			->willReturn( 'test@test.com' );
		$nullTimestampMock->expects( $this->once() )
			->method( 'getEmailAuthenticationTimestamp' )
			->willReturn( null );
		yield 'Invalid email auth timestamp' => [
			true, $nullTimestampMock, []
		];
	}

	/**
	 * @dataProvider provideGetUserAutopromoteEmailConfirmed
	 * @covers \MediaWiki\User\UserGroupManager::getUserAutopromoteGroups
	 * @covers \MediaWiki\User\UserGroupManager::checkCondition
	 * @param bool $emailAuthentication
	 * @param User $user
	 * @param array $expected
	 */
	public function testGetUserAutopromoteEmailConfirmed(
		bool $emailAuthentication,
		User $user,
		array $expected
	) {
		$manager = $this->getManager( [
			MainConfigNames::Autopromote => [ 'test_autoconfirmed' => [ APCOND_EMAILCONFIRMED ] ],
			MainConfigNames::EmailAuthentication => $emailAuthentication
		] );
		$this->assertArrayEquals( $expected, $manager->getUserAutopromoteGroups( $user ) );
	}

	public static function provideGetUserAutopromoteEditCount() {
		yield 'Successful promote' => [
			[ APCOND_EDITCOUNT, 5 ], true, 10, [ 'test_autoconfirmed' ]
		];
		yield 'Required edit count negative' => [
			[ APCOND_EDITCOUNT, -1 ], true, 10, [ 'test_autoconfirmed' ]
		];
		yield 'No edit count, use AutoConfirmCount = 11' => [
			[ APCOND_EDITCOUNT ], true, 10, []
		];
		yield 'Null edit count, use AutoConfirmCount = 11' => [
			[ APCOND_EDITCOUNT, null ], true, 13, [ 'test_autoconfirmed' ]
		];
		yield 'Anon' => [
			[ APCOND_EDITCOUNT, 5 ], false, 100, []
		];
		yield 'Not enough edits' => [
			[ APCOND_EDITCOUNT, 100 ], true, 10, []
		];
	}

	/**
	 * @dataProvider provideGetUserAutopromoteEditCount
	 * @covers \MediaWiki\User\UserGroupManager::getUserAutopromoteGroups
	 * @covers \MediaWiki\User\UserGroupManager::checkCondition
	 */
	public function testGetUserAutopromoteEditCount(
		array $requiredCond,
		bool $userRegistered,
		int $userEditCount,
		array $expected
	) {
		$userEditTrackerMock = $this->createNoOpMock(
			UserEditTracker::class,
			[ 'getUserEditCount' ]
		);
		if ( $userRegistered ) {
			$user = $this->getTestUser()->getUser();
			$userEditTrackerMock->method( 'getUserEditCount' )
				->with( $user )
				->willReturn( $userEditCount );
		} else {
			$user = User::newFromName( 'UTUser1' );
		}
		$manager = $this->getManager(
			[
				MainConfigNames::AutoConfirmCount => 11,
				MainConfigNames::Autopromote => [ 'test_autoconfirmed' => $requiredCond ]
			],
			$userEditTrackerMock
		);
		$this->assertArrayEquals( $expected, $manager->getUserAutopromoteGroups( $user ) );
	}

	public static function provideGetUserAutopromoteAge() {
		yield 'Successful promote' => [
			[ APCOND_AGE, 1000 ],
			MWTimestamp::convert( TS_MW, time() - 1000000 ),
			[ 'test_autoconfirmed' ]
		];
		yield 'Not old enough' => [
			[ APCOND_AGE, 10000000 ], MWTimestamp::now(), []
		];
		yield 'Not old enough, using AutoConfirmAge via unset' => [
			[ APCOND_AGE ], MWTimestamp::now(), []
		];
		yield 'Not old enough, using AutoConfirmAge via null' => [
			[ APCOND_AGE, null ], MWTimestamp::now(), []
		];
	}

	/**
	 * @dataProvider provideGetUserAutopromoteAge
	 * @covers \MediaWiki\User\UserGroupManager::getUserAutopromoteGroups
	 * @covers \MediaWiki\User\UserGroupManager::checkCondition
	 * @param array $requiredCondition
	 * @param string $registrationTs
	 * @param array $expected
	 */
	public function testGetUserAutopromoteAge(
		array $requiredCondition,
		string $registrationTs,
		array $expected
	) {
		$manager = $this->getManager( [
			MainConfigNames::AutoConfirmAge => 10000000,
			MainConfigNames::Autopromote => [ 'test_autoconfirmed' => $requiredCondition ]
		] );
		$user = $this->createNoOpMock( User::class, [ 'getRegistration', 'isTemp', 'assertWiki' ] );
		$user->method( 'assertWiki' )->willReturn( true );
		$user->method( 'getRegistration' )
			->willReturn( $registrationTs );
		$this->assertArrayEquals( $expected, $manager->getUserAutopromoteGroups( $user ) );
	}

	public static function provideGetUserAutopromoteEditAge() {
		yield 'Successful promote' => [
			[ APCOND_AGE_FROM_EDIT, 1000 ],
			MWTimestamp::convert( TS_MW, time() - 1000000 ),
			[ 'test_autoconfirmed' ]
		];
		yield 'Not old enough' => [
			[ APCOND_AGE_FROM_EDIT, 10000000 ], MWTimestamp::now(), []
		];
	}

	/**
	 * @dataProvider provideGetUserAutopromoteEditAge
	 * @covers \MediaWiki\User\UserGroupManager::getUserAutopromoteGroups
	 * @covers \MediaWiki\User\UserGroupManager::checkCondition
	 * @param array $requiredCondition
	 * @param string $firstEditTs
	 * @param array $expected
	 */
	public function testGetUserAutopromoteEditAge(
		array $requiredCondition,
		string $firstEditTs,
		array $expected
	) {
		$user = $this->getTestUser()->getUser();
		$mockUserEditTracker = $this->createNoOpMock( UserEditTracker::class, [ 'getFirstEditTimestamp' ] );
		$mockUserEditTracker->expects( $this->once() )
			->method( 'getFirstEditTimestamp' )
			->with( $user )
			->willReturn( $firstEditTs );
		$manager = $this->getManager( [
			MainConfigNames::Autopromote => [ 'test_autoconfirmed' => $requiredCondition ]
		], $mockUserEditTracker );
		$this->assertArrayEquals( $expected, $manager->getUserAutopromoteGroups( $user ) );
	}

	public static function provideGetUserAutopromoteGroups() {
		yield 'Successful promote' => [
			[ 'group1', 'group2' ], [ 'group1', 'group2' ], [ 'test_autoconfirmed' ]
		];
		yield 'Not enough groups to promote' => [
			[ 'group1', 'group2' ], [ 'group1' ], []
		];
	}

	/**
	 * @dataProvider provideGetUserAutopromoteGroups
	 * @covers \MediaWiki\User\UserGroupManager::getUserAutopromoteGroups
	 * @covers \MediaWiki\User\UserGroupManager::checkCondition
	 */
	public function testGetUserAutopromoteGroups(
		array $requiredGroups,
		array $userGroups,
		array $expected
	) {
		$user = $this->getTestUser( $userGroups )->getUser();
		$manager = $this->getManager( [
			MainConfigNames::Autopromote => [ 'test_autoconfirmed' => array_merge( [ APCOND_INGROUPS ], $requiredGroups ) ]
		] );
		$this->assertArrayEquals( $expected, $manager->getUserAutopromoteGroups( $user ) );
	}

	public static function provideGetUserAutopromoteIP() {
		yield 'Individual ip, success' => [
			[ APCOND_ISIP, '123.123.123.123' ], '123.123.123.123', [ 'test_autoconfirmed' ]
		];
		yield 'Individual ip, failed' => [
			[ APCOND_ISIP, '123.123.123.123' ], '124.124.124.124', []
		];
		yield 'Range ip, success' => [
			[ APCOND_IPINRANGE, '123.123.123.1/24' ], '123.123.123.123', [ 'test_autoconfirmed' ]
		];
		yield 'Range ip, failed' => [
			[ APCOND_IPINRANGE, '123.123.123.1/24' ], '124.124.124.124', []
		];
	}

	/**
	 * @dataProvider provideGetUserAutopromoteIP
	 * @covers \MediaWiki\User\UserGroupManager::getUserAutopromoteGroups
	 * @covers \MediaWiki\User\UserGroupManager::checkCondition
	 * @param array $condition
	 * @param string $userIp
	 * @param array $expected
	 */
	public function testGetUserAutopromoteIP(
		array $condition,
		string $userIp,
		array $expected
	) {
		$manager = $this->getManager( [
			MainConfigNames::Autopromote => [ 'test_autoconfirmed' => $condition ]
		] );
		$requestMock = $this->createNoOpMock( WebRequest::class, [ 'getIP' ] );
		$requestMock->expects( $this->once() )
			->method( 'getIP' )
			->willReturn( $userIp );
		$user = $this->createNoOpMock( User::class, [ 'getRequest', 'isTemp', 'assertWiki' ] );
		$user->method( 'assertWiki' )->willReturn( true );
		$user->expects( $this->once() )
			->method( 'getRequest' )
			->willReturn( $requestMock );
		$this->assertArrayEquals( $expected, $manager->getUserAutopromoteGroups( $user ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::getUserAutopromoteGroups
	 */
	public function testGetUserAutopromoteGroupsHook() {
		$manager = $this->getManager( [ MainConfigNames::Autopromote => [] ] );
		$user = $this->getTestUser()->getUser();
		$this->setTemporaryHook(
			'GetAutoPromoteGroups',
			function ( User $hookUser, array &$promote ) use ( $user ){
				$this->assertTrue( $user->equals( $hookUser ) );
				$this->assertSame( [], $promote );
				$promote[] = 'from_hook';
			}
		);
		$this->assertArrayEquals( [ 'from_hook' ], $manager->getUserAutopromoteGroups( $user ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::checkCondition
	 * @covers \MediaWiki\User\UserGroupManager::recCheckCondition
	 */
	public function testGetUserAutopromoteComplexCondition() {
		$manager = $this->getManager( [
			MainConfigNames::Autopromote => [
				'test_autoconfirmed' => [ '&',
					[ APCOND_INGROUPS, 'group1' ],
					[ '!', [ APCOND_INGROUPS, 'group2' ] ],
					[ '^', [ APCOND_INGROUPS, 'group3' ], [ APCOND_INGROUPS, 'group4' ] ],
					[ '|', [ APCOND_INGROUPS, 'group5' ], [ APCOND_INGROUPS, 'group6' ] ]
				]
			]
		] );
		$this->assertSame( [], $manager->getUserAutopromoteGroups(
			$this->getTestUser( [ 'group1' ] )->getUser() )
		);
		$this->assertSame( [], $manager->getUserAutopromoteGroups(
			$this->getTestUser( [ 'group1', 'group2' ] )->getUser() )
		);
		$this->assertSame( [], $manager->getUserAutopromoteGroups(
			$this->getTestUser( [ 'group1', 'group3', 'group4' ] )->getUser() )
		);
		$this->assertSame( [], $manager->getUserAutopromoteGroups(
			$this->getTestUser( [ 'group1', 'group3' ] )->getUser() )
		);
		$this->assertArrayEquals(
			[ 'test_autoconfirmed' ],
			$manager->getUserAutopromoteGroups( $this->getTestUser( [ 'group1', 'group3', 'group5' ] )->getUser() )
		);
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::getUserAutopromoteGroups
	 * @covers \MediaWiki\User\UserGroupManager::checkCondition
	 */
	public function testGetUserAutopromoteBot() {
		$manager = $this->getManager( [
			MainConfigNames::Autopromote => [ 'test_autoconfirmed' => [ APCOND_ISBOT ] ]
		] );
		$notBot = $this->getTestUser()->getUser();
		$this->assertSame( [], $manager->getUserAutopromoteGroups( $notBot ) );
		$bot = $this->getTestUser( [ 'bot' ] )->getUser();
		$this->assertArrayEquals( [ 'test_autoconfirmed' ],
			$manager->getUserAutopromoteGroups( $bot ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::getUserAutopromoteGroups
	 * @covers \MediaWiki\User\UserGroupManager::checkCondition
	 */
	public function testGetUserAutopromoteBlocked() {
		$manager = $this->getManager( [
			MainConfigNames::Autopromote => [ 'test_autoconfirmed' => [ APCOND_BLOCKED ] ]
		] );
		$nonBlockedUser = $this->getTestUser()->getUser();
		$this->assertSame( [], $manager->getUserAutopromoteGroups( $nonBlockedUser ) );
		$blockedUser = $this->getTestUser( [ 'blocked' ] )->getUser();
		$block = new DatabaseBlock();
		$block->setTarget( $blockedUser );
		$block->setBlocker( $this->getTestSysop()->getUser() );
		$block->isSitewide( true );
		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );
		$this->assertArrayEquals( [ 'test_autoconfirmed' ],
			$manager->getUserAutopromoteGroups( $blockedUser ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::getUserAutopromoteGroups
	 * @covers \MediaWiki\User\UserGroupManager::checkCondition
	 */
	public function testGetUserAutopromoteBlockedDoesNotRecurse() {
		// Make sure session handling is started
		if ( !PHPSessionHandler::isInstalled() ) {
			PHPSessionHandler::install(
				SessionManager::singleton()
			);
		}
		$oldSessionId = session_id();

		$context = RequestContext::getMain();
		// Variables are unused but needed to reproduce the failure
		$oInfo = $context->exportSession();

		$user = User::newFromName( 'UnitTestContextUser' );
		$user->addToDatabase();

		$sinfo = [
			'sessionId' => 'd612ee607c87e749ef14da4983a702cd',
			'userId' => $user->getId(),
			'ip' => '192.0.2.0',
			'headers' => [
				'USER-AGENT' => 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:18.0) Gecko/20100101 Firefox/18.0'
			]
		];
		$this->overrideConfigValue(
			MainConfigNames::Autopromote,
			[ 'test_autoconfirmed' => [ '&', APCOND_BLOCKED ] ]
		);
		// Variables are unused but needed to reproduce the failure
		$sc = RequestContext::importScopedSession( $sinfo ); // load new context
		$info = $context->exportSession();

		$this->assertNull( $user->getBlock() );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::getUserAutopromoteGroups
	 * @covers \MediaWiki\User\UserGroupManager::checkCondition
	 */
	public function testGetUserAutopromoteBlockedDoesNotRecurseWithHook() {
		$this->overrideConfigValue(
			MainConfigNames::Autopromote,
			[ 'test_autoconfirmed' => [ '&', APCOND_BLOCKED ] ]
		);

		// Make sure session handling is started
		if ( !PHPSessionHandler::isInstalled() ) {
			PHPSessionHandler::install(
				SessionManager::singleton()
			);
		}
		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		$permissionManager->invalidateUsersRightsCache();

		$oldSessionId = session_id();

		$context = RequestContext::getMain();
		// Variables are unused but needed to reproduce the failure
		$oInfo = $context->exportSession();

		$user = User::newFromName( 'UnitTestContextUser' );
		$user->addToDatabase();

		$sinfo = [
			'sessionId' => 'd612ee607c87e749ef14da4983a702cd',
			'userId' => $user->getId(),
			'ip' => '192.0.2.0',
			'headers' => [
				'USER-AGENT' => 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:18.0) Gecko/20100101 Firefox/18.0'
			]
		];

		$onGetUserBlockCalled = false;
		$this->setTemporaryHook(
			'GetUserBlock',
			static function ( $user, $ip, &$block ) use ( $permissionManager, &$onGetUserBlockCalled ) {
				$onGetUserBlockCalled = true;

				try {
					if ( $permissionManager->userHasAnyRight( $user, 'ipblock-exempt', 'globalblock-exempt' ) ) {
						return true;
					}
				} catch ( LogicException $e ) {
					// We expect an uncaught LogicException from UserGroupManager::checkCondition here
					// otherwise there's something else wrong!
					if ( !str_starts_with( $e->getMessage(), "Unexpected recursion!" ) ) {
						throw $e;
					}
				}

				return true;
			}
		);

		// Variables are unused but needed to reproduce the failure
		$sc = RequestContext::importScopedSession( $sinfo ); // load new context
		$info = $context->exportSession();

		$this->assertNull( $user->getBlock() );

		$this->assertTrue(
			$onGetUserBlockCalled,
			'Check that HookRunner::onGetUserBlock was called'
		);
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::getUserAutopromoteGroups
	 */
	public function testGetUserAutopromoteInvalid() {
		$manager = $this->getManager( [
			MainConfigNames::Autopromote => [ 'test_autoconfirmed' => [ 999 ] ]
		] );
		$user = $this->getTestUser()->getUser();
		$this->expectException( InvalidArgumentException::class );
		$manager->getUserAutopromoteGroups( $user );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::getUserAutopromoteGroups
	 * @covers \MediaWiki\User\UserGroupManager::checkCondition
	 */
	public function testGetUserAutopromoteConditionHook() {
		$user = $this->getTestUser()->getUser();
		$this->setTemporaryHook(
			'AutopromoteCondition',
			function ( $type, array $arg, User $hookUser, &$result ) use ( $user ){
				$this->assertTrue( $user->equals( $hookUser ) );
				$this->assertSame( 999, $type );
				$this->assertSame( 'ARGUMENT', $arg[0] );
				$result = true;
			}
		);
		$manager = $this->getManager( [
			MainConfigNames::Autopromote => [ 'test_autoconfirmed' => [ 999, 'ARGUMENT' ] ]
		] );
		$this->assertArrayEquals( [ 'test_autoconfirmed' ], $manager->getUserAutopromoteGroups( $user ) );
	}

	public static function provideGetUserAutopromoteOnce() {
		yield 'Events are not matching' => [
			[ 'NOT_EVENT' => [ 'autopromoteonce' => [ APCOND_EDITCOUNT, 0 ] ] ], [], [], []
		];
		yield 'Empty config' => [
			[ 'EVENT' => [] ], [], [], []
		];
		yield 'Simple case, not user groups, not former groups' => [
			[ 'EVENT' => [ 'autopromoteonce' => [ APCOND_EDITCOUNT, 0 ] ] ], [], [], [ 'autopromoteonce' ]
		];
		yield 'User already in the group' => [
			[ 'EVENT' => [ 'autopromoteonce' => [ APCOND_EDITCOUNT, 0 ] ] ], [], [ 'autopromoteonce' ], []
		];
		yield 'User used to be in the group' => [
			[ 'EVENT' => [ 'autopromoteonce' => [ APCOND_EDITCOUNT, 0 ] ] ], [ 'autopromoteonce' ], [], []
		];
	}

	/**
	 * @dataProvider provideGetUserAutopromoteOnce
	 * @covers \MediaWiki\User\UserGroupManager::getUserAutopromoteOnceGroups
	 * @param array $config
	 * @param array $formerGroups
	 * @param array $userGroups
	 * @param array $expected
	 */
	public function testGetUserAutopromoteOnce(
		array $config,
		array $formerGroups,
		array $userGroups,
		array $expected
	) {
		$manager = $this->getManager( [ MainConfigNames::AutopromoteOnce => $config ] );
		$user = $this->getTestUser()->getUser();
		$manager->addUserToMultipleGroups( $user, $userGroups );
		foreach ( $formerGroups as $formerGroup ) {
			$manager->addUserToGroup( $user, $formerGroup );
			$manager->removeUserFromGroup( $user, $formerGroup );
		}
		$this->assertArrayEquals( $userGroups, $manager->getUserGroups( $user ),
			false, 'user groups are correct ' );
		$this->assertArrayEquals( $formerGroups, $manager->getUserFormerGroups( $user ),
			false, 'user former groups are correct ' );
		$this->assertArrayEquals(
			$expected,
			$manager->getUserAutopromoteOnceGroups( $user, 'EVENT' )
		);
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::addUserToAutopromoteOnceGroups
	 */
	public function testAddUserToAutopromoteOnceGroupsForeignDomain() {
		$siteConfig = new SiteConfiguration();
		$siteConfig->wikis = [ 'TEST_DOMAIN' ];
		$this->setMwGlobals( 'wgConf', $siteConfig );

		$this->overrideConfigValue( MainConfigNames::LocalDatabases, [ 'TEST_DOMAIN' ] );

		$manager = $this->getServiceContainer()
			->getUserGroupManagerFactory()
			->getUserGroupManager( 'TEST_DOMAIN' );
		$user = $this->getTestUser()->getUser();
		$this->expectException( PreconditionException::class );
		$this->assertSame( [], $manager->addUserToAutopromoteOnceGroups( $user, 'TEST' ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::addUserToAutopromoteOnceGroups
	 */
	public function testAddUserToAutopromoteOnceGroupsAnon() {
		$manager = $this->getManager();
		$anon = new UserIdentityValue( 0, 'TEST' );
		$this->assertSame( [], $manager->addUserToAutopromoteOnceGroups( $anon, 'TEST' ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::addUserToAutopromoteOnceGroups
	 */
	public function testAddUserToAutopromoteOnceGroupsReadOnly() {
		$manager = $this->getManager();
		$user = $this->getTestUser()->getUser();
		$this->getServiceContainer()->getReadOnlyMode()->setReason( 'TEST' );
		$this->assertSame( [], $manager->addUserToAutopromoteOnceGroups( $user, 'TEST' ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::addUserToAutopromoteOnceGroups
	 */
	public function testAddUserToAutopromoteOnceGroupsNoGroups() {
		$manager = $this->getManager();
		$user = $this->getTestUser()->getUser();
		$this->assertSame( [], $manager->addUserToAutopromoteOnceGroups( $user, 'TEST' ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::addUserToAutopromoteOnceGroups
	 */
	public function testAddUserToAutopromoteOnceGroupsSuccess() {
		$user = $this->getTestUser()->getUser();
		$manager = $this->getManager( [
			MainConfigNames::AutopromoteOnce => [ 'EVENT' => [ 'autopromoteonce' => [ APCOND_EDITCOUNT, 0 ] ] ]
		] );
		$this->assertNotContains( 'autopromoteonce', $manager->getUserGroups( $user ) );
		$hookCalled = false;
		$this->setTemporaryHook(
			'UserGroupsChanged',
			function ( User $hookUser, array $added, array $removed ) use ( $user, &$hookCalled ) {
				$this->assertTrue( $user->equals( $hookUser ) );
				$this->assertArrayEquals( [ 'autopromoteonce' ], $added );
				$this->assertSame( [], $removed );
				$hookCalled = true;
			}
		);
		$manager->addUserToAutopromoteOnceGroups( $user, 'EVENT' );
		$this->assertContains( 'autopromoteonce', $manager->getUserGroups( $user ) );
		$this->assertTrue( $hookCalled );
		$this->newSelectQueryBuilder()
			->select( [ 'log_type', 'log_action', 'log_params' ] )
			->from( 'logging' )
			->where( [ 'log_type' => 'rights' ] )
			->assertResultSet( [ [ 'rights',
				'autopromote',
				LogEntryBase::makeParamBlob( [
					'4::oldgroups' => [],
					'5::newgroups' => [ 'autopromoteonce' ],
				] )
			] ] );
	}

	private const CHANGEABLE_GROUPS_TEST_CONFIG = [
		MainConfigNames::GroupPermissions => [],
		MainConfigNames::AddGroups => [
			'sysop' => [ 'rollback' ],
			'bureaucrat' => [ 'sysop', 'bureaucrat' ],
		],
		MainConfigNames::RemoveGroups => [
			'sysop' => [ 'rollback' ],
			'bureaucrat' => [ 'sysop' ],
		],
		MainConfigNames::GroupsAddToSelf => [
			'sysop' => [ 'flood' ],
		],
		MainConfigNames::GroupsRemoveFromSelf => [
			'flood' => [ 'flood' ],
		],
	];

	private function assertGroupsEquals( array $expected, array $actual ) {
		// assertArrayEquals can compare without requiring the same order,
		// but the elements of an array are still required to be in the same order,
		// so just compare each element
		$this->assertArrayEquals( $expected['add'], $actual['add'], 'Add must match' );
		$this->assertArrayEquals( $expected['remove'], $actual['remove'], 'Remove must match' );
		$this->assertArrayEquals( $expected['add-self'], $actual['add-self'], 'Add-self must match' );
		$this->assertArrayEquals( $expected['remove-self'], $actual['remove-self'], 'Remove-self must match' );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::getGroupsChangeableBy
	 */
	public function testChangeableGroups() {
		$manager = $this->getManager( self::CHANGEABLE_GROUPS_TEST_CONFIG );
		$allGroups = $manager->listAllGroups();

		$user = $this->getTestUser()->getUser();
		$changeableGroups = $manager->getGroupsChangeableBy( new SimpleAuthority( $user, [ 'userrights' ] ) );
		$this->assertGroupsEquals(
			[
				'add' => $allGroups,
				'remove' => $allGroups,
				'add-self' => [],
				'remove-self' => [],
			],
			$changeableGroups
		);

		$user = $this->getTestUser( [ 'bureaucrat', 'sysop' ] )->getUser();
		$changeableGroups = $manager->getGroupsChangeableBy( new SimpleAuthority( $user, [] ) );
		$this->assertGroupsEquals(
			[
				'add' => [ 'sysop', 'bureaucrat', 'rollback' ],
				'remove' => [ 'sysop', 'rollback' ],
				'add-self' => [ 'flood' ],
				'remove-self' => [],
			],
			$changeableGroups
		);

		$user = $this->getTestUser( [ 'flood' ] )->getUser();
		$changeableGroups = $manager->getGroupsChangeableBy( new SimpleAuthority( $user, [] ) );
		$this->assertGroupsEquals(
			[
				'add' => [],
				'remove' => [],
				'add-self' => [],
				'remove-self' => [ 'flood' ],
			],
			$changeableGroups
		);
	}

	public static function provideChangeableByGroup() {
		yield 'sysop' => [ 'sysop', [
			'add' => [ 'rollback' ],
			'remove' => [ 'rollback' ],
			'add-self' => [ 'flood' ],
			'remove-self' => [],
		] ];
		yield 'flood' => [ 'flood', [
			'add' => [],
			'remove' => [],
			'add-self' => [],
			'remove-self' => [ 'flood' ],
		] ];
	}

	/**
	 * @dataProvider provideChangeableByGroup
	 * @covers \MediaWiki\User\UserGroupManager::getGroupsChangeableByGroup
	 * @param string $group
	 * @param array $expected
	 */
	public function testChangeableByGroup( string $group, array $expected ) {
		$manager = $this->getManager( self::CHANGEABLE_GROUPS_TEST_CONFIG );
		$this->assertGroupsEquals( $expected, $manager->getGroupsChangeableByGroup( $group ) );
	}

	/**
	 * @covers \MediaWiki\User\UserGroupManager::getUserPrivilegedGroups()
	 */
	public function testGetUserPrivilegedGroups() {
		$this->overrideConfigValue( MainConfigNames::PrivilegedGroups, [ 'sysop', 'interface-admin', 'bar', 'baz' ] );
		$makeHook = function ( $invocationCount, User $userToMatch, array $groupsToAdd ) {
			return function ( $u, &$groups ) use ( $userToMatch, $invocationCount, $groupsToAdd ) {
				$invocationCount();
				$this->assertTrue( $userToMatch->equals( $u ) );
				$groups = array_merge( $groups, $groupsToAdd );
			};
		};

		$manager = $this->getManager();

		$user = new User;
		$user->setName( '*Unregistered 1234' );

		$this->assertArrayEquals(
			[],
			$manager->getUserPrivilegedGroups( $user )
		);

		$user = $this->getTestUser( [ 'sysop', 'bot', 'interface-admin' ] )->getUser();

		$this->setTemporaryHook( 'UserPrivilegedGroups',
			$makeHook( $this->countPromise( $this->once() ), $user, [ 'foo' ] ) );
		$this->setTemporaryHook( 'UserEffectiveGroups',
			$makeHook( $this->countPromise( $this->once() ), $user, [ 'bar', 'boom' ] ) );
		$this->assertArrayEquals(
			[ 'sysop', 'interface-admin', 'foo', 'bar' ],
			$manager->getUserPrivilegedGroups( $user )
		);
		$this->assertArrayEquals(
			[ 'sysop', 'interface-admin', 'foo', 'bar' ],
			$manager->getUserPrivilegedGroups( $user )
		);

		$this->setTemporaryHook( 'UserPrivilegedGroups',
			$makeHook( $this->countPromise( $this->once() ), $user, [ 'baz' ] ) );
		$this->setTemporaryHook( 'UserEffectiveGroups',
			$makeHook( $this->countPromise( $this->once() ), $user, [ 'baz' ] ) );
		$this->assertArrayEquals(
			[ 'sysop', 'interface-admin', 'foo', 'bar' ],
			$manager->getUserPrivilegedGroups( $user )
		);
		$this->assertArrayEquals(
			[ 'sysop', 'interface-admin', 'baz' ],
			$manager->getUserPrivilegedGroups( $user, IDBAccessObject::READ_NORMAL, true )
		);
		$this->assertArrayEquals(
			[ 'sysop', 'interface-admin', 'baz' ],
			$manager->getUserPrivilegedGroups( $user )
		);

		$this->setTemporaryHook( 'UserPrivilegedGroups', static function () {
		} );
		$this->setTemporaryHook( 'UserEffectiveGroups', static function () {
		} );
		$user = $this->getTestUser( [] )->getUser();
		$this->assertArrayEquals(
			[],
			$manager->getUserPrivilegedGroups( $user, IDBAccessObject::READ_NORMAL, true )
		);
	}
}
PK       ! @[<B  <B    user/BotPasswordTest.phpnu Iw        <?php

use MediaWiki\Config\HashConfig;
use MediaWiki\Config\MultiConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Password\InvalidPassword;
use MediaWiki\Password\Password;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Session\SessionManager;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Session\TestUtils;
use MediaWiki\User\BotPassword;
use MediaWiki\User\CentralId\CentralIdLookup;
use Wikimedia\ObjectCache\EmptyBagOStuff;
use Wikimedia\Rdbms\IDBAccessObject;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\User\BotPassword
 * @group Database
 */
class BotPasswordTest extends MediaWikiIntegrationTestCase {

	/** @var TestUser */
	private $testUser;

	/** @var string */
	private $testUserName;

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

		$this->overrideConfigValues( [
			MainConfigNames::EnableBotPasswords => true,
			MainConfigNames::CentralIdLookupProvider => 'BotPasswordTest OkMock',
			MainConfigNames::GrantPermissions => [
				'test' => [ 'read' => true ],
			],
			MainConfigNames::UserrightsInterwikiDelimiter => '@',
		] );

		$this->testUser = $this->getMutableTestUser();
		$this->testUserName = $this->testUser->getUser()->getName();

		$mock1 = $this->getMockForAbstractClass( CentralIdLookup::class );
		$mock1->method( 'isAttached' )
			->willReturn( true );
		$mock1->method( 'lookupUserNames' )
			->willReturn( [ $this->testUserName => 42, 'UTDummy' => 43, 'UTInvalid' => 0 ] );
		$mock1->expects( $this->never() )->method( 'lookupCentralIds' );

		$mock2 = $this->getMockForAbstractClass( CentralIdLookup::class );
		$mock2->method( 'isAttached' )
			->willReturn( false );
		$mock2->method( 'lookupUserNames' )
			->willReturnArgument( 0 );
		$mock2->expects( $this->never() )->method( 'lookupCentralIds' );

		$this->mergeMwGlobalArrayValue( 'wgCentralIdLookupProviders', [
			'BotPasswordTest OkMock' => [ 'factory' => static function () use ( $mock1 ) {
				return $mock1;
			} ],
			'BotPasswordTest FailMock' => [ 'factory' => static function () use ( $mock2 ) {
				return $mock2;
			} ],
		] );
	}

	public function addDBData() {
		$passwordFactory = $this->getServiceContainer()->getPasswordFactory();
		$passwordHash = $passwordFactory->newFromPlaintext( 'foobaz' );

		$dbw = $this->getDb();
		$dbw->newDeleteQueryBuilder()
			->deleteFrom( 'bot_passwords' )
			->where( [ 'bp_user' => [ 42, 43 ], 'bp_app_id' => 'BotPassword' ] )
			->caller( __METHOD__ )->execute();
		$dbw->newInsertQueryBuilder()
			->insertInto( 'bot_passwords' )
			->row( [
				'bp_user' => 42,
				'bp_app_id' => 'BotPassword',
				'bp_password' => $passwordHash->toString(),
				'bp_token' => 'token!',
				'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
				'bp_grants' => '["test"]',
			] )
			->row( [
				'bp_user' => 43,
				'bp_app_id' => 'BotPassword',
				'bp_password' => $passwordHash->toString(),
				'bp_token' => 'token!',
				'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
				'bp_grants' => '["test"]',
			] )
			->caller( __METHOD__ )
			->execute();
	}

	public function testBasics() {
		$user = $this->testUser->getUser();
		$bp = BotPassword::newFromUser( $user, 'BotPassword' );
		$this->assertInstanceOf( BotPassword::class, $bp );
		$this->assertTrue( $bp->isSaved() );
		$this->assertSame( 42, $bp->getUserCentralId() );
		$this->assertSame( 'BotPassword', $bp->getAppId() );
		$this->assertSame( 'token!', trim( $bp->getToken(), " \0" ) );
		$this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() );
		$this->assertSame( [ 'test' ], $bp->getGrants() );

		$this->assertNull( BotPassword::newFromUser( $user, 'DoesNotExist' ) );

		$this->overrideConfigValue( MainConfigNames::CentralIdLookupProvider, 'BotPasswordTest FailMock' );
		$this->assertNull( BotPassword::newFromUser( $user, 'BotPassword' ) );

		$this->assertSame( '@', BotPassword::getSeparator() );
		$this->overrideConfigValue( MainConfigNames::UserrightsInterwikiDelimiter, '#' );
		$this->assertSame( '#', BotPassword::getSeparator() );
	}

	public function testUnsaved() {
		$user = $this->testUser->getUser();
		$bp = BotPassword::newUnsaved( [
			'user' => $user,
			'appId' => 'DoesNotExist'
		] );
		$this->assertInstanceOf( BotPassword::class, $bp );
		$this->assertFalse( $bp->isSaved() );
		$this->assertSame( 42, $bp->getUserCentralId() );
		$this->assertSame( 'DoesNotExist', $bp->getAppId() );
		$this->assertEquals( MWRestrictions::newDefault(), $bp->getRestrictions() );
		$this->assertSame( [], $bp->getGrants() );

		$bp = BotPassword::newUnsaved( [
			'username' => 'UTDummy',
			'appId' => 'DoesNotExist2',
			'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
			'grants' => [ 'test' ],
		] );
		$this->assertInstanceOf( BotPassword::class, $bp );
		$this->assertFalse( $bp->isSaved() );
		$this->assertSame( 43, $bp->getUserCentralId() );
		$this->assertSame( 'DoesNotExist2', $bp->getAppId() );
		$this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() );
		$this->assertSame( [ 'test' ], $bp->getGrants() );

		$bp = BotPassword::newUnsaved( [
			'centralId' => 45,
			'appId' => 'DoesNotExist'
		] );
		$this->assertInstanceOf( BotPassword::class, $bp );
		$this->assertFalse( $bp->isSaved() );
		$this->assertSame( 45, $bp->getUserCentralId() );
		$this->assertSame( 'DoesNotExist', $bp->getAppId() );

		$user = $this->testUser->getUser();
		$bp = BotPassword::newUnsaved( [
			'user' => $user,
			'appId' => 'BotPassword'
		] );
		$this->assertInstanceOf( BotPassword::class, $bp );
		$this->assertFalse( $bp->isSaved() );

		$this->assertNull( BotPassword::newUnsaved( [
			'user' => $user,
			'appId' => '',
		] ) );
		$this->assertNull( BotPassword::newUnsaved( [
			'user' => $user,
			'appId' => str_repeat( 'X', BotPassword::APPID_MAXLENGTH + 1 ),
		] ) );
		$this->assertNull( BotPassword::newUnsaved( [
			'user' => $this->testUserName,
			'appId' => 'Ok',
		] ) );
		$this->assertNull( BotPassword::newUnsaved( [
			'username' => 'UTInvalid',
			'appId' => 'Ok',
		] ) );
		$this->assertNull( BotPassword::newUnsaved( [
			'appId' => 'Ok',
		] ) );
	}

	public function testGetPassword() {
		/** @var BotPassword $bp */
		$bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );

		$password = $bp->getPassword();
		$this->assertInstanceOf( Password::class, $password );
		$this->assertTrue( $password->verify( 'foobaz' ) );

		$bp->centralId = 44;
		$password = $bp->getPassword();
		$this->assertInstanceOf( InvalidPassword::class, $password );

		$bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
		$dbw = $this->getDb();
		$dbw->newUpdateQueryBuilder()
			->update( 'bot_passwords' )
			->set( [ 'bp_password' => 'garbage' ] )
			->where( [ 'bp_user' => 42, 'bp_app_id' => 'BotPassword' ] )
			->caller( __METHOD__ )->execute();
		$password = $bp->getPassword();
		$this->assertInstanceOf( InvalidPassword::class, $password );
	}

	public function testInvalidateAllPasswordsForUser() {
		$bp1 = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
		$bp2 = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 43, 'BotPassword' ) );

		$this->assertNotInstanceOf( InvalidPassword::class, $bp1->getPassword() );
		$this->assertNotInstanceOf( InvalidPassword::class, $bp2->getPassword() );
		BotPassword::invalidateAllPasswordsForUser( $this->testUserName );
		$this->assertInstanceOf( InvalidPassword::class, $bp1->getPassword() );
		$this->assertNotInstanceOf( InvalidPassword::class, $bp2->getPassword() );

		$bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
		$this->assertInstanceOf( InvalidPassword::class, $bp->getPassword() );
	}

	public function testRemoveAllPasswordsForUser() {
		$this->assertNotNull( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
		$this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ) );

		BotPassword::removeAllPasswordsForUser( $this->testUserName );

		$this->assertNull( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
		$this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ) );
	}

	/**
	 * @dataProvider provideCanonicalizeLoginData
	 */
	public function testCanonicalizeLoginData( $username, $password, $expectedResult ) {
		$result = BotPassword::canonicalizeLoginData( $username, $password );
		if ( is_array( $expectedResult ) ) {
			$this->assertArrayEquals( $expectedResult, $result, true, true );
		} else {
			$this->assertSame( $expectedResult, $result );
		}
	}

	public static function provideCanonicalizeLoginData() {
		return [
			// T388255
			[ '', '', false ],
			[ 'user', '', false ],
			[ '', '12345678901234567890123456789012', false ],
			[ 'user', 'pass', false ],
			[ 'user', 'abc@def', false ],
			[ 'legacy@user', 'pass', false ],
			[ 'user@bot', '12345678901234567890123456789012',
				[ 'user@bot', '12345678901234567890123456789012' ] ],
			[ 'user', 'bot@12345678901234567890123456789012',
				[ 'user@bot', '12345678901234567890123456789012' ] ],
			[ 'user', 'bot@12345678901234567890123456789012345',
				[ 'user@bot', '12345678901234567890123456789012345' ] ],
			[ 'user', 'bot@x@12345678901234567890123456789012',
				[ 'user@bot@x', '12345678901234567890123456789012' ] ],
		];
	}

	public function testLogin() {
		// Test failure when bot passwords aren't enabled
		$this->overrideConfigValue( MainConfigNames::EnableBotPasswords, false );
		$status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', new FauxRequest );
		$this->assertEquals( Status::newFatal( 'botpasswords-disabled' ), $status );
		$this->overrideConfigValue( MainConfigNames::EnableBotPasswords, true );

		// Test failure when BotPasswordSessionProvider isn't configured
		$manager = new SessionManager( [
			'logger' => new Psr\Log\NullLogger,
			'store' => new EmptyBagOStuff,
		] );
		$reset = TestUtils::setSessionManagerSingleton( $manager );
		$this->assertNull(
			$manager->getProvider( MediaWiki\Session\BotPasswordSessionProvider::class )
		);
		$status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', new FauxRequest );
		$this->assertEquals( Status::newFatal( 'botpasswords-no-provider' ), $status );
		ScopedCallback::consume( $reset );

		// Now configure BotPasswordSessionProvider for further tests...
		$mainConfig = $this->getServiceContainer()->getMainConfig();
		$config = new HashConfig( [
			MainConfigNames::SessionProviders => $mainConfig->get( MainConfigNames::SessionProviders ) + [
				MediaWiki\Session\BotPasswordSessionProvider::class => [
					'class' => MediaWiki\Session\BotPasswordSessionProvider::class,
					'args' => [ [ 'priority' => 40 ] ],
					'services' => [ 'GrantsInfo' ],
				]
			],
		] );
		$manager = new SessionManager( [
			'config' => new MultiConfig( [ $config, $mainConfig ] ),
			'logger' => new Psr\Log\NullLogger,
			'store' => new EmptyBagOStuff,
		] );
		$reset = TestUtils::setSessionManagerSingleton( $manager );

		// No "@"-thing in the username
		$status = BotPassword::login( $this->testUserName, 'foobaz', new FauxRequest );
		$this->assertStatusError( 'botpasswords-invalid-name', $status );

		// No base user
		$status = BotPassword::login( 'UTDummy@BotPassword', 'foobaz', new FauxRequest );
		$this->assertStatusError( 'nosuchuser', $status );

		// No bot password
		$status = BotPassword::login( "{$this->testUserName}@DoesNotExist", 'foobaz', new FauxRequest );
		$this->assertStatusError( 'botpasswords-not-exist', $status );

		// Failed restriction
		$request = $this->getMockBuilder( FauxRequest::class )
			->onlyMethods( [ 'getIP' ] )
			->getMock();
		$request->method( 'getIP' )
			->willReturn( '10.0.0.1' );
		$status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', $request );
		$this->assertStatusError( 'botpasswords-restriction-failed', $status );

		// Wrong password
		$status = BotPassword::login(
			"{$this->testUserName}@BotPassword", $this->testUser->getPassword(), new FauxRequest );
		$this->assertStatusError( 'wrongpassword', $status );

		// Success!
		$request = new FauxRequest;
		$this->assertNotInstanceOf(
			MediaWiki\Session\BotPasswordSessionProvider::class,
			$request->getSession()->getProvider()
		);
		$status = BotPassword::login( "{$this->testUserName}@BotPassword", 'foobaz', $request );
		$this->assertInstanceOf( Status::class, $status );
		$this->assertStatusGood( $status );
		$session = $status->getValue();
		$this->assertInstanceOf( MediaWiki\Session\Session::class, $session );
		$this->assertInstanceOf(
			MediaWiki\Session\BotPasswordSessionProvider::class, $session->getProvider()
		);
		$this->assertSame( $session->getId(), $request->getSession()->getId() );

		ScopedCallback::consume( $reset );
	}

	/**
	 * @dataProvider provideSave
	 * @param string|null $password
	 */
	public function testSave( $password ) {
		$passwordFactory = $this->getServiceContainer()->getPasswordFactory();

		$bp = BotPassword::newUnsaved( [
			'centralId' => 42,
			'appId' => 'TestSave',
			'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
			'grants' => [ 'test' ],
		] );
		$this->assertFalse( $bp->isSaved() );
		$this->assertNull(
			BotPassword::newFromCentralId( 42, 'TestSave', IDBAccessObject::READ_LATEST )
		);

		$passwordHash = $password ? $passwordFactory->newFromPlaintext( $password ) : null;
		$this->assertStatusNotOk( $bp->save( 'update', $passwordHash ) );
		$this->assertStatusGood( $bp->save( 'insert', $passwordHash ) );

		$bp2 = BotPassword::newFromCentralId( 42, 'TestSave', IDBAccessObject::READ_LATEST );
		$this->assertInstanceOf( BotPassword::class, $bp2 );
		$this->assertEquals( $bp->getUserCentralId(), $bp2->getUserCentralId() );
		$this->assertEquals( $bp->getAppId(), $bp2->getAppId() );
		$this->assertEquals( $bp->getToken(), $bp2->getToken() );
		$this->assertEquals( $bp->getRestrictions(), $bp2->getRestrictions() );
		$this->assertEquals( $bp->getGrants(), $bp2->getGrants() );

		/** @var Password $pw */
		$pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
		if ( $password === null ) {
			$this->assertInstanceOf( InvalidPassword::class, $pw );
		} else {
			$this->assertTrue( $pw->verify( $password ) );
		}

		$token = $bp->getToken();
		$this->assertEquals( 42, $bp->getUserCentralId() );
		$this->assertEquals( 'TestSave', $bp->getAppId() );
		$this->assertStatusNotOk( $bp->save( 'insert' ) );
		$this->assertStatusGood( $bp->save( 'update' ) );
		$this->assertNotEquals( $token, $bp->getToken() );

		$bp2 = BotPassword::newFromCentralId( 42, 'TestSave', IDBAccessObject::READ_LATEST );
		$this->assertInstanceOf( BotPassword::class, $bp2 );
		$this->assertEquals( $bp->getToken(), $bp2->getToken() );
		/** @var Password $pw */
		$pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
		if ( $password === null ) {
			$this->assertInstanceOf( InvalidPassword::class, $pw );
		} else {
			$this->assertTrue( $pw->verify( $password ) );
		}

		$passwordHash = $passwordFactory->newFromPlaintext( 'XXX' );
		$token = $bp->getToken();
		$this->assertStatusGood( $bp->save( 'update', $passwordHash ) );
		$this->assertNotEquals( $token, $bp->getToken() );

		/** @var Password $pw */
		$pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
		$this->assertTrue( $pw->verify( 'XXX' ) );

		$this->assertTrue( $bp->delete() );
		$this->assertFalse( $bp->isSaved() );
		$this->assertNull( BotPassword::newFromCentralId( 42, 'TestSave', IDBAccessObject::READ_LATEST ) );

		$this->expectException( UnexpectedValueException::class );
		$bp->save( 'foobar' )->isGood();
	}

	public static function provideSave() {
		return [
			[ null ],
			[ 'foobar' ],
		];
	}

	/**
	 * Tests for error handling when bp_restrictions and bp_grants are too long
	 */
	public function testSaveValidation() {
		$lotsOfIPs = [
			'IPAddresses' => array_fill(
				0,
				5000,
				"127.0.0.0/8"
			)
		];

		$bp = BotPassword::newUnsaved( [
			'centralId' => 42,
			'appId' => 'TestSave',
			// When this becomes JSON, it'll be 70,017 characters, which is
			// greater than BotPassword::GRANTS_MAXLENGTH, so it will cause an error.
			'restrictions' => MWRestrictions::newFromArray( $lotsOfIPs ),
			'grants' => [
				// Maximum length of the JSON is BotPassword::RESTRICTIONS_MAXLENGTH characters.
				// So one long grant name should be good. Turning it into JSON will add
				// a couple of extra characters, taking it over BotPassword::RESTRICTIONS_MAXLENGTH
				// characters long, so it will cause an error.
				str_repeat( '*', BotPassword::RESTRICTIONS_MAXLENGTH )
			],
		] );

		$status = $bp->save( 'insert' );

		$this->assertStatusError( 'botpasswords-toolong-restrictions', $status );
		$this->assertStatusError( 'botpasswords-toolong-grants', $status );
	}
}
PK       ! YZ      user/ExternalUserNamesTest.phpnu Iw        <?php

use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\ExternalUserNames;

/**
 * @covers \MediaWiki\User\ExternalUserNames
 * @group Database
 */
class ExternalUserNamesTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;

	public static function provideGetUserLinkTitle() {
		return [
			[
				'Valid user name from known import source',
				'valid:>User1',
				Title::makeTitle( NS_MAIN, ':User:User1', '', 'valid' )
			],
			[
				'Valid user name that looks like an import source, from known import source',
				'valid:valid:>User1',
				Title::makeTitle( NS_MAIN, 'valid::User:User1', '', 'valid' )
			],
			[
				'Local IP address',
				'127.0.0.1',
				Title::makeTitle( NS_SPECIAL, 'Contributions/127.0.0.1', '', '' )
			],
			[
				'Valid user name from unknown import source',
				'invalid:>User1',
				null
			],
			[
				'Corrupt local user name with linebreak',
				"Foo\nBar",
				null
			],
			[
				'Corrupt local user name with terminal underscore',
				'Barf_',
				null
			],
			[
				'Corrupt local user name with initial lowercase',
				'abcd',
				null
			],
			[
				'Corrupt local user name with slash',
				'For/Bar',
				null
			],
			[
				'Corrupt local user name with octothorpe',
				'For#Bar',
				null
			],
		];
	}

	/**
	 * @covers \MediaWiki\User\ExternalUserNames::getUserLinkTitle
	 * @dataProvider provideGetUserLinkTitle
	 */
	public function testGetUserLinkTitle( $caseDescription, $username, $expected ) {
		$this->setContentLang( 'en' );

		$interwikiLookup = $this->getDummyInterwikiLookup( [ 'valid' ] );
		$this->setService( 'InterwikiLookup', $interwikiLookup );

		$this->assertEquals(
			$expected,
			ExternalUserNames::getUserLinkTitle( $username ),
			$caseDescription
		);
	}

	public static function provideApplyPrefix() {
		return [
			[ 'User1', 'prefix', 'prefix>User1' ],
			[ 'User1', 'prefix:>', 'prefix>User1' ],
			[ 'User1', 'prefix:', 'prefix>User1' ],
			[ 'user1', 'prefix', 'prefix>user1' ],
			[ '0', 'prefix', 'prefix>0' ],
			[ 'Unknown user', 'prefix', 'Unknown user' ],
		];
	}

	/**
	 * @covers \MediaWiki\User\ExternalUserNames::applyPrefix
	 * @dataProvider provideApplyPrefix
	 */
	public function testApplyPrefix( $username, $prefix, $expected ) {
		$externalUserNames = new ExternalUserNames( $prefix, true );

		$this->assertSame(
			$expected,
			$externalUserNames->applyPrefix( $username )
		);
	}

	/**
	 * @covers \MediaWiki\User\ExternalUserNames::applyPrefix
	 */
	public function testApplyPrefix_existingUser() {
		$testName = $this->getTestUser()->getUser()->getName();
		$testName2 = lcfirst( $testName );
		$this->assertNotSame( $testName, $testName2 );

		$externalUserNames = new ExternalUserNames( 'p', false );
		$this->assertSame( "p>$testName", $externalUserNames->applyPrefix( $testName ) );
		$this->assertSame( "p>$testName2", $externalUserNames->applyPrefix( $testName2 ) );

		$externalUserNames = new ExternalUserNames( 'p', true );
		$this->assertSame( $testName, $externalUserNames->applyPrefix( $testName ) );
		$this->assertSame( $testName2, $externalUserNames->applyPrefix( $testName2 ) );
	}

	public static function provideAddPrefix() {
		return [
			[ 'User1', 'prefix', 'prefix>User1' ],
			[ 'User2', 'prefix2', 'prefix2>User2' ],
			[ 'User3', 'prefix3', 'prefix3>User3' ],
		];
	}

	/**
	 * @covers \MediaWiki\User\ExternalUserNames::addPrefix
	 * @dataProvider provideAddPrefix
	 */
	public function testAddPrefix( $username, $prefix, $expected ) {
		$externalUserNames = new ExternalUserNames( $prefix, true );

		$this->assertSame(
			$expected,
			$externalUserNames->addPrefix( $username )
		);
	}

	public static function provideIsExternal() {
		return [
			[ 'User1', false ],
			[ '>User1', true ],
			[ 'prefix>User1', true ],
			[ 'prefix:>User1', true ],
		];
	}

	/**
	 * @covers \MediaWiki\User\ExternalUserNames::isExternal
	 * @dataProvider provideIsExternal
	 */
	public function testIsExternal( $username, $expected ) {
		$this->assertSame(
			$expected,
			ExternalUserNames::isExternal( $username )
		);
	}

	public static function provideGetLocal() {
		return [
			[ 'User1', 'User1' ],
			[ '>User2', 'User2' ],
			[ 'prefix>User3', 'User3' ],
			[ 'prefix:>User4', 'User4' ],
		];
	}

	/**
	 * @covers \MediaWiki\User\ExternalUserNames::getLocal
	 * @dataProvider provideGetLocal
	 */
	public function testGetLocal( $username, $expected ) {
		$this->assertSame(
			$expected,
			ExternalUserNames::getLocal( $username )
		);
	}

}
PK       ! i9  9    user/LocalIdLookupTest.phpnu Iw        <?php

use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\User\CentralId\CentralIdLookup;
use MediaWiki\User\CentralId\LocalIdLookup;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;

/**
 * @covers \MediaWiki\User\CentralId\LocalIdLookup
 * @group Database
 */
class LocalIdLookupTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;

	/** @var UserIdentity[] */
	private $localUsers = [];

	public function addDBData() {
		for ( $i = 1; $i <= 4; $i++ ) {
			$this->localUsers[] = $this->getMutableTestUser()->getUserIdentity();
		}

		$sysop = static::getTestSysop()->getUserIdentity();
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();

		$block = new DatabaseBlock( [
			'address' => $this->localUsers[2]->getName(),
			'by' => $sysop,
			'reason' => __METHOD__,
			'expiry' => '1 day',
			'hideName' => false,
		] );
		$blockStore->insertBlock( $block );

		$block = new DatabaseBlock( [
			'address' => $this->localUsers[3]->getName(),
			'by' => $sysop,
			'reason' => __METHOD__,
			'expiry' => '1 day',
			'hideName' => true,
		] );
		$blockStore->insertBlock( $block );
	}

	private function newLookup( array $configOverride = [] ) {
		$lookup = new LocalIdLookup(
			new HashConfig( [
				MainConfigNames::SharedDB => null,
				MainConfigNames::SharedTables => [],
				MainConfigNames::LocalDatabases => [],
			] + $configOverride ),
			$this->getServiceContainer()->getConnectionProvider(),
			$this->getServiceContainer()->getHideUserUtils()
		);
		$lookup->init(
			'test',
			$this->getServiceContainer()->getUserIdentityLookup(),
			$this->getServiceContainer()->getUserFactory()
		);
		return $lookup;
	}

	public function testLookupCentralIds() {
		$lookup = $this->newLookup();
		$permitted = $this->mockAnonAuthorityWithPermissions( [ 'hideuser' ] );
		$nonPermitted = $this->mockAnonAuthorityWithoutPermissions( [ 'hideuser' ] );

		$this->assertSame( [], $lookup->lookupCentralIds( [] ) );

		$expect = [];
		foreach ( $this->localUsers as $localUser ) {
			$expect[$localUser->getId()] = $localUser->getName();
		}
		$expect[12345] = 'X';
		ksort( $expect );

		$expect2 = $expect;
		$expect2[$this->localUsers[3]->getId()] = '';

		$arg = array_fill_keys( array_keys( $expect ), 'X' );

		$this->assertSame( $expect2, $lookup->lookupCentralIds( $arg ) );
		$this->assertSame( $expect, $lookup->lookupCentralIds( $arg, CentralIdLookup::AUDIENCE_RAW ) );
		$this->assertSame( $expect, $lookup->lookupCentralIds( $arg, $permitted ) );
		$this->assertSame( $expect2, $lookup->lookupCentralIds( $arg, $nonPermitted ) );
	}

	public function testLookupUserNames() {
		$lookup = $this->newLookup();
		$permitted = $this->mockAnonAuthorityWithPermissions( [ 'hideuser' ] );
		$nonPermitted = $this->mockAnonAuthorityWithoutPermissions( [ 'hideuser' ] );

		$this->assertSame( [], $lookup->lookupUserNames( [] ) );

		$expect = [];
		foreach ( $this->localUsers as $localUser ) {
			$expect[$localUser->getName()] = $localUser->getId();
		}
		$expect['UTDoesNotExist'] = 'X';
		ksort( $expect );

		$expect2 = $expect;
		$expect2[$this->localUsers[3]->getName()] = 'X';

		$arg = array_fill_keys( array_keys( $expect ), 'X' );

		$this->assertSame( $expect2, $lookup->lookupUserNames( $arg ) );
		$this->assertSame( $expect, $lookup->lookupUserNames( $arg, CentralIdLookup::AUDIENCE_RAW ) );
		$this->assertSame( $expect, $lookup->lookupUserNames( $arg, $permitted ) );
		$this->assertSame( $expect2, $lookup->lookupUserNames( $arg, $nonPermitted ) );
	}

	public function testIsAttached() {
		$lookup = $this->newLookup();
		$user1 = UserIdentityValue::newRegistered( 42, 'Test' );
		$user2 = UserIdentityValue::newAnonymous( 'DoesNotExist' );

		$this->assertTrue( $lookup->isAttached( $user1 ) );
		$this->assertFalse( $lookup->isAttached( $user2 ) );

		$wiki = UserIdentityValue::LOCAL;
		$this->assertTrue( $lookup->isAttached( $user1, $wiki ) );
		$this->assertFalse( $lookup->isAttached( $user2, $wiki ) );

		$wiki = 'some_other_wiki';
		$this->assertFalse( $lookup->isAttached( $user1, $wiki ) );
		$this->assertFalse( $lookup->isAttached( $user2, $wiki ) );
	}

	/**
	 * @dataProvider provideIsAttachedShared
	 * @param bool $sharedDB $wgSharedDB is set
	 * @param bool $sharedTable $wgSharedTables contains 'user'
	 * @param bool $localDBSet $wgLocalDatabases contains the shared DB
	 */
	public function testIsAttachedShared( $sharedDB, $sharedTable, $localDBSet ) {
		$lookup = $this->newLookup( [
			MainConfigNames::SharedDB => $sharedDB ? "dummy" : null,
			MainConfigNames::SharedTables => $sharedTable ? [ 'user' ] : [],
			MainConfigNames::LocalDatabases => $localDBSet ? [ 'shared' ] : [],
		] );
		$this->assertSame(
			$sharedDB && $sharedTable && $localDBSet,
			$lookup->isAttached( UserIdentityValue::newRegistered( 42, 'Test' ), 'shared' )
		);
	}

	public static function provideIsAttachedShared() {
		$ret = [];
		for ( $i = 0; $i < 7; $i++ ) {
			$ret[] = [
				(bool)( $i & 1 ),
				(bool)( $i & 2 ),
				(bool)( $i & 4 ),
			];
		}
		return $ret;
	}

}
PK       ! v  v    user/UserTest.phpnu Iw        <?php

use MediaWiki\Block\CompositeBlock;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\SystemBlock;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\RateLimiter;
use MediaWiki\Permissions\RateLimitSubject;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\WebRequest;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\Utils\MWTimestamp;
use Wikimedia\Assert\PreconditionException;
use Wikimedia\Rdbms\IDBAccessObject;
use Wikimedia\TestingAccessWrapper;

/**
 * @coversDefaultClass \MediaWiki\User\User
 * @group Database
 */
class UserTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;
	use TempUserTestTrait;

	/** Constant for self::testIsBlockedFrom */
	private const USER_TALK_PAGE = '<user talk page>';

	/**
	 * @var User
	 */
	protected $user;

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

		$this->overrideConfigValues( [
			MainConfigNames::GroupPermissions => [],
			MainConfigNames::RevokePermissions => [],
			MainConfigNames::UseRCPatrol => true,
			MainConfigNames::WatchlistExpiry => true,
			MainConfigNames::AutoConfirmAge => 0,
			MainConfigNames::AutoConfirmCount => 0,
		] );

		$this->setUpPermissionGlobals();

		$this->user = $this->getTestUser( 'unittesters' )->getUser();
	}

	private function setUpPermissionGlobals() {
		$this->setGroupPermissions( [
			// Data for regular $wgGroupPermissions test
			'unittesters' => [
				'test' => true,
				'runtest' => true,
				'writetest' => false,
				'nukeworld' => false,
				'autoconfirmed' => false,
			],
			'testwriters' => [
				'test' => true,
				'writetest' => true,
				'modifytest' => true,
				'autoconfirmed' => true,
			],
			// For the options and watchlist tests
			'*' => [
				'editmyoptions' => true,
				'editmywatchlist' => true,
				'viewmywatchlist' => true,
			],
			// For patrol tests
			'patroller' => [
				'patrol' => true,
			],
			// For account creation when blocked test
			'accountcreator' => [
				'createaccount' => true,
				'ipblock-exempt' => true
			],
			// For bot and ratelimit tests
			'bot' => [
				'bot' => true,
				'noratelimit' => true,
			]
		] );

		$this->overrideConfigValue(
			MainConfigNames::RevokePermissions,
			// Data for regular $wgRevokePermissions test
			[ 'formertesters' => [ 'runtest' => true ] ]
		);
	}

	private function setSessionUser( User $user, WebRequest $request ) {
		RequestContext::getMain()->setUser( $user );
		RequestContext::getMain()->setRequest( $request );
		TestingAccessWrapper::newFromObject( $user )->mRequest = $request;
		$request->getSession()->setUser( $user );
	}

	/**
	 * @covers \MediaWiki\User\User::isAllowedAny
	 * @covers \MediaWiki\User\User::isAllowedAll
	 * @covers \MediaWiki\User\User::isAllowed
	 * @covers \MediaWiki\User\User::isNewbie
	 */
	public function testIsAllowed() {
		$this->assertFalse(
			$this->user->isAllowed( 'writetest' ),
			'Basic isAllowed works with a group not granted a right'
		);
		$this->assertTrue(
			$this->user->isAllowedAny( 'test', 'writetest' ),
			'A user with only one of the rights can pass isAllowedAll'
		);
		$this->assertTrue(
			$this->user->isAllowedAll( 'test', 'runtest' ),
			'A user with multiple rights can pass isAllowedAll'
		);
		$this->assertFalse(
			$this->user->isAllowedAll( 'test', 'runtest', 'writetest' ),
			'A user needs all rights specified to pass isAllowedAll'
		);
		$this->assertTrue(
			$this->user->isNewbie(),
			'Unit testers are not autoconfirmed yet'
		);

		$user = $this->getTestUser( 'testwriters' )->getUser();
		$this->assertTrue(
			$user->isAllowed( 'test' ),
			'Basic isAllowed works with a group granted a right'
		);
		$this->assertTrue(
			$user->isAllowed( 'writetest' ),
			'Testwriters pass isAllowed with `writetest`'
		);
		$this->assertFalse(
			$user->isNewbie(),
			'Test writers are autoconfirmed'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::useRCPatrol
	 * @covers \MediaWiki\User\User::useNPPatrol
	 * @covers \MediaWiki\User\User::useFilePatrol
	 */
	public function testPatrolling() {
		$user = $this->getTestUser( 'patroller' )->getUser();

		$this->assertTrue( $user->useRCPatrol() );
		$this->assertTrue( $user->useNPPatrol() );
		$this->assertTrue( $user->useFilePatrol() );

		$this->assertFalse( $this->user->useRCPatrol() );
		$this->assertFalse( $this->user->useNPPatrol() );
		$this->assertFalse( $this->user->useFilePatrol() );
	}

	/**
	 * @covers \MediaWiki\User\User::isBot
	 */
	public function testBot() {
		$user = $this->getTestUser( 'bot' )->getUser();

		$userGroupManager = $this->getServiceContainer()->getUserGroupManager();
		$this->assertSame( [ 'bot' ], $userGroupManager->getUserGroups( $user ) );
		$this->assertArrayHasKey( 'bot', $userGroupManager->getUserGroupMemberships( $user ) );
		$this->assertTrue( $user->isBot() );

		$this->assertArrayNotHasKey( 'bot', $userGroupManager->getUserGroupMemberships( $this->user ) );
		$this->assertFalse( $this->user->isBot() );
	}

	/**
	 * Test User::editCount
	 * @group medium
	 * @covers \MediaWiki\User\User::getEditCount
	 */
	public function testGetEditCount() {
		$user = $this->getMutableTestUser()->getUser();

		// let the user have a few (3) edits
		$title = Title::makeTitle( NS_HELP, 'UserTest_EditCount' );
		for ( $i = 0; $i < 3; $i++ ) {
			$this->editPage(
				$title,
				(string)$i,
				'test',
				NS_MAIN,
				$user
			);
		}

		$this->assertSame(
			3,
			$user->getEditCount(),
			'After three edits, the user edit count should be 3'
		);

		// increase the edit count
		$this->getServiceContainer()->getUserEditTracker()->incrementUserEditCount( $user );
		$user->clearInstanceCache();

		$this->assertSame(
			4,
			$user->getEditCount(),
			'After increasing the edit count manually, the user edit count should be 4'
		);
	}

	/**
	 * Test User::editCount
	 * @group medium
	 * @covers \MediaWiki\User\User::getEditCount
	 */
	public function testGetEditCountForAnons() {
		$user = User::newFromName( 'Anonymous' );

		$this->assertNull(
			$user->getEditCount(),
			'Edit count starts null for anonymous users.'
		);

		$this->assertNull(
			$this->getServiceContainer()->getUserEditTracker()->incrementUserEditCount( $user ),
			'Edit count cannot be increased for anonymous users'
		);

		$this->assertNull(
			$user->getEditCount(),
			'Edit count remains null for anonymous users despite calls to increase it.'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::getRightDescription
	 */
	public function testGetRightDescription() {
		$key = 'deletechangetags';
		$parsedDescription = User::getRightDescription( $key );
		$this->assertMatchesRegularExpression( '/[|]/', $parsedDescription );
	}

	/**
	 * @covers \MediaWiki\User\User::getRightDescriptionHtml
	 */
	public function testGetParsedRightDescription() {
		$key = 'deletechangetags';
		$parsedDescription = User::getRightDescriptionHtml( $key );
		$this->assertMatchesRegularExpression( '/<.*>/', $parsedDescription );
	}

	/**
	 * Test password validity checks. There are 3 checks in core:
	 *	- ensure the password meets the minimal length
	 *	- ensure the password is not the same as the username
	 *	- ensure the username/password combo isn't forbidden
	 * @covers \MediaWiki\User\User::checkPasswordValidity()
	 * @covers \MediaWiki\User\User::isValidPassword()
	 */
	public function testCheckPasswordValidity() {
		$this->overrideConfigValue(
			MainConfigNames::PasswordPolicy,
			[
				'policies' => [
					'sysop' => [
						'MinimalPasswordLength' => 8,
						'MinimumPasswordLengthToLogin' => 1,
						'PasswordCannotBeSubstringInUsername' => 1,
					],
					'default' => [
						'MinimalPasswordLength' => 6,
						'PasswordCannotBeSubstringInUsername' => true,
						'PasswordCannotMatchDefaults' => true,
						'MaximalPasswordLength' => 40,
					],
				],
				'checks' => [
					'MinimalPasswordLength' => 'MediaWiki\Password\PasswordPolicyChecks::checkMinimalPasswordLength',
					'MinimumPasswordLengthToLogin' => 'MediaWiki\Password\PasswordPolicyChecks::checkMinimumPasswordLengthToLogin',
					'PasswordCannotBeSubstringInUsername' =>
						'MediaWiki\Password\PasswordPolicyChecks::checkPasswordCannotBeSubstringInUsername',
					'PasswordCannotMatchDefaults' => 'MediaWiki\Password\PasswordPolicyChecks::checkPasswordCannotMatchDefaults',
					'MaximalPasswordLength' => 'MediaWiki\Password\PasswordPolicyChecks::checkMaximalPasswordLength',
				],
			]
		);

		$this->assertTrue( $this->user->isValidPassword( 'Password1234' ) );

		// Minimum length
		$this->assertFalse( $this->user->isValidPassword( 'a' ) );
		$status = $this->user->checkPasswordValidity( 'a' );
		$this->assertStatusWarning( 'passwordtooshort', $status );

		// Maximum length
		$longPass = str_repeat( 'a', 41 );
		$this->assertFalse( $this->user->isValidPassword( $longPass ) );
		$status = $this->user->checkPasswordValidity( $longPass );
		$this->assertStatusError( 'passwordtoolong', $status );

		// Matches username
		$status = $this->user->checkPasswordValidity( $this->user->getName() );
		$this->assertStatusWarning( 'password-substring-username-match', $status );

		$this->setTemporaryHook( 'isValidPassword', static function ( $password, &$result, $user ) {
			$result = 'isValidPassword returned false';
			return false;
		} );
		$status = $this->user->checkPasswordValidity( 'Password1234' );
		$this->assertStatusWarning( 'isValidPassword returned false', $status );

		$this->removeTemporaryHook( 'isValidPassword' );

		$this->setTemporaryHook( 'isValidPassword', static function ( $password, &$result, $user ) {
			$result = true;
			return true;
		} );
		$status = $this->user->checkPasswordValidity( 'Password1234' );
		$this->assertStatusGood( $status );

		$this->removeTemporaryHook( 'isValidPassword' );

		$this->setTemporaryHook( 'isValidPassword', static function ( $password, &$result, $user ) {
			$result = 'isValidPassword returned true';
			return true;
		} );
		$status = $this->user->checkPasswordValidity( 'Password1234' );
		$this->assertStatusWarning( 'isValidPassword returned true', $status );

		$this->removeTemporaryHook( 'isValidPassword' );

		// On the forbidden list
		$user = User::newFromName( 'Useruser' );
		$status = $user->checkPasswordValidity( 'Passpass' );
		$this->assertStatusWarning( 'password-login-forbidden', $status );
	}

	/**
	 * @covers \MediaWiki\User\User::equals
	 */
	public function testEquals() {
		$first = $this->getMutableTestUser()->getUser();
		$second = User::newFromName( $first->getName() );

		$this->assertTrue( $first->equals( $first ) );
		$this->assertTrue( $first->equals( $second ) );
		$this->assertTrue( $second->equals( $first ) );

		$third = $this->getMutableTestUser()->getUser();
		$fourth = $this->getMutableTestUser()->getUser();

		$this->assertFalse( $third->equals( $fourth ) );
		$this->assertFalse( $fourth->equals( $third ) );

		// Test users loaded from db with id
		$user = $this->getMutableTestUser()->getUser();
		$fifth = User::newFromId( $user->getId() );
		$sixth = User::newFromName( $user->getName() );
		$this->assertTrue( $fifth->equals( $sixth ) );
	}

	/**
	 * @covers \MediaWiki\User\User::getId
	 * @covers \MediaWiki\User\User::setId
	 */
	public function testUserId() {
		$this->assertGreaterThan( 0, $this->user->getId() );

		$user = User::newFromName( 'UserWithNoId' );
		$this->assertSame( 0, $user->getId() );

		$user->setId( 7 );
		$this->assertSame(
			7,
			$user->getId(),
			'Manually setting a user id via ::setId is reflected in ::getId'
		);

		$user = new User;
		$user->setName( '1.2.3.4' );
		$this->assertSame(
			0,
			$user->getId(),
			'IPs have an id of 0'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::isRegistered
	 * @covers \MediaWiki\User\User::isAnon
	 * @covers \MediaWiki\User\User::logOut
	 */
	public function testIsRegistered() {
		$user = $this->getMutableTestUser()->getUser();
		$this->assertTrue( $user->isRegistered() );
		$this->assertFalse( $user->isAnon() );

		$this->setTemporaryHook( 'UserLogout', static function ( &$user ) {
			return false;
		} );
		$user->logout();
		$this->assertTrue( $user->isRegistered() );

		$this->removeTemporaryHook( 'UserLogout' );
		$user->logout();
		$this->assertFalse( $user->isRegistered() );

		// Non-existent users are perceived as anonymous
		$user = User::newFromName( 'UTNonexistent' );
		$this->assertFalse( $user->isRegistered() );
		$this->assertTrue( $user->isAnon() );

		$user = new User;
		$this->assertFalse( $user->isRegistered() );
		$this->assertTrue( $user->isAnon() );
	}

	/**
	 * @covers \MediaWiki\User\User::setRealName
	 * @covers \MediaWiki\User\User::getRealName
	 */
	public function testRealName() {
		$user = $this->getMutableTestUser()->getUser();
		$realName = 'John Doe';

		$user->setRealName( $realName );
		$this->assertSame(
			$realName,
			$user->getRealName(),
			'Real name retrieved from cache'
		);

		$id = $user->getId();
		$user->saveSettings();

		$otherUser = User::newFromId( $id );
		$this->assertSame(
			$realName,
			$otherUser->getRealName(),
			'Real name retrieved from database'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::checkAndSetTouched
	 * @covers \MediaWiki\User\User::getDBTouched()
	 */
	public function testCheckAndSetTouched() {
		$user = $this->getMutableTestUser()->getUser();
		$user = TestingAccessWrapper::newFromObject( $user );
		$this->assertTrue( $user->isRegistered() );

		$touched = $user->getDBTouched();
		$this->assertTrue(
			$user->checkAndSetTouched(), "checkAndSetTouched() succedeed" );
		$this->assertGreaterThan(
			$touched, $user->getDBTouched(), "user_touched increased with casOnTouched()" );

		$touched = $user->getDBTouched();
		$this->assertTrue(
			$user->checkAndSetTouched(), "checkAndSetTouched() succedeed #2" );
		$this->assertGreaterThan(
			$touched, $user->getDBTouched(), "user_touched increased with casOnTouched() #2" );
	}

	/**
	 * @covers \MediaWiki\User\User::validateCache
	 * @covers \MediaWiki\User\User::getTouched
	 */
	public function testValidateCache() {
		$user = $this->getTestUser()->getUser();

		$initialTouchMW = $user->getTouched();
		$initialTouchUnix = ( new MWTimestamp( $initialTouchMW ) )->getTimestamp();

		$earlierUnix = $initialTouchUnix - 1000;
		$earlierMW = ( new MWTimestamp( $earlierUnix ) )->getTimestamp( TS_MW );
		$this->assertFalse(
			$user->validateCache( $earlierMW ),
			'Caches from before the value of getTouched() are not valid'
		);

		$laterUnix = $initialTouchUnix + 1000;
		$laterMW = ( new MWTimestamp( $laterUnix ) )->getTimestamp( TS_MW );
		$this->assertTrue(
			$user->validateCache( $laterMW ),
			'Caches from after the value of getTouched() are valid'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::findUsersByGroup
	 */
	public function testFindUsersByGroup() {
		$users = User::findUsersByGroup( [] );
		$this->assertSame( 0, iterator_count( $users ) );

		$users = User::findUsersByGroup( 'foo', 1, 1 );
		$this->assertSame( 0, iterator_count( $users ) );

		$user = $this->getMutableTestUser( [ 'foo' ] )->getUser();
		$users = User::findUsersByGroup( 'foo' );
		$this->assertSame( 1, iterator_count( $users ) );
		$users->rewind();
		$this->assertTrue( $user->equals( $users->current() ) );

		// arguments have OR relationship
		$user2 = $this->getMutableTestUser( [ 'bar' ] )->getUser();
		$users = User::findUsersByGroup( [ 'foo', 'bar' ] );
		$this->assertSame( 2, iterator_count( $users ) );
		$users->rewind();
		$this->assertTrue( $user->equals( $users->current() ) );
		$users->next();
		$this->assertTrue( $user2->equals( $users->current() ) );

		// users are not duplicated
		$user = $this->getMutableTestUser( [ 'baz', 'boom' ] )->getUser();
		$users = User::findUsersByGroup( [ 'baz', 'boom' ] );
		$this->assertSame( 1, iterator_count( $users ) );
		$users->rewind();
		$this->assertTrue( $user->equals( $users->current() ) );
	}

	/**
	 * @covers \MediaWiki\User\User::getBlock
	 */
	public function testSoftBlockRanges() {
		$this->overrideConfigValue( MainConfigNames::SoftBlockRanges, [ '10.0.0.0/8' ] );

		// IP isn't in $wgSoftBlockRanges
		$user = new User();
		$request = new FauxRequest();
		$request->setIP( '192.168.0.1' );
		$this->setSessionUser( $user, $request );
		$this->assertNull( $user->getBlock() );

		// IP is in $wgSoftBlockRanges
		$user = new User();
		$request = new FauxRequest();
		$request->setIP( '10.20.30.40' );
		$this->setSessionUser( $user, $request );
		$block = $user->getBlock();
		$this->assertInstanceOf( SystemBlock::class, $block );
		$this->assertSame( 'wgSoftBlockRanges', $block->getSystemBlockType() );

		// IP is in $wgSoftBlockRanges and user is temporary
		$this->enableAutoCreateTempUser();
		$user = ( new TestUser( '~1' ) )->getUser();
		$request = new FauxRequest();
		$request->setIP( '10.20.30.40' );
		$this->setSessionUser( $user, $request );
		$block = $user->getBlock();
		$this->assertTrue( $user->isTemp() );
		$this->assertInstanceOf( SystemBlock::class, $block );
		$this->assertSame( 'wgSoftBlockRanges', $block->getSystemBlockType() );

		// Make sure the block is really soft
		$request = new FauxRequest();
		$request->setIP( '10.20.30.40' );
		$this->setSessionUser( $this->user, $request );
		$this->assertFalse( $this->user->isAnon() );
		$this->assertNull( $this->user->getBlock() );
	}

	public static function provideIsPingLimitable() {
		yield 'Not ip excluded' => [ [], null, true ];
		yield 'Ip excluded' => [ [ '1.2.3.4' ], null, false ];
		yield 'Ip subnet excluded' => [ [ '1.2.3.0/8' ], null, false ];
		yield 'noratelimit right' => [ [], 'noratelimit', false ];
	}

	/**
	 * @dataProvider provideIsPingLimitable
	 * @covers \MediaWiki\User\User::isPingLimitable
	 * @param array $rateLimitExcludeIps
	 * @param string|null $rightOverride
	 * @param bool $expected
	 */
	public function testIsPingLimitable(
		array $rateLimitExcludeIps,
		?string $rightOverride,
		bool $expected
	) {
		$request = new FauxRequest();
		$request->setIP( '1.2.3.4' );
		$user = User::newFromSession( $request );
		// We are trying to test for current user behaviour
		// since we are interested in request IP
		RequestContext::getMain()->setUser( $user );

		$this->overrideConfigValue( MainConfigNames::RateLimitsExcludedIPs, $rateLimitExcludeIps );
		if ( $rightOverride ) {
			$this->overrideUserPermissions( $user, $rightOverride );
		}
		$this->assertSame( $expected, $user->isPingLimitable() );
	}

	public static function provideExperienceLevel() {
		return [
			[ 2, 2, 'newcomer' ],
			[ 12, 3, 'newcomer' ],
			[ 8, 5, 'newcomer' ],
			[ 15, 10, 'learner' ],
			[ 450, 20, 'learner' ],
			[ 460, 33, 'learner' ],
			[ 525, 28, 'learner' ],
			[ 538, 33, 'experienced' ],
			[ 9, null, 'newcomer' ],
			[ 10, null, 'learner' ],
			[ 501, null, 'experienced' ],
		];
	}

	/**
	 * @covers \MediaWiki\User\User::getExperienceLevel
	 * @dataProvider provideExperienceLevel
	 */
	public function testExperienceLevel( $editCount, $memberSince, $expLevel ) {
		$this->overrideConfigValues( [
			MainConfigNames::LearnerEdits => 10,
			MainConfigNames::LearnerMemberSince => 4,
			MainConfigNames::ExperiencedUserEdits => 500,
			MainConfigNames::ExperiencedUserMemberSince => 30,
		] );

		$db = $this->getDb();
		$row = User::newQueryBuilder( $db )
			->where( [ 'user_id' => $this->user->getId() ] )
			->caller( __METHOD__ )
			->fetchRow();
		$row->user_editcount = $editCount;
		if ( $memberSince !== null ) {
			$row->user_registration = $db->timestamp( time() - $memberSince * 86400 );
		} else {
			$row->user_registration = null;
		}
		$user = User::newFromRow( $row );

		$this->assertSame( $expLevel, $user->getExperienceLevel() );
	}

	/**
	 * @covers \MediaWiki\User\User::getExperienceLevel
	 */
	public function testExperienceLevelAnon() {
		$user = User::newFromName( '10.11.12.13', false );

		$this->assertFalse( $user->getExperienceLevel() );
	}

	public static function provideIsLocallyBlockedProxy() {
		return [
			[ '1.2.3.4', '1.2.3.4' ],
			[ '1.2.3.4', '1.2.3.0/16' ],
		];
	}

	/**
	 * @covers \MediaWiki\User\User::newFromId
	 */
	public function testNewFromId() {
		$userId = $this->user->getId();
		$this->assertGreaterThan(
			0,
			$userId,
			'user has a working id'
		);

		$otherUser = User::newFromId( $userId );
		$this->assertTrue(
			$this->user->equals( $otherUser ),
			'User created by id should match user with that id'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::newFromActorId
	 */
	public function testActorId() {
		$this->filterDeprecated( '/Passing a parameter to getActorId\(\) is deprecated/', '1.36' );

		// Newly-created user has an actor ID
		$user = User::createNew( 'UserTestActorId1' );
		$id = $user->getId();
		$this->assertGreaterThan( 0, $user->getActorId(), 'User::createNew sets an actor ID' );

		$user = User::newFromName( 'UserTestActorId2' );
		$user->addToDatabase();
		$this->assertGreaterThan( 0, $user->getActorId(), 'User::addToDatabase sets an actor ID' );

		$user = User::newFromName( 'UserTestActorId1' );
		$this->assertGreaterThan( 0, $user->getActorId(),
			'Actor ID can be retrieved for user loaded by name' );

		$user = User::newFromId( $id );
		$this->assertGreaterThan( 0, $user->getActorId(),
			'Actor ID can be retrieved for user loaded by ID' );

		$user2 = User::newFromActorId( $user->getActorId() );
		$this->assertSame( $user->getId(), $user2->getId(),
			'User::newFromActorId works for an existing user' );

		$row = User::newQueryBuilder( $this->getDb() )
			->where( [ 'user_id' => $id ] )
			->caller( __METHOD__ )
			->fetchRow();
		$user = User::newFromRow( $row );
		$this->assertGreaterThan( 0, $user->getActorId(),
			'Actor ID can be retrieved for user loaded with User::selectFields()' );

		$user = User::newFromId( $id );
		$user->setName( 'UserTestActorId4-renamed' );
		$user->saveSettings();
		$this->assertSame(
			$user->getName(),
			$this->getDb()->newSelectQueryBuilder()
				->select( 'actor_name' )
				->from( 'actor' )
				->where( [ 'actor_id' => $user->getActorId() ] )
				->caller( __METHOD__ )->fetchField(),
			'User::saveSettings updates actor table for name change'
		);

		$ip = '192.168.12.34';
		$this->getDb()->newDeleteQueryBuilder()
			->deleteFrom( 'actor' )
			->where( [ 'actor_name' => $ip ] )
			->caller( __METHOD__ )
			->execute();

		// Next tests require disabling temp user feature.
		$this->disableAutoCreateTempUser();
		$user = User::newFromName( $ip, false );
		$this->assertSame( 0, $user->getActorId(), 'Anonymous user has no actor ID by default' );
		$this->filterDeprecated( '/Passing parameter of type IDatabase/' );
		$this->assertGreaterThan( 0, $user->getActorId( $this->getDb() ),
			'Actor ID can be created for an anonymous user' );

		$user = User::newFromName( $ip, false );
		$this->assertGreaterThan( 0, $user->getActorId(),
			'Actor ID can be loaded for an anonymous user' );
		$user2 = User::newFromActorId( $user->getActorId() );
		$this->assertSame( $user->getName(), $user2->getName(),
			'User::newFromActorId works for an anonymous user' );
	}

	/**
	 * @covers \MediaWiki\User\User::getActorId
	 */
	public function testForeignGetActorId() {
		$this->filterDeprecated( '/Passing a parameter to getActorId\(\) is deprecated/', '1.36' );

		$user = User::newFromName( 'UserTestActorId1' );
		$this->expectException( PreconditionException::class );
		$user->getActorId( 'Foreign Wiki' );
	}

	/**
	 * @covers \MediaWiki\User\User::getWikiId
	 */
	public function testGetWiki() {
		$user = User::newFromName( 'UserTestActorId1' );
		$this->assertSame( User::LOCAL, $user->getWikiId() );
	}

	/**
	 * @covers \MediaWiki\User\User::assertWiki
	 */
	public function testAssertWiki() {
		$user = User::newFromName( 'UserTestActorId1' );

		$user->assertWiki( User::LOCAL );
		$this->assertTrue( true, 'User is for local wiki' );

		$this->expectException( PreconditionException::class );
		$user->assertWiki( 'Foreign Wiki' );
	}

	/**
	 * @covers \MediaWiki\User\User::newFromAnyId
	 */
	public function testNewFromAnyId() {
		$this->disableAutoCreateTempUser();
		// Registered user
		$user = $this->user;
		for ( $i = 1; $i <= 7; $i++ ) {
			$test = User::newFromAnyId(
				( $i & 1 ) ? $user->getId() : null,
				( $i & 2 ) ? $user->getName() : null,
				( $i & 4 ) ? $user->getActorId() : null
			);
			$this->assertSame( $user->getId(), $test->getId() );
			$this->assertSame( $user->getName(), $test->getName() );
			$this->assertSame( $user->getActorId(), $test->getActorId() );
		}

		// Anon user. Can't load by only user ID when that's 0.
		$user = User::newFromName( '192.168.12.34', false );
		// Make sure an actor ID exists
		$this->getServiceContainer()->getActorNormalization()->acquireActorId( $user, $this->getDb() );

		$test = User::newFromAnyId( null, '192.168.12.34', null );
		$this->assertSame( $user->getId(), $test->getId() );
		$this->assertSame( $user->getName(), $test->getName() );
		$this->assertSame( $user->getActorId(), $test->getActorId() );
		$test = User::newFromAnyId( null, null, $user->getActorId() );
		$this->assertSame( $user->getId(), $test->getId() );
		$this->assertSame( $user->getName(), $test->getName() );
		$this->assertSame( $user->getActorId(), $test->getActorId() );

		// Bogus data should still "work" as long as nothing triggers a ->load(),
		// and accessing the specified data shouldn't do that.
		$test = User::newFromAnyId( 123456, 'Bogus', 654321 );
		$this->assertSame( 123456, $test->getId() );
		$this->assertSame( 'Bogus', $test->getName() );
		$this->assertSame( 654321, $test->getActorId() );

		// Loading remote user by name from remote wiki should succeed
		$test = User::newFromAnyId( null, 'Bogus', null, 'foo' );
		$this->assertSame( 0, $test->getId() );
		$this->assertSame( 'Bogus', $test->getName() );
		$this->assertSame( 0, $test->getActorId() );
		$test = User::newFromAnyId( 123456, 'Bogus', 654321, 'foo' );
		$this->assertSame( 0, $test->getId() );
		$this->assertSame( 0, $test->getActorId() );

		// Exceptional cases
		try {
			User::newFromAnyId( null, null, null );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
		}
		try {
			User::newFromAnyId( 0, null, 0 );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
		}

		// Loading remote user by id from remote wiki should fail
		try {
			User::newFromAnyId( 123456, null, 654321, 'foo' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
		}
	}

	/**
	 * @covers \MediaWiki\User\User::newFromIdentity
	 */
	public function testNewFromIdentity() {
		// Registered user
		$user = $this->user;

		$this->assertSame( $user, User::newFromIdentity( $user ) );

		// ID only
		$identity = new UserIdentityValue( $user->getId(), '' );
		$result = User::newFromIdentity( $identity );
		$this->assertInstanceOf( User::class, $result );
		$this->assertSame( $user->getId(), $result->getId(), 'ID' );
		$this->assertSame( $user->getName(), $result->getName(), 'Name' );
		$this->assertSame( $user->getActorId(), $result->getActorId(), 'Actor' );

		// Name only
		$identity = new UserIdentityValue( 0, $user->getName() );
		$result = User::newFromIdentity( $identity );
		$this->assertInstanceOf( User::class, $result );
		$this->assertSame( $user->getId(), $result->getId(), 'ID' );
		$this->assertSame( $user->getName(), $result->getName(), 'Name' );
		$this->assertSame( $user->getActorId(), $result->getActorId(), 'Actor' );
	}

	/**
	 * @covers \MediaWiki\User\User::newFromConfirmationCode
	 */
	public function testNewFromConfirmationCode() {
		$user = User::newFromConfirmationCode( 'NotARealConfirmationCode' );
		$this->assertNull(
			$user,
			'Invalid confirmation codes result in null users when reading from replicas'
		);

		$user = User::newFromConfirmationCode( 'OtherFakeCode', IDBAccessObject::READ_LATEST );
		$this->assertNull(
			$user,
			'Invalid confirmation codes result in null users when reading from master'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::newFromName
	 * @covers \MediaWiki\User\User::getName
	 * @covers \MediaWiki\User\User::getUserPage
	 * @covers \MediaWiki\User\User::getTalkPage
	 * @covers \MediaWiki\User\User::getTitleKey
	 * @covers \MediaWiki\User\User::whoIs
	 * @dataProvider provideNewFromName
	 */
	public function testNewFromName( $name, $titleKey ) {
		$user = User::newFromName( $name );
		$this->assertSame( $user->getName(), $name );
		$this->assertEquals( $user->getUserPage(), Title::makeTitle( NS_USER, $name ) );
		$this->assertEquals( $user->getTalkPage(), Title::makeTitle( NS_USER_TALK, $name ) );
		$this->assertSame( $user->getTitleKey(), $titleKey );

		$status = $user->addToDatabase();
		$this->assertStatusOK( $status, 'User can be added to the database' );
		$this->assertSame( $name, User::whoIs( $user->getId() ) );
	}

	public static function provideNewFromName() {
		return [
			[ 'Example1', 'Example1' ],
			[ 'MediaWiki easter egg', 'MediaWiki_easter_egg' ],
			[ 'See T22281 for more', 'See_T22281_for_more' ],
			[ 'DannyS712', 'DannyS712' ],
		];
	}

	/**
	 * @covers \MediaWiki\User\User::newFromName
	 */
	public function testNewFromName_extra() {
		$user = User::newFromName( '1.2.3.4' );
		$this->assertFalse( $user, 'IP addresses are not valid user names' );

		$user = User::newFromName( 'DannyS712', true );
		$otherUser = User::newFromName( 'DannyS712', 'valid' );
		$this->assertTrue(
			$user->equals( $otherUser ),
			'true maps to valid for backwards compatibility'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::newFromSession
	 * @covers \MediaWiki\User\User::getRequest
	 */
	public function testSessionAndRequest() {
		$req1 = new WebRequest;
		$this->setRequest( $req1 );
		$user = User::newFromSession();
		$request = $user->getRequest();

		$this->assertSame(
			$req1,
			$request,
			'Creating a user without a request defaults to $wgRequest'
		);
		$req2 = new WebRequest;
		$this->assertNotSame(
			$req1,
			$req2,
			'passing a request that does not match $wgRequest'
		);
		$user = User::newFromSession( $req2 );
		$request = $user->getRequest();
		$this->assertSame(
			$req2,
			$request,
			'Creating a user by passing a WebRequest successfully sets the request, ' .
				'instead of using $wgRequest'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::newFromRow
	 * @covers \MediaWiki\User\User::loadFromRow
	 */
	public function testNewFromRow() {
		// TODO: Create real tests here for loadFromRow
		$row = (object)[];
		$user = User::newFromRow( $row );
		$this->assertInstanceOf( User::class, $user, 'newFromRow returns a user object' );
	}

	/**
	 * @covers \MediaWiki\User\User::newFromRow
	 * @covers \MediaWiki\User\User::loadFromRow
	 */
	public function testNewFromRow_bad() {
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( '$row must be an object' );
		User::newFromRow( [] );
	}

	/**
	 * @covers \MediaWiki\User\User::getBlock
	 * @covers \MediaWiki\User\User::isHidden
	 */
	public function testBlockInstanceCache() {
		$this->hideDeprecated( User::class . '::isBlockedFrom' );
		// First, check the user isn't blocked
		$user = $this->getMutableTestUser()->getUser();
		$ut = Title::makeTitle( NS_USER_TALK, $user->getName() );
		$this->assertNull( $user->getBlock( false ) );
		$this->assertFalse( $user->isHidden() );

		// Block the user
		$blocker = $this->getTestSysop()->getUser();
		$block = new DatabaseBlock( [
			'hideName' => true,
			'allowUsertalk' => false,
			'reason' => 'Because',
		] );
		$block->setTarget( $user );
		$block->setBlocker( $blocker );
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$res = $blockStore->insertBlock( $block );
		$this->assertTrue( (bool)$res['id'], 'Failed to insert block' );

		// Clear cache and confirm it loaded the block properly
		$user->clearInstanceCache();
		$this->assertInstanceOf( DatabaseBlock::class, $user->getBlock( false ) );
		$this->assertTrue( $user->isHidden() );

		// Unblock
		$blockStore->deleteBlock( $block );

		// Clear cache and confirm it loaded the not-blocked properly
		$user->clearInstanceCache();
		$this->assertNull( $user->getBlock( false ) );
		$this->assertFalse( $user->isHidden() );
	}

	/**
	 * @covers \MediaWiki\User\User::getBlock
	 */
	public function testCompositeBlocks() {
		$user = $this->getMutableTestUser()->getUser();
		$request = $user->getRequest();
		$this->setSessionUser( $user, $request );

		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$ipBlock = new DatabaseBlock( [
			'address' => $user->getRequest()->getIP(),
			'by' => $this->getTestSysop()->getUser(),
			'createAccount' => true,
		] );
		$blockStore->insertBlock( $ipBlock );

		$userBlock = new DatabaseBlock( [
			'address' => $user,
			'by' => $this->getTestSysop()->getUser(),
			'createAccount' => false,
		] );
		$blockStore->insertBlock( $userBlock );

		$block = $user->getBlock();
		$this->assertInstanceOf( CompositeBlock::class, $block );
		$this->assertTrue( $block->isCreateAccountBlocked() );
		$this->assertTrue( $block->appliesToPasswordReset() );
		$this->assertTrue( $block->appliesToNamespace( NS_MAIN ) );
	}

	/**
	 * @covers \MediaWiki\User\User::getBlock
	 */
	public function testUserBlock() {
		$user = $this->getMutableTestUser()->getUser();
		$request = $user->getRequest();
		$this->setSessionUser( $user, $request );

		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$ipBlock = new DatabaseBlock( [
			'address' => $user,
			'by' => $this->getTestSysop()->getUser(),
			'createAccount' => true,
		] );
		$blockStore->insertBlock( $ipBlock );

		$block = $user->getBlock();
		$this->assertNotNull( $block, 'getuserBlock' );
		$this->assertNotNull( $block->getTargetUserIdentity(), 'getTargetUserIdentity()' );
		$this->assertSame( $user->getName(), $block->getTargetUserIdentity()->getName() );
	}

	public static function provideIsBlockedFrom() {
		return [
			'Sitewide block, basic operation' => [ 'Test page', true ],
			'Sitewide block, not allowing user talk' => [
				self::USER_TALK_PAGE, true, [
					'allowUsertalk' => false,
				]
			],
			'Sitewide block, allowing user talk' => [
				self::USER_TALK_PAGE, false, [
					'allowUsertalk' => true,
				]
			],
			'Sitewide block, allowing user talk but $wgBlockAllowsUTEdit is false' => [
				self::USER_TALK_PAGE, true, [
					'allowUsertalk' => true,
					'blockAllowsUTEdit' => false,
				]
			],
			'Partial block, blocking the page' => [
				'Test page', true, [
					'pageRestrictions' => [ 'Test page' ],
				]
			],
			'Partial block, not blocking the page' => [
				'Test page 2', false, [
					'pageRestrictions' => [ 'Test page' ],
				]
			],
			'Partial block, not allowing user talk but user talk page is not blocked' => [
				self::USER_TALK_PAGE, false, [
					'allowUsertalk' => false,
					'pageRestrictions' => [ 'Test page' ],
				]
			],
			'Partial block, allowing user talk but user talk page is blocked' => [
				self::USER_TALK_PAGE, true, [
					'allowUsertalk' => true,
					'pageRestrictions' => [ self::USER_TALK_PAGE ],
				]
			],
			'Partial block, user talk page is not blocked but $wgBlockAllowsUTEdit is false' => [
				self::USER_TALK_PAGE, false, [
					'allowUsertalk' => false,
					'pageRestrictions' => [ 'Test page' ],
					'blockAllowsUTEdit' => false,
				]
			],
			'Partial block, user talk page is blocked and $wgBlockAllowsUTEdit is false' => [
				self::USER_TALK_PAGE, true, [
					'allowUsertalk' => true,
					'pageRestrictions' => [ self::USER_TALK_PAGE ],
					'blockAllowsUTEdit' => false,
				]
			],
			'Partial user talk namespace block, not allowing user talk' => [
				self::USER_TALK_PAGE, true, [
					'allowUsertalk' => false,
					'namespaceRestrictions' => [ NS_USER_TALK ],
				]
			],
			'Partial user talk namespace block, allowing user talk' => [
				self::USER_TALK_PAGE, false, [
					'allowUsertalk' => true,
					'namespaceRestrictions' => [ NS_USER_TALK ],
				]
			],
			'Partial user talk namespace block, where $wgBlockAllowsUTEdit is false' => [
				self::USER_TALK_PAGE, true, [
					'allowUsertalk' => true,
					'namespaceRestrictions' => [ NS_USER_TALK ],
					'blockAllowsUTEdit' => false,
				]
			],
		];
	}

	/**
	 * @covers \MediaWiki\User\User::isBlockedFromEmailuser
	 * @covers \MediaWiki\User\User::isAllowedToCreateAccount
	 * @dataProvider provideIsBlockedFromAction
	 * @param bool $blockFromEmail Whether to block email access.
	 * @param bool $blockFromAccountCreation Whether to block account creation.
	 */
	public function testIsBlockedFromAction( $blockFromEmail, $blockFromAccountCreation ) {
		$this->hideDeprecated( User::class . '::isBlockedFromEmailuser' );
		$user = $this->getMutableTestUser( 'accountcreator' )->getUser();

		$block = new DatabaseBlock( [
			'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ),
			'sitewide' => true,
			'blockEmail' => $blockFromEmail,
			'createAccount' => $blockFromAccountCreation
		] );
		$block->setTarget( $user );
		$block->setBlocker( $this->getTestSysop()->getUser() );
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockStore->insertBlock( $block );

		$this->assertSame( $blockFromEmail, $user->isBlockedFromEmailuser() );
		$this->assertSame( !$blockFromAccountCreation, $user->isAllowedToCreateAccount() );
	}

	public static function provideIsBlockedFromAction() {
		return [
			'Block email access and account creation' => [ true, true ],
			'Block only email access' => [ true, false ],
			'Block only account creation' => [ false, true ],
			'Allow email access and account creation' => [ false, false ],
		];
	}

	/**
	 * @covers \MediaWiki\User\User::isBlockedFromUpload
	 * @dataProvider provideIsBlockedFromUpload
	 * @param bool $sitewide Whether to block sitewide.
	 * @param bool $expected Whether the user is expected to be blocked from uploads.
	 */
	public function testIsBlockedFromUpload( $sitewide, $expected ) {
		$user = $this->getMutableTestUser()->getUser();

		$block = new DatabaseBlock( [
			'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ),
			'sitewide' => $sitewide,
		] );
		$block->setTarget( $user );
		$block->setBlocker( $this->getTestSysop()->getUser() );
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockStore->insertBlock( $block );

		$this->assertSame( $expected, $user->isBlockedFromUpload() );
	}

	public static function provideIsBlockedFromUpload() {
		return [
			'sitewide blocks block uploads' => [ true, true ],
			'partial blocks allow uploads' => [ false, false ],
		];
	}

	/**
	 * @covers \MediaWiki\User\User::isSystemUser
	 */
	public function testIsSystemUser() {
		$this->assertFalse( $this->user->isSystemUser(), 'Normal users are not system users' );

		$user = User::newSystemUser( __METHOD__ );
		$this->assertTrue( $user->isSystemUser(), 'Users created with newSystemUser() are system users' );
	}

	/**
	 * @covers \MediaWiki\User\User::newSystemUser
	 * @dataProvider provideNewSystemUser
	 * @param string $exists How/whether to create the user before calling User::newSystemUser
	 *  - 'missing': Do not create the user
	 *  - 'actor': Create an anonymous actor
	 *  - 'user': Create a non-system user
	 *  - 'system': Create a system user
	 * @param string $options Options to User::newSystemUser
	 * @param array $testOpts Test options
	 * @param string $expect 'user', 'exception', or 'null'
	 */
	public function testNewSystemUser( $exists, $options, $testOpts, $expect ) {
		$this->filterDeprecated( '/User::newSystemUser options/' );
		$origUser = null;
		$actorId = null;

		switch ( $exists ) {
			case 'missing':
				$name = 'TestNewSystemUser ' . TestUserRegistry::getNextId();
				break;

			case 'actor':
				$name = 'TestNewSystemUser ' . TestUserRegistry::getNextId();
				$this->getDb()->newInsertQueryBuilder()
					->insertInto( 'actor' )
					->row( [ 'actor_name' => $name ] )
					->caller( __METHOD__ )
					->execute();
				$actorId = (int)$this->getDb()->insertId();
				break;

			case 'user':
				$origUser = $this->getMutableTestUser()->getUser();
				$name = $origUser->getName();
				$actorId = $origUser->getActorId();
				break;

			case 'system':
				$name = 'TestNewSystemUser ' . TestUserRegistry::getNextId();
				$user = User::newSystemUser( $name ); // Heh.
				$actorId = $user->getActorId();
				// Use this hook as a proxy for detecting when a "steal" happens.
				$this->setTemporaryHook( 'InvalidateEmailComplete', function () {
					$this->fail( 'InvalidateEmailComplete hook should not have been called' );
				} );
				break;
		}

		$globals = $testOpts['globals'] ?? [];
		if ( !empty( $testOpts['reserved'] ) ) {
			$globals[MainConfigNames::ReservedUsernames] = [ $name ];
		}
		$this->overrideConfigValues( $globals );
		$userNameUtils = $this->getServiceContainer()->getUserNameUtils();
		$this->assertSame( empty( $testOpts['reserved'] ), $userNameUtils->isUsable( $name ) );
		$this->assertTrue( $userNameUtils->isValid( $name ) );

		if ( $expect === 'exception' ) {
			// T248195: Duplicate entry errors will log the exception, don't fail because of that.
			$this->setNullLogger( 'rdbms' );
			$this->expectException( Exception::class );
		}
		$user = User::newSystemUser( $name, $options );
		if ( $expect === 'null' ) {
			$this->assertNull( $user );
			if ( $origUser ) {
				$this->assertNotSame(
					User::INVALID_TOKEN, TestingAccessWrapper::newFromObject( $origUser )->mToken
				);
				$this->assertNotSame( '', $origUser->getEmail() );
				$this->assertFalse( $origUser->isSystemUser(), 'Normal users should not be system users' );
			}
		} else {
			$this->assertInstanceOf( User::class, $user );
			$this->assertSame( $name, $user->getName() );
			if ( $actorId !== null ) {
				$this->assertSame( $actorId, $user->getActorId() );
			}
			$this->assertSame( User::INVALID_TOKEN, TestingAccessWrapper::newFromObject( $user )->mToken );
			$this->assertSame( '', $user->getEmail() );
			$this->assertTrue( $user->isSystemUser(), 'Newly created system users should be system users' );
		}
	}

	public static function provideNewSystemUser() {
		return [
			'Basic creation' => [ 'missing', [], [], 'user' ],
			'No creation' => [ 'missing', [ 'create' => false ], [], 'null' ],
			'Validation fail' => [
				'missing',
				[ 'validate' => 'usable' ],
				[ 'reserved' => true ],
				'null'
			],
			'No stealing' => [ 'user', [], [], 'null' ],
			'Stealing allowed' => [ 'user', [ 'steal' => true ], [], 'user' ],
			'Stealing an already-system user' => [ 'system', [ 'steal' => true ], [], 'user' ],
			'Anonymous actor (T236444)' => [ 'actor', [], [ 'reserved' => true ], 'user' ],
			'System user (T236444), reserved' => [ 'system', [], [ 'reserved' => true ], 'user' ],
			'Reserved but no anonymous actor' => [ 'missing', [], [ 'reserved' => true ], 'user' ],
			'Anonymous actor but no creation' => [ 'actor', [ 'create' => false ], [], 'null' ],
			'Anonymous actor but not reserved' => [ 'actor', [], [], 'exception' ],
		];
	}

	/**
	 * @covers \MediaWiki\User\User::getName
	 * @covers \MediaWiki\User\User::setName
	 */
	public function testUserName() {
		$user = User::newFromName( 'DannyS712' );
		$this->assertSame(
			'DannyS712',
			$user->getName(),
			'Santiy check: Users created using ::newFromName should return the name used'
		);

		$user->setName( 'FooBarBaz' );
		$this->assertSame(
			'FooBarBaz',
			$user->getName(),
			'Changing a username via ::setName should be reflected in ::getName'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::getEmail
	 * @covers \MediaWiki\User\User::setEmail
	 * @covers \MediaWiki\User\User::invalidateEmail
	 */
	public function testUserEmail() {
		$user = $this->user;

		$user->setEmail( 'TestEmail@mediawiki.org' );
		$this->assertSame(
			'TestEmail@mediawiki.org',
			$user->getEmail(),
			'Setting an email via ::setEmail should be reflected in ::getEmail'
		);

		$this->setTemporaryHook( 'UserSetEmail', function ( $user, &$email ) {
			$this->fail(
				'UserSetEmail hook should not be called when the new email ' .
				'is the same as the old email.'
			);
		} );
		$user->setEmail( 'TestEmail@mediawiki.org' );

		$this->removeTemporaryHook( 'UserSetEmail' );

		$this->setTemporaryHook( 'UserSetEmail', static function ( $user, &$email ) {
			$email = 'SettingIntercepted@mediawiki.org';
		} );
		$user->setEmail( 'NewEmail@mediawiki.org' );
		$this->assertSame(
			'SettingIntercepted@mediawiki.org',
			$user->getEmail(),
			'Hooks can override setting email addresses'
		);

		$this->setTemporaryHook( 'UserGetEmail', static function ( $user, &$email ) {
			$email = 'GettingIntercepted@mediawiki.org';
		} );
		$this->assertSame(
			'GettingIntercepted@mediawiki.org',
			$user->getEmail(),
			'Hooks can override getting email address'
		);

		$this->removeTemporaryHook( 'UserGetEmail' );
		$this->removeTemporaryHook( 'UserSetEmail' );

		$user->invalidateEmail();
		$this->assertSame(
			'',
			$user->getEmail(),
			'After invalidation, a user email should be an empty string'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::setEmailWithConfirmation
	 */
	public function testSetEmailWithConfirmation_basic() {
		$user = $this->getTestUser()->getUser();
		$startingEmail = 'startingemail@mediawiki.org';
		$user->setEmail( $startingEmail );

		$this->overrideConfigValues( [
			MainConfigNames::EnableEmail => false,
			MainConfigNames::EmailAuthentication => false
		] );
		$status = $user->setEmailWithConfirmation( 'test1@mediawiki.org' );
		$this->assertStatusError( 'emaildisabled', $status,
			'Cannot set email when email is disabled'
		);
		$this->assertSame(
			$user->getEmail(),
			$startingEmail,
			'Email has not changed'
		);

		$this->overrideConfigValue( MainConfigNames::EnableEmail, true );
		$status = $user->setEmailWithConfirmation( $startingEmail );
		$this->assertTrue(
			$status->getValue(),
			'Returns true if the email specified is the current email'
		);
		$this->assertSame(
			$user->getEmail(),
			$startingEmail,
			'Email has not changed'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::isItemLoaded
	 * @covers \MediaWiki\User\User::setItemLoaded
	 */
	public function testItemLoaded() {
		$user = User::newFromName( 'DannyS712' );
		$this->assertTrue(
			$user->isItemLoaded( 'name', 'only' ),
			'Users created by name have user names loaded'
		);
		$this->assertFalse(
			$user->isItemLoaded( 'all', 'all' ),
			'Not everything is loaded yet'
		);
		$user->load();
		$this->assertTrue(
			$user->isItemLoaded( 'FooBar', 'all' ),
			'All items now loaded'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::requiresHTTPS
	 * @dataProvider provideRequiresHTTPS
	 */
	public function testRequiresHTTPS( $preference, bool $expected ) {
		$this->overrideConfigValues( [
			MainConfigNames::SecureLogin => true,
			MainConfigNames::ForceHTTPS => false,
		] );

		$user = User::newFromName( 'UserWhoMayRequireHTTPS' );
		$user->addToDatabase();
		$this->getServiceContainer()->getUserOptionsManager()->setOption(
			$user,
			'prefershttps',
			$preference
		);
		$user->saveSettings();

		$this->assertTrue( $user->isRegistered() );
		$this->assertSame( $expected, $user->requiresHTTPS() );
	}

	public static function provideRequiresHTTPS() {
		return [
			'Wants, requires' => [ true, true ],
			'Does not want, not required' => [ false, false ],
		];
	}

	/**
	 * @covers \MediaWiki\User\User::requiresHTTPS
	 */
	public function testRequiresHTTPS_disabled() {
		$this->overrideConfigValues( [
			MainConfigNames::SecureLogin => false,
			MainConfigNames::ForceHTTPS => false,
		] );

		$user = User::newFromName( 'UserWhoMayRequireHTTP' );
		$user->addToDatabase();
		$this->getServiceContainer()->getUserOptionsManager()->setOption(
			$user,
			'prefershttps',
			true
		);
		$user->saveSettings();

		$this->assertTrue( $user->isRegistered() );
		$this->assertFalse(
			$user->requiresHTTPS(),
			'User preference ignored if wgSecureLogin  is false'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::requiresHTTPS
	 */
	public function testRequiresHTTPS_forced() {
		$this->overrideConfigValues( [
			MainConfigNames::SecureLogin => true,
			MainConfigNames::ForceHTTPS => true,
		] );

		$user = User::newFromName( 'UserWhoMayRequireHTTP' );
		$user->addToDatabase();
		$this->getServiceContainer()->getUserOptionsManager()->setOption(
			$user,
			'prefershttps',
			false
		);
		$user->saveSettings();

		$this->assertTrue( $user->isRegistered() );
		$this->assertTrue(
			$user->requiresHTTPS(),
			'User preference ignored if wgForceHTTPS is true'
		);
	}

	/**
	 * @covers \MediaWiki\User\User::addToDatabase
	 */
	public function testAddToDatabase_bad() {
		$user = new User();
		$this->expectException( RuntimeException::class );
		$this->expectExceptionMessage(
			'User name field is not set.'
		);
		$user->addToDatabase();
	}

	/**
	 * @covers \MediaWiki\User\User::pingLimiter
	 */
	public function testPingLimiter() {
		$user = $this->getTestUser()->getUser();

		$limiter = $this->createNoOpMock( RateLimiter::class, [ 'limit', 'isLimitable' ] );
		$limiter->method( 'isLimitable' )->willReturn( true );
		$limiter->method( 'limit' )->willReturnCallback(
			function ( RateLimitSubject $subject, $action ) use ( $user ) {
				$this->assertSame( $user, $subject->getUser() );
				return $action === 'limited';
			}
		);

		$this->setService( 'RateLimiter', $limiter );

		$this->assertTrue( $user->pingLimiter( 'limited' ) );
		$this->assertFalse( $user->pingLimiter( 'unlimited' ) );
	}

	/**
	 * @covers \MediaWiki\User\User::loadFromDatabase
	 * @covers \MediaWiki\User\User::loadDefaults
	 */
	public function testBadUserID() {
		$user = User::newFromId( 999999999 );
		$this->assertSame( 'Unknown user', $user->getName() );
	}

	/**
	 * @covers \MediaWiki\User\User::probablyCan
	 * @covers \MediaWiki\User\User::definitelyCan
	 * @covers \MediaWiki\User\User::authorizeRead
	 * @covers \MediaWiki\User\User::authorizeWrite
	 */
	public function testAuthorityMethods() {
		$user = $this->getTestUser()->getUser();
		$page = Title::makeTitle( NS_MAIN, 'Test' );
		$this->assertFalse( $user->probablyCan( 'create', $page ) );
		$this->assertFalse( $user->definitelyCan( 'create', $page ) );
		$this->assertFalse( $user->authorizeRead( 'create', $page ) );
		$this->assertFalse( $user->authorizeWrite( 'create', $page ) );

		$this->overrideUserPermissions( $user, 'createpage' );
		$this->assertTrue( $user->probablyCan( 'create', $page ) );
		$this->assertTrue( $user->definitelyCan( 'create', $page ) );
		$this->assertTrue( $user->authorizeRead( 'create', $page ) );
		$this->assertTrue( $user->authorizeWrite( 'create', $page ) );
	}

	/**
	 * @covers \MediaWiki\User\User::isAllowed
	 * @covers \MediaWiki\User\User::__sleep
	 */
	public function testSerializationRoudTripWithAuthority() {
		$user = $this->getTestUser()->getUser();
		$isAllowed = $user->isAllowed( 'read' ); // Memoize the Authority
		$unserializedUser = unserialize( serialize( $user ) );
		$this->assertSame( $user->getId(), $unserializedUser->getId() );
		$this->assertSame( $isAllowed, $unserializedUser->isAllowed( 'read' ) );
	}

	public static function provideIsTemp() {
		return [
			[ '~2024-1', true ],
			[ '~1', true ],
			[ 'Some user', false ],
		];
	}

	/**
	 * @covers \MediaWiki\User\User::isTemp
	 * @dataProvider provideIsTemp
	 */
	public function testIsTemp( $name, $expected ) {
		$this->enableAutoCreateTempUser();
		$user = new User;
		$user->setName( $name );
		$this->assertSame( $expected, $user->isTemp() );
	}

	/**
	 * @covers \MediaWiki\User\User::isTemp
	 */
	public function testSetIsTempInLoadDefaults() {
		$this->enableAutoCreateTempUser();
		$user = new User();
		$user->loadDefaults();
		$this->assertSame( false, $user->isTemp() );
		$user->loadDefaults( '~2024-1' );
		$this->assertSame( true, $user->isTemp() );
	}

	/**
	 * @covers \MediaWiki\User\User::isNamed
	 */
	public function testIsNamed() {
		$this->enableAutoCreateTempUser();

		// Temp user is not named
		$user = new User;
		$user->setName( '~1' );
		$this->assertFalse( $user->isNamed() );

		// Registered user is named
		$user = $this->getMutableTestUser()->getUser();
		$this->assertTrue( $user->isNamed() );

		// Anon is not named
		$user = new User;
		$this->assertFalse( $user->isNamed() );
	}

	public static function provideAddToDatabase_temp() {
		return [
			[ '~1', '1' ],
			[ 'Some user', '0' ]
		];
	}

	/**
	 * @covers \MediaWiki\User\User::addToDatabase
	 * @dataProvider provideAddToDatabase_temp
	 */
	public function testAddToDatabase_temp( $name, $expected ) {
		$this->enableAutoCreateTempUser();

		$user = User::newFromName( $name );
		$user->addToDatabase();
		$field = $this->getDb()->newSelectQueryBuilder()
			->select( 'user_is_temp' )
			->from( 'user' )
			->where( [ 'user_name' => $name ] )
			->caller( __METHOD__ )
			->fetchField();

		$this->assertSame( $expected, $field );
	}

	/**
	 * @covers \MediaWiki\User\User::spreadAnyEditBlock
	 * @covers \MediaWiki\User\User::spreadBlock
	 */
	public function testSpreadAnyEditBlockForAnonUser() {
		$hookCalled = false;
		$this->setTemporaryHook( 'SpreadAnyEditBlock', static function () use ( &$hookCalled ){
			$hookCalled = true;
		} );
		$user = new User;
		$user->setName( '1.2.3.4' );
		$user->spreadAnyEditBlock();
		$this->assertFalse( $hookCalled );
	}

	/**
	 * @covers \MediaWiki\User\User::spreadAnyEditBlock
	 * @covers \MediaWiki\User\User::spreadBlock
	 * @dataProvider provideBlockWasSpreadValues
	 */
	public function testSpreadAnyEditBlockForUnblockedUser( $mockBlockWasSpreadHookValue ) {
		// Assert that the SpreadAnyEditBlock hook gets called with the right arguments when
		// ::spreadAnyEditBlock is called for a registered user.
		$hookCalled = false;
		$this->setTemporaryHook(
			'SpreadAnyEditBlock',
			function ( $user, &$blockWasSpread ) use ( &$hookCalled, $mockBlockWasSpreadHookValue ) {
				$hookCalled = true;
				$blockWasSpread = $mockBlockWasSpreadHookValue;
				$this->assertSame( $this->user, $user );
			}
		);
		$this->assertSame( $mockBlockWasSpreadHookValue, $this->user->spreadAnyEditBlock() );
		$this->assertTrue( $hookCalled );
	}

	public static function provideBlockWasSpreadValues() {
		return [
			'SpreadAnyEditBlock hook handler sets $blockWasSpread to true' => [ true ],
			'No SpreadAnyEditBlock hook handler spread a block' => [ false ],
		];
	}

	/**
	 * @covers \MediaWiki\User\User::spreadAnyEditBlock
	 * @covers \MediaWiki\User\User::spreadBlock
	 */
	public function testSpreadAnyEditBlockForBlockedUser() {
		$this->getServiceContainer()->getBlockUserFactory()->newBlockUser(
			$this->user, $this->getTestSysop()->getAuthority(), 'indefinite', '', [ 'isAutoblocking' => true ]
		)->placeBlockUnsafe();
		RequestContext::getMain()->getRequest()->setIP( '1.2.3.4' );
		$this->assertTrue( $this->user->spreadAnyEditBlock() );
		$this->assertNotNull( $this->getServiceContainer()->getBlockManager()->getIpBlock( '1.2.3.4', true ) );
	}
}
PK       ! !>pU  U    user/UserEditTrackerTest.phpnu Iw        <?php

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Deferred\UserEditCountUpdate;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\User\UserRigorOptions;

/**
 * @covers \MediaWiki\User\UserEditTracker
 * @group Database
 */
class UserEditTrackerTest extends MediaWikiIntegrationTestCase {

	use TempUserTestTrait;

	/**
	 * Do an edit
	 *
	 * @param UserIdentity $user
	 * @param string $timestamp
	 */
	private function editTrackerDoEdit( $user, $timestamp ) {
		$title = Title::newFromText( __FUNCTION__ );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		if ( !$page->exists() ) {
			$page->insertOn( $this->getDb() );
		}

		$rev = new MutableRevisionRecord( $title );
		$rev->setContent( SlotRecord::MAIN, new WikitextContent( $timestamp ) );
		$rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );
		$rev->setTimestamp( $timestamp );
		$rev->setUser( $user );
		$rev->setPageId( $page->getId() );
		$this->getServiceContainer()->getRevisionStore()->insertRevisionOn( $rev, $this->getDb() );
	}

	/**
	 * Change the user_editcount field in the DB
	 *
	 * @param UserIdentity $user
	 * @param int|null $count
	 */
	private function setDbEditCount( $user, $count ) {
		$this->getDb()->newUpdateQueryBuilder()
			->update( 'user' )
			->set( [ 'user_editcount' => $count ] )
			->where( [ 'user_id' => $user->getId() ] )
			->caller( __METHOD__ )
			->execute();
	}

	public function testGetUserEditCount() {
		// Set user_editcount to 5
		$user = $this->getMutableTestUser()->getUser();
		$update = new UserEditCountUpdate( $user, 5 );
		$update->doUpdate();

		$tracker = $this->getServiceContainer()->getUserEditTracker();
		$this->assertSame( 5, $tracker->getUserEditCount( $user ) );

		// Now fetch from cache
		$this->assertSame( 5, $tracker->getUserEditCount( $user ) );
	}

	public function testGetUserEditCount_anon() {
		// getUserEditCount returns null if the user is unregistered
		$anon = UserIdentityValue::newAnonymous( '1.2.3.4' );
		$tracker = $this->getServiceContainer()->getUserEditTracker();
		$this->assertNull( $tracker->getUserEditCount( $anon ) );
	}

	public function testGetUserEditCount_null() {
		// getUserEditCount doesn't find a value in user_editcount and calls
		// initializeUserEditCount
		$user = $this->getMutableTestUser()->getUserIdentity();
		$this->setDbEditCount( $user, null );
		$tracker = $this->getServiceContainer()->getUserEditTracker();
		$this->assertSame( 0, $tracker->getUserEditCount( $user ) );
	}

	public function testInitializeUserEditCount() {
		$user = $this->getMutableTestUser()->getUser();
		$this->editTrackerDoEdit( $user, '20200101000000' );
		$tracker = $this->getServiceContainer()->getUserEditTracker();
		$tracker->initializeUserEditCount( $user );
		$this->runJobs();
		$this->assertSame( 1, $tracker->getUserEditCount( $user ) );
	}

	public function testGetEditTimestamp() {
		$user = $this->getMutableTestUser()->getUser();
		$tracker = $this->getServiceContainer()->getUserEditTracker();
		$this->assertFalse( $tracker->getFirstEditTimestamp( $user ) );
		$this->assertFalse( $tracker->getLatestEditTimestamp( $user ) );

		$ts1 = '20010101000000';
		$ts2 = '20020101000000';
		$ts3 = '20030101000000';
		$this->editTrackerDoEdit( $user, $ts3 );
		$this->editTrackerDoEdit( $user, $ts2 );
		$this->editTrackerDoEdit( $user, $ts1 );

		$this->assertSame( $ts1, $tracker->getFirstEditTimestamp( $user ) );
		$this->assertSame( $ts3, $tracker->getLatestEditTimestamp( $user ) );
	}

	public function testGetEditTimestamp_anon() {
		$this->disableAutoCreateTempUser();
		$user = $this->getServiceContainer()->getUserFactory()
			->newFromName( '127.0.0.1', UserRigorOptions::RIGOR_NONE );
		$tracker = $this->getServiceContainer()->getUserEditTracker();
		$this->editTrackerDoEdit( $user, '20200101000000' );
		$this->assertFalse( $tracker->getFirstEditTimestamp( $user ) );
		$this->assertFalse( $tracker->getLatestEditTimestamp( $user ) );
	}

	public function testClearUserEditCache() {
		$user = $this->getMutableTestUser()->getUser();
		$tracker = $this->getServiceContainer()->getUserEditTracker();
		$this->assertSame( 0, $tracker->getUserEditCount( $user ) );
		$this->setDbEditCount( $user, 1 );
		$this->assertSame( 0, $tracker->getUserEditCount( $user ) );
		$tracker->clearUserEditCache( $user );
		$this->assertSame( 1, $tracker->getUserEditCount( $user ) );
	}

	public function testIncrementUserEditCount() {
		$tracker = $this->getServiceContainer()->getUserEditTracker();
		$user = $this->getMutableTestUser()->getUser();

		$editCountStart = $tracker->getUserEditCount( $user );

		$this->getDb()->startAtomic( __METHOD__ ); // let deferred updates queue up

		$tracker->incrementUserEditCount( $user );
		$this->assertSame(
			1,
			DeferredUpdates::pendingUpdatesCount(),
			'Update queued for registered user'
		);

		$tracker->incrementUserEditCount( UserIdentityValue::newAnonymous( '1.1.1.1' ) );
		$this->assertSame(
			1,
			DeferredUpdates::pendingUpdatesCount(),
			'No update queued for anonymous user'
		);

		$this->getDb()->endAtomic( __METHOD__ ); // run deferred updates
		$this->assertSame(
			0,
			DeferredUpdates::pendingUpdatesCount(),
			'deferred updates ran'
		);

		$editCountEnd = $tracker->getUserEditCount( $user );
		$this->assertSame(
			1,
			$editCountEnd - $editCountStart,
			'Edit count was incremented'
		);
	}

	public function testManualCache() {
		// Make sure manually setting the cached value overrides the database, in case
		// User::loadFromRow() is called with a row containing user_editcount that is
		// different from the actual database value, the row takes precedence
		$user = new UserIdentityValue( 123, __METHOD__ );
		$this->setDbEditCount( $user, 5 );

		$tracker = $this->getServiceContainer()->getUserEditTracker();
		$tracker->setCachedUserEditCount( $user, 10 );
		$this->assertSame( 10, $tracker->getUserEditCount( $user ) );
	}

}
PK       ! nR&S  &S    user/PasswordResetTest.phpnu Iw        <?php

use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\TemporaryPasswordAuthenticationRequest;
use MediaWiki\Block\CompositeBlock;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\SystemBlock;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\WebRequest;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\User\Options\StaticUserOptionsLookup;
use MediaWiki\User\Options\UserOptionsLookup;
use MediaWiki\User\PasswordReset;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentityLookup;
use MediaWiki\User\UserNameUtils;
use Psr\Log\NullLogger;

/**
 * TODO make this a unit test, all dependencies are injected, but DatabaseBlock::__construct()
 * can't be used in unit tests.
 *
 * @covers \MediaWiki\User\PasswordReset
 * @group Database
 */
class PasswordResetTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;

	private const VALID_IP = '1.2.3.4';
	private const VALID_EMAIL = 'foo@bar.baz';

	/**
	 * @dataProvider provideIsAllowed
	 */
	public function testIsAllowed( $passwordResetRoutes, $enableEmail,
		$allowsAuthenticationDataChange, $canEditPrivate, $block, $isAllowed
	) {
		$config = $this->makeConfig( $enableEmail, $passwordResetRoutes );

		$authManager = $this->createMock( AuthManager::class );
		$authManager->method( 'allowsAuthenticationDataChange' )
			->willReturn( $allowsAuthenticationDataChange ? Status::newGood() : Status::newFatal( 'foo' ) );

		$user = $this->createMock( User::class );
		$user->method( 'getName' )->willReturn( 'Foo' );
		$user->method( 'getBlock' )->willReturn( $block );
		$user->method( 'isAllowed' )->with( 'editmyprivateinfo' )->willReturn( $canEditPrivate );

		$passwordReset = new PasswordReset(
			$config,
			new NullLogger(),
			$authManager,
			$this->createHookContainer(),
			$this->createNoOpMock( UserIdentityLookup::class ),
			$this->createNoOpMock( UserFactory::class ),
			$this->createNoOpMock( UserNameUtils::class ),
			$this->createNoOpMock( UserOptionsLookup::class )
		);

		$this->assertSame( $isAllowed, $passwordReset->isAllowed( $user )->isGood() );
	}

	public static function provideIsAllowed() {
		return [
			'no routes' => [
				'passwordResetRoutes' => [],
				'enableEmail' => true,
				'allowsAuthenticationDataChange' => true,
				'canEditPrivate' => true,
				'block' => null,
				'isAllowed' => false,
			],
			'email disabled' => [
				'passwordResetRoutes' => [ 'username' => true ],
				'enableEmail' => false,
				'allowsAuthenticationDataChange' => true,
				'canEditPrivate' => true,
				'block' => null,
				'isAllowed' => false,
			],
			'auth data change disabled' => [
				'passwordResetRoutes' => [ 'username' => true ],
				'enableEmail' => true,
				'allowsAuthenticationDataChange' => false,
				'canEditPrivate' => true,
				'block' => null,
				'isAllowed' => false,
			],
			'cannot edit private data' => [
				'passwordResetRoutes' => [ 'username' => true ],
				'enableEmail' => true,
				'allowsAuthenticationDataChange' => true,
				'canEditPrivate' => false,
				'block' => null,
				'isAllowed' => false,
			],
			'blocked with account creation disabled' => [
				'passwordResetRoutes' => [ 'username' => true ],
				'enableEmail' => true,
				'allowsAuthenticationDataChange' => true,
				'canEditPrivate' => true,
				'block' => new DatabaseBlock( [ 'createAccount' => true ] ),
				'isAllowed' => false,
			],
			'blocked w/o account creation disabled' => [
				'passwordResetRoutes' => [ 'username' => true ],
				'enableEmail' => true,
				'allowsAuthenticationDataChange' => true,
				'canEditPrivate' => true,
				'block' => new DatabaseBlock( [] ),
				'isAllowed' => true,
			],
			'using blocked proxy' => [
				'passwordResetRoutes' => [ 'username' => true ],
				'enableEmail' => true,
				'allowsAuthenticationDataChange' => true,
				'canEditPrivate' => true,
				'block' => new SystemBlock(
					[ 'systemBlock' => 'proxy' ]
				),
				'isAllowed' => false,
			],
			'globally blocked with account creation not disabled' => [
				'passwordResetRoutes' => [ 'username' => true ],
				'enableEmail' => true,
				'allowsAuthenticationDataChange' => true,
				'canEditPrivate' => true,
				'block' => null,
				'isAllowed' => true,
			],
			'blocked via wgSoftBlockRanges' => [
				'passwordResetRoutes' => [ 'username' => true ],
				'enableEmail' => true,
				'allowsAuthenticationDataChange' => true,
				'canEditPrivate' => true,
				'block' => new SystemBlock(
					[ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ]
				),
				'isAllowed' => true,
			],
			'blocked with an unknown system block type' => [
				'passwordResetRoutes' => [ 'username' => true ],
				'enableEmail' => true,
				'allowsAuthenticationDataChange' => true,
				'canEditPrivate' => true,
				'block' => new SystemBlock( [ 'systemBlock' => 'unknown' ] ),
				'isAllowed' => false,
			],
			'blocked with multiple blocks, all allowing password reset' => [
				'passwordResetRoutes' => [ 'username' => true ],
				'enableEmail' => true,
				'allowsAuthenticationDataChange' => true,
				'canEditPrivate' => true,
				'block' => new CompositeBlock( [
					'originalBlocks' => [
						new SystemBlock( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ),
						new DatabaseBlock( [] ),
					]
				] ),
				'isAllowed' => true,
			],
			'blocked with multiple blocks, not all allowing password reset' => [
				'passwordResetRoutes' => [ 'username' => true ],
				'enableEmail' => true,
				'allowsAuthenticationDataChange' => true,
				'canEditPrivate' => true,
				'block' => new CompositeBlock( [
					'originalBlocks' => [
						new SystemBlock( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ),
						new SystemBlock( [ 'systemBlock' => 'proxy' ] ),
					]
				] ),
				'isAllowed' => false,
			],
			'all OK' => [
				'passwordResetRoutes' => [ 'username' => true ],
				'enableEmail' => true,
				'allowsAuthenticationDataChange' => true,
				'canEditPrivate' => true,
				'block' => null,
				'isAllowed' => true,
			],
		];
	}

	public function testExecute_notAllowed() {
		$user = $this->createMock( User::class );
		/** @var User $user */

		$passwordReset = $this->getMockBuilder( PasswordReset::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'isAllowed' ] )
			->getMock();
		$passwordReset->method( 'isAllowed' )
			->with( $user )
			->willReturn( Status::newFatal( 'somestatuscode' ) );
		/** @var PasswordReset $passwordReset */

		$this->expectException( LogicException::class );
		$passwordReset->execute( $user );
	}

	/**
	 * @dataProvider provideExecute
	 * @param string|bool $expectedError
	 * @param ServiceOptions $config
	 * @param User $performingUser
	 * @param AuthManager $authManager
	 * @param string|null $username
	 * @param string|null $email
	 * @param User[] $usersWithEmail
	 * @covers \MediaWiki\Deferred\SendPasswordResetEmailUpdate
	 */
	public function testExecute(
		$expectedError,
		ServiceOptions $config,
		User $performingUser,
		AuthManager $authManager,
		$username = '',
		$email = '',
		array $usersWithEmail = []
	) {
		$users = $this->makeUsers();

		// Only User1 has `requireemail` true, everything else false (so that is the default)
		$userOptionsLookup = new StaticUserOptionsLookup(
			[ 'User1' => [ 'requireemail' => true ] ],
			[ 'requireemail' => false ]
		);

		// Similar to $lookupUser callback, but with null instead of false
		$userFactory = $this->createMock( UserFactory::class );
		$userFactory->method( 'newFromName' )
			->willReturnCallback(
				static function ( $username ) use ( $users ) {
					return $users[ $username ] ?? null;
				}
			);

		$userIdentityLookup = $this->createMock( UserIdentityLookup::class );
		$userFactory->method( 'newFromUserIdentity' )
			->willReturnArgument( 0 );

		$lookupUser = static function ( $username ) use ( $users ) {
			return $users[ $username ] ?? false;
		};

		$passwordReset = $this->getMockBuilder( PasswordReset::class )
			->onlyMethods( [ 'getUsersByEmail', 'isAllowed' ] )
			->setConstructorArgs( [
				$config,
				new NullLogger(),
				$authManager,
				$this->createHookContainer(),
				$userIdentityLookup,
				$userFactory,
				$this->getDummyUserNameUtils(),
				$userOptionsLookup
			] )
			->getMock();
		$passwordReset->method( 'getUsersByEmail' )->with( $email )
			->willReturn( array_map( $lookupUser, $usersWithEmail ) );
		$passwordReset->method( 'isAllowed' )
			->willReturn( Status::newGood() );

		/** @var PasswordReset $passwordReset */
		$status = $passwordReset->execute( $performingUser, $username, $email );

		if ( is_string( $expectedError ) ) {
			$this->assertStatusError( $expectedError, $status );
		} elseif ( $expectedError ) {
			$this->assertStatusNotOk( $status );
		} else {
			$this->assertStatusGood( $status );
		}
	}

	public function provideExecute() {
		// 'User1' has the 'requireemail' preference set (see testExecute()). Other users do not.
		$defaultConfig = $this->makeConfig( true, [ 'username' => true, 'email' => true ] );
		$performingUser = $this->makePerformingUser( self::VALID_IP, false );
		$throttledUser = $this->makePerformingUser( self::VALID_IP, true );

		return [
			'Throttled, pretend everything is ok' => [
				'expectedError' => false,
				'config' => $defaultConfig,
				'performingUser' => $throttledUser,
				'authManager' => $this->makeAuthManager(),
				'username' => 'User1',
				'email' => '',
				'usersWithEmail' => [],
			],
			'Throttled, email required for resets, is invalid, pretend everything is ok' => [
				'expectedError' => false,
				'config' => $defaultConfig,
				'performingUser' => $throttledUser,
				'authManager' => $this->makeAuthManager(),
				'username' => 'User1',
				'email' => '[invalid email]',
				'usersWithEmail' => [],
			],
			'Invalid email' => [
				'expectedError' => 'passwordreset-invalidemail',
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager(),
				'username' => '',
				'email' => '[invalid email]',
				'usersWithEmail' => [],
			],
			'No username, no email' => [
				'expectedError' => 'passwordreset-nodata',
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager(),
				'username' => '',
				'email' => '',
				'usersWithEmail' => [],
			],
			'Email route not enabled' => [
				'expectedError' => 'passwordreset-nodata',
				'config' => $this->makeConfig( true, [ 'username' => true ] ),
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager(),
				'username' => '',
				'email' => self::VALID_EMAIL,
				'usersWithEmail' => [],
			],
			'Username route not enabled' => [
				'expectedError' => 'passwordreset-nodata',
				'config' => $this->makeConfig( true, [ 'email' => true ] ),
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager(),
				'username' => 'User1',
				'email' => '',
				'usersWithEmail' => [],
			],
			'No routes enabled' => [
				'expectedError' => 'passwordreset-nodata',
				'config' => $this->makeConfig( true, [] ),
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager(),
				'username' => 'User1',
				'email' => self::VALID_EMAIL,
				'usersWithEmail' => [],
			],
			'Email required for resets but is empty, pretend everything is OK' => [
				'expectedError' => false,
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager(),
				'username' => 'User1',
				'email' => '',
				'usersWithEmail' => [],
			],
			'Email required for resets but is invalid' => [
				'expectedError' => 'passwordreset-invalidemail',
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager(),
				'username' => 'User1',
				'email' => '[invalid email]',
				'usersWithEmail' => [],
			],
			'Password email already sent within 24 hours, pretend everything is ok' => [
				'expectedError' => false,
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager( [ 'User1' ], 0, [], [ 'User1' ] ),
				'username' => 'User1',
				'email' => '',
				'usersWithEmail' => [ 'User1' ],
			],
			'No user by this username, pretend everything is OK' => [
				'expectedError' => false,
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager(),
				'username' => 'Nonexistent user',
				'email' => '',
				'usersWithEmail' => [],
			],
			'Username is not valid' => [
				'expectedError' => 'noname',
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager(),
				'username' => 'Invalid|username',
				'email' => '',
				'usersWithEmail' => [],
			],
			'If no users with this email found, pretend everything is OK' => [
				'expectedError' => false,
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager(),
				'username' => '',
				'email' => 'some@not.found.email',
				'usersWithEmail' => [],
			],
			'No email for the user, pretend everything is OK' => [
				'expectedError' => false,
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager(),
				'username' => 'BadUser',
				'email' => '',
				'usersWithEmail' => [],
			],
			'Email required for resets, no match' => [
				'expectedError' => false,
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager(),
				'username' => 'User1',
				'email' => 'some@other.email',
				'usersWithEmail' => [],
			],
			"Couldn't determine the performing user's IP" => [
				'expectedError' => 'badipaddress',
				'config' => $defaultConfig,
				'performingUser' => $this->makePerformingUser( '', false ),
				'authManager' => $this->makeAuthManager(),
				'username' => 'User1',
				'email' => '',
				'usersWithEmail' => [],
			],
			'User is allowed, but ignored' => [
				'expectedError' => 'passwordreset-ignored',
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager( [ 'User2' ], 0, [ 'User2' ] ),
				'username' => 'User2',
				'email' => '',
				'usersWithEmail' => [],
			],
			'One of users is ignored' => [
				'expectedError' => 'passwordreset-ignored',
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager( [ 'User1', 'User2' ], 0, [ 'User2' ] ),
				'username' => '',
				'email' => self::VALID_EMAIL,
				'usersWithEmail' => [ 'User1', 'User2' ],
			],
			'User is rejected' => [
				'expectedError' => 'rejected by test mock',
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager(),
				'username' => 'User2',
				'email' => '',
				'usersWithEmail' => [],
			],
			'One of users is rejected' => [
				'expectedError' => 'rejected by test mock',
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager( [ 'User1' ] ),
				'username' => '',
				'email' => self::VALID_EMAIL,
				'usersWithEmail' => [ 'User1', 'User2' ],
			],
			'Reset one user via password' => [
				'expectedError' => false,
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager( [ 'User1' ], 1 ),
				'username' => 'User1',
				'email' => self::VALID_EMAIL,
				// Make sure that only the user specified by username is reset
				'usersWithEmail' => [ 'User1', 'User2' ],
			],
			'Reset one user via email' => [
				'expectedError' => false,
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager( [ 'User2' ], 1 ),
				'username' => '',
				'email' => self::VALID_EMAIL,
				'usersWithEmail' => [ 'User2' ],
			],
			'Reset multiple users via email' => [
				'expectedError' => false,
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager( [ 'User2', 'User3' ], 2 ),
				'username' => '',
				'email' => self::VALID_EMAIL,
				'usersWithEmail' => [ 'User2', 'User3' ],
			],
			"Email is not required for resets, this user didn't opt in" => [
				'expectedError' => false,
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager( [ 'User2' ], 1 ),
				'username' => 'User2',
				'email' => self::VALID_EMAIL,
				'usersWithEmail' => [ 'User2' ],
			],
			'Reset three users via email that did not opt in, multiple users with same email' => [
				'expectedError' => false,
				'config' => $defaultConfig,
				'performingUser' => $performingUser,
				'authManager' => $this->makeAuthManager( [ 'User2', 'User3', 'User4' ], 3, [ 'User1' ] ),
				'username' => '',
				'email' => self::VALID_EMAIL,
				'usersWithEmail' => [ 'User1', 'User2', 'User3', 'User4' ],
			],
		];
	}

	private function makeConfig( $enableEmail, array $passwordResetRoutes ) {
		$hash = [
			MainConfigNames::EnableEmail => $enableEmail,
			MainConfigNames::PasswordResetRoutes => $passwordResetRoutes,
		];

		return new ServiceOptions( PasswordReset::CONSTRUCTOR_OPTIONS, $hash );
	}

	/**
	 * @param string $ip
	 * @param bool $pingLimited
	 * @return User
	 */
	private function makePerformingUser( string $ip, $pingLimited ): User {
		$request = $this->createMock( WebRequest::class );
		$request->method( 'getIP' )
			->willReturn( $ip );
		/** @var WebRequest $request */

		$user = $this->getMockBuilder( User::class )
			->onlyMethods( [ 'getName', 'pingLimiter', 'getRequest', 'isAllowed' ] )
			->getMock();

		$user->method( 'getName' )
			->willReturn( 'SomeUser' );
		$user->method( 'pingLimiter' )
			->with( 'mailpassword' )
			->willReturn( $pingLimited );
		$user->method( 'getRequest' )
			->willReturn( $request );

		// Always has the relevant rights, just checking based on rate limits
		$user->method( 'isAllowed' )->with( 'editmyprivateinfo' )->willReturn( true );

		/** @var User $user */
		return $user;
	}

	/**
	 * @param string[] $allowed Usernames that are allowed to send password reset email
	 *  by AuthManager's allowsAuthenticationDataChange method.
	 * @param int $numUsersToAuth Number of users that will receive email
	 * @param string[] $ignored Usernames that are allowed but ignored by AuthManager's
	 *  allowsAuthenticationDataChange method and will not receive password reset email.
	 * @param string[] $mailThrottledLimited Usernames that have already
	 *  received the password reset email within a given time, and AuthManager
	 *  changeAuthenticationData method will mark them as 'throttled-mailpassword.'
	 * @return AuthManager
	 */
	private function makeAuthManager(
		array $allowed = [],
		$numUsersToAuth = 0,
		array $ignored = [],
		array $mailThrottledLimited = []
	): AuthManager {
		$authManager = $this->createMock( AuthManager::class );
		$authManager->method( 'allowsAuthenticationDataChange' )
			->willReturnCallback(
				static function ( TemporaryPasswordAuthenticationRequest $req )
						use ( $allowed, $ignored, $mailThrottledLimited ) {
					if ( in_array( $req->username, $mailThrottledLimited, true ) ) {
						return Status::newGood( 'throttled-mailpassword' );
					}

					$value = in_array( $req->username, $ignored, true )
						? 'ignored'
						: 'okie dokie';

					return in_array( $req->username, $allowed, true )
						? Status::newGood( $value )
						: Status::newFatal( 'rejected by test mock' );
				} );
		// changeAuthenticationData is executed in the deferred update class
		// SendPasswordResetEmailUpdate
		$authManager->expects( $this->exactly( $numUsersToAuth ) )
			->method( 'changeAuthenticationData' );

		/** @var AuthManager $authManager */
		return $authManager;
	}

	/**
	 * @return User[]
	 */
	private function makeUsers() {
		$getGoodUserCb = function ( int $num ) {
			$user = $this->createMock( User::class );
			$user->method( 'getName' )->willReturn( "User$num" );
			$user->method( 'getId' )->willReturn( $num );
			$user->method( 'isRegistered' )->willReturn( true );
			$user->method( 'getEmail' )->willReturn( self::VALID_EMAIL );
			return $user;
		};
		$user1 = $getGoodUserCb( 1 );
		$user2 = $getGoodUserCb( 2 );
		$user3 = $getGoodUserCb( 3 );
		$user4 = $getGoodUserCb( 4 );

		$badUser = $this->createMock( User::class );
		$badUser->method( 'getName' )->willReturn( 'BadUser' );
		$badUser->method( 'getId' )->willReturn( 5 );
		$badUser->method( 'isRegistered' )->willReturn( true );
		$badUser->method( 'getEmail' )->willReturn( '' );

		$nonexistUser = $this->createMock( User::class );
		$nonexistUser->method( 'getName' )->willReturn( 'Nonexistent user' );
		$nonexistUser->method( 'getId' )->willReturn( 0 );
		$nonexistUser->method( 'isRegistered' )->willReturn( false );
		$nonexistUser->method( 'getEmail' )->willReturn( '' );

		return [
			'User1' => $user1,
			'User2' => $user2,
			'User3' => $user3,
			'User4' => $user4,
			'BadUser' => $badUser,
			'Nonexistent user' => $nonexistUser,
		];
	}
}
PK       ! w@9   9     MockServiceWiring.phpnu Iw        <?php

return MediaWikiServicesTest::$mockServiceWiring;
PK       ! Df      actions/RollbackActionTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Action;

use Article;
use ErrorPageError;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\WebRequest;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use RollbackAction;

/**
 * @covers \RollbackAction
 * @group Database
 */
class RollbackActionTest extends MediaWikiIntegrationTestCase {

	/** @var User */
	private $vandal;

	/** @var User */
	private $sysop;

	/** @var Title */
	private $testPage;

	protected function setUp(): void {
		parent::setUp();
		$this->testPage = Title::makeTitle( NS_MAIN, 'RollbackActionTest' );

		$this->vandal = $this->getTestUser()->getUser();
		$this->sysop = $this->getTestSysop()->getUser();
		$this->editPage( $this->testPage, 'Some text', '', NS_MAIN, $this->sysop );
		$this->editPage( $this->testPage, 'Vandalism', '', NS_MAIN, $this->vandal );
	}

	private function getRollbackAction( WebRequest $request ) {
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setTitle( $this->testPage );
		$context->setRequest( $request );
		$context->setUser( $this->sysop );
		$mwServices = $this->getServiceContainer();
		return new RollbackAction(
			Article::newFromTitle( $this->testPage, $context ),
			$context,
			$mwServices->getContentHandlerFactory(),
			$mwServices->getRollbackPageFactory(),
			$mwServices->getUserOptionsLookup(),
			$mwServices->getWatchlistManager(),
			$mwServices->getCommentFormatter()
		);
	}

	public static function provideRollbackParamFail() {
		yield 'No from parameter' => [
			'requestParams' => [],
		];
		yield 'Non existent user' => [
			'requestParams' => [
				'from' => 'abirvalg',
			],
		];
		yield 'User mismatch' => [
			'requestParams' => [
				'from' => 'UTSysop',
			],
		];
	}

	/**
	 * @dataProvider provideRollbackParamFail
	 */
	public function testRollbackParamFail( array $requestParams ) {
		$request = new FauxRequest( $requestParams );
		$rollbackAction = $this->getRollbackAction( $request );
		$this->expectException( ErrorPageError::class );
		$rollbackAction->handleRollbackRequest();
	}

	public function testRollbackTokenMismatch() {
		$request = new FauxRequest( [
			'from' => $this->vandal->getName(),
			'token' => 'abrvalg',
		] );
		$rollbackAction = $this->getRollbackAction( $request );
		$this->expectException( ErrorPageError::class );
		$rollbackAction->handleRollbackRequest();
	}

	public function testRollback() {
		$request = new FauxRequest( [
			'from' => $this->vandal->getName(),
			'token' => $this->sysop->getEditToken( 'rollback' ),
		] );
		$rollbackAction = $this->getRollbackAction( $request );
		$rollbackAction->handleRollbackRequest();

		$revisionStore = $this->getServiceContainer()->getRevisionStore();
		// Content of latest revision should match the initial.
		$latestRev = $revisionStore->getRevisionByTitle( $this->testPage );
		$initialRev = $revisionStore->getFirstRevision( $this->testPage );
		$this->assertTrue( $latestRev->hasSameContent( $initialRev ) );
		// ...but have different rev IDs.
		$this->assertNotSame( $latestRev->getId(), $initialRev->getId() );

		$recentChange = $revisionStore->getRecentChange( $latestRev );
		$this->assertSame( '0', $recentChange->getAttribute( 'rc_bot' ) );
		$this->assertSame( $this->sysop->getName(), $recentChange->getAttribute( 'rc_user_text' ) );
	}

	public function testRollbackMarkBot() {
		$request = new FauxRequest( [
			'from' => $this->vandal->getName(),
			'token' => $this->sysop->getEditToken( 'rollback' ),
			'bot' => true,
		] );
		$rollbackAction = $this->getRollbackAction( $request );
		$rollbackAction->handleRollbackRequest();

		$revisionStore = $this->getServiceContainer()->getRevisionStore();
		$latestRev = $revisionStore->getRevisionByTitle( $this->testPage );
		$recentChange = $revisionStore->getRecentChange( $latestRev );
		$this->assertSame( '1', $recentChange->getAttribute( 'rc_bot' ) );
	}
}
PK       ! L%      actions/ActionTest.phpnu Iw        <?php

use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\DAO\WikiAwareEntity;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Title\Title;
use MediaWiki\User\User;

/**
 * @covers \Action
 *
 * @group Action
 * @group Database
 *
 * @license GPL-2.0-or-later
 * @author Thiemo Kreuz
 */
class ActionTest extends MediaWikiIntegrationTestCase {

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

		$context = $this->getContext();
		$this->overrideConfigValue(
			MainConfigNames::Actions,
			[
				'null' => null,
				'disabled' => false,
				'view' => true,
				'edit' => true,
				'dummy' => true,
				'access' => 'ControlledAccessDummyAction',
				'unblock' => 'RequiresUnblockDummyAction',
				'string' => 'NamedDummyAction',
				'declared' => 'NonExistingClassName',
				'callable' => [ $this, 'dummyActionCallback' ],
				'object' => new InstantiatedDummyAction(
					$this->getArticle(),
					$context
				),
			]
		);
	}

	/**
	 * @param string $requestedAction
	 * @param WikiPage|null $wikiPage
	 * @return Action|bool|null
	 */
	private function getAction(
		string $requestedAction,
		?WikiPage $wikiPage = null
	) {
		$context = $this->getContext( $requestedAction );

		return Action::factory(
			$requestedAction,
			$this->getArticle( $wikiPage, $context ),
			$context
		);
	}

	/**
	 * @param WikiPage|null $wikiPage
	 * @param IContextSource|null $context
	 * @return Article
	 */
	private function getArticle(
		?WikiPage $wikiPage = null,
		?IContextSource $context = null
	): Article {
		$context ??= $this->getContext();
		if ( $wikiPage !== null ) {
			$context->setWikiPage( $wikiPage );
			$context->setTitle( $wikiPage->getTitle() );
		} else {
			$wikiPage = $this->getPage();
		}

		return Article::newFromWikiPage( $wikiPage, $context );
	}

	private function getPage(): WikiPage {
		$title = Title::makeTitle( 0, 'Title' );
		return $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
	}

	/**
	 * @param string|null $requestedAction
	 * @return IContextSource
	 */
	private function getContext(
		?string $requestedAction = null
	): IContextSource {
		$request = new FauxRequest( [ 'action' => $requestedAction ] );

		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setRequest( $request );
		$context->setWikiPage( $this->getPage() );

		return $context;
	}

	public static function provideActions() {
		return [
			[ 'dummy', 'DummyAction' ],
			[ 'string', 'NamedDummyAction' ],
			[ 'callable', 'CalledDummyAction' ],
			[ 'object', 'InstantiatedDummyAction' ],

			// Capitalization is ignored
			[ 'DUMMY', 'DummyAction' ],
			[ 'STRING', 'NamedDummyAction' ],

			// non-existing values
			[ 'null', null ],
			[ 'undeclared', null ],
			[ '', null ],

			// disabled action exists but cannot be created
			[ 'disabled', false ],
		];
	}

	public static function provideGetActionName() {
		return [
			'dummy' => [ 'dummy', 'DummyAction' ],
			'string' => [ 'string', 'NamedDummyAction' ],
			'callable' => [ 'callable', 'CalledDummyAction' ],
			'object' => [ 'object', 'InstantiatedDummyAction' ],

			// Capitalization is ignored
			'dummy (caps)' => [ 'DUMMY', 'DummyAction' ],
			'string (caps)' => [ 'STRING', 'NamedDummyAction' ],

			// non-existing values
			'null (string)' => [ 'null', 'nosuchaction' ],
			'undeclared' => [ 'undeclared', 'nosuchaction' ],
			'empty' => [ '', 'nosuchaction' ],

			// impossible
			'null (value)' => [ null, 'view' ],
			'false' => [ false, 'nosuchaction' ],

			// Compatibility with old URLs
			'editredlink' => [ 'editredlink', 'edit' ],
			'historysubmit' => [ 'historysubmit', 'view' ],

			'disabled not resolvable' => [ 'disabled', 'nosuchaction' ],
		];
	}

	/**
	 * @dataProvider provideGetActionName
	 * @param string $requestedAction
	 * @param string $expected
	 */
	public function testGetActionName( $requestedAction, $expected ) {
		$actionName = Action::getActionName(
			$this->getContext( $requestedAction )
		);
		$this->assertEquals( $expected, $actionName );
	}

	public function testGetActionName_whenCanNotUseWikiPage_defaultsToView() {
		$request = new FauxRequest( [ 'action' => 'edit' ] );
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setRequest( $request );
		$actionName = Action::getActionName( $context );

		$this->assertEquals( 'view', $actionName );
	}

	/**
	 * @covers \Action::factory
	 *
	 * @dataProvider provideActions
	 * @param string $requestedAction
	 * @param string|false|null $expected
	 */
	public function testActionFactory( string $requestedAction, $expected ) {
		$action = $this->getAction( $requestedAction );

		if ( is_string( $expected ) ) {
			$this->assertInstanceOf( $expected, $action );
		} else {
			$this->assertSame( $expected, $action );
		}
	}

	public function dummyActionCallback() {
		$article = $this->getArticle();
		return new CalledDummyAction(
			$article,
			$article->getContext()
		);
	}

	public function testCanExecute() {
		$user = $this->getTestUser()->getUser();
		$this->overrideUserPermissions( $user, 'access' );
		$action = $this->getAction( 'access' );
		$this->assertNull( $action->canExecute( $user ) );
	}

	public function testCanExecuteNoRight() {
		$user = $this->getTestUser()->getUser();
		$this->overrideUserPermissions( $user, [] );
		$action = $this->getAction( 'access' );
		$this->expectException( PermissionsError::class );
		$action->canExecute( $user );
	}

	public function testCanExecuteRequiresUnblock() {
		$page = $this->getExistingTestPage();
		$action = $this->getAction( 'unblock', $page );

		$user = $this->createMock( User::class );

		$user->method( 'getWikiId' )->willReturn( WikiAwareEntity::LOCAL );

		$block = new DatabaseBlock( [
			'address' => $user,
			'by' => $this->getTestSysop()->getUser(),
			'expiry' => 'infinity',
			'sitewide' => false,
		] );

		$user->expects( $this->once() )
			->method( 'getBlock' )
			->willReturn( $block );

		$permissionManager = $this->createMock( PermissionManager::class );
		$permissionManager->method( 'isBlockedFrom' )->willReturn( true );
		$this->setService( 'PermissionManager', $permissionManager );

		$this->expectException( UserBlockedError::class );
		$action->canExecute( $user );
	}

}

class DummyAction extends Action {

	public function getName() {
		return static::class;
	}

	public function show() {
	}

	public function execute() {
	}

	public function canExecute( User $user ) {
		$this->checkCanExecute( $user );
	}
}

class NamedDummyAction extends DummyAction {
}

class CalledDummyAction extends DummyAction {
}

class InstantiatedDummyAction extends DummyAction {
}

class ControlledAccessDummyAction extends DummyAction {
	public function getRestriction() {
		return 'access';
	}
}

class RequiresUnblockDummyAction extends DummyAction {
	public function requiresUnblock() {
		return true;
	}
}
PK       ! n?h9  9    actions/WatchActionTest.phpnu Iw        <?php

use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\Language\Language;
use MediaWiki\Language\RawMessage;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Output\OutputPage;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Request\WebRequest;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\Watchlist\WatchedItem;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @covers \WatchAction
 *
 * @group Action
 */
class WatchActionTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;
	use MockAuthorityTrait;

	/**
	 * @var WatchAction
	 */
	private $watchAction;

	/**
	 * @var WikiPage
	 */
	private $testWikiPage;

	/**
	 * @var IContextSource
	 */
	private $context;

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValues( [
			MainConfigNames::LanguageCode => 'en',
		] );

		$this->setService( 'ReadOnlyMode', $this->getDummyReadOnlyMode( false ) );
		$testTitle = Title::makeTitle( NS_MAIN, 'UTTest' );
		$this->testWikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $testTitle );
		$testContext = new DerivativeContext( RequestContext::getMain() );
		$testContext->setTitle( $testTitle );
		$this->context = $testContext;
		$this->watchAction = $this->getWatchAction(
			Article::newFromWikiPage( $this->testWikiPage, $testContext ),
			$testContext
		);
	}

	private function getWatchAction( Article $article, IContextSource $context ) {
		$mwServices = $this->getServiceContainer();
		return new WatchAction(
			$article,
			$context,
			$mwServices->getWatchlistManager(),
			$mwServices->getWatchedItemStore()
		);
	}

	/**
	 * @covers \WatchAction::getName()
	 */
	public function testGetName() {
		$this->assertEquals( 'watch', $this->watchAction->getName() );
	}

	/**
	 * @covers \WatchAction::requiresUnblock()
	 */
	public function testRequiresUnlock() {
		$this->assertFalse( $this->watchAction->requiresUnblock() );
	}

	/**
	 * @covers \WatchAction::doesWrites()
	 */
	public function testDoesWrites() {
		$this->assertTrue( $this->watchAction->doesWrites() );
	}

	/**
	 * @covers \WatchAction::onSubmit()
	 */
	public function testOnSubmit() {
		/** @var Status $actual */
		$actual = $this->watchAction->onSubmit( [] );

		$this->assertStatusGood( $actual );
	}

	/**
	 * @covers \WatchAction::onSubmit()
	 */
	public function testOnSubmitHookAborted() {
		// WatchlistExpiry feature flag.
		$this->overrideConfigValue( MainConfigNames::WatchlistExpiry, true );

		$testContext = $this->getMockBuilder( DerivativeContext::class )
			->onlyMethods( [ 'getRequest' ] )
			->setConstructorArgs( [ $this->watchAction->getContext() ] )
			->getMock();

		// Change the context to have a registered user with correct permission.
		$user = new UserIdentityValue( 100, 'User Name' );
		$performer = $this->mockUserAuthorityWithPermissions( $user, [ 'editmywatchlist' ] );
		$testContext->setAuthority( $performer );
		$userFactory = $this->createMock( UserFactory::class );
		$userFactory->method( 'newFromUserIdentity' )->willReturn( $this->createMock( User::class ) );
		$this->setService( 'UserFactory', $userFactory );

		/** @var MockObject|WebRequest $testRequest */
		$testRequest = $this->createMock( WebRequest::class );
		$testRequest->expects( $this->once() )
			->method( 'getVal' )
			->willReturn( '6 months' );
		$testContext->method( 'getRequest' )->willReturn( $testRequest );

		$this->setService( 'WatchedItemStore', $this->getDummyWatchedItemStore() );

		$this->watchAction = $this->getWatchAction(
			Article::newFromWikiPage( $this->testWikiPage, $testContext ),
			$testContext
		);

		$this->setTemporaryHook( 'WatchArticle', static function () {
			return false;
		} );

		/** @var Status $actual */
		$actual = $this->watchAction->onSubmit( [] );

		$this->assertInstanceOf( Status::class, $actual );
		$this->assertStatusError( 'hookaborted', $actual );
	}

	/**
	 * @covers \WatchAction::checkCanExecute()
	 */
	public function testShowUserNotLoggedIn() {
		$notLoggedInUser = new User();
		$testContext = new DerivativeContext( $this->watchAction->getContext() );
		$testContext->setUser( $notLoggedInUser );
		$watchAction = $this->getWatchAction(
			Article::newFromWikiPage( $this->testWikiPage, $testContext ),
			$testContext
		);
		$this->expectException( UserNotLoggedIn::class );

		$watchAction->show();
	}

	/**
	 * @covers \WatchAction::checkCanExecute()
	 */
	public function testShowUserLoggedInNoException() {
		$this->setService( 'PermissionManager', $this->createMock( PermissionManager::class ) );
		$registeredUser = $this->createMock( User::class );
		$registeredUser->method( 'isRegistered' )->willReturn( true );
		$registeredUser->method( 'isNamed' )->willReturn( true );
		$testContext = new DerivativeContext( $this->watchAction->getContext() );
		$testContext->setUser( $registeredUser );
		$watchAction = $this->getWatchAction(
			Article::newFromWikiPage( $this->testWikiPage, $testContext ),
			$testContext
		);

		$exception = null;
		try {
			$watchAction->show();
		} catch ( UserNotLoggedIn $e ) {
			$exception = $e;
		}
		$this->assertNull( $exception,
			'UserNotLoggedIn exception should not be thrown if user is a registered one.' );
	}

	/**
	 * @covers \WatchAction::onSuccess()
	 */
	public function testOnSuccessMainNamespaceTitle() {
		/** @var MockObject|IContextSource $testContext */
		$testContext = $this->getMockBuilder( DerivativeContext::class )
			->onlyMethods( [ 'msg' ] )
			->setConstructorArgs( [ $this->watchAction->getContext() ] )
			->getMock();
		$testOutput = new OutputPage( $testContext );
		$testContext->setOutput( $testOutput );
		$testContext->method( 'msg' )->willReturnCallback( static function ( $msgKey ) {
			return new RawMessage( $msgKey );
		} );
		$watchAction = $this->getWatchAction(
			Article::newFromWikiPage( $this->testWikiPage, $testContext ),
			$testContext
		);

		$watchAction->onSuccess();

		$this->assertEquals( '<p>addedwatchtext
</p>', $testOutput->getHTML() );
	}

	/**
	 * @covers \WatchAction::onSuccess()
	 */
	public function testOnSuccessTalkPage() {
		/** @var MockObject|IContextSource $testContext */
		$testContext = $this->getMockBuilder( DerivativeContext::class )
			->onlyMethods( [ 'getOutput', 'msg' ] )
			->setConstructorArgs( [ $this->watchAction->getContext() ] )
			->getMock();
		$testOutput = new OutputPage( $testContext );
		$testContext->method( 'getOutput' )->willReturn( $testOutput );
		$testContext->method( 'msg' )->willReturnCallback( static function ( $msgKey ) {
			return new RawMessage( $msgKey );
		} );
		$talkPageTitle = Title::makeTitle( NS_TALK, 'UTTest' );
		$testContext->setTitle( $talkPageTitle );
		$watchAction = $this->getWatchAction(
			Article::newFromTitle( $talkPageTitle, $testContext ),
			$testContext
		);

		$watchAction->onSuccess();

		$this->assertEquals( '<p>addedwatchtext-talk
</p>', $testOutput->getHTML() );
	}

	/**
	 * @dataProvider provideOnSuccessDifferentMessages
	 */
	public function testOnSuccessDifferentMessages(
		$watchlistExpiry, $msg, $prefixedTitle, $submittedExpiry, $expiryLabel
	) {
		// Fake current time to be 2020-09-17 12:00:00 UTC.
		ConvertibleTimestamp::setFakeTime( '20200917120000' );

		// WatchlistExpiry feature flag.
		$this->overrideConfigValue( MainConfigNames::WatchlistExpiry, $watchlistExpiry );

		// Set up context, request, and output.
		/** @var MockObject|IContextSource $testContext */
		$testContext = $this->getMockBuilder( DerivativeContext::class )
			->onlyMethods( [ 'getOutput', 'getRequest', 'getLanguage' ] )
			->setConstructorArgs( [ $this->watchAction->getContext() ] )
			->getMock();
		/** @var MockObject|OutputPage $testOutput */
		$testOutput = $this->createMock( OutputPage::class );
		$testOutput->expects( $this->once() )
			->method( 'addWikiMsg' )
			->with( $msg, $prefixedTitle, $expiryLabel );
		$testContext->method( 'getOutput' )->willReturn( $testOutput );
		// Set language to anything non-English/default, to catch assumptions.
		$langDe = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'de' );
		$testContext->method( 'getLanguage' )->willReturn( $langDe );
		/** @var MockObject|WebRequest $testRequest */
		$testRequest = $this->createMock( WebRequest::class );
		$testRequest->expects( $this->once() )
			->method( 'getText' )
			->willReturn( $submittedExpiry );
		$testContext->method( 'getRequest' )->willReturn( $testRequest );

		// Call the onSuccess method, and the above mocks will confirm it's correct.
		/** @var WatchAction $watchAction */
		$watchAction = TestingAccessWrapper::newFromObject(
			$this->getWatchAction(
				Article::newFromTitle( Title::newFromText( $prefixedTitle ), $testContext ),
				$testContext
			)
		);
		$watchAction->onSuccess();
	}

	public static function provideOnSuccessDifferentMessages() {
		return [
			[
				'wgWatchlistExpiry' => false,
				'msg' => 'addedwatchtext',
				'prefixedTitle' => 'Foo',
				'submittedExpiry' => null,
				'expiryLabel' => null,
			],
			[
				'wgWatchlistExpiry' => false,
				'msg' => 'addedwatchtext-talk',
				'prefixedTitle' => 'Talk:Foo',
				'submittedExpiry' => null,
				'expiryLabel' => null,
			],
			[
				'wgWatchlistExpiry' => true,
				'msg' => 'addedwatchindefinitelytext',
				'prefixedTitle' => 'Foo',
				'submittedExpiry' => 'infinite',
				'expiryLabel' => 'Dauerhaft',
			],
			[
				'wgWatchlistExpiry' => true,
				'msg' => 'addedwatchindefinitelytext-talk',
				'prefixedTitle' => 'Talk:Foo',
				'submittedExpiry' => 'infinite',
				'expiryLabel' => 'Dauerhaft',
			],
			[
				'wgWatchlistExpiry' => true,
				'msg' => 'addedwatchexpirytext',
				'prefixedTitle' => 'Foo',
				'submittedExpiry' => '1 week',
				'expiryLabel' => '1 Woche',
			],
			[
				'wgWatchlistExpiry' => true,
				'msg' => 'addedwatchexpirytext-talk',
				'prefixedTitle' => 'Talk:Foo',
				'submittedExpiry' => '1 week',
				'expiryLabel' => '1 Woche',
			],
			[
				'wgWatchlistExpiry' => true,
				'msg' => 'addedwatchexpiryhours',
				'prefixedTitle' => 'Foo',
				'submittedExpiry' => '2020-09-17T14:00:00Z',
				'expiryLabel' => null,
			],
			[
				'wgWatchlistExpiry' => true,
				'msg' => 'addedwatchexpiryhours-talk',
				'prefixedTitle' => 'Talk:Foo',
				'submittedExpiry' => '2020-09-17T14:00:00Z',
				'expiryLabel' => null,
			],
		];
	}

	/**
	 * @covers \WatchAction::getExpiryOptions()
	 */
	public function testGetExpiryOptions() {
		// Fake current time to be 2020-06-10T00:00:00Z
		ConvertibleTimestamp::setFakeTime( '20200610000000' );
		$userIdentity = new UserIdentityValue( 100, 'User Name' );
		$target = new TitleValue( 0, 'SomeDbKey' );

		$optionsNoExpiry = WatchAction::getExpiryOptions( $this->context, false );
		$expectedNoExpiry = [
			'options' => [
				'Permanent' => 'infinite',
				'1 week' => '1 week',
				'1 month' => '1 month',
				'3 months' => '3 months',
				'6 months' => '6 months',
				'1 year' => '1 year',
			],
			'default' => 'infinite'
		];

		$this->assertSame( $expectedNoExpiry, $optionsNoExpiry );

		// Adding a watched item with an expiry a month from the frozen time
		$watchedItemMonth = new WatchedItem( $userIdentity, $target, null, '20200710000000' );
		$optionsExpiryOneMonth = WatchAction::getExpiryOptions( $this->context, $watchedItemMonth );
		$expectedExpiryOneMonth = [
			'options' => [
				'30 days left' => '2020-07-10T00:00:00Z',
				'Permanent' => 'infinite',
				'1 week' => '1 week',
				'1 month' => '1 month',
				'3 months' => '3 months',
				'6 months' => '6 months',
				'1 year' => '1 year',
			],
			'default' => '2020-07-10T00:00:00Z'
		];

		$this->assertSame( $expectedExpiryOneMonth, $optionsExpiryOneMonth );

		// Adding a watched item with an expiry 7 days from the frozen time
		$watchedItemWeek = new WatchedItem( $userIdentity, $target, null, '20200617000000' );
		$optionsExpiryOneWeek = WatchAction::getExpiryOptions( $this->context, $watchedItemWeek );
		$expectedOneWeek = [
			'options' => [
				'7 days left' => '2020-06-17T00:00:00Z',
				'Permanent' => 'infinite',
				'1 week' => '1 week',
				'1 month' => '1 month',
				'3 months' => '3 months',
				'6 months' => '6 months',
				'1 year' => '1 year',
			],
			'default' => '2020-06-17T00:00:00Z'
		];

		$this->assertSame( $expectedOneWeek, $optionsExpiryOneWeek );

		// Case for when WatchedItem is true
		$optionsNoExpiryWIFalse = WatchAction::getExpiryOptions( $this->context, true );
		$expectedNoExpiryWIFalse = [
			'options' => [
				'Permanent' => 'infinite',
				'1 week' => '1 week',
				'1 month' => '1 month',
				'3 months' => '3 months',
				'6 months' => '6 months',
				'1 year' => '1 year',
			],
			'default' => 'infinite'
		];

		$this->assertSame( $expectedNoExpiryWIFalse, $optionsNoExpiryWIFalse );
	}

	/**
	 * @covers \WatchAction::getExpiryOptions()
	 */
	public function testGetExpiryOptionsWithInvalidTranslations() {
		$mockMessageLocalizer = $this->createMock( MockMessageLocalizer::class );
		$mockLanguage = $this->createMock( Language::class );
		$mockLanguage->method( 'getCode' )->willReturn( 'not-english' );
		$mockMessage = $this->getMockMessage( 'invalid:invalid, foo:bar, thing' );
		$mockMessage->method( 'getLanguage' )->willReturn( $mockLanguage );

		$mockMessageLocalizer->expects( $this->exactly( 2 ) )
			->method( 'msg' )
			->willReturnOnConsecutiveCalls(
					$mockMessage,
					new Message( 'watchlist-expiry-options' )
				);

		$expected = WatchAction::getExpiryOptions( new MockMessageLocalizer( 'en' ), false );
		$expiryOptions = WatchAction::getExpiryOptions( $mockMessageLocalizer, false );
		$this->assertSame( $expected, $expiryOptions );
	}

	/**
	 * @covers \WatchAction::getExpiryOptions()
	 */
	public function testGetExpiryOptionsWithPartialInvalidTranslations() {
		$mockMessageLocalizer = $this->createMock( MockMessageLocalizer::class );
		$mockMessageLocalizer->expects( $this->once() )
			->method( 'msg' )
			->with( 'watchlist-expiry-options' )
			->willReturn( $this->getMockMessage( 'invalid:invalid, thing, 1 week: 1 week,3 days:3 days' ) );

		$expected = [
			'options' => [
				'1 week' => '1 week',
				'3 days' => '3 days',
			],
			'default' => '1 week'
		];
		$expiryOptions = WatchAction::getExpiryOptions( $mockMessageLocalizer, false );
		$this->assertSame( $expected, $expiryOptions );
	}
}
PK       ! #Y3  Y3     actions/ActionEntryPointTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Action;

use BadTitleError;
use MediaWiki\Actions\ActionEntryPoint;
use MediaWiki\Context\RequestContext;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Deferred\DeferredUpdatesScopeMediaWikiStack;
use MediaWiki\Deferred\DeferredUpdatesScopeStack;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\FauxResponse;
use MediaWiki\Request\WebRequest;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Tests\MockEnvironment;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\MalformedTitleException;
use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\Assert;
use ReflectionMethod;
use Wikimedia\TestingAccessWrapper;
use WikiPage;

// phpcs:disable MediaWiki.Usage.SuperGlobalsUsage.SuperGlobals

/**
 * @group Database
 * @covers \MediaWiki\Actions\ActionEntryPoint
 */
class ActionEntryPointTest extends MediaWikiIntegrationTestCase {
	use TempUserTestTrait;

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

		$this->overrideConfigValues( [
			MainConfigNames::Server => 'http://example.org',
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::LanguageCode => 'en',
		] );

		// Needed to test redirects to My* special pages as an anonymous user.
		$this->disableAutoCreateTempUser();
	}

	protected function tearDown(): void {
		// Restore a scope stack that will run updates immediately
		DeferredUpdates::setScopeStack( new DeferredUpdatesScopeMediaWikiStack() );
		parent::tearDown();
	}

	/**
	 * @param MockEnvironment|WebRequest|array|null $environment
	 * @param RequestContext|null $context
	 *
	 * @return ActionEntryPoint
	 */
	private function getEntryPoint( $environment = null, ?RequestContext $context = null ) {
		if ( !$environment ) {
			$environment = new MockEnvironment();
		}

		if ( is_array( $environment ) ) {
			$environment = new FauxRequest( $environment );
		}

		if ( $environment instanceof WebRequest ) {
			$environment = new MockEnvironment( $environment );
		}

		$entryPoint = new ActionEntryPoint(
			$context ?? $environment->makeFauxContext(),
			$environment,
			$this->getServiceContainer()
		);
		$entryPoint->enableOutputCapture();

		return $entryPoint;
	}

	public static function provideTryNormaliseRedirect() {
		return [
			[
				// View: Canonical
				'url' => 'http://example.org/wiki/Foo_Bar',
				'query' => [],
				'title' => 'Foo_Bar',
				'redirect' => false,
			],
			[
				// View: Escaped title
				'url' => 'http://example.org/wiki/Foo%20Bar',
				'query' => [],
				'title' => 'Foo_Bar',
				'redirect' => 'http://example.org/wiki/Foo_Bar',
			],
			[
				// View: Script path
				'url' => 'http://example.org/w/index.php?title=Foo_Bar',
				'query' => [ 'title' => 'Foo_Bar' ],
				'title' => 'Foo_Bar',
				'redirect' => false,
			],
			[
				// View: Script path with implicit title from page id
				'url' => 'http://example.org/w/index.php?curid=123',
				'query' => [ 'curid' => '123' ],
				'title' => 'Foo_Bar',
				'redirect' => false,
			],
			[
				// View: Script path with implicit title from revision id
				'url' => 'http://example.org/w/index.php?oldid=123',
				'query' => [ 'oldid' => '123' ],
				'title' => 'Foo_Bar',
				'redirect' => false,
			],
			[
				// View: Script path without title
				'url' => 'http://example.org/w/index.php',
				'query' => [],
				'title' => 'Main_Page',
				'redirect' => 'http://example.org/wiki/Main_Page',
			],
			[
				// View: Script path with empty title
				'url' => 'http://example.org/w/index.php?title=',
				'query' => [ 'title' => '' ],
				'title' => 'Main_Page',
				'redirect' => 'http://example.org/wiki/Main_Page',
			],
			[
				// View: Index with escaped title
				'url' => 'http://example.org/w/index.php?title=Foo%20Bar',
				'query' => [ 'title' => 'Foo Bar' ],
				'title' => 'Foo_Bar',
				'redirect' => 'http://example.org/wiki/Foo_Bar',
			],
			[
				// View: Script path with escaped title
				'url' => 'http://example.org/w/?title=Foo_Bar',
				'query' => [ 'title' => 'Foo_Bar' ],
				'title' => 'Foo_Bar',
				'redirect' => false,
			],
			[
				// View: Root path with escaped title
				'url' => 'http://example.org/?title=Foo_Bar',
				'query' => [ 'title' => 'Foo_Bar' ],
				'title' => 'Foo_Bar',
				'redirect' => false,
			],
			[
				// View: Canonical with redundant query
				'url' => 'http://example.org/wiki/Foo_Bar?action=view',
				'query' => [ 'action' => 'view' ],
				'title' => 'Foo_Bar',
				'redirect' => false,
			],
			[
				// Edit: Canonical view url with action query
				'url' => 'http://example.org/wiki/Foo_Bar?action=edit',
				'query' => [ 'action' => 'edit' ],
				'title' => 'Foo_Bar',
				'redirect' => false,
			],
			[
				// View: Index with action query
				'url' => 'http://example.org/w/index.php?title=Foo_Bar&action=view',
				'query' => [ 'title' => 'Foo_Bar', 'action' => 'view' ],
				'title' => 'Foo_Bar',
				'redirect' => false,
			],
			[
				// Edit: Index with action query
				'url' => 'http://example.org/w/index.php?title=Foo_Bar&action=edit',
				'query' => [ 'title' => 'Foo_Bar', 'action' => 'edit' ],
				'title' => 'Foo_Bar',
				'redirect' => false,
			],
			[
				// Path with double slash prefix (T100782)
				'url' => 'http://example.org//wiki/Double_slash',
				'query' => [],
				'title' => 'Double_slash',
				'redirect' => false,
			],
			[
				// View: Media namespace redirect (T203942)
				'url' => 'http://example.org/w/index.php?title=Media:Foo_Bar',
				'query' => [ 'title' => 'Foo_Bar' ],
				'title' => 'File:Foo_Bar',
				'redirect' => 'http://example.org/wiki/File:Foo_Bar',
			],
		];
	}

	/**
	 * @dataProvider provideTryNormaliseRedirect
	 */
	public function testTryNormaliseRedirect( $url, $query, $title, $expectedRedirect = false ) {
		$environment = new MockEnvironment();
		$environment->setRequestInfo( $url, $query );

		$titleObj = Title::newFromText( $title );

		// Set global context since some involved code paths don't yet have context
		$context = $environment->makeFauxContext();
		$context->setTitle( $titleObj );

		$mw = $this->getEntryPoint( $environment, $context );

		$method = new ReflectionMethod( $mw, 'tryNormaliseRedirect' );
		$method->setAccessible( true );
		$ret = $method->invoke( $mw, $titleObj );

		$this->assertEquals(
			$expectedRedirect !== false,
			$ret,
			'Return true only when redirecting'
		);

		$this->assertEquals(
			$expectedRedirect ?: '',
			$context->getOutput()->getRedirect()
		);
	}

	public function testMainPageIsDomainRoot() {
		$this->overrideConfigValue( MainConfigNames::MainPageIsDomainRoot, true );

		$environment = new MockEnvironment();
		$environment->setRequestInfo( '/' );

		// Set global context since some involved code paths don't yet have context
		$context = $environment->makeFauxContext();

		$entryPoint = $this->getEntryPoint( $environment, $context );
		$entryPoint->run();

		$expected = '<title>(pagetitle: Main Page)';
		Assert::assertStringContainsString( $expected, $entryPoint->getCapturedOutput() );
	}

	public static function provideParseTitle() {
		return [
			"No title means main page" => [
				'query' => [],
				'expected' => 'Main Page',
			],
			"Empty title also means main page" => [
				'query' => wfCgiToArray( '?title=' ),
				'expected' => 'Main Page',
			],
			"Valid title" => [
				'query' => wfCgiToArray( '?title=Foo' ),
				'expected' => 'Foo',
			],
			"Invalid title" => [
				'query' => wfCgiToArray( '?title=[INVALID]' ),
				'expected' => false,
			],
			"Invalid 'oldid'… means main page? (we show an error elsewhere)" => [
				'query' => wfCgiToArray( '?oldid=9999999' ),
				'expected' => 'Main Page',
			],
			"Invalid 'diff'… means main page? (we show an error elsewhere)" => [
				'query' => wfCgiToArray( '?diff=9999999' ),
				'expected' => 'Main Page',
			],
			"Invalid 'curid'" => [
				'query' => wfCgiToArray( '?curid=9999999' ),
				'expected' => false,
			],
			"'search' parameter with no title provided forces Special:Search" => [
				'query' => wfCgiToArray( '?search=foo' ),
				'expected' => 'Special:Search',
			],
			"'action=revisiondelete' forces Special:RevisionDelete even with title" => [
				'query' => wfCgiToArray( '?action=revisiondelete&title=Unused' ),
				'expected' => 'Special:RevisionDelete',
			],
			"'action=historysubmit&revisiondelete=1' forces Special:RevisionDelete even with title" => [
				'query' => wfCgiToArray( '?action=historysubmit&revisiondelete=1&title=Unused' ),
				'expected' => 'Special:RevisionDelete',
			],
			"'action=editchangetags' forces Special:EditTags even with title" => [
				'query' => wfCgiToArray( '?action=editchangetags&title=Unused' ),
				'expected' => 'Special:EditTags',
			],
			"'action=historysubmit&editchangetags=1' forces Special:EditTags even with title" => [
				'query' => wfCgiToArray( '?action=historysubmit&editchangetags=1&title=Unused' ),
				'expected' => 'Special:EditTags',
			],
			"No title with 'action' still means main page" => [
				'query' => wfCgiToArray( '?action=history' ),
				'expected' => 'Main Page',
			],
			"No title with 'action=delete' does not mean main page, because we want to discourage deleting it by accident :D" => [
				'query' => wfCgiToArray( '?action=delete' ),
				'expected' => false,
			],
		];
	}

	private function doTestParseTitle( array $query, $expected ): void {
		if ( $expected === false ) {
			$this->expectException( MalformedTitleException::class );
		}

		$req = new FauxRequest( $query );
		$mw = $this->getEntryPoint( $req );

		$method = new ReflectionMethod( $mw, 'parseTitle' );
		$method->setAccessible( true );
		$ret = $method->invoke( $mw, $req );

		$this->assertEquals(
			$expected,
			$ret->getPrefixedText()
		);
	}

	/**
	 * @dataProvider provideParseTitle
	 */
	public function testParseTitle( $query, $expected ) {
		$this->doTestParseTitle( $query, $expected );
	}

	public static function provideParseTitleExistingPage(): array {
		return [
			"Valid 'oldid'" => [
				static fn ( WikiPage $page ): array => wfCgiToArray( '?oldid=' . $page->getRevisionRecord()->getId() ),
			],
			"Valid 'diff'" => [
				static fn ( WikiPage $page ): array => wfCgiToArray( '?diff=' . $page->getRevisionRecord()->getId() ),
			],
			"Valid 'curid'" => [
				static fn ( WikiPage $page ): array => wfCgiToArray( '?curid=' . $page->getId() ),
			],
		];
	}

	/**
	 * @dataProvider provideParseTitleExistingPage
	 */
	public function testParseTitle__existingPage( callable $queryBuildCallback ) {
		$pageTitle = 'TestParseTitle test page';
		$page = $this->getExistingTestPage( $pageTitle );
		$query = $queryBuildCallback( $page );
		$this->doTestParseTitle( $query, $pageTitle );
	}

	/**
	 * Test a post-send update cannot set cookies (T191537).
	 * @coversNothing
	 */
	public function testPostSendJobDoesNotSetCookie() {
		// Prevent updates from running immediately by setting
		// a plain DeferredUpdatesScopeStack which doesn't allow
		// opportunistic updates.
		DeferredUpdates::setScopeStack( new DeferredUpdatesScopeStack() );

		$mw = TestingAccessWrapper::newFromObject( $this->getEntryPoint() );

		/** @var FauxResponse $response */
		$response = $mw->getResponse();

		// A update that attempts to set a cookie
		$jobHasRun = false;
		DeferredUpdates::addCallableUpdate( static function () use ( $response, &$jobHasRun ) {
			$jobHasRun = true;
			$response->setCookie( 'JobCookie', 'yes' );
			$response->header( 'Foo: baz' );
		} );

		$mw->doPostOutputShutdown();

		// restInPeace() might have been registered to a callback of
		// register_postsend_function() and thus cannot be triggered from
		// PHPUnit.
		if ( $jobHasRun === false ) {
			$mw->restInPeace();
		}

		$this->assertTrue( $jobHasRun, 'post-send job has run' );
		$this->assertNull( $response->getCookie( 'JobCookie' ) );
		$this->assertNull( $response->getHeader( 'Foo' ) );
	}

	public function testInvalidRedirectingOnSpecialPageWithPersonallyIdentifiableTarget() {
		$this->overrideConfigValue( MainConfigNames::HideIdentifiableRedirects, true );

		$specialTitle = SpecialPage::getTitleFor( 'Mypage', 'in<valid' );
		$req = new FauxRequest( [
			'title' => $specialTitle->getPrefixedDbKey(),
		] );
		$req->setRequestURL( $specialTitle->getLinkURL() );

		$env = new MockEnvironment( $req );
		$context = $env->makeFauxContext();
		$context->setTitle( $specialTitle );

		$mw = TestingAccessWrapper::newFromObject( $this->getEntryPoint( $env, $context ) );

		$this->expectException( BadTitleError::class );
		$this->expectExceptionMessage( 'The requested page title contains invalid characters: "<".' );
		$mw->performRequest();
	}

	public function testView() {
		$page = $this->getExistingTestPage();

		$request = new FauxRequest( [ 'title' => $page->getTitle()->getPrefixedDBkey() ] );
		$env = new MockEnvironment( $request );

		$entryPoint = $this->getEntryPoint( $env );
		$entryPoint->run();

		$expected = '<title>(pagetitle: ' . $page->getTitle()->getPrefixedText();
		Assert::assertStringContainsString( $expected, $entryPoint->getCapturedOutput() );
	}

}
PK       ! A"    (  actions/ActionFactoryIntegrationTest.phpnu Iw        <?php

use MediaWiki\Actions\ActionFactory;
use MediaWiki\Context\RequestContext;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;

/**
 * Test that runs against all core actions to make sure that
 * creating an instance of the action works
 *
 * @coversNothing
 * @group Database
 */
class ActionFactoryIntegrationTest extends MediaWikiIntegrationTestCase {

	public function testActionFactoryServiceWiring() {
		$services = MediaWikiServices::getInstance();
		$actionFactory = $services->getActionFactory();
		$context = RequestContext::getMain();
		$article = Article::newFromTitle( Title::makeTitle( NS_MAIN, 'ActionFactoryServiceWiringTest' ), $context );

		$actionSpecs = ( new ReflectionClassConstant( ActionFactory::class, 'CORE_ACTIONS' ) )->getValue();
		foreach ( $actionSpecs as $action => $_ ) {
			$this->assertInstanceOf( Action::class, $actionFactory->getAction( $action, $article, $context ) );
		}
	}

}
PK       ! 7q
  q
    SampleTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;

/**
 * @coversNothing Just a sample
 */
class SampleTest extends MediaWikiLangTestCase {

	/**
	 * Anything that needs to happen before your tests should go here.
	 */
	protected function setUp(): void {
		// Be sure to call the parent setup and teardown functions.
		// This makes sure that all the various cleanup and restorations
		// happen as they should (including the restoration for setMwGlobals).
		parent::setUp();

		// This sets the config settings, and will restore them automatically
		// after each test.
		$this->overrideConfigValues( [
			MainConfigNames::LanguageCode => 'en',
			MainConfigNames::CapitalLinks => true,
		] );
	}

	/**
	 * Anything cleanup you need to do should go here.
	 */
	protected function tearDown(): void {
		parent::tearDown();
	}

	/**
	 * Name tests so that PHPUnit can turn them into sentences when
	 * they run. You are encouraged to use the naming described at:
	 * https://phpunit.de/manual/6.5/en/other-uses-for-tests.html
	 */
	public function testTitleObjectStringConversion() {
		$title = Title::makeTitle( NS_MAIN, "Text" );
		$this->assertInstanceOf( Title::class, $title, "Title creation" );
		$this->assertEquals( "Text", $title, "Automatic string conversion" );

		$title = Title::makeTitle( NS_MEDIA, "Text" );
		$this->assertEquals( "Media:Text", $title, "Title creation with namespace" );
	}

	/**
	 * If you want to run the same test with a variety of data, use a data provider.
	 * See https://phpunit.de/manual/6.5/en/writing-tests-for-phpunit.html
	 */
	public static function provideTitles() {
		return [
			[ 'Text', NS_MEDIA, 'Media:Text' ],
			[ 'Text', null, 'Text' ],
			[ 'text', null, 'Text' ],
			[ 'Text', NS_USER, 'User:Text' ],
			[ 'Photo.jpg', NS_FILE, 'File:Photo.jpg' ]
		];
	}

	/**
	 * @dataProvider provideTitles
	 * See https://phpunit.de/manual/6.5/en/appendixes.annotations.html#appendixes.annotations.dataProvider
	 */
	public function testCreateBasicListOfTitles( $titleName, $ns, $text ) {
		$title = Title::newFromText( $titleName, $ns );
		$this->assertEquals( $text, "$title", "see if '$titleName' matches '$text'" );
	}

	/**
	 * Instead of putting a bunch of tests in a single test method,
	 * you should put only one or two tests in each test method.  This
	 * way, the test method names can remain descriptive.
	 */

	/**
	 * See https://phpunit.de/manual/6.5/en/writing-tests-for-phpunit.html#writing-tests-for-phpunit.exceptions
	 */
	public function testTitleObjectFromObject() {
		$this->expectException( InvalidArgumentException::class );
		Title::newFromText( Title::makeTitle( NS_MAIN, 'Test' ) );
	}
}
PK       ! R?M M   Output/OutputPageTest.phpnu Iw        <?php

use MediaWiki\Config\HashConfig;
use MediaWiki\Config\MultiConfig;
use MediaWiki\Context\RequestContext;
use MediaWiki\Html\Html;
use MediaWiki\Language\ILanguageConverter;
use MediaWiki\Language\Language;
use MediaWiki\Language\LanguageCode;
use MediaWiki\Language\RawMessage;
use MediaWiki\Languages\LanguageConverterFactory;
use MediaWiki\MainConfigNames;
use MediaWiki\Output\OutputPage;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageReference;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Page\PageStoreRecord;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\ParserOutputFlags;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\Request\ContentSecurityPolicy;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\WebRequest;
use MediaWiki\ResourceLoader as RL;
use MediaWiki\ResourceLoader\ResourceLoader;
use MediaWiki\Tests\ResourceLoader\ResourceLoaderTestCase;
use MediaWiki\Tests\ResourceLoader\ResourceLoaderTestModule;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\User;
use MediaWiki\Utils\MWTimestamp;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\DependencyStore\KeyValueDependencyStore;
use Wikimedia\LightweightObjectStore\ExpirationAwareness;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\TestingAccessWrapper;

/**
 * @author Matthew Flaschen
 *
 * @group Database
 * @group Output
 * @covers \MediaWiki\Output\OutputPage
 */
class OutputPageTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;
	use MockTitleTrait;

	private const SCREEN_MEDIA_QUERY = 'screen and (min-width: 982px)';
	private const SCREEN_ONLY_MEDIA_QUERY = 'only screen and (min-width: 982px)';
	private const RSS_RC_LINK = '<link rel="alternate" type="application/rss+xml" title=" RSS feed" href="/w/index.php?title=Special:RecentChanges&amp;feed=rss">';
	private const ATOM_RC_LINK = '<link rel="alternate" type="application/atom+xml" title=" Atom feed" href="/w/index.php?title=Special:RecentChanges&amp;feed=atom">';

	private const RSS_TEST_LINK = '<link rel="alternate" type="application/rss+xml" title="&quot;Test&quot; RSS feed" href="fake-link">';
	private const ATOM_TEST_LINK = '<link rel="alternate" type="application/atom+xml" title="&quot;Test&quot; Atom feed" href="fake-link">';
	// phpcs:enable

	// Ensure that we don't affect the global ResourceLoader state.
	protected function setUp(): void {
		parent::setUp();
		ResourceLoader::clearCache();

		$this->overrideConfigValues( [
			MainConfigNames::ScriptPath => '/mw',
			MainConfigNames::Script => '/mw/index.php',
			MainConfigNames::ArticlePath => '/wikipage/$1',
			MainConfigNames::Server => 'http://example.org',
			MainConfigNames::CanonicalServer => 'https://www.example.org',
		] );
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'en' );
	}

	protected function tearDown(): void {
		ResourceLoader::clearCache();
		parent::tearDown();
	}

	/**
	 * @dataProvider provideRedirect
	 */
	public function testRedirect( $url, $code = null ) {
		$op = $this->newInstance();
		if ( isset( $code ) ) {
			$op->redirect( $url, $code );
		} else {
			$op->redirect( $url );
		}
		$expectedUrl = str_replace( "\n", '', $url );
		$this->assertSame( $expectedUrl, $op->getRedirect() );
		$this->assertSame( $expectedUrl, $op->mRedirect );
		$this->assertSame( $code ?? '302', $op->mRedirectCode );
	}

	public static function provideRedirect() {
		return [
			[ 'http://example.com' ],
			[ 'http://example.com', '400' ],
			[ 'http://example.com', 'squirrels!!!' ],
			[ "a\nb" ],
		];
	}

	private function setupFeedLinks( $feed, $types ): OutputPage {
		$outputPage = $this->newInstance( [
			MainConfigNames::AdvertisedFeedTypes => $types,
			MainConfigNames::Feed => $feed,
			MainConfigNames::OverrideSiteFeed => false,
			MainConfigNames::Script => '/w',
			MainConfigNames::Sitename => false,
		] );
		$outputPage->setTitle( Title::makeTitle( NS_MAIN, 'Test' ) );
		$this->overrideConfigValue( MainConfigNames::Script, '/w/index.php' );
		return $outputPage;
	}

	private function assertFeedLinks( OutputPage $outputPage, $message, $present, $non_present ) {
		$links = $outputPage->getHeadLinksArray();
		foreach ( $present as $link ) {
			$this->assertContains( $link, $links, $message );
		}
		foreach ( $non_present as $link ) {
			$this->assertNotContains( $link, $links, $message );
		}
	}

	private function assertFeedUILinks( OutputPage $outputPage, $ui_links ) {
		if ( $ui_links ) {
			$this->assertTrue( $outputPage->isSyndicated(), 'Syndication should be offered' );
			$this->assertGreaterThan( 0, count( $outputPage->getSyndicationLinks() ),
				'Some syndication links should be there' );
		} else {
			$this->assertFalse( $outputPage->isSyndicated(), 'No syndication should be offered' );
			$this->assertSame( [], $outputPage->getSyndicationLinks(),
				'No syndication links should be there' );
		}
	}

	public static function provideFeedLinkData() {
		return [
			[
				true, [ 'rss' ], 'Only RSS RC link should be offerred',
				[ self::RSS_RC_LINK ], [ self::ATOM_RC_LINK ]
			],
			[
				true, [ 'atom' ], 'Only Atom RC link should be offerred',
				[ self::ATOM_RC_LINK ], [ self::RSS_RC_LINK ]
			],
			[
				true, [], 'No RC feed formats should be offerred',
				[], [ self::ATOM_RC_LINK, self::RSS_RC_LINK ]
			],
			[
				false, [ 'atom' ], 'No RC feeds should be offerred',
				[], [ self::ATOM_RC_LINK, self::RSS_RC_LINK ]
			],
		];
	}

	public function testSetCanonicalUrl() {
		$op = $this->newInstance();
		$op->setCanonicalUrl( 'http://example.comm' );
		$op->setCanonicalUrl( 'http://example.com' );

		$this->assertSame( 'http://example.com', $op->getCanonicalUrl() );

		$headLinks = $op->getHeadLinksArray();

		$this->assertContains( Html::element( 'link', [
			'rel' => 'canonical', 'href' => 'http://example.com'
		] ), $headLinks );

		$this->assertNotContains( Html::element( 'link', [
			'rel' => 'canonical', 'href' => 'http://example.comm'
		] ), $headLinks );
	}

	public static function provideGetHeadLinksArray() {
		return [
			[
				[ MainConfigNames::EnableCanonicalServerLink => true ],
				'https://www.example.org/xyzzy/Hello',
				true,
				'/xyzzy/Hello'
			],
			[
				[ MainConfigNames::EnableCanonicalServerLink => true ],
				'https://www.example.org/wikipage/My_test_page',
				true,
				null
			],
			[
				[ MainConfigNames::EnableCanonicalServerLink => true ],
				'https://www.mediawiki.org/wiki/Manual:FauxRequest.php',
				false,
				null
			],
		];
	}

	/**
	 * @dataProvider provideGetHeadLinksArray
	 */
	public function testGetHeadLinksArray( $config, $canonicalUrl, $isArticleRelated, $canonicalUrlToSet = null ) {
		$request = new FauxRequest();
		$request->setRequestURL( 'https://www.mediawiki.org/wiki/Manual:FauxRequest.php' );
		$op = $this->newInstance( $config, $request );
		if ( $canonicalUrlToSet ) {
			$op->setCanonicalUrl( $canonicalUrlToSet );
		}
		$op->setArticleRelated( $isArticleRelated );
		$headLinks = $op->getHeadLinksArray();
		$this->assertSame(
			Html::element( 'link',
				[ 'rel' => 'canonical', 'href' => $canonicalUrl ]
			),
			$headLinks['link-canonical']
		);
	}

	/**
	 * Test the generation of hreflang Tags when site language has variants
	 */
	public function testGetLanguageVariantUrl() {
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'zh' );

		$op = $this->newInstance();
		$headLinks = $op->getHeadLinksArray();

		# T123901, T305540, T108443: Don't use language variant link for mixed-variant variant
		#  (the language code with converter / the main code)
		$this->assertSame(
			Html::element( 'link', [ 'rel' => 'alternate', 'hreflang' => 'zh',
				'href' => 'http://example.org/wikipage/My_test_page' ] ),
			$headLinks['link-alternate-language-zh']
		);

		# Make sure alternate URLs use BCP 47 codes in hreflang
		$this->assertSame(
			Html::element( 'link', [ 'rel' => 'alternate', 'hreflang' => 'zh-Hant-TW',
				'href' => 'http://example.org/mw/index.php?title=My_test_page&variant=zh-tw' ] ),
			$headLinks['link-alternate-language-zh-hant-tw']
		);

		# Make sure $wgVariantArticlePath work
		# We currently use MediaWiki internal language code as the primary variant URL parameter
		$this->overrideConfigValues( [
			MainConfigNames::LanguageCode => 'zh',
			MainConfigNames::VariantArticlePath => '/$2/$1',
		] );

		$op = $this->newInstance();
		$headLinks = $op->getHeadLinksArray();

		$this->assertSame(
			Html::element( 'link', [ 'rel' => 'alternate', 'hreflang' => 'zh-Hant-TW',
				'href' => 'http://example.org/zh-tw/My_test_page' ] ),
			$headLinks['link-alternate-language-zh-hant-tw']
		);
	}

	public static function provideCanonicalUrlAndAlternateUrlData() {
		# $messsage, $action, $urlVariant, $canonicalUrl, $altUrlLangCode, $present, $nonpresent
		return [
			[
				'Non-specified variant with view action - '
					. 'We currently use MediaWiki internal codes as the primary URL parameter',
				null,
				null,
				'https://www.example.org/wikipage/My_test_page',
				'zh-tw',
				'http://example.org/mw/index.php?title=My_test_page&variant=zh-tw',
				'http://example.org/mw/index.php?title=My_test_page&variant=zh-hant-tw',
			],
			[
				'Specified zh-tw variant with view action - '
					. 'Canonical URL and alternate URL should be the same; '
					. 'Alternate URL should be kept even when it is the current page view language',
				null,
				'zh-tw',
				'https://www.example.org/mw/index.php?title=My_test_page&variant=zh-tw',
				'zh-tw',
				'http://example.org/mw/index.php?title=My_test_page&variant=zh-tw',
				'http://example.org/mw/index.php?title=My_test_page&variant=zh-hant-tw',
			],
			[
				'Non-specified variant with history action - '
					. 'There should be no alternate URLs for language variants'
					. 'There should be no alternate URLs for language variants',
				'history',
				null,
				'https://www.example.org/mw/index.php?title=My_test_page&action=history',
				'zh-tw',
				null,
				'https://www.example.org/mw/index.php?title=My_test_page&action=history&variant=zh-tw',
			],
			[
				'Specified zh-tw variant with history action - '
					. 'There should be no alternate URLs for language variants',
				'history',
				'zh-tw',
				'https://www.example.org/mw/index.php?title=My_test_page&action=history',
				'zh-tw',
				null,
				'https://www.example.org/mw/index.php?title=My_test_page&action=history&variant=zh-tw',
			],
		];
	}

	/**
	 * @dataProvider provideCanonicalUrlAndAlternateUrlData
	 */
	public function testCanonicalUrlAndAlternateUrls(
		$messsage, $action, $urlVariant, $canonicalUrl, $altUrlLangCode, $present, $nonpresent
	) {
		$req = new FauxRequest( [
			'title' => 'My_test_page',
			'action' => $action,
			'variant' => $urlVariant,
		] );
		$this->setRequest( $req );
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'zh' );
		$op = $this->newInstance( [ MainConfigNames::EnableCanonicalServerLink => true ], $req );
		$bcp47 = LanguageCode::bcp47( $altUrlLangCode );
		$bcp47Lowercase = strtolower( $bcp47 );
		$headLinks = $op->getHeadLinksArray();

		$this->assertSame(
			Html::element( 'link', [ 'rel' => 'canonical', 'href' => $canonicalUrl ] ),
			$headLinks['link-canonical'],
			$messsage
		);

		if ( isset( $present ) ) {
			$this->assertSame(
				Html::element(
					'link',
					[
						'rel' => 'alternate',
						'hreflang' => $bcp47,
						'href' => $present,
					]
				),
				$headLinks['link-alternate-language-' . $bcp47Lowercase],
				$messsage
			);
		}

		$this->assertNotContains(
			Html::element(
				'link',
				[ 'rel' => 'alternate', 'hreflang' => $bcp47, 'href' => $nonpresent, ]
			),
			$headLinks,
			$messsage
		);
	}

	public function testSetCopyrightUrl() {
		$op = $this->newInstance();
		$op->setCopyrightUrl( 'http://example.com' );

		$this->assertSame(
			Html::element( 'link', [ 'rel' => 'license', 'href' => 'http://example.com' ] ),
			$op->getHeadLinksArray()['copyright']
		);
	}

	/**
	 * @dataProvider provideFeedLinkData
	 */
	public function testRecentChangesFeed( $feed, $advertised_feed_types,
				$message, $present, $non_present ) {
		$outputPage = $this->setupFeedLinks( $feed, $advertised_feed_types );
		$this->assertFeedLinks( $outputPage, $message, $present, $non_present );
	}

	public static function provideAdditionalFeedData() {
		return [
			[
				true, [ 'atom' ], 'Additional Atom feed should be offered',
				'atom',
				[ self::ATOM_TEST_LINK, self::ATOM_RC_LINK ],
				[ self::RSS_TEST_LINK, self::RSS_RC_LINK ],
				true,
			],
			[
				true, [ 'rss' ], 'Additional RSS feed should be offered',
				'rss',
				[ self::RSS_TEST_LINK, self::RSS_RC_LINK ],
				[ self::ATOM_TEST_LINK, self::ATOM_RC_LINK ],
				true,
			],
			[
				true, [ 'rss' ], 'Additional Atom feed should NOT be offered with RSS enabled',
				'atom',
				[ self::RSS_RC_LINK ],
				[ self::RSS_TEST_LINK, self::ATOM_TEST_LINK, self::ATOM_RC_LINK ],
				false,
			],
			[
				false, [ 'atom' ], 'Additional Atom feed should NOT be offered, all feeds disabled',
				'atom',
				[],
				[
					self::RSS_TEST_LINK, self::ATOM_TEST_LINK,
					self::ATOM_RC_LINK, self::ATOM_RC_LINK,
				],
				false,
			],
		];
	}

	/**
	 * @dataProvider provideAdditionalFeedData
	 */
	public function testAdditionalFeeds( $feed, $advertised_feed_types, $message,
			$additional_feed_type, $present, $non_present, $any_ui_links ) {
		$outputPage = $this->setupFeedLinks( $feed, $advertised_feed_types );
		$outputPage->addFeedLink( $additional_feed_type, 'fake-link' );
		$this->assertFeedLinks( $outputPage, $message, $present, $non_present );
		$this->assertFeedUILinks( $outputPage, $any_ui_links );
	}

	// @todo How to test setStatusCode?

	public function testMetaTags() {
		$op = $this->newInstance();
		$op->addMeta( 'http:expires', '0' );
		$op->addMeta( 'keywords', 'first' );
		$op->addMeta( 'keywords', 'second' );
		$op->addMeta( 'og:title', 'Ta-duh' );

		$expected = [
			[ 'http:expires', '0' ],
			[ 'keywords', 'first' ],
			[ 'keywords', 'second' ],
			[ 'og:title', 'Ta-duh' ],
		];
		$this->assertSame( $expected, $op->getMetaTags() );

		$links = $op->getHeadLinksArray();
		$this->assertContains( '<meta http-equiv="expires" content="0">', $links );
		$this->assertContains( '<meta name="keywords" content="first">', $links );
		$this->assertContains( '<meta name="keywords" content="second">', $links );
		$this->assertContains( '<meta property="og:title" content="Ta-duh">', $links );
		$this->assertArrayHasKey( 'meta-robots', $links );
	}

	public function testAddLink() {
		$op = $this->newInstance();

		$links = [
			[],
			[ 'rel' => 'foo', 'href' => 'http://example.com' ],
		];

		foreach ( $links as $link ) {
			$op->addLink( $link );
		}

		$this->assertSame( $links, $op->getLinkTags() );

		$result = $op->getHeadLinksArray();

		foreach ( $links as $link ) {
			$this->assertContains( Html::element( 'link', $link ), $result );
		}
	}

	public function testAddScript() {
		$op = $this->newInstance();
		$op->addScript( 'some random string' );

		$this->assertStringContainsString(
			"\nsome random string\n",
			"\n" . $op->getBottomScripts() . "\n"
		);
	}

	public function testAddScriptFile() {
		$op = $this->newInstance();
		$op->addScriptFile( '/somescript.js' );
		$op->addScriptFile( '//example.com/somescript.js' );

		$this->assertStringContainsString(
			"\n" . Html::linkedScript( '/somescript.js' ) .
				Html::linkedScript( '//example.com/somescript.js' ) . "\n",
			"\n" . $op->getBottomScripts() . "\n"
		);
	}

	public function testAddInlineScript() {
		$op = $this->newInstance();
		$op->addInlineScript( 'let foo = "bar";' );
		$op->addInlineScript( 'alert( foo );' );

		$this->assertStringContainsString(
			"\n" . Html::inlineScript( "\nlet foo = \"bar\";\n" ) . "\n" .
				Html::inlineScript( "\nalert( foo );\n" ) . "\n",
			"\n" . $op->getBottomScripts() . "\n"
		);
	}

	// @todo How to test addContentOverride(Callback)?

	public function testHeadItems() {
		$op = $this->newInstance();
		$op->addHeadItem( 'a', 'b' );
		$op->addHeadItems( [ 'c' => '<d>&amp;', 'e' => 'f', 'a' => 'q' ] );
		$op->addHeadItem( 'e', 'g' );
		$op->addHeadItems( 'x' );

		$this->assertSame( [ 'a' => 'q', 'c' => '<d>&amp;', 'e' => 'g', 'x' ],
			$op->getHeadItemsArray() );

		$this->assertTrue( $op->hasHeadItem( 'a' ) );
		$this->assertTrue( $op->hasHeadItem( 'c' ) );
		$this->assertTrue( $op->hasHeadItem( 'e' ) );
		$this->assertTrue( $op->hasHeadItem( '0' ) );

		$this->assertStringContainsString( "\nq\n<d>&amp;\ng\nx\n",
			'' . $op->headElement( $op->getContext()->getSkin() ) );
	}

	public function testHeadItemsParserOutput() {
		$op = $this->newInstance();
		$stubPO1 = $this->createParserOutputStub( 'getHeadItems', [ 'a' => 'b' ] );
		$op->addParserOutputMetadata( $stubPO1 );
		$stubPO2 = $this->createParserOutputStub( 'getHeadItems',
			[ 'c' => '<d>&amp;', 'e' => 'f', 'a' => 'q' ] );
		$op->addParserOutputMetadata( $stubPO2 );
		$stubPO3 = $this->createParserOutputStub( 'getHeadItems', [ 'e' => 'g' ] );
		$op->addParserOutput( $stubPO3 );
		$stubPO4 = $this->createParserOutputStub( 'getHeadItems', [ 'x' ] );
		$op->addParserOutputMetadata( $stubPO4 );

		$this->assertSame( [ 'a' => 'q', 'c' => '<d>&amp;', 'e' => 'g', 'x' ],
			$op->getHeadItemsArray() );

		$this->assertTrue( $op->hasHeadItem( 'a' ) );
		$this->assertTrue( $op->hasHeadItem( 'c' ) );
		$this->assertTrue( $op->hasHeadItem( 'e' ) );
		$this->assertTrue( $op->hasHeadItem( '0' ) );
		$this->assertFalse( $op->hasHeadItem( 'b' ) );

		$this->assertStringContainsString( "\nq\n<d>&amp;\ng\nx\n",
			'' . $op->headElement( $op->getContext()->getSkin() ) );
	}

	public function testCSPParserOutput() {
		$this->overrideConfigValue( MainConfigNames::CSPHeader, [] );
		foreach ( [ 'Default', 'Script', 'Style' ] as $type ) {
			$op = $this->newInstance();
			$ltype = strtolower( $type );
			$stubPO1 = $this->createParserOutputStub( "getExtraCSP{$type}Srcs", [ "{$ltype}src.com" ] );
			$op->addParserOutputMetadata( $stubPO1 );
			$csp = TestingAccessWrapper::newFromObject( $op->getCSP() );
			$actual = $csp->makeCSPDirectives( [ 'default-src' => [] ], false );
			$regex = '/(^|;)\s*' . $ltype . '-src\s[^;]*' . $ltype . 'src\.com[\s;]/';
			$this->assertMatchesRegularExpression( $regex, $actual, $type );
		}
	}

	public function testAddBodyClasses() {
		$op = $this->newInstance();
		$op->addBodyClasses( 'a' );
		$op->addBodyClasses( 'mediawiki' );
		$op->addBodyClasses( 'b c' );
		$op->addBodyClasses( [ 'd', 'e' ] );
		$op->addBodyClasses( 'a' );

		$this->assertStringContainsString( '<body class="a mediawiki b c d e ',
			'' . $op->headElement( $op->getContext()->getSkin() ) );
	}

	public function testArticleBodyOnly() {
		$op = $this->newInstance();
		$this->assertFalse( $op->getArticleBodyOnly() );

		$op->setArticleBodyOnly( true );
		$this->assertTrue( $op->getArticleBodyOnly() );

		$op->addHTML( '<b>a</b>' );

		$this->assertSame( '<b>a</b>', $op->output( true ) );
	}

	public function testProperties() {
		$op = $this->newInstance();

		$this->assertNull( $op->getProperty( 'foo' ) );

		$op->setProperty( 'foo', 'bar' );
		$op->setProperty( 'baz', 'quz' );

		$this->assertSame( 'bar', $op->getProperty( 'foo' ) );
		$this->assertSame( 'quz', $op->getProperty( 'baz' ) );
	}

	/**
	 * @dataProvider provideCheckLastModified
	 */
	public function testCheckLastModified(
		$timestamp, $ifModifiedSince, $expected, $config = [], $callback = null
	) {
		$request = new FauxRequest();
		if ( $ifModifiedSince ) {
			if ( is_numeric( $ifModifiedSince ) ) {
				// Unix timestamp
				$ifModifiedSince = date( 'D, d M Y H:i:s', $ifModifiedSince ) . ' GMT';
			}
			$request->setHeader( 'If-Modified-Since', $ifModifiedSince );
		}

		// Make sure it's not too recent
		$config['CacheEpoch'] ??= '20000101000000';
		$config['CachePages'] ??= true;

		$op = $this->newInstance( $config, $request );

		if ( $callback ) {
			$callback( $op, $this );
		}

		// Ignore complaint about not being able to disable compression
		$this->assertEquals( $expected, @$op->checkLastModified( $timestamp ) );
	}

	public function provideCheckLastModified() {
		$lastModified = time() - 3600;
		return [
			'Timestamp 0' =>
				[ '0', $lastModified, false ],
			'Timestamp Unix epoch' =>
				[ '19700101000000', $lastModified, false ],
			'Timestamp same as If-Modified-Since' =>
				[ $lastModified, $lastModified, true ],
			'Timestamp one second after If-Modified-Since' =>
				[ $lastModified + 1, $lastModified, false ],
			'No If-Modified-Since' =>
				[ $lastModified + 1, null, false ],
			'Malformed If-Modified-Since' =>
				[ $lastModified + 1, 'GIBBERING WOMBATS !!!', false ],
			'Non-standard IE-style If-Modified-Since' =>
				[ $lastModified, date( 'D, d M Y H:i:s', $lastModified ) . ' GMT; length=5202',
					true ],
			// @todo Should we fix this behavior to match the spec?  Probably no reason to.
			'If-Modified-Since not per spec but we accept it anyway because strtotime does' =>
				[ $lastModified, "@$lastModified", true ],
			'$wgCachePages = false' =>
				[ $lastModified, $lastModified, false, [ MainConfigNames::CachePages => false ] ],
			'$wgCacheEpoch' =>
				[ $lastModified, $lastModified, false,
					[ MainConfigNames::CacheEpoch => wfTimestamp( TS_MW, $lastModified + 1 ) ] ],
			'Recently-touched user' =>
				[ $lastModified, $lastModified, false, [],
				function ( OutputPage $op ) {
					$op->getContext()->setUser( $this->getTestUser()->getUser() );
				} ],
			'After CDN expiry' =>
				[ $lastModified, $lastModified, false,
					[ MainConfigNames::UseCdn => true, MainConfigNames::CdnMaxAge => 3599 ] ],
			'Hook allows cache use' =>
				[ $lastModified + 1, $lastModified, true, [],
				static function ( $op, $that ) {
					$that->setTemporaryHook( 'OutputPageCheckLastModified',
						static function ( &$modifiedTimes ) {
							$modifiedTimes = [ 1 ];
						}
					);
				} ],
			'Hooks prohibits cache use' =>
				[ $lastModified, $lastModified, false, [],
				static function ( $op, $that ) {
					$that->setTemporaryHook( 'OutputPageCheckLastModified',
						static function ( &$modifiedTimes ) {
							$modifiedTimes = [ max( $modifiedTimes ) + 1 ];
						}
					);
				} ],
		];
	}

	// @todo How to test setLastModified?

	public function testSetRobotPolicy() {
		$op = $this->newInstance();
		$op->setRobotPolicy( 'noindex, nofollow' );

		$links = $op->getHeadLinksArray();
		$this->assertContains( '<meta name="robots" content="noindex,nofollow,max-image-preview:standard">', $links );
	}

	public function testSetRobotsOptions() {
		$op = $this->newInstance();
		$op->setRobotPolicy( 'nofollow' );
		$op->setRobotsOptions( [ 'max-snippet' => '500' ] );
		$op->setIndexPolicy( 'index' );

		$links = $op->getHeadLinksArray();
		$this->assertContains( '<meta name="robots" content="index,nofollow,max-image-preview:standard,max-snippet:500">', $links );

		$op->setFollowPolicy( 'follow' );
		$links = $op->getHeadLinksArray();
		$this->assertContains(
			'<meta name="robots" content="max-image-preview:standard,max-snippet:500">',
			$links,
			'When index,follow (browser default) omit'
		);

		$op->setIndexPolicy( 'noindex' );
		$links = $op->getHeadLinksArray();
		$this->assertContains(
			'<meta name="robots" content="noindex,follow,max-image-preview:standard,max-snippet:500">',
			$links,
			'noindex takes precedence over index'
		);

		// Deprecated behavior: for OutputPage (unlike ParserOutput) we can
		// reset to 'index' after 'noindex' has been set.
		$this->filterDeprecated( '/OutputPage::setIndexPolicy with index after noindex/' );
		$op->setIndexPolicy( 'index' );
		$links = $op->getHeadLinksArray();
		$this->assertContains(
			'<meta name="robots" content="max-image-preview:standard,max-snippet:500">',
			$links,
			'index can reset noindex (deprecated)'
		);
	}

	public function testGetRobotPolicy() {
		$op = $this->newInstance();
		$op->setRobotPolicy( 'noindex, follow' );

		$policy = $op->getRobotPolicy();
		$this->assertSame( 'noindex,follow', $policy );
	}

	/**
	 * This test is safe to remove once ::setIndexPolicy() is removed.
	 * @covers \MediaWiki\Output\OutputPage::setIndexPolicy
	 */
	public function testSetIndexPoliciesBackCompat() {
		$op = $this->newInstance();
		$this->assertSame( "", $op->getMetadata()->getIndexPolicy() );
		$op->setIndexPolicy( 'index' );
		$this->assertEquals( "index", $op->getIndexPolicy() );
		$this->assertEquals( "index", $op->getMetadata()->getIndexPolicy() );
		$op->setIndexPolicy( 'noindex' );
		$this->assertEquals( "noindex", $op->getIndexPolicy() );
		$this->assertEquals( "noindex", $op->getMetadata()->getIndexPolicy() );
	}

	/**
	 * @covers \MediaWiki\Output\OutputPage::getMetadata
	 * @covers \MediaWiki\Output\OutputPage::getIndexPolicy
	 * @covers \MediaWiki\Output\OutputPage::setFollowPolicy
	 * @covers \MediaWiki\Output\OutputPage::getHeadLinksArray
	 */
	public function testSetIndexFollowPolicies() {
		$op = $this->newInstance();
		$this->assertSame( "", $op->getMetadata()->getIndexPolicy() );
		$this->assertEquals( "index", $op->getIndexPolicy() );
		$op->getMetadata()->setIndexPolicy( 'noindex' );
		$this->assertEquals( "noindex", $op->getIndexPolicy() );
		$op->setFollowPolicy( 'nofollow' );

		$links = $op->getHeadLinksArray();
		$this->assertContains( '<meta name="robots" content="noindex,nofollow,max-image-preview:standard">', $links );
	}

	private function extractHTMLTitle( OutputPage $op ) {
		$html = $op->headElement( $op->getContext()->getSkin() );

		// OutputPage should always output the title in a nice format such that regexes will work
		// fine.  If it doesn't, we'll fail the tests.
		preg_match_all( '!<title>(.*?)</title>!', $html, $matches );

		$this->assertLessThanOrEqual( 1, count( $matches[1] ), 'More than one <title>!' );

		return $matches[1][0] ?? null;
	}

	/**
	 * Shorthand for getting the text of a message, in content language.
	 * @param MessageLocalizer $op
	 * @param mixed ...$msgParams
	 * @return string
	 */
	private static function getMsgText( MessageLocalizer $op, ...$msgParams ) {
		return $op->msg( ...$msgParams )->inContentLanguage()->text();
	}

	public function testHTMLTitle() {
		$op = $this->newInstance();

		// Default
		$this->assertSame( '', $op->getHTMLTitle() );
		$this->assertSame( '', $op->getPageTitle() );
		$this->assertSame(
			$this->getMsgText( $op, 'pagetitle', '' ),
			$this->extractHTMLTitle( $op )
		);

		// Set to string
		$op->setHTMLTitle( 'Potatoes will eat me' );

		$this->assertSame( 'Potatoes will eat me', $op->getHTMLTitle() );
		$this->assertSame( 'Potatoes will eat me', $this->extractHTMLTitle( $op ) );
		// Shouldn't have changed the page title
		$this->assertSame( '', $op->getPageTitle() );

		// Set to message
		$msg = $op->msg( 'mainpage' );

		$op->setHTMLTitle( $msg );
		$this->assertSame( $msg->text(), $op->getHTMLTitle() );
		$this->assertSame( $msg->text(), $this->extractHTMLTitle( $op ) );
		$this->assertSame( '', $op->getPageTitle() );
	}

	public function testSetRedirectedFrom() {
		$op = $this->newInstance();

		$op->setRedirectedFrom( new PageReferenceValue( NS_TALK, 'Some page', PageReference::LOCAL ) );
		$this->assertSame( 'Talk:Some_page', $op->getJSVars()['wgRedirectedFrom'] );
	}

	public function testPageTitle() {
		// We don't test the actual HTML output anywhere, because that's up to the skin.
		$op = $this->newInstance();

		// Test default
		$this->assertSame( '', $op->getPageTitle() );
		$this->assertSame( '', $op->getHTMLTitle() );

		// Test set to plain text
		$op->setPageTitle( 'foobar' );

		$this->assertSame( 'foobar', $op->getPageTitle() );
		// HTML title should change as well
		$this->assertSame( $this->getMsgText( $op, 'pagetitle', 'foobar' ), $op->getHTMLTitle() );

		// Test set to text with good and bad HTML.  We don't try to be *too*
		// comprehensive here, that belongs in Sanitizer tests, but we'll
		// address the issues specifically noted in T298401/T67747 at least...
		$sanitizerTests = [
			[
				'input' => '<script>a</script>&amp;<i>b</i>',
				'getPageTitle' => '&lt;script&gt;a&lt;/script&gt;&amp;<i>b</i>',
				'getHTMLTitle' => '<script>a</script>&b',
			],
			[
				'input' => '<code style="display:none">', # T298401
				'getPageTitle' => '<code style="display:none"></code>',
				'getHTMLTitle' => '',
			],
			[
				'input' => '<b>Foo bar<b>', # T67747
				'getPageTitle' => '<b>Foo bar<b></b></b>',
				'getHTMLTitle' => 'Foo bar',
			],
		];
		foreach ( $sanitizerTests as $case ) {
			$op->setPageTitle( $case['input'] );

			$this->assertSame( $case['getPageTitle'], $op->getPageTitle() );
			$this->assertSame(
				$this->getMsgText( $op, 'pagetitle', $case['getHTMLTitle'] ),
				$op->getHTMLTitle()
			);
		}

		// Test set to message (deprecated unescaped)
		$text = $this->getMsgText( $op, 'mainpage' );

		$op->setPageTitleMsg( $op->msg( 'mainpage' )->inContentLanguage() );
		$this->assertSame( $text, $op->getPageTitle() );
		$this->assertSame( $this->getMsgText( $op, 'pagetitle', $text ), $op->getHTMLTitle() );

		// Test set to message (::setPageTitleMsg(), escaped)
		$msg = ( new RawMessage( 'nope:<span>$1 yes:$2' ) )
			->plaintextParams( '</span>' )
			->rawParams( '<span>!</span>' );
		// preferred ::setPageTitleMsg(Msg)
		$op->setPageTitleMsg( $msg );
		$this->assertSame( 'nope:&lt;span&gt;&lt;/span&gt; yes:<span>!</span>', $op->getPageTitle() );
		// Note that HTML title is unescaped plaintext, it is expected to be
		// HTML escaped before becoming the <title> element.
		$this->assertSame( $this->getMsgText( $op, 'pagetitle', 'nope:<span></span> yes:!' ), $op->getHTMLTitle() );

		// deprecated ::setPageTitle(Message), doesn't escape either
		// the localized message or the plaintext parameters
		$this->filterDeprecated( '/OutputPage::setPageTitle with Message argument/' );
		$op->setPageTitle( $msg );
		$this->assertSame( "nope:<span></span> yes:<span>!</span>", $op->getPageTitle() );
	}

	public function testSetTitle() {
		$op = $this->newInstance();

		$this->assertSame( 'My test page', $op->getTitle()->getPrefixedText() );

		$op->setTitle( Title::makeTitle( NS_MAIN, 'Another test page' ) );

		$this->assertSame( 'Another test page', $op->getTitle()->getPrefixedText() );
	}

	public function testSubtitle() {
		$op = $this->newInstance();

		$this->assertSame( '', $op->getSubtitle() );

		$op->addSubtitle( '<b>foo</b>' );

		$this->assertSame( '<b>foo</b>', $op->getSubtitle() );

		$op->addSubtitle( $op->msg( 'mainpage' )->inContentLanguage() );

		$this->assertSame(
			"<b>foo</b><br />\n\t\t\t\t" . $this->getMsgText( $op, 'mainpage' ),
			$op->getSubtitle()
		);

		$op->setSubtitle( 'There can be only one' );

		$this->assertSame( 'There can be only one', $op->getSubtitle() );

		$op->clearSubtitle();

		$this->assertSame( '', $op->getSubtitle() );
	}

	/**
	 * @dataProvider provideBacklinkSubtitle
	 */
	public function testBuildBacklinkSubtitle( $titles, $queries, $contains, $notContains ) {
		if ( count( $titles ) > 1 ) {
			// Not applicable
			$this->assertTrue( true );
			return;
		}

		$title = $titles[0];
		$query = $queries[0];

		$str = OutputPage::buildBacklinkSubtitle( $title, $query )->text();

		foreach ( $contains as $substr ) {
			$this->assertStringContainsString( $substr, $str );
		}

		foreach ( $notContains as $substr ) {
			$this->assertStringNotContainsString( $substr, $str );
		}
	}

	/**
	 * @dataProvider provideBacklinkSubtitle
	 */
	public function testAddBacklinkSubtitle( $titles, $queries, $contains, $notContains ) {
		$op = $this->newInstance();
		foreach ( $titles as $i => $unused ) {
			$op->addBacklinkSubtitle( $titles[$i], $queries[$i] );
		}

		$str = $op->getSubtitle();

		foreach ( $contains as $substr ) {
			$this->assertStringContainsString( $substr, $str );
		}

		foreach ( $notContains as $substr ) {
			$this->assertStringNotContainsString( $substr, $str );
		}
	}

	public function provideBacklinkSubtitle() {
		$page1title = $this->makeMockTitle( 'Page 1', [ 'redirect' => true ] );
		$page1ref = new PageReferenceValue( NS_MAIN, 'Page 1', PageReference::LOCAL );

		$row = [
			'page_id' => 28,
			'page_namespace' => NS_MAIN,
			'page_title' => 'Page 2',
			'page_latest' => 75,
			'page_is_redirect' => true,
			'page_is_new' => true,
			'page_touched' => '20200101221133',
			'page_lang' => 'en',
		];
		$page2rec = new PageStoreRecord( (object)$row, PageReference::LOCAL );

		$special = new PageReferenceValue( NS_SPECIAL, 'BlankPage', PageReference::LOCAL );

		return [
			[
				[ $page1title ],
				[ [] ],
				[ 'Page 1' ],
				[ 'redirect', 'Page 2' ],
			],
			[
				[ $page2rec ],
				[ [] ],
				[ 'redirect=no' ],
				[ 'Page 1' ],
			],
			[
				[ $special ],
				[ [] ],
				[ 'Special:BlankPage' ],
				[ 'redirect=no' ],
			],
			[
				[ $page1ref ],
				[ [ 'action' => 'edit' ] ],
				[ 'action=edit' ],
				[],
			],
			[
				[ $page1ref, $page2rec ],
				[ [], [] ],
				[ 'Page 1', 'Page 2', "<br />\n\t\t\t\t" ],
				[],
			],
			// @todo Anything else to test?
		];
	}

	public function testPrintable() {
		$op = $this->newInstance();

		$this->assertFalse( $op->isPrintable() );

		$op->setPrintable();

		$this->assertTrue( $op->isPrintable() );
	}

	public function testDisable() {
		$op = $this->newInstance();

		$this->assertFalse( $op->isDisabled() );
		$this->assertNotSame( '', $op->output( true ) );

		$op->disable();

		$this->assertTrue( $op->isDisabled() );
		$this->assertSame( '', $op->output( true ) );
	}

	public function testShowNewSectionLink() {
		$op = $this->newInstance();

		$this->assertFalse( $op->showNewSectionLink() );
		$this->assertFalse( $op->getOutputFlag( ParserOutputFlags::NEW_SECTION ) );

		$pOut1 = $this->createParserOutputStubWithFlags(
			[ 'getNewSection' => true ], [ ParserOutputFlags::NEW_SECTION ]
		);
		$op->addParserOutputMetadata( $pOut1 );
		$this->assertTrue( $op->showNewSectionLink() );
		$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::NEW_SECTION ) );

		$pOut2 = $this->createParserOutputStub( 'getNewSection', false );
		$op->addParserOutput( $pOut2 );
		$this->assertFalse( $op->showNewSectionLink() );
		// Note that flags are OR'ed together, and not reset.
		$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::NEW_SECTION ) );
	}

	public function testForceHideNewSectionLink() {
		$op = $this->newInstance();

		$this->assertFalse( $op->forceHideNewSectionLink() );
		$this->assertFalse( $op->getOutputFlag( ParserOutputFlags::HIDE_NEW_SECTION ) );

		$pOut1 = $this->createParserOutputStubWithFlags(
			[ 'getHideNewSection' => true ], [ ParserOutputFlags::HIDE_NEW_SECTION ]
		);
		$op->addParserOutputMetadata( $pOut1 );
		$this->assertTrue( $op->forceHideNewSectionLink() );
		$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::HIDE_NEW_SECTION ) );

		$pOut2 = $this->createParserOutputStub( 'getHideNewSection', false );
		$op->addParserOutput( $pOut2 );
		$this->assertFalse( $op->forceHideNewSectionLink() );
		// Note that flags are OR'ed together, and not reset.
		$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::HIDE_NEW_SECTION ) );
	}

	public function testSetSyndicated() {
		$op = $this->newInstance( [ MainConfigNames::Feed => true ] );
		$this->assertFalse( $op->isSyndicated() );

		$op->setSyndicated();
		$this->assertTrue( $op->isSyndicated() );

		$op->setSyndicated( false );
		$this->assertFalse( $op->isSyndicated() );

		$op = $this->newInstance(); // Feed => false by default
		$this->assertFalse( $op->isSyndicated() );

		$op->setSyndicated();
		$this->assertFalse( $op->isSyndicated() );
	}

	public function testFeedLinks() {
		$op = $this->newInstance( [ MainConfigNames::Feed => true ] );
		$this->assertSame( [], $op->getSyndicationLinks() );

		$op->addFeedLink( 'not a supported format', 'abc' );
		$this->assertFalse( $op->isSyndicated() );
		$this->assertSame( [], $op->getSyndicationLinks() );

		$feedTypes = $op->getConfig()->get( MainConfigNames::AdvertisedFeedTypes );

		$op->addFeedLink( $feedTypes[0], 'def' );
		$this->assertTrue( $op->isSyndicated() );
		$this->assertSame( [ $feedTypes[0] => 'def' ], $op->getSyndicationLinks() );

		$op->setFeedAppendQuery( false );
		$expected = [];
		foreach ( $feedTypes as $type ) {
			$expected[$type] = $op->getTitle()->getLocalURL( "feed=$type" );
		}
		$this->assertSame( $expected, $op->getSyndicationLinks() );

		$op->setFeedAppendQuery( 'apples=oranges' );
		foreach ( $feedTypes as $type ) {
			$expected[$type] = $op->getTitle()->getLocalURL( "feed=$type&apples=oranges" );
		}
		$this->assertSame( $expected, $op->getSyndicationLinks() );

		$op = $this->newInstance(); // Feed => false by default
		$this->assertSame( [], $op->getSyndicationLinks() );

		$op->addFeedLink( $feedTypes[0], 'def' );
		$this->assertFalse( $op->isSyndicated() );
		$this->assertSame( [], $op->getSyndicationLinks() );
	}

	public function testArticleFlags() {
		$op = $this->newInstance();
		$this->assertFalse( $op->isArticle() );
		$this->assertTrue( $op->isArticleRelated() );

		$op->setArticleRelated( false );
		$this->assertFalse( $op->isArticle() );
		$this->assertFalse( $op->isArticleRelated() );

		$op->setArticleFlag( true );
		$this->assertTrue( $op->isArticle() );
		$this->assertTrue( $op->isArticleRelated() );

		$op->setArticleFlag( false );
		$this->assertFalse( $op->isArticle() );
		$this->assertTrue( $op->isArticleRelated() );

		$op->setArticleFlag( true );
		$op->setArticleRelated( false );
		$this->assertFalse( $op->isArticle() );
		$this->assertFalse( $op->isArticleRelated() );
	}

	public function testLanguageLinks() {
		$op = $this->newInstance();
		$this->assertSame( [], $op->getLanguageLinks() );

		$op->addLanguageLinks( [ 'fr:A#x', 'it:B' ] );
		$this->assertSame( [ 'fr:A#x', 'it:B' ], $op->getLanguageLinks() );

		$op->addLanguageLinks( [
			TitleValue::tryNew( NS_MAIN, 'C', '', 'de' ),
			TitleValue::tryNew( NS_MAIN, 'D', '', 'es' ),
		] );
		$this->assertSame( [ 'fr:A#x', 'it:B', 'de:C', 'es:D' ], $op->getLanguageLinks() );

		$op->setLanguageLinks( [ TitleValue::tryNew( NS_MAIN, 'E', '', 'pt' ) ] );
		$this->assertSame( [ 'pt:E' ], $op->getLanguageLinks() );

		$pOut1 = $this->createParserOutputStub( 'getLanguageLinks', [ 'he:F', 'ar:G#y' ] );
		$op->addParserOutputMetadata( $pOut1 );
		$this->assertSame( [ 'pt:E', 'he:F', 'ar:G#y' ], $op->getLanguageLinks() );

		# Duplicates are removed in OutputPage (T26502)
		$pOut2 = $this->createParserOutputStub( 'getLanguageLinks', [ 'pt:H' ] );
		$op->addParserOutput( $pOut2 );
		$this->assertSame( [ 'pt:E', 'he:F', 'ar:G#y' ], $op->getLanguageLinks() );
	}

	// @todo Are these category links tests too abstract and complicated for what they test?  Would
	// it make sense to just write out all the tests by hand with maybe some copy-and-paste?

	/**
	 * @dataProvider provideGetCategories
	 *
	 *
	 * @param array $args Array of form [ category name => sort key ]
	 * @param array $fakeResults Array of form [ category name => value to return from mocked
	 *   LinkBatch ]
	 * @param callable|null $variantLinkCallback Callback to replace findVariantLink() call
	 * @param array $expectedNormal Expected return value of getCategoryLinks['normal']
	 * @param array $expectedHidden Expected return value of getCategoryLinks['hidden']
	 */
	public function testAddCategoryLinks(
		array $args, array $fakeResults, ?callable $variantLinkCallback,
		array $expectedNormal, array $expectedHidden
	) {
		$expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'add' );
		$expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'add' );

		$op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );

		$op->addCategoryLinks( $args );

		$this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
		$this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
	}

	/**
	 * @dataProvider provideGetCategories
	 */
	public function testAddCategoryLinksOneByOne(
		array $args, array $fakeResults, ?callable $variantLinkCallback,
		array $expectedNormal, array $expectedHidden
	) {
		if ( count( $args ) <= 1 ) {
			// @todo Should this be skipped instead of passed?
			$this->assertTrue( true );
			return;
		}

		$expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'onebyone' );
		$expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'onebyone' );

		$op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );

		foreach ( $args as $key => $val ) {
			$op->addCategoryLinks( [ $key => $val ] );
		}

		$this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
		$this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
	}

	/**
	 * @dataProvider provideGetCategories
	 */
	public function testSetCategoryLinks(
		array $args, array $fakeResults, ?callable $variantLinkCallback,
		array $expectedNormal, array $expectedHidden
	) {
		$expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'set' );
		$expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'set' );

		$op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );

		$this->filterDeprecated( '/OutputPage::setCategoryLinks was deprecated/' );
		$op->setCategoryLinks( [ 'Initial page' => 'Initial page' ] );
		$op->setCategoryLinks( $args );

		// We don't reset the categories, for some reason, only the links
		$expectedNormalCats = array_merge( [ 'Initial page' ], $expectedNormal );

		$this->doCategoryAsserts( $op, $expectedNormalCats, $expectedHidden );
		$this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
	}

	/**
	 * @dataProvider provideGetCategories
	 */
	public function testParserOutputCategoryLinks(
		array $args, array $fakeResults, ?callable $variantLinkCallback,
		array $expectedNormal, array $expectedHidden
	) {
		$expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'pout' );
		$expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'pout' );

		$op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );

		$stubPO = $this->createParserOutputStub( [
			'getCategoryMap' => $args,
		] );

		// addParserOutput and addParserOutputMetadata should behave identically for us, so
		// alternate to get coverage for both without adding extra tests
		static $idx = 0;
		$idx++;
		$method = [ 'addParserOutputMetadata', 'addParserOutput' ][$idx % 2];
		$op->$method( $stubPO );

		$this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
		$this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
	}

	/**
	 * @dataProvider provideGetCategories
	 */
	public function testOutputPageRenderCategoryLinkHook(
		array $args, array $fakeResults, ?callable $variantLinkCallback,
		array $expectedNormal, array $expectedHidden, array $expectedText
	) {
		$expectedNormal = $this->extractExpectedCategories( $expectedNormal, 'add' );
		$expectedHidden = $this->extractExpectedCategories( $expectedHidden, 'add' );

		$op = $this->setupCategoryTests( $fakeResults, $variantLinkCallback );

		$tf = $this->getServiceContainer()->getTitleFormatter();
		$this->setTemporaryHook( 'OutputPageRenderCategoryLink',
			static function ( $outputPage, $categoryTitle, $text, &$link ) use ( $tf ) {
				$link = 'Custom link: ' . $tf->getPrefixedText( $categoryTitle ) . ", text: ($text)";
			}
		);
		$op->addCategoryLinks( $args );

		$this->doCategoryAsserts( $op, $expectedNormal, $expectedHidden );
		$this->doCategoryLinkAsserts( $op, $expectedNormal, $expectedHidden );
		$i = 0;
		foreach ( $op->getCategoryLinks() as $type => $actual ) {
			foreach ( $actual as $link ) {
				$text = $expectedText[$i++];
				$this->assertStringContainsString( 'Custom link: Category:', $link );
				$this->assertStringContainsString( ", text: ($text)", $link );
			}
		}
		$this->assertCount( $i, $expectedText );
	}

	/**
	 * We allow different expectations for different tests as an associative array, like
	 * [ 'set' => [ ... ], 'default' => [ ... ] ] if setCategoryLinks() will give a different
	 * result.
	 * @param array $expected
	 * @param string $key
	 * @return array
	 */
	private function extractExpectedCategories( array $expected, $key ) {
		if ( !$expected || isset( $expected[0] ) ) {
			return $expected;
		}
		return $expected[$key] ?? $expected['default'];
	}

	private function setupCategoryTests(
		array $fakeResults, ?callable $variantLinkCallback = null
	): OutputPage {
		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, true );

		if ( $variantLinkCallback ) {
			$mockLanguageConverter = $this
				->createMock( ILanguageConverter::class );
			$mockLanguageConverter
				->method( 'findVariantLink' )
				->willReturnCallback( $variantLinkCallback );
			$mockLanguageConverter
				->method( 'convertHtml' )
				->willReturnCallback( 'strrev' );

			$languageConverterFactory = $this
				->createMock( LanguageConverterFactory::class );
			$languageConverterFactory
				->method( 'getLanguageConverter' )
				->willReturn( $mockLanguageConverter );
			$this->setService(
				'LanguageConverterFactory',
				$languageConverterFactory
			);
		}

		$op = $this->getMockBuilder( OutputPage::class )
			->setConstructorArgs( [ new RequestContext() ] )
			->onlyMethods( [ 'addCategoryLinksToLBAndGetResult', 'getTitle' ] )
			->getMock();

		$title = Title::makeTitle( NS_MAIN, 'My test page' );
		$op->method( 'getTitle' )
			->willReturn( $title );

		$op->method( 'addCategoryLinksToLBAndGetResult' )
			->willReturnCallback( static function ( array $categories ) use ( $fakeResults ) {
				$return = [];
				foreach ( $categories as $category => $unused ) {
					if ( isset( $fakeResults[$category] ) ) {
						$return[] = $fakeResults[$category];
					}
				}
				return new FakeResultWrapper( $return );
			} );

		$this->assertSame( [], $op->getCategories() );

		return $op;
	}

	private function doCategoryAsserts( OutputPage $op, $expectedNormal, $expectedHidden ) {
		$this->assertSame( array_merge( $expectedHidden, $expectedNormal ), $op->getCategories() );
		$this->assertSame( $expectedNormal, $op->getCategories( 'normal' ) );
		$this->assertSame( $expectedHidden, $op->getCategories( 'hidden' ) );
	}

	private function doCategoryLinkAsserts( OutputPage $op, $expectedNormal, $expectedHidden ) {
		$catLinks = $op->getCategoryLinks();
		$this->assertCount( (bool)$expectedNormal + (bool)$expectedHidden, $catLinks );
		if ( $expectedNormal ) {
			$this->assertSameSize( $expectedNormal, $catLinks['normal'] );
		}
		if ( $expectedHidden ) {
			$this->assertSameSize( $expectedHidden, $catLinks['hidden'] );
		}

		foreach ( $expectedNormal as $i => $name ) {
			$this->assertStringContainsString( $name, $catLinks['normal'][$i] );
		}
		foreach ( $expectedHidden as $i => $name ) {
			$this->assertStringContainsString( $name, $catLinks['hidden'][$i] );
		}
	}

	public static function provideGetCategories() {
		return [
			'No categories' => [ [], [], null, [], [], [] ],
			'Simple test' => [
				[ 'Test1' => 'Some sortkey', 'Test2' => 'A different sortkey' ],
				[ 'Test1' => (object)[ 'pp_value' => 1, 'page_title' => 'Test1' ],
					'Test2' => (object)[ 'page_title' => 'Test2' ] ],
				null,
				[ 'Test2' ],
				[ 'Test1' ],
				[ 'Test1', 'Test2' ],
			],
			'Invalid title' => [
				[ '[' => '[', 'Test' => 'Test' ],
				[ 'Test' => (object)[ 'page_title' => 'Test' ] ],
				null,
				[ 'Test' ],
				[],
				[ 'Test' ],
			],
			'Variant link' => [
				[ 'Test' => 'Test', 'Estay' => 'Estay' ],
				[ 'Test' => (object)[ 'page_title' => 'Test' ] ],
				static function ( &$link, &$title ) {
					if ( $link === 'Estay' ) {
						$link = 'Test';
						$title = Title::makeTitleSafe( NS_CATEGORY, $link );
					}
				},
				// For adding one by one, the variant gets added as well as the original category,
				// but if you add them all together the second time gets skipped.
				[ 'onebyone' => [ 'Test', 'Test' ], 'default' => [ 'Test' ] ],
				[],
				[ 'tseT' ],
			],
		];
	}

	public function testGetCategoriesInvalid() {
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( 'Invalid category type given: hiddne' );

		$op = $this->newInstance();
		$op->getCategories( 'hiddne' );
	}

	// @todo Should we test addCategoryLinksToLBAndGetResult?  If so, how?  Insert some test rows in
	// the DB?

	public function testIndicators() {
		$op = $this->newInstance();
		$this->assertSame( [], $op->getIndicators() );

		$op->setIndicators( [] );
		$this->assertSame( [], $op->getIndicators() );

		// Test sorting alphabetically
		$op->setIndicators( [ 'b' => 'x', 'a' => 'y' ] );
		$this->assertSame( [ 'a' => 'y', 'b' => 'x' ], $op->getIndicators() );

		// Test overwriting existing keys
		$op->setIndicators( [ 'c' => 'z', 'a' => 'w' ] );
		$this->assertSame( [ 'a' => 'w', 'b' => 'x', 'c' => 'z' ], $op->getIndicators() );

		// Test with addParserOutputMetadata
		// Note that the indicators are wrapped.
		$pOut1 = $this->createParserOutputStub( [
			'getIndicators' => [ 'c' => 'u', 'd' => 'v' ],
			'getWrapperDivClass' => 'wrapper1',
		] );
		$op->addParserOutputMetadata( $pOut1 );
		$this->assertSame( [
			'a' => 'w',
			'b' => 'x',
			'c' => '<div class="wrapper1">u</div>',
			'd' => '<div class="wrapper1">v</div>',
		], $op->getIndicators() );

		// Test with addParserOutput
		$pOut2 = $this->createParserOutputStub( [
			'getIndicators' => [ 'a' => '!!!' ],
			'getWrapperDivClass' => 'wrapper2',
		] );
		$op->addParserOutput( $pOut2 );
		$this->assertSame( [
			'a' => '<div class="wrapper2">!!!</div>',
			'b' => 'x',
			'c' => '<div class="wrapper1">u</div>',
			'd' => '<div class="wrapper1">v</div>',
		], $op->getIndicators() );
	}

	public function testAddHelpLink() {
		$op = $this->newInstance();

		$op->addHelpLink( 'Manual:PHP unit testing' );
		$indicators = $op->getIndicators();
		$this->assertSame( [ 'mw-helplink' ], array_keys( $indicators ) );
		$this->assertStringContainsString( 'Manual:PHP_unit_testing', $indicators['mw-helplink'] );

		$op->addHelpLink( 'https://phpunit.de', true );
		$indicators = $op->getIndicators();
		$this->assertSame( [ 'mw-helplink' ], array_keys( $indicators ) );
		$this->assertStringContainsString( 'https://phpunit.de', $indicators['mw-helplink'] );
		$this->assertStringNotContainsString( 'mediawiki', $indicators['mw-helplink'] );
		$this->assertStringNotContainsString( 'Manual:PHP', $indicators['mw-helplink'] );
	}

	public function testBodyHTML() {
		$op = $this->newInstance();
		$this->assertSame( '', $op->getHTML() );

		$op->addHTML( 'a' );
		$this->assertSame( 'a', $op->getHTML() );

		$op->addHTML( 'b' );
		$this->assertSame( 'ab', $op->getHTML() );

		$op->prependHTML( 'c' );
		$this->assertSame( 'cab', $op->getHTML() );

		$op->addElement( 'p', [ 'id' => 'foo' ], 'd' );
		$this->assertSame( 'cab<p id="foo">d</p>', $op->getHTML() );

		$op->clearHTML();
		$this->assertSame( '', $op->getHTML() );
	}

	/**
	 * @dataProvider provideRevisionId
	 */
	public function testRevisionId( $newVal, $expected ) {
		$op = $this->newInstance();

		$this->assertNull( $op->setRevisionId( $newVal ) );
		$this->assertSame( $expected, $op->getRevisionId() );
		$this->assertSame( $expected, $op->setRevisionId( null ) );
		$this->assertNull( $op->getRevisionId() );
	}

	public static function provideRevisionId() {
		return [
			[ null, null ],
			[ 7, 7 ],
			[ -1, -1 ],
			[ 3.2, 3 ],
			[ '0', 0 ],
			[ '32% finished', 32 ],
			[ false, 0 ],
		];
	}

	public function testRevisionTimestamp() {
		$op = $this->newInstance();
		$this->assertNull( $op->getRevisionTimestamp() );

		$this->assertNull( $op->setRevisionTimestamp( 'abc' ) );
		$this->assertSame( 'abc', $op->getRevisionTimestamp() );
		$this->assertSame( 'abc', $op->setRevisionTimestamp( null ) );
		$this->assertNull( $op->getRevisionTimestamp() );
	}

	public function testFileVersion() {
		$op = $this->newInstance();
		$this->assertNull( $op->getFileVersion() );

		$stubFile = $this->createMock( File::class );
		$stubFile->method( 'exists' )->willReturn( true );
		$stubFile->method( 'getTimestamp' )->willReturn( '12211221123321' );
		$stubFile->method( 'getSha1' )->willReturn( 'bf3ffa7047dc080f5855377a4f83cd18887e3b05' );

		/** @var File $stubFile */
		$op->setFileVersion( $stubFile );

		$this->assertEquals(
			[ 'time' => '12211221123321', 'sha1' => 'bf3ffa7047dc080f5855377a4f83cd18887e3b05' ],
			$op->getFileVersion()
		);

		$stubMissingFile = $this->createMock( File::class );
		$stubMissingFile->method( 'exists' )->willReturn( false );

		/** @var File $stubMissingFile */
		$op->setFileVersion( $stubMissingFile );
		$this->assertNull( $op->getFileVersion() );

		$op->setFileVersion( $stubFile );
		$this->assertNotNull( $op->getFileVersion() );

		$op->setFileVersion( null );
		$this->assertNull( $op->getFileVersion() );
	}

	/**
	 * Call either with arguments $methodName, $returnValue; or an array
	 * [ $methodName => $returnValue, $methodName => $returnValue, ... ]
	 * @param mixed ...$args
	 * @return ParserOutput
	 */
	private function createParserOutputStub( ...$args ): ParserOutput {
		if ( count( $args ) === 0 ) {
			$retVals = [];
		} elseif ( count( $args ) === 1 ) {
			$retVals = $args[0];
		} elseif ( count( $args ) === 2 ) {
			$retVals = [ $args[0] => $args[1] ];
		}
		return $this->createParserOutputStubWithFlags( $retVals, [] );
	}

	/**
	 * First argument is an array
	 * [ $methodName => $returnValue, $methodName => $returnValue, ... ]
	 * Second argument is an array of parser flags for which ::getOutputFlag()
	 * should return 'TRUE'.
	 * @param array $retVals
	 * @param array $flags
	 * @return ParserOutput
	 */
	private function createParserOutputStubWithFlags( array $retVals, array $flags ): ParserOutput {
		$pOut = $this->createMock( ParserOutput::class );

		$mockedRunOutputPipeline = false;
		foreach ( $retVals as $method => $retVal ) {
			$pOut->method( $method )->willReturn( $retVal );
			if ( $method === 'runOutputPipeline' ) {
				$mockedRunOutputPipeline = true;
			}
		}

		// Needed to ensure OutputPage::getParserOutputText doesn't return null
		if ( !$mockedRunOutputPipeline ) {
			$pOut->method( 'runOutputPipeline' )->willReturn( new ParserOutput( '' ) );
		}

		$arrayReturningMethods = [
			'getCategoryMap',
			'getFileSearchOptions',
			'getHeadItems',
			'getImages',
			'getIndicators',
			'getSections',
			'getLanguageLinks',
			'getTemplateIds',
			'getExtraCSPDefaultSrcs',
			'getExtraCSPStyleSrcs',
			'getExtraCSPScriptSrcs',
		];

		foreach ( $arrayReturningMethods as $method ) {
			$pOut->method( $method )->willReturn( [] );
		}

		$pOut->method( 'getOutputFlag' )->willReturnCallback( static function ( $name ) use ( $flags ) {
			return in_array( $name, $flags, true );
		} );

		return $pOut;
	}

	public function testTemplateIds() {
		$op = $this->newInstance();
		$this->assertSame( [], $op->getTemplateIds() );

		// Test with no template id's
		$stubPOEmpty = $this->createParserOutputStub();
		$op->addParserOutputMetadata( $stubPOEmpty );
		$this->assertSame( [], $op->getTemplateIds() );

		// Test with some arbitrary template id's
		$ids = [
			NS_MAIN => [ 'A' => 3, 'B' => 17 ],
			NS_TALK => [ 'C' => 31 ],
			NS_MEDIA => [ 'D' => -1 ],
		];

		$stubPO1 = $this->createParserOutputStub( 'getTemplateIds', $ids );

		$op->addParserOutputMetadata( $stubPO1 );
		$this->assertSame( $ids, $op->getTemplateIds() );

		// Test merging with a second set of id's
		$stubPO2 = $this->createParserOutputStub( 'getTemplateIds', [
			NS_MAIN => [ 'E' => 1234 ],
			NS_PROJECT => [ 'F' => 5678 ],
		] );

		$finalIds = [
			NS_MAIN => [ 'E' => 1234, 'A' => 3, 'B' => 17 ],
			NS_TALK => [ 'C' => 31 ],
			NS_MEDIA => [ 'D' => -1 ],
			NS_PROJECT => [ 'F' => 5678 ],
		];

		$op->addParserOutput( $stubPO2 );
		$this->assertSame( $finalIds, $op->getTemplateIds() );

		// Test merging with an empty set of id's
		$op->addParserOutputMetadata( $stubPOEmpty );
		$this->assertSame( $finalIds, $op->getTemplateIds() );
	}

	public function testFileSearchOptions() {
		$op = $this->newInstance();
		$this->assertSame( [], $op->getFileSearchOptions() );

		// Test with no files
		$stubPOEmpty = $this->createParserOutputStub();

		$op->addParserOutputMetadata( $stubPOEmpty );
		$this->assertSame( [], $op->getFileSearchOptions() );

		// Test with some arbitrary files
		$files1 = [
			'A' => [ 'time' => null, 'sha1' => '' ],
			'B' => [
				'time' => '12211221123321',
				'sha1' => 'bf3ffa7047dc080f5855377a4f83cd18887e3b05',
			],
		];

		$stubPO1 = $this->createParserOutputStub( 'getFileSearchOptions', $files1 );

		$op->addParserOutput( $stubPO1 );
		$this->assertSame( $files1, $op->getFileSearchOptions() );

		// Test merging with a second set of files
		$files2 = [
			'C' => [ 'time' => null, 'sha1' => '' ],
			'B' => [ 'time' => null, 'sha1' => '' ],
		];

		$stubPO2 = $this->createParserOutputStub( 'getFileSearchOptions', $files2 );

		$op->addParserOutputMetadata( $stubPO2 );
		$this->assertSame( array_merge( $files1, $files2 ), $op->getFileSearchOptions() );

		// Test merging with an empty set of files
		$op->addParserOutput( $stubPOEmpty );
		$this->assertSame( array_merge( $files1, $files2 ), $op->getFileSearchOptions() );
	}

	/**
	 * @dataProvider provideAddWikiText
	 */
	public function testAddWikiText( $method, array $args, $expected ) {
		$op = $this->newInstance();
		$this->assertSame( '', $op->getHTML() );

		if ( in_array(
			$method,
			[ 'addWikiTextAsInterface', 'addWikiTextAsContent' ]
		) && count( $args ) >= 3 && $args[2] === null ) {
			// Special placeholder because we can't get the actual title in the provider
			$args[2] = $op->getTitle();
		}

		$op->$method( ...$args );
		$this->assertSame( $expected, $op->getHTML() );
	}

	public static function provideAddWikiText() {
		$somePageRef = new PageReferenceValue( NS_TALK, 'Some page', PageReference::LOCAL );

		$tests = [
			'addWikiTextAsInterface' => [
				'Simple wikitext' => [
					[ "'''Bold'''" ],
					"<p><b>Bold</b>\n</p>",
				], 'Untidy wikitext' => [
					[ "<b>Bold" ],
					"<p><b>Bold\n</b></p>",
				], 'List at start' => [
					[ '* List' ],
					"<ul><li>List</li></ul>\n",
				], 'List not at start' => [
					[ '* Not a list', false ],
					'<p>* Not a list</p>',
				], 'No section edit links' => [
					[ '== Title ==' ],
					'<div class="mw-heading mw-heading2"><h2 id="Title">Title</h2></div>',
				], 'With title at start' => [
					[ '* {{PAGENAME}}', true, Title::makeTitle( NS_TALK, 'Some page' ) ],
					"<ul><li>Some page</li></ul>\n",
				], 'With title not at start' => [
					[ '* {{PAGENAME}}', false, Title::makeTitle( NS_TALK, 'Some page' ) ],
					"<p>* Some page</p>",
				], 'Untidy input' => [
					[ '<b>{{PAGENAME}}', true, $somePageRef ],
					"<p><b>Some page\n</b></p>",
				],
			],
			'addWikiTextAsContent' => [
				'SpecialNewimages' => [
					[ "<p lang='en' dir='ltr'>\nMy message" ],
					'<p lang="en" dir="ltr">' . "\nMy message</p>"
				], 'List at start' => [
					[ '* List' ],
					"<ul><li>List</li></ul>",
				], 'List not at start' => [
					[ '* <b>Not a list', false ],
					'<p>* <b>Not a list</b></p>',
				], 'With title at start' => [
					[ '* {{PAGENAME}}', true, Title::makeTitle( NS_TALK, 'Some page' ) ],
					"<ul><li>Some page</li></ul>",
				], 'With title not at start' => [
					[ '* {{PAGENAME}}', false, Title::makeTitle( NS_TALK, 'Some page' ) ],
					"<p>* Some page</p>",
				], 'EditPage' => [
					[ "<div class='mw-editintro'>{{PAGENAME}}", true, $somePageRef ],
					'<div class="mw-editintro">' . "Some page</div>"
				],
			],
			'wrapWikiTextAsInterface' => [
				'Simple' => [
					[ 'wrapperClass', 'text' ],
					"<div class=\"mw-content-ltr wrapperClass\" lang=\"en\" dir=\"ltr\"><p>text\n</p></div>"
				], 'Spurious </div>' => [
					[ 'wrapperClass', 'text</div><div>more' ],
					"<div class=\"mw-content-ltr wrapperClass\" lang=\"en\" dir=\"ltr\"><p>text</p><div>more</div></div>"
				], 'Extra newlines would break <p> wrappers' => [
					[ 'two classes', "1\n\n2\n\n3" ],
					"<div class=\"mw-content-ltr two classes\" lang=\"en\" dir=\"ltr\"><p>1\n</p><p>2\n</p><p>3\n</p></div>"
				], 'Other unclosed tags' => [
					[ 'error', 'a<b>c<i>d' ],
					"<div class=\"mw-content-ltr error\" lang=\"en\" dir=\"ltr\"><p>a<b>c<i>d\n</i></b></p></div>"
				],
			],
		];

		// We have to reformat our array to match what PHPUnit wants
		$ret = [];
		foreach ( $tests as $key => $subarray ) {
			foreach ( $subarray as $subkey => $val ) {
				$val = array_merge( [ $key ], $val );
				$ret[$subkey] = $val;
			}
		}

		return $ret;
	}

	public function testAddWikiTextAsInterfaceNoTitle() {
		$this->expectException( RuntimeException::class );
		$this->expectExceptionMessage( 'No title' );

		$op = $this->newInstance( [], null, 'notitle' );
		$op->addWikiTextAsInterface( 'a' );
	}

	public function testAddWikiTextAsContentNoTitle() {
		$this->expectException( RuntimeException::class );
		$this->expectExceptionMessage( 'No title' );

		$op = $this->newInstance( [], null, 'notitle' );
		$op->addWikiTextAsContent( 'a' );
	}

	public function testAddWikiMsg() {
		$msg = wfMessage( 'parentheses' );
		$this->assertSame( '(a)', $msg->rawParams( 'a' )->plain() );

		$op = $this->newInstance();
		$this->assertSame( '', $op->getHTML() );
		$op->addWikiMsg( 'parentheses', "<b>a" );
		// The input is bad unbalanced HTML, but the output is tidied
		$this->assertSame( "<p>(<b>a)\n</b></p>", $op->getHTML() );
	}

	public function testWrapWikiMsg() {
		$msg = wfMessage( 'parentheses' );
		$this->assertSame( '(a)', $msg->rawParams( 'a' )->plain() );

		$op = $this->newInstance();
		$this->assertSame( '', $op->getHTML() );
		$op->wrapWikiMsg( '[$1]', [ 'parentheses', "<b>a" ] );
		// The input is bad unbalanced HTML, but the output is tidied
		$this->assertSame( "<p>[(<b>a)]\n</b></p>", $op->getHTML() );
	}

	public function testNoGallery() {
		$op = $this->newInstance();
		$this->assertFalse( $op->getNoGallery() );
		$this->assertFalse( $op->getOutputFlag( ParserOutputFlags::NO_GALLERY ) );

		$stubPO1 = $this->createParserOutputStubWithFlags(
			[ 'getNoGallery' => true ], [ ParserOutputFlags::NO_GALLERY ]
		);
		$op->addParserOutputMetadata( $stubPO1 );
		$this->assertTrue( $op->getNoGallery() );
		$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::NO_GALLERY ) );

		$stubPO2 = $this->createParserOutputStub( 'getNoGallery', false );
		$op->addParserOutput( $stubPO2 );
		$this->assertFalse( $op->getNoGallery() );
		// Note that flags are OR'ed together, and not reset.
		$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::NO_GALLERY ) );
	}

	// @todo Make sure to test the following in addParserOutputMetadata() as well when we add tests
	// for them:
	//   * addModules()
	//   * addModuleStyles()
	//   * addJsConfigVars()
	//   * enableOOUI()
	// Otherwise those lines of addParserOutputMetadata() will be reported as covered, but we won't
	// be testing they actually work.

	public function testAddParserOutputText() {
		$op = $this->newInstance();
		$this->assertSame( '', $op->getHTML() );

		$text = '<some text>';
		$pOut = $this->createParserOutputStub( 'runOutputPipeline', new ParserOutput( $text ) );

		$op->addParserOutputMetadata( $pOut );
		$this->assertSame( '', $op->getHTML() );

		$op->addParserOutputText( $text );
		$this->assertSame( '<some text>', $op->getHTML() );
	}

	public function testAddParserOutput() {
		$op = $this->newInstance();
		$this->assertSame( '', $op->getHTML() );
		$this->assertFalse( $op->showNewSectionLink() );
		$this->assertFalse( $op->getOutputFlag( ParserOutputFlags::NEW_SECTION ) );

		$pOut = $this->createParserOutputStubWithFlags( [
			'getContentHolderText' => '<some text>',
			'getNewSection' => true,
		], [
			ParserOutputFlags::NEW_SECTION,
		] );

		$op->addParserOutput( $pOut );
		$this->assertSame( '<some text>', $op->getHTML() );
		$this->assertTrue( $op->showNewSectionLink() );
		$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::NEW_SECTION ) );
	}

	public function testAddTemplate() {
		$template = $this->createMock( QuickTemplate::class );
		$template->method( 'getHTML' )->willReturn( '<abc>&def;' );

		$op = $this->newInstance();
		$op->addTemplate( $template );

		$this->assertSame( '<abc>&def;', $op->getHTML() );
	}

	/**
	 * @dataProvider provideParseAs
	 */
	public function testParseAsContent(
		array $args, $expectedHTML, $expectedHTMLInline = null
	) {
		$op = $this->newInstance();
		$this->assertSame( $expectedHTML, $op->parseAsContent( ...$args ) );
	}

	/**
	 * @dataProvider provideParseAs
	 */
	public function testParseAsInterface(
		array $args, $expectedHTML, $expectedHTMLInline = null
	) {
		$op = $this->newInstance();
		$this->assertSame( $expectedHTML, $op->parseAsInterface( ...$args ) );
	}

	/**
	 * @dataProvider provideParseAs
	 */
	public function testParseInlineAsInterface(
		array $args, $expectedHTML, $expectedHTMLInline = null
	) {
		$op = $this->newInstance();
		$this->assertSame(
			$expectedHTMLInline ?? $expectedHTML,
			$op->parseInlineAsInterface( ...$args )
		);
	}

	public static function provideParseAs() {
		return [
			'List at start of line' => [
				[ '* List', true ],
				"<ul><li>List</li></ul>",
			],
			'List not at start' => [
				[ "* ''Not'' list", false ],
				'<p>* <i>Not</i> list</p>',
				'* <i>Not</i> list',
			],
			'Italics' => [
				[ "''Italic''", true ],
				"<p><i>Italic</i>\n</p>",
				'<i>Italic</i>',
			],
			'formatnum' => [
				[ '{{formatnum:123456.789}}', true ],
				"<p>123,456.789\n</p>",
				"123,456.789",
			],
			'No section edit links' => [
				[ '== Header ==' ],
				'<div class="mw-heading mw-heading2"><h2 id="Header">Header</h2></div>',
			]
		];
	}

	public function testParseAsContentNullTitle() {
		$this->expectException( RuntimeException::class );
		$this->expectExceptionMessage( 'No title' );
		$op = $this->newInstance( [], null, 'notitle' );
		$op->parseAsContent( '' );
	}

	public function testParseAsInterfaceNullTitle() {
		$this->expectException( RuntimeException::class );
		$this->expectExceptionMessage( 'No title' );
		$op = $this->newInstance( [], null, 'notitle' );
		$op->parseAsInterface( '' );
	}

	public function testParseInlineAsInterfaceNullTitle() {
		$this->expectException( RuntimeException::class );
		$this->expectExceptionMessage( 'No title' );
		$op = $this->newInstance( [], null, 'notitle' );
		$op->parseInlineAsInterface( '' );
	}

	public function testCdnMaxage() {
		$op = $this->newInstance();
		$wrapper = TestingAccessWrapper::newFromObject( $op );
		$this->assertSame( 0, $wrapper->mCdnMaxage );

		$op->setCdnMaxage( -1 );
		$this->assertSame( -1, $wrapper->mCdnMaxage );

		$op->setCdnMaxage( 120 );
		$this->assertSame( 120, $wrapper->mCdnMaxage );

		$op->setCdnMaxage( 60 );
		$this->assertSame( 60, $wrapper->mCdnMaxage );

		$op->setCdnMaxage( 180 );
		$this->assertSame( 180, $wrapper->mCdnMaxage );

		$op->lowerCdnMaxage( 240 );
		$this->assertSame( 180, $wrapper->mCdnMaxage );

		$op->setCdnMaxage( 300 );
		$this->assertSame( 240, $wrapper->mCdnMaxage );

		$op->lowerCdnMaxage( 120 );
		$this->assertSame( 120, $wrapper->mCdnMaxage );

		$op->setCdnMaxage( 180 );
		$this->assertSame( 120, $wrapper->mCdnMaxage );

		$op->setCdnMaxage( 60 );
		$this->assertSame( 60, $wrapper->mCdnMaxage );

		$op->setCdnMaxage( 240 );
		$this->assertSame( 120, $wrapper->mCdnMaxage );
	}

	/** @var int Faked time to set for tests that need it */
	private static $fakeTime;

	/**
	 * @dataProvider provideAdaptCdnTTL
	 * @param array $args To pass to adaptCdnTTL()
	 * @param int $expected Expected new value of mCdnMaxageLimit
	 * @param array $options Associative array:
	 *  initialMaxage => Maxage to set before calling adaptCdnTTL() (default 86400)
	 */
	public function testAdaptCdnTTL( array $args, $expected, array $options = [] ) {
		MWTimestamp::setFakeTime( self::$fakeTime );

		$op = $this->newInstance();
		// Set a high maxage so that it will get reduced by adaptCdnTTL().  The default maxage
		// is 0, so adaptCdnTTL() won't mutate the object at all.
		$initial = $options['initialMaxage'] ?? 86400;
		$op->setCdnMaxage( $initial );
		$op->adaptCdnTTL( ...$args );

		$wrapper = TestingAccessWrapper::newFromObject( $op );

		// Special rules for false/null
		if ( $args[0] === null || $args[0] === false ) {
			$this->assertSame( $initial, $wrapper->mCdnMaxage, 'member value' );
			$op->setCdnMaxage( $expected + 1 );
			$this->assertSame( $expected + 1, $wrapper->mCdnMaxage, 'member value after new set' );
			return;
		}

		$this->assertSame( $expected, $wrapper->mCdnMaxageLimit, 'limit value' );

		if ( $initial >= $expected ) {
			$this->assertSame( $expected, $wrapper->mCdnMaxage, 'member value' );
		} else {
			$this->assertSame( $initial, $wrapper->mCdnMaxage, 'member value' );
		}

		$op->setCdnMaxage( $expected + 1 );
		$this->assertSame( $expected, $wrapper->mCdnMaxage, 'member value after new set' );
	}

	public static function provideAdaptCdnTTL() {
		global $wgCdnMaxAge;
		$now = time();
		self::$fakeTime = $now;
		return [
			'Five minutes ago' => [ [ $now - 300 ], 270 ],
			'Now' => [ [ +0 ], ExpirationAwareness::TTL_MINUTE ],
			'Five minutes from now' => [ [ $now + 300 ], ExpirationAwareness::TTL_MINUTE ],
			'Five minutes ago, initial maxage four minutes' =>
				[ [ $now - 300 ], 270, [ 'initialMaxage' => 240 ] ],
			'A very long time ago' => [ [ $now - 1000000000 ], $wgCdnMaxAge ],
			'Initial maxage zero' => [ [ $now - 300 ], 270, [ 'initialMaxage' => 0 ] ],

			'false' => [ [ false ], ExpirationAwareness::TTL_MINUTE ],
			'null' => [ [ null ], ExpirationAwareness::TTL_MINUTE ],
			"'0'" => [ [ '0' ], ExpirationAwareness::TTL_MINUTE ],
			'Empty string' => [ [ '' ], ExpirationAwareness::TTL_MINUTE ],
			// @todo These give incorrect results due to timezones, how to test?
			//"'now'" => [ [ 'now' ], ExpirationAwareness::TTL_MINUTE ],
			//"'parse error'" => [ [ 'parse error' ], ExpirationAwareness::TTL_MINUTE ],

			'Now, minTTL 0' => [ [ $now, 0 ], ExpirationAwareness::TTL_MINUTE ],
			'Now, minTTL 0.000001' => [ [ $now, 0.000001 ], 0 ],
			'A very long time ago, maxTTL even longer' =>
				[ [ $now - 1000000000, 0, 1000000001 ], 900000000 ],
		];
	}

	public function testClientCache() {
		$op = $this->newInstance();
		$op->considerCacheSettingsFinal();

		// Test initial value
		$this->assertSame( true, $op->couldBePublicCached() );

		// Test setting to false
		$op->disableClientCache();
		$this->assertSame( false, $op->couldBePublicCached() );

		// Test setting to true
		$op->enableClientCache();
		$this->assertSame( true, $op->couldBePublicCached() );

		// set back to false
		$op->disableClientCache();

		// Test that a cacheable ParserOutput doesn't set to true
		$pOutCacheable = $this->createParserOutputStub( 'isCacheable', true );
		$op->addParserOutputMetadata( $pOutCacheable );
		$this->assertSame( false, $op->couldBePublicCached() );

		// Reset to true
		$op = $this->newInstance();
		$op->considerCacheSettingsFinal();
		$this->assertSame( true, $op->couldBePublicCached() );

		// Test that an uncacheable ParserOutput does set to false
		$pOutUncacheable = $this->createParserOutputStub( 'isCacheable', false );
		$op->addParserOutput( $pOutUncacheable );
		$this->assertSame( false, $op->couldBePublicCached() );
	}

	public function testGetCacheVaryCookies() {
		global $wgCookiePrefix, $wgDBname;
		$op = $this->newInstance();
		$prefix = $wgCookiePrefix !== false ? $wgCookiePrefix : $wgDBname;
		$expectedCookies = [
			"{$prefix}Token",
			"{$prefix}LoggedOut",
			"{$prefix}_session",
			'forceHTTPS',
			'cookie1',
			'cookie2',
		];

		// We have to reset the cookies because getCacheVaryCookies may have already been called
		TestingAccessWrapper::newFromClass( OutputPage::class )->cacheVaryCookies = null;

		$this->overrideConfigValue( MainConfigNames::CacheVaryCookies, [ 'cookie1' ] );
		$this->setTemporaryHook( 'GetCacheVaryCookies',
			function ( $innerOP, &$cookies ) use ( $op, $expectedCookies ) {
				$this->assertSame( $op, $innerOP );
				$cookies[] = 'cookie2';
				$this->assertSame( $expectedCookies, $cookies );
			}
		);

		$this->assertSame( $expectedCookies, $op->getCacheVaryCookies() );
	}

	public function testHaveCacheVaryCookies() {
		$request = new FauxRequest();
		$op = $this->newInstance( [], $request );

		// No cookies are set.
		$this->assertFalse( $op->haveCacheVaryCookies() );

		// 'Token' is present but empty, so it shouldn't count.
		$request->setCookie( 'Token', '' );
		$this->assertFalse( $op->haveCacheVaryCookies() );

		// 'Token' present and nonempty.
		$request->setCookie( 'Token', '123' );
		$this->assertTrue( $op->haveCacheVaryCookies() );
	}

	/**
	 * @dataProvider provideVaryHeaders
	 *
	 *
	 * @param array[] $calls For each array, call addVaryHeader() with those arguments
	 * @param string[] $cookies Array of cookie names to vary on
	 * @param string $vary Text of expected Vary header (including the 'Vary: ')
	 */
	public function testVaryHeaders( array $calls, array $cookies, $vary ) {
		// Get rid of default Vary fields
		$op = $this->getMockBuilder( OutputPage::class )
			->setConstructorArgs( [ new RequestContext() ] )
			->onlyMethods( [ 'getCacheVaryCookies' ] )
			->getMock();
		$op->method( 'getCacheVaryCookies' )
			->willReturn( $cookies );
		TestingAccessWrapper::newFromObject( $op )->mVaryHeader = [];

		foreach ( $calls as $call ) {
			$op->addVaryHeader( ...$call );
		}
		$this->assertEquals( $vary, $op->getVaryHeader(), 'Vary:' );
	}

	public static function provideVaryHeaders() {
		return [
			'No header' => [
				[],
				[],
				'Vary: ',
			],
			'Single header' => [
				[
					[ 'Cookie' ],
				],
				[],
				'Vary: Cookie',
			],
			'Non-unique headers' => [
				[
					[ 'Cookie' ],
					[ 'Accept-Language' ],
					[ 'Cookie' ],
				],
				[],
				'Vary: Cookie, Accept-Language',
			],
			'Two headers with single options' => [
				// Options are deprecated since 1.34
				[
					[ 'Cookie', [ 'param=phpsessid' ] ],
					[ 'Accept-Language', [ 'substr=en' ] ],
				],
				[],
				'Vary: Cookie, Accept-Language',
			],
			'One header with multiple options' => [
				// Options are deprecated since 1.34
				[
					[ 'Cookie', [ 'param=phpsessid', 'param=userId' ] ],
				],
				[],
				'Vary: Cookie',
			],
			'Duplicate option' => [
				// Options are deprecated since 1.34
				[
					[ 'Cookie', [ 'param=phpsessid' ] ],
					[ 'Cookie', [ 'param=phpsessid' ] ],
					[ 'Accept-Language', [ 'substr=en', 'substr=en' ] ],
				],
				[],
				'Vary: Cookie, Accept-Language',
			],
			'Same header, different options' => [
				// Options are deprecated since 1.34
				[
					[ 'Cookie', [ 'param=phpsessid' ] ],
					[ 'Cookie', [ 'param=userId' ] ],
				],
				[],
				'Vary: Cookie',
			],
			'No header, vary cookies' => [
				[],
				[ 'cookie1', 'cookie2' ],
				'Vary: Cookie',
			],
			'Cookie header with option plus vary cookies' => [
				// Options are deprecated since 1.34
				[
					[ 'Cookie', [ 'param=cookie1' ] ],
				],
				[ 'cookie2', 'cookie3' ],
				'Vary: Cookie',
			],
			'Non-cookie header plus vary cookies' => [
				[
					[ 'Accept-Language' ],
				],
				[ 'cookie' ],
				'Vary: Accept-Language, Cookie',
			],
			'Cookie and non-cookie headers plus vary cookies' => [
				// Options are deprecated since 1.34
				[
					[ 'Cookie', [ 'param=cookie1' ] ],
					[ 'Accept-Language' ],
				],
				[ 'cookie2' ],
				'Vary: Cookie, Accept-Language',
			],
		];
	}

	public function testVaryHeaderDefault() {
		$op = $this->newInstance();
		$this->assertSame( 'Vary: Accept-Encoding, Cookie', $op->getVaryHeader() );
	}

	/**
	 * @dataProvider provideLinkHeaders
	 */
	public function testLinkHeaders( array $headers, $result ) {
		$op = $this->newInstance();

		foreach ( $headers as $header ) {
			$op->addLinkHeader( $header );
		}

		$this->assertEquals( $result, $op->getLinkHeader() );
	}

	public static function provideLinkHeaders() {
		return [
			[
				[],
				false
			],
			[
				[ '<https://foo/bar.jpg>;rel=preload;as=image' ],
				'Link: <https://foo/bar.jpg>;rel=preload;as=image',
			],
			[
				[
					'<https://foo/bar.jpg>;rel=preload;as=image',
					'<https://foo/baz.jpg>;rel=preload;as=image'
				],
				'Link: <https://foo/bar.jpg>;rel=preload;as=image,<https://foo/baz.jpg>;' .
					'rel=preload;as=image',
			],
		];
	}

	/**
	 * @dataProvider provideAddAcceptLanguage
	 */
	public function testAddAcceptLanguage(
		$code, array $variants, $expected, array $options = []
	) {
		$req = new FauxRequest( in_array( 'varianturl', $options ) ? [ 'variant' => 'x' ] : [] );
		$op = $this->newInstance( [], $req, in_array( 'notitle', $options ) ? 'notitle' : null );

		if ( !in_array( 'notitle', $options ) ) {
			$mockLang = $this->createMock( Language::class );
			$mockLang->method( 'getCode' )->willReturn( $code );

			$mockLanguageConverter = $this
				->createMock( ILanguageConverter::class );
			if ( in_array( 'varianturl', $options ) ) {
				$mockLanguageConverter->expects( $this->never() )->method( $this->anything() );
			} else {
				$mockLanguageConverter->method( 'hasVariants' )->willReturn( count( $variants ) > 1 );
				$mockLanguageConverter->method( 'getVariants' )->willReturn( $variants );
			}

			$languageConverterFactory = $this
				->createMock( LanguageConverterFactory::class );
			$languageConverterFactory
				->method( 'getLanguageConverter' )
				->willReturn( $mockLanguageConverter );
			$this->setService(
				'LanguageConverterFactory',
				$languageConverterFactory
			);

			$mockTitle = $this->createMock( Title::class );
			$mockTitle->method( 'getPageLanguage' )->willReturn( $mockLang );

			$op->setTitle( $mockTitle );
		}

		// This will run addAcceptLanguage()
		$op->sendCacheControl();
		$this->assertSame( "Vary: $expected", $op->getVaryHeader() );
	}

	public static function provideAddAcceptLanguage() {
		return [
			'No variants' => [
				'en',
				[ 'en' ],
				'Accept-Encoding, Cookie',
			],
			'One simple variant' => [
				'en',
				[ 'en', 'en-x-piglatin' ],
				'Accept-Encoding, Cookie, Accept-Language',
			],
			'Multiple variants with BCP47 alternatives' => [
				'zh',
				[ 'zh', 'zh-hans', 'zh-cn', 'zh-tw' ],
				'Accept-Encoding, Cookie, Accept-Language',
			],
			'No title' => [
				'en',
				[ 'en', 'en-x-piglatin' ],
				'Accept-Encoding, Cookie',
				[ 'notitle' ]
			],
			'Variant in URL' => [
				'en',
				[ 'en', 'en-x-piglatin' ],
				'Accept-Encoding, Cookie',
				[ 'varianturl' ]
			],
		];
	}

	public function testClickjacking() {
		$op = $this->newInstance();
		$this->assertTrue( $op->getPreventClickjacking() );

		$op->setPreventClickjacking( false );
		$this->assertFalse( $op->getPreventClickjacking() );

		$op->setPreventClickjacking( true );
		$this->assertTrue( $op->getPreventClickjacking() );

		$op->setPreventClickjacking( false );
		$this->assertFalse( $op->getPreventClickjacking() );

		$pOut1 = $this->createParserOutputStub( 'getPreventClickjacking', true );
		$op->addParserOutputMetadata( $pOut1 );
		$this->assertTrue( $op->getPreventClickjacking() );

		// The ParserOutput can't allow, only prevent
		$pOut2 = $this->createParserOutputStub( 'getPreventClickjacking', false );
		$op->addParserOutputMetadata( $pOut2 );
		$this->assertTrue( $op->getPreventClickjacking() );

		// Reset to test with addParserOutput()
		$op->setPreventClickjacking( false );
		$this->assertFalse( $op->getPreventClickjacking() );

		$op->addParserOutput( $pOut1 );
		$this->assertTrue( $op->getPreventClickjacking() );

		$op->addParserOutput( $pOut2 );
		$this->assertTrue( $op->getPreventClickjacking() );
	}

	/**
	 * @dataProvider provideGetFrameOptions
	 */
	public function testGetFrameOptions(
		$breakFrames, $preventClickjacking, $editPageFrameOptions, $expected
	) {
		$op = $this->newInstance( [
			MainConfigNames::BreakFrames => $breakFrames,
			MainConfigNames::EditPageFrameOptions => $editPageFrameOptions,
		] );
		$op->setPreventClickjacking( $preventClickjacking );

		$this->assertSame( $expected, $op->getFrameOptions() );
	}

	public static function provideGetFrameOptions() {
		return [
			'BreakFrames true' => [ true, false, false, 'DENY' ],
			'Allow clickjacking locally' => [ false, false, 'DENY', false ],
			'Allow clickjacking globally' => [ false, true, false, false ],
			'DENY globally' => [ false, true, 'DENY', 'DENY' ],
			'SAMEORIGIN' => [ false, true, 'SAMEORIGIN', 'SAMEORIGIN' ],
			'BreakFrames with SAMEORIGIN' => [ true, true, 'SAMEORIGIN', 'DENY' ],
		];
	}

	/**
	 * See ClientHtmlTest for full coverage.
	 *
	 * @dataProvider provideMakeResourceLoaderLink
	 */
	public function testMakeResourceLoaderLink( $args, $expectedHtml ) {
		$this->overrideConfigValues( [
			MainConfigNames::ResourceLoaderDebug => false,
			MainConfigNames::LoadScript => 'http://127.0.0.1:8080/w/load.php',
			MainConfigNames::CSPReportOnlyHeader => true,
		] );
		$class = new ReflectionClass( OutputPage::class );
		$method = $class->getMethod( 'makeResourceLoaderLink' );
		$method->setAccessible( true );
		$ctx = new RequestContext();
		$skinFactory = $this->getServiceContainer()->getSkinFactory();
		$ctx->setSkin( $skinFactory->makeSkin( 'fallback' ) );
		$ctx->setLanguage( 'en' );
		$out = new OutputPage( $ctx );
		$reflectCSP = new ReflectionClass( ContentSecurityPolicy::class );
		$rl = $out->getResourceLoader();
		$rl->setMessageBlobStore( $this->createMock( RL\MessageBlobStore::class ) );
		$rl->setDependencyStore( $this->createMock( KeyValueDependencyStore::class ) );
		$rl->register( [
			'test.foo' => [
				'class' => ResourceLoaderTestModule::class,
				'script' => 'mw.test.foo( { a: true } );',
				'styles' => '.mw-test-foo { content: "style"; }',
			],
			'test.bar' => [
				'class' => ResourceLoaderTestModule::class,
				'script' => 'mw.test.bar( { a: true } );',
				'styles' => '.mw-test-bar { content: "style"; }',
			],
			'test.baz' => [
				'class' => ResourceLoaderTestModule::class,
				'script' => 'mw.test.baz( { a: true } );',
				'styles' => '.mw-test-baz { content: "style"; }',
			],
			'test.quux' => [
				'class' => ResourceLoaderTestModule::class,
				'script' => 'mw.test.baz( { token: 123 } );',
				'styles' => '/* pref-animate=off */ .mw-icon { transition: none; }',
				'group' => 'private',
			],
			'test.noscript' => [
				'class' => ResourceLoaderTestModule::class,
				'styles' => '.stuff { color: red; }',
				'group' => 'noscript',
			],
			'test.group.foo' => [
				'class' => ResourceLoaderTestModule::class,
				'script' => 'mw.doStuff( "foo" );',
				'group' => 'foo',
			],
			'test.group.bar' => [
				'class' => ResourceLoaderTestModule::class,
				'script' => 'mw.doStuff( "bar" );',
				'group' => 'bar',
			],
		] );
		$links = $method->invokeArgs( $out, $args );
		$actualHtml = strval( $links );
		$this->assertEquals( $expectedHtml, $actualHtml );
	}

	public static function provideMakeResourceLoaderLink() {
		return [
			// Single only=scripts load
			[
				[ 'test.foo', RL\Module::TYPE_SCRIPTS ],
				"<script>(RLQ=window.RLQ||[]).push(function(){"
					. 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.foo\u0026only=scripts");'
					. "});</script>"
			],
			// Multiple only=styles load
			[
				[ [ 'test.baz', 'test.foo', 'test.bar' ], RL\Module::TYPE_STYLES ],

				'<link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?lang=en&amp;modules=test.bar%2Cbaz%2Cfoo&amp;only=styles">'
			],
			// Private embed (only=scripts)
			[
				[ 'test.quux', RL\Module::TYPE_SCRIPTS ],
				"<script>(RLQ=window.RLQ||[]).push(function(){"
					. "mw.test.baz({token:123});\nmw.loader.state({\"test.quux\":\"ready\"});"
					. "});</script>"
			],
			// Load private module (combined)
			[
				[ 'test.quux', RL\Module::TYPE_COMBINED ],
				"<script>(RLQ=window.RLQ||[]).push(function(){"
					. "mw.loader.impl(function(){return[\"test.quux@1b4i1\",function($,jQuery,require,module){"
					. "mw.test.baz({token:123});\n"
					. "},{\"css\":[\".mw-icon{transition:none}"
					. "\"]}];});});</script>"
			],
			// Load no modules
			[
				[ [], RL\Module::TYPE_COMBINED ],
				'',
			],
			// noscript group
			[
				[ 'test.noscript', RL\Module::TYPE_STYLES ],
				'<noscript><link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?lang=en&amp;modules=test.noscript&amp;only=styles"></noscript>'
			],
			// Load two modules in separate groups
			[
				[ [ 'test.group.foo', 'test.group.bar' ], RL\Module::TYPE_COMBINED ],
				"<script>(RLQ=window.RLQ||[]).push(function(){"
					. 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.group.bar");'
					. 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.group.foo");'
					. "});</script>"
			],
		];
		// phpcs:enable
	}

	/**
	 * @dataProvider provideBuildExemptModules
	 */
	public function testBuildExemptModules( array $exemptStyleModules, $expect ) {
		$this->overrideConfigValues( [
			MainConfigNames::ResourceLoaderDebug => false,
			MainConfigNames::LoadScript => '/w/load.php',
			// Stub wgCacheEpoch as it influences getVersionHash used for the
			// urls in the expected HTML
			MainConfigNames::CacheEpoch => '20140101000000',
		] );

		// Set up stubs
		$ctx = new RequestContext();
		$skinFactory = $this->getServiceContainer()->getSkinFactory();
		$ctx->setSkin( $skinFactory->makeSkin( 'fallback' ) );
		$ctx->setLanguage( 'en' );
		$op = $this->getMockBuilder( OutputPage::class )
			->setConstructorArgs( [ $ctx ] )
			->onlyMethods( [ 'buildCssLinksArray' ] )
			->getMock();
		$op->method( 'buildCssLinksArray' )
			->willReturn( [] );
		/** @var OutputPage $op */
		$rl = $op->getResourceLoader();
		$rl->setMessageBlobStore( $this->createMock( RL\MessageBlobStore::class ) );

		// Register custom modules
		$rl->register( [
			'example.site.a' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'site' ],
			'example.site.b' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'site' ],
			'example.user' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'user' ],
		] );

		$op = TestingAccessWrapper::newFromObject( $op );
		$op->rlExemptStyleModules = $exemptStyleModules;
		$expect = strtr( $expect, [
			'{blankCombi}' => ResourceLoaderTestCase::BLANK_COMBI,
		] );
		$this->assertEquals(
			$expect,
			strval( $op->buildExemptModules() )
		);
	}

	public static function provideBuildExemptModules() {
		return [
			'empty' => [
				'exemptStyleModules' => [],
				'',
			],
			'empty sets' => [
				'exemptStyleModules' => [ 'site' => [], 'noscript' => [], 'private' => [], 'user' => [] ],
				'',
			],
			'default logged-out' => [
				'exemptStyleModules' => [ 'site' => [ 'site.styles' ] ],
				'<meta name="ResourceLoaderDynamicStyles" content="">' . "\n" .
				'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles">',
			],
			'default logged-in' => [
				'exemptStyleModules' => [ 'site' => [ 'site.styles' ], 'user' => [ 'user.styles' ] ],
				'<meta name="ResourceLoaderDynamicStyles" content="">' . "\n" .
				'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles">' . "\n" .
				'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=user.styles&amp;only=styles&amp;version=94mvi">',
			],
			'custom modules' => [
				'exemptStyleModules' => [
					'site' => [ 'site.styles', 'example.site.a', 'example.site.b' ],
					'user' => [ 'user.styles', 'example.user' ],
				],
				'<meta name="ResourceLoaderDynamicStyles" content="">' . "\n" .
				'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=example.site.a%2Cb&amp;only=styles">' . "\n" .
				'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles">' . "\n" .
				'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=example.user&amp;only=styles&amp;version={blankCombi}">' . "\n" .
				'<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=user.styles&amp;only=styles&amp;version=94mvi">',
			],
		];
		// phpcs:enable
	}

	/**
	 * @dataProvider provideTransformFilePath
	 */
	public function testTransformResourcePath( $baseDir, $basePath, $uploadDir = null,
		$uploadPath = null, $path = null, $expected = null
	) {
		if ( $path === null ) {
			// Skip optional $uploadDir and $uploadPath
			$path = $uploadDir;
			$expected = $uploadPath;
			$uploadDir = "$baseDir/images";
			$uploadPath = "$basePath/images";
		}
		$conf = new HashConfig( [
			MainConfigNames::ResourceBasePath => $basePath,
			MainConfigNames::UploadDirectory => $uploadDir,
			MainConfigNames::UploadPath => $uploadPath,
			MainConfigNames::BaseDirectory => $baseDir
		] );

		// Some of these paths don't exist and will cause warnings
		$actual = @OutputPage::transformResourcePath( $conf, $path );

		$this->assertEquals( $expected ?: $path, $actual );
	}

	public static function provideTransformFilePath() {
		$baseDir = dirname( __DIR__ ) . '/../data/media';
		return [
			// File that matches basePath, and exists. Hash found and appended.
			[
				'baseDir' => $baseDir, 'basePath' => '/w',
				'/w/test.jpg',
				'/w/test.jpg?edcf2'
			],
			// File that matches basePath, but not found on disk. Empty query.
			[
				'baseDir' => $baseDir, 'basePath' => '/w',
				'/w/unknown.png',
				'/w/unknown.png'
			],
			// File not matching basePath. Ignored.
			[
				'baseDir' => $baseDir, 'basePath' => '/w',
				'/files/test.jpg'
			],
			// Empty string. Ignored.
			[
				'baseDir' => $baseDir, 'basePath' => '/w',
				'',
				''
			],
			// Similar path, but with domain component. Ignored.
			[
				'baseDir' => $baseDir, 'basePath' => '/w',
				'//example.org/w/test.jpg'
			],
			[
				'baseDir' => $baseDir, 'basePath' => '/w',
				'https://www.example.org/w/test.jpg'
			],
			// Unrelated path with domain component. Ignored.
			[
				'baseDir' => $baseDir, 'basePath' => '/w',
				'https://www.example.org/files/test.jpg'
			],
			[
				'baseDir' => $baseDir, 'basePath' => '/w',
				'//example.org/files/test.jpg'
			],
			// Unrelated path with domain, and empty base path (root mw install). Ignored.
			[
				'baseDir' => $baseDir, 'basePath' => '',
				'https://www.example.org/files/test.jpg'
			],
			[
				'baseDir' => $baseDir, 'basePath' => '',
				// T155310
				'//example.org/files/test.jpg'
			],
			// Check UploadPath before ResourceBasePath (T155146)
			[
				'baseDir' => dirname( $baseDir ), 'basePath' => '',
				'uploadDir' => $baseDir, 'uploadPath' => '/images',
				'/images/test.jpg',
				'/images/test.jpg?edcf2'
			],
		];
	}

	/**
	 * Tests a particular case of transformCssMedia, using the given input, globals,
	 * expected return, and message
	 *
	 * Asserts that $expectedReturn is returned.
	 *
	 * options['queryData'] - value of query string
	 * options['media'] - passed into the method under the same name
	 * options['expectedReturn'] - expected return value
	 * options['message'] - PHPUnit message for assertion
	 *
	 * @param array $args Key-value array of arguments as shown above
	 */
	protected function assertTransformCssMediaCase( $args ) {
		$queryData = $args['queryData'] ?? [];

		$fauxRequest = new FauxRequest( $queryData, false );
		$this->setRequest( $fauxRequest );

		$actualReturn = OutputPage::transformCssMedia( $args['media'] );
		$this->assertSame( $args['expectedReturn'], $actualReturn, $args['message'] );
	}

	public function testPrintRequests() {
		$this->assertTransformCssMediaCase( [
			'queryData' => [ 'printable' => '1' ],
			'media' => 'screen',
			'expectedReturn' => null,
			'message' => 'On printable request, screen returns null'
		] );

		$this->assertTransformCssMediaCase( [
			'queryData' => [ 'printable' => '1' ],
			'media' => self::SCREEN_MEDIA_QUERY,
			'expectedReturn' => null,
			'message' => 'On printable request, screen media query returns null'
		] );

		$this->assertTransformCssMediaCase( [
			'queryData' => [ 'printable' => '1' ],
			'media' => self::SCREEN_ONLY_MEDIA_QUERY,
			'expectedReturn' => null,
			'message' => 'On printable request, screen media query with only returns null'
		] );

		$this->assertTransformCssMediaCase( [
			'queryData' => [ 'printable' => '1' ],
			'media' => 'print',
			'expectedReturn' => '',
			'message' => 'On printable request, media print returns empty string'
		] );
	}

	/**
	 * Test screen requests, without either query parameter set
	 */
	public function testScreenRequests() {
		$this->assertTransformCssMediaCase( [
			'media' => 'screen',
			'expectedReturn' => 'screen',
			'message' => 'On screen request, screen media type is preserved'
		] );

		$this->assertTransformCssMediaCase( [
			'media' => 'handheld',
			'expectedReturn' => 'handheld',
			'message' => 'On screen request, handheld media type is preserved'
		] );

		$this->assertTransformCssMediaCase( [
			'media' => self::SCREEN_MEDIA_QUERY,
			'expectedReturn' => self::SCREEN_MEDIA_QUERY,
			'message' => 'On screen request, screen media query is preserved.'
		] );

		$this->assertTransformCssMediaCase( [
			'media' => self::SCREEN_ONLY_MEDIA_QUERY,
			'expectedReturn' => self::SCREEN_ONLY_MEDIA_QUERY,
			'message' => 'On screen request, screen media query with only is preserved.'
		] );

		$this->assertTransformCssMediaCase( [
			'media' => 'print',
			'expectedReturn' => 'print',
			'message' => 'On screen request, print media type is preserved'
		] );
	}

	public function testIsTOCEnabled() {
		$op = $this->newInstance();
		$this->assertFalse( $op->isTOCEnabled() );
		$this->assertFalse( $op->getOutputFlag( ParserOutputFlags::SHOW_TOC ) );

		$pOut1 = $this->createParserOutputStub();
		$op->addParserOutputMetadata( $pOut1 );
		$this->assertFalse( $op->isTOCEnabled() );
		$this->assertFalse( $op->getOutputFlag( ParserOutputFlags::SHOW_TOC ) );

		$pOut2 = $this->createParserOutputStubWithFlags(
			[], [ ParserOutputFlags::SHOW_TOC ]
		);
		$op->addParserOutput( $pOut2 );
		$this->assertTrue( $op->isTOCEnabled() );
		$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::SHOW_TOC ) );

		// The parser output doesn't disable the TOC after it was enabled
		$op->addParserOutputMetadata( $pOut1 );
		$this->assertTrue( $op->isTOCEnabled() );
		$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::SHOW_TOC ) );
	}

	public function testNoTOC() {
		$op = $this->newInstance();
		$this->assertFalse( $op->getOutputFlag( ParserOutputFlags::NO_TOC ) );

		$stubPO1 = $this->createParserOutputStubWithFlags(
			[], [ ParserOutputFlags::NO_TOC ]
		);
		$op->addParserOutputMetadata( $stubPO1 );
		$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::NO_TOC ) );

		$stubPO2 = $this->createParserOutputStub();
		$this->assertFalse( $stubPO2->getOutputFlag( ParserOutputFlags::NO_TOC ) );
		$op->addParserOutput( $stubPO2 );
		// Note that flags are OR'ed together, and not reset.
		$this->assertTrue( $op->getOutputFlag( ParserOutputFlags::NO_TOC ) );
	}

	/**
	 * @dataProvider providePreloadLinkHeaders
	 * @covers \MediaWiki\ResourceLoader\SkinModule
	 */
	public function testPreloadLinkHeaders( $config, $result ) {
		$ctx = $this->createMock( RL\Context::class );
		$module = new RL\SkinModule();
		$module->setConfig( new HashConfig( $config + ResourceLoaderTestCase::getSettings() ) );

		$this->assertEquals( [ $result ], $module->getHeaders( $ctx ) );
	}

	public static function providePreloadLinkHeaders() {
		return [
			[
				[
					MainConfigNames::ResourceBasePath => '/w',
					MainConfigNames::Logo => '/img/default.png',
					MainConfigNames::Logos => [
						'1.5x' => '/img/one-point-five.png',
						'2x' => '/img/two-x.png',
					],
				],
				'Link: </img/default.png>;rel=preload;as=image;media=' .
				'not all and (min-resolution: 1.5dppx),' .
				'</img/one-point-five.png>;rel=preload;as=image;media=' .
				'(min-resolution: 1.5dppx) and (max-resolution: 1.999999dppx),' .
				'</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
			],
			[
				[
					MainConfigNames::ResourceBasePath => '/w',
					MainConfigNames::Logos => [
						'1x' => '/img/default.png',
					],
				],
				'Link: </img/default.png>;rel=preload;as=image'
			],
			[
				[
					MainConfigNames::ResourceBasePath => '/w',
					MainConfigNames::Logos => [
						'1x' => '/img/default.png',
						'2x' => '/img/two-x.png',
					],
				],
				'Link: </img/default.png>;rel=preload;as=image;media=' .
				'not all and (min-resolution: 2dppx),' .
				'</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
			],
			[
				[
					MainConfigNames::ResourceBasePath => '/w',
					MainConfigNames::Logos => [
						'1x' => '/img/default.png',
						'svg' => '/img/vector.svg',
					],
				],
				'Link: </img/vector.svg>;rel=preload;as=image'

			],
			[
				[
					MainConfigNames::ResourceBasePath => '/w',
					MainConfigNames::Logos => [
						'1x' => '/w/test.jpg',
					],
					MainConfigNames::UploadPath => '/w/images',
					MainConfigNames::BaseDirectory => dirname( __DIR__ ) . '/../data/media'
				],
				'Link: </w/test.jpg?edcf2>;rel=preload;as=image',
			],
		];
	}

	/**
	 * @param int $titleLastRevision Last Title revision to set
	 * @param int $outputRevision Revision stored in OutputPage
	 * @param bool $expectedResult Expected result of $output->isRevisionCurrent call
	 * @dataProvider provideIsRevisionCurrent
	 */
	public function testIsRevisionCurrent( $titleLastRevision, $outputRevision, $expectedResult ) {
		$titleMock = $this->createMock( Title::class );
		$titleMock->method( 'getLatestRevID' )
			->willReturn( $titleLastRevision );

		$output = $this->newInstance( [], null );
		$output->setTitle( $titleMock );
		$output->setRevisionId( $outputRevision );
		$this->assertEquals( $expectedResult, $output->isRevisionCurrent() );
	}

	public static function provideIsRevisionCurrent() {
		return [
			[ 10, null, true ],
			[ 42, 42, true ],
			[ null, 0, true ],
			[ 42, 47, false ],
			[ 47, 42, false ]
		];
	}

	/**
	 * @dataProvider provideSendCacheControl
	 */
	public function testSendCacheControl( array $options = [], array $expectations = [] ) {
		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, $options['variant'] ?? false );

		$output = $this->newInstance( [
			MainConfigNames::UseCdn => $options['useCdn'] ?? false,
		] );
		$output->considerCacheSettingsFinal();

		$cacheable = $options['enableClientCache'] ?? true;
		if ( !$cacheable ) {
			$output->disableClientCache();
		}
		$this->assertEquals( $cacheable, $output->couldBePublicCached() );

		$output->setCdnMaxage( $options['cdnMaxAge'] ?? 0 );

		if ( isset( $options['lastModified'] ) ) {
			$output->setLastModified( $options['lastModified'] );
		}

		$response = $output->getRequest()->response();
		if ( isset( $options['cookie'] ) ) {
			$response->setCookie( 'test', 1234 );
		}

		$output->sendCacheControl();

		$headers = [
			'Vary' => 'Accept-Encoding, Cookie',
			'Cache-Control' => 'private, must-revalidate, max-age=0',
			'Expires' => true,
			'Last-Modified' => false,
		];

		foreach ( $headers as $header => $default ) {
			$value = $expectations[$header] ?? $default;
			if ( $value === true ) {
				$this->assertNotEmpty( $response->getHeader( $header ), "$header header" );
			} elseif ( $value === false ) {
				$this->assertNull( $response->getHeader( $header ), "$header header" );
			} else {
				$this->assertEquals( $value, $response->getHeader( $header ), "$header header" );
			}
		}
	}

	public static function provideSendCacheControl() {
		return [
			'Vary on variant' => [
				[
					'variant' => true,
				],
				[
					'Vary' => 'Accept-Encoding, Cookie, Accept-Language',
				]
			],
			'Private by default' => [
				[],
				[
					'Cache-Control' => 'private, must-revalidate, max-age=0',
				],
			],
			'Cookies force private' => [
				[
					'cookie' => true,
					'useCdn' => true,
					'cdnMaxAge' => 300,
				],
				[
					'Cache-Control' => 'private, must-revalidate, max-age=0',
				]
			],
			'Disable client cache' => [
				[
					'enableClientCache' => false,
					'useCdn' => true,
					'cdnMaxAge' => 300,
				],
				[
					'Cache-Control' => 'no-cache, no-store, max-age=0, must-revalidate',
				],
			],
			'Set last modified' => [
				[
					// 0 is the current time, so we'll use 1 instead.
					'lastModified' => 1,
				],
				[
					'Last-Modified' => 'Thu, 01 Jan 1970 00:00:01 GMT',
				]
			],
			'Public' => [
				[
					'useCdn' => true,
					'cdnMaxAge' => 300,
				],
				[
					'Cache-Control' => 's-maxage=300, must-revalidate, max-age=0',
					'Expires' => false,
				],
			],
		];
	}

	public function provideGetJsVarsEditable() {
		yield 'can edit and create' => [
			'performer' => $this->mockAnonAuthorityWithPermissions( [ 'edit', 'create' ] ),
			'expectedEditableConfig' => [
				'wgIsProbablyEditable' => true,
				'wgRelevantPageIsProbablyEditable' => true,
			]
		];
		yield 'cannot edit or create' => [
			'performer' => $this->mockAnonAuthorityWithoutPermissions( [ 'edit', 'create' ] ),
			'expectedEditableConfig' => [
				'wgIsProbablyEditable' => false,
				'wgRelevantPageIsProbablyEditable' => false,
			]
		];
		yield 'only can edit relevant title' => [
			'performer' => $this->mockAnonAuthority( static function (
				string $permission,
				PageIdentity $page
			) {
				return ( $permission === 'edit' || $permission === 'create' ) && $page->getDBkey() === 'RelevantTitle';
			} ),
			'expectedEditableConfig' => [
				'wgIsProbablyEditable' => false,
				'wgRelevantPageIsProbablyEditable' => true,
			]
		];
	}

	/**
	 * @dataProvider provideGetJsVarsEditable
	 */
	public function testGetJsVarsEditable( Authority $performer, array $expectedEditableConfig ) {
		$op = $this->newInstance( [], null, null, $performer );
		$op->getContext()->getSkin()->setRelevantTitle( Title::makeTitle( NS_MAIN, 'RelevantTitle' ) );
		$this->assertArraySubmapSame( $expectedEditableConfig, $op->getJSVars() );
	}

	public function provideJsVarsAboutPageLang() {
		// Format:
		// - expected
		// - title
		// - site content language
		// - user language
		// - wgDefaultLanguageVariant
		return [
			[ 'fr', [ NS_HELP, 'I_need_somebody' ], 'fr', 'fr', false ],
			[ 'es', [ NS_HELP, 'I_need_somebody' ], 'es', 'zh-tw', false ],
			[ 'zh', [ NS_HELP, 'I_need_somebody' ], 'zh', 'zh-tw', false ],
			[ 'es', [ NS_HELP, 'I_need_somebody' ], 'es', 'zh-tw', 'zh-cn' ],
			[ 'es', [ NS_MEDIAWIKI, 'About' ], 'es', 'zh-tw', 'zh-cn' ],
			[ 'es', [ NS_MEDIAWIKI, 'About/' ], 'es', 'zh-tw', 'zh-cn' ],
			[ 'de', [ NS_MEDIAWIKI, 'About/de' ], 'es', 'zh-tw', 'zh-cn' ],
			[ 'en', [ NS_MEDIAWIKI, 'Common.js' ], 'es', 'zh-tw', 'zh-cn' ],
			[ 'en', [ NS_MEDIAWIKI, 'Common.css' ], 'es', 'zh-tw', 'zh-cn' ],
			[ 'en', [ NS_USER, 'JohnDoe/Common.js' ], 'es', 'zh-tw', 'zh-cn' ],
			[ 'en', [ NS_USER, 'JohnDoe/Monobook.css' ], 'es', 'zh-tw', 'zh-cn' ],

			[ 'zh-cn', [ NS_HELP, 'I_need_somebody' ], 'zh', 'zh-tw', 'zh-cn' ],
			[ 'zh', [ NS_MEDIAWIKI, 'About' ], 'zh', 'zh-tw', 'zh-cn' ],
			[ 'zh', [ NS_MEDIAWIKI, 'About/' ], 'zh', 'zh-tw', 'zh-cn' ],
			[ 'de', [ NS_MEDIAWIKI, 'About/de' ], 'zh', 'zh-tw', 'zh-cn' ],
			[ 'zh-cn', [ NS_MEDIAWIKI, 'About/zh-cn' ], 'zh', 'zh-tw', 'zh-cn' ],
			[ 'zh-tw', [ NS_MEDIAWIKI, 'About/zh-tw' ], 'zh', 'zh-tw', 'zh-cn' ],
			[ 'en', [ NS_MEDIAWIKI, 'Common.js' ], 'zh', 'zh-tw', 'zh-cn' ],
			[ 'en', [ NS_MEDIAWIKI, 'Common.css' ], 'zh', 'zh-tw', 'zh-cn' ],
			[ 'en', [ NS_USER, 'JohnDoe/Common.js' ], 'zh', 'zh-tw', 'zh-cn' ],
			[ 'en', [ NS_USER, 'JohnDoe/Monobook.css' ], 'zh', 'zh-tw', 'zh-cn' ],

			[ 'nl', [ NS_SPECIAL, 'BlankPage' ], 'en', 'nl', false ],
			[ 'zh-tw', [ NS_SPECIAL, 'NewPages' ], 'es', 'zh-tw', 'zh-cn' ],
			[ 'zh-tw', [ NS_SPECIAL, 'NewPages' ], 'zh', 'zh-tw', 'zh-cn' ],

			[ 'sr-ec', [ NS_FILE, 'Example' ], 'sr', 'sr', 'sr-ec' ],
			[ 'sr', [ NS_FILE, 'Example' ], 'sr', 'sr', 'sr' ],
			[ 'sr-ec', [ NS_MEDIAWIKI, 'Example' ], 'sr-ec', 'sr-ec', 'sr' ],
			[ 'sr', [ NS_MEDIAWIKI, 'Example' ], 'sr', 'sr', 'sr-ec' ],
		];
	}

	/**
	 * @dataProvider provideJsVarsAboutPageLang
	 */
	public function testGetJsVarsAboutPageLang( $expected, $title, $contLang, $userLang, $variant ) {
		$this->overrideConfigValues( [
			MainConfigNames::DefaultLanguageVariant => $variant,
		] );
		$this->setContentLang( $contLang );
		$output = $this->newInstance(
			[ MainConfigNames::LanguageCode => $contLang ],
			new FauxRequest( [ 'uselang' => $userLang ] ),
			'notitle'
		);
		$output->setTitle( Title::makeTitle( $title[0], $title[1] ) );

		$this->assertArraySubmapSame( [
			'wgPageViewLanguage' => $expected,
			'wgPageContentLanguage' => $expected,
		], $output->getJSVars() );
	}

	/**
	 * @param bool $registered
	 * @param bool $matchToken
	 * @return MockObject|User
	 */
	private function mockUser( bool $registered, bool $matchToken ) {
		$user = $this->createNoOpMock( User::class, [ 'isRegistered', 'matchEditToken' ] );
		$user->method( 'isRegistered' )->willReturn( $registered );
		$user->method( 'matchEditToken' )->willReturn( $matchToken );
		return $user;
	}

	public function provideUserCanPreview() {
		yield 'all good' => [
			'performer' => $this->mockUserAuthorityWithPermissions(
				$this->mockUser( true, true ),
				[ 'edit' ]
			),
			'request' => new FauxRequest( [ 'action' => 'submit' ], true ),
			true
		];
		yield 'get request' => [
			'performer' => $this->mockUserAuthorityWithPermissions(
				$this->mockUser( true, true ),
				[ 'edit' ]
			),
			'request' => new FauxRequest( [ 'action' => 'submit' ], false ),
			false
		];
		yield 'not a submit action' => [
			'performer' => $this->mockUserAuthorityWithPermissions(
				$this->mockUser( true, true ),
				[ 'edit' ]
			),
			'request' => new FauxRequest( [ 'action' => 'something' ], true ),
			false
		];
		yield 'anon can not' => [
			'performer' => $this->mockUserAuthorityWithPermissions(
				$this->mockUser( false, true ),
				[ 'edit' ]
			),
			'request' => new FauxRequest( [ 'action' => 'submit' ], true ),
			false
		];
		yield 'token not match' => [
			'performer' => $this->mockUserAuthorityWithPermissions(
				$this->mockUser( true, false ),
				[ 'edit' ]
			),
			'request' => new FauxRequest( [ 'action' => 'submit' ], true ),
			false
		];
		yield 'no permission' => [
			'performer' => $this->mockUserAuthorityWithoutPermissions(
				$this->mockUser( true, true ),
				[ 'edit' ]
			),
			'request' => new FauxRequest( [ 'action' => 'submit' ], true ),
			false
		];
	}

	/**
	 * @dataProvider provideUserCanPreview
	 */
	public function testUserCanPreview( Authority $performer, WebRequest $request, bool $expected ) {
		$op = $this->newInstance( [], $request, null, $performer );
		$this->assertSame( $expected, $op->userCanPreview() );
	}

	public function providePermissionStatus() {
		yield 'no errors' => [
			PermissionStatus::newEmpty(),
			'',
		];

		yield 'one message' => [
			PermissionStatus::newEmpty()->fatal( 'badaccess-group0' ),
			'(permissionserrorstext: 1)

<div class="permissions-errors"><div class="mw-permissionerror-badaccess-group0">(badaccess-group0)</div></div>',
		];

		yield 'two messages' => [
			PermissionStatus::newEmpty()->fatal( 'badaccess-group0' )->fatal( 'foobar' ),
			'(permissionserrorstext: 2)

<ul class="permissions-errors"><li class="mw-permissionerror-badaccess-group0">(badaccess-group0)</li><li class="mw-permissionerror-foobar">(foobar)</li></ul>',
		];
	}

	public function provideFormatPermissionStatus() {
		yield 'RawMessage' => [
			PermissionStatus::newEmpty()->fatal( new RawMessage( 'Foo Bar' ) ),
			'(permissionserrorstext: 1)

<div class="permissions-errors"><div class="mw-permissionerror-rawmessage">Foo Bar</div></div>',
		];
	}

	public function provideFormatPermissionsErrorMessage() {
		yield 'RawMessage' => [
			PermissionStatus::newEmpty()->fatal( new RawMessage( 'Foo Bar' ) ),
			'(permissionserrorstext: 1)

<div class="permissions-errors"><div class="mw-permissionerror-rawmessage">(rawmessage: Foo Bar)</div></div>',
		];
	}

	/**
	 * @dataProvider providePermissionStatus
	 * @dataProvider provideFormatPermissionStatus
	 */
	public function testFormatPermissionStatus( PermissionStatus $status, string $expected ) {
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'qqx' );

		$actual = self::newInstance()->formatPermissionStatus( $status );
		$this->assertEquals( $expected, $actual );
	}

	/**
	 * @dataProvider providePermissionStatus
	 * @dataProvider provideFormatPermissionsErrorMessage
	 */
	public function testFormatPermissionsErrorMessage( PermissionStatus $status, string $expected ) {
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'qqx' );
		$this->filterDeprecated( '/OutputPage::formatPermissionsErrorMessage was deprecated/' );

		// Unlike formatPermissionStatus, this method doesn't accept good statuses
		$actual = $status->isGood() ? '' :
			self::newInstance()->formatPermissionsErrorMessage( $status->toLegacyErrorArray() );
		$this->assertEquals( $expected, $actual );
	}

	private function newInstance(
		array $config = [],
		?WebRequest $request = null,
		$option = null,
		?Authority $performer = null
	): OutputPage {
		$this->overrideConfigValues( [
			// Avoid configured skin affecting the headings
			MainConfigNames::ParserEnableLegacyHeadingDOM => false,
			MainConfigNames::DefaultSkin => 'fallback',
			MainConfigNames::HiddenPrefs => [ 'skin' ],
		] );

		$context = new RequestContext();

		$context->setConfig( new MultiConfig( [
			new HashConfig( $config + [
				MainConfigNames::AppleTouchIcon => false,
				MainConfigNames::EnableCanonicalServerLink => false,
				MainConfigNames::Favicon => false,
				MainConfigNames::Feed => false,
				MainConfigNames::LanguageCode => false,
				MainConfigNames::ReferrerPolicy => false,
				MainConfigNames::RightsPage => false,
				MainConfigNames::RightsUrl => false,
				MainConfigNames::UniversalEditButton => false,
			] ),
			$this->getServiceContainer()->getMainConfig(),
		] ) );

		if ( $option !== 'notitle' ) {
			$context->setTitle( Title::makeTitle( NS_MAIN, 'My test page' ) );
		}

		if ( $request ) {
			$context->setRequest( $request );
		}

		if ( $performer ) {
			$context->setAuthority( $performer );
		}

		return new OutputPage( $context );
	}
}
PK       !       content/TextContentTest.phpnu Iw        <?php

use MediaWiki\Content\Content;
use MediaWiki\Content\JavaScriptContent;
use MediaWiki\Content\TextContent;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;
use MediaWiki\User\User;

/**
 * Needs database to do link updates.
 *
 * @group ContentHandler
 * @group Database
 * @covers \MediaWiki\Content\TextContent
 * @covers \MediaWiki\Content\TextContentHandler
 */
class TextContentTest extends MediaWikiLangTestCase {
	/** @var RequestContext */
	protected $context;

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

		// Anon user
		$user = new User();
		$user->setName( '127.0.0.1' );

		$this->context = new RequestContext();
		$this->context->setTitle( Title::makeTitle( NS_MAIN, 'Test' ) );
		$this->context->setUser( $user );

		RequestContext::getMain()->setTitle( $this->context->getTitle() );

		$this->overrideConfigValues( [
			MainConfigNames::TextModelsToParse => [
				CONTENT_MODEL_WIKITEXT,
				CONTENT_MODEL_CSS,
				CONTENT_MODEL_JAVASCRIPT,
			],
			MainConfigNames::CapitalLinks => true,
		] );
		$this->clearHook( 'ContentGetParserOutput' );
	}

	/**
	 * NOTE: Overridden by subclass!
	 *
	 * @param string $text
	 * @return TextContent
	 */
	public function newContent( $text ) {
		return new TextContent( $text );
	}

	public static function dataGetRedirectTarget() {
		return [
			[ '#REDIRECT [[Test]]',
				null,
			],
		];
	}

	/**
	 * @dataProvider dataGetRedirectTarget
	 */
	public function testGetRedirectTarget( $text, $expected ) {
		$content = $this->newContent( $text );
		$t = $content->getRedirectTarget();

		if ( $expected === null ) {
			$this->assertNull( $t, "text should not have generated a redirect target: $text" );
		} else {
			$this->assertEquals( $expected, $t->getPrefixedText() );
		}
	}

	/**
	 * @dataProvider dataGetRedirectTarget
	 */
	public function testIsRedirect( $text, $expected ) {
		$content = $this->newContent( $text );

		$this->assertEquals( $expected !== null, $content->isRedirect() );
	}

	public static function dataIsCountable() {
		return [
			[ '',
				null,
				'any',
				true
			],
			[ 'Foo',
				null,
				'any',
				true
			],
		];
	}

	/**
	 * @dataProvider dataIsCountable
	 */
	public function testIsCountable( $text, $hasLinks, $mode, $expected ) {
		$this->overrideConfigValue( MainConfigNames::ArticleCountMethod, $mode );

		$content = $this->newContent( $text );

		$v = $content->isCountable( $hasLinks );

		$this->assertEquals(
			$expected,
			$v,
			'isCountable() returned unexpected value ' . var_export( $v, true )
				. ' instead of ' . var_export( $expected, true )
				. " in mode `$mode` for text \"$text\""
		);
	}

	public static function dataGetTextForSummary() {
		return [
			[ "hello\nworld.",
				16,
				'hello world.',
			],
			[ 'hello world.',
				8,
				'hello...',
			],
			[ '[[hello world]].',
				8,
				'[[hel...',
			],
		];
	}

	/**
	 * @dataProvider dataGetTextForSummary
	 */
	public function testGetTextForSummary( $text, $maxlength, $expected ) {
		$content = $this->newContent( $text );

		$this->assertEquals( $expected, $content->getTextForSummary( $maxlength ) );
	}

	/**
	 */
	public function testCopy() {
		$content = $this->newContent( 'hello world.' );
		$copy = $content->copy();

		$this->assertTrue( $content->equals( $copy ), 'copy must be equal to original' );
		$this->assertEquals( 'hello world.', $copy->getText() );
	}

	public function testGetTextMethods() {
		$content = $this->newContent( 'hello world.' );

		$this->assertEquals( 12, $content->getSize() );
		$this->assertEquals( 'hello world.', $content->getText() );
		$this->assertEquals( 'hello world.', $content->getTextForSearchIndex() );
		$this->assertEquals( 'hello world.', $content->getNativeData() );
		$this->assertEquals( 'hello world.', $content->getWikitextForTransclusion() );
	}

	public function testGetModel() {
		$content = $this->newContent( "hello world." );

		$this->assertEquals( CONTENT_MODEL_TEXT, $content->getModel() );
	}

	public function testGetContentHandler() {
		$content = $this->newContent( "hello world." );

		$this->assertEquals( CONTENT_MODEL_TEXT, $content->getContentHandler()->getModelID() );
	}

	public static function dataIsEmpty() {
		return [
			[ '', true ],
			[ '  ', false ],
			[ '0', false ],
			[ 'hallo welt.', false ],
		];
	}

	/**
	 * @dataProvider dataIsEmpty
	 */
	public function testIsEmpty( $text, $empty ) {
		$content = $this->newContent( $text );

		$this->assertEquals( $empty, $content->isEmpty() );
	}

	public static function dataEquals() {
		return [
			[ new TextContent( "hallo" ), null, false ],
			[ new TextContent( "hallo" ), new TextContent( "hallo" ), true ],
			[ new TextContent( "hallo" ), new JavaScriptContent( "hallo" ), false ],
			[ new TextContent( "hallo" ), new WikitextContent( "hallo" ), false ],
			[ new TextContent( "hallo" ), new TextContent( "HALLO" ), false ],
		];
	}

	/**
	 * @dataProvider dataEquals
	 */
	public function testEquals( Content $a, ?Content $b = null, $equal = false ) {
		$this->assertEquals( $equal, $a->equals( $b ) );
	}

	public static function provideConvert() {
		return [
			[ // #0
				'Hallo Welt',
				CONTENT_MODEL_WIKITEXT,
				'lossless',
				'Hallo Welt'
			],
			[ // #1
				'Hallo Welt',
				CONTENT_MODEL_WIKITEXT,
				'lossless',
				'Hallo Welt'
			],
			[ // #1
				'Hallo Welt',
				CONTENT_MODEL_CSS,
				'lossless',
				'Hallo Welt'
			],
			[ // #1
				'Hallo Welt',
				CONTENT_MODEL_JAVASCRIPT,
				'lossless',
				'Hallo Welt'
			],
		];
	}

	/**
	 * @dataProvider provideConvert
	 */
	public function testConvert( $text, $model, $lossy, $expectedNative ) {
		$content = $this->newContent( $text );

		/** @var TextContent $converted */
		$converted = $content->convert( $model, $lossy );

		if ( $expectedNative === false ) {
			$this->assertFalse( $converted, "conversion to $model was expected to fail!" );
		} else {
			$this->assertInstanceOf( Content::class, $converted );
			$this->assertEquals( $expectedNative, $converted->getText() );
		}
	}

	/**
	 * @dataProvider provideNormalizeLineEndings
	 */
	public function testNormalizeLineEndings( $input, $expected ) {
		$this->assertEquals( $expected, TextContent::normalizeLineEndings( $input ) );
	}

	public static function provideNormalizeLineEndings() {
		return [
			[
				"Foo\r\nbar",
				"Foo\nbar"
			],
			[
				"Foo\rbar",
				"Foo\nbar"
			],
			[
				"Foobar\n  ",
				"Foobar"
			]
		];
	}

	public function testSerialize() {
		$cnt = $this->newContent( 'testing text' );

		$this->assertSame( 'testing text', $cnt->serialize() );
	}

}
PK       ! MS  S    content/ContentHandlerTest.phpnu Iw        <?php

use MediaWiki\Content\ContentHandler;
use MediaWiki\Content\CssContentHandler;
use MediaWiki\Content\JavaScriptContentHandler;
use MediaWiki\Content\JsonContent;
use MediaWiki\Content\JsonContentHandler;
use MediaWiki\Content\TextContentHandler;
use MediaWiki\Content\ValidationParams;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Content\WikitextContentHandler;
use MediaWiki\Context\RequestContext;
use MediaWiki\Language\Language;
use MediaWiki\Languages\LanguageNameUtils;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\Hook\OpportunisticLinksUpdateHook;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Parser\MagicWordFactory;
use MediaWiki\Parser\ParserFactory;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\Parsoid\ParsoidParserFactory;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleFactory;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\UUID\GlobalIdGenerator;

/**
 * @group ContentHandler
 * @group Database
 * @covers \MediaWiki\Content\ContentHandler
 */
class ContentHandlerTest extends MediaWikiIntegrationTestCase {

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

		$this->overrideConfigValues( [
			MainConfigNames::ExtraNamespaces => [
				12312 => 'Dummy',
				12313 => 'Dummy_talk',
			],
			// The below tests assume that namespaces not mentioned here (Help, User, MediaWiki, ..)
			// default to CONTENT_MODEL_WIKITEXT.
			MainConfigNames::NamespaceContentModels => [
				12312 => 'testing',
			],
			MainConfigNames::ContentHandlers => [
				CONTENT_MODEL_WIKITEXT => [
					'class' => WikitextContentHandler::class,
					'services' => [
						'TitleFactory',
						'ParserFactory',
						'GlobalIdGenerator',
						'LanguageNameUtils',
						'LinkRenderer',
						'MagicWordFactory',
						'ParsoidParserFactory',
					],
				],
				CONTENT_MODEL_JAVASCRIPT => JavaScriptContentHandler::class,
				CONTENT_MODEL_JSON => JsonContentHandler::class,
				CONTENT_MODEL_CSS => CssContentHandler::class,
				CONTENT_MODEL_TEXT => TextContentHandler::class,
				'testing' => DummyContentHandlerForTesting::class,
				'testing-callbacks' => static function ( $modelId ) {
					return new DummyContentHandlerForTesting( $modelId );
				}
			],
		] );
	}

	public function addDBDataOnce() {
		$this->insertPage( 'Not_Main_Page', 'This is not a main page' );
		$this->insertPage( 'Smithee', 'A smithee is one who smiths. See also [[Alan Smithee]]' );
	}

	public static function dataGetDefaultModelFor() {
		return [
			[ 'Help:Foo', CONTENT_MODEL_WIKITEXT ],
			[ 'Help:Foo.js', CONTENT_MODEL_WIKITEXT ],
			[ 'Help:Foo.css', CONTENT_MODEL_WIKITEXT ],
			[ 'Help:Foo.json', CONTENT_MODEL_WIKITEXT ],
			[ 'Help:Foo/bar.js', CONTENT_MODEL_WIKITEXT ],
			[ 'User:Foo', CONTENT_MODEL_WIKITEXT ],
			[ 'User:Foo.js', CONTENT_MODEL_WIKITEXT ],
			[ 'User:Foo.css', CONTENT_MODEL_WIKITEXT ],
			[ 'User:Foo.json', CONTENT_MODEL_WIKITEXT ],
			[ 'User:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ],
			[ 'User:Foo/bar.css', CONTENT_MODEL_CSS ],
			[ 'User:Foo/bar.json', CONTENT_MODEL_JSON ],
			[ 'User:Foo/bar.json.nope', CONTENT_MODEL_WIKITEXT ],
			[ 'User talk:Foo/bar.css', CONTENT_MODEL_WIKITEXT ],
			[ 'User:Foo/bar.js.xxx', CONTENT_MODEL_WIKITEXT ],
			[ 'User:Foo/bar.xxx', CONTENT_MODEL_WIKITEXT ],
			[ 'MediaWiki:Foo.js', CONTENT_MODEL_JAVASCRIPT ],
			[ 'MediaWiki:Foo.JS', CONTENT_MODEL_WIKITEXT ],
			[ 'MediaWiki:Foo.css', CONTENT_MODEL_CSS ],
			[ 'MediaWiki:Foo.css.xxx', CONTENT_MODEL_WIKITEXT ],
			[ 'MediaWiki:Foo.CSS', CONTENT_MODEL_WIKITEXT ],
			[ 'MediaWiki:Foo.json', CONTENT_MODEL_JSON ],
			[ 'MediaWiki:Foo.JSON', CONTENT_MODEL_WIKITEXT ],
		];
	}

	/**
	 * @dataProvider dataGetDefaultModelFor
	 */
	public function testGetDefaultModelFor( $title, $expectedModelId ) {
		$title = Title::newFromText( $title );
		$this->hideDeprecated( 'MediaWiki\\Content\\ContentHandler::getDefaultModelFor' );
		$this->assertEquals( $expectedModelId, ContentHandler::getDefaultModelFor( $title ) );
	}

	public static function dataGetLocalizedName() {
		return [
			[ null, null ],
			[ "xyzzy", null ],

			// XXX: depends on content language
			[ CONTENT_MODEL_JAVASCRIPT, '/javascript/i' ],
		];
	}

	/**
	 * @dataProvider dataGetLocalizedName
	 */
	public function testGetLocalizedName( $id, $expected ) {
		$name = ContentHandler::getLocalizedName( $id );

		if ( $expected ) {
			$this->assertNotNull( $name, "no name found for content model $id" );
			$this->assertTrue( preg_match( $expected, $name ) > 0,
				"content model name for #$id did not match pattern $expected"
			);
		} else {
			$this->assertEquals( $id, $name, "localization of unknown model $id should have "
				. "fallen back to use the model id directly."
			);
		}
	}

	public static function dataGetPageLanguage() {
		global $wgLanguageCode;

		return [
			[ "Main", $wgLanguageCode ],
			[ "Dummy:Foo", $wgLanguageCode ],
			[ "MediaWiki:common.js", 'en' ],
			[ "User:Foo/common.js", 'en' ],
			[ "MediaWiki:common.css", 'en' ],
			[ "User:Foo/common.css", 'en' ],
			[ "User:Foo", $wgLanguageCode ],
		];
	}

	/**
	 * @dataProvider dataGetPageLanguage
	 */
	public function testGetPageLanguage( $title, $expected ) {
		$title = Title::newFromText( $title );
		$this->getServiceContainer()->getLinkCache()->addBadLinkObj( $title );

		$handler = $this->getServiceContainer()
			->getContentHandlerFactory()
			->getContentHandler( $title->getContentModel() );
		$lang = $handler->getPageLanguage( $title );

		$this->assertInstanceOf( Language::class, $lang );
		$this->assertEquals( $expected, $lang->getCode() );
	}

	public function testGetContentText_Null() {
		$this->hideDeprecated( 'MediaWiki\\Content\\ContentHandler::getContentText' );
		$content = null;
		$text = ContentHandler::getContentText( $content );
		$this->assertSame( '', $text );
	}

	public function testGetContentText_TextContent() {
		$this->hideDeprecated( 'MediaWiki\\Content\\ContentHandler::getContentText' );
		$content = new WikitextContent( "hello world" );
		$text = ContentHandler::getContentText( $content );
		$this->assertEquals( $content->getText(), $text );
	}

	public function testGetContentText_NonTextContent() {
		$this->hideDeprecated( 'MediaWiki\\Content\\ContentHandler::getContentText' );
		$content = new DummyContentForTesting( "hello world" );
		$text = ContentHandler::getContentText( $content );
		$this->assertNull( $text );
	}

	public static function dataMakeContent() {
		return [
			[ 'hallo', 'Help:Test', null, null, CONTENT_MODEL_WIKITEXT, false ],
			[ 'hallo', 'MediaWiki:Test.js', null, null, CONTENT_MODEL_JAVASCRIPT, false ],
			[ 'hallo', 'Dummy:Test', null, null, "testing", false ],

			[
				'hallo',
				'Help:Test',
				null,
				CONTENT_FORMAT_WIKITEXT,
				CONTENT_MODEL_WIKITEXT,
				false
			],
			[
				'hallo',
				'MediaWiki:Test.js',
				null,
				CONTENT_FORMAT_JAVASCRIPT,
				CONTENT_MODEL_JAVASCRIPT,
				false
			],
			[ 'hallo', 'Dummy:Test', null, "testing", "testing", false ],

			[ 'hallo', 'Help:Test', CONTENT_MODEL_CSS, null, CONTENT_MODEL_CSS, false ],
			[
				'hallo',
				'MediaWiki:Test.js',
				CONTENT_MODEL_CSS,
				null,
				CONTENT_MODEL_CSS,
				false
			],
			[
				serialize( 'hallo' ),
				'Dummy:Test',
				CONTENT_MODEL_CSS,
				null,
				CONTENT_MODEL_CSS,
				false
			],

			[ 'hallo', 'Help:Test', CONTENT_MODEL_WIKITEXT, "testing", null, true ],
			[ 'hallo', 'MediaWiki:Test.js', CONTENT_MODEL_CSS, "testing", null, true ],
			[ 'hallo', 'Dummy:Test', CONTENT_MODEL_JAVASCRIPT, "testing", null, true ],
		];
	}

	/**
	 * @dataProvider dataMakeContent
	 */
	public function testMakeContent( $data, $title, $modelId, $format,
		$expectedModelId, $shouldFail
	) {
		$title = Title::newFromText( $title );
		$this->getServiceContainer()->getLinkCache()->addBadLinkObj( $title );
		try {
			$content = ContentHandler::makeContent( $data, $title, $modelId, $format );

			if ( $shouldFail ) {
				$this->fail( "ContentHandler::makeContent should have failed!" );
			}

			$this->assertEquals( $expectedModelId, $content->getModel(), 'bad model id' );
			$this->assertEquals( $data, $content->serialize(), 'bad serialized data' );
		} catch ( MWException $ex ) {
			if ( !$shouldFail ) {
				$this->fail( "ContentHandler::makeContent failed unexpectedly: " . $ex->getMessage() );
			} else {
				// dummy, so we don't get the "test did not perform any assertions" message.
				$this->assertTrue( true );
			}
		}
	}

	/**
	 * getAutoSummary() should set "Created blank page" summary if we save an empy string.
	 */
	public function testGetAutosummary() {
		$this->setContentLang( 'en' );

		$content = new DummyContentHandlerForTesting( CONTENT_MODEL_WIKITEXT );
		$title = Title::makeTitle( NS_HELP, 'Test' );
		// Create a new content object with no content
		$newContent = ContentHandler::makeContent( '', $title, CONTENT_MODEL_WIKITEXT, null );
		// first check, if we become a blank page created summary with the right bitmask
		$autoSummary = $content->getAutosummary( null, $newContent, 97 );
		$this->assertEquals(
			wfMessage( 'autosumm-newblank' )->inContentLanguage()->text(),
			$autoSummary
		);
		// now check, what we become with another bitmask
		$autoSummary = $content->getAutosummary( null, $newContent, 92 );
		$this->assertSame( '', $autoSummary );
	}

	/**
	 * Test software tag that is added when content model of the page changes
	 */
	public function testGetChangeTag() {
		$this->overrideConfigValue( MainConfigNames::SoftwareTags, [ 'mw-contentmodelchange' => true ] );
		$wikitextContentHandler = new DummyContentHandlerForTesting( CONTENT_MODEL_WIKITEXT );
		// Create old content object with javascript content model
		$oldContent = ContentHandler::makeContent( '', null, CONTENT_MODEL_JAVASCRIPT, null );
		// Create new content object with wikitext content model
		$newContent = ContentHandler::makeContent( '', null, CONTENT_MODEL_WIKITEXT, null );
		// Get the tag for this edit
		$tag = $wikitextContentHandler->getChangeTag( $oldContent, $newContent, EDIT_UPDATE );
		$this->assertSame( 'mw-contentmodelchange', $tag );
	}

	public function testSupportsCategories() {
		$handler = new DummyContentHandlerForTesting( CONTENT_MODEL_WIKITEXT );
		$this->assertTrue( $handler->supportsCategories(), 'content model supports categories' );
	}

	public function testSupportsDirectEditing() {
		$handler = new DummyContentHandlerForTesting( CONTENT_MODEL_JSON );
		$this->assertFalse( $handler->supportsDirectEditing(), 'direct editing is not supported' );
	}

	public static function dummyHookHandler( $foo, &$text, $bar ) {
		if ( $text === null || $text === false ) {
			return false;
		}

		$text = strtoupper( $text );

		return true;
	}

	public static function provideGetModelForID() {
		return [
			[ CONTENT_MODEL_WIKITEXT, WikitextContentHandler::class ],
			[ CONTENT_MODEL_JAVASCRIPT, JavaScriptContentHandler::class ],
			[ CONTENT_MODEL_JSON, JsonContentHandler::class ],
			[ CONTENT_MODEL_CSS, CssContentHandler::class ],
			[ CONTENT_MODEL_TEXT, TextContentHandler::class ],
			[ 'testing', DummyContentHandlerForTesting::class ],
			[ 'testing-callbacks', DummyContentHandlerForTesting::class ],
		];
	}

	/**
	 * @dataProvider provideGetModelForID
	 */
	public function testGetModelForID( $modelId, $handlerClass ) {
		$handler = $this->getServiceContainer()->getContentHandlerFactory()
			->getContentHandler( $modelId );

		$this->assertInstanceOf( $handlerClass, $handler );
	}

	public function testGetFieldsForSearchIndex() {
		$searchEngine = $this->newSearchEngine();

		$handler = $this->getMockBuilder( ContentHandler::class )
			->onlyMethods(
				[ 'serializeContent', 'unserializeContent', 'makeEmptyContent' ]
			)
			->disableOriginalConstructor()
			->getMock();

		$fields = $handler->getFieldsForSearchIndex( $searchEngine );

		$this->assertArrayHasKey( 'category', $fields );
		$this->assertArrayHasKey( 'external_link', $fields );
		$this->assertArrayHasKey( 'outgoing_link', $fields );
		$this->assertArrayHasKey( 'template', $fields );
		$this->assertArrayHasKey( 'content_model', $fields );
	}

	private function newSearchEngine() {
		$searchEngine = $this->createMock( SearchEngine::class );

		$searchEngine->method( 'makeSearchFieldMapping' )
			->willReturnCallback( static function ( $name, $type ) {
					return new DummySearchIndexFieldDefinition( $name, $type );
			} );

		return $searchEngine;
	}

	public function testDataIndexFields() {
		$mockEngine = $this->createMock( SearchEngine::class );
		$title = Title::makeTitle( NS_MAIN, 'Not_Main_Page' );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$this->setTemporaryHook( 'SearchDataForIndex',
			static function (
				&$fields,
				ContentHandler $handler,
				WikiPage $page,
				ParserOutput $output,
				SearchEngine $engine
			) {
				$fields['testDataField'] = 'test content';
			} );

		$revision = $page->getRevisionRecord();
		$output = $page->getContentHandler()->getParserOutputForIndexing( $page, null, $revision );
		$data = $page->getContentHandler()->getDataForSearchIndex( $page, $output, $mockEngine, $revision );
		$this->assertArrayHasKey( 'text', $data );
		$this->assertArrayHasKey( 'text_bytes', $data );
		$this->assertArrayHasKey( 'language', $data );
		$this->assertArrayHasKey( 'testDataField', $data );
		$this->assertEquals( 'test content', $data['testDataField'] );
		$this->assertEquals( 'wikitext', $data['content_model'] );
	}

	public function testParserOutputForIndexing() {
		$opportunisticUpdateHook =
			$this->createMock( OpportunisticLinksUpdateHook::class );
		// WikiPage::triggerOpportunisticLinksUpdate should not be triggered when
		// getParserOutputForIndexing is called
		$opportunisticUpdateHook->expects( $this->never() )
			->method( 'onOpportunisticLinksUpdate' )
			->willReturn( false );
		$this->setTemporaryHook( 'OpportunisticLinksUpdate', $opportunisticUpdateHook );

		$title = Title::makeTitle( NS_MAIN, 'Smithee' );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$revision = $page->getRevisionRecord();

		$out = $page->getContentHandler()->getParserOutputForIndexing( $page, null, $revision );
		$this->assertInstanceOf( ParserOutput::class, $out );
		$this->assertStringContainsString( 'one who smiths', $out->getRawText() );
	}

	public function testGetContentModelsHook() {
		$this->setTemporaryHook( 'GetContentModels', static function ( &$models ) {
			$models[] = 'Ferrari';
		} );
		$this->hideDeprecated( 'MediaWiki\\Content\\ContentHandler::getContentModels' );
		$this->assertContains( 'Ferrari', ContentHandler::getContentModels() );
	}

	public function testGetSlotDiffRenderer_default() {
		$this->mergeMwGlobalArrayValue( 'wgHooks', [
			'GetSlotDiffRenderer' => [],
		] );

		// test default renderer
		$contentHandler = new WikitextContentHandler(
			CONTENT_MODEL_WIKITEXT,
			$this->createMock( TitleFactory::class ),
			$this->createMock( ParserFactory::class ),
			$this->createMock( GlobalIdGenerator::class ),
			$this->createMock( LanguageNameUtils::class ),
			$this->createMock( LinkRenderer::class ),
			$this->createMock( MagicWordFactory::class ),
			$this->createMock( ParsoidParserFactory::class )
		);
		$slotDiffRenderer = $contentHandler->getSlotDiffRenderer( RequestContext::getMain() );
		$this->assertInstanceOf( TextSlotDiffRenderer::class, $slotDiffRenderer );
	}

	public function testGetSlotDiffRenderer_bc() {
		$this->mergeMwGlobalArrayValue( 'wgHooks', [
			'GetSlotDiffRenderer' => [],
		] );

		// test B/C renderer
		$customDifferenceEngine = $this->createMock( DifferenceEngine::class );
		// hack to track object identity across cloning
		$customDifferenceEngine->objectId = 12345;
		$customContentHandler = $this->getMockBuilder( ContentHandler::class )
			->setConstructorArgs( [ 'foo', [] ] )
			->onlyMethods( [ 'createDifferenceEngine' ] )
			->getMockForAbstractClass();
		$customContentHandler->method( 'createDifferenceEngine' )
			->willReturn( $customDifferenceEngine );
		/** @var ContentHandler $customContentHandler */
		$slotDiffRenderer = $customContentHandler->getSlotDiffRenderer( RequestContext::getMain() );
		$this->assertInstanceOf( DifferenceEngineSlotDiffRenderer::class, $slotDiffRenderer );
		$this->assertSame(
			$customDifferenceEngine->objectId,
			TestingAccessWrapper::newFromObject( $slotDiffRenderer )->differenceEngine->objectId
		);
	}

	public function testGetSlotDiffRenderer_nobc() {
		$this->mergeMwGlobalArrayValue( 'wgHooks', [
			'GetSlotDiffRenderer' => [],
		] );

		// test that B/C renderer does not get used when getSlotDiffRendererInternal is overridden
		$customDifferenceEngine = $this->createMock( DifferenceEngine::class );
		$customSlotDiffRenderer = $this->getMockBuilder( SlotDiffRenderer::class )
			->disableOriginalConstructor()
			->getMockForAbstractClass();
		$customContentHandler2 = $this->getMockBuilder( ContentHandler::class )
			->setConstructorArgs( [ 'bar', [] ] )
			->onlyMethods( [ 'createDifferenceEngine', 'getSlotDiffRendererInternal' ] )
			->getMockForAbstractClass();
		$customContentHandler2->method( 'createDifferenceEngine' )
			->willReturn( $customDifferenceEngine );
		$customContentHandler2->method( 'getSlotDiffRendererInternal' )
			->willReturn( $customSlotDiffRenderer );
		/** @var ContentHandler $customContentHandler2 */
		$this->hideDeprecated( 'ContentHandler::getSlotDiffRendererInternal' );
		$slotDiffRenderer = $customContentHandler2->getSlotDiffRenderer( RequestContext::getMain() );
		$this->assertSame( $customSlotDiffRenderer, $slotDiffRenderer );
	}

	public function testGetSlotDiffRenderer_hook() {
		$this->mergeMwGlobalArrayValue( 'wgHooks', [
			'GetSlotDiffRenderer' => [],
		] );

		// test that the hook handler takes precedence
		$customDifferenceEngine = $this->createMock( DifferenceEngine::class );
		$customContentHandler = $this->getMockBuilder( ContentHandler::class )
			->setConstructorArgs( [ 'foo', [] ] )
			->onlyMethods( [ 'createDifferenceEngine' ] )
			->getMockForAbstractClass();
		$customContentHandler->method( 'createDifferenceEngine' )
			->willReturn( $customDifferenceEngine );
		/** @var ContentHandler $customContentHandler */

		$customSlotDiffRenderer = $this->getMockBuilder( SlotDiffRenderer::class )
			->disableOriginalConstructor()
			->getMockForAbstractClass();
		$customContentHandler2 = $this->getMockBuilder( ContentHandler::class )
			->setConstructorArgs( [ 'bar', [] ] )
			->onlyMethods( [ 'createDifferenceEngine', 'getSlotDiffRendererInternal' ] )
			->getMockForAbstractClass();
		$customContentHandler2->method( 'createDifferenceEngine' )
			->willReturn( $customDifferenceEngine );
		$customContentHandler2->method( 'getSlotDiffRendererInternal' )
			->willReturn( $customSlotDiffRenderer );
		/** @var ContentHandler $customContentHandler2 */

		$customSlotDiffRenderer2 = $this->getMockBuilder( SlotDiffRenderer::class )
			->disableOriginalConstructor()
			->getMockForAbstractClass();
		$this->setTemporaryHook( 'GetSlotDiffRenderer',
			static function ( $handler, &$slotDiffRenderer ) use ( $customSlotDiffRenderer2 ) {
				$slotDiffRenderer = $customSlotDiffRenderer2;
			} );

		$this->hideDeprecated( 'ContentHandler::getSlotDiffRendererInternal' );
		$slotDiffRenderer = $customContentHandler->getSlotDiffRenderer( RequestContext::getMain() );
		$this->assertSame( $customSlotDiffRenderer2, $slotDiffRenderer );

		$this->hideDeprecated( 'ContentHandler::getSlotDiffRendererInternal' );
		$slotDiffRenderer = $customContentHandler2->getSlotDiffRenderer( RequestContext::getMain() );
		$this->assertSame( $customSlotDiffRenderer2, $slotDiffRenderer );
	}

	public static function providerGetPageViewLanguage() {
		yield [ NS_FILE, 'sr', 'sr-ec', 'sr-ec' ];
		yield [ NS_FILE, 'sr', 'sr', 'sr' ];
		yield [ NS_MEDIAWIKI, 'sr-ec', 'sr', 'sr-ec' ];
		yield [ NS_MEDIAWIKI, 'sr', 'sr-ec', 'sr' ];
	}

	/**
	 * Superseded by OutputPageTest::testGetJsVarsAboutPageLang
	 *
	 * @dataProvider providerGetPageViewLanguage
	 */
	public function testGetPageViewLanguage( $namespace, $lang, $variant, $expected ) {
		$contentHandler = $this->getMockBuilder( ContentHandler::class )
			->disableOriginalConstructor()
			->getMockForAbstractClass();

		$title = Title::makeTitle( $namespace, 'SimpleTitle' );

		$this->overrideConfigValue( MainConfigNames::DefaultLanguageVariant, $variant );

		$this->setUserLang( $lang );
		$this->setContentLang( $lang );

		$pageViewLanguage = $contentHandler->getPageViewLanguage( $title );
		$this->assertEquals( $expected, $pageViewLanguage->getCode() );
	}

	public static function provideValidateSave() {
		yield 'wikitext' => [
			new WikitextContent( 'hello world' ),
			true
		];

		yield 'valid json' => [
			new JsonContent( '{ "0": "bar" }' ),
			true
		];

		yield 'invalid json' => [
			new JsonContent( 'foo' ),
			false
		];
	}

	/**
	 * @dataProvider provideValidateSave
	 */
	public function testValidateSave( $content, $expectedResult ) {
		$page = new PageIdentityValue( 0, 1, 'Foo', PageIdentity::LOCAL );
		$contentHandlerFactory = $this->getServiceContainer()->getContentHandlerFactory();
		$contentHandler = $contentHandlerFactory->getContentHandler( $content->getModel() );
		$validateParams = new ValidationParams( $page, 0 );

		$status = $contentHandler->validateSave( $content, $validateParams );
		$this->assertEquals( $expectedResult, $status->isOK() );
	}
}
PK       ! O6  6  5  content/Transform/PreSaveTransformParamsValueTest.phpnu Iw        <?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
 * @since 1.42
 */

namespace MediaWiki\Tests\Content\Transform;

use MediaWiki\Content\Transform\PreSaveTransformParamsValue;
use MediaWiki\Page\PageReference;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Content\Transform\PreSaveTransformParamsValue
 * @group Database
 */
class PreSaveTransformParamsValueTest extends MediaWikiIntegrationTestCase {

	protected PageReference $page;
	protected UserIdentity $user;
	protected ParserOptions $parserOptions;

	protected function setUp(): void {
		parent::setUp();
		$this->page = $this->createMock( PageReference::class );
		$this->user = $this->createMock( UserIdentity::class );
		$this->parserOptions = $this->createMock( ParserOptions::class );
	}

	public function testConstruct() {
		$params = new PreSaveTransformParamsValue( $this->page, $this->user, $this->parserOptions );
		$this->assertSame( $this->page, $params->getPage() );
		$this->assertSame( $this->user, $params->getUser() );
		$this->assertSame( $this->parserOptions, $params->getParserOptions() );
	}

	public function testGetPage() {
		$title = Title::newFromText( 'TestPage' );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$params = new PreSaveTransformParamsValue( $page, $this->user, $this->parserOptions );
		$this->assertSame( $page, $params->getPage() );
	}

	public function testGetUser() {
		$user = $this->getTestUser();
		$params = new PreSaveTransformParamsValue( $this->page, $user->getUserIdentity(), $this->parserOptions );
		$expectedUser = $user->getUserIdentity();
		$actualUser = $params->getUser();
		$this->assertEquals( $expectedUser->getId(), $actualUser->getId(), 'User IDs do not match' );
		$this->assertEquals( $expectedUser->getName(), $actualUser->getName(), 'User names do not match' );
		$this->assertEquals( $expectedUser->getWikiId(), $actualUser->getWikiId(), 'Wiki IDs do not match' );
	}

	/**
	 * @dataProvider provideParserOptions
	 */
	public function testGetParserOptions( $options ) {
		$title = Title::newFromText( 'TestPage' );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$params = new PreSaveTransformParamsValue( $page, $this->user, $options );
		$this->assertSame( $options, $params->getParserOptions() );
	}

	public function provideParserOptions(): array {
		$user = new User();
		$options = ParserOptions::newFromUser( $user );
		return [
			[ $options ],
			[ ParserOptions::newFromUser( $user ) ],
		];
	}
}
PK       ! U,h    ,  content/Transform/ContentTransformerTest.phpnu Iw        <?php

use MediaWiki\Content\WikitextContent;
use MediaWiki\MainConfigNames;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Title\Title;
use MediaWiki\User\User;

/**
 * @covers \MediaWiki\Content\Transform\ContentTransformer
 */
class ContentTransformerTest extends MediaWikiIntegrationTestCase {

	public static function preSaveTransformProvider() {
		return [
			[
				new WikitextContent( 'Test ~~~' ),
				'Test [[Special:Contributions/127.0.0.1|127.0.0.1]]'
			],
		];
	}

	/**
	 *
	 * @dataProvider preSaveTransformProvider
	 */
	public function testPreSaveTransform( $content, $expectedContainText ) {
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'en' );
		$services = $this->getServiceContainer();
		$title = Title::makeTitle( NS_MAIN, 'Test' );
		$user = new User();
		$user->setName( "127.0.0.1" );
		$options = ParserOptions::newFromUser( $user );

		$newContent = $services->getContentTransformer()->preSaveTransform( $content, $title, $user, $options );
		$this->assertSame( $expectedContainText, $newContent->serialize() );
	}

	public static function preloadTransformProvider() {
		return [
			[
				new WikitextContent( '{{Foo}}<noinclude> censored</noinclude> information <!-- is very secret -->' ),
				'{{Foo}} information <!-- is very secret -->'
			],
		];
	}

	/**
	 * @dataProvider preloadTransformProvider
	 */
	public function testPreloadTransform( $content, $expectedContainText ) {
		$services = $this->getServiceContainer();
		$title = Title::makeTitle( NS_MAIN, 'Test' );
		$options = ParserOptions::newFromAnon();

		$newContent = $services->getContentTransformer()->preloadTransform( $content, $title, $options );
		$this->assertSame( $expectedContainText, $newContent->serialize() );
	}
}
PK       !     (  content/JavaScriptContentHandlerTest.phpnu Iw        <?php

use MediaWiki\Content\JavaScriptContent;
use MediaWiki\Content\JavaScriptContentHandler;
use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;

/**
 * @covers \MediaWiki\Content\JavaScriptContentHandler
 */
class JavaScriptContentHandlerTest extends MediaWikiLangTestCase {

	/**
	 * @dataProvider provideMakeRedirectContent
	 */
	public function testMakeRedirectContent( $title, $expected ) {
		$this->overrideConfigValues( [
			MainConfigNames::Server => '//example.org',
			MainConfigNames::Script => '/w/index.php',
		] );
		$ch = new JavaScriptContentHandler();
		$content = $ch->makeRedirectContent( Title::newFromText( $title ) );
		$this->assertInstanceOf( JavaScriptContent::class, $content );
		$this->assertEquals( $expected, $content->serialize( CONTENT_FORMAT_JAVASCRIPT ) );
	}

	/**
	 * This is re-used by JavaScriptContentTest to assert roundtrip
	 */
	public static function provideMakeRedirectContent() {
		return [
			'MediaWiki namespace page' => [
				'MediaWiki:MonoBook.js',
				'/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=MediaWiki:MonoBook.js&action=raw&ctype=text/javascript");'
			],
			'User subpage' => [
				'User:FooBar/common.js',
				'/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=User:FooBar/common.js&action=raw&ctype=text/javascript");'
			],
			'Gadget page' => [
				'Gadget:FooBaz.js',
				'/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=Gadget:FooBaz.js&action=raw&ctype=text/javascript");'
			],
			'Unicode basename' => [
				'User:😂/unicode.js',
				'/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=User:%F0%9F%98%82/unicode.js&action=raw&ctype=text/javascript");'
			],
			'Ampersand basename' => [
				'User:Penn & Teller/ampersand.js',
				'/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=User:Penn_%26_Teller/ampersand.js&action=raw&ctype=text/javascript");'
			],
		];
	}
}
PK       ! Uik	  k	  D  content/RegistrationContentHandlerFactoryToMediaWikiServicesTest.phpnu Iw        <?php

use MediaWiki\Content\CssContentHandler;
use MediaWiki\Content\JavaScriptContentHandler;
use MediaWiki\Content\JsonContentHandler;
use MediaWiki\Content\TextContentHandler;
use MediaWiki\Content\WikitextContentHandler;
use MediaWiki\MainConfigNames;

/**
 * @group ContentHandlerFactory
 * @covers \MediaWiki\MediaWikiServices::getContentHandlerFactory
 */
class RegistrationContentHandlerFactoryToMediaWikiServicesTest extends MediaWikiIntegrationTestCase {

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

		$this->overrideConfigValue(
			MainConfigNames::ContentHandlers,
			[
				CONTENT_MODEL_WIKITEXT => [
					'class' => WikitextContentHandler::class,
					'services' => [
						'TitleFactory',
						'ParserFactory',
						'GlobalIdGenerator',
						'LanguageNameUtils',
						'LinkRenderer',
						'MagicWordFactory',
						'ParsoidParserFactory',
					],
				],
				CONTENT_MODEL_JAVASCRIPT => JavaScriptContentHandler::class,
				CONTENT_MODEL_JSON => JsonContentHandler::class,
				CONTENT_MODEL_CSS => CssContentHandler::class,
				CONTENT_MODEL_TEXT => TextContentHandler::class,
				'testing' => DummyContentHandlerForTesting::class,
				'testing-callbacks' => static function ( $modelId ) {
					return new DummyContentHandlerForTesting( $modelId );
				},
			]
		);
	}

	public function testCallFromService_get_ok(): void {
		$this->assertInstanceOf(
			\MediaWiki\Content\IContentHandlerFactory::class,
			$this->getServiceContainer()->getContentHandlerFactory()
		);

		$this->assertSame(
			[
				'wikitext',
				'javascript',
				'json',
				'css',
				'text',
				'testing',
				'testing-callbacks',
			],
			$this->getServiceContainer()->getContentHandlerFactory()->getContentModels()
		);
	}

	public function testCallFromService_second_same(): void {
		$this->assertSame(
			$this->getServiceContainer()->getContentHandlerFactory(),
			$this->getServiceContainer()->getContentHandlerFactory()
		);
	}

	public function testCallFromService_afterCustomDefine_same(): void {
		$factory = $this->getServiceContainer()->getContentHandlerFactory();
		$factory->defineContentHandler(
			'model name',
			DummyContentHandlerForTesting::class
		);
		$this->assertTrue(
			$this->getServiceContainer()
				->getContentHandlerFactory()
				->isDefinedModel( 'model name' )
		);
		$this->assertSame(
			$factory,
			$this->getServiceContainer()->getContentHandlerFactory()
		);
	}
}
PK       ! 6~F   F     content/WikitextContentTest.phpnu Iw        <?php

use MediaWiki\Content\JavaScriptContent;
use MediaWiki\Content\TextContent;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Deferred\LinksUpdate\LinksDeletionUpdate;
use MediaWiki\Parser\Parser;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Title\Title;

/**
 * @group ContentHandler
 *
 * @group Database
 *        ^--- needed, because we do need the database to test link updates
 * @covers \MediaWiki\Content\WikitextContent
 */
class WikitextContentTest extends TextContentTest {
	public const SECTIONS = "Intro

== stuff ==
hello world

== test ==
just a test

== foo ==
more stuff
";

	public function newContent( $text ) {
		return new WikitextContent( $text );
	}

	public static function dataGetSection() {
		return [
			[ self::SECTIONS,
				"0",
				"Intro"
			],
			[ self::SECTIONS,
				"2",
				"== test ==
just a test"
			],
			[ self::SECTIONS,
				"8",
				false
			],
		];
	}

	/**
	 * @dataProvider dataGetSection
	 */
	public function testGetSection( $text, $sectionId, $expectedText ) {
		$content = $this->newContent( $text );

		$sectionContent = $content->getSection( $sectionId );
		if ( is_object( $sectionContent ) ) {
			$sectionText = $sectionContent->getText();
		} else {
			$sectionText = $sectionContent;
		}

		$this->assertEquals( $expectedText, $sectionText );
	}

	public static function dataReplaceSection() {
		return [
			[ self::SECTIONS,
				"0",
				"No more",
				null,
				trim( preg_replace( '/^Intro/m', 'No more', self::SECTIONS ) )
			],
			[ self::SECTIONS,
				"",
				"No more",
				null,
				"No more"
			],
			[ self::SECTIONS,
				"2",
				"== TEST ==\nmore fun",
				null,
				trim( preg_replace(
					'/^== test ==.*== foo ==/sm', "== TEST ==\nmore fun\n\n== foo ==",
					self::SECTIONS
				) )
			],
			[ self::SECTIONS,
				"8",
				"No more",
				null,
				self::SECTIONS
			],
			[ self::SECTIONS,
				"new",
				"No more",
				"New",
				trim( self::SECTIONS ) . "\n\n\n== New ==\n\nNo more"
			],
		];
	}

	/**
	 * @dataProvider dataReplaceSection
	 */
	public function testReplaceSection( $text, $section, $with, $sectionTitle, $expected ) {
		$content = $this->newContent( $text );
		/** @var WikitextContent $c */
		$c = $content->replaceSection( $section, $this->newContent( $with ), $sectionTitle );

		$this->assertEquals( $expected, $c ? $c->getText() : null );
	}

	public function testAddSectionHeader() {
		$content = $this->newContent( 'hello world' );
		$content = $content->addSectionHeader( 'test' );
		$this->assertEquals( "== test ==\n\nhello world", $content->getText() );

		$content = $this->newContent( 'hello world' );
		$content = $content->addSectionHeader( '' );
		$this->assertEquals( "hello world", $content->getText() );
	}

	public static function dataPreSaveTransform() {
		return [
			[ 'hello this is ~~~',
				"hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]",
			],
			[ 'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
				'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
			],
			[ // rtrim
				" Foo \n ",
				" Foo",
			],
		];
	}

	public static function dataGetRedirectTarget() {
		return [
			[ '#REDIRECT [[Test]]',
				'Test',
			],
			[ '#REDIRECT Test',
				null,
			],
			[ '* #REDIRECT [[Test]]',
				null,
			],
		];
	}

	public static function dataGetTextForSummary() {
		return [
			[ "hello\nworld.",
				16,
				'hello world.',
			],
			[ 'hello world.',
				8,
				'hello...',
			],
			[ '[[hello world]].',
				8,
				'hel...',
			],
		];
	}

	public static function dataIsCountable() {
		return [
			[ '',
				null,
				'any',
				true
			],
			[ 'Foo',
				null,
				'any',
				true
			],
			[ 'Foo',
				null,
				'link',
				false
			],
			[ 'Foo [[bar]]',
				null,
				'link',
				true
			],
			[ 'Foo',
				true,
				'link',
				true
			],
			[ 'Foo [[bar]]',
				false,
				'link',
				false
			],
			[ '#REDIRECT [[bar]]',
				true,
				'any',
				false
			],
			[ '#REDIRECT [[bar]]',
				true,
				'link',
				false
			],
		];
	}

	public function testMatchMagicWord() {
		$mw = $this->getServiceContainer()->getMagicWordFactory()->get( "staticredirect" );

		$content = $this->newContent( "#REDIRECT [[FOO]]\n__STATICREDIRECT__" );
		$this->assertTrue( $content->matchMagicWord( $mw ), "should have matched magic word" );

		$content = $this->newContent( "#REDIRECT [[FOO]]" );
		$this->assertFalse(
			$content->matchMagicWord( $mw ),
			"should not have matched magic word"
		);
	}

	public function testUpdateRedirect() {
		$target = Title::makeTitle( NS_MAIN, 'TestUpdateRedirect_target' );

		// test with non-redirect page
		$content = $this->newContent( "hello world." );
		$newContent = $content->updateRedirect( $target );

		$this->assertTrue( $content->equals( $newContent ), "content should be unchanged" );

		// test with actual redirect
		$content = $this->newContent( "#REDIRECT [[Someplace]]" );
		$newContent = $content->updateRedirect( $target );

		$this->assertFalse( $content->equals( $newContent ), "content should have changed" );
		$this->assertTrue( $newContent->isRedirect(), "new content should be a redirect" );

		$this->assertEquals(
			$target->getFullText(),
			$newContent->getRedirectTarget()->getFullText()
		);
	}

	public function testGetModel() {
		$content = $this->newContent( "hello world." );

		$this->assertEquals( CONTENT_MODEL_WIKITEXT, $content->getModel() );
	}

	public function testGetContentHandler() {
		$content = $this->newContent( "hello world." );

		$this->assertEquals( CONTENT_MODEL_WIKITEXT, $content->getContentHandler()->getModelID() );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOptions
	 */
	public function testRedirectParserOption() {
		$title = Title::makeTitle( NS_MAIN, 'TestRedirectParserOption' );
		$contentRenderer = $this->getServiceContainer()->getContentRenderer();

		// Set up hook and its reporting variables
		$wikitext = null;
		$redirectTarget = null;
		$this->mergeMwGlobalArrayValue( 'wgHooks', [
			'InternalParseBeforeLinks' => [
				static function ( Parser $parser, $text, $stripState ) use ( &$wikitext, &$redirectTarget ) {
					$wikitext = $text;
					$redirectTarget = $parser->getOptions()->getRedirectTarget();
				}
			]
		] );

		// Test with non-redirect page
		$wikitext = false;
		$redirectTarget = false;
		$content = $this->newContent( 'hello world.' );
		$options = ParserOptions::newFromAnon();
		$options->setRedirectTarget( $title );
		$contentRenderer->getParserOutput( $content, $title, null, $options );
		$this->assertEquals( 'hello world.', $wikitext,
			'Wikitext passed to hook was not as expected'
		);
		$this->assertNull( $redirectTarget, 'Redirect seen in hook was not null' );
		$this->assertEquals( $title, $options->getRedirectTarget(),
			'ParserOptions\' redirectTarget was changed'
		);

		// Test with a redirect page
		$wikitext = false;
		$redirectTarget = false;
		$content = $this->newContent(
			"#REDIRECT [[TestRedirectParserOption/redir]]\nhello redirect."
		);
		$options = ParserOptions::newFromAnon();
		$contentRenderer->getParserOutput( $content, $title, null, $options );
		$this->assertEquals(
			'hello redirect.',
			$wikitext,
			'Wikitext passed to hook was not as expected'
		);
		$this->assertNotEquals(
			null,
			$redirectTarget,
			'Redirect seen in hook was null' );
		$this->assertEquals(
			'TestRedirectParserOption/redir',
			$redirectTarget->getFullText(),
			'Redirect seen in hook was not the expected title'
		);
		$this->assertNull(
			$options->getRedirectTarget(),
			'ParserOptions\' redirectTarget was changed'
		);
	}

	public static function dataEquals() {
		return [
			[ new WikitextContent( "hallo" ), null, false ],
			[ new WikitextContent( "hallo" ), new WikitextContent( "hallo" ), true ],
			[ new WikitextContent( "hallo" ), new JavaScriptContent( "hallo" ), false ],
			[ new WikitextContent( "hallo" ), new TextContent( "hallo" ), false ],
			[ new WikitextContent( "hallo" ), new WikitextContent( "HALLO" ), false ],
		];
	}

	public static function dataGetDeletionUpdates() {
		return [
			[
				CONTENT_MODEL_WIKITEXT, "hello ''world''\n",
				[ LinksDeletionUpdate::class => [] ]
			],
			[
				CONTENT_MODEL_WIKITEXT, "hello [[world test 21344]]\n",
				[ LinksDeletionUpdate::class => [] ]
			],
			// @todo more...?
		];
	}
}
PK       ! N&8  8  -  content/JsonContentHandlerIntegrationTest.phpnu Iw        <?php

use MediaWiki\Content\JsonContent;
use MediaWiki\Content\JsonContentHandler;
use MediaWiki\Content\ValidationParams;
use MediaWiki\Json\FormatJson;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Title\Title;

/**
 * @covers \MediaWiki\Content\JsonContentHandler
 */
class JsonContentHandlerIntegrationTest extends MediaWikiLangTestCase {

	public static function provideDataAndParserText() {
		return [
			[
				[],
				'<div class="noresize"><table class="mw-json"><tbody><tr><td>' .
				'<table class="mw-json"><tbody><tr><td class="mw-json-empty">Empty array</td></tr>'
				. '</tbody></table></td></tr></tbody></table></div>'
			],
			[
				(object)[],
				'<div class="noresize"><table class="mw-json"><tbody><tr><td class="mw-json-empty">Empty object</td></tr>' .
				'</tbody></table></div>'
			],
			[
				(object)[ 'foo' ],
				'<div class="noresize"><table class="mw-json"><tbody><tr><th><span>0</span></th>' .
				'<td class="mw-json-value">"foo"</td></tr></tbody></table></div>'
			],
			[
				(object)[ 'foo', 'bar' ],
				'<div class="noresize"><table class="mw-json"><tbody><tr><th><span>0</span></th>' .
				'<td class="mw-json-value">"foo"</td></tr><tr><th><span>1</span></th>' .
				'<td class="mw-json-value">"bar"</td></tr></tbody></table></div>'
			],
			[
				(object)[ 'baz' => 'foo', 'bar' ],
				'<div class="noresize"><table class="mw-json"><tbody><tr><th><span>baz</span></th>' .
				'<td class="mw-json-value">"foo"</td></tr><tr><th><span>0</span></th>' .
				'<td class="mw-json-value">"bar"</td></tr></tbody></table></div>'
			],
			[
				(object)[ 'baz' => 1000, 'bar' ],
				'<div class="noresize"><table class="mw-json"><tbody><tr><th><span>baz</span></th>' .
				'<td class="mw-json-value">1000</td></tr><tr><th><span>0</span></th>' .
				'<td class="mw-json-value">"bar"</td></tr></tbody></table></div>'
			],
			[
				(object)[ '<script>alert("evil!")</script>' ],
				'<div class="noresize"><table class="mw-json"><tbody><tr><th><span>0</span></th><td class="mw-json-value">"' .
				'&lt;script>alert("evil!")&lt;/script>"' .
				'</td></tr></tbody></table></div>',
			],
			[
				'{ broken JSON ]',
				'Invalid JSON: $1',
			],
		];
	}

	/**
	 * @dataProvider provideDataAndParserText
	 */
	public function testFillParserOutput( $data, $expected ) {
		if ( !is_string( $data ) ) {
			$data = FormatJson::encode( $data );
		}

		$title = $this->createMock( Title::class );
		$title->method( 'getPageLanguage' )
			->willReturn( $this->getServiceContainer()->getContentLanguage() );

		$content = new JsonContent( $data );
		$contentRenderer = $this->getServiceContainer()->getContentRenderer();
		$opts = ParserOptions::newFromAnon();
		$parserOutput = $contentRenderer->getParserOutput(
			$content,
			$title,
			null,
			$opts,
			true
		);
		$this->assertInstanceOf( ParserOutput::class, $parserOutput );
		$this->assertEquals( $expected, $parserOutput->runOutputPipeline( $opts, [] )->getContentHolderText() );
	}

	public function testValidateSave() {
		$handler = new JsonContentHandler();
		$validationParams = new ValidationParams(
			PageIdentityValue::localIdentity( 123, NS_MEDIAWIKI, 'Config.json' ),
			0
		);

		$validJson = new JsonContent( FormatJson::encode( [ 'test' => 'value' ] ) );
		$invalidJson = new JsonContent( '{"key":' );

		$this->assertStatusGood( $handler->validateSave( $validJson, $validationParams ) );
		$this->assertStatusError( 'invalid-json-data',
			$handler->validateSave( $invalidJson, $validationParams ) );

		$this->setTemporaryHook(
			'JsonValidateSave',
			static function ( JsonContent $content, PageIdentity $pageIdentity, StatusValue $status )
			{
				if ( $pageIdentity->getDBkey() === 'Config.json' &&
					!isset( $content->getData()->getValue()->foo ) ) {
					$status->fatal( 'missing-key-foo' );
				}
			}
		);

		$this->assertStatusError( 'invalid-json-data',
			$handler->validateSave( $invalidJson, $validationParams ) );
		$this->assertStatusError( 'missing-key-foo',
			$handler->validateSave( $validJson, $validationParams ) );
	}
}
PK       ! 1  1    content/CssContentTest.phpnu Iw        <?php

use MediaWiki\Content\Content;
use MediaWiki\Content\CssContent;
use MediaWiki\Content\WikitextContent;
use MediaWiki\MainConfigNames;

/**
 * @group ContentHandler
 * @group Database
 *        ^--- needed, because we do need the database to test link updates
 * @covers \MediaWiki\Content\CssContent
 */
class CssContentTest extends TextContentTest {

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

		$this->overrideConfigValue(
			MainConfigNames::TextModelsToParse,
			[
				CONTENT_MODEL_CSS,
			]
		);
	}

	public function newContent( $text ) {
		return new CssContent( $text );
	}

	// XXX: currently, preSaveTransform is applied to styles. this may change or become optional.
	public static function dataPreSaveTransform() {
		return [
			[ 'hello this is ~~~',
				"hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]",
			],
			[ 'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
				'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
			],
			[ " Foo \n ",
				" Foo",
			],
		];
	}

	public function testGetModel() {
		$content = $this->newContent( 'hello world.' );

		$this->assertEquals( CONTENT_MODEL_CSS, $content->getModel() );
	}

	public function testGetContentHandler() {
		$content = $this->newContent( 'hello world.' );

		$this->assertEquals( CONTENT_MODEL_CSS, $content->getContentHandler()->getModelID() );
	}

	/**
	 * Redirects aren't supported
	 */
	public static function provideUpdateRedirect() {
		return [
			[
				'#REDIRECT [[Someplace]]',
				'#REDIRECT [[Someplace]]',
			],
		];
	}

	/**
	 * @dataProvider provideGetRedirectTarget
	 */
	public function testGetRedirectTarget( $title, $text ) {
		$this->overrideConfigValues( [
			MainConfigNames::Server => '//example.org',
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
		] );
		$content = new CssContent( $text );
		$target = $content->getRedirectTarget();
		$this->assertEquals( $title, $target ? $target->getPrefixedText() : null );
	}

	/**
	 * Keep this in sync with CssContentHandlerTest::provideMakeRedirectContent()
	 */
	public static function provideGetRedirectTarget() {
		return [
			[ 'MediaWiki:MonoBook.css', "/* #REDIRECT */@import url(//example.org/w/index.php?title=MediaWiki:MonoBook.css&action=raw&ctype=text/css);" ],
			[ 'User:FooBar/common.css', "/* #REDIRECT */@import url(//example.org/w/index.php?title=User:FooBar/common.css&action=raw&ctype=text/css);" ],
			[
				'User:😂/unicode.css',
				'/* #REDIRECT */@import url(//example.org/w/index.php?title=User:%F0%9F%98%82/unicode.css&action=raw&ctype=text/css);'
			],
			# No #REDIRECT comment
			[ null, "@import url(//example.org/w/index.php?title=Gadget:FooBaz.css&action=raw&ctype=text/css);" ],
			# Wrong domain
			[ null, "/* #REDIRECT */@import url(//example.com/w/index.php?title=Gadget:FooBaz.css&action=raw&ctype=text/css);" ],
		];
		// phpcs:enable
	}

	public static function dataEquals() {
		return [
			[ new CssContent( 'hallo' ), null, false ],
			[ new CssContent( 'hallo' ), new CssContent( 'hallo' ), true ],
			[ new CssContent( 'hallo' ), new WikitextContent( 'hallo' ), false ],
			[ new CssContent( 'hallo' ), new CssContent( 'HALLO' ), false ],
		];
	}

	/**
	 * @dataProvider dataEquals
	 */
	public function testEquals( Content $a, ?Content $b = null, $equal = false ) {
		$this->assertEquals( $equal, $a->equals( $b ) );
	}
}
PK       ! /t    -  content/TextContentHandlerIntegrationTest.phpnu Iw        <?php

use MediaWiki\Content\ContentHandler;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Title\Title;
use Wikimedia\Parsoid\ParserTests\TestUtils;

/**
 * @group ContentHandler
 * @group Database
 *        ^--- needed, because we do need the database to test link updates
 * @covers \MediaWiki\Content\TextContentHandler
 */
class TextContentHandlerIntegrationTest extends MediaWikiLangTestCase {

	public static function provideGetParserOutput() {
		yield 'Basic render' => [
			'title' => 'TextContentTest_testGetParserOutput',
			'model' => CONTENT_MODEL_TEXT,
			'text' => "hello ''world'' & [[stuff]]\n",
			'expectedHtml' => "<pre>hello ''world'' &amp; [[stuff]]\n</pre>",
			'expectedFields' =>	[ 'Links' => [] ]
		];
		yield 'Multi line render' => [
			'title' => 'TextContentTest_testGetParserOutput',
			'model' => CONTENT_MODEL_TEXT,
			'text' => "Test 1\nTest 2\n\nTest 3\n",
			'expectedHtml' => "<pre>Test 1\nTest 2\n\nTest 3\n</pre>",
			'expectedFields' =>	[ 'Links' => [] ]
		];
	}

	/**
	 * @dataProvider provideGetParserOutput
	 */
	public function testGetParserOutput( $title, $model, $text, $expectedHtml,
		$expectedFields = null, $parserOptions = null
	) {
		$title = Title::newFromText( $title );
		$content = ContentHandler::makeContent( $text, $title, $model );
		$contentRenderer = $this->getServiceContainer()->getContentRenderer();
		if ( $parserOptions === null ) {
			$parserOptions = ParserOptions::newFromAnon();
		}
		$po = $contentRenderer->getParserOutput( $content, $title, null, $parserOptions );

		// TODO T371004
		$processedPo = $po->runOutputPipeline( $parserOptions, [] );
		$html = $processedPo->getContentHolderText();
		$html = preg_replace( '#<!--.*?-->#sm', '', $html ); // strip comments
		$html = TestUtils::stripParsoidIds( $html );

		if ( $expectedHtml !== null ) {
			$this->assertEquals( TestUtils::stripParsoidIds( $expectedHtml ), trim( $html ) );
		}

		if ( $expectedFields ) {
			foreach ( $expectedFields as $field => $exp ) {
				$getter = 'get' . ucfirst( $field );
				$v = $processedPo->$getter();

				if ( is_array( $exp ) ) {
					$this->assertArrayEquals( $exp, $v );
				} else {
					$this->assertEquals( $exp, $v );
				}
			}
		}
	}
}
PK       ! h:H%  H%  "  content/ContentModelChangeTest.phpnu Iw        <?php

use MediaWiki\Content\ContentHandler;
use MediaWiki\Content\ContentModelChange;
use MediaWiki\Context\RequestContext;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\WikiPageFactory;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\Permissions\RateLimiter;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Unit\MockServiceDependenciesTrait;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Title\Title;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * TODO convert to a pure unit test
 *
 * @group Database
 * @covers \MediaWiki\Content\ContentModelChange
 *
 * @author DannyS712
 * @method ContentModelChange newServiceInstance(string $serviceClass, array $parameterOverrides)
 */
class ContentModelChangeTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;
	use MockServiceDependenciesTrait;

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

		$this->getExistingTestPage( 'ExistingPage' );
		$this->mergeMwGlobalArrayValue( 'wgContentHandlers', [
			'testing' => 'DummyContentHandlerForTesting',
		] );
	}

	private function newContentModelChange(
		Authority $performer,
		WikiPage $page,
		string $newModel
	) {
		return $this->getServiceContainer()
			->getContentModelChangeFactory()
			->newContentModelChange( $performer, $page, $newModel );
	}

	/**
	 * Test that the content model needs to change
	 */
	public function testChangeNeeded() {
		$wikipage = $this->getExistingTestPage( 'ExistingPage' );
		$this->assertSame(
			'wikitext',
			$wikipage->getTitle()->getContentModel(),
			'`ExistingPage` should be wikitext'
		);

		$change = $this->newContentModelChange(
			$this->mockRegisteredAuthorityWithPermissions( [ 'editcontentmodel' ] ),
			$wikipage,
			'wikitext'
		);
		$status = $change->doContentModelChange(
			RequestContext::getMain(),
			__METHOD__ . ' comment',
			false
		);
		$this->assertStatusError( 'apierror-nochanges', $status );
	}

	/**
	 * Test that the content needs to be valid for the requested model
	 */
	public function testInvalidContent() {
		$invalidJSON = 'Foo\nBar\nEaster egg\nT22281';
		$wikipage = $this->getExistingTestPage( 'PageWithTextThatIsNotValidJSON' );
		$wikipage->doUserEditContent(
			ContentHandler::makeContent( $invalidJSON, $wikipage->getTitle() ),
			$this->getTestSysop()->getUser(),
			'EditSummaryForThisTest',
			EDIT_UPDATE | EDIT_SUPPRESS_RC
		);
		$this->assertSame(
			'wikitext',
			$wikipage->getTitle()->getContentModel(),
			'`PageWithTextThatIsNotValidJSON` should be wikitext at first'
		);

		$change = $this->newContentModelChange(
			$this->mockRegisteredAuthorityWithPermissions( [ 'editcontentmodel' ] ),
			$wikipage,
			'json'
		);
		$status = $change->doContentModelChange(
			RequestContext::getMain(),
			__METHOD__ . ' comment',
			false
		);
		$this->assertStatusError( 'invalid-json-data', $status );
	}

	/**
	 * Test the EditFilterMergedContent hook can be intercepted
	 *
	 * @dataProvider provideTestEditFilterMergedContent
	 * @param string|bool $customMessage Hook message, or false
	 * @param string $expectedMessage expected fatal
	 */
	public function testEditFilterMergedContent( $customMessage, $expectedMessage ) {
		$wikipage = $this->getExistingTestPage( 'ExistingPage' );
		$this->assertSame(
			'wikitext',
			$wikipage->getTitle()->getContentModel( IDBAccessObject::READ_LATEST ),
			'`ExistingPage` should be wikitext'
		);

		$this->setTemporaryHook( 'EditFilterMergedContent',
			static function ( $unused1, $unused2, Status $status ) use ( $customMessage ) {
				if ( $customMessage !== false ) {
					$status->fatal( $customMessage );
				}
				return false;
			}
		);

		$change = $this->newContentModelChange(
			$this->mockRegisteredAuthorityWithPermissions( [ 'editcontentmodel' ] ),
			$wikipage,
			'text'
		);
		$status = $change->doContentModelChange(
			RequestContext::getMain(),
			__METHOD__ . ' comment',
			false
		);
		$this->assertStatusError( $expectedMessage, $status );
	}

	public static function provideTestEditFilterMergedContent() {
		return [
			[ 'DannyS712 objects to this change!', 'DannyS712 objects to this change!' ],
			[ false, 'hookaborted' ]
		];
	}

	/**
	 * Test the ContentModelCanBeUsedOn hook can be intercepted
	 */
	public function testContentModelCanBeUsedOn() {
		$wikipage = $this->getExistingTestPage( 'ExistingPage' );
		$wikipage->doUserEditContent(
			ContentHandler::makeContent( 'Text', $wikipage->getTitle() ),
			$this->getTestSysop()->getUser(),
			'Ensure a revision exists',
			EDIT_UPDATE | EDIT_SUPPRESS_RC
		);
		$this->assertSame(
			'wikitext',
			$wikipage->getTitle()->getContentModel( IDBAccessObject::READ_LATEST ),
			'`ExistingPage` should be wikitext'
		);

		$this->setTemporaryHook( 'ContentModelCanBeUsedOn',
			static function ( $unused1, $unused2, &$ok ) {
				$ok = false;
				return false;
			}
		);

		$change = $this->newContentModelChange(
			$this->mockRegisteredAuthorityWithPermissions( [ 'editcontentmodel' ] ),
			$wikipage,
			'text'
		);
		$status = $change->doContentModelChange(
			RequestContext::getMain(),
			__METHOD__ . ' comment',
			false
		);
		$this->assertStatusError( 'apierror-changecontentmodel-cannotbeused', $status );
	}

	/**
	 * Test that content handler must support direct editing
	 */
	public function testNoDirectEditing() {
		$title = Title::newFromText( 'Dummy:NoDirectEditing' );
		$wikipage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$dummyContent = $this->getServiceContainer()
			->getContentHandlerFactory()
			->getContentHandler( 'testing' )
			->makeEmptyContent();

		$wikipage->doUserEditContent(
			$dummyContent,
			$this->getTestSysop()->getUser(),
			'EditSummaryForThisTest',
			EDIT_NEW | EDIT_SUPPRESS_RC
		);
		$this->assertSame(
			'testing',
			$title->getContentModel( IDBAccessObject::READ_LATEST ),
			'Dummy:NoDirectEditing should start with the `testing` content model'
		);

		$change = $this->newContentModelChange(
			$this->mockRegisteredAuthorityWithPermissions( [ 'editcontentmodel' ] ),
			$wikipage,
			'text'
		);
		$status = $change->doContentModelChange(
			RequestContext::getMain(),
			__METHOD__ . ' comment',
			false
		);
		$this->assertStatusError(
			'apierror-changecontentmodel-nodirectediting',
			$status
		);
	}

	public function testCannotApplyTags() {
		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'edit content model tag' );

		$change = $this->newContentModelChange(
			$this->mockRegisteredAuthorityWithoutPermissions( [ 'applychangetags' ] ),
			$this->getExistingTestPage( 'ExistingPage' ),
			'text'
		);
		$status = $change->setTags( [ 'edit content model tag' ] );
		$this->assertStatusError(
			'tags-apply-no-permission',
			$status
		);
	}

	public function testCheckPermissions() {
		$wikipage = $this->getExistingTestPage( 'ExistingPage' );
		$title = $wikipage->getTitle();
		$currentContentModel = $title->getContentModel( IDBAccessObject::READ_LATEST );
		$newContentModel = 'text';

		$this->assertSame(
			'wikitext',
			$currentContentModel,
			'`ExistingPage` should be wikitext'
		);

		$performer = $this->mockRegisteredAuthority( static function (
			string $permission,
			PageIdentity $page,
			PermissionStatus $status
		) use ( $currentContentModel, $newContentModel ) {
			$title = Title::newFromPageIdentity( $page );
			if ( $permission === 'editcontentmodel' && $title->hasContentModel( $currentContentModel ) ) {
				$status->fatal( 'no edit old content model' );
				return false;
			}
			if ( $permission === 'editcontentmodel' && $title->hasContentModel( $newContentModel ) ) {
				$status->fatal( 'no edit new content model' );
				return false;
			}
			if ( $permission === 'edit' && $title->hasContentModel( $currentContentModel ) ) {
				$status->fatal( 'no edit at all old content model' );
				return false;
			}
			if ( $permission === 'edit' && $title->hasContentModel( $newContentModel ) ) {
				$status->fatal( 'no edit at all new content model' );
				return false;
			}
			return true;
		} );

		$wpFactory = $this->createMock( WikiPageFactory::class );
		$wpFactory->method( 'newFromTitle' )->willReturn( $wikipage );
		$change = $this->newServiceInstance(
			ContentModelChange::class,
			[
				'performer' => $performer,
				'page' => $wikipage,
				'newModel' => $newContentModel,
				'wikiPageFactory' => $wpFactory,
			]
		);

		foreach ( [ 'probablyCanChange', 'authorizeChange' ] as $method ) {
			$status = $change->$method();
			$this->assertArrayEquals(
				[
					[ 'no edit new content model' ],
					[ 'no edit old content model' ],
					[ 'no edit at all old content model' ],
					[ 'no edit at all new content model' ],
				],
				$status->toLegacyErrorArray()
			);
		}
	}

	public function testCheckPermissionsThrottle() {
		$user = $this->getTestUser()->getUser();

		$limiter = $this->createNoOpMock( RateLimiter::class, [ 'limit', 'isLimitable' ] );
		$limiter->method( 'isLimitable' )->willReturn( true );
		$limiter->method( 'limit' )
			->willReturnCallback( function ( $user, $action, $incr ) {
				if ( $action === 'editcontentmodel' ) {
					$this->assertSame( 1, $incr );
					return true;
				}
				return false;
			} );

		$this->setService( 'RateLimiter', $limiter );

		$change = $this->newContentModelChange(
			$user,
			$this->getNonexistingTestPage( 'NonExistingPage' ),
			'text'
		);

		$status = $change->authorizeChange();
		$this->assertFalse( $status->isOK() );
		$this->assertTrue( $status->isRateLimitExceeded() );
	}

}
PK       ! F'  F'  1  content/WikitextContentHandlerIntegrationTest.phpnu Iw        <?php

use MediaWiki\Content\Renderer\ContentParseParams;
use MediaWiki\Interwiki\ClassicInterwikiLookup;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MainConfigNames;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use Wikimedia\Parsoid\Parsoid;

/**
 * @group ContentHandler
 * @group Database
 *        ^--- needed, because we do need the database to test link updates
 * @covers \MediaWiki\Content\WikitextContentHandler
 */
class WikitextContentHandlerIntegrationTest extends TextContentHandlerIntegrationTest {
	protected function setUp(): void {
		parent::setUp();

		// Set up temporary interwiki links for 'en' and 'google'
		$defaults = [
			'iw_local' => 0,
			'iw_api' => '/w/api.php',
			'iw_url' => ''
		];
		$this->overrideConfigValue(
			MainConfigNames::InterwikiCache,
			ClassicInterwikiLookup::buildCdbHash( [
				[
					'iw_prefix' => 'en',
					'iw_url' => 'https://en.wikipedia.org/wiki/$1',
					'iw_wikiid' => 'enwiki',
				] + $defaults,
				[
					'iw_prefix' => 'google',
					'iw_url' => 'https://google.com/?q=$1',
					'iw_wikiid' => 'google',
				] + $defaults,
			] )
		);
	}

	public static function provideGetParserOutput() {
		$commonOptions = [
			'collapsibleSections',
			'disableContentConversion',
			'interfaceMessage',
			'isPreview',
			'maxIncludeSize',
			'suppressSectionEditLinks',
			'useParsoid',
			'wrapclass',
		];
		$commonParsoidOptions = array_merge( $commonOptions, [
			// Currently no options specific to parsoid parses
		] );
		$commonLegacyOptions = array_merge( $commonOptions, [
			'disableTitleConversion',
			'expensiveParserFunctionLimit',
			'maxPPExpandDepth',
			'maxPPNodeCount',
			'suppressTOC',
			'targetLanguage',
		] );
		$parsoidVersion =
			'data-mw-parsoid-version="' . Parsoid::version() . '" ' .
			'data-mw-html-version="' . Parsoid::defaultHTMLVersion() . '"';

		yield 'Basic render' => [
			'title' => 'WikitextContentTest_testGetParserOutput',
			'model' => CONTENT_MODEL_WIKITEXT,
			'text' => "hello ''world''\n",
			'expectedHtml' => '<div class="mw-content-ltr mw-parser-output" lang="en" dir="ltr">' . "<p>hello <i>world</i>\n</p></div>",
			'expectedFields' => [
				'Links' => [
				],
				'Sections' => [
				],
				'UsedOptions' => $commonLegacyOptions,
			],
		];
		yield 'Basic Parsoid render' => [
			'title' => 'WikitextContentTest_testGetParserOutput',
			'model' => CONTENT_MODEL_WIKITEXT,
			'text' => "hello ''world''\n",
			'expectedHtml' => "<div class=\"mw-content-ltr mw-parser-output\" lang=\"en\" dir=\"ltr\" $parsoidVersion><section data-mw-section-id=\"0\" id=\"mwAQ\"><p id=\"mwAg\">hello <i id=\"mwAw\">world</i></p>\n</section></div>",
			'expectedFields' => [
				'Links' => [
				],
				'Sections' => [
				],
				'UsedOptions' => $commonParsoidOptions,
			],
			'options' => [ 'useParsoid' => true, 'suppressSectionEditLinks' => true ],
		];
		yield 'Parsoid render (redirect page)' => [
			'title' => 'WikitextContentTest_testGetParserOutput',
			'model' => CONTENT_MODEL_WIKITEXT,
			'text' => "#REDIRECT [[Main Page]]",
			'expectedHtml' => "<div class=\"mw-content-ltr mw-parser-output\" lang=\"en\" dir=\"ltr\" $parsoidVersion><div class=\"redirectMsg\"><p>Redirect to:</p><ul class=\"redirectText\"><li><a href=\"/w/index.php?title=Main_Page&amp;action=edit&amp;redlink=1\" class=\"new\" title=\"Main Page (page does not exist)\">Main Page</a></li></ul></div><section data-mw-section-id=\"0\" id=\"mwAQ\"><link rel=\"mw:PageProp/redirect\" href=\"./Main_Page\" id=\"mwAg\"/></section></div>",
			'expectedFields' => [
				'Links' => [
					[ 'Main_Page' => 0 ],
				],
				'Sections' => [
				],
				'UsedOptions' => $commonParsoidOptions,
			],
			'options' => [ 'useParsoid' => true, 'suppressSectionEditLinks' => true ],
		];
		yield 'Parsoid render (section edit links)' => [
			'title' => 'WikitextContentTest_testGetParserOutput',
			'model' => CONTENT_MODEL_WIKITEXT,
			'text' => "== Hello ==",
			'expectedHtml' => "<div class=\"mw-content-ltr mw-parser-output\" lang=\"en\" dir=\"ltr\" $parsoidVersion id=\"mwAw\">" . '<section data-mw-section-id="0" id="mwAQ"></section><section data-mw-section-id="1" id="mwAg"><div class="mw-heading mw-heading2" id="mwBA"><h2 id="Hello">Hello</h2><span class="mw-editsection" id="mwBQ"><span class="mw-editsection-bracket" id="mwBg">[</span><a href="/w/index.php?title=WikitextContentTest_testGetParserOutput&amp;action=edit&amp;section=1" title="Edit section: Hello" id="mwBw"><span id="mwCA">edit</span></a><span class="mw-editsection-bracket" id="mwCQ">]</span></span></div></section></div>',
			'expectedFields' => [
				'Links' => [
				],
				'Sections' => [
					[
						'toclevel' => 1,
						'level' => '2',
						'line' => 'Hello',
						'number' => '1',
						'index' => '1',
						'fromtitle' => 'WikitextContentTest_testGetParserOutput',
						'byteoffset' => 0,
						'anchor' => 'Hello',
						'linkAnchor' => 'Hello',
					],
				],
				'UsedOptions' => $commonParsoidOptions,
			],
			'options' => [ 'useParsoid' => true ],
		];
		yield 'Links' => [
			'title' => 'WikitextContentTest_testGetParserOutput',
			'model' => CONTENT_MODEL_WIKITEXT,
			'text' => "[[title that does not really exist]]",
			'expectedHtml' => null,
			'expectedFields' => [
				'Links' => [
					[ 'Title_that_does_not_really_exist' => 0, ],
				],
				'Sections' => [
				],
			],
		];
		yield 'TOC' => [
			'title' => 'WikitextContentTest_testGetParserOutput',
			'model' => CONTENT_MODEL_WIKITEXT,
			'text' => "==One==\n==Two==\n==Three==\n==Four==\n<h2>Five</h2>\n===Six+Seven %2525===",
			'expectedHtml' => null,
			'expectedFields' => [
				'Links' => [
				],
				'Sections' => [
					[
						'toclevel' => 1,
						'level' => '2',
						'line' => 'One',
						'number' => '1',
						'index' => '1',
						'fromtitle' => 'WikitextContentTest_testGetParserOutput',
						'byteoffset' => 0,
						'anchor' => 'One',
						'linkAnchor' => 'One',
					],
					[
						'toclevel' => 1,
						'level' => '2',
						'line' => 'Two',
						'number' => '2',
						'index' => '2',
						'fromtitle' => 'WikitextContentTest_testGetParserOutput',
						'byteoffset' => 8,
						'anchor' => 'Two',
						'linkAnchor' => 'Two',
					],
					[
						'toclevel' => 1,
						'level' => '2',
						'line' => 'Three',
						'number' => '3',
						'index' => '3',
						'fromtitle' => 'WikitextContentTest_testGetParserOutput',
						'byteoffset' => 16,
						'anchor' => 'Three',
						'linkAnchor' => 'Three',
					],
					[
						'toclevel' => 1,
						'level' => '2',
						'line' => 'Four',
						'number' => '4',
						'index' => '4',
						'fromtitle' => 'WikitextContentTest_testGetParserOutput',
						'byteoffset' => 26,
						'anchor' => 'Four',
						'linkAnchor' => 'Four',
					],
					[
						'toclevel' => 1,
						'level' => '2',
						'line' => 'Five',
						'number' => '5',
						'index' => '',
						'fromtitle' => false,
						'byteoffset' => null,
						'anchor' => 'Five',
						'linkAnchor' => 'Five',
					],
					[
						'toclevel' => 2,
						'level' => '3',
						'line' => 'Six+Seven %2525',
						'number' => '5.1',
						'index' => '5',
						'fromtitle' => 'WikitextContentTest_testGetParserOutput',
						'byteoffset' => 49,
						'anchor' => 'Six+Seven_%2525',
						'linkAnchor' => 'Six+Seven_%252525',
					],
				],
			],
		];
	}

	/**
	 * @dataProvider provideGetParserOutput
	 */
	public function testGetParserOutput( $title, $model, $text, $expectedHtml,
		$expectedFields = null, $options = null
	) {
		$this->overrideConfigValues( [
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::FragmentMode => [ 'html5' ],
		] );

		$parserOptions = null;
		if ( $options ) {
			$parserOptions = ParserOptions::newFromAnon();
			foreach ( $options as $key => $val ) {
				$parserOptions->setOption( $key, $val );
			}
		}
		parent::testGetParserOutput(
			$title, $model, $text, $expectedHtml, $expectedFields, $parserOptions
		);
	}

	/**
	 * @dataProvider provideMakeRedirectContent
	 * @param LinkTarget $target
	 * @param string $expectedWT Serialized wikitext form of the content object built
	 * @param string $expectedTarget Expected target string in the HTML redirect
	 */
	public function testMakeRedirectContent( LinkTarget $target, string $expectedWT, string $expectedTarget ) {
		$handler = $this->getServiceContainer()->getContentHandlerFactory()
			->getContentHandler( CONTENT_MODEL_WIKITEXT );
		$content = $handler->makeRedirectContent( Title::newFromLinkTarget( $target ) );
		$this->assertEquals( $expectedWT, $content->serialize() );

		// Check that an appropriate redirect header was added to the
		// ParserOutput
		$parserOutput = $handler->getParserOutput(
			$content,
			new ContentParseParams( Title::newMainPage() )
		);
		$redirectHeader = $parserOutput->getRedirectHeader();
		$this->assertStringContainsString( '<div class="redirectMsg">', $redirectHeader );
		$this->assertMatchesRegularExpression( '!<a[^<>]+>' . $expectedTarget . '</a>!', $redirectHeader );
	}

	public static function provideMakeRedirectContent() {
		return [
			[ new TitleValue( NS_MAIN, 'Hello' ), '#REDIRECT [[Hello]]', 'Hello' ],
			[ new TitleValue( NS_TEMPLATE, 'Hello' ), '#REDIRECT [[Template:Hello]]', 'Template:Hello' ],
			[ new TitleValue( NS_MAIN, 'Hello', 'section' ), '#REDIRECT [[Hello#section]]', 'Hello#section' ],
			[ new TitleValue( NS_USER, 'John doe', 'section' ), '#REDIRECT [[User:John doe#section]]', 'User:John doe#section' ],
			[ new TitleValue( NS_MEDIAWIKI, 'FOOBAR' ), '#REDIRECT [[MediaWiki:FOOBAR]]', 'MediaWiki:FOOBAR' ],
			[ new TitleValue( NS_CATEGORY, 'Foo' ), '#REDIRECT [[:Category:Foo]]', 'Category:Foo' ],
			[ new TitleValue( NS_MAIN, 'en:Foo' ), '#REDIRECT [[en:Foo]]', 'en:Foo' ],
			[ new TitleValue( NS_MAIN, 'Foo', '', 'en' ), '#REDIRECT [[:en:Foo]]', 'en:Foo' ],
			[
				new TitleValue( NS_MAIN, 'Bar', 'fragment', 'google' ),
				'#REDIRECT [[google:Bar#fragment]]',
				'google:Bar#fragment'
			],
		];
	}
}
PK       ! ?    !  content/CssContentHandlerTest.phpnu Iw        <?php

use MediaWiki\Content\CssContent;
use MediaWiki\Content\CssContentHandler;
use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;

/**
 * @covers \MediaWiki\Content\CssContentHandler
 */
class CssContentHandlerTest extends MediaWikiLangTestCase {

	/**
	 * @dataProvider provideMakeRedirectContent
	 */
	public function testMakeRedirectContent( int $namespace, string $title, $expected ) {
		$this->overrideConfigValues( [
			MainConfigNames::Server => '//example.org',
			MainConfigNames::Script => '/w/index.php',
		] );
		$ch = new CssContentHandler();
		$content = $ch->makeRedirectContent( Title::makeTitle( $namespace, $title ) );
		$this->assertInstanceOf( CssContent::class, $content );
		$this->assertEquals( $expected, $content->serialize( CONTENT_FORMAT_CSS ) );
	}

	/**
	 * Keep this in sync with CssContentTest::provideGetRedirectTarget()
	 */
	public static function provideMakeRedirectContent() {
		return [
			[
				NS_MEDIAWIKI,
				'MonoBook.css',
				"/* #REDIRECT */@import url(//example.org/w/index.php?title=MediaWiki:MonoBook.css&action=raw&ctype=text/css);"
			],
			[
				NS_USER,
				'FooBar/common.css',
				"/* #REDIRECT */@import url(//example.org/w/index.php?title=User:FooBar/common.css&action=raw&ctype=text/css);"
			],
			[
				NS_USER,
				'😂/unicode.css',
				'/* #REDIRECT */@import url(//example.org/w/index.php?title=User:%F0%9F%98%82/unicode.css&action=raw&ctype=text/css);'
			],
		];
		// phpcs:enable
	}
}
PK       ! nED!  !  &  content/WikitextContentHandlerTest.phpnu Iw        <?php

use MediaWiki\Content\FileContentHandler;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Content\WikitextContentHandler;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\ParserOutputFlags;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleFactory;
use MediaWiki\User\UserIdentity;
use Wikimedia\Rdbms\IDBAccessObject;
use Wikimedia\TestingAccessWrapper;

/**
 * See also unit tests at \MediaWiki\Tests\Unit\WikitextContentHandlerTest
 *
 * @group ContentHandler
 * @covers \MediaWiki\Content\WikitextContentHandler
 * @covers \MediaWiki\Content\TextContentHandler
 * @covers \MediaWiki\Content\ContentHandler
 */
class WikitextContentHandlerTest extends MediaWikiLangTestCase {
	private WikitextContentHandler $handler;

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

		$this->handler = $this->getServiceContainer()->getContentHandlerFactory()
			->getContentHandler( CONTENT_MODEL_WIKITEXT );
	}

	public static function dataMerge3() {
		return [
			[
				"first paragraph

					second paragraph\n",

				"FIRST paragraph

					second paragraph\n",

				"first paragraph

					SECOND paragraph\n",

				"FIRST paragraph

					SECOND paragraph\n",
			],

			[ "first paragraph
					second paragraph\n",

				"Bla bla\n",

				"Blubberdibla\n",

				false,
			],
		];
	}

	/**
	 * @dataProvider dataMerge3
	 */
	public function testMerge3( $old, $mine, $yours, $expected ) {
		$this->markTestSkippedIfNoDiff3();

		// test merge
		$oldContent = new WikitextContent( $old );
		$myContent = new WikitextContent( $mine );
		$yourContent = new WikitextContent( $yours );

		$merged = $this->handler->merge3( $oldContent, $myContent, $yourContent );

		$this->assertEquals( $expected, $merged ? $merged->getText() : $merged );
	}

	public static function dataGetAutosummary() {
		return [
			[
				'Hello there, world!',
				'#REDIRECT [[Foo]]',
				0,
				'/^Redirected page .*Foo/'
			],

			[
				null,
				'Hello world!',
				EDIT_NEW,
				'/^Created page .*Hello/'
			],

			[
				null,
				'',
				EDIT_NEW,
				'/^Created blank page$/'
			],

			[
				'Hello there, world!',
				'',
				0,
				'/^Blanked/'
			],

			[
				'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
				eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam
				voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet
				clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.',
				'Hello world!',
				0,
				'/^Replaced .*Hello/'
			],

			[
				'foo',
				'bar',
				0,
				'/^$/'
			],
		];
	}

	/**
	 * @dataProvider dataGetAutosummary
	 */
	public function testGetAutosummary( $old, $new, $flags, $expected ) {
		$oldContent = $old === null ? null : new WikitextContent( $old );
		$newContent = $new === null ? null : new WikitextContent( $new );

		$summary = $this->handler->getAutosummary( $oldContent, $newContent, $flags );

		$this->assertTrue(
			(bool)preg_match( $expected, $summary ),
			"Autosummary didn't match expected pattern $expected: $summary"
		);
	}

	public static function dataGetChangeTag() {
		return [
			[
				null,
				'#REDIRECT [[Foo]]',
				0,
				'mw-new-redirect'
			],

			[
				'Lorem ipsum dolor',
				'#REDIRECT [[Foo]]',
				0,
				'mw-new-redirect'
			],

			[
				'#REDIRECT [[Foo]]',
				'Lorem ipsum dolor',
				0,
				'mw-removed-redirect'
			],

			[
				'#REDIRECT [[Foo]]',
				'#REDIRECT [[Bar]]',
				0,
				'mw-changed-redirect-target'
			],

			[
				null,
				'Lorem ipsum dolor',
				EDIT_NEW,
				null // mw-newpage is not defined as a tag
			],

			[
				null,
				'',
				EDIT_NEW,
				null // mw-newblank is not defined as a tag
			],

			[
				'Lorem ipsum dolor',
				'',
				0,
				'mw-blank'
			],

			[
				'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
				eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam
				voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet
				clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.',
				'Ipsum',
				0,
				'mw-replace'
			],

			[
				'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
				eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam
				voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet
				clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.',
				'Duis purus odio, rhoncus et finibus dapibus, facilisis ac urna. Pellentesque
				arcu, tristique nec tempus nec, suscipit vel arcu. Sed non dolor nec ligula
				congue tempor. Quisque pellentesque finibus orci a molestie. Nam maximus, purus
				euismod finibus mollis, dui ante malesuada felis, dignissim rutrum diam sapien.',
				0,
				null
			],
		];
	}

	/**
	 * @dataProvider dataGetChangeTag
	 */
	public function testGetChangeTag( $old, $new, $flags, $expected ) {
		$this->overrideConfigValue( MainConfigNames::SoftwareTags, [
			'mw-new-redirect' => true,
			'mw-removed-redirect' => true,
			'mw-changed-redirect-target' => true,
			'mw-newpage' => true,
			'mw-newblank' => true,
			'mw-blank' => true,
			'mw-replace' => true,
		] );
		$oldContent = $old === null ? null : new WikitextContent( $old );
		$newContent = $new === null ? null : new WikitextContent( $new );

		$tag = $this->handler->getChangeTag( $oldContent, $newContent, $flags );

		$this->assertSame( $expected, $tag );
	}

	public function testGetFieldsForSearchIndex() {
		$searchEngine = $this->createMock( SearchEngine::class );

		$searchEngine->method( 'makeSearchFieldMapping' )
			->willReturnCallback( static function ( $name, $type ) {
				return new DummySearchIndexFieldDefinition( $name, $type );
			} );

		$this->hideDeprecated( 'MediaWiki\\Content\\ContentHandler::getForModelID' );

		$fields = $this->handler->getFieldsForSearchIndex( $searchEngine );

		$this->assertArrayHasKey( 'category', $fields );
		$this->assertArrayHasKey( 'external_link', $fields );
		$this->assertArrayHasKey( 'outgoing_link', $fields );
		$this->assertArrayHasKey( 'template', $fields );
		$this->assertArrayHasKey( 'content_model', $fields );
	}

	public function testDataIndexFieldsFile() {
		$mockEngine = $this->createMock( SearchEngine::class );
		$title = Title::makeTitle( NS_FILE, 'Somefile.jpg' );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		// Mark the page as not existent to avoid DB queries.
		$pageWrapper = TestingAccessWrapper::newFromObject( $page );
		$pageWrapper->mLatest = null;
		$pageWrapper->mId = 0;
		$pageWrapper->mDataLoadedFrom = IDBAccessObject::READ_NORMAL;

		$fileHandler = $this->getMockBuilder( FileContentHandler::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getDataForSearchIndex' ] )
			->getMock();

		$handler = $this->getMockBuilder( WikitextContentHandler::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getFileHandler' ] )
			->getMock();

		$handler->method( 'getFileHandler' )->willReturn( $fileHandler );
		$fileHandler->expects( $this->once() )
			->method( 'getDataForSearchIndex' )
			->willReturn( [ 'file_text' => 'This is file content' ] );

		$data = $handler->getDataForSearchIndex( $page, new ParserOutput( '' ), $mockEngine );
		$this->assertArrayHasKey( 'file_text', $data );
		$this->assertEquals( 'This is file content', $data['file_text'] );
	}

	public function testHadSignature() {
		$services = $this->getServiceContainer();

		$pageObj = PageReferenceValue::localReference( NS_MAIN, __CLASS__ );
		// Force a content model in the converted Title to avoid DB queries.
		$title = $services->getTitleFactory()->newFromPageReference( $pageObj );
		$title->setContentModel( CONTENT_MODEL_WIKITEXT );
		$titleFactory = $this->createMock( TitleFactory::class );
		$titleFactory->method( 'newFromPageReference' )
			->with( $pageObj )
			->willReturn( $title );
		$this->setService( 'TitleFactory', $titleFactory );

		$contentTransformer = $services->getContentTransformer();
		$contentRenderer = $services->getContentRenderer();
		$this->hideDeprecated( 'AbstractContent::preSaveTransform' );

		$content = new WikitextContent( '~~~~' );
		$pstContent = $contentTransformer->preSaveTransform(
			$content,
			$pageObj,
			$this->createMock( UserIdentity::class ),
			ParserOptions::newFromAnon()
		);

		$this->assertTrue( $contentRenderer->getParserOutput( $pstContent, $pageObj )->getOutputFlag(
			ParserOutputFlags::USER_SIGNATURE
		) );
	}
}
PK       ! ̌Q?    3  content/JavaScriptContentHandlerIntegrationTest.phpnu Iw        <?php

/**
 * @group ContentHandler
 * @group Database
 *        ^--- needed, because we do need the database to test link updates
 * @covers \MediaWiki\Content\JavaScriptContentHandler
 */
class JavaScriptContentHandlerIntegrationTest extends TextContentHandlerIntegrationTest {
	public static function provideGetParserOutput() {
		yield 'Basic render' => [
			'title' => 'MediaWiki:Test.js',
			'model' => null,
			'text' => "hello <world>\n",
			'expectedHtml' => "<pre class=\"mw-code mw-js\" dir=\"ltr\">\nhello &lt;world>\n\n</pre>",
			'expectedFields' => [
				'Links' => [
				],
				'Sections' => [
				],
			],
		];
		yield 'Links' => [
			'title' => 'MediaWiki:Test.js',
			'model' => null,
			'text' => "hello(); // [[world]]\n",
			'expectedHtml' => "<pre class=\"mw-code mw-js\" dir=\"ltr\">\nhello(); // [[world]]\n\n</pre>",
			'expectedFields' => [
				'Links' => [
					[ 'World' => 0, ],
				],
				'Sections' => [
				],
			],
		];
		yield 'TOC' => [
			'title' => 'MediaWiki:Test.js',
			'model' => null,
			'text' => "==One==\n<h2>Two</h2>",
			'expectedHtml' => "<pre class=\"mw-code mw-js\" dir=\"ltr\">\n==One==\n&lt;h2>Two&lt;/h2>\n</pre>",
			'expectedFields' => [
				'Links' => [
				],
				# T307691
				'Sections' => [
				],
			],
		];
	}
}
PK       ! h  h    content/FallbackContentTest.phpnu Iw        <?php

use MediaWiki\Content\Content;
use MediaWiki\Content\FallbackContent;
use MediaWiki\Content\FallbackContentHandler;
use MediaWiki\Content\JavaScriptContent;
use MediaWiki\Content\WikitextContent;

/**
 * @group ContentHandler
 * @covers \MediaWiki\Content\FallbackContent
 * @covers \MediaWiki\Content\FallbackContentHandler
 */
class FallbackContentTest extends MediaWikiLangTestCase {

	private const CONTENT_MODEL = 'xyzzy';

	protected function setUp(): void {
		parent::setUp();
		$this->mergeMwGlobalArrayValue(
			'wgContentHandlers',
			[ self::CONTENT_MODEL => FallbackContentHandler::class ]
		);
	}

	/**
	 * @param string $data
	 * @param string $type
	 *
	 * @return FallbackContent
	 */
	public function newContent( $data, $type = self::CONTENT_MODEL ) {
		return new FallbackContent( $data, $type );
	}

	public function testGetRedirectTarget() {
		$content = $this->newContent( '#REDIRECT [[Horkyporky]]' );
		$this->assertNull( $content->getRedirectTarget() );
	}

	public function testIsRedirect() {
		$content = $this->newContent( '#REDIRECT [[Horkyporky]]' );
		$this->assertFalse( $content->isRedirect() );
	}

	public function testIsCountable() {
		$content = $this->newContent( '[[Horkyporky]]' );
		$this->assertFalse( $content->isCountable( true ) );
	}

	public function testGetTextForSummary() {
		$content = $this->newContent( 'Horkyporky' );
		$this->assertSame( '', $content->getTextForSummary() );
	}

	public function testGetTextForSearchIndex() {
		$content = $this->newContent( 'Horkyporky' );
		$this->assertSame( '', $content->getTextForSearchIndex() );
	}

	public function testCopy() {
		$content = $this->newContent( 'hello world.' );
		$copy = $content->copy();

		$this->assertSame( $content, $copy );
	}

	public function testGetSize() {
		$content = $this->newContent( 'hello world.' );

		$this->assertEquals( 12, $content->getSize() );
	}

	public function testGetData() {
		$content = $this->newContent( 'hello world.' );

		$this->assertEquals( 'hello world.', $content->getData() );
	}

	public function testGetNativeData() {
		$content = $this->newContent( 'hello world.' );

		$this->assertEquals( 'hello world.', $content->getNativeData() );
	}

	public function testGetWikitextForTransclusion() {
		$content = $this->newContent( 'hello world.' );

		$this->assertFalse( $content->getWikitextForTransclusion() );
	}

	public function testGetModel() {
		$content = $this->newContent( "hello world.", 'horkyporky' );

		$this->assertEquals( 'horkyporky', $content->getModel() );
	}

	public function testGetContentHandler() {
		$this->mergeMwGlobalArrayValue(
			'wgContentHandlers',
			[ 'horkyporky' => FallbackContentHandler::class ]
		);

		$content = $this->newContent( "hello world.", 'horkyporky' );

		$this->assertInstanceOf( FallbackContentHandler::class, $content->getContentHandler() );
		$this->assertEquals( 'horkyporky', $content->getContentHandler()->getModelID() );
	}

	public static function dataIsEmpty() {
		return [
			[ '', true ],
			[ '  ', false ],
			[ '0', false ],
			[ 'hallo welt.', false ],
		];
	}

	/**
	 * @dataProvider dataIsEmpty
	 */
	public function testIsEmpty( $text, $empty ) {
		$content = $this->newContent( $text );

		$this->assertEquals( $empty, $content->isEmpty() );
	}

	public static function provideEquals() {
		return [
			[ new FallbackContent( "hallo", 'horky' ), null, false ],
			[ new FallbackContent( "hallo", 'horky' ), new FallbackContent( "hallo", 'horky' ), true ],
			[ new FallbackContent( "hallo", 'horky' ), new FallbackContent( "hallo", 'xyzzy' ), false ],
			[ new FallbackContent( "hallo", 'horky' ), new JavaScriptContent( "hallo" ), false ],
			[ new FallbackContent( "hallo", 'horky' ), new WikitextContent( "hallo" ), false ],
		];
	}

	/**
	 * @dataProvider provideEquals
	 */
	public function testEquals( Content $a, ?Content $b = null, $equal = false ) {
		$this->assertEquals( $equal, $a->equals( $b ) );
	}

	public static function provideConvert() {
		return [
			[ // #0
				'Hallo Welt',
				CONTENT_MODEL_WIKITEXT,
				'lossless',
				'Hallo Welt'
			],
			[ // #1
				'Hallo Welt',
				CONTENT_MODEL_WIKITEXT,
				'lossless',
				'Hallo Welt'
			],
			[ // #1
				'Hallo Welt',
				CONTENT_MODEL_CSS,
				'lossless',
				'Hallo Welt'
			],
			[ // #1
				'Hallo Welt',
				CONTENT_MODEL_JAVASCRIPT,
				'lossless',
				'Hallo Welt'
			],
		];
	}

	public function testConvert() {
		$content = $this->newContent( 'More horkyporky?' );

		$this->assertFalse( $content->convert( CONTENT_MODEL_TEXT ) );
	}

	public function testSerialize() {
		$content = $this->newContent( 'Hörkypörky', 'horkyporky' );

		$this->assertSame( 'Hörkypörky', $content->serialize() );
	}

}
PK       ! w    !  content/WikitextStructureTest.phpnu Iw        <?php

use MediaWiki\Content\WikitextContent;
use MediaWiki\Content\WikiTextStructure;
use MediaWiki\Title\Title;

/**
 * @group Database
 * @covers \MediaWiki\Content\WikiTextStructure
 */
class WikitextStructureTest extends MediaWikiLangTestCase {

	/**
	 * Get WikitextStructure for given text
	 * @param string $text
	 * @return WikiTextStructure
	 */
	private function getStructure( $text ) {
		$content = new WikitextContent( $text );
		$contentRenderer = $this->getServiceContainer()->getContentRenderer();
		$parserOutput = $contentRenderer->getParserOutput( $content, Title::makeTitle( NS_MAIN, 'TestTitle' ) );
		return new WikiTextStructure( $parserOutput );
	}

	public function testHeadings() {
		$text = <<<END
Some text here
== Heading one ==
Some text
==== heading two ====
More text
=== Applicability of the strict mass-energy equivalence formula, ''E'' = ''mc''<sup>2</sup> ===
and more text
== Wikitext '''in''' [[Heading]] and also <b>html</b> ==
more text
==== See also ====
* Also things to see!
END;
		$struct = $this->getStructure( $text );
		$headings = $struct->headings();
		$this->assertCount( 4, $headings );
		$this->assertContains( "Heading one", $headings );
		$this->assertContains( "heading two", $headings );
		$this->assertContains( "Applicability of the strict mass-energy equivalence formula, E = mc2",
			$headings );
		$this->assertContains( "Wikitext in Heading and also html", $headings );
	}

	public function testDefaultSort() {
		$text = <<<END
Louise Michel
== Heading one ==
Some text
==== See also ====
* Also things to see!
{{DEFAULTSORT:Michel, Louise}}
END;
		$struct = $this->getStructure( $text );
		$this->assertEquals( "Michel, Louise", $struct->getDefaultSort() );
	}

	public function testHeadingsFirst() {
		$text = <<<END
== Heading one ==
Some text
==== heading two ====
END;
		$struct = $this->getStructure( $text );
		$headings = $struct->headings();
		$this->assertCount( 2, $headings );
		$this->assertContains( "Heading one", $headings );
		$this->assertContains( "heading two", $headings );
	}

	public function testHeadingsNone() {
		$text = "This text is completely devoid of headings.";
		$struct = $this->getStructure( $text );
		$headings = $struct->headings();
		$this->assertArrayEquals( [], $headings );
	}

	public function testTexts() {
		$text = <<<END
Opening text is opening.
<h2 class="hello">Then comes header</h2>
Then we got more<br>text
=== And more headers ===
{| class="wikitable"
|-
! Header table
|-
| row in table
|-
| another row in table
|}
END;
		$struct = $this->getStructure( $text );
		$this->assertEquals( "Opening text is opening.", $struct->getOpeningText() );
		$this->assertEquals( "Opening text is opening. Then we got more text",
			$struct->getMainText() );
		$this->assertEquals( [ "Header table row in table another row in table" ],
			$struct->getAuxiliaryText() );
	}

	public function testPreservesWordSpacing() {
		$text = "<dd><dl>foo</dl><dl>bar</dl></dd><p>baz</p>";
		$struct = $this->getStructure( $text );
		$this->assertEquals( "foo bar baz", $struct->getMainText() );
	}
}
PK       ! V=    !  content/JavaScriptContentTest.phpnu Iw        <?php

use MediaWiki\Content\CssContent;
use MediaWiki\Content\JavaScriptContent;
use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;

/**
 * Needs database to do link updates.
 *
 * @group ContentHandler
 * @group Database
 * @covers \MediaWiki\Content\JavaScriptContent
 */
class JavaScriptContentTest extends TextContentTest {

	public function newContent( $text ) {
		return new JavaScriptContent( $text );
	}

	public function testAddSectionHeader() {
		$content = $this->newContent( 'hello world' );
		$c = $content->addSectionHeader( 'test' );

		$this->assertTrue( $content->equals( $c ) );
	}

	// XXX: currently, preSaveTransform is applied to scripts. this may change or become optional.
	public static function dataPreSaveTransform() {
		return [
			[ 'hello this is ~~~',
				"hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]",
			],
			[ 'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
				'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
			],
			[ " Foo \n ",
				" Foo",
			],
		];
	}

	public static function dataGetRedirectTarget() {
		return [
			[ '#REDIRECT [[Test]]',
				null,
			],
			[ '#REDIRECT Test',
				null,
			],
			[ '* #REDIRECT [[Test]]',
				null,
			],
		];
	}

	public static function dataIsCountable() {
		return [
			[ '',
				null,
				'any',
				true
			],
			[ 'Foo',
				null,
				'any',
				true
			],
			[ 'Foo',
				null,
				'link',
				false
			],
			[ 'Foo [[bar]]',
				null,
				'link',
				false
			],
			[ 'Foo',
				true,
				'link',
				false
			],
			[ 'Foo [[bar]]',
				false,
				'link',
				false
			],
			[ '#REDIRECT [[bar]]',
				true,
				'any',
				true
			],
			[ '#REDIRECT [[bar]]',
				true,
				'link',
				false
			],
		];
	}

	public static function dataGetTextForSummary() {
		return [
			[ "hello\nworld.",
				16,
				'hello world.',
			],
			[ 'hello world.',
				8,
				'hello...',
			],
			[ '[[hello world]].',
				8,
				'[[hel...',
			],
		];
	}

	public function testMatchMagicWord() {
		$mw = $this->getServiceContainer()->getMagicWordFactory()->get( "staticredirect" );

		$content = $this->newContent( "#REDIRECT [[FOO]]\n__STATICREDIRECT__" );
		$this->assertFalse(
			$content->matchMagicWord( $mw ),
			"should not have matched magic word, since it's not wikitext"
		);
	}

	/**
	 * @dataProvider provideUpdateRedirect
	 */
	public function testUpdateRedirect( $oldText, $expectedText ) {
		$this->overrideConfigValues( [
			MainConfigNames::Server => '//example.org',
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::ResourceBasePath => '/w',
		] );
		$target = Title::makeTitle( NS_MAIN, 'TestUpdateRedirect_target' );

		$content = new JavaScriptContent( $oldText );
		$newContent = $content->updateRedirect( $target );

		$this->assertEquals( $expectedText, $newContent->getText() );
	}

	public static function provideUpdateRedirect() {
		return [
			[
				'#REDIRECT [[Someplace]]',
				'#REDIRECT [[Someplace]]',
			],
			[
				'/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=MediaWiki:MonoBook.js&action=raw&ctype=text/javascript");',
				'/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=TestUpdateRedirect_target&action=raw&ctype=text/javascript");'
			]
		];
	}

	public function testGetModel() {
		$content = $this->newContent( "hello world." );

		$this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $content->getModel() );
	}

	public function testGetContentHandler() {
		$content = $this->newContent( "hello world." );

		$this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $content->getContentHandler()->getModelID() );
	}

	// NOTE: Overridden by subclass!
	public static function dataEquals() {
		return [
			[ new JavaScriptContent( "hallo" ), null, false ],
			[ new JavaScriptContent( "hallo" ), new JavaScriptContent( "hallo" ), true ],
			[ new JavaScriptContent( "hallo" ), new CssContent( "hallo" ), false ],
			[ new JavaScriptContent( "hallo" ), new JavaScriptContent( "HALLO" ), false ],
		];
	}

	/**
	 * @dataProvider provideGetRedirectTarget
	 */
	public function testGetRedirectTarget( $title, $text ) {
		$this->overrideConfigValues( [
			MainConfigNames::Server => '//example.org',
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::ResourceBasePath => '/w',
		] );
		$content = new JavaScriptContent( $text );
		$target = $content->getRedirectTarget();
		$this->assertEquals( $title, $target ? $target->getPrefixedText() : null );
	}

	public static function provideGetRedirectTarget() {
		// Roundtrip testing
		yield from JavaScriptContentHandlerTest::provideMakeRedirectContent();

		// Additional cases that don't roundtrip (errors, and back-compat)
		yield 'Missing #REDIRECT comment' => [
			null,
			'mw.loader.load("//example.org/w/index.php?title=MediaWiki:NoRedirect.js&action=raw&ctype=text/javascript");'
		];
		yield 'Different domain' => [
			null,
			'/* #REDIRECT */mw.loader.load("//example.com/w/index.php?title=MediaWiki:OtherWiki.js&action=raw&ctype=text/javascript");'
		];
		yield 'Encoding before MW 1.42 (T107289)' => [
			// \u0026 instead of literal &
			'MediaWiki:MonoBook.js',
			'/* #REDIRECT */mw.loader.load("//example.org/w/index.php?title=MediaWiki:MonoBook.js\u0026action=raw\u0026ctype=text/javascript");'
		];
	}
}
PK       ! d];/	  	  &  content/FallbackContentHandlerTest.phpnu Iw        <?php

use MediaWiki\Content\FallbackContent;
use MediaWiki\Content\FallbackContentHandler;
use MediaWiki\Context\RequestContext;
use MediaWiki\Parser\ParserObserver;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Title\Title;

/**
 * See also unit tests at \MediaWiki\Tests\Unit\FallbackContentHandlerTest
 *
 * @group ContentHandler
 * @covers \MediaWiki\Content\FallbackContentHandler
 * @covers \MediaWiki\Content\ContentHandler
 */
class FallbackContentHandlerTest extends MediaWikiLangTestCase {

	private const CONTENT_MODEL = 'xyzzy';

	protected function setUp(): void {
		parent::setUp();
		$this->mergeMwGlobalArrayValue(
			'wgContentHandlers',
			[ self::CONTENT_MODEL => FallbackContentHandler::class ]
		);
		$this->setService( '_ParserObserver', $this->createMock( ParserObserver::class ) );
	}

	private function newContent( string $data, string $type = self::CONTENT_MODEL ) {
		return new FallbackContent( $data, $type );
	}

	public function testGetSlotDiffRenderer() {
		$context = new RequestContext();
		$context->setRequest( new FauxRequest() );

		$handler = new FallbackContentHandler( 'horkyporky' );
		$this->hideDeprecated( 'ContentHandler::getSlotDiffRendererInternal' );
		$slotDiffRenderer = $handler->getSlotDiffRenderer( $context );

		$oldContent = $handler->unserializeContent( 'Foo' );
		$newContent = $handler->unserializeContent( 'Foo bar' );

		$diff = $slotDiffRenderer->getDiff( $oldContent, $newContent );
		$this->assertNotEmpty( $diff );
	}

	public function testGetParserOutput() {
		$this->setUserLang( 'en' );
		$this->setContentLang( 'qqx' );

		$title = $this->createMock( Title::class );
		$title->method( 'getPageLanguage' )
			->willReturn( $this->getServiceContainer()->getContentLanguage() );

		$content = $this->newContent( 'Horkyporky' );
		$contentRenderer = $this->getServiceContainer()->getContentRenderer();
		$opts = ParserOptions::newFromAnon();
		// TODO T371004
		$po = $contentRenderer->getParserOutput( $content, $title, null, $opts );
		$html = $po->runOutputPipeline( $opts, [] )->getContentHolderText();
		$html = preg_replace( '#<!--.*?-->#sm', '', $html ); // strip comments

		$this->assertStringNotContainsString( 'Horkyporky', $html );
		$this->assertStringNotContainsString( '(unsupported-content-model)', $html );
	}
}
PK       ! q    ,  content/CssContentHandlerIntegrationTest.phpnu Iw        <?php

/**
 * @group ContentHandler
 * @group Database
 *        ^--- needed, because we do need the database to test link updates
 * @covers \MediaWiki\Content\CssContentHandler
 */
class CssContentHandlerIntegrationTest extends TextContentHandlerIntegrationTest {
	public static function provideGetParserOutput() {
		yield 'Basic render' => [
			'title' => 'MediaWiki:Test.css',
			'model' => null,
			'text' => "hello <world>x\n",
			'expectedHtml' => "<pre class=\"mw-code mw-css\" dir=\"ltr\">\nhello &lt;world>x\n\n</pre>",
			'expectedFields' => [
				'Links' => [
				],
				'Sections' => [
				],
			],
		];
		yield 'Links' => [
			'title' => 'MediaWiki:Test.css',
			'model' => null,
			'text' => "/* hello [[world]] */\n",
			'expectedHtml' => "<pre class=\"mw-code mw-css\" dir=\"ltr\">\n/* hello [[world]] */\n\n</pre>",
			'expectedFields' => [
				'Links' => [
					[ 'World' => 0, ],
				],
				'Sections' => [
				],
			],
		];
		yield 'TOC' => [
			'title' => 'MediaWiki:Test.css',
			'model' => null,
			'text' => "==One==\n<h2>Two</h2>",
			'expectedHtml' => "<pre class=\"mw-code mw-css\" dir=\"ltr\">\n==One==\n&lt;h2>Two&lt;/h2>\n</pre>",
			'expectedFields' => [
				'Links' => [
				],
				# T307691
				'Sections' => [
				],
			]
		];
	}
}
PK       ! q)  )    TestUser.phpnu Iw        <?php

use MediaWiki\MediaWikiServices;
use MediaWiki\Permissions\Authority;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;

/**
 * Wraps the user object, so we can also retain full access to properties
 * like password if we log in via the API.
 */
class TestUser {
	/**
	 * @var string
	 */
	private $username;

	/**
	 * @var string
	 */
	private $password;

	/**
	 * @var User
	 */
	private $user;

	private function assertNotReal() {
		global $wgDBprefix;
		if (
			$wgDBprefix !== MediaWikiIntegrationTestCase::DB_PREFIX &&
			$wgDBprefix !== ParserTestRunner::DB_PREFIX
		) {
			throw new RuntimeException( "Can't create user on real database" );
		}
	}

	public function __construct( $username, $realname = 'Real Name',
		$email = 'sample@example.com', $groups = []
	) {
		$this->assertNotReal();

		$this->username = $username;
		$this->password = 'TestUser';

		$this->user = User::newFromName( $this->username );
		$this->user->load();

		// In an ideal world we'd have a new wiki (or mock data store) for every single test.
		// But for now, we just need to create or update the user with the desired properties.
		// we particularly need the new password, since we just generated it randomly.
		// In core MediaWiki, there is no functionality to delete users, so this is the best we can do.
		if ( !$this->user->isRegistered() ) {
			// create the user
			$this->user = User::createNew(
				$this->username, [
					"email" => $email,
					"real_name" => $realname
				]
			);

			if ( !$this->user ) {
				throw new RuntimeException( "Error creating TestUser " . $username );
			}
		}

		// Update the user to use the password and other details
		$this->setPassword( $this->password );
		$change = $this->setEmail( $email ) ||
			$this->setRealName( $realname );

		// Adjust groups by adding any missing ones and removing any extras
		$userGroupManager = MediaWikiServices::getInstance()->getUserGroupManager();
		$currentGroups = $userGroupManager->getUserGroups( $this->user );
		$userGroupManager->addUserToMultipleGroups( $this->user, array_diff( $groups, $currentGroups ) );
		foreach ( array_diff( $currentGroups, $groups ) as $group ) {
			$userGroupManager->removeUserFromGroup( $this->user, $group );
		}
		if ( $change ) {
			// Disable CAS check before saving. The User object may have been initialized from cached
			// information that may be out of whack with the database during testing. If tests were
			// perfectly isolated, this would not happen. But if it does happen, let's just ignore the
			// inconsistency, and just write the data we want - during testing, we are not worried
			// about data loss.
			$this->user->mTouched = '';
			$this->user->saveSettings();
		}
	}

	/**
	 * @param string $realname
	 * @return bool
	 */
	private function setRealName( $realname ) {
		if ( $this->user->getRealName() !== $realname ) {
			$this->user->setRealName( $realname );
			return true;
		}

		return false;
	}

	/**
	 * @param string $email
	 * @return bool
	 */
	private function setEmail( string $email ) {
		if ( $this->user->getEmail() !== $email ) {
			$this->user->setEmail( $email );
			return true;
		}

		return false;
	}

	/**
	 * @param string $password
	 */
	private function setPassword( $password ) {
		self::setPasswordForUser( $this->user, $password );
	}

	/**
	 * Set the password on a testing user
	 *
	 * This assumes we're still using the generic AuthManager config from
	 * PHPUnitMaintClass::finalSetup(), and just sets the password in the
	 * database directly.
	 * @param User $user
	 * @param string $password
	 */
	public static function setPasswordForUser( User $user, $password ) {
		if ( !$user->getId() ) {
			throw new InvalidArgumentException( "Passed User has not been added to the database yet!" );
		}

		$services = MediaWikiServices::getInstance();

		$dbw = $services->getConnectionProvider()->getPrimaryDatabase();
		$row = $dbw->newSelectQueryBuilder()
			->select( [ 'user_password' ] )
			->from( 'user' )
			->where( [ 'user_id' => $user->getId() ] )
			->caller( __METHOD__ )->fetchRow();
		if ( !$row ) {
			throw new RuntimeException( "Passed User has an ID but is not in the database?" );
		}

		$passwordFactory = $services->getPasswordFactory();
		if ( !$passwordFactory->newFromCiphertext( $row->user_password )->verify( $password ) ) {
			$passwordHash = $passwordFactory->newFromPlaintext( $password );
			$dbw->newUpdateQueryBuilder()
				->update( 'user' )
				->set( [ 'user_password' => $passwordHash->toString() ] )
				->where( [ 'user_id' => $user->getId() ] )
				->caller( __METHOD__ )->execute();
		}
	}

	/**
	 * @since 1.25
	 * @return User
	 */
	public function getUser() {
		return $this->user;
	}

	/**
	 * @since 1.39
	 * @return Authority
	 */
	public function getAuthority(): Authority {
		return $this->user;
	}

	/**
	 * @since 1.36
	 * @return UserIdentity
	 */
	public function getUserIdentity(): UserIdentity {
		return new UserIdentityValue( $this->user->getId(), $this->user->getName() );
	}

	/**
	 * @since 1.25
	 * @return string
	 */
	public function getPassword() {
		return $this->password;
	}
}
PK       ! gn    0  OutputTransform/OutputTransformStageTestBase.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform;

use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\OutputTransform\OutputTransformStage;
use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter;
use MediaWikiIntegrationTestCase;

abstract class OutputTransformStageTestBase extends MediaWikiIntegrationTestCase {
	abstract public function createStage(): OutputTransformStage;

	abstract public function provideShouldRun(): iterable;

	abstract public function provideShouldNotRun(): iterable;

	abstract public function provideTransform(): iterable;

	/**
	 * @dataProvider provideShouldRun
	 */
	public function testShouldRun( $parserOutput, $parserOptions, $options ) {
		$stage = $this->createStage();
		$this->assertTrue( $stage->shouldRun( $parserOutput, $parserOptions, $options ) );
	}

	public function setUp(): void {
		RequestContext::resetMain();
		$this->overrideConfigValues( [
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::Server => '//TEST_SERVER',
			MainConfigNames::DefaultSkin => 'fallback'
		] );
	}

	/**
	 * @dataProvider provideShouldNotRun
	 */
	public function testShouldNotRun( $parserOutput, $parserOptions, $options ) {
		$stage = $this->createStage();
		$this->assertFalse( $stage->shouldRun( $parserOutput, $parserOptions, $options ) );
	}

	/**
	 * @dataProvider provideTransform
	 */
	public function testTransform( $parserOutput, $parserOptions, $options, $expected, $message = '' ) {
		$stage = $this->createStage();
		$result = $stage->transform( $parserOutput, $parserOptions, $options );
		// If this has Parsoid internal metadata, clear it in both the expected
		// value and the result; these are internal implementation details
		// that shouldn't be hardwired into tests.
		if ( PageBundleParserOutputConverter::hasPageBundle( $result ) ) {
			$key = PageBundleParserOutputConverter::PARSOID_PAGE_BUNDLE_KEY;
			$expected->setExtensionData( $key, $result->getExtensionData( $key ) );
		}
		// Similarly, clear the parse start time to avoid a spurious diff.
		$result->clearParseStartTime();
		$expected->clearParseStartTime();
		$this->assertEquals( $expected, $result, $message );
	}
}
PK       ! ,;U  U  4  OutputTransform/DefaultOutputPipelineFactoryTest.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform;

use LogicException;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Parser\ParserOutput;
use MediaWikiLangTestCase;

/**
 * @covers \MediaWiki\OutputTransform\DefaultOutputPipelineFactory
 * The tests in this file are copied from the tests in ParserOutputTest. They aim at being the sole version
 * once we deprecate ParserOutput::getText. Some of them have been moved to their specific pipeline stage instead.
 * @group Database
 *        ^--- trigger DB shadowing because we are using Title magic
 */
class DefaultOutputPipelineFactoryTest extends MediaWikiLangTestCase {

	/**
	 * @covers \MediaWiki\OutputTransform\DefaultOutputPipelineFactory::buildPipeline
	 * @dataProvider provideTransform
	 * @param array $options Options to transform()
	 * @param string $text Parser text
	 * @param string $expect Expected output
	 */
	public function testTransform( $options, $text, $expect ) {
		// Avoid other skins affecting the section edit links
		$this->overrideConfigValue( MainConfigNames::DefaultSkin, 'fallback' );
		RequestContext::resetMain();

		$this->overrideConfigValues( [
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::ParserEnableLegacyHeadingDOM => false,
		] );

		$po = new ParserOutput( $text );
		TestUtils::initSections( $po );
		$actual = $this->getServiceContainer()->getDefaultOutputPipeline()
			->run( $po, null, $options )->getContentHolderText();
		$this->assertSame( $expect, $actual );
	}

	public static function provideTransform() {
		return [
			'No options' => [
				[], TestUtils::TEST_DOC, <<<EOF
<p>Test document.
</p>
<div id="toc" class="toc" role="navigation" aria-labelledby="mw-toc-heading"><input type="checkbox" role="button" id="toctogglecheckbox" class="toctogglecheckbox" style="display:none" /><div class="toctitle" lang="en" dir="ltr"><h2 id="mw-toc-heading">Contents</h2><span class="toctogglespan"><label class="toctogglelabel" for="toctogglecheckbox"></label></span></div>
<ul>
<li class="toclevel-1 tocsection-1"><a href="#Section_1"><span class="tocnumber">1</span> <span class="toctext">Section 1</span></a></li>
<li class="toclevel-1 tocsection-2"><a href="#Section_2"><span class="tocnumber">2</span> <span class="toctext">Section 2</span></a>
<ul>
<li class="toclevel-2 tocsection-3"><a href="#Section_2.1"><span class="tocnumber">2.1</span> <span class="toctext">Section 2.1</span></a></li>
</ul>
</li>
<li class="toclevel-1 tocsection-4"><a href="#Section_3"><span class="tocnumber">3</span> <span class="toctext">Section 3</span></a></li>
</ul>
</div>

<div class="mw-heading mw-heading2"><h2 id="Section_1">Section 1</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=1" title="Edit section: Section 1">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>One
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_2">Section 2</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=2" title="Edit section: Section 2">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>Two
</p>
<div class="mw-heading mw-heading3"><h3 id="Section_2.1">Section 2.1</h3></div>
<p>Two point one
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_3">Section 3</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=4" title="Edit section: Section 3">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>Three
</p>
EOF
			],
			'Disable section edit links' => [
				[ 'enableSectionEditLinks' => false ], TestUtils::TEST_DOC, <<<EOF
<p>Test document.
</p>
<div id="toc" class="toc" role="navigation" aria-labelledby="mw-toc-heading"><input type="checkbox" role="button" id="toctogglecheckbox" class="toctogglecheckbox" style="display:none" /><div class="toctitle" lang="en" dir="ltr"><h2 id="mw-toc-heading">Contents</h2><span class="toctogglespan"><label class="toctogglelabel" for="toctogglecheckbox"></label></span></div>
<ul>
<li class="toclevel-1 tocsection-1"><a href="#Section_1"><span class="tocnumber">1</span> <span class="toctext">Section 1</span></a></li>
<li class="toclevel-1 tocsection-2"><a href="#Section_2"><span class="tocnumber">2</span> <span class="toctext">Section 2</span></a>
<ul>
<li class="toclevel-2 tocsection-3"><a href="#Section_2.1"><span class="tocnumber">2.1</span> <span class="toctext">Section 2.1</span></a></li>
</ul>
</li>
<li class="toclevel-1 tocsection-4"><a href="#Section_3"><span class="tocnumber">3</span> <span class="toctext">Section 3</span></a></li>
</ul>
</div>

<div class="mw-heading mw-heading2"><h2 id="Section_1">Section 1</h2></div>
<p>One
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_2">Section 2</h2></div>
<p>Two
</p>
<div class="mw-heading mw-heading3"><h3 id="Section_2.1">Section 2.1</h3></div>
<p>Two point one
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_3">Section 3</h2></div>
<p>Three
</p>
EOF
			],
			'Disable TOC, but wrap' => [
				[ 'allowTOC' => false, 'wrapperDivClass' => 'mw-parser-output' ], TestUtils::TEST_DOC, <<<EOF
<div class="mw-content-ltr mw-parser-output" lang="en" dir="ltr"><p>Test document.
</p>

<div class="mw-heading mw-heading2"><h2 id="Section_1">Section 1</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=1" title="Edit section: Section 1">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>One
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_2">Section 2</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=2" title="Edit section: Section 2">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>Two
</p>
<div class="mw-heading mw-heading3"><h3 id="Section_2.1">Section 2.1</h3></div>
<p>Two point one
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_3">Section 3</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=4" title="Edit section: Section 3">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>Three
</p></div>
EOF
			],
			'Style deduplication disabled' => [
				[ 'deduplicateStyles' => false ], TestUtils::TEST_TO_DEDUP, TestUtils::TEST_TO_DEDUP
			],
		];
		// phpcs:enable
	}

	/**
	 * @covers \MediaWiki\OutputTransform\DefaultOutputPipelineFactory::buildPipeline
	 */
	public function testTransform_failsIfNoText() {
		$po = new ParserOutput( null );

		$this->expectException( LogicException::class );
		$this->getServiceContainer()->getDefaultOutputPipeline()
			->run( $po, null, [] );
	}
}
PK       ! q      OutputTransform/TestUtils.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform;

use MediaWiki\Parser\ParserOutput;
use Wikimedia\Parsoid\Core\SectionMetadata;
use Wikimedia\Parsoid\Core\TOCData;

/**
 * Consts and utils used for OutputTransform tests
 */
class TestUtils {
	public const TEST_DOC = <<<HTML
<p>Test document.
</p>
<meta property="mw:PageProp/toc" />
<h2 data-mw-anchor="Section_1">Section 1<mw:editsection page="Test Page" section="1">Section 1</mw:editsection></h2>
<p>One
</p>
<h2 data-mw-anchor="Section_2">Section 2<mw:editsection page="Test Page" section="2">Section 2</mw:editsection></h2>
<p>Two
</p>
<h3 data-mw-anchor="Section_2.1">Section 2.1</h3>
<p>Two point one
</p>
<h2 data-mw-anchor="Section_3">Section 3<mw:editsection page="Test Page" section="4">Section 3</mw:editsection></h2>
<p>Three
</p>
HTML;
	public const TEST_DOC_WITH_LINKS_LEGACY_MARKUP = <<<HTML
<p>Test document.
</p>
<meta property="mw:PageProp/toc" />
<h2><span class="mw-headline" id="Section_1">Section 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=1" title="Edit section: Section 1">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
<p>One
</p>
<h2><span class="mw-headline" id="Section_2">Section 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=2" title="Edit section: Section 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
<p>Two
</p>
<h3><span class="mw-headline" id="Section_2.1">Section 2.1</span></h3>
<p>Two point one
</p>
<h2><span class="mw-headline" id="Section_3">Section 3</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=4" title="Edit section: Section 3">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
<p>Three
</p>
HTML;
	public const TEST_DOC_WITH_LINKS_NEW_MARKUP = <<<HTML
<p>Test document.
</p>
<meta property="mw:PageProp/toc" />
<div class="mw-heading mw-heading2"><h2 id="Section_1">Section 1</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=1" title="Edit section: Section 1">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>One
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_2">Section 2</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=2" title="Edit section: Section 2">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>Two
</p>
<div class="mw-heading mw-heading3"><h3 id="Section_2.1">Section 2.1</h3></div>
<p>Two point one
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_3">Section 3</h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=4" title="Edit section: Section 3">edit</a><span class="mw-editsection-bracket">]</span></span></div>
<p>Three
</p>
HTML;
	public const TEST_DOC_WITHOUT_LINKS_LEGACY_MARKUP = <<<HTML
<p>Test document.
</p>
<meta property="mw:PageProp/toc" />
<h2><span class="mw-headline" id="Section_1">Section 1</span></h2>
<p>One
</p>
<h2><span class="mw-headline" id="Section_2">Section 2</span></h2>
<p>Two
</p>
<h3><span class="mw-headline" id="Section_2.1">Section 2.1</span></h3>
<p>Two point one
</p>
<h2><span class="mw-headline" id="Section_3">Section 3</span></h2>
<p>Three
</p>
HTML;
	public const TEST_DOC_WITHOUT_LINKS_NEW_MARKUP = <<<HTML
<p>Test document.
</p>
<meta property="mw:PageProp/toc" />
<div class="mw-heading mw-heading2"><h2 id="Section_1">Section 1</h2></div>
<p>One
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_2">Section 2</h2></div>
<p>Two
</p>
<div class="mw-heading mw-heading3"><h3 id="Section_2.1">Section 2.1</h3></div>
<p>Two point one
</p>
<div class="mw-heading mw-heading2"><h2 id="Section_3">Section 3</h2></div>
<p>Three
</p>
HTML;

	// In this test, the `>` is not escaped in the 'data-mw-anchor' attribute and tag content, which
	// is allowed in HTML, but it's not the serialization used by the Parser. Extensions that modify
	// the HTML, e.g. DiscussionTools, can cause this to appear.
	public const TEST_DOC_ANGLE_BRACKETS = <<<HTML
<h2 data-mw-anchor=">">><mw:editsection page="Test Page" section="1">></mw:editsection></h2>
HTML;
	public const TEST_DOC_ANGLE_BRACKETS_WITH_LINKS_LEGACY_MARKUP = <<<HTML
<h2><span class="mw-headline" id="&gt;">></span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=1" title="Edit section: &gt;">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
HTML;
	public const TEST_DOC_ANGLE_BRACKETS_WITH_LINKS_NEW_MARKUP = <<<HTML
<div class="mw-heading mw-heading2"><h2 id="&gt;">></h2><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=1" title="Edit section: &gt;">edit</a><span class="mw-editsection-bracket">]</span></span></div>
HTML;
	public const TEST_DOC_ANGLE_BRACKETS_WITHOUT_LINKS_LEGACY_MARKUP = <<<HTML
<h2><span class="mw-headline" id="&gt;">></span></h2>
HTML;
	public const TEST_DOC_ANGLE_BRACKETS_WITHOUT_LINKS_NEW_MARKUP = <<<HTML
<div class="mw-heading mw-heading2"><h2 id="&gt;">></h2></div>
HTML;

	public const TEST_TO_DEDUP = <<<HTML
<p>This is a test document.</p>
<style data-mw-deduplicate="duplicate1">.Duplicate1 {}</style>
<style data-mw-deduplicate="duplicate1">.Duplicate1 {}</style>
<style data-mw-deduplicate="duplicate2">.Duplicate2 {}</style>
<style data-mw-deduplicate="duplicate1">.Duplicate1 {}</style>
<style data-mw-deduplicate="duplicate2">.Duplicate2 {}</style>
<style data-mw-not-deduplicate="duplicate1">.Duplicate1 {}</style>
<style data-mw-deduplicate="duplicate1">.Same-attribute-different-content {}</style>
<style data-mw-deduplicate="duplicate3">.Duplicate1 {}</style>
<style>.Duplicate1 {}</style>
HTML;

	public static function initSections( ParserOutput $po ): void {
		$po->setTOCData( new TOCData( SectionMetadata::fromLegacy( [
			'index' => "1",
			'level' => 1,
			'toclevel' => 1,
			'number' => "1",
			'line' => "Section 1",
			'anchor' => "Section_1"
		] ), SectionMetadata::fromLegacy( [
			'index' => "2",
			'level' => 1,
			'toclevel' => 1,
			'number' => "2",
			'line' => "Section 2",
			'anchor' => "Section_2"
		] ), SectionMetadata::fromLegacy( [
			'index' => "3",
			'level' => 2,
			'toclevel' => 2,
			'number' => "2.1",
			'line' => "Section 2.1",
			'anchor' => "Section_2.1"
		] ), SectionMetadata::fromLegacy( [
			'index' => "4",
			'level' => 1,
			'toclevel' => 1,
			'number' => "3",
			'line' => "Section 3",
			'anchor' => "Section_3"
		] ), ) );
	}
}
PK       ! ӂ    0  OutputTransform/ContentDOMTransformStageTest.phpnu Iw        <?php

namespace MediaWiki\OutputTransform;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\MediaWikiServices;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter;
use MediaWiki\Tests\OutputTransform\DummyDOMTransformStage;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Wikimedia\Parsoid\Core\PageBundle;

class ContentDOMTransformStageTest extends TestCase {

	public function createStage(): ContentDOMTransformStage {
		return new DummyDOMTransformStage(
			new ServiceOptions( [] ),
			new NullLogger()
		);
	}

	/**
	 * Regression test for T365036 - checking that a very basic ParserOutput continues serializing after going
	 * through a ContentDOMTransformStage
	 * @covers \MediaWiki\OutputTransform\ContentDOMTransformStage::transformDOM
	 */
	public function testTransform() {
		$html = "<div>some output</div>";
		$po = new ParserOutput( $html );
		PageBundleParserOutputConverter::applyPageBundleDataToParserOutput( new PageBundle( $html ), $po );
		$transform = $this->createStage();
		$options = [ 'isParsoidContent' => true ];
		$po = $transform->transform( $po, null, $options );
		$json = MediaWikiServices::getInstance()->getJsonCodec()->serialize( $po );
		self::assertStringContainsString( "parsoid-page-bundle", $json );
	}

	/**
	 * @covers \MediaWiki\OutputTransform\ContentDOMTransformStage::parsoidTransform
	 * @covers \MediaWiki\OutputTransform\ContentDOMTransformStage::legacyTransform
	 */
	public function testTransformOption() {
		$html = "<div>some output</div>";
		$po = new ParserOutput( $html );
		$transform = $this->createStage();

		// Legacy, should roundtrip the input
		$options = [ 'isParsoidContent' => false ];
		$po = $transform->transform( $po, null, $options );
		$text = $po->getContentHolderText();
		$this->assertEquals( $html, $text );

		// Parsoid, input is sullied with rich attributes
		$options = [ 'isParsoidContent' => true ];
		$po = $transform->transform( $po, null, $options );
		$text = $po->getContentHolderText();
		$this->assertNotEquals( $html, $text );
		// Without PageBundle data, attributes are inlined
		self::assertStringContainsString( "data-parsoid", $text );
	}

}
PK       ! AGZ    8  OutputTransform/Stages/HandleParsoidSectionLinksTest.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform\Stages;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Language\Language;
use MediaWiki\OutputTransform\OutputTransformStage;
use MediaWiki\OutputTransform\Stages\HandleParsoidSectionLinks;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter;
use MediaWiki\Tests\OutputTransform\OutputTransformStageTestBase;
use Psr\Log\NullLogger;
use Skin;
use Wikimedia\Parsoid\Core\PageBundle;
use Wikimedia\Parsoid\Core\TOCData;

/** @covers \MediaWiki\OutputTransform\Stages\HandleParsoidSectionLinks */
class HandleParsoidSectionLinksTest extends OutputTransformStageTestBase {

	public function createStage(): OutputTransformStage {
		return new HandleParsoidSectionLinks(
			new ServiceOptions( [] ),
			new NullLogger(),
			$this->getServiceContainer()->getTitleFactory()
		);
	}

	public function provideShouldRun(): iterable {
		yield [ new ParserOutput(), null, [ 'isParsoidContent' => true ] ];
	}

	public function provideShouldNotRun(): iterable {
		yield [ new ParserOutput(), null, [ 'isParsoidContent' => false ] ];
	}

	private static function newParserOutput(
		?string $rawText = null,
		?ParserOptions $parserOptions = null,
		?TOCData $toc = null,
		string ...$flags
	) {
		$po = new ParserOutput();
		if ( $rawText !== null ) {
			$po = PageBundleParserOutputConverter::parserOutputFromPageBundle(
				new PageBundle( $rawText )
			);
		}
		if ( $parserOptions !== null ) {
			$po->setFromParserOptions( $parserOptions );
		}
		if ( $toc !== null ) {
			$po->setTOCData( $toc );
		}
		foreach ( $flags as $f ) {
			$po->setOutputFlag( $f );
		}
		return $po;
	}

	public function provideTransform(): iterable {
		$skin = $this->createNoOpMock(
			Skin::class, [ 'getLanguage', 'doEditSectionLink' ]
		);
		$skin->method( 'getLanguage' )->willReturn(
			$this->createNoOpMock( Language::class )
		);
		$skin->method( 'doEditSectionLink' )->willReturn(
			'!<a id="c">edit</a>!'
		);
		$options = [
			'isParsoidContent' => true,
			'enableSectionEditLinks' => true,
			'skin' => $skin,
		];
		$toc = TOCData::fromLegacy( [ [
			'toclevel' => 1,
			'fromtitle' => 'TestTitle',
			'anchor' => 'foo',
		] ] );
		$input = '<section id="a"><h2 id="foo">Foo</h2>Bar</section>';

		$expected = '<section id="a"><div class="mw-heading mw-heading-1" id="mwAQ"><h2 id="foo">Foo</h2></div>Bar</section>';
		yield 'Standard Parsoid output: no links' => [
			self::newParserOutput( $input, null, $toc ),
			null, [ 'enableSectionEditLinks' => false ] + $options,
			self::newParserOutput( $expected, null, $toc )
		];

		$expected = '<section id="a"><div class="mw-heading mw-heading-1" id="mwAQ"><h2 id="foo">Foo</h2>!<a id="c">edit</a>!</div>Bar</section>';
		yield 'Standard Parsoid output: with links' => [
			self::newParserOutput( $input, null, $toc ),
			null, $options,
			self::newParserOutput( $expected, null, $toc )
		];

		// Test collapsible section wrapper (T359001)
		$pOpts = ParserOptions::newFromAnon();
		$pOpts->setCollapsibleSections();
		$expected = '<section id="a"><div class="mw-heading mw-heading-1" id="mwAQ"><h2 id="foo">Foo</h2>!<a id="c">edit</a>!</div><div id="mwAg">Bar</div></section>';
		yield 'Standard Parsoid output: collapsible with links' => [
			self::newParserOutput( $input, $pOpts, $toc ),
			$pOpts, $options,
			self::newParserOutput( $expected, $pOpts, $toc )
		];

		// Test that an existing heading <div> wrapper is reused (T357826)
		$input = '<section id="a"><div class="mw-heading mw-heading2" id="b">prefix<h2 id="foo">Foo</h2>suffix</div>Bar</section>';
		$expected = '<section id="a"><div class="mw-heading mw-heading2" id="b">prefix<h2 id="foo">Foo</h2>!<a id="c">edit</a>!suffix</div>Bar</section>';
		yield 'Output with existing div: with links' => [
			self::newParserOutput( $input, null, $toc ),
			null, $options,
			self::newParserOutput( $expected, null, $toc )
		];

		// Reused <div> plus collapsible sections
		$expected = '<section id="a"><div class="mw-heading mw-heading2" id="b">prefix<h2 id="foo">Foo</h2>!<a id="c">edit</a>!suffix</div><div id="mwAQ">Bar</div></section>';
		yield 'Output with existing div: collapsible with links' => [
			self::newParserOutput( $input, $pOpts, $toc ),
			$pOpts, $options,
			self::newParserOutput( $expected, $pOpts, $toc )
		];
	}
}
PK       ! 3I  I  3  OutputTransform/Stages/ExpandToAbsoluteUrlsTest.phpnu Iw        <?php
namespace MediaWiki\Tests\OutputTransform\Stages;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\OutputTransform\OutputTransformStage;
use MediaWiki\OutputTransform\Stages\ExpandToAbsoluteUrls;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Tests\OutputTransform\OutputTransformStageTestBase;
use Psr\Log\NullLogger;

/**
 * @covers \MediaWiki\OutputTransform\Stages\ExpandToAbsoluteUrls
 */
class ExpandToAbsoluteUrlsTest extends OutputTransformStageTestBase {

	public function createStage(): OutputTransformStage {
		return new ExpandToAbsoluteUrls(
			new ServiceOptions( [] ),
			new NullLogger()
		);
	}

	public function provideShouldRun(): array {
		return [
			[ new ParserOutput(), null, [ 'absoluteURLs' => true ] ],
		];
	}

	public function provideShouldNotRun(): array {
		return [
			[ new ParserOutput(), null, [ 'absoluteURLs' => false ] ],
			[ new ParserOutput(), null, [] ],
		];
	}

	public function provideTransform(): array {
		$options = [];
		return [
			[ new ParserOutput( '' ), null, $options, new ParserOutput( '' ) ],
			[ new ParserOutput( '<p>test</p>' ), null, $options, new ParserOutput( '<p>test</p>' ) ],
			[
				new ParserOutput( '<a href="/wiki/Test">test</a>' ),
				null, $options,
				new ParserOutput( '<a href="//TEST_SERVER/wiki/Test">test</a>' )
			],
			[
				new ParserOutput( '<a href="//TEST_SERVER/wiki/Test">test</a>' ),
				null, $options,
				new ParserOutput( '<a href="//TEST_SERVER/wiki/Test">test</a>' )
			],
			[
				new ParserOutput( '<a href="https://TEST_SERVER/wiki/Test">test</a>' ),
				null, $options,
				new ParserOutput( '<a href="https://TEST_SERVER/wiki/Test">test</a>' )
			],
			[
				new ParserOutput( '<a href="https://en.wikipedia.org/wiki/Test">test</a>' ),
				null, $options,
				new ParserOutput( '<a href="https://en.wikipedia.org/wiki/Test">test</a>' )
			],
		];
	}
}
PK       ! )/[    *  OutputTransform/Stages/ExtractBodyTest.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform\Stages;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\OutputTransform\OutputTransformStage;
use MediaWiki\OutputTransform\Stages\ExtractBody;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\Parsoid\ParsoidParser;
use MediaWiki\Tests\OutputTransform\OutputTransformStageTestBase;
use Psr\Log\NullLogger;

/**
 * @covers \MediaWiki\OutputTransform\Stages\ExtractBody
 */
class ExtractBodyTest extends OutputTransformStageTestBase {

	public function createStage(): OutputTransformStage {
		$urlUtils = $this->getServiceContainer()->getUrlUtils();
		return new ExtractBody( new ServiceOptions( [] ), new NullLogger(), $urlUtils, null );
	}

	public function provideShouldRun(): array {
		return [
			[ new ParserOutput(), null, [ 'isParsoidContent' => true ] ],
		];
	}

	public function provideShouldNotRun(): array {
		return [
			[ new ParserOutput(), null, [ 'isParsoidContent' => false ] ],
			[ new ParserOutput(), null, [] ],
		];
	}

	public function provideTransform(): array {
		$pageTemplate = <<<EOF
<!DOCTYPE html>
<html><head><base href="https://www.example.com/w/"/></head><body>__BODY__
</body></html>
EOF;
		$testData = [
			[
				"title" => "Foo",
				"body" => "<p><a href=\"./Hello\">hello</a></p>",
				"result" => "<p><a href=\"https://www.example.com/w/Hello\">hello</a></p>\n"
			],
			// Rest of these tests ensure that self-page cite fragments are converted to fragment urls
			// Tests different title scenarios to exercise edge cases
			[
				"title" => "Foo",
				"body" => "<p><a href=\"./Foo#cite1\">hello</a><a href=\"./Foo/Bar#cite1\">boo</a></p>",
				"result" => "<p><a href=\"#cite1\">hello</a><a href=\"https://www.example.com/w/Foo/Bar#cite1\">boo</a></p>\n"
			],
			// hrefs have db-key normalization of titles - test that output isn't tripped by that
			[
				"title" => "Foo_Bar Baz",
				"body" => "<p><a href=\"./Foo_Bar_Baz#cite1\">hello</a></p>",
				"result" => "<p><a href=\"#cite1\">hello</a></p>\n"
			],
			[
				"title" => "Foo+Bar",
				"body" => "<p><a href=\"./Foo+Bar#cite1\">hello</a></p>",
				"result" => "<p><a href=\"#cite1\">hello</a></p>\n"
			],
			[
				"title" => "Foo/Bar",
				"body" => "<p><a href=\"./Foo/Bar#cite1\">hello</a></p>",
				"result" => "<p><a href=\"#cite1\">hello</a></p>\n"
			],
			[
				"title" => "Foo/Bar'Baz",
				"body" => "<p><a href=\"./Foo/Bar'Baz#cite1\">hello</a></p>",
				"result" => "<p><a href=\"#cite1\">hello</a></p>\n"
			],
			[
				"title" => 'Foo/Bar"Baz',
				"body" => "<p><a href='./Foo/Bar\"Baz#cite1'>hello</a></p>",
				"result" => "<p><a href=\"#cite1\">hello</a></p>\n"
			]
		];
		$opts = [];
		$tests = [];
		// Set test title in parser output extension data
		foreach ( $testData as $t ) {
			$titleDBKey = strtr( $t['title'], ' ', '_' );
			$text = str_replace( "__BODY__", $t['body'], $pageTemplate );
			$poInput = new ParserOutput( $text );
			$poOutput = new ParserOutput( $t['result'] );
			$poInput->setExtensionData( ParsoidParser::PARSOID_TITLE_KEY, $titleDBKey );
			$poOutput->setExtensionData( ParsoidParser::PARSOID_TITLE_KEY, $titleDBKey );
			$tests[] = [ $poInput, null, $opts, $poOutput ];
		}
		return $tests;
	}
}
PK       ! R    0  OutputTransform/Stages/DeduplicateStylesTest.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform\Stages;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\OutputTransform\OutputTransformStage;
use MediaWiki\OutputTransform\Stages\DeduplicateStyles;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Tests\OutputTransform\OutputTransformStageTestBase;
use MediaWiki\Tests\OutputTransform\TestUtils;
use Psr\Log\NullLogger;

/**
 * @covers \MediaWiki\OutputTransform\Stages\DeduplicateStyles
 */
class DeduplicateStylesTest extends OutputTransformStageTestBase {

	public function createStage(): OutputTransformStage {
		return new DeduplicateStyles(
			new ServiceOptions( [] ),
			new NullLogger()
		);
	}

	public function provideShouldRun(): array {
		return( [
			[ new ParserOutput(), null, [ 'deduplicateStyles' => true ] ],
			[ new ParserOutput(), null, [] ],
		] );
	}

	public function provideShouldNotRun(): array {
		return( [
			[ new ParserOutput(), null, [ 'deduplicateStyles' => false ] ],
		] );
	}

	public function provideTransform(): array {
		$dedup = <<<EOF
<p>This is a test document.</p>
<style data-mw-deduplicate="duplicate1">.Duplicate1 {}</style>
<link rel="mw-deduplicated-inline-style" href="mw-data:duplicate1">
<style data-mw-deduplicate="duplicate2">.Duplicate2 {}</style>
<link rel="mw-deduplicated-inline-style" href="mw-data:duplicate1">
<link rel="mw-deduplicated-inline-style" href="mw-data:duplicate2">
<style data-mw-not-deduplicate="duplicate1">.Duplicate1 {}</style>
<link rel="mw-deduplicated-inline-style" href="mw-data:duplicate1">
<style data-mw-deduplicate="duplicate3">.Duplicate1 {}</style>
<style>.Duplicate1 {}</style>
EOF;

		$po = new ParserOutput( TestUtils::TEST_TO_DEDUP );
		$expected = new ParserOutput( $dedup );
		$opts = [];
		return [
			[ $po, null, $opts, $expected ]
		];
	}
}
PK       ! C    /  OutputTransform/Stages/HandleTOCMarkersTest.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform\Stages;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Language\Language;
use MediaWiki\OutputTransform\OutputTransformStage;
use MediaWiki\OutputTransform\Stages\HandleTOCMarkers;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Tests\OutputTransform\OutputTransformStageTestBase;
use MediaWiki\Tests\OutputTransform\TestUtils;
use Psr\Log\NullLogger;
use Skin;

/**
 * @covers \MediaWiki\OutputTransform\Stages\HandleTOCMarkers
 */
class HandleTOCMarkersTest extends OutputTransformStageTestBase {

	public function createStage(): OutputTransformStage {
		return new HandleTOCMarkers(
			new ServiceOptions( [] ),
			new NullLogger(),
			$this->getServiceContainer()->getTidy()
		);
	}

	public function provideShouldRun(): array {
		return [
			[ new ParserOutput(), null, [] ],
			[ new ParserOutput(), null, [ 'allowTOC' => false, 'injectTOC' => false ] ],
			[ new ParserOutput(), null, [ 'allowTOC' => false, 'injectTOC' => true ] ],
			[ new ParserOutput(), null, [ 'allowTOC' => true, 'injectTOC' => true ] ],
			[ new ParserOutput(), null, [ 'injectTOC' => true ] ],
			[ new ParserOutput(), null, [ 'allowTOC' => true ] ],
		];
	}

	public function provideShouldNotRun(): array {
		return [
			[ new ParserOutput(), null, [ 'allowTOC' => true, 'injectTOC' => false ] ]
		];
	}

	public function provideTransform(): iterable {
		$lang = $this->createNoOpMock(
			Language::class, [ 'getCode', 'getHtmlCode', 'getDir' ]
		);
		$lang->method( 'getCode' )->willReturn( 'en' );
		$lang->method( 'getHtmlCode' )->willReturn( 'en' );
		$lang->method( 'getDir' )->willReturn( 'ltr' );

		$skin = $this->createNoOpMock(
			Skin::class, [ 'getLanguage' ]
		);
		$skin->method( 'getLanguage' )->willReturn( $lang );

		$withToc = <<<EOF
<p>Test document.
</p>
<div id="toc" class="toc" role="navigation" aria-labelledby="mw-toc-heading"><input type="checkbox" role="button" id="toctogglecheckbox" class="toctogglecheckbox" style="display:none" /><div class="toctitle" lang="en" dir="ltr"><h2 id="mw-toc-heading">Contents</h2><span class="toctogglespan"><label class="toctogglelabel" for="toctogglecheckbox"></label></span></div>
<ul>
<li class="toclevel-1 tocsection-1"><a href="#Section_1"><span class="tocnumber">1</span> <span class="toctext">Section 1</span></a></li>
<li class="toclevel-1 tocsection-2"><a href="#Section_2"><span class="tocnumber">2</span> <span class="toctext">Section 2</span></a>
<ul>
<li class="toclevel-2 tocsection-3"><a href="#Section_2.1"><span class="tocnumber">2.1</span> <span class="toctext">Section 2.1</span></a></li>
</ul>
</li>
<li class="toclevel-1 tocsection-4"><a href="#Section_3"><span class="tocnumber">3</span> <span class="toctext">Section 3</span></a></li>
</ul>
</div>

<h2 data-mw-anchor="Section_1">Section 1<mw:editsection page="Test Page" section="1">Section 1</mw:editsection></h2>
<p>One
</p>
<h2 data-mw-anchor="Section_2">Section 2<mw:editsection page="Test Page" section="2">Section 2</mw:editsection></h2>
<p>Two
</p>
<h3 data-mw-anchor="Section_2.1">Section 2.1</h3>
<p>Two point one
</p>
<h2 data-mw-anchor="Section_3">Section 3<mw:editsection page="Test Page" section="4">Section 3</mw:editsection></h2>
<p>Three
</p>
EOF;

		$withoutToc = <<<EOF
<p>Test document.
</p>

<h2 data-mw-anchor="Section_1">Section 1<mw:editsection page="Test Page" section="1">Section 1</mw:editsection></h2>
<p>One
</p>
<h2 data-mw-anchor="Section_2">Section 2<mw:editsection page="Test Page" section="2">Section 2</mw:editsection></h2>
<p>Two
</p>
<h3 data-mw-anchor="Section_2.1">Section 2.1</h3>
<p>Two point one
</p>
<h2 data-mw-anchor="Section_3">Section 3<mw:editsection page="Test Page" section="4">Section 3</mw:editsection></h2>
<p>Three
</p>
EOF;
		$poTest1 = new ParserOutput( TestUtils::TEST_DOC );
		TestUtils::initSections( $poTest1 );
		$expectedWith = new ParserOutput( $withToc );
		TestUtils::initSections( $expectedWith );
		yield [ $poTest1, null, [
			'userLang' => $lang,
			'skin' => $skin,
			'allowTOC' => true,
			'injectTOC' => true
		], $expectedWith, 'should insert TOC' ];

		$poTest2 = new ParserOutput( TestUtils::TEST_DOC );
		TestUtils::initSections( $poTest2 );
		$expectedWithout = new ParserOutput( $withoutToc );
		TestUtils::initSections( $expectedWithout );
		yield [ $poTest2, null, [ 'allowTOC' => false ], $expectedWithout, 'should not insert TOC' ];

		$poTest3 = new ParserOutput( TestUtils::TEST_DOC . '<meta property="mw:PageProp/toc" />' );
		TestUtils::initSections( $poTest3 );
		$expectedWith = new ParserOutput( $withToc );
		TestUtils::initSections( $expectedWith );
		yield [ $poTest3, null, [
			'userLang' => $lang,
			'skin' => $skin,
			'allowTOC' => true,
			'injectTOC' => true
		], $expectedWith, 'should insert TOC only once' ];
	}
}
PK       ! .=    1  OutputTransform/Stages/AddWrapperDivClassTest.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform\Stages;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\OutputTransform\OutputTransformStage;
use MediaWiki\OutputTransform\Stages\AddWrapperDivClass;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Tests\OutputTransform\OutputTransformStageTestBase;
use MediaWiki\Tests\OutputTransform\TestUtils;
use Psr\Log\NullLogger;

/**
 * @covers \MediaWiki\OutputTransform\Stages\AddWrapperDivClass
 */
class AddWrapperDivClassTest extends OutputTransformStageTestBase {
	public function createStage(): OutputTransformStage {
		return new AddWrapperDivClass(
			new ServiceOptions( [] ),
			new NullLogger(),
			$this->getServiceContainer()->getLanguageFactory(),
			$this->getServiceContainer()->getContentLanguage()
		);
	}

	public function provideShouldRun(): array {
		return( [
			[ new ParserOutput(), null, [ 'wrapperDivClass' => 'some string' ] ]
		] );
	}

	public function provideShouldNotRun(): array {
		return( [
			[ new ParserOutput(), null, [ 'wrapperDivClass' => '' ] ],
			[ new ParserOutput(), null, [] ]
		] );
	}

	public function provideTransform(): array {
		$opts = [ 'wrapperDivClass' => 'mw-parser-output' ];
		$po = new ParserOutput( TestUtils::TEST_DOC );
		$wrappedText = <<<EOF
<div class="mw-content-ltr mw-parser-output" lang="en" dir="ltr"><p>Test document.
</p>
<meta property="mw:PageProp/toc" />
<h2 data-mw-anchor="Section_1">Section 1<mw:editsection page="Test Page" section="1">Section 1</mw:editsection></h2>
<p>One
</p>
<h2 data-mw-anchor="Section_2">Section 2<mw:editsection page="Test Page" section="2">Section 2</mw:editsection></h2>
<p>Two
</p>
<h3 data-mw-anchor="Section_2.1">Section 2.1</h3>
<p>Two point one
</p>
<h2 data-mw-anchor="Section_3">Section 3<mw:editsection page="Test Page" section="4">Section 3</mw:editsection></h2>
<p>Three
</p></div>
EOF;
		$expected = new ParserOutput( $wrappedText );
		return [
			[ $po, null, $opts, $expected ]
		];
	}
}
PK       ! UC  C  =  OutputTransform/Stages/ExecutePostCacheTransformHooksTest.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform\Stages;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\OutputTransform\Stages\ExecutePostCacheTransformHooks;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Tests\OutputTransform\TestUtils;
use Psr\Log\NullLogger;

/**
 * This test does not extend OutputTransformStageTestBase because we're explicitly testing that
 * the options are modified during the pipeline run.
 * @covers \MediaWiki\OutputTransform\Stages\ExecutePostCacheTransformHooks
 */
class ExecutePostCacheTransformHooksTest extends \MediaWikiIntegrationTestCase {

	public function createStage(): ExecutePostCacheTransformHooks {
		return new ExecutePostCacheTransformHooks(
			new ServiceOptions( [] ),
			new NullLogger(),
			$this->getServiceContainer()->getHookContainer()
		);
	}

	/**
	 * @covers \MediaWiki\OutputTransform\Stages\ExecutePostCacheTransformHooks::transform
	 */
	public function testTransform(): void {
		// Avoid other skins affecting the section edit links
		$this->overrideConfigValue( MainConfigNames::DefaultSkin, 'fallback' );
		RequestContext::resetMain();

		$this->overrideConfigValues( [
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::ParserEnableLegacyHeadingDOM => false,
		] );

		// This tests that the options are modified by the PostCacheTransformHookRunner (if it is not run, or if
		// the options are not modified, the test fails)
		$po = new ParserOutput( TestUtils::TEST_DOC );
		$expected = new ParserOutput( TestUtils::TEST_DOC_WITH_LINKS_NEW_MARKUP );
		$this->getServiceContainer()->getHookContainer()->register( 'ParserOutputPostCacheTransform',
			static function ( ParserOutput $out, &$text, array &$options ) {
				$options['enableSectionEditLinks'] = true;
			}
		);
		// T358103: VisualEditor will change the section edit links causing a test failure.
		$this->clearHook( 'SkinEditSectionLinks' );
		$pipeline = $this->getServiceContainer()->getDefaultOutputPipeline();
		$res = $pipeline->run( $po, null,
			[
				'allowTOC' => true,
				'injectTOC' => false,
				'enableSectionEditLinks' => false,
				'userLang' => null,
				'skin' => null,
				'unwrap' => false,
				'wrapperDivClass' => '',
				'deduplicateStyles' => false,
				'absoluteURLs' => false,
				'includeDebugInfo' => false,
			]
		);
		$res->clearParseStartTime();
		$expected->clearParseStartTime();
		$this->assertEquals( $expected, $res );
	}

	/**
	 * @covers \MediaWiki\OutputTransform\Stages\ExecutePostCacheTransformHooks::shouldRun
	 */
	public function testShouldRun() {
		$transform = $this->createStage();
		$this->getServiceContainer()
			->getHookContainer()
			->register( 'ParserOutputPostCacheTransform',
				static function ( ParserOutput $out, &$text, array &$options ) {
					$options['enableSectionEditLinks'] = true;
				} );
		$options = [];
		self::assertTrue( $transform->shouldRun( new ParserOutput(), null, $options ) );
	}

	/**
	 * @covers \MediaWiki\OutputTransform\Stages\ExecutePostCacheTransformHooks::shouldRun
	 */
	public function testShouldNotRun() {
		$transform = $this->createStage();
		$this->getServiceContainer()->getHookContainer()->clear( 'ParserOutputPostCacheTransform' );
		$options = [];
		self::assertFalse( $transform->shouldRun( new ParserOutput(), null, $options ) );
	}
}
PK       ! .I|  |  .  OutputTransform/Stages/RenderDebugInfoTest.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform\Stages;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\OutputTransform\OutputTransformStage;
use MediaWiki\OutputTransform\Stages\RenderDebugInfo;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Tests\OutputTransform\OutputTransformStageTestBase;
use Psr\Log\NullLogger;

/**
 * @covers \MediaWiki\OutputTransform\Stages\RenderDebugInfo
 */
class RenderDebugInfoTest extends OutputTransformStageTestBase {

	public function createStage(): OutputTransformStage {
		return new RenderDebugInfo(
			new ServiceOptions( [] ),
			new NullLogger(),
			$this->getServiceContainer()->getHookContainer()
		);
	}

	public function provideShouldRun(): array {
		return [
			[ new ParserOutput(), null, [ 'includeDebugInfo' => true ] ],
		];
	}

	public function provideShouldNotRun(): array {
		return [
			[ new ParserOutput(), null, [] ],
			[ new ParserOutput(), null, [ 'includeDebugInfo' => false ] ],
		];
	}

	/**
	 * TODO this only covers the addition of the report, not the content of the report itself. Expanding this
	 * test may be a good idea.
	 */
	public function provideTransform(): array {
		$text = <<<EOF
<!DOCTYPE html>
<html><head><title>Main Page</title></head><body data-parsoid='{"dsr":[0,6,0,0]}' lang="en"><p data-parsoid='{"dsr":[0,5,0,0]}'>hello</p>
</body></html>
EOF;
		$expectedText = $text . "\n<!-- \nNewPP limit report\nComplications: []\n-->\n";
		$po = new ParserOutput( $text );
		$po->setLimitReportData( 'test', 'limit' );
		$expected = new ParserOutput( $expectedText );
		$expected->setLimitReportData( 'test', 'limit' );
		return [
			[ $po, null, [], $expected ],
		];
	}
}
PK       ! ,    8  OutputTransform/Stages/HydrateHeaderPlaceholdersTest.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform\Stages;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\OutputTransform\OutputTransformStage;
use MediaWiki\OutputTransform\Stages\HydrateHeaderPlaceholders;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Tests\OutputTransform\OutputTransformStageTestBase;
use Psr\Log\NullLogger;

/**
 * @covers \MediaWiki\OutputTransform\Stages\HydrateHeaderPlaceholders
 */
class HydrateHeaderPlaceholdersTest extends OutputTransformStageTestBase {

	public function createStage(): OutputTransformStage {
		return new HydrateHeaderPlaceholders(
			new ServiceOptions( [] ),
			new NullLogger()
		);
	}

	public function provideShouldRun(): array {
		return [
			[ new ParserOutput(), null, [] ]
		];
	}

	public function provideShouldNotRun(): array {
		$this->markTestSkipped( 'HydrateHeaderPlaceHolders should always run' );
	}

	public function provideTransform(): array {
		$text = "<h1><mw:slotheader>Header&amp;1</mw:slotheader></h1><h2><mw:slotheader>Header 2</mw:slotheader></h2>";
		$expectedText = "<h1>Header&1</h1><h2>Header 2</h2>";
		return [
			[ new ParserOutput( $text ), null, [], new ParserOutput( $expectedText ) ],
		];
	}
}
PK       ! <~,u  u  0  OutputTransform/Stages/AddRedirectHeaderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform\Stages;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\OutputTransform\OutputTransformStage;
use MediaWiki\OutputTransform\Stages\AddRedirectHeader;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Tests\OutputTransform\OutputTransformStageTestBase;
use Psr\Log\NullLogger;

/**
 * @covers \MediaWiki\OutputTransform\Stages\AddRedirectHeader
 * @group Database
 *        ^ Title shenanigans seem to require this
 */
class AddRedirectHeaderTest extends OutputTransformStageTestBase {

	public function createStage(): OutputTransformStage {
		return new AddRedirectHeader(
			new ServiceOptions( [] ),
			new NullLogger()
		);
	}

	public function provideShouldRun(): iterable {
		$po = new ParserOutput();
		$po->setRedirectHeader( 'xyz' );
		yield [ $po, null, [] ];
	}

	public function provideShouldNotRun(): array {
		return [ [ new ParserOutput(), null, [] ] ];
	}

	public function provideTransform(): array {
		$text = "<h1>header</h1>\n<p>hello world</p>";
		$redirect = '<div class="redirectMsg">REDIRECT</div>';
		$expectedText = <<<EOF
<div class="redirectMsg">REDIRECT</div><h1>header</h1>\n<p>hello world</p>
EOF;

		$po = new ParserOutput( $text );
		$po->setRedirectHeader( $redirect );
		$expected = new ParserOutput( $expectedText );
		$expected->setRedirectHeader( $redirect );
		return [ [ $po, null, [], $expected ] ];
	}
}
PK       !     1  OutputTransform/Stages/HandleSectionLinksTest.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform\Stages;

use MediaWiki\Config\HashConfig;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\OutputTransform\OutputTransformStage;
use MediaWiki\OutputTransform\Stages\HandleSectionLinks;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Tests\OutputTransform\OutputTransformStageTestBase;
use MediaWiki\Tests\OutputTransform\TestUtils;
use Psr\Log\NullLogger;
use Skin;

/** @covers \MediaWiki\OutputTransform\Stages\HandleSectionLinks */
class HandleSectionLinksTest extends OutputTransformStageTestBase {

	public function createStage(): OutputTransformStage {
		return new HandleSectionLinks(
			new ServiceOptions(
				HandleSectionLinks::CONSTRUCTOR_OPTIONS,
				new HashConfig( [
					MainConfigNames::ParserEnableLegacyHeadingDOM => false,
				] )
			),
			new NullLogger(),
			$this->getServiceContainer()->getTitleFactory()
		);
	}

	public function provideShouldRun(): array {
		return [ [ new ParserOutput(), null, [] ] ];
	}

	public function provideShouldNotRun(): array {
		return [ [ new ParserOutput(), null, [ 'isParsoidContent' => true ] ] ];
	}

	private static function newParserOutput(
		?string $rawText = null,
		?ParserOptions $parserOptions = null,
		string ...$flags
	) {
		$po = new ParserOutput();
		if ( $rawText !== null ) {
			$po->setRawText( $rawText );
		}
		if ( $parserOptions !== null ) {
			$po->setFromParserOptions( $parserOptions );
		}
		foreach ( $flags as $f ) {
			$po->setOutputFlag( $f );
		}
		return $po;
	}

	public function provideTransform(): iterable {
		yield "TEST_DOC default: with links" => [
			self::newParserOutput( TestUtils::TEST_DOC ),
			null, [],
			self::newParserOutput( TestUtils::TEST_DOC_WITH_LINKS_NEW_MARKUP )
		];
		yield "TEST_DOC default ParserOptions: with links" => [
			self::newParserOutput( TestUtils::TEST_DOC ),
			ParserOptions::newFromAnon(), [],
			self::newParserOutput( TestUtils::TEST_DOC_WITH_LINKS_NEW_MARKUP )
		];
		yield 'TEST_DOC disabled via $options: no links' => [
			self::newParserOutput( TestUtils::TEST_DOC ),
			null, [ 'enableSectionEditLinks' => false ],
			self::newParserOutput( TestUtils::TEST_DOC_WITHOUT_LINKS_NEW_MARKUP )
		];
		$pOptsNoLinks = ParserOptions::newFromAnon();
		$pOptsNoLinks->setSuppressSectionEditLinks();
		yield 'TEST_DOC disabled via ParserOptions: no links' => [
			self::newParserOutput( TestUtils::TEST_DOC, $pOptsNoLinks ),
			$pOptsNoLinks, [],
			self::newParserOutput( TestUtils::TEST_DOC_WITHOUT_LINKS_NEW_MARKUP, $pOptsNoLinks )
		];
		yield 'TEST_DOC enabled via $options: with links' => [
			self::newParserOutput( TestUtils::TEST_DOC ),
			null, [ 'enableSectionEditLinks' => true ],
			self::newParserOutput( TestUtils::TEST_DOC_WITH_LINKS_NEW_MARKUP )
		];
		$legacyMarkupSkin = $this->getMockBuilder( Skin::class )
			->setConstructorArgs( [ [ 'name' => 'whatever', 'supportsMwHeading' => false ] ] )
			->getMockForAbstractClass();
		yield "TEST_DOC legacy markup: with links" => [
			self::newParserOutput( TestUtils::TEST_DOC ),
			null, [ 'skin' => $legacyMarkupSkin ],
			self::newParserOutput( TestUtils::TEST_DOC_WITH_LINKS_LEGACY_MARKUP )
		];
		yield 'TEST_DOC legacy markup: no links' => [
			self::newParserOutput( TestUtils::TEST_DOC ),
			null, [ 'skin' => $legacyMarkupSkin, 'enableSectionEditLinks' => false ],
			self::newParserOutput( TestUtils::TEST_DOC_WITHOUT_LINKS_LEGACY_MARKUP )
		];
		yield 'TEST_DOC_ANGLE_BRACKETS default: with links' => [
			self::newParserOutput( TestUtils::TEST_DOC_ANGLE_BRACKETS ),
			null, [],
			self::newParserOutput( TestUtils::TEST_DOC_ANGLE_BRACKETS_WITH_LINKS_NEW_MARKUP )
		];
		yield 'TEST_DOC_ANGLE_BRACKETS disabled via $options: no links' => [
			self::newParserOutput( TestUtils::TEST_DOC_ANGLE_BRACKETS ),
			null, [ 'enableSectionEditLinks' => false ],
			self::newParserOutput( TestUtils::TEST_DOC_ANGLE_BRACKETS_WITHOUT_LINKS_NEW_MARKUP )
		];
		yield 'TEST_DOC_ANGLE_BRACKETS disabled via ParserOptions: no links' => [
			self::newParserOutput( TestUtils::TEST_DOC_ANGLE_BRACKETS, $pOptsNoLinks ),
			$pOptsNoLinks, [],
			self::newParserOutput( TestUtils::TEST_DOC_ANGLE_BRACKETS_WITHOUT_LINKS_NEW_MARKUP, $pOptsNoLinks )
		];
		yield 'TEST_DOC_ANGLE_BRACKETS enabled via $options: with links' => [
			self::newParserOutput( TestUtils::TEST_DOC_ANGLE_BRACKETS ),
			null, [ 'enableSectionEditLinks' => true ],
			self::newParserOutput( TestUtils::TEST_DOC_ANGLE_BRACKETS_WITH_LINKS_NEW_MARKUP )
		];
		yield "TEST_DOC_ANGLE_BRACKETS legacy markup: with links" => [
			self::newParserOutput( TestUtils::TEST_DOC_ANGLE_BRACKETS ),
			null, [ 'skin' => $legacyMarkupSkin ],
			self::newParserOutput( TestUtils::TEST_DOC_ANGLE_BRACKETS_WITH_LINKS_LEGACY_MARKUP )
		];
		yield 'TEST_DOC_ANGLE_BRACKETS legacy markup: no links' => [
			self::newParserOutput( TestUtils::TEST_DOC_ANGLE_BRACKETS ),
			null, [ 'skin' => $legacyMarkupSkin, 'enableSectionEditLinks' => false ],
			self::newParserOutput( TestUtils::TEST_DOC_ANGLE_BRACKETS_WITHOUT_LINKS_LEGACY_MARKUP )
		];
	}
}
PK       ! g    (  OutputTransform/Stages/HardenNFCTest.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform\Stages;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\OutputTransform\OutputTransformStage;
use MediaWiki\OutputTransform\Stages\HardenNFC;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Tests\OutputTransform\OutputTransformStageTestBase;
use Psr\Log\NullLogger;

/**
 * @covers \MediaWiki\OutputTransform\Stages\HardenNFC
 */
class HardenNFCTest extends OutputTransformStageTestBase {

	public function createStage(): OutputTransformStage {
		return new HardenNFC(
			new ServiceOptions( [] ),
			new NullLogger()
		);
	}

	public function provideShouldRun(): array {
		return [
			[ new ParserOutput(), null, [] ]
		];
	}

	public function provideShouldNotRun(): array {
		$this->markTestSkipped( 'HydrateHeaderPlaceHolders should always run' );
	}

	public function provideTransform(): array {
		$text = "<h1>\u{0338}</h1>";
		$expectedText = "<h1>&#x338;</h1>";
		return [
			[ new ParserOutput( $text ), null, [], new ParserOutput( $expectedText ) ],
		];
	}
}
PK       ! n=    2  OutputTransform/Stages/ParsoidLocalizationTest.phpnu Iw        <?php

namespace MediaWiki\OutputTransform\Stages;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter;
use MediaWikiIntegrationTestCase;
use Psr\Log\NullLogger;
use Wikimedia\Bcp47Code\Bcp47CodeValue;
use Wikimedia\Parsoid\Core\PageBundle;
use Wikimedia\Parsoid\ParserTests\TestUtils;
use Wikimedia\Parsoid\Utils\ContentUtils;
use Wikimedia\Parsoid\Utils\WTUtils;

/**
 * @covers \MediaWiki\OutputTransform\Stages\ParsoidLocalization
 * @group Database
 */
class ParsoidLocalizationTest extends MediaWikiIntegrationTestCase {

	public function setUp(): void {
		global $IP;
		$msgDirs = [];
		$msgDirs[] = "$IP/tests/phpunit/data/OutputTransform/i18n";
		$this->overrideConfigValue( MainConfigNames::MessagesDirs, $msgDirs );
	}

	public function createStage(): ParsoidLocalization {
		return new ParsoidLocalization(
			new ServiceOptions( [] ),
			new NullLogger()
		);
	}

	/**
	 * @dataProvider provideDocsToLocalize
	 */
	public function testApplyTransformation(
		string $input, string $expected, string $pagelang, string $userlang,
		string $message
	) {
		$this->setUserLang( $userlang );
		$loc = $this->createStage();
		$po = PageBundleParserOutputConverter::parserOutputFromPageBundle( new PageBundle( $input ) );
		$po->setLanguage( new Bcp47CodeValue( $pagelang ) );
		$opts = [ 'isParsoidContent' => true ];
		$transf = $loc->transform( $po, null, $opts );
		$res = $transf->getContentHolderText();
		self::assertEquals( $expected, TestUtils::stripParsoidIds( $res ), $message );
	}

	/**
	 * @dataProvider provideSpans
	 */
	public function testTransformGeneratedSpans( string $key, array $params, string $expected, string $message ) {
		// one of the messages we use resolves a link
		$this->overrideConfigValue( MainConfigNames::ArticlePath, '/wiki/$1' );
		$loc = $this->createStage();
		$doc = ContentUtils::createDocument();
		$p = $doc->createElement( 'p' );
		$doc->body->appendChild( $p );
		$p->appendChild( WTUtils::createInterfaceI18nFragment( $doc, $key, $params ) );
		$po = PageBundleParserOutputConverter::parserOutputFromPageBundle(
			new PageBundle( ContentUtils::ppToXML( $doc ) ) );
		$po->setLanguage( new Bcp47CodeValue( 'en' ) );
		$opts = [ 'isParsoidContent' => true ];
		$transf = $loc->transform( $po, null, $opts );
		$res = $transf->getContentHolderText();
		$this->assertEquals( $expected, TestUtils::stripParsoidIds( $res ), $message );
	}

	/**
	 * @dataProvider provideAttrs
	 */
	public function testTransformGeneratedAttrs( string $key, array $params, string $expected, string $message ) {
		$loc = $this->createStage();
		$doc = ContentUtils::createDocument();
		$a = $doc->createElement( 'a' );
		$doc->body->appendChild( $a );
		WTUtils::addInterfaceI18nAttribute( $a, 'title', $key, $params );

		$po = PageBundleParserOutputConverter::parserOutputFromPageBundle(
			new PageBundle( ContentUtils::ppToXML( $doc ) ) );
		$po->setLanguage( new Bcp47CodeValue( 'fr' ) );
		$opts = [ 'isParsoidContent' => true ];
		$transf = $loc->transform( $po, null, $opts );
		$res = $transf->getContentHolderText();
		$this->assertEquals( $expected, TestUtils::stripParsoidIds( $res ), $message );
	}

	public static function provideDocsToLocalize(): array {
		return [
			[
				'<p><a rel="mw:WikiLink" href="./Zigzagzogzagzig?action=edit&amp;redlink=1" title="Zigzagzogzagzig" class="new" typeof="mw:LocalizedAttrs" data-mw-i18n=\'{"title":{"lang":"x-page","key":"testparam","params":["Zigzagzogzagzig"]}}\'>Zigzagzogzagzig</a></p>',
				'<p><a rel="mw:WikiLink" href="./Zigzagzogzagzig?action=edit&amp;redlink=1" title="français Zigzagzogzagzig" class="new" typeof="mw:LocalizedAttrs" data-mw-i18n=\'{"title":{"lang":"x-page","key":"testparam","params":["Zigzagzogzagzig"]}}\'>Zigzagzogzagzig</a></p>',
				'fr',
				'de',
				'Red link resolution, content language'
			],
			[
				'<p><a rel="mw:WikiLink" href="./Zigzagzogzagzig?action=edit&amp;redlink=1" title="Zigzagzogzagzig" class="new" typeof="mw:LocalizedAttrs" data-mw-i18n=\'{"title":{"lang":"x-user","key":"testparam","params":["Zigzagzogzagzig"]}}\'>Zigzagzogzagzig</a></p>',
				'<p><a rel="mw:WikiLink" href="./Zigzagzogzagzig?action=edit&amp;redlink=1" title="deutsch Zigzagzogzagzig" class="new" typeof="mw:LocalizedAttrs" data-mw-i18n=\'{"title":{"lang":"x-user","key":"testparam","params":["Zigzagzogzagzig"]}}\'>Zigzagzogzagzig</a></p>',
				'fr',
				'de',
				'Red link resolution, user language'
			],
			[
				'<p><a rel="mw:WikiLink" href="./Zigzagzogzagzig?action=edit&amp;redlink=1" title="Zigzagzogzagzig" class="new" typeof="mw:LocalizedAttrs" data-mw-i18n=\'{"title":{"lang":"pt-br","key":"testparam","params":["Zigzagzogzagzig"]}}\'>Zigzagzogzagzig</a></p>',
				'<p><a rel="mw:WikiLink" href="./Zigzagzogzagzig?action=edit&amp;redlink=1" title="brazilian Zigzagzogzagzig" class="new" typeof="mw:LocalizedAttrs" data-mw-i18n=\'{"title":{"lang":"pt-br","key":"testparam","params":["Zigzagzogzagzig"]}}\'>Zigzagzogzagzig</a></p>',
				'fr',
				'de',
				'Red link resolution, arbitrary language'
			],
		];
	}

	public static function provideSpans(): array {
		return [
			[
				'testparam',
				[ '&<' ],
				'<p><span typeof="mw:I18n" data-mw-i18n=\'{"/":{"lang":"x-user","key":"testparam","params":["&amp;&lt;"]}}\'>english &amp;&lt;</span></p>',
				'Span with &<',
			],
			[
				'testparam',
				[ "<script>console()</script>" ],
				'<p><span typeof="mw:I18n" data-mw-i18n=\'{"/":{"lang":"x-user","key":"testparam","params":["&lt;script>console()&lt;/script>"]}}\'>english &lt;script>console()&lt;/script></span></p>',
				'Span with <script> (gets escaped)'
			],
			[
				'testparam',
				[ "<b>bold move</b>" ],
				'<p><span typeof="mw:I18n" data-mw-i18n=\'{"/":{"lang":"x-user","key":"testparam","params":["&lt;b>bold move&lt;/b>"]}}\'>english <b>bold move</b></span></p>',
				'Span with <b> (doesn\'t get escaped)'
			],
			[
				'testblock',
				[],
				// Observe that we're not generating HTML conforming to content types in this specific case
				'<p><span typeof="mw:I18n" data-mw-i18n=\'{"/":{"lang":"x-user","key":"testblock","params":[]}}\'><p>english </p><div>stuff</div></span></p>',
				'Message with block content in a span'
			]
		];
	}

	public static function provideAttrs(): array {
		return [
			[
				'testparam',
				[ '&<' ],
				'<a typeof="mw:LocalizedAttrs" title="english &amp;&lt;" data-mw-i18n=\'{"title":{"lang":"x-user","key":"testparam","params":["&amp;&lt;"]}}\'></a>',
				'Attr with &<'
			],
			[
				'testparam',
				[ "<script>console()</script>" ],
				'<a typeof="mw:LocalizedAttrs" title="english &lt;script>console()&lt;/script>" data-mw-i18n=\'{"title":{"lang":"x-user","key":"testparam","params":["&lt;script>console()&lt;/script>"]}}\'></a>',
				'Attr with <script> (gets escaped)'
			],
			[
				'testparam',
				[ "<b>bold move</b>" ],
				'<a typeof="mw:LocalizedAttrs" title="english bold move" data-mw-i18n=\'{"title":{"lang":"x-user","key":"testparam","params":["&lt;b>bold move&lt;/b>"]}}\'></a>',
				'Attr with <b> (gets dropped)'
			],
			[
				'testblock',
				[],
				'<a typeof="mw:LocalizedAttrs" title="english stuff" data-mw-i18n=\'{"title":{"lang":"x-user","key":"testblock","params":[]}}\'></a>',
				'Attr with block content'
			]
		];
	}
}
PK       ! vk    *  OutputTransform/DummyDOMTransformStage.phpnu Iw        <?php

namespace MediaWiki\Tests\OutputTransform;

use MediaWiki\OutputTransform\ContentDOMTransformStage;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use Wikimedia\Parsoid\DOM\Document;

class DummyDOMTransformStage extends ContentDOMTransformStage {

	public function transformDOM( Document $dom, ParserOutput $po, ?ParserOptions $popts, array &$options
	): Document {
		return $dom;
	}

	public function shouldRun( ParserOutput $po, ?ParserOptions $popts, array $options = [] ): bool {
		return true;
	}
}
PK       ! hz  z  !  CommentStore/CommentStoreTest.sqlnu Iw        -- These are carefully crafted to work in all five supported databases

CREATE TABLE /*_*/commentstore1 (
  cs1_id integer not null,
  cs1_comment varchar(200),
  cs1_comment_id integer
);

CREATE TABLE /*_*/commentstore2 (
  cs2_id integer not null,
  cs2_comment varchar(200)
);

CREATE TABLE /*_*/commentstore2_temp (
  cs2t_id integer not null,
  cs2t_comment_id integer
);
PK       ! $  $  !  CommentStore/CommentStoreTest.phpnu Iw        <?php

use MediaWiki\CommentStore\CommentStore;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Language\Language;
use MediaWiki\Language\RawMessage;
use MediaWiki\Message\Message;
use Wikimedia\Rdbms\IMaintainableDatabase;

/**
 * @group Database
 * @covers \MediaWiki\CommentStore\CommentStore
 * @covers \MediaWiki\CommentStore\CommentStoreComment
 */
class CommentStoreTest extends MediaWikiLangTestCase {

	protected function getSchemaOverrides( IMaintainableDatabase $db ) {
		return [
			'scripts' => [
				__DIR__ . '/CommentStoreTest.sql',
			],
			'drop' => [],
			'create' => [ 'commentstore1', 'commentstore2', 'commentstore2_temp' ],
			'alter' => [],
		];
	}

	/**
	 * Create a store for a particular stage
	 * @return CommentStore
	 */
	private function makeStore() {
		$lang = $this->createMock( Language::class );
		$lang->method( 'truncateForDatabase' )->willReturnCallback( static function ( $str, $len ) {
			return strlen( $str ) > $len ? substr( $str, 0, $len - 3 ) . '...' : $str;
		} );
		$lang->method( 'truncateForVisual' )->willReturnCallback( static function ( $str, $len ) {
			return mb_strlen( $str ) > $len ? mb_substr( $str, 0, $len - 3 ) . '...' : $str;
		} );
		return new CommentStore( $lang );
	}

	/**
	 * @dataProvider provideGetJoin
	 * @param string $key
	 * @param array $expect
	 */
	public function testGetJoin( $key, $expect ) {
		$store = $this->makeStore();
		$result = $store->getJoin( $key );
		$this->assertEquals( $expect, $result );
	}

	public static function provideGetJoin() {
		return [
			'Simple table' => [
				'ipb_reason', [
					'tables' => [ 'comment_ipb_reason' => 'comment' ],
					'fields' => [
						'ipb_reason_text' => 'comment_ipb_reason.comment_text',
						'ipb_reason_data' => 'comment_ipb_reason.comment_data',
						'ipb_reason_cid' => 'comment_ipb_reason.comment_id',
					],
					'joins' => [
						'comment_ipb_reason' => [ 'JOIN', 'comment_ipb_reason.comment_id = ipb_reason_id' ],
					],
				],
			],

			'Revision' => [
				'rev_comment', [
					'tables' => [
						'comment_rev_comment' => 'comment',
					],
					'fields' => [
						'rev_comment_text' => 'comment_rev_comment.comment_text',
						'rev_comment_data' => 'comment_rev_comment.comment_data',
						'rev_comment_cid' => 'comment_rev_comment.comment_id',
					],
					'joins' => [
						'comment_rev_comment' => [ 'JOIN', 'comment_rev_comment.comment_id = rev_comment_id' ],
					],
				],
			],

			'Image' => [
				'img_description', [
					'tables' => [
						'comment_img_description' => 'comment',
					],
					'fields' => [
						'img_description_text' => 'comment_img_description.comment_text',
						'img_description_data' => 'comment_img_description.comment_data',
						'img_description_cid' => 'comment_img_description.comment_id',
					],
					'joins' => [
						'comment_img_description' => [ 'JOIN',
							'comment_img_description.comment_id = img_description_id',
						],
					],
				],
			],
		];
	}

	private function assertComment( $expect, $actual, $from ) {
		$this->assertSame( $expect['text'], $actual->text, "text $from" );
		$this->assertInstanceOf( get_class( $expect['message'] ), $actual->message,
			"message class $from" );
		$this->assertSame( $expect['message']->getKeysToTry(), $actual->message->getKeysToTry(),
			"message keys $from" );
		$this->assertEquals( $expect['message']->text(), $actual->message->text(),
			"message rendering $from" );
		$this->assertEquals( $expect['text'], $actual->message->text(),
			"message rendering and text $from" );
		$this->assertEquals( $expect['data'], $actual->data, "data $from" );
	}

	/**
	 * @dataProvider provideInsertRoundTrip
	 * @param string $table
	 * @param string $key
	 * @param string $pk
	 * @param string|Message $comment
	 * @param array|null $data
	 * @param array $expect
	 */
	public function testInsertRoundTrip( $table, $key, $pk, $comment, $data, $expect ) {
		static $id = 1;

		$wstore = $this->makeStore();

		$fields = $wstore->insert( $this->getDb(), $key, $comment, $data );

		$this->assertArrayNotHasKey( $key, $fields, "old field" );
		$this->assertArrayHasKey( "{$key}_id", $fields, "new field" );

		$this->getDb()->newInsertQueryBuilder()
			->insertInto( $table )
			->row( [ $pk => ++$id ] + $fields )
			->caller( __METHOD__ )
			->execute();

		$rstore = $this->makeStore();

		$fieldRow = $this->getDb()->newSelectQueryBuilder()
			->select( [ "{$key}_id" => "{$key}_id" ] )
			->from( $table )
			->where( [ $pk => $id ] )
			->caller( __METHOD__ )->fetchRow();

		$queryInfo = $rstore->getJoin( $key );
		$joinRow = $this->getDb()->newSelectQueryBuilder()
			->queryInfo( $queryInfo )
			->from( $table )
			->where( [ $pk => $id ] )
			->caller( __METHOD__ )
			->fetchRow();

		$this->assertComment(
			$expect,
			$rstore->getCommentLegacy( $this->getDb(), $key, $fieldRow ),
			"from getFields()"
		);
		$this->assertComment(
			$expect,
			$rstore->getComment( $key, $joinRow ),
			"from getJoin()"
		);
	}

	public static function provideInsertRoundTrip() {
		$msgComment = new Message( 'parentheses', [ 'message comment' ] );
		$textCommentMsg = new RawMessage( '$1', [ Message::plaintextParam( '{{text}} comment' ) ] );
		$nestedMsgComment = new Message( [ 'parentheses', 'rawmessage' ], [ new Message( 'mainpage' ) ] );
		$comStoreComment = new CommentStoreComment(
			null, 'comment store comment', null, [ 'foo' => 'bar' ]
		);

		return [
			'Simple table, text comment' => [
				'commentstore1', 'cs1_comment', 'cs1_id', '{{text}} comment', null, [
					'text' => '{{text}} comment',
					'message' => $textCommentMsg,
					'data' => null,
				]
			],
			'Simple table, text comment with data' => [
				'commentstore1', 'cs1_comment', 'cs1_id', '{{text}} comment', [ 'message' => 42 ], [
					'text' => '{{text}} comment',
					'message' => $textCommentMsg,
					'data' => [ 'message' => 42 ],
				]
			],
			'Simple table, message comment' => [
				'commentstore1', 'cs1_comment', 'cs1_id', $msgComment, null, [
					'text' => '(message comment)',
					'message' => $msgComment,
					'data' => null,
				]
			],
			'Simple table, message comment with data' => [
				'commentstore1', 'cs1_comment', 'cs1_id', $msgComment, [ 'message' => 42 ], [
					'text' => '(message comment)',
					'message' => $msgComment,
					'data' => [ 'message' => 42 ],
				]
			],
			'Simple table, nested message comment' => [
				'commentstore1', 'cs1_comment', 'cs1_id', $nestedMsgComment, null, [
					'text' => '(Main Page)',
					'message' => $nestedMsgComment,
					'data' => null,
				]
			],
			'Simple table, CommentStoreComment' => [
				'commentstore1', 'cs1_comment', 'cs1_id', clone $comStoreComment, [ 'baz' => 'baz' ], [
					'text' => 'comment store comment',
					'message' => $comStoreComment->message,
					'data' => [ 'foo' => 'bar' ],
				]
			],
		];
	}

	public function testGetCommentErrors() {
		$store = $this->makeStore();
		try {
			$store->getComment( 'dummy', [ 'dummy' => 'comment' ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( '$row does not contain fields needed for comment dummy', $ex->getMessage() );
		}
		// Ignore: Using deprecated fallback handling for comment dummy
		$res = @$store->getComment( 'dummy', [ 'dummy' => 'comment' ], true );
		$this->assertSame( 'comment', $res->text );
		try {
			$store->getComment( 'dummy', [ 'dummy_id' => 1 ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'$row does not contain fields needed for comment dummy and getComment(), '
				. 'but does have fields for getCommentLegacy()',
				$ex->getMessage()
			);
		}

		$store = $this->makeStore();
		try {
			$store->getComment( 'rev_comment', [ 'rev_comment' => 'comment' ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'$row does not contain fields needed for comment rev_comment', $ex->getMessage()
			);
		}
		$res = @$store->getComment( 'rev_comment', [ 'rev_comment' => 'comment' ], true );
		$this->assertSame( 'comment', $res->text );
		try {
			$store->getComment( 'rev_comment', [ 'rev_comment_id' => 1 ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'$row does not contain fields needed for comment rev_comment and getComment(), '
				. 'but does have fields for getCommentLegacy()',
				$ex->getMessage()
			);
		}
	}

	public function testInsertTruncation() {
		$comment = str_repeat( '💣', 16400 );
		$truncated = str_repeat( '💣', CommentStore::COMMENT_CHARACTER_LIMIT - 3 ) . '...';

		$store = $this->makeStore();
		$fields = $store->insert( $this->getDb(), 'ipb_reason', $comment );
		$stored = $this->getDb()->newSelectQueryBuilder()
			->select( 'comment_text' )
			->from( 'comment' )
			->where( [ 'comment_id' => $fields['ipb_reason_id'] ] )
			->caller( __METHOD__ )->fetchField();
		$this->assertSame( $truncated, $stored );
	}

	public function testInsertTooMuchData() {
		$store = $this->makeStore();
		$this->expectException( OverflowException::class );
		$this->expectExceptionMessage( "Comment data is too long (65611 bytes, maximum is 65535)" );
		$store->insert( $this->getDb(), 'ipb_reason', 'foo', [
			'long' => str_repeat( '💣', 16400 )
		] );
	}

}
PK       ! "    (  CommentStore/CommentStoreCommentTest.phpnu Iw        <?php

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Message\Message;
use PHPUnit\Framework\TestCase;

/**
 * @covers \MediaWiki\CommentStore\CommentStoreComment
 *
 * @license GPL-2.0-or-later
 */
class CommentStoreCommentTest extends TestCase {

	public function testConstructorWithMessage() {
		$message = new Message( 'test' );
		$comment = new CommentStoreComment( null, 'test', $message );

		$this->assertSame( $message, $comment->message );
	}

	public function testConstructorWithoutMessage() {
		$text = '{{template|param}}';
		$comment = new CommentStoreComment( null, $text );

		$this->assertSame( $text, $comment->message->text() );
	}

}
PK       ! Ʉ      utils/BatchRowUpdateTest.phpnu Iw        <?php

use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\Rdbms\Platform\SQLPlatform;
use Wikimedia\Rdbms\SelectQueryBuilder;

/**
 * Tests for BatchRowUpdate and its components
 *
 * @group db
 *
 * @covers \BatchRowUpdate
 * @covers \BatchRowIterator
 * @covers \BatchRowWriter
 */
class BatchRowUpdateTest extends MediaWikiIntegrationTestCase {

	public function testWriterBasicFunctionality() {
		$db = $this->mockDb( [ 'update' ] );
		$writer = new BatchRowWriter( $db, 'echo_event' );

		$updates = [
			self::mockUpdate( [ 'something' => 'changed' ] ),
			self::mockUpdate( [ 'otherthing' => 'changed' ] ),
			self::mockUpdate( [ 'and' => 'something', 'else' => 'changed' ] ),
		];

		$db->expects( $this->exactly( count( $updates ) ) )
			->method( 'update' );

		$writer->write( $updates );
	}

	protected static function mockUpdate( array $changes ) {
		static $i = 0;
		return [
			'primaryKey' => [ 'event_id' => $i++ ],
			'changes' => $changes,
		];
	}

	public function testReaderBasicIterate() {
		$batchSize = 2;
		$response = $this->genSelectResult( $batchSize, /*numRows*/ 5, static function () {
			static $i = 0;
			return [ 'id_field' => ++$i ];
		} );
		$db = $this->mockDbConsecutiveSelect( $response );
		$reader = new BatchRowIterator( $db, 'some_table', 'id_field', $batchSize );

		$pos = 0;
		foreach ( $reader as $rows ) {
			$this->assertEquals( $response[$pos], $rows, "Testing row in position $pos" );
			$pos++;
		}
		// -1 is because the final [] marks the end and isn't included
		$this->assertEquals( count( $response ) - 1, $pos );
	}

	public static function provider_readerGetPrimaryKey() {
		$row = [
			'id_field' => 42,
			'some_col' => 'dvorak',
			'other_col' => 'samurai',
		];
		return [

			[
				'Must return single column pk when requested',
				[ 'id_field' => 42 ],
				$row
			],

			[
				'Must return multiple column pks when requested',
				[ 'id_field' => 42, 'other_col' => 'samurai' ],
				$row
			],

		];
	}

	/**
	 * @dataProvider provider_readerGetPrimaryKey
	 */
	public function testReaderGetPrimaryKey( $message, array $expected, array $row ) {
		$reader = new BatchRowIterator( $this->mockDb(), 'some_table', array_keys( $expected ), 8675309 );
		$this->assertEquals( $expected, $reader->extractPrimaryKeys( (object)$row ), $message );
	}

	public static function provider_readerSetFetchColumns() {
		return [

			[
				'Must merge primary keys into select conditions',
				// Expected column select
				[ 'foo', 'bar' ],
				// primary keys
				[ 'foo' ],
				// setFetchColumn
				[ 'bar' ]
			],

			[
				'Must not merge primary keys into the all columns selector',
				// Expected column select
				[ '*' ],
				// primary keys
				[ 'foo' ],
				// setFetchColumn
				[ '*' ],
			],

			[
				'Must not duplicate primary keys into column selector',
				// Expected column select.
				[ 'foo', 'bar', 'baz' ],
				// primary keys
				[ 'foo', 'bar', ],
				// setFetchColumn
				[ 'bar', 'baz' ],
			],
		];
	}

	/**
	 * @dataProvider provider_readerSetFetchColumns
	 */
	public function testReaderSetFetchColumns(
		$message, array $columns, array $primaryKeys, array $fetchColumns
	) {
		$db = $this->mockDb( [ 'select' ] );
		$db->expects( $this->once() )
			->method( 'select' )
			// only testing second parameter of Database::select
			->with( [ 'some_table' ], $columns )
			->willReturn( new FakeResultWrapper( [] ) );

		$reader = new BatchRowIterator( $db, 'some_table', $primaryKeys, 22 );
		$reader->setFetchColumns( $fetchColumns );
		// triggers first database select
		$reader->rewind();
	}

	public static function provider_readerSelectConditions() {
		return [

			[
				"With single primary key must generate id > 'value'",
				// Expected second iteration
				[ "id_field > '3'" ],
				// Primary key(s)
				'id_field',
			],

			[
				'With multiple primary keys the first conditions ' .
					'must use >= and the final condition must use >',
				// Expected second iteration
				[ "id_field > '3' OR (id_field = '3' AND (foo > '103'))" ],
				// Primary key(s)
				[ 'id_field', 'foo' ],
			],

		];
	}

	/**
	 * Slightly hackish to use reflection, but asserting different parameters
	 * to consecutive calls of Database::select in phpunit is error prone
	 *
	 * @dataProvider provider_readerSelectConditions
	 */
	public function testReaderSelectConditionsMultiplePrimaryKeys(
		$message, $expectedSecondIteration, $primaryKeys, $batchSize = 3
	) {
		$results = $this->genSelectResult( $batchSize, $batchSize * 3, static function () {
			static $i = 0, $j = 100, $k = 1000;
			return [ 'id_field' => ++$i, 'foo' => ++$j, 'bar' => ++$k ];
		} );
		$db = $this->mockDbConsecutiveSelect( $results );

		$conditions = [ 'bar' => 42, 'baz' => 'hai' ];
		$reader = new BatchRowIterator( $db, 'some_table', $primaryKeys, $batchSize );
		$reader->addConditions( $conditions );

		$buildConditions = new ReflectionMethod( $reader, 'buildConditions' );
		$buildConditions->setAccessible( true );

		// On first iteration only the passed conditions must be used
		$this->assertEquals( $conditions, $buildConditions->invoke( $reader ),
			'First iteration must return only the conditions passed in addConditions' );
		$reader->rewind();

		// Second iteration must use the maximum primary key of last set
		$this->assertEquals(
			$conditions + $expectedSecondIteration,
			$buildConditions->invoke( $reader ),
			$message
		);
	}

	protected function mockDbConsecutiveSelect( array $retvals ) {
		$db = $this->mockDb( [ 'select', 'newSelectQueryBuilder', 'addQuotes' ] );
		$db->method( 'newSelectQueryBuilder' )->willReturnCallback( static function () use ( $db ) {
			return new SelectQueryBuilder( $db );
		} );
		$db->method( 'select' )
			->will( $this->consecutivelyReturnFromSelect( $retvals ) );
		$db->method( 'addQuotes' )
			->willReturnCallback( static function ( $value ) {
				return "'$value'"; // not real quoting: doesn't matter in test
			} );

		return $db;
	}

	protected function consecutivelyReturnFromSelect( array $results ) {
		$retvals = [];
		foreach ( $results as $rows ) {
			// The Database::select method returns result wrapper, so we do too.
			$retvals[] = $this->returnValue( new FakeResultWrapper( $rows ) );
		}

		return $this->onConsecutiveCalls( ...$retvals );
	}

	protected function genSelectResult( $batchSize, $numRows, $rowGenerator ) {
		$res = [];
		for ( $i = 0; $i < $numRows; $i += $batchSize ) {
			$rows = [];
			for ( $j = 0; $j < $batchSize && $i + $j < $numRows; $j++ ) {
				$rows[] = (object)$rowGenerator();
			}
			$res[] = $rows;
		}
		$res[] = []; // termination condition requires empty result for last row
		return $res;
	}

	protected function mockDb( $methods = [] ) {
		// @TODO: mock from Database
		// FIXME: the constructor normally sets mAtomicLevels and mSrvCache, and platform
		$databaseMysql = $this->getMockBuilder( Wikimedia\Rdbms\DatabaseMySQL::class )
			->disableOriginalConstructor()
			->onlyMethods( array_merge( [ 'isOpen' ], $methods ) )
			->getMock();

		$reflection = new ReflectionClass( $databaseMysql );
		$reflectionProperty = $reflection->getProperty( 'platform' );
		$reflectionProperty->setAccessible( true );
		$reflectionProperty->setValue( $databaseMysql, new SQLPlatform( $databaseMysql ) );

		$databaseMysql->method( 'isOpen' )
			->willReturn( true );
		return $databaseMysql;
	}
}
PK       ! ЬsT  T    utils/GitInfoTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Utils\GitInfo;

/**
 * @covers \MediaWiki\Utils\GitInfo
 */
class GitInfoTest extends MediaWikiIntegrationTestCase {

	/** @var string */
	private static $tempDir;

	public static function setUpBeforeClass(): void {
		parent::setUpBeforeClass();

		self::$tempDir = wfTempDir() . '/mw-phpunit-' . wfRandomString( 8 );
		if ( !mkdir( self::$tempDir ) ) {
			self::$tempDir = null;
			self::fail( 'Unable to create temporary directory' );
		}
		mkdir( self::$tempDir . '/gitrepo' );
		mkdir( self::$tempDir . '/gitrepo/1' );
		mkdir( self::$tempDir . '/gitrepo/2' );
		mkdir( self::$tempDir . '/gitrepo/3' );
		mkdir( self::$tempDir . '/gitrepo/1/.git' );
		mkdir( self::$tempDir . '/gitrepo/1/.git/refs' );
		mkdir( self::$tempDir . '/gitrepo/1/.git/refs/heads' );
		file_put_contents( self::$tempDir . '/gitrepo/1/.git/HEAD',
			"ref: refs/heads/master\n" );
		file_put_contents( self::$tempDir . '/gitrepo/1/.git/refs/heads/master',
			"0123456789012345678901234567890123abcdef\n" );
		file_put_contents( self::$tempDir . '/gitrepo/1/.git/packed-refs',
			"abcdef6789012345678901234567890123456789 refs/heads/master\n" );
		file_put_contents( self::$tempDir . '/gitrepo/2/.git',
			"gitdir: ../1/.git\n" );
		file_put_contents( self::$tempDir . '/gitrepo/3/.git',
			'gitdir: ' . self::$tempDir . "/gitrepo/1/.git\n" );
	}

	public static function tearDownAfterClass(): void {
		if ( self::$tempDir ) {
			wfRecursiveRemoveDir( self::$tempDir );
		}
		parent::tearDownAfterClass();
	}

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValue( MainConfigNames::GitInfoCacheDirectory, __DIR__ . '/../../data/gitinfo' );
	}

	protected function assertValidGitInfo( GitInfo $gitInfo ) {
		$this->assertTrue( $gitInfo->cacheIsComplete() );
		$this->assertEquals( 'refs/heads/master', $gitInfo->getHead() );
		$this->assertSame( '0123456789abcdef0123456789abcdef01234567',
			$gitInfo->getHeadSHA1() );
		$this->assertSame( '1070884800', $gitInfo->getHeadCommitDate() );
		$this->assertEquals( 'master', $gitInfo->getCurrentBranch() );
		$this->assertStringContainsString( '0123456789abcdef0123456789abcdef01234567',
			$gitInfo->getHeadViewUrl() );
	}

	public function testValidJsonData() {
		global $IP;

		$this->assertValidGitInfo( new GitInfo( "$IP/testValidJsonData" ) );
		$this->assertValidGitInfo( new GitInfo( __DIR__ . "/../../data/gitinfo/extension" ) );
	}

	public function testMissingJsonData() {
		$dir = $GLOBALS['IP'] . '/testMissingJsonData';
		$fixture = new GitInfo( $dir );

		$this->assertFalse( $fixture->cacheIsComplete() );

		$this->assertFalse( $fixture->getHead() );
		$this->assertFalse( $fixture->getHeadSHA1() );
		$this->assertFalse( $fixture->getHeadCommitDate() );
		$this->assertFalse( $fixture->getCurrentBranch() );
		$this->assertFalse( $fixture->getHeadViewUrl() );

		// After calling all the outputs, the cache should be complete
		$this->assertTrue( $fixture->cacheIsComplete() );
	}

	public function testReadingHead() {
		$dir = self::$tempDir . '/gitrepo/1';
		$fixture = new GitInfo( $dir );

		$this->assertEquals( 'refs/heads/master', $fixture->getHead() );
		$this->assertSame( '0123456789012345678901234567890123abcdef', $fixture->getHeadSHA1() );
	}

	public function testIndirection() {
		$dir = self::$tempDir . '/gitrepo/2';
		$fixture = new GitInfo( $dir );

		$this->assertEquals( 'refs/heads/master', $fixture->getHead() );
		$this->assertSame( '0123456789012345678901234567890123abcdef', $fixture->getHeadSHA1() );
	}

	public function testIndirection2() {
		$dir = self::$tempDir . '/gitrepo/3';
		$fixture = new GitInfo( $dir );

		$this->assertEquals( 'refs/heads/master', $fixture->getHead() );
		$this->assertSame( '0123456789012345678901234567890123abcdef', $fixture->getHeadSHA1() );
	}

	public function testReadingPackedRefs() {
		$dir = self::$tempDir . '/gitrepo/1';
		unlink( self::$tempDir . '/gitrepo/1/.git/refs/heads/master' );
		$fixture = new GitInfo( $dir );

		$this->assertEquals( 'refs/heads/master', $fixture->getHead() );
		$this->assertEquals( 'abcdef6789012345678901234567890123456789', $fixture->getHeadSHA1() );
	}
}
PK       ! .=	  =	     utils/ZipDirectoryReaderTest.phpnu Iw        <?php

/**
 * @covers \ZipDirectoryReader
 */
class ZipDirectoryReaderTest extends MediaWikiIntegrationTestCase {
	private const ZIP_DIR = __DIR__ . '/../../data/zip';

	/** @var array[] */
	protected $entries;

	public function zipCallback( $entry ) {
		$this->entries[] = $entry;
	}

	public function readZipAssertError( $file, $error, $assertMessage ) {
		$this->entries = [];
		$status = ZipDirectoryReader::read( self::ZIP_DIR . "/$file", [ $this, 'zipCallback' ] );
		$this->assertStatusError( $error, $status, $assertMessage );
	}

	public function readZipAssertSuccess( $file, $assertMessage ) {
		$this->entries = [];
		$status = ZipDirectoryReader::read( self::ZIP_DIR . "/$file", [ $this, 'zipCallback' ] );
		$this->assertStatusOK( $status, $assertMessage );
	}

	public function testEmpty() {
		$this->readZipAssertSuccess( 'empty.zip', 'Empty zip' );
	}

	public function testMultiDisk0() {
		$this->readZipAssertError( 'split.zip', 'zip-unsupported',
			'Split zip error' );
	}

	public function testNoSignature() {
		$this->readZipAssertError( 'nosig.zip', 'zip-wrong-format',
			'No signature should give "wrong format" error' );
	}

	public function testSimple() {
		$this->readZipAssertSuccess( 'class.zip', 'Simple ZIP' );
		$this->assertEquals( [ [
			'name' => 'Class.class',
			'mtime' => '20010115000000',
			'size' => 1,
		] ], $this->entries );
	}

	public function testBadCentralEntrySignature() {
		$this->readZipAssertError( 'wrong-central-entry-sig.zip', 'zip-bad',
			'Bad central entry error' );
	}

	public function testTrailingBytes() {
		// Due to T40432 this is now zip-wrong-format instead of zip-bad
		$this->readZipAssertError( 'trail.zip', 'zip-wrong-format',
			'Trailing bytes error' );
	}

	public function testWrongCDStart() {
		$this->readZipAssertError( 'wrong-cd-start-disk.zip', 'zip-unsupported',
			'Wrong CD start disk error' );
	}

	public function testCentralDirectoryGap() {
		$this->readZipAssertError( 'cd-gap.zip', 'zip-bad',
			'CD gap error' );
	}

	public function testCentralDirectoryTruncated() {
		$this->readZipAssertError( 'cd-truncated.zip', 'zip-bad',
			'CD truncated error (should hit unpack() overrun)' );
	}

	public function testLooksLikeZip64() {
		$this->readZipAssertError( 'looks-like-zip64.zip', 'zip-unsupported',
			'A file which looks like ZIP64 but isn\'t, should give error' );
	}
}
PK       ! ݝ       utils/FileContentsHasherTest.phpnu Iw        <?php

/**
 * @covers \FileContentsHasher
 */
class FileContentsHasherTest extends PHPUnit\Framework\TestCase {

	use MediaWikiCoversValidator;

	public static function provideSingleFile() {
		return array_map( static function ( $file ) {
			return [ $file, file_get_contents( $file ) ];
		}, glob( __DIR__ . '/../../data/filecontentshasher/*.*' ) );
	}

	/**
	 * @dataProvider provideSingleFile
	 */
	public function testSingleFileHash( $fileName, $contents ) {
		$expected = hash( 'md4', $contents );
		$actualHash = FileContentsHasher::getFileContentsHash( $fileName );
		$this->assertEquals( $expected, $actualHash );

		$actualHashRepeat = FileContentsHasher::getFileContentsHash( $fileName );
		$this->assertEquals( $expected, $actualHashRepeat );
	}

	public function provideMultipleFiles() {
		return [
			[ $this->provideSingleFile() ]
		];
	}

	/**
	 * @dataProvider provideMultipleFiles
	 */
	public function testMultipleFileHash( $files ) {
		$fileNames = [];
		$hashes = [];
		foreach ( $files as [ $fileName, $contents ] ) {
			$fileNames[] = $fileName;
			$hashes[] = hash( 'md4', $contents );
		}

		$expectedHash = hash( 'md4', implode( '', $hashes ) );
		$actualHash = FileContentsHasher::getFileContentsHash( $fileNames );
		$this->assertEquals( $expectedHash, $actualHash );

		$actualHashRepeat = FileContentsHasher::getFileContentsHash( $fileNames );
		$this->assertEquals( $expectedHash, $actualHashRepeat );
	}
}
PK       ! @%IMS
  S
    utils/MWTimestampTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\User\Options\StaticUserOptionsLookup;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\Utils\MWTimestamp;

/**
 * @covers \MediaWiki\Utils\MWTimestamp
 */
class MWTimestampTest extends MediaWikiLangTestCase {

	private function setMockUserOptions( array $options ) {
		$defaults = $this->getServiceContainer()->getMainConfig()->get( MainConfigNames::DefaultUserOptions );

		// $options are set as the options for "Pamela", the name used in the tests
		$userOptionsLookup = new StaticUserOptionsLookup(
			[ 'Pamela' => $options ],
			$defaults
		);

		$this->setService( 'UserOptionsLookup', $userOptionsLookup );
	}

	/**
	 * @dataProvider provideRelativeTimestampTests
	 */
	public function testRelativeTimestamp(
		$tsTime, // The timestamp to format
		$currentTime, // The time to consider "now"
		$timeCorrection, // The time offset to use
		$dateFormat, // The date preference to use
		$expectedOutput, // The expected output
		$desc // Description
	) {
		$this->setMockUserOptions( [
			'timecorrection' => $timeCorrection,
			'date' => $dateFormat
		] );

		$user = new UserIdentityValue( 13, 'Pamela' );

		$tsTime = new MWTimestamp( $tsTime );
		$currentTime = new MWTimestamp( $currentTime );

		$this->assertEquals(
			$expectedOutput,
			$tsTime->getRelativeTimestamp( $currentTime, $user ),
			$desc
		);
	}

	public static function provideRelativeTimestampTests() {
		return [
			[
				'20111231170000',
				'20120101000000',
				'Offset|0',
				'mdy',
				'7 hours ago',
				'"Yesterday" across years',
			],
			[
				'20120717190900',
				'20120717190929',
				'Offset|0',
				'mdy',
				'29 seconds ago',
				'"Just now"',
			],
			[
				'20120717190900',
				'20120717191530',
				'Offset|0',
				'mdy',
				'6 minutes and 30 seconds ago',
				'Combination of multiple units',
			],
			[
				'20121006173100',
				'20121006173200',
				'Offset|0',
				'mdy',
				'1 minute ago',
				'"1 minute ago"',
			],
			[
				'19910130151500',
				'20120716193700',
				'Offset|0',
				'mdy',
				'2 decades, 1 year, 168 days, 2 hours, 8 minutes and 48 seconds ago',
				'A long time ago',
			],
			[
				'20120101050000',
				'20120101080000',
				'Offset|-360',
				'mdy',
				'3 hours ago',
				'"Yesterday" across years with time correction',
			],
			[
				'20120714184300',
				'20120716184300',
				'Offset|-420',
				'mdy',
				'2 days ago',
				'Recent weekday with time correction',
			],
			[
				'20120714184300',
				'20120715040000',
				'Offset|-420',
				'mdy',
				'9 hours and 17 minutes ago',
				'Today at another time with time correction',
			],
		];
	}
}
PK       ! ?;&  ;&    linker/LinkRendererTest.phpnu Iw        <?php

use MediaWiki\Cache\LinkCache;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Linker\LinkRendererFactory;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageReference;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;

/**
 * @covers \MediaWiki\Linker\LinkRenderer
 */
class LinkRendererTest extends MediaWikiLangTestCase {
	use LinkCacheTestTrait;
	use MockTitleTrait;

	/**
	 * @var LinkRendererFactory
	 */
	private $factory;

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValues( [
			MainConfigNames::Server => '//example.org',
			MainConfigNames::CanonicalServer => 'http://example.org',
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
		] );
		$this->factory = $this->getServiceContainer()->getLinkRendererFactory();
	}

	public static function provideMergeAttribs() {
		yield [ new TitleValue( NS_SPECIAL, 'BlankPage' ) ];
		yield [ new PageReferenceValue( NS_SPECIAL, 'BlankPage', PageReference::LOCAL ) ];
	}

	/**
	 * @dataProvider provideMergeAttribs
	 * @covers \MediaWiki\Linker\LinkRenderer::makeBrokenLink
	 */
	public function testMergeAttribs( $target ) {
		$linkRenderer = $this->factory->create();
		$link = $linkRenderer->makeBrokenLink( $target, null, [
			// Appended to class
			'class' => 'foobar',
			// Suppresses href attribute
			'href' => false,
			// Extra attribute
			'bar' => 'baz'
		] );
		$this->assertEquals(
			'<a href="/wiki/Special:BlankPage" class="new foobar" '
			. 'title="Special:BlankPage (page does not exist)" bar="baz">'
			. 'Special:BlankPage</a>',
			$link
		);
	}

	public static function provideMakeKnownLink() {
		yield [ new TitleValue( NS_MAIN, 'Foobar' ) ];
		yield [ new PageReferenceValue( NS_MAIN, 'Foobar', PageReference::LOCAL ) ];
	}

	/**
	 * @dataProvider provideMakeKnownLink
	 * @covers \MediaWiki\Linker\LinkRenderer::makeKnownLink
	 */
	public function testMakeKnownLink( $target ) {
		$linkCache = $this->createMock( LinkCache::class );
		$linkCache->method( 'addLinkObj' )->willReturn( 42 );
		$this->setService( 'LinkCache', $linkCache );
		$linkRenderer = $this->getServiceContainer()->getLinkRendererFactory()->create();

		// Query added
		$this->assertEquals(
			'<a href="/w/index.php?title=Foobar&amp;foo=bar" title="Foobar">Foobar</a>',
			$linkRenderer->makeKnownLink( $target, null, [], [ 'foo' => 'bar' ] )
		);

		$linkRenderer->setForceArticlePath( true );
		$this->assertEquals(
			'<a href="/wiki/Foobar?foo=bar" title="Foobar">Foobar</a>',
			$linkRenderer->makeKnownLink( $target, null, [], [ 'foo' => 'bar' ] )
		);

		// expand = HTTPS
		$linkRenderer->setForceArticlePath( false );
		$linkRenderer->setExpandURLs( PROTO_HTTPS );
		$this->assertEquals(
			'<a href="https://example.org/wiki/Foobar" title="Foobar">Foobar</a>',
			$linkRenderer->makeKnownLink( $target )
		);
	}

	public static function provideMakeBrokenLink() {
		yield [
			new TitleValue( NS_MAIN, 'Foobar' ),
			new TitleValue( NS_SPECIAL, 'Foobar' )
		];
		yield [
			new PageReferenceValue( NS_MAIN, 'Foobar', PageReference::LOCAL ),
			new PageReferenceValue( NS_SPECIAL, 'Foobar', PageReference::LOCAL )
		];
	}

	/**
	 * @dataProvider provideMakeBrokenLink
	 * @covers \MediaWiki\Linker\LinkRenderer::makeBrokenLink
	 */
	public function testMakeBrokenLink( $target, $special ) {
		$linkRenderer = $this->factory->create();

		// action=edit&redlink=1 added
		$this->assertEquals(
			'<a href="/w/index.php?title=Foobar&amp;action=edit&amp;redlink=1" '
			. 'class="new" title="Foobar (page does not exist)">Foobar</a>',
			$linkRenderer->makeBrokenLink( $target )
		);

		// action=edit&redlink=1 not added due to action query parameter
		$this->assertEquals(
			'<a href="/w/index.php?title=Foobar&amp;action=foobar" class="new" '
			. 'title="Foobar (page does not exist)">Foobar</a>',
			$linkRenderer->makeBrokenLink( $target, null, [], [ 'action' => 'foobar' ] )
		);

		// action=edit&redlink=1 not added due to NS_SPECIAL
		$this->assertEquals(
			'<a href="/wiki/Special:Foobar" class="new" title="Special:Foobar '
			. '(page does not exist)">Special:Foobar</a>',
			$linkRenderer->makeBrokenLink( $special )
		);

		// fragment stripped
		if ( $target instanceof LinkTarget ) {
			$this->assertEquals(
				'<a href="/w/index.php?title=Foobar&amp;action=edit&amp;redlink=1" class="new" '
				. 'title="Foobar (page does not exist)">Foobar</a>',
				$linkRenderer->makeBrokenLink( $target->createFragmentTarget( 'foobar' ) )
			);
		}
	}

	public static function provideMakeLink() {
		yield [
			new TitleValue( NS_SPECIAL, 'Foobar' ),
			new TitleValue( NS_SPECIAL, 'BlankPage' )
		];
		yield [
			new PageReferenceValue( NS_SPECIAL, 'Foobar', PageReference::LOCAL ),
			new PageReferenceValue( NS_SPECIAL, 'BlankPage', PageReference::LOCAL )
		];
	}

	/**
	 * @dataProvider provideMakeLink
	 * @covers \MediaWiki\Linker\LinkRenderer::makeLink
	 */
	public function testMakeLink( $foobar, $blankpage ) {
		$linkRenderer = $this->factory->create();
		$this->assertEquals(
			'<a href="/wiki/Special:Foobar" class="new" title="Special:Foobar '
			. '(page does not exist)">foo</a>',
			$linkRenderer->makeLink( $foobar, 'foo' )
		);

		$this->assertEquals(
			'<a href="/wiki/Special:BlankPage" title="Special:BlankPage">blank</a>',
			$linkRenderer->makeLink( $blankpage, 'blank' )
		);

		$this->assertEquals(
			'<a href="/wiki/Special:Foobar" class="new" title="Special:Foobar '
			. '(page does not exist)">&lt;script&gt;evil()&lt;/script&gt;</a>',
			$linkRenderer->makeLink( $foobar, '<script>evil()</script>' )
		);

		$this->assertEquals(
			'<a href="/wiki/Special:Foobar" class="new" title="Special:Foobar '
			. '(page does not exist)"><script>evil()</script></a>',
			$linkRenderer->makeLink( $foobar, new HtmlArmor( '<script>evil()</script>' ) )
		);

		$this->assertEquals(
			'<a href="#fragment">fragment</a>',
			$linkRenderer->makeLink( Title::newFromText( '#fragment' ) )
		);
	}

	public static function provideMakeRedirectHeader() {
		return [
			[
				[
					'title' => 'Main_Page',
				],
				'<div class="redirectMsg"><p>Redirect to:</p><ul class="redirectText"><li><a class="new" title="Main Page (page does not exist)">Main Page</a></li></ul></div>'
			],
			[
				[
					'title' => 'Redirect',
					'redirect' => true,
				],
				'<div class="redirectMsg"><p>Redirect to:</p><ul class="redirectText"><li><a class="new" title="Redirect (page does not exist)">Redirect</a></li></ul></div>'
			],
			// Test "forceKnown"; change namespace to NS_SPECIAL so we don't
			// have to mock the LinkCache.
			[
				[
					'title' => 'Main_Page',
					'namespace' => NS_SPECIAL,
					'forceKnown' => true,
				],
				'<div class="redirectMsg"><p>Redirect to:</p><ul class="redirectText"><li><a title="Special:Main Page">Special:Main Page</a></li></ul></div>',
			],
			[
				[
					'title' => 'Redirect',
					'namespace' => NS_SPECIAL,
					'redirect' => true,
					'forceKnown' => true,
				],
				'<div class="redirectMsg"><p>Redirect to:</p><ul class="redirectText"><li><a href="/w/index.php?title=Special:Redirect&amp;redirect=no" title="Special:Redirect">Special:Redirect</a></li></ul></div>',
			],
		];
	}

	/**
	 * @dataProvider provideMakeRedirectHeader
	 * @covers \MediaWiki\Linker\LinkRenderer::makeRedirectHeader
	 */
	public function testMakeRedirectHeader( $test, $expected ) {
		$lang = $this->getServiceContainer()->getContentLanguage();
		$target = $this->makeMockTitle( $test['title'], $test );
		$forceKnown = $test['forceKnown'] ?? false;

		$linkRenderer = $this->factory->create();
		$this->assertEquals(
			$expected,
			$linkRenderer->makeRedirectHeader( $lang, $target, $forceKnown )
		);
	}

	public static function provideGetLinkClasses() {
		yield [
			new TitleValue( NS_MAIN, 'FooBar' ),
			new TitleValue( NS_MAIN, 'Redirect' ),
			new TitleValue( NS_USER, 'Someuser' )
		];
		yield [
			new PageReferenceValue( NS_MAIN, 'FooBar', PageReference::LOCAL ),
			new PageReferenceValue( NS_MAIN, 'Redirect', PageReference::LOCAL ),
			new PageReferenceValue( NS_USER, 'Someuser', PageReference::LOCAL )
		];
	}

	/**
	 * @dataProvider provideGetLinkClasses
	 * @covers \MediaWiki\Linker\LinkRenderer::getLinkClasses
	 */
	public function testGetLinkClasses( $foobarTitle, $redirectTitle, $userTitle ) {
		$services = $this->getServiceContainer();
		$titleFormatter = $services->getTitleFormatter();
		$specialPageFactory = $services->getSpecialPageFactory();
		$hookContainer = $services->getHookContainer();
		$linkCache = $services->getLinkCache();
		if ( $foobarTitle instanceof PageReference ) {
			$cacheTitle = Title::newFromPageReference( $foobarTitle );
		} else {
			$cacheTitle = $foobarTitle;
		}
		$this->addGoodLinkObject( 1, $cacheTitle, 10, 0 );
		if ( $redirectTitle instanceof PageReference ) {
			$cacheTitle = Title::newFromPageReference( $redirectTitle );
		} else {
			$cacheTitle = $redirectTitle;
		}
		$this->addGoodLinkObject( 2, $cacheTitle, 10, 1 );

		if ( $userTitle instanceof PageReference ) {
			$cacheTitle = Title::newFromPageReference( $userTitle );
		} else {
			$cacheTitle = $userTitle;
		}
		$this->addGoodLinkObject( 3, $cacheTitle, 10, 0 );

		$linkRenderer = new LinkRenderer(
			$titleFormatter,
			$linkCache,
			$specialPageFactory,
			$hookContainer,
			new ServiceOptions( LinkRenderer::CONSTRUCTOR_OPTIONS, [ 'renderForComment' => false ] )
		);
		$this->assertSame(
			'',
			$linkRenderer->getLinkClasses( $foobarTitle )
		);
		$this->assertEquals(
			'mw-redirect',
			$linkRenderer->getLinkClasses( $redirectTitle )
		);
	}

	protected function tearDown(): void {
		Title::clearCaches();
		parent::tearDown();
	}
}
PK       ! 2'  '    linker/LinkTargetStoreTest.phpnu Iw        <?php

use MediaWiki\Title\TitleValue;

/**
 * @group Database
 * @covers \MediaWiki\Linker\LinkTargetStore
 */
class LinkTargetStoreTest extends MediaWikiIntegrationTestCase {

	public static function provideLinkTargets() {
		yield [ new TitleValue( NS_SPECIAL, 'BlankPage' ) ];
		yield [ new TitleValue( NS_MAIN, 'Foobar' ) ];
		yield [ new TitleValue( NS_USER, 'Someuser' ) ];
	}

	/**
	 * @dataProvider provideLinkTargets
	 * @covers \MediaWiki\Linker\LinkTargetStore::acquireLinkTargetId
	 */
	public function testAcquireLinkTargetId( $target ) {
		$linkTargetStore = $this->getServiceContainer()->getLinkTargetLookup();
		$db = $this->getDb();
		$id = $linkTargetStore->acquireLinkTargetId( $target, $db );
		$row = $db->newSelectQueryBuilder()
			->select( [ 'lt_id', 'lt_namespace', 'lt_title' ] )
			->from( 'linktarget' )
			->where( [ 'lt_namespace' => $target->getNamespace(), 'lt_title' => $target->getDBkey() ] )
			->fetchRow();
		$this->assertSame( (int)$row->lt_id, $id );
	}

	/**
	 * @dataProvider provideLinkTargets
	 * @covers \MediaWiki\Linker\LinkTargetStore::acquireLinkTargetId
	 * @covers \MediaWiki\Linker\LinkTargetStore::getLinkTargetById
	 */
	public function testGetLinkTargetById( $target ) {
		$linkTargetStore = $this->getServiceContainer()->getLinkTargetLookup();
		$db = $this->getDb();
		$id = $linkTargetStore->acquireLinkTargetId( $target, $db );
		$actualLinkTarget = $linkTargetStore->getLinkTargetById( $id, $db );
		$this->assertEquals( $target, $actualLinkTarget );
	}

	/**
	 * @dataProvider provideLinkTargets
	 * @covers \MediaWiki\Linker\LinkTargetStore::acquireLinkTargetId
	 * @covers \MediaWiki\Linker\LinkTargetStore::getLinkTargetById
	 */
	public function testGetLinkTargetByIdWithoutCache( $target ) {
		$linkTargetStore = $this->getServiceContainer()->getLinkTargetLookup();
		$db = $this->getDb();
		$id = $linkTargetStore->acquireLinkTargetId( $target, $db );
		$linkTargetStore->clearClassCache();
		$actualLinkTarget = $linkTargetStore->getLinkTargetById( $id, $db );
		$this->assertEquals( $target, $actualLinkTarget );
	}

}
PK       ! @6!3  !3    linker/LinkerTest.phpnu Iw        <?php

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\TextContent;
use MediaWiki\Context\RequestContext;
use MediaWiki\Linker\Linker;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\Watchlist\WatchedItem;

/**
 * @group Database
 */
class LinkerTest extends MediaWikiLangTestCase {
	/**
	 * @dataProvider provideCasesForUserLink
	 * @covers \MediaWiki\Linker\Linker::userLink
	 */
	public function testUserLink( $expected, $userId, $userName, $altUserName = false, $msg = '' ) {
		// We'd also test the warning, but injecting a mock logger into a static method is tricky.
		if ( !$userName ) {
			$actual = @Linker::userLink( $userId, $userName, $altUserName );
		} else {
			$actual = Linker::userLink( $userId, $userName, $altUserName );
		}

		$this->assertEquals( $expected, $actual, $msg );
	}

	public static function provideCasesForUserLink() {
		# Format:
		# - expected
		# - userid
		# - username
		# - optional altUserName
		# - optional message
		return [
			# Empty name (T222529)
			'Empty username, userid 0' => [ '(no username available)', 0, '' ],
			'Empty username, userid > 0' => [ '(no username available)', 73, '' ],

			'false instead of username' => [ '(no username available)', 73, false ],
			'null instead of username' => [ '(no username available)', 0, null ],

			# ## ANONYMOUS USER ########################################
			[
				'<a href="/wiki/Special:Contributions/JohnDoe" '
					. 'class="mw-userlink mw-anonuserlink" '
					. 'title="Special:Contributions/JohnDoe"><bdi>JohnDoe</bdi></a>',
				0, 'JohnDoe', false,
			],
			[
				'<a href="/wiki/Special:Contributions/::1" '
					. 'class="mw-userlink mw-anonuserlink" '
					. 'title="Special:Contributions/::1"><bdi>::1</bdi></a>',
				0, '::1', false,
				'Anonymous with pretty IPv6'
			],
			[
				'<a href="/wiki/Special:Contributions/0:0:0:0:0:0:0:1" '
					. 'class="mw-userlink mw-anonuserlink" '
					. 'title="Special:Contributions/0:0:0:0:0:0:0:1"><bdi>::1</bdi></a>',
				0, '0:0:0:0:0:0:0:1', false,
				'Anonymous with almost pretty IPv6'
			],
			[
				'<a href="/wiki/Special:Contributions/0000:0000:0000:0000:0000:0000:0000:0001" '
					. 'class="mw-userlink mw-anonuserlink" '
					. 'title="Special:Contributions/0000:0000:0000:0000:0000:0000:0000:0001"><bdi>::1</bdi></a>',
				0, '0000:0000:0000:0000:0000:0000:0000:0001', false,
				'Anonymous with full IPv6'
			],
			[
				'<a href="/wiki/Special:Contributions/::1" '
					. 'class="mw-userlink mw-anonuserlink" '
					. 'title="Special:Contributions/::1"><bdi>AlternativeUsername</bdi></a>',
				0, '::1', 'AlternativeUsername',
				'Anonymous with pretty IPv6 and an alternative username'
			],

			# IPV4
			[
				'<a href="/wiki/Special:Contributions/127.0.0.1" '
					. 'class="mw-userlink mw-anonuserlink" '
					. 'title="Special:Contributions/127.0.0.1"><bdi>127.0.0.1</bdi></a>',
				0, '127.0.0.1', false,
				'Anonymous with IPv4'
			],
			[
				'<a href="/wiki/Special:Contributions/127.0.0.1" '
					. 'class="mw-userlink mw-anonuserlink" '
					. 'title="Special:Contributions/127.0.0.1"><bdi>AlternativeUsername</bdi></a>',
				0, '127.0.0.1', 'AlternativeUsername',
				'Anonymous with IPv4 and an alternative username'
			],

			# IP ranges
			[
				'<a href="/wiki/Special:Contributions/1.2.3.4/31" '
					. 'class="mw-userlink mw-anonuserlink" '
					. 'title="Special:Contributions/1.2.3.4/31"><bdi>1.2.3.4/31</bdi></a>',
				0, '1.2.3.4/31', false,
				'Anonymous with IPv4 range'
			],
			[
				'<a href="/wiki/Special:Contributions/2001:db8::1/43" '
					. 'class="mw-userlink mw-anonuserlink" '
					. 'title="Special:Contributions/2001:db8::1/43"><bdi>2001:db8::1/43</bdi></a>',
				0, '2001:db8::1/43', false,
				'Anonymous with IPv6 range'
			],

			# External (imported) user, unknown prefix
			[
				'<span class="mw-userlink mw-extuserlink mw-anonuserlink"><bdi>acme&gt;Alice</bdi></span>',
				0, "acme>Alice", false,
				'User from acme wiki'
			],

			# Corrupt user names
			[
				"<span class=\"mw-userlink mw-anonuserlink\"><bdi>Foo\nBar</bdi></span>",
				0, "Foo\nBar", false,
				'User name with line break'
			],
			[
				'<span class="mw-userlink mw-anonuserlink"><bdi>Barf_</bdi></span>',
				0, "Barf_", false,
				'User name with trailing underscore'
			],
			[
				'<span class="mw-userlink mw-anonuserlink"><bdi>abcd</bdi></span>',
				0, "abcd", false,
				'Lower case user name'
			],
			[
				'<span class="mw-userlink mw-anonuserlink"><bdi>For/Bar</bdi></span>',
				0, "For/Bar", false,
				'User name with slash'
			],
			[
				'<span class="mw-userlink mw-anonuserlink"><bdi>For#Bar</bdi></span>',
				0, "For#Bar", false,
				'User name with hash'
			],

			# ## Regular user ##########################################
			# TODO!
		];
	}

	/**
	 * @dataProvider provideUserToolLinks
	 * @covers \MediaWiki\Linker\Linker::userToolLinks
	 * @param string $expected
	 * @param int $userId
	 * @param string $userText
	 */
	public function testUserToolLinks( $expected, $userId, $userText ) {
		// We'd also test the warning, but injecting a mock logger into a static method is tricky.
		if ( $userText === '' ) {
			$actual = @Linker::userToolLinks( $userId, $userText );
		} else {
			$actual = Linker::userToolLinks( $userId, $userText );
		}

		$this->assertSame( $expected, $actual );
	}

	public static function provideUserToolLinks() {
		return [
			// Empty name (T222529)
			'Empty username, userid 0' => [ ' (no username available)', 0, '' ],
			'Empty username, userid > 0' => [ ' (no username available)', 73, '' ],
		];
	}

	/**
	 * @dataProvider provideUserLink
	 * @covers \MediaWiki\Linker\Linker::userTalkLink
	 * @param string $expected
	 * @param int $userId
	 * @param string $userText
	 */
	public function testUserTalkLink( $expected, $userId, $userText ) {
		// We'd also test the warning, but injecting a mock logger into a static method is tricky.
		if ( $userText === '' ) {
			$actual = @Linker::userTalkLink( $userId, $userText );
		} else {
			$actual = Linker::userTalkLink( $userId, $userText );
		}

		$this->assertSame( $expected, $actual );
	}

	/**
	 * @dataProvider provideUserLink
	 * @covers \MediaWiki\Linker\Linker::blockLink
	 * @param string $expected
	 * @param int $userId
	 * @param string $userText
	 */
	public function testBlockLink( $expected, $userId, $userText ) {
		// We'd also test the warning, but injecting a mock logger into a static method is tricky.
		if ( $userText === '' ) {
			$actual = @Linker::blockLink( $userId, $userText );
		} else {
			$actual = Linker::blockLink( $userId, $userText );
		}

		$this->assertSame( $expected, $actual );
	}

	/**
	 * @dataProvider provideUserLink
	 * @covers \MediaWiki\Linker\Linker::emailLink
	 * @param string $expected
	 * @param int $userId
	 * @param string $userText
	 */
	public function testEmailLink( $expected, $userId, $userText ) {
		// We'd also test the warning, but injecting a mock logger into a static method is tricky.
		if ( $userText === '' ) {
			$actual = @Linker::emailLink( $userId, $userText );
		} else {
			$actual = Linker::emailLink( $userId, $userText );
		}

		$this->assertSame( $expected, $actual );
	}

	public static function provideUserLink() {
		return [
			// Empty name (T222529)
			'Empty username, userid 0' => [ '(no username available)', 0, '' ],
			'Empty username, userid > 0' => [ '(no username available)', 73, '' ],
		];
	}

	/**
	 * @covers \MediaWiki\Linker\Linker::generateRollback
	 * @dataProvider provideCasesForRollbackGeneration
	 */
	public function testGenerateRollback( $rollbackEnabled, $expectedModules, $title ) {
		$context = RequestContext::getMain();
		$user = $this->getTestUser()->getUser();
		$context->setUser( $user );
		$this->getServiceContainer()->getUserOptionsManager()->setOption(
			$user,
			'showrollbackconfirmation',
			$rollbackEnabled
		);

		$this->assertSame( 0, Title::newFromText( $title )->getArticleID() );
		$pageData = $this->insertPage( $title );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $pageData['title'] );

		$summary = CommentStoreComment::newUnsavedComment( 'Some comment!' );
		$page->newPageUpdater( $user )
			->setContent(
				SlotRecord::MAIN,
				new TextContent( 'Technical Wishes 123!' )
			)
			->saveRevision( $summary );

		$rollbackOutput = Linker::generateRollback( $page->getRevisionRecord(), $context );
		$modules = $context->getOutput()->getModules();
		$currentRev = $page->getRevisionRecord();
		$revisionLookup = $this->getServiceContainer()->getRevisionLookup();
		$oldestRev = $revisionLookup->getFirstRevision( $page->getTitle() );

		$this->assertEquals( $expectedModules, $modules );
		$this->assertInstanceOf( RevisionRecord::class, $currentRev );
		$this->assertInstanceOf( User::class, $currentRev->getUser() );
		$this->assertEquals( $user->getName(), $currentRev->getUser()->getName() );
		$this->assertEquals(
			static::getTestSysop()->getUser(),
			$oldestRev->getUser()->getName()
		);

		$ids = [];
		$r = $oldestRev;
		while ( $r ) {
			$ids[] = $r->getId();
			$r = $revisionLookup->getNextRevision( $r );
		}
		$this->assertEquals( [ $oldestRev->getId(), $currentRev->getId() ], $ids );

		$this->assertStringContainsString( 'rollback 1 edit', $rollbackOutput );
	}

	public static function provideCasesForRollbackGeneration() {
		return [
			[
				true,
				[ 'mediawiki.misc-authed-curate' ],
				'Rollback_Test_Page'
			],
			[
				false,
				[],
				'Rollback_Test_Page2'
			]
		];
	}

	public static function provideTooltipAndAccesskeyAttribs() {
		return [
			'Watch no expiry' => [
				'ca-watch', [], null, [ 'title' => 'Add this page to your watchlist [w]', 'accesskey' => 'w' ]
			],
			'Key does not exist' => [
				'key-does-not-exist', [], null, []
			],
			'Unwatch no expiry' => [
				'ca-unwatch', [], null, [ 'title' => 'Remove this page from your watchlist [w]',
					'accesskey' => 'w' ]
			],
		];
	}

	/**
	 * @covers \MediaWiki\Linker\Linker::tooltipAndAccesskeyAttribs
	 * @dataProvider provideTooltipAndAccesskeyAttribs
	 */
	public function testTooltipAndAccesskeyAttribs( $name, $msgParams, $options, $expected ) {
		$this->overrideConfigValue( MainConfigNames::WatchlistExpiry, true );
		$user = $this->createMock( User::class );
		$user->method( 'isRegistered' )->willReturn( true );

		$title = SpecialPage::getTitleFor( 'Blankpage' );

		$context = RequestContext::getMain();
		$context->setTitle( $title );
		$context->setUser( $user );

		$watchedItemWithoutExpiry = new WatchedItem( $user, $title, null, null );

		$result = Linker::tooltipAndAccesskeyAttribs( $name, $msgParams, $options );

		$this->assertEquals( $expected, $result );
	}

	/**
	 * @covers \MediaWiki\Linker\Linker::specialLink
	 * @dataProvider provideSpecialLink
	 */
	public function testSpecialLink( $expected, $target, $key = null ) {
		$this->overrideConfigValues( [
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::ArticlePath => '/wiki/$1',
		] );

		$this->assertEquals( $expected, Linker::specialLink( $target, $key ) );
	}

	public static function provideSpecialLink() {
		yield 'Recent Changes' => [
			'<a href="/wiki/Special:RecentChanges" title="Special:RecentChanges">Recent changes</a>',
			'Recentchanges'
		];

		yield 'Recent Changes, only for a given tag' => [
			'<a href="/w/index.php?title=Special:RecentChanges&amp;tagfilter=blanking" title="Special:RecentChanges">Recent changes</a>',
			'Recentchanges?tagfilter=blanking'
		];

		yield 'Contributions' => [
			'<a href="/wiki/Special:Contributions" title="Special:Contributions">User contributions</a>',
			'Contributions'
		];

		yield 'Contributions, custom key' => [
			'<a href="/wiki/Special:Contributions" title="Special:Contributions">⧼made-up-display-key⧽</a>',
			'Contributions',
			'made-up-display-key'
		];

		yield 'Contributions, targetted' => [
			'<a href="/wiki/Special:Contributions/JohnDoe" title="Special:Contributions/JohnDoe">User contributions</a>',
			'Contributions/JohnDoe'
		];

		yield 'Contributions, targetted, topOnly' => [
			'<a href="/w/index.php?title=Special:Contributions/JohnDoe&amp;topOnly=1" title="Special:Contributions/JohnDoe">User contributions</a>',
			'Contributions/JohnDoe?topOnly=1'
		];

		yield 'Userlogin' => [
			'<a href="/wiki/Special:UserLogin" title="Special:UserLogin">Log in</a>',
			'Userlogin',
			'login'
		];

		yield 'Userlogin, returnto' => [
			'<a href="/w/index.php?title=Special:UserLogin&amp;returnto=Main+Page" title="Special:UserLogin">Log in</a>',
			'Userlogin?returnto=Main+Page',
			'login'
		];

		yield 'Userlogin, targetted' => [
			// Note that this special page doesn't have any support for and doesn't do anything with
			// the subtitle; this is here as demonstration that Linker doesn't care.
			'<a href="/wiki/Special:UserLogin/JohnDoe" title="Special:UserLogin/JohnDoe">Log in</a>',
			'Userlogin/JohnDoe',
			'login'
		];
	}
}
PK       ! c^A"  "    site/HashSiteStoreTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Site;

use MediaWiki\Site\HashSiteStore;
use MediaWiki\Site\MediaWikiSite;
use MediaWiki\Site\Site;
use MediaWiki\Site\SiteList;
use MediaWikiIntegrationTestCase;

/**
 * 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
 * @since 1.25
 *
 * @ingroup Site
 * @group Site
 *
 * @author Katie Filbert < aude.wiki@gmail.com >
 */
class HashSiteStoreTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers \MediaWiki\Site\HashSiteStore::getSites
	 */
	public function testGetSites() {
		$expectedSites = [];

		foreach ( TestSites::getSites() as $testSite ) {
			$siteId = $testSite->getGlobalId();
			$expectedSites[$siteId] = $testSite;
		}

		$siteStore = new HashSiteStore( $expectedSites );

		$this->assertEquals( new SiteList( $expectedSites ), $siteStore->getSites() );
	}

	/**
	 * @covers \MediaWiki\Site\HashSiteStore::saveSite
	 * @covers \MediaWiki\Site\HashSiteStore::getSite
	 */
	public function testSaveSite() {
		$store = new HashSiteStore();

		$site = new Site();
		$site->setGlobalId( 'dewiki' );

		$this->assertCount( 0, $store->getSites(), '0 sites in store' );

		$store->saveSite( $site );

		$this->assertCount( 1, $store->getSites(), 'Store has 1 sites' );
		$this->assertEquals( $site, $store->getSite( 'dewiki' ), 'Store has dewiki' );
	}

	/**
	 * @covers \MediaWiki\Site\HashSiteStore::saveSites
	 */
	public function testSaveSites() {
		$store = new HashSiteStore();

		$sites = [];

		$site = new Site();
		$site->setGlobalId( 'enwiki' );
		$site->setLanguageCode( 'en' );
		$sites[] = $site;

		$site = new MediaWikiSite();
		$site->setGlobalId( 'eswiki' );
		$site->setLanguageCode( 'es' );
		$sites[] = $site;

		$this->assertCount( 0, $store->getSites(), '0 sites in store' );

		$store->saveSites( $sites );

		$this->assertCount( 2, $store->getSites(), 'Store has 2 sites' );
		$this->assertTrue( $store->getSites()->hasSite( 'enwiki' ), 'Store has enwiki' );
		$this->assertTrue( $store->getSites()->hasSite( 'eswiki' ), 'Store has eswiki' );
	}

	/**
	 * @covers \MediaWiki\Site\HashSiteStore::clear
	 */
	public function testClear() {
		$store = new HashSiteStore();

		$site = new Site();
		$site->setGlobalId( 'arwiki' );
		$store->saveSite( $site );

		$this->assertCount( 1, $store->getSites(), '1 site in store' );

		$store->clear();
		$this->assertCount( 0, $store->getSites(), '0 sites in store' );
	}
}
PK       ! *Qs  s    site/SiteImporterTest.xmlnu Iw        <sites version="1.0" xmlns="http://www.mediawiki.org/xml/sitelist-1.0/">
	<site><globalid>Foo</globalid></site>
	<site>
		<globalid>acme.com</globalid>
		<localid type="interwiki">acme</localid>
		<group>Test</group>
		<path type="link">http://acme.com/</path>
	</site>
	<site type="mediawiki">
		<source>meta.wikimedia.org</source>
		<globalid>dewiki</globalid>
		<localid type="interwiki">wikipedia</localid>
		<localid type="equivalent">de</localid>
		<group>wikipedia</group>
		<forward/>
		<path type="link">http://de.wikipedia.org/w/</path>
		<path type="page_path">http://de.wikipedia.org/wiki/</path>
	</site>
</sites>
PK       ! f~      site/SiteTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Site;

use MediaWiki\Site\Site;
use MediaWikiIntegrationTestCase;

/**
 * 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
 * @since 1.21
 *
 * @ingroup Site
 * @ingroup Test
 *
 * @group Site
 *
 * @author Jeroen De Dauw < jeroendedauw@gmail.com >
 */
class SiteTest extends MediaWikiIntegrationTestCase {

	public function instanceProvider() {
		return $this->arrayWrap( TestSites::getSites() );
	}

	/**
	 * @dataProvider instanceProvider
	 * @param Site $site
	 * @covers \MediaWiki\Site\Site::getInterwikiIds
	 */
	public function testGetInterwikiIds( Site $site ) {
		$this->assertIsArray( $site->getInterwikiIds() );
	}

	/**
	 * @dataProvider instanceProvider
	 * @param Site $site
	 * @covers \MediaWiki\Site\Site::getNavigationIds
	 */
	public function testGetNavigationIds( Site $site ) {
		$this->assertIsArray( $site->getNavigationIds() );
	}

	/**
	 * @dataProvider instanceProvider
	 * @param Site $site
	 * @covers \MediaWiki\Site\Site::addNavigationId
	 */
	public function testAddNavigationId( Site $site ) {
		$site->addNavigationId( 'foobar' );
		$this->assertContains( 'foobar', $site->getNavigationIds() );
	}

	/**
	 * @dataProvider instanceProvider
	 * @param Site $site
	 * @covers \MediaWiki\Site\Site::addInterwikiId
	 */
	public function testAddInterwikiId( Site $site ) {
		$site->addInterwikiId( 'foobar' );
		$this->assertContains( 'foobar', $site->getInterwikiIds() );
	}

	/**
	 * @dataProvider instanceProvider
	 * @param Site $site
	 * @covers \MediaWiki\Site\Site::getLanguageCode
	 */
	public function testGetLanguageCode( Site $site ) {
		$this->assertThat(
			$site->getLanguageCode(),
			$this->logicalOr( $this->isNull(), $this->isType( 'string' ) )
		);
	}

	/**
	 * @dataProvider instanceProvider
	 * @param Site $site
	 * @covers \MediaWiki\Site\Site::setLanguageCode
	 */
	public function testSetLanguageCode( Site $site ) {
		$site->setLanguageCode( 'en' );
		$this->assertEquals( 'en', $site->getLanguageCode() );
	}

	/**
	 * @dataProvider instanceProvider
	 * @param Site $site
	 * @covers \MediaWiki\Site\Site::normalizePageName
	 */
	public function testNormalizePageName( Site $site ) {
		$this->assertIsString( $site->normalizePageName( 'Foobar' ) );
	}

	/**
	 * @dataProvider instanceProvider
	 * @param Site $site
	 * @covers \MediaWiki\Site\Site::getGlobalId
	 */
	public function testGetGlobalId( Site $site ) {
		$this->assertThat(
			$site->getGlobalId(),
			$this->logicalOr( $this->isNull(), $this->isType( 'string' ) )
		);
	}

	/**
	 * @dataProvider instanceProvider
	 * @param Site $site
	 * @covers \MediaWiki\Site\Site::setGlobalId
	 */
	public function testSetGlobalId( Site $site ) {
		$site->setGlobalId( 'foobar' );
		$this->assertEquals( 'foobar', $site->getGlobalId() );
	}

	/**
	 * @dataProvider instanceProvider
	 * @param Site $site
	 * @covers \MediaWiki\Site\Site::getType
	 */
	public function testGetType( Site $site ) {
		$this->assertIsString( $site->getType() );
	}

	/**
	 * @dataProvider instanceProvider
	 * @param Site $site
	 * @covers \MediaWiki\Site\Site::getPath
	 */
	public function testGetPath( Site $site ) {
		$this->assertThat(
			$site->getPath( 'page_path' ),
			$this->logicalOr( $this->isNull(), $this->isType( 'string' ) )
		);
		$this->assertThat(
			$site->getPath( 'file_path' ),
			$this->logicalOr( $this->isNull(), $this->isType( 'string' ) )
		);
		$this->assertThat(
			$site->getPath( 'foobar' ),
			$this->logicalOr( $this->isNull(), $this->isType( 'string' ) )
		);
	}

	/**
	 * @dataProvider instanceProvider
	 * @param Site $site
	 * @covers \MediaWiki\Site\Site::getAllPaths
	 */
	public function testGetAllPaths( Site $site ) {
		$this->assertIsArray( $site->getAllPaths() );
	}

	/**
	 * @dataProvider instanceProvider
	 * @param Site $site
	 * @covers \MediaWiki\Site\Site::setPath
	 * @covers \MediaWiki\Site\Site::removePath
	 */
	public function testSetAndRemovePath( Site $site ) {
		$count = count( $site->getAllPaths() );

		$site->setPath( 'spam', 'http://www.wikidata.org/$1' );
		$site->setPath( 'spam', 'http://www.wikidata.org/foo/$1' );
		$site->setPath( 'foobar', 'http://www.wikidata.org/bar/$1' );

		$this->assertCount( $count + 2, $site->getAllPaths() );

		$this->assertIsString( $site->getPath( 'foobar' ) );
		$this->assertEquals( 'http://www.wikidata.org/foo/$1', $site->getPath( 'spam' ) );

		$site->removePath( 'spam' );
		$site->removePath( 'foobar' );

		$this->assertCount( $count, $site->getAllPaths() );

		$this->assertNull( $site->getPath( 'foobar' ) );
		$this->assertNull( $site->getPath( 'spam' ) );
	}

	/**
	 * @covers \MediaWiki\Site\Site::setLinkPath
	 */
	public function testSetLinkPath() {
		$site = new Site();
		$path = "TestPath/$1";

		$site->setLinkPath( $path );
		$this->assertEquals( $path, $site->getLinkPath() );
	}

	/**
	 * @covers \MediaWiki\Site\Site::getLinkPathType
	 */
	public function testGetLinkPathType() {
		$site = new Site();

		$path = 'TestPath/$1';
		$site->setLinkPath( $path );
		$this->assertEquals( $path, $site->getPath( $site->getLinkPathType() ) );

		$path = 'AnotherPath/$1';
		$site->setPath( $site->getLinkPathType(), $path );
		$this->assertEquals( $path, $site->getLinkPath() );
	}

	/**
	 * @covers \MediaWiki\Site\Site::setPath
	 */
	public function testSetPath() {
		$site = new Site();

		$path = 'TestPath/$1';
		$site->setPath( 'foo', $path );

		$this->assertEquals( $path, $site->getPath( 'foo' ) );
	}

	/**
	 * @covers \MediaWiki\Site\Site::setPath
	 * @covers \MediaWiki\Site\Site::getProtocol
	 */
	public function testProtocolRelativePath() {
		$site = new Site();

		$type = $site->getLinkPathType();
		$path = '//acme.com/'; // protocol-relative URL
		$site->setPath( $type, $path );

		$this->assertSame( '', $site->getProtocol() );
	}

	public static function provideGetPageUrl() {
		// NOTE: the assumption that the URL is built by replacing $1
		//      with the urlencoded version of $page
		//      is true for Site but not guaranteed for subclasses.
		//      Subclasses need to override this provider appropriately.

		return [
			[ # 0
				'http://acme.test/TestPath/$1',
				'Foo',
				'/TestPath/Foo',
			],
			[ # 1
				'http://acme.test/TestScript?x=$1&y=bla',
				'Foo',
				'TestScript?x=Foo&y=bla',
			],
			[ # 2
				'http://acme.test/TestPath/$1',
				'foo & bar/xyzzy (quux-shmoox?)',
				'/TestPath/foo%20%26%20bar%2Fxyzzy%20%28quux-shmoox%3F%29',
			],
		];
	}

	/**
	 * @dataProvider provideGetPageUrl
	 * @covers \MediaWiki\Site\Site::getPageUrl
	 */
	public function testGetPageUrl( $path, $page, $expected ) {
		$site = new Site();

		// NOTE: the assumption that getPageUrl is based on getLinkPath
		//      is true for Site but not guaranteed for subclasses.
		//      Subclasses need to override this test case appropriately.
		$site->setLinkPath( $path );
		$this->assertStringContainsString( $path, $site->getPageUrl() );

		$this->assertStringContainsString( $expected, $site->getPageUrl( $page ) );
	}

	/**
	 * @dataProvider instanceProvider
	 * @param Site $site
	 * @covers \MediaWiki\Site\Site::__serialize
	 * @covers \MediaWiki\Site\Site::__unserialize
	 */
	public function testSerialization( Site $site ) {
		$serialization = serialize( $site );
		$newInstance = unserialize( $serialization );

		$this->assertInstanceOf( Site::class, $newInstance );

		$this->assertEquals( $serialization, serialize( $newInstance ) );
	}
}
PK       ! y,t
  
    site/SiteImporterTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Site;

use Exception;
use MediaWiki\Site\MediaWikiSite;
use MediaWiki\Site\Site;
use MediaWiki\Site\SiteImporter;
use MediaWiki\Site\SiteList;
use MediaWiki\Site\SiteStore;
use MediaWikiIntegrationTestCase;
use Psr\Log\LoggerInterface;

/**
 * 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
 *
 * @ingroup Site
 * @ingroup Test
 *
 * @group Site
 *
 * @covers \MediaWiki\Site\SiteImporter
 *
 * @author Daniel Kinzler
 */
class SiteImporterTest extends MediaWikiIntegrationTestCase {

	private function newSiteImporter( array $expectedSites, $errorCount ) {
		$store = $this->createMock( SiteStore::class );

		$store->expects( $this->once() )
			->method( 'saveSites' )
			->willReturnCallback( function ( $sites ) use ( $expectedSites ) {
				$this->assertSitesEqual( $expectedSites, $sites );
			} );

		$store->method( 'getSites' )
			->willReturn( new SiteList() );

		$errorHandler = $this->createMock( LoggerInterface::class );
		$errorHandler->expects( $this->exactly( $errorCount ) )
			->method( 'error' );

		$importer = new SiteImporter( $store );
		$importer->setExceptionCallback( [ $errorHandler, 'error' ] );

		return $importer;
	}

	public function assertSitesEqual( $expected, $actual, $message = '' ) {
		$this->assertEquals(
			$this->getSerializedSiteList( $expected ),
			$this->getSerializedSiteList( $actual ),
			$message
		);
	}

	public static function provideImportFromXML() {
		$foo = Site::newForType( Site::TYPE_UNKNOWN );
		$foo->setGlobalId( 'Foo' );

		$acme = Site::newForType( Site::TYPE_UNKNOWN );
		$acme->setGlobalId( 'acme.com' );
		$acme->setGroup( 'Test' );
		$acme->addLocalId( Site::ID_INTERWIKI, 'acme' );
		$acme->setPath( Site::PATH_LINK, 'http://acme.com/' );

		$dewiki = Site::newForType( Site::TYPE_MEDIAWIKI );
		$dewiki->setGlobalId( 'dewiki' );
		$dewiki->setGroup( 'wikipedia' );
		$dewiki->setForward( true );
		$dewiki->addLocalId( Site::ID_INTERWIKI, 'wikipedia' );
		$dewiki->addLocalId( Site::ID_EQUIVALENT, 'de' );
		$dewiki->setPath( Site::PATH_LINK, 'http://de.wikipedia.org/w/' );
		$dewiki->setPath( MediaWikiSite::PATH_PAGE, 'http://de.wikipedia.org/wiki/' );
		$dewiki->setSource( 'meta.wikimedia.org' );

		return [
			'empty' => [
				'<sites></sites>',
				[],
			],
			'no sites' => [
				'<sites><Foo><globalid>Foo</globalid></Foo><Bar><quux>Bla</quux></Bar></sites>',
				[],
			],
			'minimal' => [
				'<sites>' .
					'<site><globalid>Foo</globalid></site>' .
				'</sites>',
				[ $foo ],
			],
			'full' => [
				'<sites>' .
					'<site><globalid>Foo</globalid></site>' .
					'<site>' .
						'<globalid>acme.com</globalid>' .
						'<localid type="interwiki">acme</localid>' .
						'<group>Test</group>' .
						'<path type="link">http://acme.com/</path>' .
					'</site>' .
					'<site type="mediawiki">' .
						'<source>meta.wikimedia.org</source>' .
						'<globalid>dewiki</globalid>' .
						'<localid type="interwiki">wikipedia</localid>' .
						'<localid type="equivalent">de</localid>' .
						'<group>wikipedia</group>' .
						'<forward/>' .
						'<path type="link">http://de.wikipedia.org/w/</path>' .
						'<path type="page_path">http://de.wikipedia.org/wiki/</path>' .
					'</site>' .
				'</sites>',
				[ $foo, $acme, $dewiki ],
			],
			'skip' => [
				'<sites>' .
					'<site><globalid>Foo</globalid></site>' .
					'<site><barf>Foo</barf></site>' .
					'<site>' .
						'<globalid>acme.com</globalid>' .
						'<localid type="interwiki">acme</localid>' .
						'<silly>boop!</silly>' .
						'<group>Test</group>' .
						'<path type="link">http://acme.com/</path>' .
					'</site>' .
				'</sites>',
				[ $foo, $acme ],
				1
			],
		];
	}

	/**
	 * @dataProvider provideImportFromXML
	 */
	public function testImportFromXML( $xml, array $expectedSites, $errorCount = 0 ) {
		$importer = $this->newSiteImporter( $expectedSites, $errorCount );
		$importer->importFromXML( $xml );
	}

	public function testImportFromXML_malformed() {
		$this->expectException( Exception::class );

		$store = $this->createMock( SiteStore::class );
		$importer = new SiteImporter( $store );
		$importer->importFromXML( 'THIS IS NOT XML' );
	}

	public function testImportFromFile() {
		$foo = Site::newForType( Site::TYPE_UNKNOWN );
		$foo->setGlobalId( 'Foo' );

		$acme = Site::newForType( Site::TYPE_UNKNOWN );
		$acme->setGlobalId( 'acme.com' );
		$acme->setGroup( 'Test' );
		$acme->addLocalId( Site::ID_INTERWIKI, 'acme' );
		$acme->setPath( Site::PATH_LINK, 'http://acme.com/' );

		$dewiki = Site::newForType( Site::TYPE_MEDIAWIKI );
		$dewiki->setGlobalId( 'dewiki' );
		$dewiki->setGroup( 'wikipedia' );
		$dewiki->setForward( true );
		$dewiki->addLocalId( Site::ID_INTERWIKI, 'wikipedia' );
		$dewiki->addLocalId( Site::ID_EQUIVALENT, 'de' );
		$dewiki->setPath( Site::PATH_LINK, 'http://de.wikipedia.org/w/' );
		$dewiki->setPath( MediaWikiSite::PATH_PAGE, 'http://de.wikipedia.org/wiki/' );
		$dewiki->setSource( 'meta.wikimedia.org' );

		$importer = $this->newSiteImporter( [ $foo, $acme, $dewiki ], 0 );

		$file = __DIR__ . '/SiteImporterTest.xml';
		$importer->importFromFile( $file );
	}

	/**
	 * @param Site[] $sites
	 *
	 * @return array[]
	 */
	private function getSerializedSiteList( $sites ) {
		$serialized = [];

		foreach ( $sites as $site ) {
			$key = $site->getGlobalId();
			$data = unserialize( serialize( $site ) );

			$serialized[$key] = $data;
		}

		return $serialized;
	}
}
PK       ! mE  E    site/DBSiteStoreTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Site;

use MediaWiki\Site\DBSiteStore;
use MediaWiki\Site\MediaWikiSite;
use MediaWiki\Site\Site;
use MediaWiki\Site\SiteList;
use MediaWikiIntegrationTestCase;

/**
 * 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
 * @since 1.21
 *
 * @ingroup Site
 * @ingroup Test
 *
 * @group Site
 * @group Database
 *
 * @author Jeroen De Dauw < jeroendedauw@gmail.com >
 */
class DBSiteStoreTest extends MediaWikiIntegrationTestCase {

	/**
	 * @return DBSiteStore
	 */
	private function newDBSiteStore() {
		return new DBSiteStore( $this->getServiceContainer()->getConnectionProvider() );
	}

	/**
	 * @covers \MediaWiki\Site\DBSiteStore::getSites
	 */
	public function testGetSites() {
		$expectedSites = TestSites::getSites();
		TestSites::insertIntoDb();

		$store = $this->newDBSiteStore();

		$sites = $store->getSites();

		$this->assertInstanceOf( SiteList::class, $sites );

		/**
		 * @var Site $site
		 */
		foreach ( $sites as $site ) {
			$this->assertInstanceOf( Site::class, $site );
		}

		foreach ( $expectedSites as $site ) {
			if ( $site->getGlobalId() !== null ) {
				$this->assertTrue( $sites->hasSite( $site->getGlobalId() ) );
			}
		}
	}

	/**
	 * @covers \MediaWiki\Site\DBSiteStore::saveSites
	 */
	public function testSaveSites() {
		$store = $this->newDBSiteStore();

		$sites = [];

		$site = new Site();
		$site->setGlobalId( 'ertrywuutr' );
		$site->setLanguageCode( 'en' );
		$sites[] = $site;

		$site = new MediaWikiSite();
		$site->setGlobalId( 'sdfhxujgkfpth' );
		$site->setLanguageCode( 'nl' );
		$sites[] = $site;

		$this->assertTrue( $store->saveSites( $sites ) );

		$site = $store->getSite( 'ertrywuutr' );
		$this->assertInstanceOf( Site::class, $site );
		$this->assertEquals( 'en', $site->getLanguageCode() );
		$this->assertIsInt( $site->getInternalId() );
		$this->assertGreaterThanOrEqual( 0, $site->getInternalId() );

		$site = $store->getSite( 'sdfhxujgkfpth' );
		$this->assertInstanceOf( Site::class, $site );
		$this->assertEquals( 'nl', $site->getLanguageCode() );
		$this->assertIsInt( $site->getInternalId() );
		$this->assertGreaterThanOrEqual( 0, $site->getInternalId() );
	}

	/**
	 * @covers \MediaWiki\Site\DBSiteStore::reset
	 */
	public function testReset() {
		TestSites::insertIntoDb();
		$store1 = $this->newDBSiteStore();
		$store2 = $this->newDBSiteStore();

		// initialize internal cache
		$this->assertGreaterThan( 0, $store1->getSites()->count() );
		$this->assertGreaterThan( 0, $store2->getSites()->count() );

		// Clear actual data. Will purge the external cache and reset the internal
		// cache in $store1, but not the internal cache in store2.
		$store1->clear();

		// check: $store2 should have a stale cache now
		$this->assertNotNull( $store2->getSite( 'enwiki' ) );

		// purge cache
		$store2->reset();

		// ...now the internal cache of $store2 should be updated and thus empty.
		$site = $store2->getSite( 'enwiki' );
		$this->assertNull( $site );
	}

	/**
	 * @covers \MediaWiki\Site\DBSiteStore::clear
	 */
	public function testClear() {
		$store = $this->newDBSiteStore();
		$store->clear();

		$site = $store->getSite( 'enwiki' );
		$this->assertNull( $site );

		$sites = $store->getSites();
		$this->assertSame( 0, $sites->count() );
	}

	/**
	 * @covers \MediaWiki\Site\DBSiteStore::getSites
	 */
	public function testGetSitesDefaultOrder() {
		$store = $this->newDBSiteStore();
		$siteB = new Site();
		$siteB->setGlobalId( 'B' );
		$siteA = new Site();
		$siteA->setGlobalId( 'A' );
		$store->saveSites( [ $siteB, $siteA ] );

		$sites = $store->getSites();
		$siteIdentifiers = [];
		/** @var Site $site */
		foreach ( $sites as $site ) {
			$siteIdentifiers[] = $site->getGlobalId();
		}
		$this->assertSame( [ 'A', 'B' ], $siteIdentifiers );

		// Note: SiteList::getGlobalIdentifiers uses an other internal state. Iteration must be
		// tested separately.
		$this->assertSame( [ 'A', 'B' ], $sites->getGlobalIdentifiers() );
	}
}
PK       ! #      site/TestSites.phpnu Iw        <?php

namespace MediaWiki\Tests\Site;

use MediaWiki\MediaWikiServices;
use MediaWiki\Site\MediaWikiSite;
use MediaWiki\Site\Site;

/**
 * Holds sites for testing purposes.
 *
 * 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
 * @since 1.21
 *
 * @ingroup Site
 * @ingroup Test
 *
 * @author Jeroen De Dauw < jeroendedauw@gmail.com >
 */
class TestSites {

	/**
	 * @since 1.21
	 *
	 * @return array
	 */
	public static function getSites() {
		$sites = [];

		$site = new Site();
		$site->setGlobalId( 'foobar' );
		$sites[] = $site;

		$site = new MediaWikiSite();
		$site->setGlobalId( 'enwiktionary' );
		$site->setGroup( 'wiktionary' );
		$site->setLanguageCode( 'en' );
		$site->addNavigationId( 'enwiktionary' );
		$site->setPath( MediaWikiSite::PATH_PAGE, "https://en.wiktionary.org/wiki/$1" );
		$site->setPath( MediaWikiSite::PATH_FILE, "https://en.wiktionary.org/w/$1" );
		$sites[] = $site;

		$site = new MediaWikiSite();
		$site->setGlobalId( 'dewiktionary' );
		$site->setGroup( 'wiktionary' );
		$site->setLanguageCode( 'de' );
		$site->addInterwikiId( 'dewiktionary' );
		$site->addInterwikiId( 'wiktionaryde' );
		$site->setPath( MediaWikiSite::PATH_PAGE, "https://de.wiktionary.org/wiki/$1" );
		$site->setPath( MediaWikiSite::PATH_FILE, "https://de.wiktionary.org/w/$1" );
		$sites[] = $site;

		$site = new Site();
		$site->setGlobalId( 'spam' );
		$site->setGroup( 'spam' );
		$site->setLanguageCode( 'en' );
		$site->addNavigationId( 'spam' );
		$site->addNavigationId( 'spamz' );
		$site->addInterwikiId( 'spamzz' );
		$site->setLinkPath( "http://spamzz.test/testing/" );
		$sites[] = $site;

		/**
		 * Add at least one right-to-left language (current RTL languages in MediaWiki core are:
		 * aeb, ar, arc, arz, azb, bcc, bqi, ckb, dv, en_rtl, fa, glk, he, khw, kk_arab, kk_cn,
		 * ks_arab, ku_arab, lrc, mzn, pnb, ps, sd, ug_arab, ur, yi).
		 */
		$languageCodes = [
			'de',
			'en',
			'fa', // right-to-left
			'nl',
			'nn',
			'no',
			'sr',
			'sv',
		];
		foreach ( $languageCodes as $langCode ) {
			$site = new MediaWikiSite();
			$site->setGlobalId( $langCode . 'wiki' );
			$site->setGroup( 'wikipedia' );
			$site->setLanguageCode( $langCode );
			$site->addInterwikiId( $langCode );
			$site->addNavigationId( $langCode );
			$site->setPath( MediaWikiSite::PATH_PAGE, "https://$langCode.wikipedia.org/wiki/$1" );
			$site->setPath( MediaWikiSite::PATH_FILE, "https://$langCode.wikipedia.org/w/$1" );
			$sites[] = $site;
		}

		return $sites;
	}

	/**
	 * Inserts sites into the database for the unit tests that need them.
	 *
	 * @since 0.1
	 */
	public static function insertIntoDb() {
		$sitesTable = MediaWikiServices::getInstance()->getSiteStore();
		$sitesTable->clear();
		$sitesTable->saveSites( self::getSites() );
	}
}

/** @deprecated class alias since 1.42 */
class_alias( TestSites::class, 'TestSites' );
PK       ! Bq.  .    site/MediaWikiSiteTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Site;

use MediaWiki\MainConfigNames;
use MediaWiki\Site\MediaWikiSite;

/**
 * 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
 * @since 1.21
 *
 * @ingroup Site
 * @ingroup Test
 *
 * @group Site
 *
 * @author Jeroen De Dauw < jeroendedauw@gmail.com >
 */
class MediaWikiSiteTest extends SiteTest {

	/**
	 * @covers \MediaWiki\Site\MediaWikiSite::normalizePageName
	 */
	public function testNormalizePageTitle() {
		$this->overrideConfigValue( MainConfigNames::CapitalLinks, true );

		$site = new MediaWikiSite();
		$site->setGlobalId( 'enwiki' );

		// NOTE: this does not actually call out to the enwiki site to perform the normalization,
		//      but uses a local Title object to do so. This is hardcoded on SiteLink::normalizePageTitle
		//      for the case that MW_PHPUNIT_TEST is set.
		$this->assertEquals( 'Foo', $site->normalizePageName( ' foo ' ) );
	}

	public static function fileUrlProvider() {
		return [
			// url, filepath, path arg, expected
			[ 'https://en.wikipedia.org', '/w/$1', 'api.php', 'https://en.wikipedia.org/w/api.php' ],
			[ 'https://en.wikipedia.org', '/w/', 'api.php', 'https://en.wikipedia.org/w/' ],
			[
				'https://en.wikipedia.org',
				'/foo/page.php?name=$1',
				'api.php',
				'https://en.wikipedia.org/foo/page.php?name=api.php'
			],
			[
				'https://en.wikipedia.org',
				'/w/$1',
				'',
				'https://en.wikipedia.org/w/'
			],
			[
				'https://en.wikipedia.org',
				'/w/$1',
				'foo/bar/api.php',
				'https://en.wikipedia.org/w/foo/bar/api.php'
			],
		];
	}

	/**
	 * @dataProvider fileUrlProvider
	 * @covers \MediaWiki\Site\MediaWikiSite::getFileUrl
	 */
	public function testGetFileUrl( $url, $filePath, $pathArgument, $expected ) {
		$site = new MediaWikiSite();
		$site->setFilePath( $url . $filePath );

		$this->assertEquals( $expected, $site->getFileUrl( $pathArgument ) );
	}

	public static function provideGetPageUrl() {
		return [
			// path, page, expected substring
			[ 'http://acme.test/wiki/$1', 'Berlin', '/wiki/Berlin' ],
			[ 'http://acme.test/wiki/', 'Berlin', '/wiki/' ],
			[ 'http://acme.test/w/index.php?title=$1', 'Berlin', '/w/index.php?title=Berlin' ],
			[ 'http://acme.test/wiki/$1', '', '/wiki/' ],
			[ 'http://acme.test/wiki/$1', 'Berlin/subpage', '/wiki/Berlin/subpage' ],
			[ 'http://acme.test/wiki/$1', 'Berlin/subpage with spaces', '/wiki/Berlin/subpage_with_spaces' ],
			[ 'http://acme.test/wiki/$1', 'Cork (city)   ', '/Cork_(city)' ],
			[ 'http://acme.test/wiki/$1', 'M&M', '/wiki/M%26M' ],
		];
	}

	/**
	 * @dataProvider provideGetPageUrl
	 * @covers \MediaWiki\Site\MediaWikiSite::getPageUrl
	 */
	public function testGetPageUrl( $path, $page, $expected ) {
		$site = new MediaWikiSite();
		$site->setLinkPath( $path );

		$this->assertStringContainsString( $path, $site->getPageUrl() );
		$this->assertStringContainsString( $expected, $site->getPageUrl( $page ) );
	}
}
PK       ! `r      site/SiteListTest.phpnu Iw        <?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
 */

namespace MediaWiki\Tests\Site;

use MediaWiki\Site\Site;
use MediaWiki\Site\SiteList;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Site\SiteList
 * @group Site
 * @author Jeroen De Dauw < jeroendedauw@gmail.com >
 */
class SiteListTest extends MediaWikiIntegrationTestCase {

	/**
	 * Returns instances of SiteList implementing objects.
	 * @return array
	 */
	public function siteListProvider() {
		$sitesArrays = $this->siteArrayProvider();

		$listInstances = [];

		foreach ( $sitesArrays as $sitesArray ) {
			$listInstances[] = new SiteList( $sitesArray[0] );
		}

		return $this->arrayWrap( $listInstances );
	}

	/**
	 * Returns arrays with instances of Site implementing objects.
	 * @return array
	 */
	public function siteArrayProvider() {
		$sites = TestSites::getSites();

		$siteArrays = [];

		$siteArrays[] = $sites;

		$siteArrays[] = [ array_shift( $sites ) ];

		$siteArrays[] = [ array_shift( $sites ), array_shift( $sites ) ];

		return $this->arrayWrap( $siteArrays );
	}

	/**
	 * @dataProvider siteListProvider
	 * @param SiteList $sites
	 */
	public function testIsEmpty( SiteList $sites ) {
		$this->assertEquals( count( $sites ) === 0, $sites->isEmpty() );
	}

	/**
	 * @dataProvider siteListProvider
	 * @param SiteList $sites
	 */
	public function testGetSiteByGlobalId( SiteList $sites ) {
		/**
		 * @var Site $site
		 */
		foreach ( $sites as $site ) {
			$this->assertEquals( $site, $sites->getSite( $site->getGlobalId() ) );
		}

		$this->assertTrue( true );
	}

	/**
	 * @dataProvider siteListProvider
	 * @param SiteList $sites
	 */
	public function testGetSiteByInternalId( $sites ) {
		/**
		 * @var Site $site
		 */
		foreach ( $sites as $site ) {
			if ( is_int( $site->getInternalId() ) ) {
				$this->assertEquals( $site, $sites->getSiteByInternalId( $site->getInternalId() ) );
			}
		}

		$this->assertTrue( true );
	}

	/**
	 * @dataProvider siteListProvider
	 * @param SiteList $sites
	 */
	public function testGetSiteByNavigationId( $sites ) {
		/**
		 * @var Site $site
		 */
		foreach ( $sites as $site ) {
			$ids = $site->getNavigationIds();
			foreach ( $ids as $navId ) {
				$this->assertEquals( $site, $sites->getSiteByNavigationId( $navId ) );
			}
		}

		$this->assertTrue( true );
	}

	/**
	 * @dataProvider siteListProvider
	 * @param SiteList $sites
	 */
	public function testHasGlobalId( $sites ) {
		$this->assertFalse( $sites->hasSite( 'non-existing-global-id' ) );
		$this->assertFalse( $sites->hasInternalId( 720101010 ) );

		if ( !$sites->isEmpty() ) {
			/**
			 * @var Site $site
			 */
			foreach ( $sites as $site ) {
				$this->assertTrue( $sites->hasSite( $site->getGlobalId() ) );
			}
		}
	}

	/**
	 * @dataProvider siteListProvider
	 * @param SiteList $sites
	 */
	public function testHasInternallId( $sites ) {
		/**
		 * @var Site $site
		 */
		foreach ( $sites as $site ) {
			if ( is_int( $site->getInternalId() ) ) {
				$this->assertTrue( $site, $sites->hasInternalId( $site->getInternalId() ) );
			}
		}

		$this->assertFalse( $sites->hasInternalId( -1 ) );
	}

	/**
	 * @dataProvider siteListProvider
	 * @param SiteList $sites
	 */
	public function testHasNavigationId( $sites ) {
		/**
		 * @var Site $site
		 */
		foreach ( $sites as $site ) {
			$ids = $site->getNavigationIds();
			foreach ( $ids as $navId ) {
				$this->assertTrue( $sites->hasNavigationId( $navId ) );
			}
		}

		$this->assertFalse( $sites->hasNavigationId( 'non-existing-navigation-id' ) );
	}

	/**
	 * @dataProvider siteListProvider
	 * @param SiteList $sites
	 */
	public function testGetGlobalIdentifiers( SiteList $sites ) {
		$identifiers = $sites->getGlobalIdentifiers();

		$this->assertIsArray( $identifiers );

		$expected = [];

		/**
		 * @var Site $site
		 */
		foreach ( $sites as $site ) {
			$expected[] = $site->getGlobalId();
		}

		$this->assertArrayEquals( $expected, $identifiers );
	}

	/**
	 * @dataProvider siteListProvider
	 * @param SiteList $list
	 */
	public function testSerialization( SiteList $list ) {
		$serialization = serialize( $list );
		/**
		 * @var SiteList $copy
		 */
		$copy = unserialize( $serialization );

		$this->assertArrayEquals( $list->getGlobalIdentifiers(), $copy->getGlobalIdentifiers() );

		/**
		 * @var Site $site
		 */
		foreach ( $list as $site ) {
			$this->assertTrue( $copy->hasInternalId( $site->getInternalId() ) );

			foreach ( $site->getNavigationIds() as $navId ) {
				$this->assertTrue(
					$copy->hasNavigationId( $navId ),
					'unserialized data expects nav id ' . $navId . ' for site ' . $site->getGlobalId()
				);
			}
		}
	}
}
PK       !       site/SiteExporterTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Site;

use DOMDocument;
use InvalidArgumentException;
use MediaWiki\Site\MediaWikiSite;
use MediaWiki\Site\Site;
use MediaWiki\Site\SiteExporter;
use MediaWiki\Site\SiteImporter;
use MediaWiki\Site\SiteList;
use MediaWiki\Site\SiteStore;
use MediaWikiIntegrationTestCase;

/**
 * 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
 *
 * @ingroup Site
 * @ingroup Test
 *
 * @group Site
 *
 * @covers \MediaWiki\Site\SiteExporter
 *
 * @author Daniel Kinzler
 */
class SiteExporterTest extends MediaWikiIntegrationTestCase {

	public function testConstructor_InvalidArgument() {
		$this->expectException( InvalidArgumentException::class );

		new SiteExporter( 'Foo' );
	}

	public function testExportSites() {
		$foo = Site::newForType( Site::TYPE_UNKNOWN );
		$foo->setGlobalId( 'Foo' );

		$acme = Site::newForType( Site::TYPE_UNKNOWN );
		$acme->setGlobalId( 'acme.com' );
		$acme->setGroup( 'Test' );
		$acme->addLocalId( Site::ID_INTERWIKI, 'acme' );
		$acme->setPath( Site::PATH_LINK, 'http://acme.com/' );

		$tmp = tmpfile();
		$exporter = new SiteExporter( $tmp );

		$exporter->exportSites( [ $foo, $acme ] );

		fseek( $tmp, 0 );
		$xml = fread( $tmp, 16 * 1024 );

		$this->assertStringContainsString( '<sites ', $xml );
		$this->assertStringContainsString( '<site>', $xml );
		$this->assertStringContainsString( '<globalid>Foo</globalid>', $xml );
		$this->assertStringContainsString( '</site>', $xml );
		$this->assertStringContainsString( '<globalid>acme.com</globalid>', $xml );
		$this->assertStringContainsString( '<group>Test</group>', $xml );
		$this->assertStringContainsString( '<localid type="interwiki">acme</localid>', $xml );
		$this->assertStringContainsString( '<path type="link">http://acme.com/</path>', $xml );
		$this->assertStringContainsString( '</sites>', $xml );

		$xsdFile = __DIR__ . '/../../../../docs/sitelist-1.0.xsd';
		$xsdData = file_get_contents( $xsdFile );

		$document = new DOMDocument();
		$document->loadXML( $xml, LIBXML_NONET );
		$document->schemaValidateSource( $xsdData );
	}

	private function newSiteStore( SiteList $sites ) {
		$store = $this->createMock( SiteStore::class );

		$store->expects( $this->once() )
			->method( 'saveSites' )
			->willReturnCallback( static function ( $moreSites ) use ( $sites ) {
				foreach ( $moreSites as $site ) {
					$sites->setSite( $site );
				}
			} );

		$store->method( 'getSites' )
			->willReturn( new SiteList() );

		return $store;
	}

	public static function provideRoundTrip() {
		$foo = Site::newForType( Site::TYPE_UNKNOWN );
		$foo->setGlobalId( 'Foo' );

		$acme = Site::newForType( Site::TYPE_UNKNOWN );
		$acme->setGlobalId( 'acme.com' );
		$acme->setGroup( 'Test' );
		$acme->addLocalId( Site::ID_INTERWIKI, 'acme' );
		$acme->setPath( Site::PATH_LINK, 'http://acme.com/' );

		$dewiki = Site::newForType( Site::TYPE_MEDIAWIKI );
		$dewiki->setGlobalId( 'dewiki' );
		$dewiki->setGroup( 'wikipedia' );
		$dewiki->setForward( true );
		$dewiki->addLocalId( Site::ID_INTERWIKI, 'wikipedia' );
		$dewiki->addLocalId( Site::ID_EQUIVALENT, 'de' );
		$dewiki->setPath( Site::PATH_LINK, 'http://de.wikipedia.org/w/' );
		$dewiki->setPath( MediaWikiSite::PATH_PAGE, 'http://de.wikipedia.org/wiki/' );
		$dewiki->setSource( 'meta.wikimedia.org' );

		return [
			'empty' => [
				new SiteList()
			],

			'some' => [
				new SiteList( [ $foo, $acme, $dewiki ] ),
			],
		];
	}

	/**
	 * @dataProvider provideRoundTrip()
	 */
	public function testRoundTrip( SiteList $sites ) {
		$tmp = tmpfile();
		$exporter = new SiteExporter( $tmp );

		$exporter->exportSites( $sites );

		fseek( $tmp, 0 );
		$xml = fread( $tmp, 16 * 1024 );

		$actualSites = new SiteList();
		$store = $this->newSiteStore( $actualSites );

		$importer = new SiteImporter( $store );
		$importer->importFromXML( $xml );

		$this->assertEquals( $sites, $actualSites );
	}

}
PK       ! \      site/CachingSiteStoreTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Site;

use MediaWiki\MediaWikiServices;
use MediaWiki\Site\CachingSiteStore;
use MediaWiki\Site\HashSiteStore;
use MediaWiki\Site\MediaWikiSite;
use MediaWiki\Site\Site;
use MediaWiki\Site\SiteList;
use MediaWiki\Site\SiteStore;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Site\CachingSiteStore
 * @group Site
 * @group Database
 * @author Jeroen De Dauw < jeroendedauw@gmail.com >
 */
class CachingSiteStoreTest extends MediaWikiIntegrationTestCase {

	public function testGetSites() {
		$testSites = TestSites::getSites();
		$services = MediaWikiServices::getInstance();

		$store = new CachingSiteStore(
			$this->getHashSiteStore( $testSites ),
			$services->getObjectCacheFActory()
				->getLocalClusterInstance()
		);

		$sites = $store->getSites();

		$this->assertInstanceOf( SiteList::class, $sites );

		/** @var Site $site */
		foreach ( $sites as $site ) {
			$this->assertInstanceOf( Site::class, $site );
		}

		foreach ( $testSites as $site ) {
			if ( $site->getGlobalId() !== null ) {
				$this->assertTrue( $sites->hasSite( $site->getGlobalId() ) );
			}
		}
	}

	public function testSaveSites() {
		$services = MediaWikiServices::getInstance();
		$store = new CachingSiteStore(
			new HashSiteStore(),
			$services->getObjectCacheFActory()->getLocalClusterInstance()
		);

		$sites = [];

		$site = new Site();
		$site->setGlobalId( 'ertrywuutr' );
		$site->setLanguageCode( 'en' );
		$sites[] = $site;

		$site = new MediaWikiSite();
		$site->setGlobalId( 'sdfhxujgkfpth' );
		$site->setLanguageCode( 'nl' );
		$sites[] = $site;

		$this->assertTrue( $store->saveSites( $sites ) );

		$site = $store->getSite( 'ertrywuutr' );
		$this->assertInstanceOf( Site::class, $site );
		$this->assertEquals( 'en', $site->getLanguageCode() );

		$site = $store->getSite( 'sdfhxujgkfpth' );
		$this->assertInstanceOf( Site::class, $site );
		$this->assertEquals( 'nl', $site->getLanguageCode() );
	}

	public function testReset() {
		$dbSiteStore = $this->createMock( SiteStore::class );

		$dbSiteStore->method( 'getSite' )
			->willReturn( $this->getTestSite() );

		$services = MediaWikiServices::getInstance()->getObjectCacheFactory();

		$dbSiteStore->method( 'getSites' )
			->willReturnCallback( function () {
				$siteList = new SiteList();
				$siteList->setSite( $this->getTestSite() );

				return $siteList;
			} );

		$store = new CachingSiteStore( $dbSiteStore, $services->getLocalClusterInstance() );

		// initialize internal cache
		$this->assertGreaterThan( 0, $store->getSites()->count(), 'count sites' );

		$store->getSite( 'enwiki' )->setLanguageCode( 'en-ca' );

		// check: $store should have the new language code for 'enwiki'
		$this->assertEquals( 'en-ca', $store->getSite( 'enwiki' )->getLanguageCode() );

		// purge cache
		$store->reset();

		// the internal cache of $store should be updated, and now pulling
		// the site from the 'fallback' DBSiteStore with the original language code.
		$this->assertEquals( 'en', $store->getSite( 'enwiki' )->getLanguageCode(), 'reset' );
	}

	public function getTestSite() {
		$enwiki = new MediaWikiSite();
		$enwiki->setGlobalId( 'enwiki' );
		$enwiki->setLanguageCode( 'en' );
		return $enwiki;
	}

	public function testClear() {
		$services = MediaWikiServices::getInstance()->getObjectCacheFactory();
		$store = new CachingSiteStore(
			new HashSiteStore(), $services->getLocalClusterInstance()
		);
		$this->assertTrue( $store->clear() );

		$site = $store->getSite( 'enwiki' );
		$this->assertNull( $site );

		$sites = $store->getSites();
		$this->assertSame( 0, $sites->count() );
	}

	/**
	 * @param Site[] $sites
	 * @return SiteStore
	 */
	private function getHashSiteStore( array $sites ) {
		$siteStore = new HashSiteStore();
		$siteStore->saveSites( $sites );
		return $siteStore;
	}

}
PK       ! YR:h"  h"  #  password/UserPasswordPolicyTest.phpnu Iw        <?php
/**
 * Testing for password-policy enforcement, based on a user's groups.
 *
 * 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\Password\UserPasswordPolicy;
use MediaWiki\Status\Status;
use MediaWiki\User\User;

/**
 * @group Database
 * @covers \MediaWiki\Password\UserPasswordPolicy
 */
class UserPasswordPolicyTest extends MediaWikiIntegrationTestCase {

	private const POLICIES = [
		'checkuser' => [
			'MinimalPasswordLength' => [ 'value' => 10, 'forceChange' => true ],
			'MinimumPasswordLengthToLogin' => 6,
		],
		'sysop' => [
			'MinimalPasswordLength' => [ 'value' => 8, 'suggestChangeOnLogin' => true ],
			'MinimumPasswordLengthToLogin' => 1,
		],
		'bureaucrat' => [
			'MinimalPasswordLength' => [
				'value' => 6,
				'suggestChangeOnLogin' => false,
				'forceChange' => true,
			],
		],
		'default' => [
			'MinimalPasswordLength' => 4,
			'MinimumPasswordLengthToLogin' => 1,
			'PasswordCannotMatchDefaults' => true,
			'MaximalPasswordLength' => 4096,
			'PasswordCannotBeSubstringInUsername' => true,
		],
	];

	private const CHECKS = [
		'MinimalPasswordLength' => 'MediaWiki\Password\PasswordPolicyChecks::checkMinimalPasswordLength',
		'MinimumPasswordLengthToLogin' => 'MediaWiki\Password\PasswordPolicyChecks::checkMinimumPasswordLengthToLogin',
		'PasswordCannotBeSubstringInUsername' =>
			'MediaWiki\Password\PasswordPolicyChecks::checkPasswordCannotBeSubstringInUsername',
		'PasswordCannotMatchDefaults' => 'MediaWiki\Password\PasswordPolicyChecks::checkPasswordCannotMatchDefaults',
		'MaximalPasswordLength' => 'MediaWiki\Password\PasswordPolicyChecks::checkMaximalPasswordLength',
	];

	private function getUserPasswordPolicy() {
		return new UserPasswordPolicy( self::POLICIES, self::CHECKS );
	}

	public function testGetPoliciesForUser() {
		$upp = $this->getUserPasswordPolicy();

		$user = $this->getTestUser( [ 'sysop' ] )->getUser();
		$this->assertArrayEquals(
			[
				'MinimalPasswordLength' => [ 'value' => 8, 'suggestChangeOnLogin' => true ],
				'MinimumPasswordLengthToLogin' => 1,
				'PasswordCannotBeSubstringInUsername' => true,
				'PasswordCannotMatchDefaults' => true,
				'MaximalPasswordLength' => 4096,
			],
			$upp->getPoliciesForUser( $user )
		);

		$user = $this->getTestUser( [ 'sysop', 'checkuser' ] )->getUser();
		$this->assertArrayEquals(
			[
				'MinimalPasswordLength' => [
					'value' => 10,
					'forceChange' => true,
					'suggestChangeOnLogin' => true
				],
				'MinimumPasswordLengthToLogin' => 6,
				'PasswordCannotBeSubstringInUsername' => true,
				'PasswordCannotMatchDefaults' => true,
				'MaximalPasswordLength' => 4096,
			],
			$upp->getPoliciesForUser( $user )
		);
	}

	public function testGetPoliciesForGroups() {
		$effective = UserPasswordPolicy::getPoliciesForGroups(
			self::POLICIES,
			[ 'user', 'checkuser', 'sysop' ],
			self::POLICIES['default']
		);

		$this->assertArrayEquals(
			[
				'MinimalPasswordLength' => [
					'value' => 10,
					'forceChange' => true,
					'suggestChangeOnLogin' => true
				],
				'MinimumPasswordLengthToLogin' => 6,
				'PasswordCannotBeSubstringInUsername' => true,
				'PasswordCannotMatchDefaults' => true,
				'MaximalPasswordLength' => 4096,
			],
			$effective
		);
	}

	/**
	 * @dataProvider provideCheckUserPassword
	 */
	public function testCheckUserPassword( $groups, $password, StatusValue $expectedStatus ) {
		$upp = $this->getUserPasswordPolicy();
		$user = $this->getTestUser( $groups )->getUser();

		$status = $upp->checkUserPassword( $user, $password );
		$this->assertSame( $expectedStatus->isGood(), $status->isGood(), 'password valid' );
		$this->assertSame( $expectedStatus->isOK(), $status->isOK(), 'can login' );
		$this->assertSame( $expectedStatus->getValue(), $status->getValue(), 'flags' );
	}

	public static function provideCheckUserPassword() {
		$success = Status::newGood( [] );
		$warning = Status::newGood( [] );
		$forceChange = Status::newGood( [ 'forceChange' => true ] );
		$suggestChangeOnLogin = Status::newGood( [ 'suggestChangeOnLogin' => true ] );
		$fatal = Status::newGood( [] );

		// the message does not matter, we only test for state and value
		$warning->warning( 'invalid-password' );
		$forceChange->warning( 'invalid-password' );
		$suggestChangeOnLogin->warning( 'invalid-password' );
		$warning->warning( 'invalid-password' );
		$fatal->fatal( 'invalid-password' );

		return [
			'No groups, default policy, password too short to login' => [
				[],
				'',
				$fatal,
			],
			'Default policy, short password' => [
				[ 'user' ],
				'aaa',
				$warning,
			],
			'Sysop with good password' => [
				[ 'sysop' ],
				'abcdabcdabcd',
				$success,
			],
			'Sysop with short password and suggestChangeOnLogin set to true' => [
				[ 'sysop' ],
				'abcd',
				$suggestChangeOnLogin,
			],
			'Checkuser with short password' => [
				[ 'checkuser' ],
				'abcdabcd',
				$forceChange,
			],
			'Bureaucrat bad password with forceChange true, suggestChangeOnLogin false' => [
				[ 'bureaucrat' ],
				'short',
				$forceChange,
			],
			'Checkuser with too short password to login' => [
				[ 'sysop', 'checkuser' ],
				'abcd',
				$fatal,
			],
		];
	}

	public function testCheckUserPassword_disallowed() {
		$upp = $this->getUserPasswordPolicy();
		$user = User::newFromName( 'Useruser' );
		$user->addToDatabase();

		$status = $upp->checkUserPassword( $user, 'Passpass' );
		$this->assertStatusWarning( 'password-login-forbidden', $status );
	}

	/**
	 * @dataProvider provideMaxOfPolicies
	 */
	public function testMaxOfPolicies( $p1, $p2, $max ) {
		$this->assertArrayEquals(
			$max,
			UserPasswordPolicy::maxOfPolicies( $p1, $p2 )
		);
	}

	public static function provideMaxOfPolicies() {
		return [
			'Basic max in p1' => [
				[ 'MinimalPasswordLength' => 8 ], // p1
				[ 'MinimalPasswordLength' => 2 ], // p2
				[ 'MinimalPasswordLength' => 8 ], // max
			],
			'Basic max in p2' => [
				[ 'MinimalPasswordLength' => 2 ], // p1
				[ 'MinimalPasswordLength' => 8 ], // p2
				[ 'MinimalPasswordLength' => 8 ], // max
			],
			'Missing items in p1' => [
				[
					'MinimalPasswordLength' => 8,
				], // p1
				[
					'MinimalPasswordLength' => 2,
					'PasswordCannotBeSubstringInUsername' => 1,
				], // p2
				[
					'MinimalPasswordLength' => 8,
					'PasswordCannotBeSubstringInUsername' => 1,
				], // max
			],
			'Missing items in p2' => [
				[
					'MinimalPasswordLength' => 8,
					'PasswordCannotBeSubstringInUsername' => 1,
				], // p1
				[
					'MinimalPasswordLength' => 2,
				], // p2
				[
					'MinimalPasswordLength' => 8,
					'PasswordCannotBeSubstringInUsername' => 1,
				], // max
			],
			'complex value in p1' => [
				[
					'MinimalPasswordLength' => [
						'value' => 8,
						'foo' => 1,
					],
				], // p1
				[
					'MinimalPasswordLength' => 2,
				], // p2
				[
					'MinimalPasswordLength' => [
						'value' => 8,
						'foo' => 1,
					],
				], // max
			],
			'complex value in p2' => [
				[
					'MinimalPasswordLength' => 8,
				], // p1
				[
					'MinimalPasswordLength' => [
						'value' => 2,
						'foo' => 1,
					],
				], // p2
				[
					'MinimalPasswordLength' => [
						'value' => 8,
						'foo' => 1,
					],
				], // max
			],
			'complex value in both p1 and p2' => [
				[
					'MinimalPasswordLength' => [
						'value' => 8,
						'foo' => 1,
						'baz' => false,
					],
				], // p1
				[
					'MinimalPasswordLength' => [
						'value' => 2,
						'bar' => 2,
						'baz' => true,
					],
				], // p2
				[
					'MinimalPasswordLength' => [
						'value' => 8,
						'foo' => 1,
						'bar' => 2,
						'baz' => true,
					],
				], // max
			],
			'complex value in both p1 and p2 #2' => [
				[
					'MinimalPasswordLength' => [
						'value' => 8,
						'foo' => 1,
						'baz' => false,
					],
				], // p1
				[
					'MinimalPasswordLength' => [
						'value' => 2,
						'bar' => true
					],
				], // p2
				[
					'MinimalPasswordLength' => [
						'value' => 8,
						'foo' => 1,
						'bar' => true,
						'baz' => false,
					],
				], // max
			],
		];
	}

}
PK       ! W2    *  collation/CustomUppercaseCollationTest.phpnu Iw        <?php

/**
 * TODO convert to a Unit test
 *
 * @covers \CustomUppercaseCollation
 */
class CustomUppercaseCollationTest extends MediaWikiIntegrationTestCase {

	/** @var CustomUppercaseCollation */
	private $collation;

	protected function setUp(): void {
		parent::setUp();
		$this->collation = new CustomUppercaseCollation(
			$this->getServiceContainer()->getLanguageFactory(),
			[
				'D',
				'C',
				'Cs',
				'B'
			],
			'en' // digital transformation language
		);
	}

	/**
	 * @dataProvider providerOrder
	 */
	public function testOrder( $first, $second, $msg ) {
		$sortkey1 = $this->collation->getSortKey( $first );
		$sortkey2 = $this->collation->getSortKey( $second );

		$this->assertTrue( strcmp( $sortkey1, $sortkey2 ) < 0, $msg );
	}

	public static function providerOrder() {
		return [
			[ 'X', 'Z', 'Maintain order of unrearranged' ],
			[ 'D', 'C', 'Actually resorts' ],
			[ 'D', 'B', 'resort test 2' ],
			[ 'Adobe', 'Abode', 'not first letter' ],
			[ '💩 ', 'C', 'Test relocated to end' ],
			[ 'c', 'b', 'lowercase' ],
			[ 'x', 'z', 'lowercase original' ],
			[ 'Cz', 'Cs', 'digraphs' ],
			[ 'C50D', 'C100', 'Numbers' ]
		];
	}

	/**
	 * @dataProvider provideGetFirstLetter
	 */
	public function testGetFirstLetter( $string, $first ) {
		$this->assertSame( $this->collation->getFirstLetter( $string ), $first );
	}

	public static function provideGetFirstLetter() {
		return [
			[ 'Do', 'D' ],
			[ 'do', 'D' ],
			[ 'Ao', 'A' ],
			[ 'afdsa', 'A' ],
			[ "\u{F3000}Foo", 'D' ],
			[ "\u{F3001}Foo", 'C' ],
			[ "\u{F3002}Foo", 'Cs' ],
			[ "\u{F3003}Foo", 'B' ],
			[ "\u{F3004}Foo", "\u{F3004}" ],
			[ 'C', 'C' ],
			[ 'Cz', 'C' ],
			[ 'Cs', 'Cs' ],
			[ 'CS', 'Cs' ],
			[ 'cs', 'Cs' ],
		];
	}
}
PK       ! t'  '    collation/CollationTest.phpnu Iw        <?php

/**
 * @covers \Collation
 * @covers \IcuCollation
 * @covers \IdentityCollation
 * @covers \UppercaseCollation
 */
class CollationTest extends MediaWikiLangTestCase {

	/**
	 * Test to make sure, that if you
	 * have "X" and "XY", the binary
	 * sortkey also has "X" being a
	 * prefix of "XY". Our collation
	 * code makes this assumption.
	 *
	 * @param string $lang Language code for collator
	 * @param string $base
	 * @param string $extended String containing base as a prefix.
	 *
	 * @covers \Collation::getSortKey()
	 * @covers \IcuCollation::getSortKey()
	 * @covers \IdentityCollation::getSortKey()
	 * @covers \UppercaseCollation::getSortKey()
	 * @dataProvider prefixDataProvider
	 */
	public function testIsPrefix( $lang, $base, $extended ) {
		$cp = Collator::create( $lang );
		$cp->setStrength( Collator::PRIMARY );
		$baseBin = $cp->getSortKey( $base );
		$extendedBin = $cp->getSortKey( $extended );
		$this->assertStringStartsWith( $baseBin, $extendedBin, "$base is not a prefix of $extended" );
	}

	public static function prefixDataProvider() {
		return [
			[ 'en', 'A', 'AA' ],
			[ 'en', 'A', 'AAA' ],
			[ 'en', 'Д', 'ДЂ' ],
			[ 'en', 'Д', 'ДA' ],
			// 'Ʒ' should expand to 'Z ' (note space).
			[ 'fi', 'Z', 'Ʒ' ],
			// 'Þ' should expand to 'th'
			[ 'sv', 't', 'Þ' ],
			// Javanese is a limited use alphabet, so should have 3 bytes
			// per character, so do some tests with it.
			[ 'en', 'ꦲ', 'ꦲꦤ' ],
			[ 'en', 'ꦲ', 'ꦲД' ],
			[ 'en', 'A', 'Aꦲ' ],
		];
	}

	/**
	 * Opposite of testIsPrefix
	 *
	 * @covers \Collation::getSortKey()
	 * @covers \IcuCollation::getSortKey()
	 * @covers \IdentityCollation::getSortKey()
	 * @covers \UppercaseCollation::getSortKey()
	 * @dataProvider notPrefixDataProvider
	 */
	public function testNotIsPrefix( $lang, $base, $extended ) {
		$cp = Collator::create( $lang );
		$cp->setStrength( Collator::PRIMARY );
		$baseBin = $cp->getSortKey( $base );
		$extendedBin = $cp->getSortKey( $extended );
		$this->assertStringStartsNotWith( $baseBin, $extendedBin, "$base is a prefix of $extended" );
	}

	public static function notPrefixDataProvider() {
		return [
			[ 'en', 'A', 'B' ],
			[ 'en', 'AC', 'ABC' ],
			[ 'en', 'Z', 'Ʒ' ],
			[ 'en', 'A', 'ꦲ' ],
		];
	}

	/**
	 * Test correct first letter is fetched.
	 *
	 * @param string $collation Collation name (aka uca-en)
	 * @param string $string String to get first letter of
	 * @param string $firstLetter Expected first letter.
	 *
	 * @covers \Collation::getFirstLetter()
	 * @covers \IcuCollation::getFirstLetter()
	 * @covers \IdentityCollation::getFirstLetter()
	 * @covers \UppercaseCollation::getFirstLetter()
	 * @dataProvider firstLetterProvider
	 */
	public function testGetFirstLetter( $collation, $string, $firstLetter ) {
		$col = $this->getServiceContainer()->getCollationFactory()->makeCollation( $collation );
		$this->assertEquals( $firstLetter, $col->getFirstLetter( $string ) );
	}

	public static function firstLetterProvider() {
		return [
			[ 'uppercase', 'Abc', 'A' ],
			[ 'uppercase', 'abc', 'A' ],
			[ 'identity', 'abc', 'a' ],
			[ 'uca-en', 'abc', 'A' ],
			[ 'uca-en', ' ', ' ' ],
			[ 'uca-en', 'Êveryone', 'E' ],
			[ 'uca-vi', 'Êveryone', 'Ê' ],
			// Make sure thorn is not a first letter.
			[ 'uca-sv', 'The', 'T' ],
			[ 'uca-sv', 'Å', 'Å' ],
			[ 'uca-hu', 'dzsdo', 'Dzs' ],
			[ 'uca-hu', 'dzdso', 'Dz' ],
			[ 'uca-hu', 'CSD', 'Cs' ],
			[ 'uca-root', 'CSD', 'C' ],
			[ 'uca-fi', 'Ǥ', 'G' ],
			[ 'uca-fi', 'Ŧ', 'T' ],
			[ 'uca-fi', 'Ʒ', 'Z' ],
			[ 'uca-fi', 'Ŋ', 'N' ],
			[ 'uppercase-ba', 'в', 'В' ],
		];
	}
}
PK       ! *
  
  $  collation/RemoteIcuCollationTest.phpnu Iw        <?php

use Wikimedia\TestingAccessWrapper;

/**
 * @covers \RemoteIcuCollation
 */
class RemoteIcuCollationTest extends MediaWikiLangTestCase {
	public static function provideEncode() {
		return [
			[
				[],
				''
			],
			[
				[ 'foo' ],
				'00000003foo'
			],
			[
				[ 'foo', 'a somewhat longer string' ],
				'00000003foo00000018a somewhat longer string'
			],
		];
	}

	/** @dataProvider provideEncode */
	public function testEncode( $input, $expected ) {
		$coll = TestingAccessWrapper::newFromClass( RemoteIcuCollation::class );
		$this->assertSame( $expected, $coll->encode( $input ) );
	}

	public static function provideEncodeDecode() {
		return [
			[ [ "\000" ] ],
			[ [ "a\000b" ] ],
			[ [ str_repeat( "\001", 100 ) ] ],
			[ [ 'foo' ] ],
			[ [ 'foo', 'bar' ] ],
			[ [ 'foo', 'bar', str_repeat( 'x', 1000 ) ] ]
		];
	}

	/** @dataProvider provideEncodeDecode */
	public function testEncodeDecode( $input ) {
		$coll = TestingAccessWrapper::newFromClass( RemoteIcuCollation::class );
		$this->assertSame( $input, $coll->decode( $coll->encode( $input ) ) );
	}

	public static function provideGetSortKeys() {
		$cases = [
			[],
			[ '' ],
			[ 'test1' => 'bar', 'test2' => 'foo' ],
			[
				'bar',
				'foo'
			],
			[
				'first',
				'Second'
			],
			[
				'',
				'second'
			],
			[
				'Berić',
				'Berisha',
			],
			[
				'2',
				'10',
			]
		];
		foreach ( $cases as $case ) {
			yield [ $case ];
		}
	}

	/** @dataProvider provideGetSortKeys */
	public function testGetSortKeys( $inputs ) {
		$coll = new RemoteIcuCollation(
			$this->getServiceContainer()->getShellboxClientFactory(),
			'uca-default-u-kn'
		);
		$sortKeys = $coll->getSortKeys( $inputs );
		$prevKey = null;
		if ( count( $inputs ) ) {
			foreach ( $inputs as $i => $input ) {
				$key = $sortKeys[$i];
				$this->assertIsString( $key );
				if ( $prevKey ) {
					$this->assertLessThan( 0, strcmp( $prevKey, $key ) );
				}
				$prevKey = $key;
			}
		} else {
			$this->assertSame( [], $sortKeys );
		}
	}

	/** @dataProvider provideGetSortKeys */
	public function testGetSortKey( $inputs ) {
		if ( !count( $inputs ) ) {
			// Not risky, it's just handy to reuse the provider
			$this->assertTrue( true );
		}
		$coll = new RemoteIcuCollation(
			$this->getServiceContainer()->getShellboxClientFactory(),
			'uca-default-u-kn'
		);
		$prevKey = null;
		foreach ( $inputs as $input ) {
			$key = $coll->getSortKey( $input );
			$this->assertIsString( $key );
			if ( $prevKey ) {
				$this->assertLessThan( 0, strcmp( $prevKey, $key ) );
			}
			$prevKey = $key;
		}
	}
}
PK       ! 3&  3&    WikiMap/WikiMapTest.phpnu Iw        <?php

use MediaWiki\Config\SiteConfiguration;
use MediaWiki\MainConfigNames;
use MediaWiki\Tests\Site\TestSites;
use MediaWiki\WikiMap\WikiMap;
use MediaWiki\WikiMap\WikiReference;
use Wikimedia\Rdbms\DatabaseDomain;

/**
 * @covers \MediaWiki\WikiMap\WikiMap
 *
 * @group Database
 */
class WikiMapTest extends MediaWikiLangTestCase {

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

		$conf = new SiteConfiguration();
		$conf->settings = [
			'wgServer' => [
				'enwiki' => 'http://en.example.org',
				'ruwiki' => '//ru.example.org',
				'nopathwiki' => '//nopath.example.org',
				'thiswiki' => '//this.wiki.org'
			],
			'wgArticlePath' => [
				'enwiki' => '/w/$1',
				'ruwiki' => '/wiki/$1',
			],
		];
		$conf->suffixes = [ 'wiki' ];
		$this->setMwGlobals( 'wgConf', $conf );
		$this->overrideConfigValues( [
			MainConfigNames::LocalDatabases => [ 'enwiki', 'ruwiki', 'nopathwiki' ],
			MainConfigNames::CanonicalServer => '//this.wiki.org',
			MainConfigNames::DBname => 'thiswiki',
			MainConfigNames::DBprefix => ''
		] );

		TestSites::insertIntoDb();
	}

	public static function provideGetWiki() {
		// As provided by $wgConf
		$enwiki = new WikiReference( 'http://en.example.org', '/w/$1' );
		$ruwiki = new WikiReference( '//ru.example.org', '/wiki/$1' );

		// Created from site objects
		$nlwiki = new WikiReference( 'https://nl.wikipedia.org', '/wiki/$1' );
		// enwiktionary doesn't have an interwiki id, thus this falls back to minor = lang code
		$enwiktionary = new WikiReference( 'https://en.wiktionary.org', '/wiki/$1' );

		return [
			'unknown' => [ null, 'xyzzy' ],
			'enwiki (wgConf)' => [ $enwiki, 'enwiki' ],
			'ruwiki (wgConf)' => [ $ruwiki, 'ruwiki' ],
			'nlwiki (sites)' => [ $nlwiki, 'nlwiki', false ],
			'enwiktionary (sites)' => [ $enwiktionary, 'enwiktionary', false ],
			'non MediaWiki site' => [ null, 'spam', false ],
			'boguswiki' => [ null, 'boguswiki' ],
			'nopathwiki' => [ null, 'nopathwiki' ],
		];
	}

	/**
	 * @dataProvider provideGetWiki
	 */
	public function testGetWiki( $expected, $wikiId, $useWgConf = true ) {
		if ( !$useWgConf ) {
			$this->setMwGlobals( [
				'wgConf' => new SiteConfiguration(),
			] );
		}

		$this->assertEquals( $expected, WikiMap::getWiki( $wikiId ) );
	}

	public static function provideGetWikiName() {
		return [
			'unknown' => [ 'xyzzy', 'xyzzy' ],
			'enwiki' => [ 'en.example.org', 'enwiki' ],
			'ruwiki' => [ 'ru.example.org', 'ruwiki' ],
			'enwiktionary (sites)' => [ 'en.wiktionary.org', 'enwiktionary' ],
		];
	}

	/**
	 * @dataProvider provideGetWikiName
	 */
	public function testGetWikiName( $expected, $wikiId ) {
		$this->assertEquals( $expected, WikiMap::getWikiName( $wikiId ) );
	}

	public static function provideMakeForeignLink() {
		return [
			'unknown' => [ false, 'xyzzy', 'Foo' ],
			'enwiki' => [
				'<a class="external" rel="nofollow" ' .
					'href="http://en.example.org/w/Foo">Foo</a>',
				'enwiki',
				'Foo'
			],
			'ruwiki' => [
				'<a class="external" rel="nofollow" ' .
					'href="//ru.example.org/wiki/%D0%A4%D1%83">вар</a>',
				'ruwiki',
				'Фу',
				'вар'
			],
			'enwiktionary (sites)' => [
				'<a class="external" rel="nofollow" ' .
					'href="https://en.wiktionary.org/wiki/Kitten">Kittens!</a>',
				'enwiktionary',
				'Kitten',
				'Kittens!'
			],
		];
	}

	/**
	 * @dataProvider provideMakeForeignLink
	 */
	public function testMakeForeignLink( $expected, $wikiId, $page, $text = null ) {
		$this->assertEquals(
			$expected,
			WikiMap::makeForeignLink( $wikiId, $page, $text )
		);
	}

	public static function provideForeignUserLink() {
		return [
			'unknown' => [ false, 'xyzzy', 'Foo' ],
			'enwiki' => [
				'<a class="external" rel="nofollow" ' .
					'href="http://en.example.org/w/User:Foo">User:Foo</a>',
				'enwiki',
				'Foo'
			],
			'ruwiki' => [
				'<a class="external" rel="nofollow" ' .
					'href="//ru.example.org/wiki/User:%D0%A4%D1%83">вар</a>',
				'ruwiki',
				'Фу',
				'вар'
			],
			'enwiktionary (sites)' => [
				'<a class="external" rel="nofollow" ' .
					'href="https://en.wiktionary.org/wiki/User:Dummy">Whatever</a>',
				'enwiktionary',
				'Dummy',
				'Whatever'
			],
		];
	}

	/**
	 * @dataProvider provideForeignUserLink
	 */
	public function testForeignUserLink( $expected, $wikiId, $user, $text = null ) {
		$this->assertEquals( $expected, WikiMap::foreignUserLink( $wikiId, $user, $text ) );
	}

	public static function provideGetForeignURL() {
		return [
			'unknown' => [ false, 'xyzzy', 'Foo' ],
			'enwiki' => [ 'http://en.example.org/w/Foo', 'enwiki', 'Foo' ],
			'enwiktionary (sites)' => [
				'https://en.wiktionary.org/wiki/Testme',
				'enwiktionary',
				'Testme'
			],
			'ruwiki with fragment' => [
				'//ru.example.org/wiki/%D0%A4%D1%83#%D0%B2%D0%B0%D1%80',
				'ruwiki',
				'Фу',
				'вар'
			],
		];
	}

	/**
	 * @dataProvider provideGetForeignURL
	 */
	public function testGetForeignURL( $expected, $wikiId, $page, $fragment = null ) {
		$this->assertEquals( $expected, WikiMap::getForeignURL( $wikiId, $page, $fragment ) );
	}

	/**
	 * @covers \MediaWiki\WikiMap\WikiMap::getCanonicalServerInfoForAllWikis()
	 */
	public function testGetCanonicalServerInfoForAllWikis() {
		$expected = [
			'thiswiki' => [
				'url' => '//this.wiki.org',
				'parts' => [ 'scheme' => '', 'host' => 'this.wiki.org', 'delimiter' => '//' ]
			],
			'enwiki' => [
				'url' => 'http://en.example.org',
				'parts' => [
					'scheme' => 'http', 'host' => 'en.example.org', 'delimiter' => '://' ]
			],
			'ruwiki' => [
				'url' => '//ru.example.org',
				'parts' => [ 'scheme' => '', 'host' => 'ru.example.org', 'delimiter' => '//' ]
			]
		];

		$this->assertArrayEquals(
			$expected,
			WikiMap::getCanonicalServerInfoForAllWikis(),
			true,
			true
		);
	}

	public static function provideGetWikiFromUrl() {
		return [
			[ 'http://this.wiki.org', 'thiswiki' ],
			[ 'https://this.wiki.org', 'thiswiki' ],
			[ 'http://this.wiki.org/$1', 'thiswiki' ],
			[ 'https://this.wiki.org/$2', 'thiswiki' ],
			[ 'http://en.example.org', 'enwiki' ],
			[ 'https://en.example.org', 'enwiki' ],
			[ 'http://en.example.org/$1', 'enwiki' ],
			[ 'https://en.example.org/$2', 'enwiki' ],
			[ 'http://ru.example.org', 'ruwiki' ],
			[ 'https://ru.example.org', 'ruwiki' ],
			[ 'http://ru.example.org/$1', 'ruwiki' ],
			[ 'https://ru.example.org/$2', 'ruwiki' ],
			[ 'http://not.defined.org', false ]
		];
	}

	/**
	 * @dataProvider provideGetWikiFromUrl
	 * @covers \MediaWiki\WikiMap\WikiMap::getWikiFromUrl()
	 */
	public function testGetWikiFromUrl( $url, $wiki ) {
		$this->assertEquals( $wiki, WikiMap::getWikiFromUrl( $url ) );
	}

	public static function provideGetWikiIdFromDbDomain() {
		return [
			[ 'db-prefix_', 'db-prefix_' ],
			[ WikiMap::getCurrentWikiId(), WikiMap::getCurrentWikiId() ],
			[ new DatabaseDomain( 'db-dash', null, 'prefix_' ), 'db-dash-prefix_' ],
			[ WikiMap::getCurrentWikiId(), WikiMap::getCurrentWikiId() ],
			[ new DatabaseDomain( 'db-dash', null, 'prefix_' ), 'db-dash-prefix_' ],
			[ new DatabaseDomain( 'db', 'mediawiki', 'prefix_' ), 'db-prefix_' ], // schema ignored
			[ new DatabaseDomain( 'db', 'custom', 'prefix_' ), 'db-custom-prefix_' ],
		];
	}

	/**
	 * @dataProvider provideGetWikiIdFromDbDomain
	 * @covers \MediaWiki\WikiMap\WikiMap::getWikiIdFromDbDomain()
	 */
	public function testGetWikiIdFromDbDomain( $domain, $wikiId ) {
		$this->assertEquals( $wikiId, WikiMap::getWikiIdFromDbDomain( $domain ) );
	}

	/**
	 * @covers \MediaWiki\WikiMap\WikiMap::isCurrentWikiDbDomain()
	 * @covers \MediaWiki\WikiMap\WikiMap::getCurrentWikiDbDomain()
	 */
	public function testIsCurrentWikiDomain() {
		$this->overrideConfigValue( MainConfigNames::DBmwschema, 'mediawiki' );

		$localDomain = WikiMap::getCurrentWikiDbDomain()->getId();
		$this->assertTrue( WikiMap::isCurrentWikiDbDomain( $localDomain ) );

		$localDomain = DatabaseDomain::newFromId( $localDomain );
		$domain1 = new DatabaseDomain(
			$localDomain->getDatabase(), 'someschema', $localDomain->getTablePrefix() );
		$domain2 = new DatabaseDomain(
			$localDomain->getDatabase(), null, $localDomain->getTablePrefix() );

		$this->assertFalse( WikiMap::isCurrentWikiDbDomain( $domain1 ), 'Schema not ignored' );
		$this->assertFalse( WikiMap::isCurrentWikiDbDomain( $domain2 ), 'Null schema not ignored' );

		$this->assertTrue( WikiMap::isCurrentWikiDbDomain( WikiMap::getCurrentWikiDbDomain() ) );
	}

	public static function provideIsCurrentWikiId() {
		return [
			[ 'db', 'db', null, '' ],
			[ 'db-schema-', 'db', 'schema', '' ],
			[ 'db', 'db', 'mediawiki', '' ], // common b/c case
			[ 'db-prefix_', 'db', null, 'prefix_' ],
			[ 'db-schema-prefix_', 'db', 'schema', 'prefix_' ],
			[ 'db-prefix_', 'db', 'mediawiki', 'prefix_' ], // common b/c case
			// Bad hyphen cases (best effort support)
			[ 'db-stuff', 'db-stuff', null, '' ],
			[ 'db-stuff-prefix_', 'db-stuff', null, 'prefix_' ],
			[ 'db-stuff-schema-', 'db-stuff', 'schema', '' ],
			[ 'db-stuff-schema-prefix_', 'db-stuff', 'schema', 'prefix_' ],
			[ 'db-stuff-prefix_', 'db-stuff', 'mediawiki', 'prefix_' ] // common b/c case
		];
	}

	/**
	 * @dataProvider provideIsCurrentWikiId
	 * @covers \MediaWiki\WikiMap\WikiMap::isCurrentWikiId()
	 * @covers \MediaWiki\WikiMap\WikiMap::getCurrentWikiDbDomain()
	 * @covers \MediaWiki\WikiMap\WikiMap::getWikiIdFromDbDomain()
	 */
	public function testIsCurrentWikiId( $wikiId, $db, $schema, $prefix ) {
		$this->overrideConfigValues( [
			MainConfigNames::DBname => $db,
			MainConfigNames::DBmwschema => $schema,
			MainConfigNames::DBprefix => $prefix
		] );

		$this->assertTrue( WikiMap::isCurrentWikiId( $wikiId ), "ID matches" );
		$this->assertNotTrue( WikiMap::isCurrentWikiId( $wikiId . '-more' ), "Bogus ID" );
	}
}
PK       ! )  )  $  filebackend/SwiftFileBackendTest.phpnu Iw        <?php

use MediaWiki\Logger\LoggerFactory;
use Wikimedia\FileBackend\FileBackend;
use Wikimedia\FileBackend\FileBackendError;
use Wikimedia\FileBackend\SwiftFileBackend;
use Wikimedia\TestingAccessWrapper;

/**
 * @group FileRepo
 * @group FileBackend
 * @group medium
 *
 * @covers \Wikimedia\FileBackend\SwiftFileBackend
 * @covers \Wikimedia\FileBackend\FileIteration\SwiftFileBackendDirList
 * @covers \Wikimedia\FileBackend\FileIteration\SwiftFileBackendFileList
 * @covers \Wikimedia\FileBackend\FileIteration\SwiftFileBackendList
 */
class SwiftFileBackendTest extends MediaWikiIntegrationTestCase {
	/** @var TestingAccessWrapper|SwiftFileBackend */
	private $backend;

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

		$this->backend = TestingAccessWrapper::newFromObject(
			new SwiftFileBackend( [
				'name'             => 'local-swift-testing',
				'class'            => SwiftFileBackend::class,
				'wikiId'           => 'unit-testing',
				'lockManager'      => $this->getServiceContainer()->getLockManagerGroupFactory()
							->getLockManagerGroup()->get( 'fsLockManager' ),
				'swiftAuthUrl'     => 'http://127.0.0.1:8080/auth', // unused
				'swiftUser'        => 'test:tester',
				'swiftKey'         => 'testing',
				'swiftTempUrlKey'  => 'b3968d0207b54ece87cccc06515a89d4', // unused
				'logger'           => LoggerFactory::getInstance( 'FileOperation' )
			] )
		);
	}

	/**
	 * @dataProvider provider_testExtractPostableContentHeaders
	 */
	public function testExtractPostableContentHeaders( $raw, $sanitized ) {
		$hdrs = $this->backend->extractMutableContentHeaders( $raw );

		$this->assertEquals( $sanitized, $hdrs, 'Correct extractPostableContentHeaders() result' );
	}

	public static function provider_testExtractPostableContentHeaders() {
		return [
			'empty' => [
				[],
				[]
			],
			[
				[
					'content-length' => 345,
					'content-type' => 'image+bitmap/jpeg',
					'content-disposition' => 'inline',
					'content-duration' => 35.6363,
					'content-Custom' => 'hello',
					'x-content-custom' => 'hello'
				],
				[
					'content-type' => 'image+bitmap/jpeg',
					'content-disposition' => 'inline',
					'content-duration' => 35.6363,
					'content-custom' => 'hello',
					'x-content-custom' => 'hello'
				]
			],
			[
				[
					'content-length' => 345,
					'content-type' => 'image+bitmap/jpeg',
					'content-Disposition' => 'inline; filename=xxx; ' . str_repeat( 'o', 1024 ),
					'content-duration' => 35.6363,
					'content-custom' => 'hello',
					'x-content-custom' => 'hello'
				],
				[
					'content-type' => 'image+bitmap/jpeg',
					'content-disposition' => 'inline; filename=xxx',
					'content-duration' => 35.6363,
					'content-custom' => 'hello',
					'x-content-custom' => 'hello'
				]
			],
			[
				[
					'content-length' => 345,
					'content-type' => 'image+bitmap/jpeg',
					'content-disposition' => 'filename=' . str_repeat( 'o', 1024 ) . ';inline',
					'content-duration' => 35.6363,
					'content-custom' => 'hello',
					'x-content-custom' => 'hello'
				],
				[
					'content-type' => 'image+bitmap/jpeg',
					'content-disposition' => '',
					'content-duration' => 35.6363,
					'content-custom' => 'hello',
					'x-content-custom' => 'hello'
				]
			],
			[
				[
					'x-delete-at' => 'non numeric',
					'x-delete-after' => 'non numeric',
					'x-content-custom' => 'hello'
				],
				[
					'x-content-custom' => 'hello'
				]
			],
			[
				[
					'x-delete-at' => '12345',
					'x-delete-after' => '12345'
				],
				[
					'x-delete-at' => '12345',
					'x-delete-after' => '12345'
				]
			],
			[
				[
					'x-delete-at' => 12345,
					'x-delete-after' => 12345
				],
				[
					'x-delete-at' => 12345,
					'x-delete-after' => 12345
				]
			]
		];
	}

	/**
	 * @dataProvider provider_testGetMetadataHeaders
	 */
	public function testGetMetadataHeaders( $raw, $sanitized ) {
		$hdrs = $this->backend->extractMetadataHeaders( $raw );

		$this->assertEquals( $sanitized, $hdrs, 'getMetadataHeaders() has unexpected result' );
	}

	public static function provider_testGetMetadataHeaders() {
		return [
			[
				[
					'content-length' => 345,
					'content-custom' => 'hello',
					'x-content-custom' => 'hello',
					'x-object-meta-custom' => 5,
					'x-object-meta-sha1Base36' => 'a3deadfg...',
				],
				[
					'x-object-meta-custom' => 5,
					'x-object-meta-sha1base36' => 'a3deadfg...',
				]
			]
		];
	}

	/**
	 * @dataProvider provider_testGetMetadata
	 */
	public function testGetMetadata( $raw, $sanitized ) {
		$hdrs = $this->backend->getMetadataFromHeaders( $raw );

		$this->assertEquals( $sanitized, $hdrs, 'getMetadata() has unexpected result' );
	}

	public static function provider_testGetMetadata() {
		return [
			[
				[
					'content-length' => 345,
					'content-custom' => 'hello',
					'x-content-custom' => 'hello',
					'x-object-meta-custom' => 5,
					'x-object-meta-sha1Base36' => 'a3deadfg...',
				],
				[
					'custom' => 5,
					'sha1base36' => 'a3deadfg...',
				]
			]
		];
	}

	private function setupAuthFailure() {
		$this->backend->authErrorTimestamp = time();
		$this->backend->http = null;
	}

	public function testGetFileStatAuthFail() {
		$this->setupAuthFailure();
		$result = $this->backend->getFileStat( [
			'src' => 'mwstore://local-swift-testing/c/test.txt'
		] );
		$this->assertSame( FileBackend::STAT_ERROR, $result );
	}

	public function testGetFileContentsAuthFail() {
		$this->setupAuthFailure();
		$result = $this->backend->getFileContents( [
			'src' => 'mwstore://local-swift-testing/c/test.txt'
		] );
		$this->assertFalse( $result );
	}

	public function testGetLocalCopyAuthFail() {
		$this->setupAuthFailure();
		$result = $this->backend->getLocalCopy( [
			'src' => 'mwstore://local-swift-testing/c/test.txt'
		] );
		$this->assertNull( $result );
	}

	public function testCreateAuthFail() {
		$this->setupAuthFailure();
		$status = $this->backend->create( [
			'dst' => 'mwstore://local-swift-testing/c/test.txt',
			'content' => '',
		] );
		// Ideally it would fail with backend-fail-connect, but preloadFileStat()
		// fails without any way to propagate error details.
		$this->assertStatusError( 'backend-fail-internal', $status );
	}

	public function testSecureAuthFail() {
		$this->setupAuthFailure();
		$status = $this->backend->secure( [
			'dir' => 'mwstore://local-swift-testing/c',
			'noAccess' => true,
		] );
		$this->assertStatusError( 'backend-fail-internal', $status );
	}

	public function testPrepareAuthFail() {
		$this->setupAuthFailure();
		$status = $this->backend->prepare( [
			'dir' => 'mwstore://local-swift-testing/c',
			'noAccess' => true,
		] );
		$this->assertStatusError( 'backend-fail-internal', $status );
	}

	public function testCleanAuthFail() {
		$this->setupAuthFailure();
		$status = $this->backend->clean( [
			'dir' => 'mwstore://local-swift-testing/c',
		] );
		$this->assertStatusError( 'backend-fail-internal', $status );
	}

	public function testGetFileListAuthFail() {
		$this->setupAuthFailure();
		$result = $this->backend->getFileList( [
			'dir' => 'mwstore://local-swift-testing/c',
		] );
		$this->expectException( FileBackendError::class );
		iterator_to_array( $result );
	}
}
PK       ! ez-  -  ;  filebackend/lockmanager/LockManagerGroupIntegrationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\WikiMap\WikiMap;

/**
 * Most of the file is covered by the unit test and/or FileBackendTest. Here we fill in the missing
 * bits that don't work with unit tests yet.
 *
 * @covers \LockManagerGroup
 */
class LockManagerGroupIntegrationTest extends MediaWikiIntegrationTestCase {
	public function testWgLockManagers() {
		$this->overrideConfigValue( MainConfigNames::LockManagers,
			[ [ 'name' => 'a', 'class' => 'b' ], [ 'name' => 'c', 'class' => 'd' ] ] );

		$lmg = $this->getServiceContainer()->getLockManagerGroupFactory()->getLockManagerGroup();
		$domain = WikiMap::getCurrentWikiDbDomain()->getId();

		$this->assertSame(
			[ 'class' => 'b', 'name' => 'a', 'domain' => $domain ],
			$lmg->config( 'a' ) );
		$this->assertSame(
			[ 'class' => 'd', 'name' => 'c', 'domain' => $domain ],
			$lmg->config( 'c' ) );
	}

	public function testSingletonFalse() {
		$this->overrideConfigValue( MainConfigNames::LockManagers, [ [ 'name' => 'a', 'class' => 'b' ] ] );

		$this->assertSame(
			WikiMap::getCurrentWikiDbDomain()->getId(),
			$this->getServiceContainer()
				->getLockManagerGroupFactory()
				->getLockManagerGroup( false )
				->config( 'a' )['domain']
		);
	}

	public function testSingletonNull() {
		$this->overrideConfigValue( MainConfigNames::LockManagers, [ [ 'name' => 'a', 'class' => 'b' ] ] );

		$this->assertSame(
			WikiMap::getCurrentWikiDbDomain()->getId(),
			$this->getServiceContainer()
				->getLockManagerGroupFactory()
				->getLockManagerGroup( null )
				->config( 'a' )['domain']
		);
	}
}
PK       ! ¶    )  filebackend/FileBackendMultiWriteTest.phpnu Iw        <?php

use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\WikiMap\WikiMap;
use Wikimedia\FileBackend\FileBackendMultiWrite;
use Wikimedia\FileBackend\MemoryFileBackend;
use Wikimedia\TestingAccessWrapper;

/**
 * @group FileRepo
 * @group FileBackend
 * @covers \Wikimedia\FileBackend\FileBackendMultiWrite
 */
class FileBackendMultiWriteTest extends MediaWikiIntegrationTestCase {
	public function testReadAffinity() {
		$be = TestingAccessWrapper::newFromObject(
			new FileBackendMultiWrite( [
				'name' => 'localtesting',
				'wikiId' => WikiMap::getCurrentWikiId() . mt_rand(),
				'backends' => [
					[ // backend 0
						'name' => 'multitesting0',
						'class' => MemoryFileBackend::class,
						'isMultiMaster' => false,
						'readAffinity' => true
					],
					[ // backend 1
						'name' => 'multitesting1',
						'class' => MemoryFileBackend::class,
						'isMultiMaster' => true
					]
				]
			] )
		);

		$this->assertSame(
			1,
			$be->getReadIndexFromParams( [ 'latest' => 1 ] ),
			'Reads with "latest" flag use backend 1'
		);
		$this->assertSame(
			0,
			$be->getReadIndexFromParams( [ 'latest' => 0 ] ),
			'Reads without "latest" flag use backend 0'
		);

		$p = 'container/test-cont/file.txt';
		$be->backends[0]->quickCreate( [
			'dst' => "mwstore://multitesting0/$p", 'content' => 'cattitude' ] );
		$be->backends[1]->quickCreate( [
			'dst' => "mwstore://multitesting1/$p", 'content' => 'princess of power' ] );

		$this->assertEquals(
			'cattitude',
			$be->getFileContents( [ 'src' => "mwstore://localtesting/$p" ] ),
			"Non-latest read came from backend 0"
		);
		$this->assertEquals(
			'princess of power',
			$be->getFileContents( [ 'src' => "mwstore://localtesting/$p", 'latest' => 1 ] ),
			"Latest read came from backend1"
		);
	}

	public function testAsyncWrites() {
		$be = TestingAccessWrapper::newFromObject(
			new FileBackendMultiWrite( [
				'name' => 'localtesting',
				'wikiId' => WikiMap::getCurrentWikiId() . mt_rand(),
				'backends' => [
					[ // backend 0
						'name' => 'multitesting0',
						'class' => MemoryFileBackend::class,
						'isMultiMaster' => false
					],
					[ // backend 1
						'name' => 'multitesting1',
						'class' => MemoryFileBackend::class,
						'isMultiMaster' => true
					]
				],
				'replication' => 'async'
			] )
		);

		$cleanup = DeferredUpdates::preventOpportunisticUpdates();

		$p = 'container/test-cont/file.txt';
		$be->quickCreate( [
			'dst' => "mwstore://localtesting/$p", 'content' => 'cattitude' ] );

		$this->assertFalse(
			$be->backends[0]->getFileContents( [ 'src' => "mwstore://multitesting0/$p" ] ),
			"File not yet written to backend 0"
		);
		$this->assertEquals(
			'cattitude',
			$be->backends[1]->getFileContents( [ 'src' => "mwstore://multitesting1/$p" ] ),
			"File already written to backend 1"
		);

		DeferredUpdates::doUpdates();

		$this->assertEquals(
			'cattitude',
			$be->backends[0]->getFileContents( [ 'src' => "mwstore://multitesting0/$p" ] ),
			"File now written to backend 0"
		);
	}
}
PK       ! c    /  filebackend/FileBackendGroupIntegrationTest.phpnu Iw        <?php

use MediaWiki\FileBackend\FileBackendGroup;
use MediaWiki\FileBackend\LockManager\LockManagerGroupFactory;
use MediaWiki\MainConfigNames;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\WikiMap\WikiMap;
use Wikimedia\ObjectCache\EmptyBagOStuff;

/**
 * @coversDefaultClass \MediaWiki\FileBackend\FileBackendGroup
 */
class FileBackendGroupIntegrationTest extends MediaWikiIntegrationTestCase {
	use FileBackendGroupTestTrait;
	use DummyServicesTrait;

	private static function getWikiID() {
		return WikiMap::getCurrentWikiId();
	}

	private function getLockManagerGroupFactory( $domain ): LockManagerGroupFactory {
		return $this->getServiceContainer()->getLockManagerGroupFactory();
	}

	private function newObj( array $options = [] ): FileBackendGroup {
		$globals = [
			MainConfigNames::DirectoryMode,
			MainConfigNames::FileBackends,
			MainConfigNames::ForeignFileRepos,
			MainConfigNames::LocalFileRepo,
		];
		foreach ( $globals as $global ) {
			$this->overrideConfigValue(
				$global, $options[$global] ?? self::getDefaultOptions()[$global] );
		}

		$serviceMembers = [
			'readOnlyMode' => 'ReadOnlyMode',
			'srvCache' => 'LocalServerObjectCache',
			'wanCache' => 'MainWANObjectCache',
			'mimeAnalyzer' => 'MimeAnalyzer',
			'lmgFactory' => 'LockManagerGroupFactory',
			'tmpFileFactory' => 'TempFSFileFactory',
		];

		foreach ( $serviceMembers as $key => $name ) {
			if ( isset( $options[$key] ) ) {
				if ( $key === 'readOnlyMode' ) {
					$this->setService( $name, $this->getDummyReadOnlyMode( $options[$key] ) );
				} else {
					$this->setService( $name, $options[$key] );
				}

			}
		}

		$this->assertSame( [],
			array_diff( array_keys( $options ), $globals, array_keys( $serviceMembers ) ) );

		$services = $this->getServiceContainer();

		$obj = $services->getFileBackendGroup();

		foreach ( $serviceMembers as $key => $name ) {
			if ( $key === 'readOnlyMode' || $key === 'mimeAnalyzer' ) {
				continue;
			}
			$this->$key = $services->getService( $name );
			if ( $key === 'srvCache' && $this->$key instanceof EmptyBagOStuff ) {
				// ServiceWiring will have created its own HashBagOStuff that we don't have a
				// reference to. Set null instead.
				$this->srvCache = null;
			}
		}

		return $obj;
	}
}
PK       ! c    $  filebackend/FileBackendStoreTest.phpnu Iw        <?php

namespace phpunit\includes\filebackend;

use MediaWikiIntegrationTestCase;
use Wikimedia\FileBackend\FileBackend;
use Wikimedia\FileBackend\MemoryFileBackend;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \Wikimedia\FileBackend\FileBackendStore
 */
class FileBackendStoreTest extends MediaWikiIntegrationTestCase {

	/**
	 * @dataProvider provider_testGetContentType
	 */
	public function testGetContentType( $mimeFromString ) {
		global $IP;

		if ( $mimeFromString ) {
			$mimeCallback = [ $this->getServiceContainer()->getFileBackendGroup(), 'guessMimeInternal' ];
		} else {
			$mimeCallback = null;
		}

		$be = TestingAccessWrapper::newFromObject( new MemoryFileBackend( [
			'name' => 'testing',
			'class' => MemoryFileBackend::class,
			'wikiId' => 'meow',
			'mimeCallback' => $mimeCallback,
		] ) );

		$dst = 'mwstore://testing/container/path/to/file_no_ext';
		$src = "$IP/tests/phpunit/data/media/srgb.jpg";
		$this->assertEquals( 'image/jpeg', $be->getContentType( $dst, null, $src ) );
		$this->assertEquals( $mimeFromString ? 'image/jpeg' : 'unknown/unknown',
			$be->getContentType( $dst, file_get_contents( $src ), null ) );

		$src = "$IP/tests/phpunit/data/media/Png-native-test.png";
		$this->assertEquals( 'image/png', $be->getContentType( $dst, null, $src ) );
		$this->assertEquals( $mimeFromString ? 'image/png' : 'unknown/unknown',
			$be->getContentType( $dst, file_get_contents( $src ), null ) );
	}

	public static function provider_testGetContentType() {
		return [
			[ false ],
			[ true ],
		];
	}

	public function testSanitizeOpHeaders() {
		$be = TestingAccessWrapper::newFromObject( new MemoryFileBackend( [
			'name' => 'localtesting',
			'wikiId' => 'wikidb',
		] ) );

		$input = [
			'headers' => [
				'content-Disposition' => FileBackend::makeContentDisposition( 'inline', 'name' ),
				'Content-dUration' => 25.6,
				'X-LONG-VALUE' => str_pad( '0', 300 ),
				'CONTENT-LENGTH' => 855055,
			],
		];
		$expected = [
			'headers' => [
				'content-disposition' => FileBackend::makeContentDisposition( 'inline', 'name' ),
				'content-duration' => 25.6,
				'content-length' => 855055,
			],
		];

		$actual = @$be->sanitizeOpHeaders( $input );
		$this->assertEquals( $expected, $actual, "Header sanitized properly" );
	}

}
PK       ! wɤ:  :    cache/LinkCacheTest.phpnu Iw        <?php

use MediaWiki\Cache\LinkCache;
use MediaWiki\Page\PageReference;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use Wikimedia\ObjectCache\EmptyBagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * @group Database
 * @group Cache
 * @covers \MediaWiki\Cache\LinkCache
 */
class LinkCacheTest extends MediaWikiIntegrationTestCase {
	use LinkCacheTestTrait;

	private function newLinkCache( ?WANObjectCache $wanCache = null ) {
		if ( !$wanCache ) {
			$wanCache = new WANObjectCache( [ 'cache' => new EmptyBagOStuff() ] );
		}

		return new LinkCache(
			$this->getServiceContainer()->getTitleFormatter(),
			$wanCache,
			$this->getServiceContainer()->getNamespaceInfo(),
			$this->getServiceContainer()->getDBLoadBalancer()
		);
	}

	public static function providePageAndLink() {
		return [
			[ new PageReferenceValue( NS_USER, __METHOD__, PageReference::LOCAL ) ],
			[ new TitleValue( NS_USER, __METHOD__ ) ]
		];
	}

	public static function providePageAndLinkAndArray() {
		return [
			[ new PageReferenceValue( NS_USER, __METHOD__, PageReference::LOCAL ) ],
			[ new TitleValue( NS_USER, __METHOD__ ) ],
			[ [ 'page_namespace' => NS_USER, 'page_title' => __METHOD__ ] ],
		];
	}

	private function getPageRow( $offset = 0 ) {
		return (object)[
			'page_id' => 8 + $offset,
			'page_namespace' => 0,
			'page_title' => 'Test ' . $offset,
			'page_len' => 18,
			'page_is_redirect' => 0,
			'page_latest' => 118 + $offset,
			'page_content_model' => CONTENT_MODEL_TEXT,
			'page_lang' => 'xyz',
			'page_is_new' => 0,
			'page_touched' => '20200202020202',
		];
	}

	/**
	 * @dataProvider providePageAndLinkAndArray
	 * @covers \MediaWiki\Cache\LinkCache::addGoodLinkObjFromRow()
	 * @covers \MediaWiki\Cache\LinkCache::getGoodLinkRow()
	 * @covers \MediaWiki\Cache\LinkCache::getGoodLinkID()
	 * @covers \MediaWiki\Cache\LinkCache::getGoodLinkFieldObj()
	 * @covers \MediaWiki\Cache\LinkCache::clearLink()
	 */
	public function testAddGoodLinkObjFromRow( $page ) {
		$linkCache = $this->newLinkCache();

		$row = $this->getPageRow();

		$dbkey = is_array( $page ) ? $page['page_title'] : $page->getDBkey();
		$ns = is_array( $page ) ? $page['page_namespace'] : $page->getNamespace();

		$linkCache->addBadLinkObj( $page );
		$linkCache->addGoodLinkObjFromRow( $page, $row );

		$this->assertEquals(
			$row,
			$linkCache->getGoodLinkRow( $ns, $dbkey )
		);

		$this->assertSame( $row->page_id, $linkCache->getGoodLinkID( $page ) );
		$this->assertFalse( $linkCache->isBadLink( $page ) );

		$this->assertSame(
			$row->page_id,
			$linkCache->getGoodLinkFieldObj( $page, 'id' )
		);
		$this->assertSame(
			$row->page_len,
			$linkCache->getGoodLinkFieldObj( $page, 'length' )
		);
		$this->assertSame(
			$row->page_is_redirect,
			$linkCache->getGoodLinkFieldObj( $page, 'redirect' )
		);
		$this->assertSame(
			$row->page_latest,
			$linkCache->getGoodLinkFieldObj( $page, 'revision' )
		);
		$this->assertSame(
			$row->page_content_model,
			$linkCache->getGoodLinkFieldObj( $page, 'model' )
		);
		$this->assertSame(
			$row->page_lang,
			$linkCache->getGoodLinkFieldObj( $page, 'lang' )
		);

		$this->assertEquals(
			$row,
			$linkCache->getGoodLinkRow( $ns, $dbkey )
		);

		$linkCache->clearBadLink( $page );
		$this->assertNotNull( $linkCache->getGoodLinkID( $page ) );
		$this->assertNotNull( $linkCache->getGoodLinkFieldObj( $page, 'length' ) );

		$linkCache->clearLink( $page );
		$this->assertSame( 0, $linkCache->getGoodLinkID( $page ) );
		$this->assertNull( $linkCache->getGoodLinkFieldObj( $page, 'length' ) );
		$this->assertNull( $linkCache->getGoodLinkRow( $ns, $dbkey ) );
	}

	/**
	 * @covers \MediaWiki\Cache\LinkCache::addGoodLinkObjFromRow()
	 * @covers \MediaWiki\Cache\LinkCache::getGoodLinkRow()
	 * @covers \MediaWiki\Cache\LinkCache::getGoodLinkID()
	 * @covers \MediaWiki\Cache\LinkCache::getGoodLinkFieldObj()
	 */
	public function testAddGoodLinkObjWithAllParameters() {
		$linkCache = $this->getServiceContainer()->getLinkCache();

		$page = new PageReferenceValue( NS_USER, __METHOD__, PageReference::LOCAL );
		$this->addGoodLinkObject( 8, $page, 18, 0, 118, CONTENT_MODEL_TEXT, 'xyz' );

		$row = $linkCache->getGoodLinkRow( $page->getNamespace(), $page->getDBkey() );
		$this->assertEquals( 8, (int)$row->page_id );
		$this->assertSame( 8, $linkCache->getGoodLinkID( $page ) );
		$this->assertSame( 8, $linkCache->getGoodLinkFieldObj( $page, 'id' ) );

		$this->assertSame(
			18,
			$linkCache->getGoodLinkFieldObj( $page, 'length' )
		);
		$this->assertSame(
			0,
			$linkCache->getGoodLinkFieldObj( $page, 'redirect' )
		);
		$this->assertSame(
			118,
			$linkCache->getGoodLinkFieldObj( $page, 'revision' )
		);
		$this->assertSame(
			CONTENT_MODEL_TEXT,
			$linkCache->getGoodLinkFieldObj( $page, 'model' )
		);
		$this->assertSame(
			'xyz',
			$linkCache->getGoodLinkFieldObj( $page, 'lang' )
		);
	}

	/**
	 * @covers \MediaWiki\Cache\LinkCache::addGoodLinkObjFromRow()
	 * @covers \MediaWiki\Cache\LinkCache::getGoodLinkRow()
	 * @covers \MediaWiki\Cache\LinkCache::getGoodLinkID()
	 * @covers \MediaWiki\Cache\LinkCache::getGoodLinkFieldObj()
	 */
	public function testAddGoodLinkObjFromRowWithMinimalParameters() {
		$linkCache = $this->getServiceContainer()->getLinkCache();

		$page = new PageReferenceValue( NS_USER, __METHOD__, PageReference::LOCAL );

		$this->addGoodLinkObject( 8, $page );
		$expectedRow = [
			'page_id' => 8,
			'page_len' => -1,
			'page_is_redirect' => 0,
			'page_latest' => 0,
			'page_content_model' => null,
			'page_lang' => null,
		];

		$actualRow = (array)$linkCache->getGoodLinkRow( $page->getNamespace(), $page->getDBkey() );
		$this->assertEquals(
			$expectedRow,
			array_intersect_key( $actualRow, $expectedRow )
		);

		$this->assertSame( 8, $linkCache->getGoodLinkID( $page ) );
		$this->assertSame( 8, $linkCache->getGoodLinkFieldObj( $page, 'id' ) );

		$this->assertSame(
			-1,
			$linkCache->getGoodLinkFieldObj( $page, 'length' )
		);
		$this->assertSame(
			0,
			$linkCache->getGoodLinkFieldObj( $page, 'redirect' )
		);
		$this->assertSame(
			0,
			$linkCache->getGoodLinkFieldObj( $page, 'revision' )
		);
		$this->assertSame(
			null,
			$linkCache->getGoodLinkFieldObj( $page, 'model' )
		);
		$this->assertSame(
			null,
			$linkCache->getGoodLinkFieldObj( $page, 'lang' )
		);
	}

	/**
	 * @covers \MediaWiki\Cache\LinkCache::addGoodLinkObjFromRow()
	 */
	public function testAddGoodLinkObjFromRowWithInterwikiLink() {
		$linkCache = $this->getServiceContainer()->getLinkCache();

		$page = new TitleValue( NS_USER, __METHOD__, '', 'acme' );

		$this->addGoodLinkObject( 8, $page );

		$this->assertSame( 0, $linkCache->getGoodLinkID( $page ) );
	}

	/**
	 * @dataProvider providePageAndLink
	 * @covers \MediaWiki\Cache\LinkCache::addBadLinkObj()
	 * @covers \MediaWiki\Cache\LinkCache::isBadLink()
	 * @covers \MediaWiki\Cache\LinkCache::clearLink()
	 */
	public function testAddBadLinkObj( $key ) {
		$linkCache = $this->getServiceContainer()->getLinkCache();
		$this->assertFalse( $linkCache->isBadLink( $key ) );

		$this->addGoodLinkObject( 17, $key );

		$linkCache->addBadLinkObj( $key );
		$this->assertTrue( $linkCache->isBadLink( $key ) );
		$this->assertSame( 0, $linkCache->getGoodLinkID( $key ) );

		$linkCache->clearLink( $key );
		$this->assertFalse( $linkCache->isBadLink( $key ) );
	}

	/**
	 * @covers \MediaWiki\Cache\LinkCache::addBadLinkObj()
	 */
	public function testAddBadLinkObjWithInterwikiLink() {
		$linkCache = $this->newLinkCache();

		$page = new TitleValue( NS_USER, __METHOD__, '', 'acme' );
		$linkCache->addBadLinkObj( $page );

		$this->assertFalse( $linkCache->isBadLink( $page ) );
	}

	/**
	 * @covers \MediaWiki\Cache\LinkCache::addLinkObj()
	 * @covers \MediaWiki\Cache\LinkCache::getGoodLinkFieldObj
	 */
	public function testAddLinkObj() {
		$existing = $this->getExistingTestPage();
		$missing = $this->getNonexistingTestPage();

		$linkCache = $this->newLinkCache();

		$linkCache->addLinkObj( $existing );
		$linkCache->addLinkObj( $missing );

		$this->assertTrue( $linkCache->isBadLink( $missing ) );
		$this->assertFalse( $linkCache->isBadLink( $existing ) );

		$this->assertSame( $existing->getId(), $linkCache->getGoodLinkID( $existing ) );
		$this->assertTrue( $linkCache->isBadLink( $missing ) );

		// Make sure nothing explodes when getting a field from a non-existing entry
		$this->assertNull( $linkCache->getGoodLinkFieldObj( $missing, 'length' ) );
	}

	/**
	 * @covers \MediaWiki\Cache\LinkCache::addLinkObj()
	 */
	public function testAddLinkObjUsesCachedInfo() {
		$existing = $this->getExistingTestPage();
		$missing = $this->getNonexistingTestPage();

		$fakeRow = $this->getPageRow( $existing->getId() + 100 );

		$linkCache = $this->newLinkCache();

		// pretend the existing page is missing, and the missing page exists
		$linkCache->addGoodLinkObjFromRow( $missing, $fakeRow );
		$linkCache->addBadLinkObj( $existing );

		// the LinkCache should use the cached info and not look into the database
		$this->assertSame( (int)$fakeRow->page_id, $linkCache->addLinkObj( $missing ) );
		$this->assertSame( 0, $linkCache->addLinkObj( $existing ) );

		// now set the "read latest" flag and try again
		$flags = IDBAccessObject::READ_LATEST;
		$this->assertSame( 0, $linkCache->addLinkObj( $missing, $flags ) );
		$this->assertSame( $existing->getId(), $linkCache->addLinkObj( $existing, $flags ) );
	}

	/**
	 * @covers \MediaWiki\Cache\LinkCache::addLinkObj()
	 */
	public function testAddLinkObjUsesWANCache() {
		// For some namespaces we cache data (Template, File, etc)
		$existing = $this->getExistingTestPage( Title::makeTitle( NS_TEMPLATE, __METHOD__ ) );
		$wanCache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
		$linkCache = $this->newLinkCache( $wanCache );

		// load the page row into the cache
		$linkCache->addLinkObj( $existing );

		// replace real row data with fake, and assert that it gets used
		$key = $wanCache->makeKey( 'page', $existing->getNamespace(), sha1( $existing->getDBkey() ) );
		$fakeRow = $this->getPageRow( $existing->getId() + 100 );
		$wanCache->set( $key, $fakeRow );
		// clear in-class cache
		$linkCache->clearLink( $existing );
		$this->assertSame( (int)$fakeRow->page_id, $linkCache->addLinkObj( $existing ) );

		// set the "read latest" flag and try again
		$flags = IDBAccessObject::READ_LATEST;
		$this->assertSame( $existing->getId(), $linkCache->addLinkObj( $existing, $flags ) );
	}

	public function testFalsyPageName() {
		$linkCache = $this->newLinkCache();

		// The stringified value is "0", which is falsy in PHP!
		$link = new TitleValue( NS_MAIN, '0' );

		$linkCache->addBadLinkObj( $link );
		$this->assertTrue( $linkCache->isBadLink( $link ) );

		$row = $this->getPageRow();
		$linkCache->addGoodLinkObjFromRow( $link, $row );
		$this->assertGreaterThan( 0, $linkCache->getGoodLinkID( $link ) );

		$this->assertSame( $row, $linkCache->getGoodLinkRow( NS_MAIN, '0' ) );
	}

	public function testClearBadLinkWithString() {
		$linkCache = $this->newLinkCache();
		$linkCache->clearBadLink( 'Xyzzy' );
		$this->addToAssertionCount( 1 );
	}

	public function testIsBadLinkWithString() {
		$linkCache = $this->newLinkCache();
		$this->assertFalse( $linkCache->isBadLink( 'Xyzzy' ) );
	}

	public function testGetGoodLinkIdWithString() {
		$linkCache = $this->newLinkCache();
		$this->assertSame( 0, $linkCache->getGoodLinkID( 'Xyzzy' ) );
	}

	public static function provideInvalidPageParams() {
		return [
			'empty' => [ NS_MAIN, '' ],
			'bad chars' => [ NS_MAIN, '_|_' ],
			'empty in namspace' => [ NS_USER, '' ],
			'special' => [ NS_SPECIAL, 'RecentChanges' ],
		];
	}

	/**
	 * @dataProvider provideInvalidPageParams
	 * @covers \MediaWiki\Cache\LinkCache::getGoodLinkRow()
	 */
	public function testGetGoodLinkRowWithBadParams( $ns, $dbkey ) {
		$linkCache = $this->newLinkCache();
		$this->assertNull( $linkCache->getGoodLinkRow( $ns, $dbkey ) );
	}

	public function getRowIfExisting( $db, $ns, $dbkey, $queryOptions ) {
		if ( $dbkey === 'Existing' ) {
			return $this->getPageRow();
		}

		return null;
	}

	/**
	 * @covers \MediaWiki\Cache\LinkCache::getGoodLinkRow()
	 * @covers \MediaWiki\Cache\LinkCache::getGoodLinkFieldObj
	 */
	public function testGetGoodLinkRow() {
		$existing = new TitleValue( NS_MAIN, 'Existing' );
		$missing = new TitleValue( NS_MAIN, 'Missing' );

		$linkCache = $this->newLinkCache();
		$callback = [ $this, 'getRowIfExisting' ];

		$linkCache->getGoodLinkRow( $existing->getNamespace(), $existing->getDBkey(), $callback );
		$linkCache->getGoodLinkRow( $missing->getNamespace(), $missing->getDBkey(), $callback );

		$this->assertTrue( $linkCache->isBadLink( $missing ) );
		$this->assertFalse( $linkCache->isBadLink( $existing ) );

		$this->assertGreaterThan( 0, $linkCache->getGoodLinkID( $existing ) );
		$this->assertTrue( $linkCache->isBadLink( $missing ) );

		// Make sure nothing explodes when getting a field from a non-existing entry
		$this->assertNull( $linkCache->getGoodLinkFieldObj( $missing, 'length' ) );
	}

	/**
	 * @covers \MediaWiki\Cache\LinkCache::getGoodLinkRow()
	 */
	public function testGetGoodLinkRowUsesCachedInfo() {
		$existing = new TitleValue( NS_MAIN, 'Existing' );
		$missing = new TitleValue( NS_MAIN, 'Missing' );
		$callback = [ $this, 'getRowIfExisting' ];

		$existingRow = $this->getPageRow( 0 );
		$fakeRow = $this->getPageRow( 3 );

		$linkCache = $this->newLinkCache();

		// pretend the existing page is missing, and the missing page exists
		$linkCache->addGoodLinkObjFromRow( $missing, $fakeRow );
		$linkCache->addBadLinkObj( $existing );

		// the LinkCache should use the cached info and not look into the database
		$this->assertSame(
			$fakeRow,
			$linkCache->getGoodLinkRow( $missing->getNamespace(), $missing->getDBkey(), $callback )
		);
		$this->assertNull(
			$linkCache->getGoodLinkRow( $existing->getNamespace(), $existing->getDBkey(), $callback )
		);

		// now set the "read latest" flag and try again
		$flags = IDBAccessObject::READ_LATEST;
		$this->assertNull(
			$linkCache->getGoodLinkRow(
				$missing->getNamespace(),
				$missing->getDBkey(),
				$callback,
				$flags
			)
		);
		$this->assertEquals(
			$existingRow,
			$linkCache->getGoodLinkRow(
				$existing->getNamespace(),
				$existing->getDBkey(),
				$callback,
				$flags
			)
		);

		// pretend again that the missing page exists, but pretend even harder
		$linkCache->addGoodLinkObjFromRow( $missing, $fakeRow, IDBAccessObject::READ_LATEST );

		// the LinkCache should use the cached info and not look into the database
		$this->assertSame(
			$fakeRow,
			$linkCache->getGoodLinkRow( $missing->getNamespace(), $missing->getDBkey(), $callback )
		);

		// now set the "read latest" flag and try again
		$flags = IDBAccessObject::READ_LATEST;
		$this->assertEquals(
			$fakeRow,
			$linkCache->getGoodLinkRow(
				$missing->getNamespace(),
				$missing->getDBkey(),
				$callback,
				$flags
			)
		);
	}
}
PK       ! ՘      cache/GenderCacheTest.phpnu Iw        <?php

use MediaWiki\Cache\GenderCache;

/**
 * @group Database
 * @group Cache
 */
class GenderCacheTest extends MediaWikiLangTestCase {

	/** @var string[] User key => username */
	private static $nameMap;

	public function addDBDataOnce() {
		// ensure the correct default gender
		$this->mergeMwGlobalArrayValue( 'wgDefaultUserOptions', [ 'gender' => 'unknown' ] );

		$userOptionsManager = $this->getServiceContainer()->getUserOptionsManager();

		$male = $this->getMutableTestUser()->getUser();
		$userOptionsManager->setOption( $male, 'gender', 'male' );
		$male->saveSettings();

		$female = $this->getMutableTestUser()->getUser();
		$userOptionsManager->setOption( $female, 'gender', 'female' );
		$female->saveSettings();

		$default = $this->getMutableTestUser()->getUser();
		$userOptionsManager->setOption( $default, 'gender', null );
		$default->saveSettings();

		self::$nameMap = [
			'UTMale'          => $male->getName(),
			'UTFemale'        => $female->getName(),
			'UTDefaultGender' => $default->getName()
		];
	}

	/**
	 * test usernames
	 *
	 * @dataProvider provideUserGenders
	 * @covers \MediaWiki\Cache\GenderCache::getGenderOf
	 */
	public function testUserName( $userKey, $expectedGender ) {
		$genderCache = $this->getServiceContainer()->getGenderCache();
		$username = self::$nameMap[$userKey] ?? $userKey;
		$gender = $genderCache->getGenderOf( $username );
		$this->assertEquals( $expectedGender, $gender, "GenderCache normal" );
	}

	/**
	 * genderCache should work with user objects, too
	 *
	 * @dataProvider provideUserGenders
	 * @covers \MediaWiki\Cache\GenderCache::getGenderOf
	 */
	public function testUserObjects( $userKey, $expectedGender ) {
		$username = self::$nameMap[$userKey] ?? $userKey;
		$genderCache = $this->getServiceContainer()->getGenderCache();
		$gender = $genderCache->getGenderOf( $username );
		$this->assertEquals( $expectedGender, $gender, "GenderCache normal" );
	}

	public static function provideUserGenders() {
		return [
			[ 'UTMale', 'male' ],
			[ 'UTFemale', 'female' ],
			[ 'UTDefaultGender', 'unknown' ],
			[ 'UTNotExist', 'unknown' ],
			// some not valid user
			[ '127.0.0.1', 'unknown' ],
			[ 'user@test', 'unknown' ],
		];
	}

	/**
	 * test strip of subpages to avoid unnecessary queries
	 * against the never existing username
	 *
	 * @dataProvider provideUserGenders
	 * @covers \MediaWiki\Cache\GenderCache::getGenderOf
	 */
	public function testStripSubpages( $userKey, $expectedGender ) {
		$username = self::$nameMap[$userKey] ?? $userKey;
		$genderCache = $this->getServiceContainer()->getGenderCache();
		$gender = $genderCache->getGenderOf( "$username/subpage" );
		$this->assertEquals( $expectedGender, $gender, "GenderCache must strip of subpages" );
	}

	/**
	 * GenderCache must work without database (like Installer)
	 * @coversNothing
	 */
	public function testWithoutDB() {
		$this->overrideMwServices();

		$services = $this->getServiceContainer();
		$services->disableService( 'DBLoadBalancer' );
		$services->disableService( 'DBLoadBalancerFactory' );

		// Make sure the disable works
		$this->assertTrue( $services->isServiceDisabled( 'DBLoadBalancer' ) );

		// Test, if it is possible to create the gender cache
		$genderCache = $services->getGenderCache();
		$this->assertInstanceOf( GenderCache::class, $genderCache );
	}
}
PK       ! L?z  z    cache/BacklinkCacheTest.phpnu Iw        <?php

use MediaWiki\Title\Title;

/**
 * @group Database
 * @group Cache
 * @covers \MediaWiki\Cache\BacklinkCache
 */
class BacklinkCacheTest extends MediaWikiIntegrationTestCase {
	/** @var array */
	private static $backlinkCacheTest;

	public function addDBDataOnce() {
		$this->insertPage( 'Template:BacklinkCacheTestA', 'wooooooo' );
		$this->insertPage( 'Template:BacklinkCacheTestB', '{{BacklinkCacheTestA}}' );

		self::$backlinkCacheTest = $this->insertPage( 'BacklinkCacheTest_1', '{{BacklinkCacheTestB}}' );
		$this->insertPage( 'BacklinkCacheTest_2', '[[BacklinkCacheTest_1]] [[Image:test.png]]' );
		$this->insertPage( 'BacklinkCacheTest_3', '[[BacklinkCacheTest_1]]' );
		$this->insertPage( 'BacklinkCacheTest_4', '[[BacklinkCacheTest_1]]' );
		$this->insertPage( 'BacklinkCacheTest_5', '[[BacklinkCacheTest_1]]' );

		$cascade = 1;
		$this->getServiceContainer()->getWikiPageFactory()->newFromTitle( self::$backlinkCacheTest['title'] )->doUpdateRestrictions(
			[ 'edit' => 'sysop' ],
			[],
			$cascade,
			'test',
			$this->getTestSysop()->getUser()
		);
	}

	public static function provideCasesForHasLink() {
		return [
			[ true, 'BacklinkCacheTest_1', 'pagelinks' ],
			[ false, 'BacklinkCacheTest_2', 'pagelinks' ],
			[ true, 'Image:test.png', 'imagelinks' ]
		];
	}

	/**
	 * @dataProvider provideCasesForHasLink
	 * @covers \MediaWiki\Cache\BacklinkCache::hasLinks
	 */
	public function testHasLink( bool $expected, string $title, string $table, string $msg = '' ) {
		$blcFactory = $this->getServiceContainer()->getBacklinkCacheFactory();
		$backlinkCache = $blcFactory->getBacklinkCache( Title::newFromText( $title ) );
		$this->assertEquals( $expected, $backlinkCache->hasLinks( $table ), $msg );
	}

	public static function provideCasesForGetNumLinks() {
		return [
			[ 4, 'BacklinkCacheTest_1', 'pagelinks' ],
			[ 0, 'BacklinkCacheTest_2', 'pagelinks' ],
			[ 1, 'Image:test.png', 'imagelinks' ],
		];
	}

	/**
	 * @dataProvider provideCasesForGetNumLinks
	 * @covers \MediaWiki\Cache\BacklinkCache::getNumLinks
	 */
	public function testGetNumLinks( int $numLinks, string $title, string $table ) {
		$blcFactory = $this->getServiceContainer()->getBacklinkCacheFactory();
		$backlinkCache = $blcFactory->getBacklinkCache( Title::newFromText( $title ) );
		$this->assertEquals( $numLinks, $backlinkCache->getNumLinks( $table ) );
	}

	public static function provideCasesForGetLinks() {
		return [
			[
				[ 'BacklinkCacheTest_2', 'BacklinkCacheTest_3', 'BacklinkCacheTest_4', 'BacklinkCacheTest_5' ],
				'BacklinkCacheTest_1',
				'pagelinks'
			],
			[
				[ 'BacklinkCacheTest_4', 'BacklinkCacheTest_5' ],
				'BacklinkCacheTest_1',
				'pagelinks',
				'BacklinkCacheTest_4'
			],
			[
				[ 'BacklinkCacheTest_2', 'BacklinkCacheTest_3' ],
				'BacklinkCacheTest_1',
				'pagelinks',
				false,
				'BacklinkCacheTest_3'
			],
			[
				[ 'BacklinkCacheTest_3', 'BacklinkCacheTest_4' ],
				'BacklinkCacheTest_1',
				'pagelinks',
				'BacklinkCacheTest_3',
				'BacklinkCacheTest_4'
			],
			[ [ 'BacklinkCacheTest_2' ], 'BacklinkCacheTest_1', 'pagelinks', false, false, 1 ],
			[ [], 'BacklinkCacheTest_2', 'pagelinks' ],
			[ [ 'BacklinkCacheTest_2' ], 'Image:test.png', 'imagelinks' ],
		];
	}

	/**
	 * @dataProvider provideCasesForGetLinks
	 * @covers \MediaWiki\Cache\BacklinkCache::getLinkPages
	 */
	public function testGetLinkPages(
		array $expectedTitles, string $title, string $table, $startId = false, $endId = false, $max = INF
	) {
		$startId = $startId ? Title::newFromText( $startId )->getId() : false;
		$endId = $endId ? Title::newFromText( $endId )->getId() : false;
		$blcFactory = $this->getServiceContainer()->getBacklinkCacheFactory();
		$backlinkCache = $blcFactory->getBacklinkCache( Title::newFromText( $title ) );
		$titlesArray = iterator_to_array( $backlinkCache->getLinkPages( $table, $startId, $endId, $max ) );
		$this->assertSameSize( $expectedTitles, $titlesArray );
		$numOfTitles = count( $titlesArray );
		for ( $i = 0; $i < $numOfTitles; $i++ ) {
			$this->assertEquals( $expectedTitles[$i], $titlesArray[$i]->getDbKey() );
		}
	}

	/**
	 * @covers \MediaWiki\Cache\BacklinkCache::partition
	 */
	public function testPartition() {
		$targetId = $this->getServiceContainer()->getLinkTargetLookup()->acquireLinkTargetId(
			Title::makeTitle( NS_MAIN, 'BLCTest1234' ),
			$this->getDb()
		);
		$targetRow = [
			'tl_target_id' => $targetId,
		];
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'templatelinks' )
			->rows( [
				[ 'tl_from' => 56890, 'tl_from_namespace' => 0 ] + $targetRow,
				[ 'tl_from' => 56891, 'tl_from_namespace' => 0 ] + $targetRow,
				[ 'tl_from' => 56892, 'tl_from_namespace' => 0 ] + $targetRow,
				[ 'tl_from' => 56893, 'tl_from_namespace' => 0 ] + $targetRow,
				[ 'tl_from' => 56894, 'tl_from_namespace' => 0 ] + $targetRow,
			] )
			->caller( __METHOD__ )
			->execute();
		$blcFactory = $this->getServiceContainer()->getBacklinkCacheFactory();
		$backlinkCache = $blcFactory->getBacklinkCache( Title::makeTitle( NS_MAIN, 'BLCTest1234' ) );
		$partition = $backlinkCache->partition( 'templatelinks', 2 );
		$this->assertArrayEquals( [
			[ false, 56891 ],
			[ 56892, 56893 ],
			[ 56894, false ]
		], $partition );
	}

}
PK       ! vz<|  |    cache/LinkCacheTestTrait.phpnu Iw        <?php

use MediaWiki\Linker\LinkTarget;
use MediaWiki\MediaWikiServices;
use MediaWiki\Page\PageReference;

/**
 * A trait providing utility functions for testing LinkCache.
 * This trait is intended to be used on subclasses of
 * MediaWikiIntegrationTestCase.
 *
 * @stable to use
 * @since 1.37
 */

trait LinkCacheTestTrait {

	/**
	 * Force information about a page into the cache, pretending it exists.
	 *
	 * @param int $id Page's ID
	 * @param LinkTarget|PageReference $page The page to set cached info for.
	 * @param int $len Text's length
	 * @param int|null $redir Whether the page is a redirect
	 * @param int $revision Latest revision's ID
	 * @param string|null $model Latest revision's content model ID
	 * @param string|null $lang Language code of the page, if not the content language
	 */
	public function addGoodLinkObject(
		$id, $page, $len = -1, $redir = null, $revision = 0, $model = null, $lang = null
	) {
		MediaWikiServices::getInstance()->getLinkCache()->addGoodLinkObjFromRow( $page, (object)[
			'page_id' => (int)$id,
			'page_namespace' => $page->getNamespace(),
			'page_title' => $page->getDBkey(),
			'page_len' => (int)$len,
			'page_is_redirect' => (int)$redir,
			'page_latest' => (int)$revision,
			'page_content_model' => $model ? (string)$model : null,
			'page_lang' => $lang ? (string)$lang : null,
			'page_is_new' => 0,
			'page_touched' => '',
		] );
	}

}
PK       !  !   !    cache/LinkBatchTest.phpnu Iw        <?php

use MediaWiki\Cache\CacheKeyHelper;
use MediaWiki\Cache\GenderCache;
use MediaWiki\Cache\LinkBatch;
use MediaWiki\Cache\LinkCache;
use MediaWiki\Language\Language;
use MediaWiki\Linker\LinksMigration;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\Page\PageReference;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleFormatter;
use MediaWiki\Title\TitleValue;
use Wikimedia\Rdbms\IConnectionProvider;

/**
 * @group Database
 * @group Cache
 * @covers \MediaWiki\Cache\LinkBatch
 */
class LinkBatchTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers \MediaWiki\Cache\LinkBatch::__construct()
	 * @covers \MediaWiki\Cache\LinkBatch::getSize()
	 * @covers \MediaWiki\Cache\LinkBatch::isEmpty()
	 */
	public function testConstructEmptyWithServices() {
		$batch = new LinkBatch(
			[],
			$this->createMock( LinkCache::class ),
			$this->createMock( TitleFormatter::class ),
			$this->createMock( Language::class ),
			$this->createMock( GenderCache::class ),
			$this->createMock( IConnectionProvider::class ),
			$this->createMock( LinksMigration::class ),
			LoggerFactory::getInstance( 'LinkBatch' )
		);

		$this->assertTrue( $batch->isEmpty() );
		$this->assertSame( 0, $batch->getSize() );
	}

	/**
	 * @covers \MediaWiki\Cache\LinkBatch::__construct()
	 * @covers \MediaWiki\Cache\LinkBatch::getSize()
	 * @covers \MediaWiki\Cache\LinkBatch::isEmpty()
	 */
	public function testConstructWithServices() {
		$batch = new LinkBatch(
			[
				new TitleValue( NS_MAIN, 'Foo' ),
				new TitleValue( NS_TALK, 'Bar' ),
			],
			$this->createMock( LinkCache::class ),
			$this->createMock( TitleFormatter::class ),
			$this->createMock( Language::class ),
			$this->createMock( GenderCache::class ),
			$this->createMock( IConnectionProvider::class ),
			$this->createMock( LinksMigration::class ),
			LoggerFactory::getInstance( 'LinkBatch' )
		);

		$this->assertFalse( $batch->isEmpty() );
		$this->assertSame( 2, $batch->getSize() );
	}

	/**
	 * @param iterable<LinkTarget>|iterable<PageReference> $objects
	 *
	 * @return LinkBatch
	 * @throws Exception
	 */
	private function newLinkBatch( $objects = [] ) {
		return new LinkBatch(
			$objects,
			$this->createMock( LinkCache::class ),
			$this->createMock( TitleFormatter::class ),
			$this->createMock( Language::class ),
			$this->createMock( GenderCache::class ),
			$this->getServiceContainer()->getConnectionProvider(),
			$this->getServiceContainer()->getLinksMigration(),
			LoggerFactory::getInstance( 'LinkBatch' )
		);
	}

	/**
	 * @covers \MediaWiki\Cache\LinkBatch::addObj()
	 * @covers \MediaWiki\Cache\LinkBatch::getSize()
	 */
	public function testAddObj() {
		$batch = $this->newLinkBatch(
			[
				new TitleValue( NS_MAIN, 'Foo' ),
				new PageReferenceValue( NS_USER, 'Foo', PageReference::LOCAL ),
			]
		);

		$batch->addObj( new PageReferenceValue( NS_TALK, 'Bar', PageReference::LOCAL ) );
		$batch->addObj( new TitleValue( NS_MAIN, 'Foo' ) );

		$this->assertSame( 3, $batch->getSize() );
		$this->assertCount( 3, $batch->getPageIdentities() );
	}

	/**
	 * @covers \MediaWiki\Cache\LinkBatch::add()
	 * @covers \MediaWiki\Cache\LinkBatch::getSize()
	 */
	public function testAdd() {
		$batch = $this->newLinkBatch(
			[
				new TitleValue( NS_MAIN, 'Foo' )
			]
		);

		$batch->add( NS_TALK, 'Bar' );
		$batch->add( NS_MAIN, 'Foo' );

		$this->assertSame( 2, $batch->getSize() );
		$this->assertCount( 2, $batch->getPageIdentities() );
	}

	public function testExecute() {
		$existing1 = $this->getExistingTestPage( __METHOD__ . '1' )->getTitle();
		$existing2 = $this->getExistingTestPage( __METHOD__ . '2' )->getTitle();
		$nonexisting1 = $this->getNonexistingTestPage( __METHOD__ . 'x' )->getTitle();
		$nonexisting2 = $this->getNonexistingTestPage( __METHOD__ . 'y' )->getTitle();

		$cache = $this->createMock( LinkCache::class );

		$good = [];
		$bad = [];

		$cache->expects( $this->exactly( 2 ) )
			->method( 'addGoodLinkObjFromRow' )
			->willReturnCallback( static function ( TitleValue $title, $row ) use ( &$good ) {
				$good["$title"] = $title;
			} );

		$cache->expects( $this->exactly( 2 ) )
			->method( 'addBadLinkObj' )
			->willReturnCallback( static function ( TitleValue $title ) use ( &$bad ) {
				$bad["$title"] = $title;
			} );

		$services = $this->getServiceContainer();

		$batch = new LinkBatch(
			[],
			$cache,
			// TODO: This would be even better with mocked dependencies
			$services->getTitleFormatter(),
			$services->getContentLanguage(),
			$services->getGenderCache(),
			$services->getConnectionProvider(),
			$services->getLinksMigration(),
			LoggerFactory::getInstance( 'LinkBatch' )
		);

		$batch->addObj( $existing1 );
		$batch->addObj( $existing2 );
		$batch->addObj( $nonexisting1 );
		$batch->addObj( $nonexisting2 );

		// Bad stuff, should be skipped!
		$batch->add( NS_MAIN, '_X' );
		$batch->add( NS_MAIN, 'X_' );
		$batch->add( NS_MAIN, '' );

		@$batch->execute();

		$this->assertArrayHasKey( $existing1->getTitleValue()->__toString(), $good );
		$this->assertArrayHasKey( $existing2->getTitleValue()->__toString(), $good );

		$this->assertArrayHasKey( $nonexisting1->getTitleValue()->__toString(), $bad );
		$this->assertArrayHasKey( $nonexisting2->getTitleValue()->__toString(), $bad );

		$expected = array_map(
			[ CacheKeyHelper::class, 'getKeyForPage' ],
			[ $existing1, $existing2, $nonexisting1, $nonexisting2 ]
		);

		$actual = array_map(
			[ CacheKeyHelper::class, 'getKeyForPage' ],
			$batch->getPageIdentities()
		);

		sort( $expected );
		sort( $actual );

		$this->assertEquals( $expected, $actual );
	}

	public function testDoGenderQueryWithEmptyLinkBatch() {
		$batch = new LinkBatch(
			[],
			$this->createMock( LinkCache::class ),
			$this->createMock( TitleFormatter::class ),
			$this->createNoOpMock( Language::class ),
			$this->createNoOpMock( GenderCache::class ),
			$this->createMock( IConnectionProvider::class ),
			$this->createMock( LinksMigration::class ),
			LoggerFactory::getInstance( 'LinkBatch' )
		);

		$this->assertFalse( $batch->doGenderQuery() );
	}

	public function testDoGenderQueryWithLanguageWithoutGenderDistinction() {
		$language = $this->createMock( Language::class );
		$language->method( 'needsGenderDistinction' )->willReturn( false );

		$batch = new LinkBatch(
			[],
			$this->createMock( LinkCache::class ),
			$this->createMock( TitleFormatter::class ),
			$language,
			$this->createNoOpMock( GenderCache::class ),
			$this->createMock( IConnectionProvider::class ),
			$this->createMock( LinksMigration::class ),
			LoggerFactory::getInstance( 'LinkBatch' )
		);
		$batch->addObj(
			new TitleValue( NS_MAIN, 'Foo' )
		);

		$this->assertFalse( $batch->doGenderQuery() );
	}

	public function testDoGenderQueryWithLanguageWithGenderDistinction() {
		$language = $this->createMock( Language::class );
		$language->method( 'needsGenderDistinction' )->willReturn( true );

		$genderCache = $this->createMock( GenderCache::class );
		$genderCache->expects( $this->once() )->method( 'doLinkBatch' );

		$batch = new LinkBatch(
			[],
			$this->createMock( LinkCache::class ),
			$this->createMock( TitleFormatter::class ),
			$language,
			$genderCache,
			$this->createMock( IConnectionProvider::class ),
			$this->createMock( LinksMigration::class ),
			LoggerFactory::getInstance( 'LinkBatch' )
		);
		$batch->addObj(
			new TitleValue( NS_MAIN, 'Foo' )
		);

		$this->assertTrue( $batch->doGenderQuery() );
	}

	public static function provideBadObjects() {
		yield 'null' => [ null ];
		yield 'empty' => [ Title::makeTitle( NS_MAIN, '' ) ];
		yield 'bad user' => [ Title::makeTitle( NS_USER, '#12345' ) ];
		yield 'section' => [ new TitleValue( NS_MAIN, '', '#See_also' ) ];
		yield 'special' => [ new TitleValue( NS_SPECIAL, 'RecentChanges' ) ];
	}

	/**
	 * @dataProvider provideBadObjects
	 */
	public function testAddBadObj( $obj ) {
		$linkBatch = $this->newLinkBatch();
		$linkBatch->addObj( $obj );
		$linkBatch->execute();
		$this->addToAssertionCount( 1 );
	}

	public static function provideBadDBKeys() {
		yield 'empty' => [ '' ];
		yield 'section' => [ '#See_also' ];
		yield 'pipe' => [ 'foo|bar' ];
	}

	/**
	 * @dataProvider provideBadDBKeys
	 */
	public function testAddBadDBKeys( $key ) {
		$linkBatch = $this->newLinkBatch();
		$linkBatch->add( NS_MAIN, $key );
		$linkBatch->execute();
		$this->addToAssertionCount( 1 );
	}
}
PK       ! F  F  -  preferences/DefaultPreferencesFactoryTest.phpnu Iw        <?php

use MediaWiki\Auth\AuthManager;
use MediaWiki\Config\Config;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Language\ILanguageConverter;
use MediaWiki\Language\Language;
use MediaWiki\Languages\LanguageConverterFactory;
use MediaWiki\Languages\LanguageNameUtils;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Parser\ParserFactory;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Preferences\DefaultPreferencesFactory;
use MediaWiki\Preferences\SignatureValidatorFactory;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Session\SessionId;
use MediaWiki\Tests\Session\TestUtils;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\Title\Title;
use MediaWiki\User\Options\UserOptionsLookup;
use MediaWiki\User\Options\UserOptionsManager;
use MediaWiki\User\User;
use MediaWiki\User\UserGroupManager;
use MediaWiki\User\UserGroupMembership;
use MediaWiki\User\UserIdentity;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\TestingAccessWrapper;

/**
 * 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
 */

/**
 * @group Preferences
 * @group Database
 * @coversDefaultClass \MediaWiki\Preferences\DefaultPreferencesFactory
 */
class DefaultPreferencesFactoryTest extends \MediaWikiIntegrationTestCase {
	use DummyServicesTrait;
	use TestAllServiceOptionsUsed;

	/** @var IContextSource */
	protected $context;

	/** @var Config */
	protected $config;

	protected function setUp(): void {
		parent::setUp();
		$this->context = new RequestContext();
		$this->context->setTitle( Title::makeTitle( NS_MAIN, self::class ) );

		$this->overrideConfigValues( [
			MainConfigNames::DisableLangConversion => false,
			MainConfigNames::UsePigLatinVariant => false,
		] );
		$this->config = $this->getServiceContainer()->getMainConfig();
	}

	/**
	 * @covers ::__construct
	 */
	public function testConstruct() {
		// Make sure if the optional services are not provided, stuff still works, so that
		// the GlobalPreferences extension isn't broken
		$params = [
			$this->createMock( ServiceOptions::class ),
			$this->createMock( Language::class ),
			$this->createMock( AuthManager::class ),
			$this->createMock( LinkRenderer::class ),
			$this->createMock( NamespaceInfo::class ),
			$this->createMock( PermissionManager::class ),
			$this->createMock( ILanguageConverter::class ),
			$this->createMock( LanguageNameUtils::class ),
			$this->createMock( HookContainer::class ),
			$this->createMock( UserOptionsLookup::class ),
		];
		$preferencesFactory = new DefaultPreferencesFactory( ...$params );
		$this->assertInstanceOf(
			DefaultPreferencesFactory::class,
			$preferencesFactory,
			'Created with some services missing'
		);

		// Now, make sure that MediaWikiServices isn't used
		// Switch the UserOptionsLookup to a UserOptionsManager
		$params[9] = $this->createMock( UserOptionsManager::class );
		$params[] = $this->createMock( LanguageConverterFactory::class );
		$params[] = $this->createMock( ParserFactory::class );
		$params[] = $this->createMock( SkinFactory::class );
		$params[] = $this->createMock( UserGroupManager::class );
		$params[] = $this->createMock( SignatureValidatorFactory::class );
		$oldMwServices = MediaWikiServices::forceGlobalInstance(
			$this->createNoOpMock( MediaWikiServices::class )
		);
		// Wrap in a try-finally block to make sure the real MediaWikiServices is
		// always put back even if something goes wrong
		try {
			$preferencesFactory = new DefaultPreferencesFactory( ...$params );
			$this->assertInstanceOf(
				DefaultPreferencesFactory::class,
				$preferencesFactory,
				'Created with all services, MediaWikiServices not used'
			);
		} finally {
			// Put back the real MediaWikiServices
			MediaWikiServices::forceGlobalInstance( $oldMwServices );
		}
	}

	/**
	 * Get a basic PreferencesFactory for testing with.
	 * @param array $options Supported options are:
	 *    'language' - A Language object, falls back to content language
	 *    'userOptionsManager' - A UserOptionsManager service, falls back to using MediaWikiServices
	 *    'userGroupManager' - A UserGroupManager service, falls back to a mock where no users
	 *                         have any extra groups, just `*` and `user`
	 * @return DefaultPreferencesFactory
	 */
	protected function getPreferencesFactory( array $options = [] ) {
		$nsInfo = $this->getDummyNamespaceInfo();

		$services = $this->getServiceContainer();

		// The PermissionManager should not be used for anything, its only a parameter
		// until we figure out how to remove it without breaking the GlobalPreferences
		// extension (GlobalPreferencesFactory extends DefaultPreferencesFactory)
		$permissionManager = $this->createNoOpMock( PermissionManager::class );

		$language = $options['language'] ?? $services->getContentLanguage();
		$userOptionsManager = $options['userOptionsManager'] ?? $services->getUserOptionsManager();

		$userGroupManager = $options['userGroupManager'] ?? false;
		if ( !$userGroupManager ) {
			$userGroupManager = $this->createMock( UserGroupManager::class );
			$userGroupManager->method( 'getUserGroupMemberships' )->willReturn( [] );
			$userGroupManager->method( 'getUserEffectiveGroups' )->willReturnCallback(
				static function ( UserIdentity $user ) {
					return $user->isRegistered() ? [ '*', 'user' ] : [ '*' ];
				}
			);
		}

		return new DefaultPreferencesFactory(
			new LoggedServiceOptions( self::$serviceOptionsAccessLog,
				DefaultPreferencesFactory::CONSTRUCTOR_OPTIONS, $this->config ),
			$language,
			$services->getAuthManager(),
			$services->getLinkRenderer(),
			$nsInfo,
			$permissionManager,
			$services->getLanguageConverterFactory()->getLanguageConverter( $language ),
			$services->getLanguageNameUtils(),
			$services->getHookContainer(),
			$userOptionsManager,
			$services->getLanguageConverterFactory(),
			$services->getParserFactory(),
			$services->getSkinFactory(),
			$userGroupManager,
			$services->getSignatureValidatorFactory()
		);
	}

	/**
	 * @covers ::getForm
	 * @covers ::searchPreferences
	 */
	public function testGetForm() {
		$this->setTemporaryHook( 'GetPreferences', HookContainer::NOOP );

		$testUser = $this->createMock( User::class );
		$prefFactory = $this->getPreferencesFactory();
		$form = $prefFactory->getForm( $testUser, $this->context );
		$this->assertInstanceOf( PreferencesFormOOUI::class, $form );
		$this->assertCount( 6, $form->getPreferenceSections() );
	}

	/**
	 * @covers ::sortSkinNames
	 */
	public function testSortSkinNames() {
		/** @var DefaultPreferencesFactory $factory */
		$factory = TestingAccessWrapper::newFromObject(
			$this->getPreferencesFactory()
		);
		$validSkinNames = [
			'minerva' => 'Minerva Neue',
			'monobook' => 'Monobook',
			'cologne-blue' => 'Cologne Blue',
			'vector' => 'Vector',
			'vector-2022' => 'Vector 2022',
			'timeless' => 'Timeless',
		];
		$currentSkin = 'monobook';
		$preferredSkins = [ 'vector-2022', 'invalid-skin', 'vector' ];

		uksort( $validSkinNames, static function ( $a, $b ) use ( $factory, $currentSkin, $preferredSkins ) {
			return $factory->sortSkinNames( $a, $b, $currentSkin, $preferredSkins );
		} );

		$this->assertArrayEquals( [
			'monobook' => 'Monobook',
			'vector-2022' => 'Vector 2022',
			'vector' => 'Vector',
			'cologne-blue' => 'Cologne Blue',
			'minerva' => 'Minerva Neue',
			'timeless' => 'Timeless',
		], $validSkinNames );
	}

	/**
	 * CSS classes for emailauthentication preference field when there's no email.
	 * @see https://phabricator.wikimedia.org/T36302
	 *
	 * @covers ::profilePreferences
	 * @dataProvider emailAuthenticationProvider
	 */
	public function testEmailAuthentication( $user, $cssClass ) {
		$this->overrideConfigValue( MainConfigNames::EmailAuthentication, true );

		$prefs = $this->getPreferencesFactory()
			->getFormDescriptor( $user, $this->context );
		$this->assertArrayHasKey( 'cssclass', $prefs['emailauthentication'] );
		$this->assertEquals( $cssClass, $prefs['emailauthentication']['cssclass'] );
	}

	/**
	 * @covers ::renderingPreferences
	 */
	public function testShowRollbackConfIsHiddenForUsersWithoutRollbackRights() {
		$userMock = $this->createMock( User::class );
		$userMock->method( 'isAllowed' )->willReturnCallback(
			static function ( $permission ) {
				return $permission === 'editmyoptions';
			}
		);

		$userOptionsManagerMock = $this->createUserOptionsManagerMock( [ 'test' => 'yes' ], true );
		$userMock = $this->getUserMockWithSession( $userMock );
		$prefs = $this->getPreferencesFactory( [
			'userOptionsManager' => $userOptionsManagerMock,
		] )->getFormDescriptor( $userMock, $this->context );
		$this->assertArrayNotHasKey( 'showrollbackconfirmation', $prefs );
	}

	/**
	 * @covers ::renderingPreferences
	 */
	public function testShowRollbackConfIsShownForUsersWithRollbackRights() {
		$userMock = $this->createMock( User::class );
		$userMock->method( 'isAllowed' )->willReturnCallback(
			static function ( $permission ) {
				return $permission === 'editmyoptions' || $permission === 'rollback';
			}
		);
		$userMock = $this->getUserMockWithSession( $userMock );

		$userOptionsManagerMock = $this->createUserOptionsManagerMock( [ 'test' => 'yes' ], true );
		$prefs = $this->getPreferencesFactory( [
			'userOptionsManager' => $userOptionsManagerMock,
		] )->getFormDescriptor( $userMock, $this->context );
		$this->assertArrayHasKey( 'showrollbackconfirmation', $prefs );
		$this->assertEquals(
			'rendering/advancedrendering',
			$prefs['showrollbackconfirmation']['section']
		);
	}

	public function emailAuthenticationProvider() {
		$userNoEmail = new User;
		$userEmailUnauthed = new User;
		$userEmailUnauthed->setEmail( 'noauth@example.org' );
		$userEmailAuthed = new User;
		$userEmailAuthed->setEmail( 'noauth@example.org' );
		$userEmailAuthed->setEmailAuthenticationTimestamp( wfTimestamp() );
		return [
			[ $userNoEmail, 'mw-email-none' ],
			[ $userEmailUnauthed, 'mw-email-not-authenticated' ],
			[ $userEmailAuthed, 'mw-email-authenticated' ],
		];
	}

	/**
	 * Test that PreferencesFormPreSave hook has correct data:
	 *  - user Object is passed
	 *  - oldUserOptions contains previous user options (before save)
	 *  - formData and User object have set up new properties
	 *
	 * @see https://phabricator.wikimedia.org/T169365
	 * @covers ::submitForm
	 */
	public function testPreferencesFormPreSaveHookHasCorrectData() {
		$oldOptions = [
			'test' => 'abc',
			'option' => 'old'
		];
		$newOptions = [
			'test' => 'abc',
			'option' => 'new'
		];

		$this->overrideConfigValue( MainConfigNames::HiddenPrefs, [] );

		$form = $this->createMock( PreferencesFormOOUI::class );

		$userMock = $this->createMock( User::class );

		$userOptionsManagerMock = $this->createUserOptionsManagerMock( $oldOptions );
		$expectedOptions = $newOptions;
		$userOptionsManagerMock->expects( $this->exactly( count( $newOptions ) ) )
			->method( 'setOption' )
			->willReturnCallback( function ( $user, $oname, $val ) use ( $userMock, &$expectedOptions ) {
				$this->assertSame( $userMock, $user );
				$this->assertArrayHasKey( $oname, $expectedOptions );
				$this->assertSame( $expectedOptions[$oname], $val );
				unset( $expectedOptions[$oname] );
			} );
		$userMock->method( 'isAllowed' )->willReturnCallback(
			static function ( $permission ) {
				return $permission === 'editmyprivateinfo' || $permission === 'editmyoptions';
			}
		);
		$userMock->method( 'isAllowedAny' )->willReturnCallback(
			static function ( ...$permissions ) {
				foreach ( $permissions as $perm ) {
					if ( $perm === 'editmyprivateinfo' || $perm === 'editmyoptions' ) {
						return true;
					}
				}
				return false;
			}
		);

		$form->method( 'getModifiedUser' )
			->willReturn( $userMock );

		$form->method( 'getContext' )
			->willReturn( $this->context );

		$this->setTemporaryHook( 'PreferencesFormPreSave',
			function (
				$formData, $form, $user, &$result, $oldUserOptions
			) use (
				$newOptions, $oldOptions, $userMock
			) {
				$this->assertSame( $userMock, $user );
				foreach ( $newOptions as $option => $value ) {
					$this->assertSame( $value, $formData[ $option ] );
				}
				foreach ( $oldOptions as $option => $value ) {
					$this->assertSame( $value, $oldUserOptions[ $option ] );
				}
				$this->assertTrue( $result );
			}
		);

		/** @var DefaultPreferencesFactory $factory */
		$factory = TestingAccessWrapper::newFromObject(
			$this->getPreferencesFactory( [ 'userOptionsManager' => $userOptionsManagerMock ] )
		);
		$factory->saveFormData( $newOptions, $form, [] );
	}

	/**
	 * The rclimit preference should accept non-integer input and filter it to become an integer.
	 *
	 * @covers ::saveFormData
	 */
	public function testIntvalFilter() {
		// Test a string with leading zeros (i.e. not octal) and spaces.
		$this->context->getRequest()->setVal( 'wprclimit', ' 0012 ' );
		$user = new User;
		$prefFactory = $this->getPreferencesFactory();
		$form = $prefFactory->getForm( $user, $this->context );
		$form->show();
		$form->trySubmit();
		$userOptionsLookup = $this->getServiceContainer()->getUserOptionsLookup();
		$this->assertEquals( 12, $userOptionsLookup->getOption( $user, 'rclimit' ) );
	}

	/**
	 * @covers ::profilePreferences
	 */
	public function testVariantsSupport() {
		$userMock = $this->createMock( User::class );
		$userMock->method( 'isAllowed' )->willReturn( true );
		$userMock = $this->getUserMockWithSession( $userMock );

		$language = $this->createMock( Language::class );
		$language->method( 'getCode' )
			->willReturn( 'sr' );

		$userOptionsManagerMock = $this->createUserOptionsManagerMock(
			[ 'LanguageCode' => 'sr', 'variant' => 'sr' ], true
		);

		$prefs = $this->getPreferencesFactory( [
			'language' => $language,
			'userOptionsManager' => $userOptionsManagerMock,
		] )->getFormDescriptor( $userMock, $this->context );
		$this->assertArrayHasKey( 'default', $prefs['variant'] );
		$this->assertEquals( 'sr', $prefs['variant']['default'] );
	}

	/**
	 * @covers ::profilePreferences
	 */
	public function testUserGroupMemberships() {
		$userMock = $this->createMock( User::class );
		$userMock->method( 'isAllowed' )->willReturn( true );
		$userMock->method( 'isAllowedAny' )->willReturn( true );
		$userMock->method( 'isRegistered' )->willReturn( true );
		$userMock = $this->getUserMockWithSession( $userMock );

		$language = $this->createMock( Language::class );
		$language->method( 'getCode' )
			->willReturn( 'en' );

		$userOptionsManagerMock = $this->createUserOptionsManagerMock( [], true );

		$prefs = $this->getPreferencesFactory( [
			'language' => $language,
			'userOptionsManager' => $userOptionsManagerMock,
		] )->getFormDescriptor( $userMock, $this->context );
		$this->assertArrayHasKey( 'default', $prefs['usergroups'] );
		$this->assertEquals(
			UserGroupMembership::getLinkHTML( 'user', $this->context ),
			( $prefs['usergroups']['default'] )()
		);
	}

	/**
	 * @coversNothing
	 */
	public function testAllServiceOptionsUsed() {
		$this->assertAllServiceOptionsUsed( [
			// Only used when $wgEnotifWatchlist or $wgEnotifUserTalk is true
			'EnotifMinorEdits',
			// Only used when $wgEnotifWatchlist or $wgEnotifUserTalk is true
			'EnotifRevealEditorAddress',
			// Only used when 'fancysig' preference is enabled
			'SignatureValidation',
		] );
	}

	/**
	 * @param array $userOptions
	 * @param bool $defaultOptions
	 * @return UserOptionsManager&MockObject
	 */
	private function createUserOptionsManagerMock( array $userOptions, bool $defaultOptions = false ) {
		$services = $this->getServiceContainer();
		$defaults = $services->getMainConfig()->get( MainConfigNames::DefaultUserOptions );
		$defaults['language'] = $services->getContentLanguage()->getCode();
		$defaults['skin'] = Skin::normalizeKey( $services->getMainConfig()->get( MainConfigNames::DefaultSkin ) );
		( new HookRunner( $services->getHookContainer() ) )->onUserGetDefaultOptions( $defaults );
		$userOptions += $defaults;

		$mock = $this->createMock( UserOptionsManager::class );
		$mock->method( 'getOptions' )->willReturn( $userOptions );
		$mock->method( 'getOption' )->willReturnCallback(
			static function ( $user, $option ) use ( $userOptions ) {
				return $userOptions[$option] ?? null;
			}
		);
		if ( $defaultOptions ) {
			$mock->method( 'getDefaultOptions' )->willReturn( $defaults );
		}
		return $mock;
	}

	/**
	 * @param MockObject $userMock
	 * @return MockObject
	 */
	private function getUserMockWithSession( MockObject $userMock ): MockObject {
		// We're mocking a stdClass because the Session class is final, and thus not mockable.
		$mock = $this->getMockBuilder( stdClass::class )
			->addMethods( [ 'getAllowedUserRights', 'deregisterSession', 'getSessionId' ] )
			->getMock();
		$mock->method( 'getSessionId' )->willReturn(
			new SessionId( str_repeat( 'X', 32 ) )
		);
		$session = TestUtils::getDummySession( $mock );
		$mockRequest = $this->getMockBuilder( FauxRequest::class )
			->onlyMethods( [ 'getSession' ] )
			->getMock();
		$mockRequest->method( 'getSession' )->willReturn( $session );
		$userMock->method( 'getRequest' )->willReturn( $mockRequest );
		$userMock->method( 'getTitleKey' )->willReturn( '' );
		return $userMock;
	}
}
PK       ! _X    &  preferences/SignatureValidatorTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Preferences\SignatureValidator;
use MediaWiki\Registration\ExtensionRegistry;
use Wikimedia\TestingAccessWrapper;

/**
 * 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
 */

/**
 * @group Preferences
 * @group Database
 */
class SignatureValidatorTest extends MediaWikiIntegrationTestCase {

	/** @var SignatureValidator */
	private $validator;

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValue( MainConfigNames::ParsoidSettings, [
			'linting' => true
		] );
		// For testing SignatureAllowedLintErrors in ::testValidateSignature
		$this->overrideConfigValue( MainConfigNames::SignatureAllowedLintErrors, [
			// No allowed lint errors in default set up
		] );
		// For testing hidden category support in ::testValidateSignature
		$this->overrideConfigValue( 'LinterCategories', [
			// No hidden categories in default set up
		] );
		$extReg = $this->createMock( ExtensionRegistry::class );
		$extReg->method( 'isLoaded' )->willReturnCallback( static function ( string $which ) {
			return $which == 'Linter';
		} );
		$this->setService( 'ExtensionRegistry', $extReg );
		$this->validator = $this->getSignatureValidator();
	}

	/**
	 * Get a basic SignatureValidator for testing with.
	 * @return SignatureValidator
	 */
	protected function getSignatureValidator() {
		$services = $this->getServiceContainer();
		$lang = $services->getLanguageFactory()->getLanguage( 'en' );
		$user = $services->getUserFactory()->newFromName( 'SignatureValidatorTest' );
		$validator = $services->getSignatureValidatorFactory()->newSignatureValidator(
			$user,
			null,
			ParserOptions::newFromUserAndLang( $user, $lang )
		);

		return TestingAccessWrapper::newFromObject( $validator );
	}

	/**
	 * @covers \MediaWiki\Preferences\SignatureValidator::applyPreSaveTransform()
	 * @dataProvider provideApplyPreSaveTransform
	 */
	public function testApplyPreSaveTransform( $signature, $expected ) {
		$pstSig = $this->validator->applyPreSaveTransform( $signature );
		$this->assertSame( $expected, $pstSig );
	}

	public static function provideApplyPreSaveTransform() {
		return [
			'Pipe trick' =>
				[ '[[test|]]', '[[test|test]]' ],
			'One level substitution' =>
				[ '{{subst:uc:whatever}}', 'WHATEVER' ],
			'Hidden nested substitution' =>
				[ '{{subst:uc:{}}{{subst:uc:{subst:uc:}}}{{subst:uc:}}}', false ],
			'Hidden nested signature' =>
				[ '{{subst:uc:~~}}{{subst:uc:~~}}', false ],
		];
	}

	/**
	 * @covers \MediaWiki\Preferences\SignatureValidator::checkUserLinks()
	 * @dataProvider provideCheckUserLinks
	 */
	public function testCheckUserLinks( $signature, $expected ) {
		$isValid = $this->validator->checkUserLinks( $signature );
		$this->assertSame( $expected, $isValid );
	}

	public static function provideCheckUserLinks() {
		return [
			'Perfect' =>
				[ '[[User:SignatureValidatorTest|Signature]] ([[User talk:SignatureValidatorTest|talk]])', true ],
			'User link' =>
				[ '[[User:SignatureValidatorTest|Signature]]', true ],
			'User talk link' =>
				[ '[[User talk:SignatureValidatorTest]]', true ],
			'Contributions link' =>
				[ '[[Special:Contributions/SignatureValidatorTest]]', true ],
			'Silly formatting permitted' =>
				[ '[[_uSeR :_signatureValidatorTest_]]', true ],
			'Contributions of wrong user' =>
				[ '[[Special:Contributions/SignatureValidatorTestNot]]', false ],
			'Link to subpage only' =>
				[ '[[User:SignatureValidatorTest/blah|Signature]]', false ],
		];
	}

	/**
	 * @covers \MediaWiki\Preferences\SignatureValidator::checkLintErrors()
	 * @dataProvider provideCheckLintErrors
	 */
	public function testCheckLintErrors( $signature, $expected ) {
		$errors = $this->validator->checkLintErrors( $signature );
		$this->assertSame( $expected, $errors );
	}

	public static function provideCheckLintErrors() {
			yield 'Perfect' => [ '<strong>Foo</strong>', [] ];
			yield 'Unclosed tag' => [
				'<strong>Foo',
				[
					[
						'type' => 'missing-end-tag',
						'dsr' => [ 0, 11, 8, 0 ],
						'templateInfo' => null,
						'params' => [
							'name' => 'strong',
							'inTable' => false,
						]
					]
				]
			];
	}

	/**
	 * @covers \MediaWiki\Preferences\SignatureValidator::validateSignature()
	 * @dataProvider provideValidateSignature
	 */
	public function testValidateSignature( string $signature, $expected ) {
		$result = $this->validator->validateSignature( $signature );
		if ( is_string( $expected ) ) {
			// All special cases should report errors here.
			$expected = true;
		}
		$this->assertSame( $expected, $result );
	}

	/**
	 * @covers \MediaWiki\Preferences\SignatureValidator::validateSignature()
	 * @dataProvider provideValidateSignature
	 */
	public function testValidateSignatureAllowed( string $signature, $expected ) {
		$this->overrideConfigValue( MainConfigNames::SignatureAllowedLintErrors, [
			'obsolete-tag'
		] );
		$this->validator = $this->getSignatureValidator();
		$result = $this->validator->validateSignature( $signature );
		if ( $expected === 'allowed' ) {
			$expected = false;
		} elseif ( is_string( $expected ) ) {
			$expected = true;
		}
		$this->assertSame( $expected, $result );
	}

	public function provideValidateSignature() {
		yield 'Perfect' => [
			'[[User:SignatureValidatorTest|Signature]] ([[User talk:SignatureValidatorTest|talk]])',
			// no complaints from lint
			false
		];
		yield 'Missing end tag' => [
			'<span>[[User:SignatureValidatorTest|Signature]] ([[User talk:SignatureValidatorTest|talk]])',
			// missing-end-tag is never allowed
			true
		];
		yield 'Obsolete tag' => [
			'<font color="red">RED</font> [[User:SignatureValidatorTest|Signature]] ([[User talk:SignatureValidatorTest|talk]])',
			// This is allowed by SignatureAllowedLintErrors
			'allowed'
		];
	}

	/**
	 * @covers \MediaWiki\Preferences\SignatureValidator::checkLineBreaks()
	 * @dataProvider provideCheckLineBreaks
	 */
	public function testCheckLineBreaks( $signature, $expected ) {
		$isValid = $this->validator->checkLineBreaks( $signature );
		$this->assertSame( $expected, $isValid );
	}

	public static function provideCheckLineBreaks() {
		return [
			'Perfect' =>
				[ '[[User:SignatureValidatorTest|Signature]] ([[User talk:SignatureValidatorTest|talk]])', true ],
			'Line break' =>
				[ "[[User:SignatureValidatorTest|Signature]] ([[User talk:SignatureValidatorTest|talk\n]])", false ],
		];
	}

}
PK       ! ayT  T     ExternalLinks/LinkFilterTest.phpnu Iw        <?php

use MediaWiki\ExternalLinks\LinkFilter;
use MediaWiki\MainConfigNames;
use MediaWiki\Tests\Unit\Libs\Rdbms\AddQuoterMock;
use Wikimedia\Rdbms\IExpression;
use Wikimedia\Rdbms\LikeMatch;

/**
 * @covers \MediaWiki\ExternalLinks\LinkFilter
 * @group Database
 */
class LinkFilterTest extends MediaWikiLangTestCase {

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

		$this->overrideConfigValue( MainConfigNames::UrlProtocols, [
			'http://',
			'https://',
			'ftp://',
			'irc://',
			'ircs://',
			'gopher://',
			'telnet://',
			'nntp://',
			'worldwind://',
			'mailto:',
			'news:',
			'svn://',
			'git://',
			'mms://',
			'//',
		] );
	}

	/**
	 * Take an array from LinkFilter::makeLikeArray(), and create a regex from it
	 *
	 * @param array $like Array as created by LinkFilter::makeLikeArray()
	 * @return string Regex
	 */
	private function createRegexFromLIKE( $like ) {
		$regex = '!^';

		foreach ( $like as $item ) {
			if ( $item instanceof LikeMatch ) {
				if ( $item->toString() == '%' ) {
					$regex .= '.*';
				} elseif ( $item->toString() == '_' ) {
					$regex .= '.';
				}
			} else {
				$regex .= preg_quote( $item, '!' );
			}

		}

		$regex .= '$!';

		return $regex;
	}

	public static function provideValidPatterns() {
		return [
			// Protocol, Search pattern, URL which matches the pattern
			[ 'http://', '*.test.com', 'http://www.test.com' ],
			[ 'http://', 'test.com:8080/dir/file', 'http://name:pass@test.com:8080/dir/file' ],
			[ 'https://', '*.com', 'https://s.s.test..com:88/dir/file?a=1&b=2' ],
			[ 'https://', '*.com', 'https://name:pass@secure.com/index.html' ],
			[ 'http://', 'name:pass@test.com', 'http://test.com' ],
			[ 'http://', 'test.com', 'http://name:pass@test.com' ],
			[ 'http://', '*.test.com', 'http://a.b.c.test.com/dir/dir/file?a=6' ],
			[ null, 'http://*.test.com', 'http://www.test.com' ],
			[ 'http://', '.test.com', 'http://.test.com' ],
			[ 'http://', '*..test.com', 'http://foo..test.com' ],
			[ 'mailto:', 'name@mail.test123.com', 'mailto:name@mail.test123.com' ],
			[ 'mailto:', '*@mail.test123.com', 'mailto:name@mail.test123.com' ],
			[ '',
				'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg',
				'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg'
			],
			[ '', 'http://name:pass@*.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg',
				'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ],
			[ '', 'http://name:wrongpass@*.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]',
				'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ],
			[ 'http://', 'name:pass@*.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg',
				'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ],
			[ '', 'http://name:pass@www.test.com:12345',
				'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ],
			[ 'ftp://', 'user:pass@ftp.test.com:1233/home/user/file;type=efw',
				'ftp://user:pass@ftp.test.com:1233/home/user/file;type=efw' ],
			[ null, 'ftp://otheruser:otherpass@ftp.test.com:1233/home/user/file;type=',
				'ftp://user:pass@ftp.test.com:1233/home/user/file;type=efw' ],
			[ null, 'ftp://@ftp.test.com:1233/home/user/file;type=',
				'ftp://user:pass@ftp.test.com:1233/home/user/file;type=efw' ],
			[ null, 'ftp://ftp.test.com/',
				'ftp://user:pass@ftp.test.com/home/user/file;type=efw' ],
			[ null, 'ftp://ftp.test.com/',
				'ftp://user:pass@ftp.test.com/home/user/file;type=efw' ],
			[ null, 'ftp://*.test.com:222/',
				'ftp://user:pass@ftp.test.com:222/home' ],
			[ 'irc://', '*.myserver:6667/', 'irc://test.myserver:6667/' ],
			[ 'irc://', 'name:pass@*.myserver/', 'irc://test.myserver:6667/' ],
			[ 'irc://', 'name:pass@*.myserver/', 'irc://other:@test.myserver:6667/' ],
			[ '', 'irc://test/name,string,abc?msg=t', 'irc://test/name,string,abc?msg=test' ],
			[ '', 'https://gerrit.wikimedia.org/r/#/q/status:open,n,z',
				'https://gerrit.wikimedia.org/r/#/q/status:open,n,z' ],
			[ '', 'https://gerrit.wikimedia.org',
				'https://gerrit.wikimedia.org/r/#/q/status:open,n,z' ],
			[ 'mailto:', '*.test.com', 'mailto:name@pop3.test.com' ],
			[ 'mailto:', 'test.com', 'mailto:name@test.com' ],
			[ 'news:', 'test.1234afc@news.test.com', 'news:test.1234afc@news.test.com' ],
			[ 'news:', '*.test.com', 'news:test.1234afc@news.test.com' ],
			[ '', 'news:4df8kh$iagfewewf(at)newsbf02aaa.news.aol.com',
				'news:4df8kh$iagfewewf(at)newsbf02aaa.news.aol.com' ],
			[ '', 'news:*.aol.com',
				'news:4df8kh$iagfewewf(at)newsbf02aaa.news.aol.com' ],

			// (T347574) Only set host if it's not already set (if // is used)
			[ 'news:', 'comp.compression', 'news://comp.compression' ],
			[ 'news:', 'comp.compression', 'news:comp.compression' ],
			// (T364743) Also only set host if it's not already set, but in a different code path
			[ '', 'news://*.example.com', 'news://example.org', [ 'found' => false ] ],

			[ '', 'git://github.com/prwef/abc-def.git', 'git://github.com/prwef/abc-def.git' ],
			[ 'git://', 'github.com/', 'git://github.com/prwef/abc-def.git' ],
			[ 'git://', '*.github.com/', 'git://a.b.c.d.e.f.github.com/prwef/abc-def.git' ],
			[ '', 'gopher://*.test.com/', 'gopher://gopher.test.com/0/v2/vstat' ],
			[ 'telnet://', '*.test.com', 'telnet://shell.test.com/~home/' ],
			[ '', 'http://test.com', 'http://test.com/index?arg=1' ],
			[ 'http://', '*.test.com', 'http://www.test.com/index?arg=1' ],
			[ '',
				'http://xx23124:__ffdfdef__@www.test.com:12345/dir',
				'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg'
			],
			[ 'http://', '127.0.0.1', 'http://127.000.000.001' ],
			[ 'http://', '127.0.0.*', 'http://127.000.000.010' ],
			[ 'http://', '127.0.*', 'http://127.000.123.010' ],
			[ 'http://', '127.*', 'http://127.127.127.127' ],
			[ 'http://', '[0:0:0:0:0:0:0:0001]', 'http://[::1]' ],
			[ 'http://', '[2001:db8:0:0:*]', 'http://[2001:0DB8::]' ],
			[ 'http://', '[2001:db8:0:0:*]', 'http://[2001:0DB8::123]' ],
			[ 'http://', '[2001:db8:0:0:*]', 'http://[2001:0DB8::123:456]' ],
			[ 'http://', 'xn--f-vgaa.example.com', 'http://fóó.example.com' ],
			[ 'http://', 'xn--f-vgaa.example.com', 'http://f%c3%b3%C3%B3.example.com' ],
			[ 'http://', 'fóó.example.com', 'http://xn--f-vgaa.example.com' ],
			[ 'http://', 'f%c3%b3%C3%B3.example.com', 'http://xn--f-vgaa.example.com' ],
			[ 'http://', 'f%c3%b3%C3%B3.example.com', 'http://fóó.example.com' ],
			[ 'http://', 'fóó.example.com', 'http://f%c3%b3%C3%B3.example.com' ],

			[ 'http://', 'example.com./foo', 'http://example.com/foo' ],
			[ 'http://', 'example.com/foo', 'http://example.com./foo' ],
			[ 'http://', '127.0.0.1./foo', 'http://127.0.0.1/foo' ],
			[ 'http://', '127.0.0.1/foo', 'http://127.0.0.1./foo' ],

			// Tests for false positives
			[ 'http://', 'test.com', 'http://www.test.com', [ 'found' => false ] ],
			[ 'http://', 'www1.test.com', 'http://www.test.com', [ 'found' => false ] ],
			[ 'http://', '*.test.com', 'http://www.test.t.com', [ 'found' => false ] ],
			[ 'http://', 'test.com', 'http://xtest.com', [ 'found' => false ] ],
			[ 'http://', '*.test.com', 'http://xtest.com', [ 'found' => false ] ],
			[ 'http://', '.test.com', 'http://test.com', [ 'found' => false ] ],
			[ 'http://', '.test.com', 'http://www.test.com', [ 'found' => false ] ],
			[ 'http://', '*..test.com', 'http://test.com', [ 'found' => false ] ],
			[ 'http://', '*..test.com', 'http://www.test.com', [ 'found' => false ] ],
			[ '', 'http://test.com:8080', 'http://www.test.com:8080', [ 'found' => false ] ],
			[ '', 'https://test.com', 'http://test.com', [ 'found' => false ] ],
			[ '', 'http://test.com', 'https://test.com', [ 'found' => false ] ],
			[ 'http://', 'http://test.com', 'http://test.com', [ 'found' => false ] ],
			[ null, 'http://www.test.com', 'http://www.test.com:80', [ 'found' => false ] ],
			[ null, 'http://www.test.com:80', 'http://www.test.com', [ 'found' => false ] ],
			[ null, 'http://*.test.com:80', 'http://www.test.com', [ 'found' => false ] ],
			[ '', 'https://gerrit.wikimedia.org/r/#/XXX/status:open,n,z',
				'https://gerrit.wikimedia.org/r/#/q/status:open,n,z', [ 'found' => false ] ],
			[ '', 'https://*.wikimedia.org/r/#/q/status:open,n,z',
				'https://gerrit.wikimedia.org/r/#/XXX/status:open,n,z', [ 'found' => false ] ],
			[ 'mailto:', '@test.com', '@abc.test.com', [ 'found' => false ] ],
			[ 'mailto:', 'mail@test.com', 'mail2@test.com', [ 'found' => false ] ],
			[ '', 'mailto:mail@test.com', 'mail2@test.com', [ 'found' => false ] ],
			[ '', 'mailto:@test.com', '@abc.test.com', [ 'found' => false ] ],
			[ 'ftp://', '*.co', 'ftp://www.co.uk', [ 'found' => false ] ],
			[ 'ftp://', '*.co', 'ftp://www.co.m', [ 'found' => false ] ],
			[ 'ftp://', '*.co/dir/', 'ftp://www.co/dir2/', [ 'found' => false ] ],
			[ 'ftp://', 'www.co/dir/', 'ftp://www.co/dir2/', [ 'found' => false ] ],
			[ 'ftp://', 'test.com/dir/', 'ftp://test.com/', [ 'found' => false ] ],
			[ '', 'http://test.com:8080/dir/', 'http://test.com:808/dir/', [ 'found' => false ] ],
			[ '', 'http://test.com/dir/index.html', 'http://test.com/dir/index.php', [ 'found' => false ] ],
			[ 'http://', '127.0.0.*', 'http://127.0.1.0', [ 'found' => false ] ],
			[ 'http://', '[2001:db8::*]', 'http://[2001:0DB8::123:456]', [ 'found' => false ] ],

			// These are false positives too and ideally shouldn't match, but that
			// would require using regexes and RLIKE instead of LIKE
			// [ null, 'http://*.test.com', 'http://www.test.com:80', [ 'found' => false ] ],
			// [ '', 'https://*.wikimedia.org/r/#/q/status:open,n,z',
			// 	'https://gerrit.wikimedia.org/XXX/r/#/q/status:open,n,z', [ 'found' => false ] ],
		];
	}

	/**
	 * Tests whether the LIKE clause produced by LinkFilter::makeLikeArray($pattern, $protocol)
	 * will find one of the URL indexes produced by LinkFilter::makeIndexes($url)
	 *
	 * @dataProvider provideValidPatterns
	 *
	 * @param string $protocol Protocol, e.g. 'http://' or 'mailto:'
	 * @param string $pattern Search pattern to feed to LinkFilter::makeLikeArray
	 * @param string $url URL to feed to LinkFilter::makeIndexes
	 * @param array $options
	 *  - found: (bool) Should the URL be found? (defaults true)
	 */
	public function testMakeLikeArrayWithValidPatterns( $protocol, $pattern, $url, $options = [] ) {
		$options += [ 'found' => true ];

		$indexes = LinkFilter::makeIndexes( $url );
		$likeArrays = LinkFilter::makeLikeArray( $pattern, $protocol );
		$likeArray = array_merge( $likeArrays[0], $likeArrays[1] );

		$this->assertIsArray( $likeArrays,
			"LinkFilter::makeLikeArray('$pattern', '$protocol') returned false on a valid pattern"
		);

		$regex = $this->createRegexFromLIKE( $likeArray );
		$debugmsg = "Regex: '" . $regex . "'\n";
		$debugmsg .= count( $indexes ) . " index(es) created by LinkFilter::makeIndexes():\n";

		$matches = 0;

		foreach ( $indexes as $index ) {
			$indexString = implode( '', $index );
			$matches += preg_match( $regex, $indexString );
			$debugmsg .= "\t'$indexString'\n";
		}

		if ( $options['found'] ) {
			$this->assertTrue(
				$matches > 0,
				"Search pattern '$protocol$pattern' does not find url '$url' \n$debugmsg"
			);
		} else {
			$this->assertFalse(
				$matches > 0,
				"Search pattern '$protocol$pattern' should not find url '$url' \n$debugmsg"
			);
		}
	}

	public static function provideInvalidPatterns() {
		return [
			[ '' ],
			[ '*' ],
			[ 'http://*' ],
			[ 'http://*/' ],
			[ 'http://*/dir/file' ],
			[ 'test.*.com' ],
			[ 'http://test.*.com' ],
			[ 'test.*.com' ],
			[ 'http://*.test.*' ],
			[ 'http://*test.com' ],
			[ 'https://*' ],
			[ '*://test.com' ],
			[ 'mailto:name:pass@t*est.com' ],
			[ 'http://*:888/' ],
			[ '*http://' ],
			[ 'test.com/*/index' ],
			[ 'test.com/dir/index?arg=*' ],
		];
	}

	/**
	 * testMakeLikeArrayWithInvalidPatterns()
	 *
	 * Tests whether LinkFilter::makeLikeArray($pattern) will reject invalid search patterns
	 *
	 * @dataProvider provideInvalidPatterns
	 *
	 * @param string $pattern Invalid search pattern
	 */
	public function testMakeLikeArrayWithInvalidPatterns( $pattern ) {
		$this->assertFalse(
			LinkFilter::makeLikeArray( $pattern ),
			"'$pattern' is not a valid pattern and should be rejected"
		);
	}

	/**
	 * @dataProvider provideMakeIndexes()
	 */
	public function testMakeIndexes( $url, $expected ) {
		// Set global so file:// tests can work
		$this->overrideConfigValue(
			MainConfigNames::UrlProtocols, [
				'http://',
				'https://',
				'mailto:',
				'//',
				'file://', # Non-default
			] );

		$index = LinkFilter::makeIndexes( $url );
		$this->assertEquals( $expected, $index, "LinkFilter::makeIndexes(\"$url\")" );
	}

	public static function provideMakeIndexes() {
		return [
			// Testcase for T30627
			[
				'https://example.org/test.cgi?id=12345',
				[ [ 'https://org.example.', '/test.cgi?id=12345' ] ]
			],
			[
				// mailtos are handled special
				'mailto:wiki@wikimedia.org',
				[ [ 'mailto:org.wikimedia.@wiki', '' ] ]
			],
			[
				// mailtos are handled special
				'mailto:wiki',
				[ [ 'mailto:@wiki', '' ] ]
			],

			// file URL cases per T30627...
			[
				// three slashes: local filesystem path Unix-style
				'file:///whatever/you/like.txt',
				[ [ 'file://.', '/whatever/you/like.txt' ] ]
			],
			[
				// three slashes: local filesystem path Windows-style
				'file:///c:/whatever/you/like.txt',
				[ [ 'file://.', '/c:/whatever/you/like.txt' ] ]
			],
			[
				// two slashes: UNC filesystem path Windows-style
				'file://intranet/whatever/you/like.txt',
				[ [ 'file://intranet.', '/whatever/you/like.txt' ] ]
			],
			// Multiple-slash cases that can sorta work on Mozilla
			// if you hack it just right are kinda pathological,
			// and unreliable cross-platform or on IE which means they're
			// unlikely to appear on intranets.
			// Those will survive the algorithm but with results that
			// are less consistent.

			// protocol-relative URL cases per T31854...
			[
				'//example.org/test.cgi?id=12345',
				[ [ 'https://org.example.', '/test.cgi?id=12345' ] ]
			],

			// IP addresses
			[
				'http://192.0.2.0/foo',
				[ [ 'http://V4.192.0.2.0.', '/foo' ] ]
			],
			[
				'http://192.0.0002.0/foo',
				[ [ 'http://V4.192.0.2.0.', '/foo' ] ]
			],
			[
				'http://[2001:db8::1]/foo',
				[ [ 'http://V6.2001.DB8.0.0.0.0.0.1.', '/foo' ] ]
			],

			// Explicit specification of the DNS root
			[
				'http://example.com./foo',
				[ [ 'http://com.example.', '/foo' ] ]
			],
			[
				'http://192.0.2.0./foo',
				[ [ 'http://V4.192.0.2.0.', '/foo' ] ]
			],

			// Weird edge case
			[
				'http://.example.com/foo',
				[ [ 'http://com.example..', '/foo' ] ]
			],
		];
	}

	/**
	 * @dataProvider provideReverseIndexes()
	 */
	public function testReverseIndex( $url, $expected ) {
		// Set global so file:// tests can work
		$this->overrideConfigValue(
			MainConfigNames::UrlProtocols, [
			'http://',
			'https://',
			'mailto:',
			'//',
			'file://', # Non-default
			] );

		$index = LinkFilter::reverseIndexes( $url );
		$this->assertEquals( $expected, $index, "LinkFilter::reverseIndexe(\"$url\")" );
	}

	public static function provideReverseIndexes() {
		return [
			// Testcase for T30627
			[
				'https://org.example./test.cgi?id=12345',
				'https://example.org'
			],
			[
				// mailtos are handled special
				'mailto:org.wikimedia.@wiki',
				'mailto:wiki@wikimedia.org'
			],
			[
				// mailtos are handled special
				'mailto:@wiki',
				'mailto:wiki@'
			],
			[
				// mailtos are handled special
				'mailto:wiki',
				'mailto:wiki'
			],

			// file URL cases per T30627...
			[
				// three slashes: local filesystem path Unix-style
				'file:///whatever/you/like.txt',
				'file://'
			],
			[
				// three slashes: local filesystem path Windows-style
				'file:///c:/whatever/you/like.txt',
				'file://'
			],
			[
				// two slashes: UNC filesystem path Windows-style
				'file://intranet/whatever/you/like.txt',
				'file://intranet'
			],
			// Multiple-slash cases that can sorta work on Mozilla
			// if you hack it just right are kinda pathological,
			// and unreliable cross-platform or on IE which means they're
			// unlikely to appear on intranets.
			// Those will survive the algorithm but with results that
			// are less consistent.

			// protocol-relative URL cases per T31854...
			[
				'//org.example/test.cgi?id=12345',
				'//example.org'
			],

			// IP addresses
			[
				'http://V4.192.0.2.0./foo',
				'http://192.0.2.0'
			],
			[
				'http://V4.192.0.2.0./foo',
				'http://192.0.2.0'
			],
			[
				'http://V6.2001.DB8.0.0.0.0.0.1./foo',
				'http://[2001:DB8:0:0:0:0:0:1]'
			],

			// Explicit specification of the DNS root
			[
				'http://com.example./foo',
				'http://example.com'
			],
			[
				'http://192.0.2.0./foo',
				'http://V4.192.0.2.0'
			],

			// Weird edge case
			[
				'http://com.example../foo',
				'http://.example.com'
			],

			// port
			[
				'http://com.example.:8000/foo',
				'http://example.com:8000'
			],
		];
	}

	/**
	 * @dataProvider provideGetQueryConditions
	 */
	public function testGetQueryConditions( $query, $options, $expected ) {
		$this->overrideConfigValues( [
			MainConfigNames::ExternalLinksDomainGaps => [
				'http://example.gaps.' => [
					10 => 20,
					100 => 200,
					1000 => 2000,
				],
				'https://example.gaps.' => [
					30 => 40,
					5000 => 60000,
				],
			],
		] );
		$conds = LinkFilter::getQueryConditions( $query, $options );
		if ( !$conds ) {
			$this->assertEquals( $expected, $conds );
			return;
		}
		$sqlConds = [];
		foreach ( $conds as $cond ) {
			if ( $cond instanceof IExpression ) {
				$sqlConds[] = $cond->toSql( new AddQuoterMock() );
			} else {
				$sqlConds[] = $cond;
			}
		}
		$this->assertEquals( $expected, $sqlConds );
	}

	public static function provideGetQueryConditions() {
		return [
			'Basic example' => [
				'example.com',
				[],
				[
					'(el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' OR ' .
					'el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\')',
					'el_to_path LIKE \'/%\' ESCAPE \'`\'',
				],
			],
			'Basic example with path' => [
				'example.com/foobar',
				[],
				[
					'(el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' OR ' .
					'el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\')',
					'el_to_path LIKE \'/foobar%\' ESCAPE \'`\'',
				],
			],
			'Wildcard domain' => [
				'*.example.com',
				[],
				[
					'(el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' OR ' .
					'el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\')',
					'el_to_path LIKE \'/%\' ESCAPE \'`\'',
				],
			],
			'Wildcard domain with path' => [
				'*.example.com/foobar',
				[],
				[
					'(el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' OR ' .
					'el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\')',
					'el_to_path LIKE \'/foobar%\' ESCAPE \'`\'',
				],
			],
			'Wildcard domain with path, oneWildcard=true' => [
				'*.example.com/foobar',
				[ 'oneWildcard' => true ],
				[
					'(el_to_domain_index = \'http://com.example.\' OR ' .
					'el_to_domain_index = \'https://com.example.\')',
					'el_to_path LIKE \'/foobar%\' ESCAPE \'`\'',
				],
			],
			'Constant prefix' => [
				'example.com/blah/blah/blah/blah/blah/blah/blah/blah/blah/blah?foo=',
				[],
				[
					'(el_to_domain_index LIKE \'http://com.example.%\' ESCAPE \'`\' OR ' .
					'el_to_domain_index LIKE \'https://com.example.%\' ESCAPE \'`\')',
					'el_to_path LIKE ' .
					'\'/blah/blah/blah/blah/blah/blah/blah/blah/blah/blah?foo=%\' ' .
					'ESCAPE \'`\'',
				],
			],
			'Bad protocol' => [
				'test/',
				[ 'protocol' => 'invalid://' ],
				false
			],
			'domains with gaps' => [
				'gaps.example',
				[],
				[
					'((el_to_domain_index LIKE \'http://example.gaps.%\' ESCAPE \'`\' AND ' .
					'(el_id < 10 OR el_id > 20) AND ' .
					'(el_id < 100 OR el_id > 200) AND ' .
					'(el_id < 1000 OR el_id > 2000)) OR ' .
					'(el_to_domain_index LIKE \'https://example.gaps.%\' ESCAPE \'`\' AND ' .
					'(el_id < 30 OR el_id > 40) AND ' .
					'(el_id < 5000 OR el_id > 60000)))',
					'el_to_path LIKE \'/%\' ESCAPE \'`\'',
				],
			],
			'Various options' => [
				'example.com',
				[ 'protocol' => 'https://' ],
				[
					"(el_to_domain_index LIKE 'https://com.example.%' ESCAPE '`')",
					"el_to_path LIKE '/%' ESCAPE '`'",
				],
			],
		];
	}

	/**
	 * @dataProvider provideGetIndexedUrlsNonReversed
	 */
	public function testGetIndexedUrlsNonReversed( $urls, $expected ) {
		$list = LinkFilter::getIndexedUrlsNonReversed( $urls );
		$this->assertEquals( $expected, $list );
	}

	public static function provideGetIndexedUrlsNonReversed() {
		return [
			'Basic example' => [
				[ 'https://example.com' ],
				[ 'https://example.com/' ],
			],
			'Basic example with path' => [
				[ 'https://example.com/foobar' ],
				[ 'https://example.com/foobar' ],
			],
			'Proto-relative' => [
				[ '//example.com/foobar' ],
				[ 'https://example.com/foobar' ],
			],
			'Links with port' => [
				[ 'https://spam.com/index.html:8000' ],
				[ 'https://spam.com/index.html:8000' ],
			],
			'Links with user' => [
				[ 'https://foo@spam.com/bar.html' ],
				[ 'https://spam.com/bar.html' ],
			],
			'Mailto' => [
				[ 'mailto:foo@example.com' ],
				[ 'mailto:foo@example.com' ],
			],
			'IPv6 (gosh I hate ipv6)' => [
				[ 'http://[::1]/' ],
				[ 'http://[0:0:0:0:0:0:0:1]/' ]
			],
			'IPv4' => [
				[ 'http://0.0.0.1/' ],
				[ 'http://0.0.0.1/' ]
			]
		];
	}
}
PK       ! ?    #  Category/TrackingCategoriesTest.phpnu Iw        <?php

use MediaWiki\Category\TrackingCategories;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Parser\ParserOutput;

/**
 * @covers \MediaWiki\Parser\ParserOutput
 * @covers \MediaWiki\Parser\CacheTime
 * @group Database
 *        ^--- trigger DB shadowing because we are using Title magic
 */
class TrackingCategoriesTest extends MediaWikiLangTestCase {
	/**
	 * @covers \MediaWiki\Category\TrackingCategories::addTrackingCategory
	 */
	public function testAddTrackingCategory() {
		$services = $this->getServiceContainer();
		$tc = new TrackingCategories(
			new ServiceOptions(
				TrackingCategories::CONSTRUCTOR_OPTIONS,
				$services->getMainConfig()
			),
			$services->getNamespaceInfo(),
			$services->getTitleParser(),
			LoggerFactory::getInstance( 'TrackingCategories' )
		);

		$po = new ParserOutput;
		$po->setUnsortedPageProperty( 'defaultsort', 'foobar' );

		$page = PageReferenceValue::localReference( NS_USER, 'Testing' );

		$tc->addTrackingCategory( $po, 'index-category', $page ); // from CORE_TRACKING_CATEGORIES
		$tc->addTrackingCategory( $po, 'sitenotice', $page ); // should be "-", which is ignored
		$tc->addTrackingCategory( $po, 'brackets-start', $page ); // invalid text
		// TODO: assert proper handling of non-existing messages

		$expected = wfMessage( 'index-category' )
			->page( $page )
			->inContentLanguage()
			->text();

		// Note that the DEFAULTSORT is applied when the category links table
		// is updated, so 'foobar' does not appear in the CategoryMap here.
		$expected = strtr( $expected, ' ', '_' );
		$this->assertSame( [ $expected => '' ], $po->getCategoryMap() );
	}
}
PK       ! )W      Category/CategoryTest.phpnu Iw        <?php

use MediaWiki\Category\Category;
use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;

/**
 * @covers \MediaWiki\Category\Category
 * @group Database
 * @group Category
 */
class CategoryTest extends MediaWikiIntegrationTestCase {
	protected function setUp(): void {
		parent::setUp();

		$this->setUserLang( 'en' );

		$this->overrideConfigValues( [
			MainConfigNames::AllowUserJs => false,
			MainConfigNames::DefaultLanguageVariant => false,
			MainConfigNames::MetaNamespace => 'Project',
			MainConfigNames::LanguageCode => 'en',
		] );
	}

	public function addDBData() {
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'category' )
			->ignore()
			->row( [
				'cat_id' => 1,
				'cat_title' => 'Example',
				'cat_pages' => 3,
				'cat_subcats' => 4,
				'cat_files' => 5
			] )
			->caller( __METHOD__ )
			->execute();
	}

	public function testInitialize_idNotExist() {
		$category = Category::newFromID( -1 );
		$this->assertFalse( $category->getName() );
	}

	public static function provideInitializeVariants() {
		return [
			// Existing title
			[ 'newFromName', 'Example', 'getID', 1 ],
			[ 'newFromName', 'Example', 'getName', 'Example' ],
			[ 'newFromName', 'Example', 'getMemberCount', 3 ],
			[ 'newFromName', 'Example', 'getSubcatCount', 4 ],
			[ 'newFromName', 'Example', 'getFileCount', 5 ],

			// Non-existing title
			[ 'newFromName', 'NoExample', 'getID', 0 ],
			[ 'newFromName', 'NoExample', 'getName', 'NoExample' ],
			[ 'newFromName', 'NoExample', 'getMemberCount', 0 ],
			[ 'newFromName', 'NoExample', 'getSubcatCount', 0 ],
			[ 'newFromName', 'NoExample', 'getFileCount', 0 ],

			// Existing ID
			[ 'newFromID', 1, 'getID', 1 ],
			[ 'newFromID', 1, 'getName', 'Example' ],
			[ 'newFromID', 1, 'getMemberCount', 3 ],
			[ 'newFromID', 1, 'getSubcatCount', 4 ],
			[ 'newFromID', 1, 'getFileCount', 5 ]
		];
	}

	/**
	 * @dataProvider provideInitializeVariants
	 */
	public function testInitialize( $createFunction, $createParam, $testFunction, $expected ) {
		$category = Category::{$createFunction}( $createParam );
		$this->assertEquals( $expected, $category->{$testFunction}() );
	}

	public function testNewFromName_validTitle() {
		$category = Category::newFromName( 'Example' );
		$this->assertSame( 'Example', $category->getName() );
	}

	public function testNewFromName_invalidTitle() {
		$this->assertFalse( Category::newFromName( '#' ) );
	}

	public function testNewFromTitle() {
		$title = Title::makeTitle( NS_CATEGORY, 'Example' );
		$category = Category::newFromTitle( $title );
		$this->assertSame( 'Example', $category->getName() );
		$this->assertTrue( $title->isSamePageAs( $category->getPage() ) );
		$this->assertTrue( $title->isSamePageAs( $category->getTitle() ) );
	}

	public function testNewFromID() {
		$category = Category::newFromID( 5 );
		$this->assertSame( 5, $category->getID() );
	}

	public function testNewFromRow_found() {
		$category = Category::newFromRow( $this->getDb()->newSelectQueryBuilder()
			->select( [ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ] )
			->from( 'category' )
			->where( [ 'cat_id' => 1 ] )
			->caller( __METHOD__ )->fetchRow()
		);

		$this->assertSame( '1', $category->getID() );
	}

	public function testNewFromRow_notFoundWithoutTitle() {
		$row = $this->getDb()->newSelectQueryBuilder()
			->select( [ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ] )
			->from( 'category' )
			->where( [ 'cat_id' => 1 ] )
			->caller( __METHOD__ )->fetchRow();
		$row->cat_title = null;

		$this->assertFalse( Category::newFromRow( $row ) );
	}

	public function testNewFromRow_notFoundWithTitle() {
		$row = $this->getDb()->newSelectQueryBuilder()
			->select( [ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ] )
			->from( 'category' )
			->where( [ 'cat_id' => 1 ] )
			->caller( __METHOD__ )->fetchRow();
		$row->cat_title = null;

		$category = Category::newFromRow(
			$row,
			Title::makeTitle( NS_CATEGORY, 'Example' )
		);

		$this->assertFalse( $category->getID() );
	}

	public function testGetCounts() {
		// Defined via addDBDataOnce
		$category = Category::newFromID( 1 );
		$this->assertEquals( 3, $category->getMemberCount() );
		$this->assertEquals( 4, $category->getSubcatCount() );
		$this->assertEquals( 5, $category->getFileCount() );
	}
}
PK       ! 7      Request/FauxRequestTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\WebRequest;
use MediaWiki\Session\SessionManager;

class FauxRequestTest extends MediaWikiIntegrationTestCase {

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValue( MainConfigNames::Server, '//wiki.test' );
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::__construct
	 */
	public function testConstructInvalidSession() {
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( 'bogus session' );
		new FauxRequest( [], false, 'x' );
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::__construct
	 */
	public function testConstructWithSession() {
		$session = SessionManager::singleton()->getEmptySession( new FauxRequest( [] ) );
		$this->assertInstanceOf(
			FauxRequest::class,
			new FauxRequest( [], false, $session )
		);
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::getText
	 */
	public function testGetText() {
		$req = new FauxRequest( [ 'x' => 'Value' ] );
		$this->assertSame( 'Value', $req->getText( 'x' ) );
		$this->assertSame( '', $req->getText( 'z' ) );
	}

	/**
	 * Integration test for parent method
	 * @covers \MediaWiki\Request\FauxRequest::getVal
	 */
	public function testGetVal() {
		$req = new FauxRequest( [ 'crlf' => "A\r\nb" ] );
		$this->assertSame( "A\r\nb", $req->getVal( 'crlf' ), 'CRLF' );
	}

	/**
	 * Integration test for parent method
	 * @covers \MediaWiki\Request\FauxRequest::getRawVal
	 */
	public function testGetRawVal() {
		$req = new FauxRequest( [
			'x' => 'Value',
			'y' => [ 'a' ],
			'crlf' => "A\r\nb"
		] );
		$this->assertSame( 'Value', $req->getRawVal( 'x' ) );
		$this->assertSame( null, $req->getRawVal( 'z' ), 'Not found' );
		$this->assertSame( null, $req->getRawVal( 'y' ), 'Array is ignored' );
		$this->assertSame( "A\r\nb", $req->getRawVal( 'crlf' ), 'CRLF' );
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::getValues
	 */
	public function testGetValues() {
		$values = [ 'x' => 'Value', 'y' => '' ];
		$req = new FauxRequest( $values );
		$this->assertSame( $values, $req->getValues() );
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::getQueryValues
	 */
	public function testGetQueryValues() {
		$values = [ 'x' => 'Value', 'y' => '' ];

		$req = new FauxRequest( $values );
		$this->assertSame( $values, $req->getQueryValues() );
		$req = new FauxRequest( $values, /*wasPosted*/ true );
		$this->assertSame( [], $req->getQueryValues() );
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::getMethod
	 */
	public function testGetMethod() {
		$req = new FauxRequest( [] );
		$this->assertSame( 'GET', $req->getMethod() );
		$req = new FauxRequest( [], /*wasPosted*/ true );
		$this->assertSame( 'POST', $req->getMethod() );
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::wasPosted
	 */
	public function testWasPosted() {
		$req = new FauxRequest( [] );
		$this->assertFalse( $req->wasPosted() );
		$req = new FauxRequest( [], /*wasPosted*/ true );
		$this->assertTrue( $req->wasPosted() );
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::getCookie
	 * @covers \MediaWiki\Request\FauxRequest::setCookie
	 * @covers \MediaWiki\Request\FauxRequest::setCookies
	 */
	public function testCookies() {
		$req = new FauxRequest();
		$this->assertSame( null, $req->getCookie( 'z', '' ) );

		$req->setCookie( 'x', 'Value', '' );
		$this->assertSame( 'Value', $req->getCookie( 'x', '' ) );

		$req->setCookies( [ 'x' => 'One', 'y' => 'Two' ], '' );
		$this->assertSame( 'One', $req->getCookie( 'x', '' ) );
		$this->assertSame( 'Two', $req->getCookie( 'y', '' ) );
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::getCookie
	 * @covers \MediaWiki\Request\FauxRequest::setCookie
	 * @covers \MediaWiki\Request\FauxRequest::setCookies
	 */
	public function testCookiesDefaultPrefix() {
		global $wgCookiePrefix;
		$oldPrefix = $wgCookiePrefix;
		$wgCookiePrefix = '_';

		$req = new FauxRequest();
		$this->assertSame( null, $req->getCookie( 'z' ) );

		$req->setCookie( 'x', 'Value' );
		$this->assertSame( 'Value', $req->getCookie( 'x' ) );

		$wgCookiePrefix = $oldPrefix;
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::getRequestURL
	 */
	public function testGetRequestURL_disallowed() {
		$req = new FauxRequest();
		$this->expectException( MWException::class );
		$req->getRequestURL();
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::setRequestURL
	 * @covers \MediaWiki\Request\FauxRequest::getRequestURL
	 */
	public function testSetRequestURL() {
		$req = new FauxRequest();
		$req->setRequestURL( 'https://example.org' );
		$this->assertSame( 'https://example.org', $req->getRequestURL() );
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::getFullRequestURL
	 */
	public function testGetFullRequestURL_disallowed() {
		$req = new FauxRequest();

		$this->expectException( MWException::class );
		$req->getFullRequestURL();
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::getFullRequestURL
	 */
	public function testGetFullRequestURL_http() {
		$req = new FauxRequest();
		$req->setRequestURL( '/path' );

		$this->assertSame(
			'http://wiki.test/path',
			$req->getFullRequestURL()
		);
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::getFullRequestURL
	 */
	public function testGetFullRequestURL_https() {
		$req = new FauxRequest( [], false, null, 'https' );
		$req->setRequestURL( '/path' );

		$this->assertSame(
			'https://wiki.test/path',
			$req->getFullRequestURL()
		);
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::__construct
	 * @covers \MediaWiki\Request\FauxRequest::getProtocol
	 */
	public function testProtocol() {
		$req = new FauxRequest();
		$this->assertSame( 'http', $req->getProtocol() );
		$req = new FauxRequest( [], false, null, 'http' );
		$this->assertSame( 'http', $req->getProtocol() );
		$req = new FauxRequest( [], false, null, 'https' );
		$this->assertSame( 'https', $req->getProtocol() );
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::setHeader
	 * @covers \MediaWiki\Request\FauxRequest::setHeaders
	 * @covers \MediaWiki\Request\FauxRequest::getHeader
	 */
	public function testGetSetHeader() {
		$value = 'text/plain, text/html';

		$request = new FauxRequest();
		$request->setHeader( 'Accept', $value );

		$this->assertSame( false, $request->getHeader( 'Nonexistent' ) );
		$this->assertSame( $value, $request->getHeader( 'Accept' ) );
		$this->assertSame( $value, $request->getHeader( 'ACCEPT' ) );
		$this->assertSame( $value, $request->getHeader( 'accept' ) );
		$this->assertSame(
			[ 'text/plain', 'text/html' ],
			$request->getHeader( 'Accept', WebRequest::GETHEADER_LIST )
		);
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::initHeaders
	 */
	public function testGetAllHeaders() {
		$_SERVER['HTTP_TEST'] = 'Example';

		$request = new FauxRequest();

		$this->assertSame( [], $request->getAllHeaders() );
		$this->assertSame( false, $request->getHeader( 'test' ) );
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::__construct
	 * @covers \MediaWiki\Request\FauxRequest::getSessionArray
	 */
	public function testSessionData() {
		$values = [ 'x' => 'Value', 'y' => '' ];

		$req = new FauxRequest( [], false, /*session*/ $values );
		$this->assertSame( $values, $req->getSessionArray() );

		$req = new FauxRequest();
		$this->assertSame( null, $req->getSessionArray() );
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::getPostValues
	 */
	public function testGetPostValues() {
		$values = [ 'x' => 'Value', 'y' => '' ];

		$req = new FauxRequest( $values, true );
		$this->assertSame( $values, $req->getPostValues() );

		$req = new FauxRequest( $values );
		$this->assertSame( [], $req->getPostValues() );
	}

	/**
	 * @covers \MediaWiki\Request\FauxRequest::getRawQueryString
	 * @covers \MediaWiki\Request\FauxRequest::getRawPostString
	 * @covers \MediaWiki\Request\FauxRequest::getRawInput
	 */
	public function testDummies() {
		$req = new FauxRequest();
		$this->assertSame( '', $req->getRawQueryString() );
		$this->assertSame( '', $req->getRawPostString() );
		$this->assertSame( '', $req->getRawInput() );
	}
}
PK       ! 9X  X  %  Request/ContentSecurityPolicyTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Json\FormatJson;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\ContentSecurityPolicy;
use MediaWiki\Request\FauxResponse;
use Wikimedia\TestingAccessWrapper;

class ContentSecurityPolicyTest extends MediaWikiIntegrationTestCase {
	/** @var ContentSecurityPolicy|TestingAccessWrapper */
	private $csp;

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

		parent::setUp();

		$this->overrideConfigValues( [
			MainConfigNames::AllowExternalImages => false,
			MainConfigNames::AllowExternalImagesFrom => [],
			MainConfigNames::EnableImageWhitelist => false,
			MainConfigNames::LoadScript => false,
			MainConfigNames::ExtensionAssetsPath => false,
			MainConfigNames::StylePath => false,
			MainConfigNames::ResourceBasePath => '/w',
			MainConfigNames::CrossSiteAJAXdomains => [
				'sister-site.somewhere.com',
				'*.wikipedia.org',
				'??.wikinews.org'
			],
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::ForeignFileRepos => [ [
				'class' => ForeignAPIRepo::class,
				'name' => 'wikimediacommons',
				'apibase' => 'https://commons.wikimedia.org/w/api.php',
				'url' => 'https://upload.wikimedia.org/wikipedia/commons',
				'thumbUrl' => 'https://upload.wikimedia.org/wikipedia/commons/thumb',
				'hashLevels' => 2,
				'transformVia404' => true,
				'fetchDescription' => true,
				'descriptionCacheExpiry' => 43200,
				'apiThumbCacheExpiry' => 0,
				'directory' => $wgUploadDirectory,
				'backend' => 'wikimediacommons-backend',
			] ],
			MainConfigNames::CSPHeader => true,
		] );
		// Note, there are some obscure globals which
		// could affect the results which aren't included above.

		$this->clearHook( 'ContentSecurityPolicyDefaultSource' );
		$this->clearHook( 'ContentSecurityPolicyScriptSource' );
		$this->clearHook( 'ContentSecurityPolicyDirectives' );

		$context = RequestContext::getMain();
		$resp = new FauxResponse();
		$conf = $context->getConfig();
		$hookContainer = $this->getServiceContainer()->getHookContainer();
		$csp = new ContentSecurityPolicy( $resp, $conf, $hookContainer );
		$this->csp = TestingAccessWrapper::newFromObject( $csp );
	}

	/**
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::getAdditionalSelfUrls
	 */
	public function testGetAdditionalSelfUrlsRespectsUrlSettings() {
		$this->overrideConfigValues( [
			MainConfigNames::LoadScript => 'https://wgLoadScript.example.org/load.php',
			MainConfigNames::ExtensionAssetsPath => 'https://wgExtensionAssetsPath.example.org/assets/',
			MainConfigNames::StylePath => 'https://wgStylePath.example.org/style/',
			MainConfigNames::ResourceBasePath => 'https://wgResourceBasePath.example.org/resources/',
		] );

		$this->assertEquals(
			[
				'https://upload.wikimedia.org',
				'https://commons.wikimedia.org',
				'https://wgLoadScript.example.org',
				'https://wgExtensionAssetsPath.example.org',
				'https://wgStylePath.example.org',
				'https://wgResourceBasePath.example.org',
			],
			array_values( $this->csp->getAdditionalSelfUrls() )
		);
	}

	/**
	 * @dataProvider providerFalsePositiveBrowser
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::falsePositiveBrowser
	 */
	public function testFalsePositiveBrowser( $ua, $expected ) {
		$actual = ContentSecurityPolicy::falsePositiveBrowser( $ua );
		$this->assertSame( $expected, $actual, $ua );
	}

	public static function providerFalsePositiveBrowser() {
		return [
			[
				'Mozilla/5.0 (X11; Linux i686; rv:41.0) Gecko/20100101 Firefox/41.0',
				true
			],
			[
				'Mozilla/5.0 (X11; U; Linux i686; en-ca) AppleWebKit/531.2+ (KHTML, like Gecko) ' .
					'Version/5.0 Safari/531.2+ Debian/squeeze (2.30.6-1) Epiphany/2.30.6',
				false
			],
		];
	}

	/**
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::addScriptSrc
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::makeCSPDirectives
	 */
	public function testAddScriptSrc() {
		$this->csp->addScriptSrc( 'https://example.com:71' );
		$actual = $this->csp->makeCSPDirectives( true, ContentSecurityPolicy::FULL_MODE );
		$expected = "script-src 'unsafe-eval' blob: 'self' 'unsafe-inline'" .
			" sister-site.somewhere.com *.wikipedia.org https://example.com:71; default-src *" .
			" data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri" .
			" /w/api.php?action=cspreport&format=json";
		$this->assertSame( $expected, $actual );
	}

	/**
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::addStyleSrc
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::makeCSPDirectives
	 */
	public function testAddStyleSrc() {
		$this->csp->addStyleSrc( 'style.example.com' );
		$actual = $this->csp->makeCSPDirectives( true, ContentSecurityPolicy::REPORT_ONLY_MODE );
		$expected = "script-src 'unsafe-eval' blob: 'self' 'unsafe-inline'" .
			" sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:;" .
			" style-src * data: blob: style.example.com 'unsafe-inline'; object-src 'none'; report-uri" .
			" /w/api.php?action=cspreport&format=json&reportonly=1";
		$this->assertSame( $expected, $actual );
	}

	/**
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::addDefaultSrc
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::makeCSPDirectives
	 */
	public function testAddDefaultSrc() {
		$this->csp->addDefaultSrc( '*.example.com' );
		$actual = $this->csp->makeCSPDirectives( true, ContentSecurityPolicy::FULL_MODE );
		$expected = "script-src 'unsafe-eval' blob: 'self' 'unsafe-inline'" .
			" sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:" .
			" *.example.com; style-src * data: blob: *.example.com 'unsafe-inline';" .
			" object-src 'none'; report-uri /w/api.php?action=cspreport&format=json";
		$this->assertSame( $expected, $actual );
	}

	/**
	 * @dataProvider providerMakeCSPDirectives
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::makeCSPDirectives
	 */
	public function testMakeCSPDirectives(
		$policy,
		$expectedFull,
		$expectedReport
	) {
		$actualFull = $this->csp->makeCSPDirectives( $policy, ContentSecurityPolicy::FULL_MODE );
		$actualReport = $this->csp->makeCSPDirectives(
			$policy, ContentSecurityPolicy::REPORT_ONLY_MODE
		);
		$policyJson = FormatJson::encode( $policy );
		$this->assertSame( $expectedFull, $actualFull, "full: " . $policyJson );
		$this->assertSame( $expectedReport, $actualReport, "report: " . $policyJson );
	}

	public static function providerMakeCSPDirectives() {
		return [
			[ false, '', '' ],
			[
				[],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
				"script-src 'unsafe-eval' blob: 'self' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'"
			],
			[
				true,
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
			[
				[ 'script-src' => [ 'http://example.com', 'http://something,else.com' ] ],
				"script-src 'unsafe-eval' blob: 'self' http://example.com http://something%2Celse.com 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' http://example.com http://something%2Celse.com 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
			[
				[ 'unsafeFallback' => false ],
				"script-src 'unsafe-eval' blob: 'self' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
			[
				[ 'unsafeFallback' => true ],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
			[
				[ 'default-src' => false ],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
			[
				[ 'default-src' => true ],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org sister-site.somewhere.com *.wikipedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org sister-site.somewhere.com *.wikipedia.org 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org sister-site.somewhere.com *.wikipedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org sister-site.somewhere.com *.wikipedia.org 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
			[
				[ 'default-src' => [ 'https://foo.com', 'http://bar.com', 'baz.de' ] ],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org https://foo.com http://bar.com baz.de sister-site.somewhere.com *.wikipedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org https://foo.com http://bar.com baz.de sister-site.somewhere.com *.wikipedia.org 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org https://foo.com http://bar.com baz.de sister-site.somewhere.com *.wikipedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org https://foo.com http://bar.com baz.de sister-site.somewhere.com *.wikipedia.org 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
			[
				[ 'includeCORS' => false ],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline'; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline'; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
			[
				[ 'includeCORS' => false, 'default-src' => true ],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline'; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline'; default-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org; style-src 'self' data: blob: https://upload.wikimedia.org https://commons.wikimedia.org 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
			[
				[ 'includeCORS' => true ],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
			[
				[ 'report-uri' => false ],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'",
			],
			[
				[ 'report-uri' => true ],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
			[
				[ 'report-uri' => 'https://example.com/index.php?foo;report=csp' ],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri https://example.com/index.php?foo%3Breport=csp",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri https://example.com/index.php?foo%3Breport=csp",
			],
			[
				[ 'object-src' => false ],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
			[
				[ 'object-src' => true ],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
			[
				[ 'object-src' => "'self'" ],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'self'; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'self'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
			[
				[ 'object-src' => [ "'self'", 'https://example.com/f;d' ] ],
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'self' https://example.com/f%3Bd; report-uri /w/api.php?action=cspreport&format=json",
				"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'self' https://example.com/f%3Bd; report-uri /w/api.php?action=cspreport&format=json&reportonly=1",
			],
		];
		// phpcs:enable
	}

	/**
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::makeCSPDirectives
	 */
	public function testMakeCSPDirectivesReportUri() {
		$actual = $this->csp->makeCSPDirectives(
			true,
			ContentSecurityPolicy::REPORT_ONLY_MODE
		);
		$expected = "script-src 'unsafe-eval' blob: 'self' 'unsafe-inline' sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:; style-src * data: blob: 'unsafe-inline'; object-src 'none'; report-uri /w/api.php?action=cspreport&format=json&reportonly=1";
		$this->assertSame( $expected, $actual );
	}

	/**
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::getHeaderName
	 */
	public function testGetHeaderName() {
		$this->assertSame(
			'Content-Security-Policy-Report-Only',
			$this->csp->getHeaderName( ContentSecurityPolicy::REPORT_ONLY_MODE )
		);
		$this->assertSame(
			'Content-Security-Policy',
			$this->csp->getHeaderName( ContentSecurityPolicy::FULL_MODE )
		);
	}

	/**
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::getReportUri
	 */
	public function testGetReportUri() {
		$full = $this->csp->getReportUri( ContentSecurityPolicy::FULL_MODE );
		$fullExpected = '/w/api.php?action=cspreport&format=json';
		$this->assertSame( $fullExpected, $full, 'normal report uri' );

		$report = $this->csp->getReportUri( ContentSecurityPolicy::REPORT_ONLY_MODE );
		$reportExpected = $fullExpected . '&reportonly=1';
		$this->assertSame( $reportExpected, $report, 'report only' );

		global $wgScriptPath;
		$origPath = wfSetVar( $wgScriptPath, '/tl;dr/a,%20wiki' );
		$esc = $this->csp->getReportUri( ContentSecurityPolicy::FULL_MODE );
		$escExpected = '/tl%3Bdr/a%2C%20wiki/api.php?action=cspreport&format=json';
		$wgScriptPath = $origPath;
		$this->assertSame( $escExpected, $esc, 'test esc rules' );
	}

	/**
	 * @dataProvider providerPrepareUrlForCSP
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::prepareUrlForCSP
	 */
	public function testPrepareUrlForCSP( $url, $expected ) {
		$actual = $this->csp->prepareUrlForCSP( $url );
		$this->assertSame( $expected, $actual, $url );
	}

	public static function providerPrepareUrlForCSP() {
		global $wgServer;
		return [
			[ $wgServer, false ],
			[ 'https://example.com', 'https://example.com' ],
			[ 'https://example.com:200', 'https://example.com:200' ],
			[ 'http://example.com', 'http://example.com' ],
			[ 'example.com', 'example.com' ],
			[ '*.example.com', '*.example.com' ],
			[ 'https://*.example.com', 'https://*.example.com' ],
			[ '//example.com', 'example.com' ],
			[ 'https://example.com/path', 'https://example.com' ],
			[ 'https://example.com/path:', 'https://example.com' ],
			[ 'https://example.com/Wikipedia:NPOV', 'https://example.com' ],
			[ 'https://tl;dr.com', 'https://tl%3Bdr.com' ],
			[ 'yes,no.com', 'yes%2Cno.com' ],
			[ '/relative-url', false ],
			[ '/relativeUrl:withColon', false ],
			[ 'data:', 'data:' ],
			[ 'blob:', 'blob:' ],
		];
	}

	/**
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::escapeUrlForCSP
	 */
	public function testEscapeUrlForCSP() {
		$escaped = $this->csp->escapeUrlForCSP( ',;%2B' );
		$this->assertSame( '%2C%3B%2B', $escaped );
	}

	/**
	 * @dataProvider provideIsNonceRequired
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::isNonceRequired
	 */
	public function testIsNonceRequired( $main, $reportOnly, $expected ) {
		$this->overrideConfigValues( [
			MainConfigNames::CSPReportOnlyHeader => $reportOnly,
			MainConfigNames::CSPHeader => $main,
		] );
		$res = ContentSecurityPolicy::isNonceRequired( $this->getServiceContainer()->getMainConfig() );
		$this->assertSame( $expected, $res );
	}

	public static function provideIsNonceRequired() {
		return [
			[ true, true, false ],
			[ false, true, false ],
			[ true, false, false ],
			[ false, false, false ],
			[ false, [], false ],
			[ [], false, false ],
			[ [ 'default-src' => [ 'foo.example.com' ] ], false, false ],
			[ [ 'useNonces' => false ], [ 'useNonces' => false ], false ],
			[ [ 'useNonces' => true ], [ 'useNonces' => false ], true ],
			[ [ 'useNonces' => false ], [ 'useNonces' => true ], true ],
		];
	}

	/**
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::getDirectives
	 */
	public function testGetDirectives() {
		$this->assertSame(
			[
				'Content-Security-Policy' => "script-src 'unsafe-eval' blob: 'self' 'unsafe-inline'"
					. " sister-site.somewhere.com *.wikipedia.org; default-src * data: blob:;"
					. " style-src * data: blob: 'unsafe-inline'; object-src 'none';"
					. " report-uri /w/api.php?action=cspreport&format=json",
			],
			$this->csp->getDirectives()
		);
	}

	/**
	 * @covers \MediaWiki\Request\ContentSecurityPolicy::sendHeaders
	 */
	public function testSendHeaders() {
		$this->csp->sendHeaders();
		$this->assertSame(
			"script-src 'unsafe-eval' blob: 'self' 'unsafe-inline'"
				. " sister-site.somewhere.com *.wikipedia.org; default-src *"
				. " data: blob:; style-src * data: blob: 'unsafe-inline';"
				. " object-src 'none'; report-uri /w/api.php?action=cspreport&format=json",
			$this->csp->response->getHeader( 'Content-Security-Policy' )
		);
		$this->assertNull( $this->csp->response->getHeader( 'Content-Security-Policy-Report-Only' ) );
	}
}
PK       ! Ig      Request/WebResponseTest.phpnu Iw        <?php

use MediaWiki\Request\WebResponse;

/**
 * @covers \MediaWiki\Request\WebResponse
 *
 * @group WebRequest
 */
class WebResponseTest extends MediaWikiIntegrationTestCase {

	/**
	 * Test that no cookies get set post-send.
	 */
	public function testDisableForPostSend() {
		$response = new WebResponse;
		$response->disableForPostSend();

		$hookWasRun = false;
		$this->setTemporaryHook( 'WebResponseSetCookie', static function () use ( &$hookWasRun ) {
			$hookWasRun = true;
			return true;
		} );

		$logger = new TestLogger();
		$logger->setCollect( true );
		$this->setLogger( 'cookie', $logger );
		$this->setLogger( 'header', $logger );

		$response->setCookie( 'TetsCookie', 'foobar' );
		$response->header( 'TestHeader', 'foobar' );

		$this->assertFalse( $hookWasRun, 'The WebResponseSetCookie hook should not run' );

		$this->assertEquals(
			[
				[ 'info', 'ignored post-send cookie {cookie}' ],
				[ 'info', 'ignored post-send header {header}' ],
			],
			$logger->getBuffer()
		);
	}

	public function testStatusCode() {
		$response = new WebResponse();

		$response->statusHeader( 404 );
		$this->assertSame( 404, $response->getStatusCode() );

		$response->header( 'Test', true, 415 );
		$this->assertSame( 415, $response->getStatusCode() );
	}

}
PK       ! V  V    Request/WebRequestTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Request\ProxyLookup;
use MediaWiki\Request\WebRequest;

/**
 * @covers \MediaWiki\Request\WebRequest
 *
 * @group WebRequest
 */
class WebRequestTest extends MediaWikiIntegrationTestCase {
	private const INTERNAL_SERVER = 'http://wiki.site';

	/** @var array */
	private $oldServer;

	/** @var array */
	private $oldCookies;

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

		$this->oldServer = $_SERVER;
		$this->oldCookies = $_COOKIE;
	}

	protected function tearDown(): void {
		$_SERVER = $this->oldServer;
		$_COOKIE = $this->oldCookies;

		parent::tearDown();
	}

	/**
	 * @dataProvider provideGetCookie
	 *
	 * @param mixed $expected Expected value
	 * @param array $cookies Cookies to set in $_COOKIE
	 * @param string $prefix Cookie prefix to use when retrieving the cookie
	 * @param string $cookieName Cookie name to retrieve
	 * @param mixed $defaultValue Default value to use when the cookie is not found
	 */
	public function testGetCookie( $expected, $cookies, $prefix, $cookieName, $defaultValue ) {
		$_COOKIE = $cookies;
		$request = new WebRequest();

		$actual = $defaultValue !== null ?
			$request->getCookie( $cookieName, $prefix, $defaultValue ) :
			$request->getCookie( $cookieName, $prefix );

		$this->assertSame( $expected, $actual );
	}

	public static function provideGetCookie() {
		yield 'no cookies with no default override' => [ null, [], '', 'test', null ];
		yield 'no cookies with explicit default override' => [ false, [], '', 'test', false ];

		yield 'missing cookie with no default override' => [ null, [ 'other' => 'bar' ], '', 'test', null ];
		yield 'missing cookie with explicit default override' => [ false, [ 'other' => 'bar' ], '', 'test', false ];

		yield 'cookie not matching prefix with no default override' => [
			null, [ 'test' => 'bar' ], 'prefix', 'test', null
		];
		yield 'cookie not matching prefix with explicit default override' => [
			false, [ 'test' => 'bar' ], 'prefix', 'test', false
		];

		// T363980
		yield 'cookie with array value with no default override' => [ null, [ 'test' => [ 'bar' ] ], '', 'test', null ];
		yield 'cookie with array value explicit default override' => [ false, [ 'test' => [ 'bar' ] ], '', 'test', false ];

		yield 'valid cookie' => [ 'value', [ 'test' => 'value' ], '', 'test', null ];
		yield 'valid cookie with prefix' => [ 'value', [ 'prefixtest' => 'value' ], 'prefix', 'test', null ];
		yield 'mangled cookie name' => [ 'value', [ 'test_mangled' => 'value' ], '', 'test.mangled', null ];
		yield 'mangled cookie name with prefix' => [
			'value', [ 'prefix_partstest_mangled' => 'value' ], 'prefix.parts', 'test.mangled', null
		];
	}

	/**
	 * @dataProvider provideDetectServer
	 */
	public function testDetectServer( $expected, $input, $description ) {
		$this->setServerVars( $input );
		$result = WebRequest::detectServer( true );
		$this->assertEquals( $expected, $result, $description );
	}

	public static function provideDetectServer() {
		return [
			[
				'http://x',
				[
					'HTTP_HOST' => 'x'
				],
				'Host header'
			],
			[
				'https://x',
				[
					'HTTP_HOST' => 'x',
					'HTTPS' => 'on',
				],
				'Host header with secure'
			],
			[
				'http://x',
				[
					'HTTP_HOST' => 'x:80',
				],
				'Host header with port'
			],
			[
				'http://x',
				[
					'HTTP_HOST' => 'x',
					'SERVER_PORT' => 80,
				],
				'Default SERVER_PORT as int',
			],
			[
				'http://x',
				[
					'HTTP_HOST' => 'x',
					'SERVER_PORT' => '80',
				],
				'Default SERVER_PORT as string',
			],
			[
				'http://x',
				[
					'HTTP_HOST' => 'x',
					'HTTPS' => 'off',
				],
				'Secure off'
			],
			[
				'https://x',
				[
					'HTTP_HOST' => 'x',
					'HTTP_X_FORWARDED_PROTO' => 'https',
				],
				'Forwarded HTTPS'
			],
			[
				'https://x',
				[
					'HTTP_HOST' => 'x',
					'HTTPS' => 'off',
					'SERVER_PORT' => '81',
					'HTTP_X_FORWARDED_PROTO' => 'https',
				],
				'Forwarded HTTPS'
			],
			[
				'http://y',
				[
					'SERVER_NAME' => 'y',
				],
				'Server name'
			],
			[
				'http://x',
				[
					'HTTP_HOST' => 'x',
					'SERVER_NAME' => 'y',
				],
				'Host server name precedence'
			],
			[
				'http://[::1]:81',
				[
					'HTTP_HOST' => '[::1]',
					'SERVER_NAME' => '::1',
					'SERVER_PORT' => '81',
				],
				'Apache bug 26005'
			],
			[
				'http://localhost',
				[
					'SERVER_NAME' => '[2001'
				],
				'Kind of like lighttpd per commit message in MW r83847',
			],
			[
				'http://[2a01:e35:2eb4:1::2]:777',
				[
					'SERVER_NAME' => '[2a01:e35:2eb4:1::2]:777'
				],
				'Possible lighttpd environment per bug 14977 comment 13',
			],
		];
	}

	/**
	 * @param array $data Request data
	 * @param array $config
	 *  - float 'requestTime': Mock value for `$_SERVER['REQUEST_TIME_FLOAT']`.
	 * @return WebRequest
	 */
	protected function mockWebRequest( array $data = [], array $config = [] ) {
		// Cannot use PHPUnit getMockBuilder() as it does not support
		// overriding protected properties afterwards
		$reflection = new ReflectionClass( WebRequest::class );
		$req = $reflection->newInstanceWithoutConstructor();

		$prop = $reflection->getProperty( 'data' );
		$prop->setAccessible( true );
		$prop->setValue( $req, $data );

		if ( isset( $config['requestTime'] ) ) {
			$prop = $reflection->getProperty( 'requestTime' );
			$prop->setAccessible( true );
			$prop->setValue( $req, $config['requestTime'] );
		}

		return $req;
	}

	public function testGetElapsedTime() {
		$now = microtime( true ) - 10.0;
		$req = $this->mockWebRequest( [], [ 'requestTime' => $now ] );
		$this->assertGreaterThanOrEqual( 10.0, $req->getElapsedTime() );
		// Catch common errors, but don't fail on slow hardware or VMs (T199764).
		$this->assertEqualsWithDelta( 10.0, $req->getElapsedTime(), 60.0 );
	}

	public function testGetValNormal() {
		// Assert that WebRequest normalises GPC data using UtfNormal\Validator
		$input = "a \x00 null";
		$normal = "a \xef\xbf\xbd null";
		$req = $this->mockWebRequest( [ 'x' => $input, 'y' => [ $input, $input ] ] );
		$this->assertSame( $normal, $req->getVal( 'x' ) );
		$this->assertNotSame( $input, $req->getVal( 'x' ) );
		$this->assertSame( [ $normal, $normal ], $req->getArray( 'y' ) );
	}

	public function testGetVal() {
		$req = $this->mockWebRequest( [ 'x' => 'Value', 'y' => [ 'a' ], 'crlf' => "A\r\nb" ] );
		$this->assertSame( 'Value', $req->getVal( 'x' ), 'Simple value' );
		$this->assertNull( $req->getVal( 'z' ), 'Not found' );
		$this->assertNull( $req->getVal( 'y' ), 'Array is ignored' );
		$this->assertSame( "A\r\nb", $req->getVal( 'crlf' ), 'CRLF' );
	}

	public function testGetRawVal() {
		$req = $this->mockWebRequest( [
			'x' => 'Value',
			'y' => [ 'a' ],
			'crlf' => "A\r\nb"
		] );
		$this->assertSame( 'Value', $req->getRawVal( 'x' ) );
		$this->assertNull( $req->getRawVal( 'z' ), 'Not found' );
		$this->assertNull( $req->getRawVal( 'y' ), 'Array is ignored' );
		$this->assertSame( "A\r\nb", $req->getRawVal( 'crlf' ), 'CRLF' );
	}

	public function testGetArray() {
		$req = $this->mockWebRequest( [ 'x' => 'Value', 'y' => [ 'a', 'b' ] ] );
		$this->assertSame( [ 'Value' ], $req->getArray( 'x' ), 'Value becomes array' );
		$this->assertNull( $req->getArray( 'z' ), 'Not found' );
		$this->assertSame( [ 'a', 'b' ], $req->getArray( 'y' ) );
	}

	public function testGetIntArray() {
		$req = $this->mockWebRequest( [ 'x' => [ 'Value' ], 'y' => [ '0', '4.2', '-2' ] ] );
		$this->assertSame( [ 0 ], $req->getIntArray( 'x' ), 'Text becomes 0' );
		$this->assertNull( $req->getIntArray( 'z' ), 'Not found' );
		$this->assertSame( [ 0, 4, -2 ], $req->getIntArray( 'y' ) );
	}

	public function testGetInt() {
		$req = $this->mockWebRequest( [
			'x' => 'Value',
			'y' => [ 'a' ],
			'zero' => '0',
			'answer' => '4.2',
			'neg' => '-2',
		] );
		$this->assertSame( 0, $req->getInt( 'x' ), 'Text' );
		$this->assertSame( 0, $req->getInt( 'y' ), 'Array' );
		$this->assertSame( 0, $req->getInt( 'z' ), 'Not found' );
		$this->assertSame( 0, $req->getInt( 'zero' ) );
		$this->assertSame( 4, $req->getInt( 'answer' ) );
		$this->assertSame( -2, $req->getInt( 'neg' ) );
	}

	public function testGetIntOrNull() {
		$req = $this->mockWebRequest( [
			'x' => 'Value',
			'y' => [ 'a' ],
			'zero' => '0',
			'answer' => '4.2',
			'neg' => '-2',
		] );
		$this->assertNull( $req->getIntOrNull( 'x' ), 'Text' );
		$this->assertNull( $req->getIntOrNull( 'y' ), 'Array' );
		$this->assertNull( $req->getIntOrNull( 'z' ), 'Not found' );
		$this->assertSame( 0, $req->getIntOrNull( 'zero' ) );
		$this->assertSame( 4, $req->getIntOrNull( 'answer' ) );
		$this->assertSame( -2, $req->getIntOrNull( 'neg' ) );
	}

	public function testGetFloat() {
		$req = $this->mockWebRequest( [
			'x' => 'Value',
			'y' => [ 'a' ],
			'zero' => '0',
			'answer' => '4.2',
			'neg' => '-2',
		] );
		$this->assertSame( 0.0, $req->getFloat( 'x' ), 'Text' );
		$this->assertSame( 0.0, $req->getFloat( 'y' ), 'Array' );
		$this->assertSame( 0.0, $req->getFloat( 'z' ), 'Not found' );
		$this->assertSame( 0.0, $req->getFloat( 'zero' ) );
		$this->assertSame( 4.2, $req->getFloat( 'answer' ) );
		$this->assertSame( -2.0, $req->getFloat( 'neg' ) );
	}

	public function testGetBool() {
		$req = $this->mockWebRequest( [
			'x' => 'Value',
			'y' => [ 'a' ],
			'zero' => '0',
			'f' => 'false',
			't' => 'true',
		] );
		$this->assertTrue( $req->getBool( 'x' ), 'Text' );
		$this->assertFalse( $req->getBool( 'y' ), 'Array' );
		$this->assertFalse( $req->getBool( 'z' ), 'Not found' );
		$this->assertFalse( $req->getBool( 'zero' ) );
		$this->assertTrue( $req->getBool( 'f' ) );
		$this->assertTrue( $req->getBool( 't' ) );
	}

	public static function provideFuzzyBool() {
		return [
			[ 'Text', true ],
			[ '', false, '(empty string)' ],
			[ '0', false ],
			[ '1', true ],
			[ 'false', false ],
			[ 'true', true ],
			[ 'False', false ],
			[ 'True', true ],
			[ 'FALSE', false ],
			[ 'TRUE', true ],
		];
	}

	/**
	 * @dataProvider provideFuzzyBool
	 */
	public function testGetFuzzyBool( $value, $expected, $message = null ) {
		$req = $this->mockWebRequest( [ 'x' => $value ] );
		$this->assertSame( $expected, $req->getFuzzyBool( 'x' ), $message ?: "Value: '$value'" );
	}

	public function testGetFuzzyBoolDefault() {
		$req = $this->mockWebRequest();
		$this->assertFalse( $req->getFuzzyBool( 'z' ), 'Not found' );
	}

	public function testGetFuzzyBoolDefaultTrue() {
		$req = $this->mockWebRequest();
		$this->assertTrue( $req->getFuzzyBool( 'z', true ), 'Not found, default true' );
	}

	public function testGetCheck() {
		$req = $this->mockWebRequest( [ 'x' => 'Value', 'zero' => '0' ] );
		$this->assertFalse( $req->getCheck( 'z' ), 'Not found' );
		$this->assertTrue( $req->getCheck( 'x' ), 'Text' );
		$this->assertTrue( $req->getCheck( 'zero' ) );
	}

	public function testGetText() {
		// Avoid MediaWiki\Request\FauxRequest (overrides getText)
		$req = $this->mockWebRequest( [ 'crlf' => "Va\r\nlue" ] );
		$this->assertSame( "Va\nlue", $req->getText( 'crlf' ), 'CR stripped' );
	}

	public function testGetValues() {
		$values = [ 'x' => 'Value', 'y' => '' ];
		$req = $this->mockWebRequest( $values );
		$this->assertSame( $values, $req->getValues() );
		$this->assertSame( [ 'x' => 'Value' ], $req->getValues( 'x' ), 'Specific keys' );
	}

	public function testGetValueNames() {
		$req = $this->mockWebRequest( [ 'x' => 'Value', 'y' => '' ] );
		$this->assertSame( [ 'x', 'y' ], $req->getValueNames() );
		$this->assertSame( [ 'x' ], $req->getValueNames( [ 'y' ] ), 'Exclude keys' );
	}

	public function testGetFullRequestURL() {
		$this->overrideConfigValue( MainConfigNames::Server, '//wiki.test' );
		$req = $this->getMockBuilder( WebRequest::class )
			->onlyMethods( [ 'getRequestURL', 'getProtocol' ] )
			->getMock();
		$req->method( 'getRequestURL' )->willReturn( '/path' );
		$req->method( 'getProtocol' )->willReturn( 'https' );

		$this->assertSame(
			'https://wiki.test/path',
			$req->getFullRequestURL()
		);
	}

	/**
	 * @dataProvider provideGetIP
	 */
	public function testGetIP( $expected, $input, $cdn, $xffList, $private, $description ) {
		$this->setServerVars( $input );
		$this->overrideConfigValue( MainConfigNames::UsePrivateIPs, $private );

		$hookContainer = $this->createHookContainer( [
			'IsTrustedProxy' => static function ( &$ip, &$trusted ) use ( $xffList ) {
				$trusted = $trusted || in_array( $ip, $xffList );
				return true;
			}
		] );
		$this->setService( 'ProxyLookup', new ProxyLookup( [], $cdn, $hookContainer ) );

		$request = new WebRequest();
		$result = $request->getIP();
		$this->assertEquals( $expected, $result, $description );
	}

	public static function provideGetIP() {
		return [
			[
				'127.0.0.1',
				[
					'REMOTE_ADDR' => '127.0.0.1'
				],
				[],
				[],
				false,
				'Simple IPv4'
			],
			[
				'::1',
				[
					'REMOTE_ADDR' => '::1'
				],
				[],
				[],
				false,
				'Simple IPv6'
			],
			[
				'12.0.0.1',
				[
					'REMOTE_ADDR' => 'abcd:0001:002:03:4:555:6666:7777',
					'HTTP_X_FORWARDED_FOR' => '12.0.0.1, abcd:0001:002:03:4:555:6666:7777',
				],
				[ 'ABCD:1:2:3:4:555:6666:7777' ],
				[],
				false,
				'IPv6 normalisation'
			],
			[
				'12.0.0.3',
				[
					'REMOTE_ADDR' => '12.0.0.1',
					'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2'
				],
				[ '12.0.0.1', '12.0.0.2' ],
				[],
				false,
				'With X-Forwaded-For'
			],
			[
				'12.0.0.1',
				[
					'REMOTE_ADDR' => '12.0.0.1',
					'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2'
				],
				[],
				[],
				false,
				'With X-Forwaded-For and disallowed server'
			],
			[
				'12.0.0.2',
				[
					'REMOTE_ADDR' => '12.0.0.1',
					'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2'
				],
				[ '12.0.0.1' ],
				[],
				false,
				'With multiple X-Forwaded-For and only one allowed server'
			],
			[
				'10.0.0.3',
				[
					'REMOTE_ADDR' => '12.0.0.2',
					'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2'
				],
				[ '12.0.0.1', '12.0.0.2' ],
				[],
				false,
				'With X-Forwaded-For and private IP (from cache proxy)'
			],
			[
				'10.0.0.4',
				[
					'REMOTE_ADDR' => '12.0.0.2',
					'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2'
				],
				[ '12.0.0.1', '12.0.0.2', '10.0.0.3' ],
				[],
				true,
				'With X-Forwaded-For and private IP (allowed)'
			],
			[
				'10.0.0.4',
				[
					'REMOTE_ADDR' => '12.0.0.2',
					'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2'
				],
				[ '12.0.0.1', '12.0.0.2' ],
				[ '10.0.0.3' ],
				true,
				'With X-Forwaded-For and private IP (allowed)'
			],
			[
				'10.0.0.3',
				[
					'REMOTE_ADDR' => '12.0.0.2',
					'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2'
				],
				[ '12.0.0.1', '12.0.0.2' ],
				[ '10.0.0.3' ],
				false,
				'With X-Forwaded-For and private IP (disallowed)'
			],
			[
				'12.0.0.3',
				[
					'REMOTE_ADDR' => '12.0.0.1',
					'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2'
				],
				[],
				[ '12.0.0.1', '12.0.0.2' ],
				false,
				'With X-Forwaded-For'
			],
			[
				'12.0.0.2',
				[
					'REMOTE_ADDR' => '12.0.0.1',
					'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2'
				],
				[],
				[ '12.0.0.1' ],
				false,
				'With multiple X-Forwaded-For and only one allowed server'
			],
			[
				'12.0.0.2',
				[
					'REMOTE_ADDR' => '12.0.0.2',
					'HTTP_X_FORWARDED_FOR' => '10.0.0.3, 12.0.0.2'
				],
				[],
				[ '12.0.0.2' ],
				false,
				'With X-Forwaded-For and private IP and hook (disallowed)'
			],
			[
				'12.0.0.1',
				[
					'REMOTE_ADDR' => 'abcd:0001:002:03:4:555:6666:7777',
					'HTTP_X_FORWARDED_FOR' => '12.0.0.1, abcd:0001:002:03:4:555:6666:7777',
				],
				[ 'ABCD:1:2:3::/64' ],
				[],
				false,
				'IPv6 CIDR'
			],
			[
				'12.0.0.3',
				[
					'REMOTE_ADDR' => '12.0.0.1',
					'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2'
				],
				[ '12.0.0.0/24' ],
				[],
				false,
				'IPv4 CIDR'
			],
		];
	}

	public function testGetIpLackOfRemoteAddrThrowAnException() {
		// ensure that local install state doesn't interfere with test
		$this->overrideConfigValues( [
			MainConfigNames::CdnServers => [],
			MainConfigNames::CdnServersNoPurge => [],
			MainConfigNames::UsePrivateIPs => false,
		] );

		$hookContainer = $this->createHookContainer();
		$this->setService( 'ProxyLookup', new ProxyLookup( [], [], $hookContainer ) );

		$request = new WebRequest();
		# Next call should throw an exception about lacking an IP
		$this->expectException( MWException::class );
		$request->getIP();
	}

	public static function provideLanguageData() {
		return [
			[ '', [], 'Empty Accept-Language header' ],
			[ 'en', [ 'en' => 1.0 ], 'One language' ],
			[ 'en;q=', [ 'en' => 1.0 ], 'Empty q= defaults to 1' ],
			[ 'en;q=0, de;q=0. pt;q=0.0 it;q=0.0000', [], 'Zeros to be skipped' ],
			[ 'EN;Q=1.0009', [ 'en' => 1.000 ], 'Limited to max. 3 decimal places' ],
			[ 'en, ar', [ 'en' => 1.0, 'ar' => 1.0 ], 'Two languages listed in appearance order.' ],
			[
				'zh-cn,zh-tw',
				[ 'zh-cn' => 1.0, 'zh-tw' => 1.0 ],
				'Two equally prefered languages, listed in appearance order per rfc3282. Checks c9119'
			],
			[
				'es, en; q=0.5',
				[ 'es' => 1.0, 'en' => 0.5 ],
				'Spanish as first language and English and second'
			],
			[ 'en; q=0.5, es', [ 'es' => 1.0, 'en' => 0.5 ], 'Less prefered language first' ],
			[ 'fr, en; q=0.5, es', [ 'fr' => 1.0, 'es' => 1.0, 'en' => 0.5 ], 'Three languages' ],
			[ 'en; q=0.5, es', [ 'es' => 1.0, 'en' => 0.5 ], 'Two languages' ],
			[ 'en, zh;q=0', [ 'en' => 1.0 ], "It's Chinese to me" ],
			[
				'es; q=1, pt;q=0.7, it; q=0.6, de; q=0.1, ru;q=0',
				[ 'es' => 1.0, 'pt' => 0.7, 'it' => 0.6, 'de' => 0.1 ],
				'Preference for Romance languages'
			],
			[
				'en-gb, en-us; q=1',
				[ 'en-gb' => 1.0, 'en-us' => 1.0 ],
				'Two equally prefered English variants'
			],
			[ '_', [], 'Invalid input' ],
		];
	}

	/**
	 * @dataProvider provideLanguageData
	 */
	public function testAcceptLang( $acceptLanguageHeader, array $expectedLanguages, $description ) {
		$this->setServerVars( [ 'HTTP_ACCEPT_LANGUAGE' => $acceptLanguageHeader ] );
		$request = new WebRequest();
		$this->assertSame( $expectedLanguages, $request->getAcceptLang(), $description );
	}

	public function testGetHeaderCanYieldSpecialCgiHeaders() {
		$contentType = 'application/json; charset=utf-8';
		$contentLength = '4711';
		$contentMd5 = 'rL0Y20zC+Fzt72VPzMSk2A==';
		$this->setServerVars( [
			'HTTP_CONTENT_TYPE' => $contentType,
			'HTTP_CONTENT_LENGTH' => $contentLength,
			'HTTP_CONTENT_MD5' => $contentMd5,
		] );
		$request = new WebRequest();
		$this->assertSame( $request->getHeader( 'Content-Type' ), $contentType );
		$this->assertSame( $request->getHeader( 'Content-Length' ), $contentLength );
		$this->assertSame( $request->getHeader( 'Content-Md5' ), $contentMd5 );
	}

	public function testGetHeaderKeyIsCaseInsensitive() {
		$cacheControl = 'private, must-revalidate, max-age=0';
		$this->setServerVars( [ 'HTTP_CACHE_CONTROL' => $cacheControl ] );
		$request = new WebRequest();
		$this->assertSame( $request->getHeader( 'Cache-Control' ), $cacheControl );
		$this->assertSame( $request->getHeader( 'cache-control' ), $cacheControl );
	}

	protected function setServerVars( $vars ) {
		// Don't remove vars which should be available in all SAPI.
		if ( !isset( $vars['REQUEST_TIME_FLOAT'] ) ) {
			$vars['REQUEST_TIME_FLOAT'] = $_SERVER['REQUEST_TIME_FLOAT'];
		}
		if ( !isset( $vars['REQUEST_TIME'] ) ) {
			$vars['REQUEST_TIME'] = $_SERVER['REQUEST_TIME'];
		}
		$_SERVER = $vars;
	}

	/**
	 * @dataProvider provideMatchURLForCDN
	 */
	public function testMatchURLForCDN( $url, $cdnUrls, $matchOrder, $expected ) {
		$this->setServerVars( [ 'REQUEST_URI' => $url ] );
		$this->overrideConfigValues( [
			MainConfigNames::InternalServer => self::INTERNAL_SERVER,
			MainConfigNames::CdnMatchParameterOrder => $matchOrder,
		] );
		$request = new WebRequest();
		$this->assertEquals( $expected, $request->matchURLForCDN( $cdnUrls ) );
	}

	public static function provideMatchURLForCDN() {
		$cdnUrls = [
			self::INTERNAL_SERVER . '/Title',
			self::INTERNAL_SERVER . '/w/index.php?title=Title&action=history',
		];
		return [
			[ self::INTERNAL_SERVER . '/Title', $cdnUrls, /* matchOrder= */ false, true ],
			[ self::INTERNAL_SERVER . '/Title', $cdnUrls, /* matchOrder= */ true, true ],
			[ self::INTERNAL_SERVER . '/Foo', $cdnUrls, /* matchOrder= */ false, false ],
			[ self::INTERNAL_SERVER . '/Foo', $cdnUrls, /* matchOrder= */ true, false ],
			[ self::INTERNAL_SERVER . '/Thing', $cdnUrls, /* matchOrder= */ false, false ],
			[ self::INTERNAL_SERVER . '/Thing', $cdnUrls, /* matchOrder= */ true, false ],
			[ self::INTERNAL_SERVER . '/w/index.php?action=history&title=Foo', $cdnUrls, /* matchOrder= */ false, false ],
			[ self::INTERNAL_SERVER . '/w/index.php?action=history&title=Foo', $cdnUrls, /* matchOrder= */ true, false ],
			[ self::INTERNAL_SERVER . '/w/index.php?title=Thing&action=history', $cdnUrls, /* matchOrder= */ false, false ],
			[ self::INTERNAL_SERVER . '/w/index.php?action=history&title=Thing', $cdnUrls, /* matchOrder= */ true, false ],
			[ self::INTERNAL_SERVER . '/w/index.php?action=history&title=Title', $cdnUrls, /* matchOrder= */ false, true ],
			[ self::INTERNAL_SERVER . '/w/index.php?action=history&title=Title', $cdnUrls, /* matchOrder= */ true, false ],
		];
	}

	/**
	 * @dataProvider provideRequestPathSuffix
	 *
	 * @param string $basePath
	 * @param string $requestUrl
	 * @param string|false $expected
	 */
	public function testRequestPathSuffix( string $basePath, string $requestUrl, $expected ) {
		$suffix = WebRequest::getRequestPathSuffix( $basePath, $requestUrl );
		$this->assertSame( $expected, $suffix );
	}

	public static function provideRequestPathSuffix() {
		yield [
			'/w/index.php',
			'/w/index.php/Hello',
			'Hello'
		];
		yield [
			'/w/index.php',
			'/w/index.php/Hello?x=y',
			'Hello'
		];
		yield [
			'/wiki/',
			'/w/index.php/Hello?x=y',
			false
		];
	}
}
PK       ! X"r  r  !  title/MediaWikiTitleCodecTest.phpnu Iw        <?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
 * @author Daniel Kinzler
 */

use MediaWiki\Cache\GenderCache;
use MediaWiki\Interwiki\InterwikiLookup;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\MalformedTitleException;
use MediaWiki\Title\MediaWikiTitleCodec;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;

/**
 * @covers \MediaWiki\Title\MediaWikiTitleCodec
 *
 * @group Title
 * @group Database
 *        ^--- needed because of global state in
 */
class MediaWikiTitleCodecTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;

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

		$this->overrideConfigValues( [
			MainConfigNames::AllowUserJs => false,
			MainConfigNames::DefaultLanguageVariant => false,
			MainConfigNames::MetaNamespace => 'Project',
			MainConfigNames::LocalInterwikis => [ 'localtestiw' ],
			MainConfigNames::CapitalLinks => true,
			MainConfigNames::LanguageCode => 'en',
		] );
		$this->setUserLang( 'en' );
	}

	/**
	 * Returns a mock GenderCache that will consider a user "female" if the
	 * first part of the user name ends with "a".
	 *
	 * @return GenderCache
	 */
	private function getGenderCache() {
		$genderCache = $this->createMock( GenderCache::class );

		$genderCache->method( 'getGenderOf' )
			->willReturnCallback( static function ( $userName ) {
				return preg_match( '/^[^- _]+a( |_|$)/u', $userName ) ? 'female' : 'male';
			} );

		return $genderCache;
	}

	/**
	 * Returns a InterwikiLookup where the only valid interwikis are 'localtestiw' and 'remotetestiw'.
	 * Only `isValidInterwiki` should actually be needed.
	 *
	 * @return InterwikiLookup
	 */
	private function getInterwikiLookup(): InterwikiLookup {
		return $this->getDummyInterwikiLookup( [ 'localtestiw', 'remotetestiw' ] );
	}

	/**
	 * Returns a NamespaceInfo where the only namespaces that exist are NS_SPECIAL, NS_MAIN, NS_TALK,
	 * NS_USER, and NS_USER_TALK. As per the real NamespaceInfo, NS_USER and NS_USER_TALK have
	 * gender distinctions. All namespaces are capitalized.
	 *
	 * @return NamespaceInfo
	 */
	private function getNamespaceInfo(): NamespaceInfo {
		return $this->getDummyNamespaceInfo( [
			MainConfigNames::CanonicalNamespaceNames => [
				NS_SPECIAL => 'Special',
				NS_MAIN => '',
				NS_TALK => 'Talk',
				NS_USER => 'User',
				NS_USER_TALK => 'User_talk',
			],
			MainConfigNames::CapitalLinks => true,
		] );
	}

	protected function makeCodec( $lang ) {
		return new MediaWikiTitleCodec(
			$this->getServiceContainer()->getLanguageFactory()->getLanguage( $lang ),
			$this->getGenderCache(),
			[ 'localtestiw' ],
			$this->getInterwikiLookup(),
			$this->getNamespaceInfo()
		);
	}

	public static function provideFormat() {
		return [
			[ NS_MAIN, 'Foo_Bar', '', '', 'en', 'Foo Bar' ],
			[ NS_USER, 'Hansi_Maier', 'stuff_and_so_on', '', 'en', 'User:Hansi Maier#stuff and so on' ],
			[ false, 'Hansi_Maier', '', '', 'en', 'Hansi Maier' ],
			[
				NS_USER_TALK,
				'hansi__maier',
				'',
				'',
				'en',
				'User talk:hansi  maier',
				'User talk:Hansi maier'
			],

			// getGenderCache() provides a mock that considers first
			// names ending in "a" to be female.
			[ NS_USER, 'Lisa_Müller', '', '', 'de', 'Benutzerin:Lisa Müller' ],
			[ NS_MAIN, 'FooBar', '', 'remotetestiw', 'en', 'remotetestiw:FooBar' ],
		];
	}

	/**
	 * @dataProvider provideFormat
	 */
	public function testFormat( $namespace, $text, $fragment, $interwiki, $lang, $expected,
		$normalized = null
	) {
		$normalized ??= $expected;

		$codec = $this->makeCodec( $lang );
		$actual = $codec->formatTitle( $namespace, $text, $fragment, $interwiki );

		$this->assertEquals( $expected, $actual, 'formatted' );

		// test round trip
		$parsed = $codec->parseTitle( $actual, NS_MAIN );
		$actual2 = $codec->formatTitle(
			$parsed->getNamespace(),
			$parsed->getText(),
			$parsed->getFragment(),
			$parsed->getInterwiki()
		);

		$this->assertEquals( $normalized, $actual2, 'normalized after round trip' );
	}

	public static function provideGetText() {
		// $title = new TitleValue( $namespace, $dbkey, $fragment );
		return [
			[ new TitleValue( NS_MAIN, 'Foo_Bar', '' ), 'en', 'Foo Bar' ],
			[ new TitleValue( NS_USER, 'Hansi_Maier', 'stuff_and_so_on' ), 'en', 'Hansi Maier' ],
			[ new PageIdentityValue( 37, NS_MAIN, 'Foo_Bar', PageIdentity::LOCAL ), 'en', 'Foo Bar' ],
			[ new PageIdentityValue( 37, NS_USER, 'Hansi_Maier', PageIdentity::LOCAL ), 'en', 'Hansi Maier' ],
		];
	}

	/**
	 * @dataProvider provideGetText
	 */
	public function testGetText( $title, $lang, $expected ) {
		$codec = $this->makeCodec( $lang );
		$actual = $codec->getText( $title );

		$this->assertEquals( $expected, $actual );
	}

	public static function provideGetPrefixedText() {
		return [
			[ new TitleValue( NS_MAIN, 'Foo_Bar', '' ), 'en', 'Foo Bar' ],
			[ new TitleValue( NS_USER, 'Hansi_Maier', 'stuff_and_so_on' ), 'en', 'User:Hansi Maier' ],

			// No capitalization or normalization is applied while formatting!
			[ new TitleValue( NS_USER_TALK, 'hansi__maier', '' ), 'en', 'User talk:hansi  maier' ],

			// getGenderCache() provides a mock that considers first
			// names ending in "a" to be female.
			[
				new TitleValue( NS_USER, 'Lisa_Müller', '' ),
				'de', 'Benutzerin:Lisa Müller'
			],
			[
				new TitleValue( 1000000, 'Invalid_namespace', '' ),
				'en',
				'Special:Badtitle/NS1000000:Invalid namespace'
			],
			[
				new PageIdentityValue( 37, NS_MAIN, 'Foo_Bar', PageIdentity::LOCAL ),
				'en',
				'Foo Bar'
			],
			[
				new PageIdentityValue( 37, NS_USER, 'Hansi_Maier', PageIdentity::LOCAL ),
				'en',
				'User:Hansi Maier'
			],
			[
				new PageIdentityValue( 37, NS_USER_TALK, 'hansi__maier', PageIdentity::LOCAL ),
				'en',
				'User talk:hansi  maier'
			],
			[
				new PageIdentityValue( 37, NS_USER, 'Lisa_Müller', PageIdentity::LOCAL ),
				'de',
				'Benutzerin:Lisa Müller'
			],
			[
				new PageIdentityValue( 37, 1000000, 'Invalid_namespace', PageIdentity::LOCAL ),
				'en',
				'Special:Badtitle/NS1000000:Invalid namespace'
			],
		];
	}

	/**
	 * @dataProvider provideGetPrefixedText
	 */
	public function testGetPrefixedText( $title, $lang, $expected ) {
		$codec = $this->makeCodec( $lang );
		$actual = $codec->getPrefixedText( $title );

		$this->assertEquals( $expected, $actual );
	}

	public static function provideGetPrefixedDBkey() {
		return [
			[ new TitleValue( NS_MAIN, 'Foo_Bar', '', '' ), 'en', 'Foo_Bar' ],
			[ new TitleValue( NS_USER, 'Hansi_Maier', 'stuff_and_so_on', '' ), 'en', 'User:Hansi_Maier' ],

			// No capitalization or normalization is applied while formatting!
			[ new TitleValue( NS_USER_TALK, 'hansi__maier', '', '' ), 'en', 'User_talk:hansi__maier' ],

			// getGenderCache() provides a mock that considers first
			// names ending in "a" to be female.
			[ new TitleValue( NS_USER, 'Lisa_Müller', '', '' ), 'de', 'Benutzerin:Lisa_Müller' ],

			[ new TitleValue( NS_MAIN, 'Remote_page', '', 'remotetestiw' ), 'en', 'remotetestiw:Remote_page' ],

			// non-existent namespace
			[ new TitleValue( 10000000, 'Foobar', '', '' ), 'en', 'Special:Badtitle/NS10000000:Foobar' ],

			[
				new PageIdentityValue( 37, NS_MAIN, 'Foo_Bar', PageIdentity::LOCAL ),
				'en',
				'Foo_Bar'
			],
			[
				new PageIdentityValue( 37, NS_USER, 'Hansi_Maier', PageIdentity::LOCAL ),
				'en',
				'User:Hansi_Maier'
			],
			[
				new PageIdentityValue( 37, NS_USER_TALK, 'hansi__maier', PageIdentity::LOCAL ),
				'en',
				'User_talk:hansi__maier'
			],
			[
				new PageIdentityValue( 37, NS_USER, 'Lisa_Müller', PageIdentity::LOCAL ),
				'de',
				'Benutzerin:Lisa_Müller'
			],
			[
				new PageIdentityValue( 37, NS_MAIN, 'Remote_page', PageIdentity::LOCAL ),
				'en',
				'Remote_page'
			],
			[
				new PageIdentityValue( 37, 10000000, 'Foobar', PageIdentity::LOCAL ),
				'en',
				'Special:Badtitle/NS10000000:Foobar'
			],
		];
	}

	/**
	 * @dataProvider provideGetPrefixedDBkey
	 */
	public function testGetPrefixedDBkey( $title, $lang, $expected
	) {
		$codec = $this->makeCodec( $lang );
		$actual = $codec->getPrefixedDBkey( $title );

		$this->assertEquals( $expected, $actual );
	}

	public static function provideGetFullText() {
		return [
			[ new TitleValue( NS_MAIN, 'Foo_Bar', '' ), 'en', 'Foo Bar' ],
			[ new TitleValue( NS_USER, 'Hansi_Maier', 'stuff_and_so_on' ), 'en', 'User:Hansi Maier#stuff and so on' ],

			// No capitalization or normalization is applied while formatting!
			[ new TitleValue( NS_USER_TALK, 'hansi__maier', '' ), 'en', 'User talk:hansi  maier' ],

			[ new TitleValue( NS_MAIN, 'Foo_Bar' ), 'en', 'Foo Bar' ],
			[ new TitleValue( NS_USER, 'Hansi_Maier' ), 'en', 'User:Hansi Maier' ],

			[
				new PageIdentityValue( 37, NS_MAIN, 'Foo_Bar', PageIdentity::LOCAL ),
				'en',
				'Foo Bar'
			],
			[
				new PageIdentityValue( 37, NS_USER, 'Hansi_Maier', PageIdentity::LOCAL ),
				'en',
				'User:Hansi Maier'
			],
			[
				new PageIdentityValue( 37, NS_USER_TALK, 'hansi__maier', PageIdentity::LOCAL ),
				'en',
				'User talk:hansi  maier'
			],
		];
	}

	/**
	 * @dataProvider provideGetFullText
	 */
	public function testGetFullText( $title, $lang, $expected ) {
		$codec = $this->makeCodec( $lang );
		$actual = $codec->getFullText( $title );

		$this->assertEquals( $expected, $actual );
	}

	public static function provideParseTitle() {
		// TODO: test capitalization and trimming
		// TODO: test unicode normalization

		return [
			[ '  : Hansi_Maier _ ', NS_MAIN, 'en',
				new TitleValue( NS_MAIN, 'Hansi_Maier', '' ) ],
			[ 'User:::1', NS_MAIN, 'de',
				new TitleValue( NS_USER, '0:0:0:0:0:0:0:1', '' ) ],
			[ ' lisa Müller', NS_USER, 'de',
				new TitleValue( NS_USER, 'Lisa_Müller', '' ) ],
			[ 'benutzerin:lisa Müller#stuff', NS_MAIN, 'de',
				new TitleValue( NS_USER, 'Lisa_Müller', 'stuff' ) ],

			[ ':Category:Quux', NS_MAIN, 'en',
				new TitleValue( NS_CATEGORY, 'Quux', '' ) ],
			[ 'Category:Quux', NS_MAIN, 'en',
				new TitleValue( NS_CATEGORY, 'Quux', '' ) ],
			[ 'Category:Quux', NS_CATEGORY, 'en',
				new TitleValue( NS_CATEGORY, 'Quux', '' ) ],
			[ 'Quux', NS_CATEGORY, 'en',
				new TitleValue( NS_CATEGORY, 'Quux', '' ) ],
			[ ':Quux', NS_CATEGORY, 'en',
				new TitleValue( NS_MAIN, 'Quux', '' ) ],

			// getGenderCache() provides a mock that considers first
			// names ending in "a" to be female.

			[ 'a b c', NS_MAIN, 'en',
				new TitleValue( NS_MAIN, 'A_b_c' ) ],
			[ ' a  b  c ', NS_MAIN, 'en',
				new TitleValue( NS_MAIN, 'A_b_c' ) ],
			[ ' _ Foo __ Bar_ _', NS_MAIN, 'en',
				new TitleValue( NS_MAIN, 'Foo_Bar' ) ],

			// NOTE: cases copied from TitleTest::testSecureAndSplit. Keep in sync.
			[ 'Sandbox', NS_MAIN, 'en', ],
			[ 'A "B"', NS_MAIN, 'en', ],
			[ 'A \'B\'', NS_MAIN, 'en', ],
			[ '.com', NS_MAIN, 'en', ],
			[ '~', NS_MAIN, 'en', ],
			[ '"', NS_MAIN, 'en', ],
			[ '\'', NS_MAIN, 'en', ],

			[ 'Talk:Sandbox', NS_MAIN, 'en',
				new TitleValue( NS_TALK, 'Sandbox' ) ],
			[ 'Talk:Foo:Sandbox', NS_MAIN, 'en',
				new TitleValue( NS_TALK, 'Foo:Sandbox' ) ],
			[ 'File:Example.svg', NS_MAIN, 'en',
				new TitleValue( NS_FILE, 'Example.svg' ) ],
			[ 'File_talk:Example.svg', NS_MAIN, 'en',
				new TitleValue( NS_FILE_TALK, 'Example.svg' ) ],
			[ 'Foo/.../Sandbox', NS_MAIN, 'en',
				'Foo/.../Sandbox' ],
			[ 'Sandbox/...', NS_MAIN, 'en',
				'Sandbox/...' ],
			[ 'A~~', NS_MAIN, 'en',
				'A~~' ],
			// Length is 256 total, but only title part matters
			[ 'Category:' . str_repeat( 'x', 248 ), NS_MAIN, 'en',
				new TitleValue( NS_CATEGORY,
					'X' . str_repeat( 'x', 247 ) ) ],
			[ str_repeat( 'x', 252 ), NS_MAIN, 'en',
				'X' . str_repeat( 'x', 251 ) ],
			// Test decoding and normalization
			[ '&quot;n&#x303;&#34;', NS_MAIN, 'en', new TitleValue( NS_MAIN, '"ñ"' ) ],
			[ 'X#n&#x303;', NS_MAIN, 'en', new TitleValue( NS_MAIN, 'X', 'ñ' ) ],
			// target section parsing
			'empty fragment' => [ 'X#', NS_MAIN, 'en', new TitleValue( NS_MAIN, 'X' ) ],
			'only fragment' => [ '#', NS_MAIN, 'en', new TitleValue( NS_MAIN, '' ) ],
			'double hash' => [ 'X##', NS_MAIN, 'en', new TitleValue( NS_MAIN, 'X', '#' ) ],
			'fragment with hash' => [ 'X#z#z', NS_MAIN, 'en', new TitleValue( NS_MAIN, 'X', 'z#z' ) ],
			'fragment with space' => [ 'X#z z', NS_MAIN, 'en', new TitleValue( NS_MAIN, 'X', 'z z' ) ],
			'fragment with percent' => [ 'X#z%z', NS_MAIN, 'en', new TitleValue( NS_MAIN, 'X', 'z%z' ) ],
			'fragment with amp' => [ 'X#z&z', NS_MAIN, 'en', new TitleValue( NS_MAIN, 'X', 'z&z' ) ],
			'remotetestiw in user' => [ 'User:remotetestiw:', NS_MAIN, 'en', new TitleValue( NS_USER, 'Remotetestiw:' ) ],
		];
	}

	/**
	 * @dataProvider provideParseTitle
	 */
	public function testParseTitle( $text, $ns, $lang, $title = null ) {
		if ( !( $title instanceof TitleValue ) ) {
			$title ??= str_replace( ' ', '_', trim( $text ) );
			$title = new TitleValue( NS_MAIN, $title, '' );
		}

		$codec = $this->makeCodec( $lang );
		$actual = $codec->parseTitle( $text, $ns );

		$this->assertEquals( $title, $actual );
	}

	public static function provideParseTitle_invalid() {
		return [
			[ 'User:#' ],
			[ '::' ],
			[ '::xx' ],
			[ '::##' ],
			[ ' :: x' ],

			[ 'Talk:File:Foo.jpg' ],
			[ 'Talk:localtestiw:Foo' ],
			[ '::1' ], // only valid in user namespace
			[ 'User::x' ], // leading ":" in a user name is only valid of IPv6 addresses
			[ 'remotetestiw:', NS_USER ],

			// NOTE: cases copied from TitleTest::testSecureAndSplit. Keep in sync.
			[ '' ],
			[ ':' ],
			[ '__  __' ],
			[ '  __  ' ],
			// Bad characters forbidden regardless of wgLegalTitleChars
			[ 'A [ B' ],
			[ 'A ] B' ],
			[ 'A { B' ],
			[ 'A } B' ],
			[ 'A < B' ],
			[ 'A > B' ],
			[ 'A | B' ],
			// URL encoding
			[ 'A%20B' ],
			[ 'A%23B' ],
			[ 'A%2523B' ],
			// XML/HTML character entity references
			// Note: Commented out because they are not marked invalid by the PHP test as
			// Title::newFromText runs Sanitizer::decodeCharReferencesAndNormalize first.
			// [ 'A &eacute; B' ],
			// [ 'A &#233; B' ],
			// [ 'A &#x00E9; B' ],
			// Subject of NS_TALK does not roundtrip to NS_MAIN
			[ 'Talk:File:Example.svg' ],
			// Directory navigation
			[ '.' ],
			[ '..' ],
			[ './Sandbox' ],
			[ '../Sandbox' ],
			[ 'Foo/./Sandbox' ],
			[ 'Foo/../Sandbox' ],
			[ 'Sandbox/.' ],
			[ 'Sandbox/..' ],
			// Tilde
			[ 'A ~~~ Name' ],
			[ 'A ~~~~ Signature' ],
			[ 'A ~~~~~ Timestamp' ],
			[ str_repeat( 'x', 256 ) ],
			// Namespace prefix without actual title
			[ 'Talk:' ],
			[ 'Category: ' ],
			[ 'Category: #bar' ],
			// Invalid Unicode
			[ "Apollo\x96Soyuz" ],
			// Input resulting from invalid Unicode being sanitized somewhere else
			[ "Apollo\u{FFFD}Soyuz" ],
		];
	}

	/**
	 * @dataProvider provideParseTitle_invalid
	 */
	public function testParseTitle_invalid( $text, $ns = NS_MAIN ) {
		$this->expectException( MalformedTitleException::class );

		$codec = $this->makeCodec( 'en' );
		$codec->parseTitle( $text, $ns );
	}

	/**
	 * @dataProvider provideMakeTitleValueSafe
	 */
	public function testMakeTitleValueSafe(
		$expected, $ns, $text, $fragment = '', $interwiki = '', $lang = 'en'
	) {
		$codec = $this->makeCodec( $lang );
		$this->assertEquals( $expected,
			$codec->makeTitleValueSafe( $ns, $text, $fragment, $interwiki ) );
	}

	/**
	 * @dataProvider provideMakeTitleValueSafe
	 * @covers \MediaWiki\Title\Title::makeTitleSafe
	 * @covers \MediaWiki\Title\Title::makeName
	 * @covers \MediaWiki\Title\Title::secureAndSplit
	 */
	public function testMakeTitleSafe(
		$expected, $ns, $text, $fragment = '', $interwiki = '', $lang = 'en'
	) {
		$codec = $this->makeCodec( $lang );
		$this->setService( 'TitleParser', $codec );
		$this->setService( 'TitleFormatter', $codec );

		$actual = Title::makeTitleSafe( $ns, $text, $fragment, $interwiki );

		if ( $expected ) {
			$this->assertNotNull( $actual );
			$expectedTitle = Title::newFromLinkTarget( $expected );
			$this->assertSame( $expectedTitle->getPrefixedDBkey(), $actual->getPrefixedDBkey() );
		} else {
			$this->assertNull( $actual );
		}
	}

	public static function provideMakeTitleValueSafe() {
		$ret = [
			'Nonexistent NS' => [ null, 942929, 'Test' ],
			'Linebreak in title' => [ null, NS_MAIN, "Test\nthis" ],
			'Pipe in title' => [ null, NS_MAIN, "Test|this" ],
			'Simple page' => [ new TitleValue( NS_MAIN, 'Test' ), NS_MAIN, 'Test' ],

			// Fragments
			'Passed fragment' => [
				new TitleValue( NS_MAIN, 'Test', 'Fragment' ),
				NS_MAIN, 'Test', 'Fragment'
			],
			'Embedded fragment' => [
				new TitleValue( NS_MAIN, 'Test', 'Fragment' ),
				NS_MAIN, 'Test#Fragment'
			],
			'Passed fragment with spaces' => [
				// XXX Leading space is okay in fragment?
				new TitleValue( NS_MAIN, 'Test', ' Frag ment' ),
				NS_MAIN, ' Test ', " Frag_ment "
			],
			'Embedded fragment with spaces' => [
				// XXX Leading space is okay in fragment?
				new TitleValue( NS_MAIN, 'Test', ' Frag ment' ),
				NS_MAIN, " Test # Frag_ment "
			],
			// XXX Is it correct that these aren't normalized to spaces?
			'Passed fragment with leading tab' => [ null, NS_MAIN, "\tTest\t", "\tFragment" ],
			'Embedded fragment with leading tab' => [ null, NS_MAIN, "\tTest\t#\tFragment" ],
			'Passed fragment with trailing tab' => [ null, NS_MAIN, "\tTest\t", "Fragment\t" ],
			'Embedded fragment with trailing tab' => [ null, NS_MAIN, "\tTest\t#Fragment\t" ],
			'Passed fragment with interior tab' => [ null, NS_MAIN, "\tTest\t", "Frag\tment" ],
			'Embedded fragment with interior tab' => [ null, NS_MAIN, "\tTest\t#\tFrag\tment" ],

			// Interwikis
			'Passed local interwiki' => [
				new TitleValue( NS_MAIN, 'Test' ),
				NS_MAIN, 'Test', '', 'localtestiw'
			],
			'Embedded local interwiki' => [
				new TitleValue( NS_MAIN, 'Test' ),
				NS_MAIN, 'localtestiw:Test'
			],
			'Passed remote interwiki' => [
				new TitleValue( NS_MAIN, 'Test', '', 'remotetestiw' ),
				NS_MAIN, 'Test', '', 'remotetestiw'
			],
			'Embedded remote interwiki' => [
				new TitleValue( NS_MAIN, 'Test', '', 'remotetestiw' ),
				NS_MAIN, 'remotetestiw:Test'
			],
			// Interwiki prefixes are not case sensitive
			'Passed local interwiki with different case' => [
				new TitleValue( NS_MAIN, 'Test' ),
				NS_MAIN, 'Test', '', 'LocalTestIW'
			],
			'Embedded local interwiki with different case' => [
				new TitleValue( NS_MAIN, 'Test' ),
				NS_MAIN, 'LocalTestIW:Test'
			],
			'Passed remote interwiki with different case' => [
				new TitleValue( NS_MAIN, 'Test', '', 'remotetestiw' ),
				NS_MAIN, 'Test', '', 'RemoteTestIW'
			],
			'Embedded remote interwiki with different case' => [
				new TitleValue( NS_MAIN, 'Test', '', 'remotetestiw' ),
				NS_MAIN, 'RemoteTestIW:Test'
			],
			'Passed local interwiki with lowercase page name' => [
				new TitleValue( NS_MAIN, 'Test' ),
				NS_MAIN, 'test', '', 'localtestiw'
			],
			'Embedded local interwiki with lowercase page name' => [
				new TitleValue( NS_MAIN, 'Test' ),
				NS_MAIN, 'localtestiw:test'
			],
			// For remote we don't auto-capitalize
			'Passed remote interwiki with lowercase page name' => [
				new TitleValue( NS_MAIN, 'test', '', 'remotetestiw' ),
				NS_MAIN, 'test', '', 'remotetestiw'
			],
			'Embedded remote interwiki with lowercase page name' => [
				new TitleValue( NS_MAIN, 'test', '', 'remotetestiw' ),
				NS_MAIN, 'remotetestiw:test'
			],

			// Fragment and interwiki
			'Fragment and local interwiki' => [
				new TitleValue( NS_MAIN, 'Test', 'Fragment' ),
				NS_MAIN, 'Test', 'Fragment', 'localtestiw'
			],
			'Fragment and remote interwiki' => [
				new TitleValue( NS_MAIN, 'Test', 'Fragment', 'remotetestiw' ),
				NS_MAIN, 'Test', 'Fragment', 'remotetestiw'
			],
			'Fragment and local interwiki and non-main namespace' => [
				new TitleValue( NS_TALK, 'Test', 'Fragment' ),
				NS_TALK, 'Test', 'Fragment', 'localtestiw'
			],
			// We don't know the foreign wiki's namespaces, so it will always be NS_MAIN
			'Fragment and remote interwiki and non-main namespace' => [
				new TitleValue( NS_MAIN, 'Talk:Test', 'Fragment', 'remotetestiw' ),
				NS_TALK, 'Test', 'Fragment', 'remotetestiw'
			],

			// Whitespace normalization and Unicode stripping
			'Name with space' => [
				new TitleValue( NS_MAIN, 'Test_test' ),
				NS_MAIN, 'Test test'
			],
			'Unicode bidi override characters' => [
				new TitleValue( NS_MAIN, 'Test' ),
				NS_MAIN, "\u{200E}T\u{200F}e\u{202A}s\u{202B}t\u{202C}\u{202D}\u{202E}"
			],
			'Invalid UTF-8 sequence' => [ null, NS_MAIN, "Te\x80\xf0st" ],
			'Whitespace collapsing' => [
				new TitleValue( NS_MAIN, 'Test_test' ),
				NS_MAIN, "Test _\u{00A0}\u{1680}\u{180E}\u{2000}\u{2001}\u{2002}\u{2003}\u{2004}" .
				"\u{2005}\u{2006}\u{2007}\u{2008}\u{2009}\u{200A}\u{2028}\u{2029}\u{202F}" .
				"\u{205F}\u{3000}test"
			],
			'UTF8_REPLACEMENT' => [ null, NS_MAIN, UtfNormal\Constants::UTF8_REPLACEMENT ],

			// Namespace prefixes
			'Talk:Test' => [
				new TitleValue( NS_TALK, 'Test' ),
				NS_MAIN, 'Talk:Test'
			],
			'Test in talk NS' => [
				new TitleValue( NS_TALK, 'Test' ),
				NS_TALK, 'Test'
			],
			'Talkk:Test' => [
				new TitleValue( NS_MAIN, 'Talkk:Test' ),
				NS_MAIN, 'Talkk:Test'
			],
			'Talk:Talk:Test' => [ null, NS_MAIN, 'Talk:Talk:Test' ],
			'Talk:User:Test' => [ null, NS_MAIN, 'Talk:User:Test' ],
			'User:Talk:Test' => [
				new TitleValue( NS_USER, 'Talk:Test' ),
				NS_MAIN, 'User:Talk:Test'
			],
			'User:Test in talk NS' => [ null, NS_TALK, 'User:Test' ],
			'Talk:Test in talk NS' => [ null, NS_TALK, 'Talk:Test' ],
			'User:Test in user NS' => [
				new TitleValue( NS_USER, 'User:Test' ),
				NS_USER, 'User:Test'
			],
			'Talk:Test in user NS' => [
				new TitleValue( NS_USER, 'Talk:Test' ),
				NS_USER, 'Talk:Test'
			],

			// Initial colon
			':Test' => [
				new TitleValue( NS_MAIN, 'Test' ),
				NS_MAIN, ':Test'
			],
			':Talk:Test' => [
				new TitleValue( NS_TALK, 'Test' ),
				NS_MAIN, ':Talk:Test'
			],
			':localtestiw:Test' => [
				new TitleValue( NS_MAIN, 'Test' ),
				NS_MAIN, ':localtestiw:Test'
			],
			':remotetestiw:Test' => [
				new TitleValue( NS_MAIN, 'Test', '', 'remotetestiw' ),
				NS_MAIN, ':remotetestiw:Test'
			],
			// XXX Is this correct? Why is it different from remote?
			'localtestiw::Test' => [ null, NS_MAIN, 'localtestiw::Test' ],
			'remotetestiw::Test' => [
				new TitleValue( NS_MAIN, 'Test', '', 'remotetestiw' ),
				NS_MAIN, 'remotetestiw::Test'
			],
			// XXX Is this correct? Why is it different from remote?
			'localtestiw:: Test' => [ null, NS_MAIN, 'localtestiw:: Test' ],
			'remotetestiw:: Test' => [
				new TitleValue( NS_MAIN, 'Test', '', 'remotetestiw' ),
				NS_MAIN, 'remotetestiw:: Test'
			],

			// Empty titles
			'Empty title' => [ null, NS_MAIN, '' ],
			'Empty title with namespace' => [ null, NS_USER, '' ],
			'Local interwiki with empty page name' => [
				new TitleValue( NS_MAIN, 'Main_Page' ),
				NS_MAIN, 'localtestiw:'
			],
			'Remote interwiki with empty page name' => [
				// XXX Is this correct? This is supposed to redirect to the main page remotely?
				new TitleValue( NS_MAIN, '', '', 'remotetestiw' ),
				NS_MAIN, 'remotetestiw:'
			],

			// Whitespace-only titles
			'Whitespace-only title' => [ null, NS_MAIN, "\t\n" ],
			'Whitespace-only title with namespace' => [ null, NS_USER, " _ " ],
			'Local interwiki with whitespace-only page name' => [
				// XXX Is whitespace-only really supposed to be different from empty?
				null,
				NS_MAIN, "localtestiw:_\t"
			],
			'Remote interwiki with whitespace-only page name' => [
				// XXX Is whitespace-only really supposed to be different from empty?
				null,
				NS_MAIN, "remotetestiw:\t_\n\r"
			],

			// Namespace and interwiki
			'Talk:localtestiw:Test' => [ null, NS_MAIN, 'Talk:localtestiw:Test' ],
			'Talk:remotetestiw:Test' => [ null, NS_MAIN, 'Talk:remotetestiw:Test' ],
			'User:localtestiw:Test' => [
				new TitleValue( NS_USER, 'Localtestiw:Test' ),
				NS_MAIN, 'User:localtestiw:Test'
			],
			'User:remotetestiw:Test' => [
				new TitleValue( NS_USER, 'Remotetestiw:Test' ),
				NS_MAIN, 'User:remotetestiw:Test'
			],
			'localtestiw:Test in user namespace' => [
				new TitleValue( NS_USER, 'Localtestiw:Test' ),
				NS_USER, 'localtestiw:Test'
			],
			'remotetestiw:Test in user namespace' => [
				new TitleValue( NS_USER, 'Remotetestiw:Test' ),
				NS_USER, 'remotetestiw:Test'
			],
			'localtestiw:talk:test' => [
				new TitleValue( NS_TALK, 'Test' ),
				NS_MAIN, 'localtestiw:talk:test'
			],
			'remotetestiw:talk:test' => [
				new TitleValue( NS_MAIN, 'talk:test', '', 'remotetestiw' ),
				NS_MAIN, 'remotetestiw:talk:test'
			],

			// Invalid chars
			'Test[test' => [ null, NS_MAIN, 'Test[test' ],

			// Long titles
			'255 chars long' => [
				new TitleValue( NS_MAIN, str_repeat( 'A', 255 ) ),
				NS_MAIN, str_repeat( 'A', 255 )
			],
			'255 chars long in user NS' => [
				new TitleValue( NS_USER, str_repeat( 'A', 255 ) ),
				NS_USER, str_repeat( 'A', 255 )
			],
			'User:255 chars long' => [
				new TitleValue( NS_USER, str_repeat( 'A', 255 ) ),
				NS_MAIN, 'User:' . str_repeat( 'A', 255 )
			],
			'256 chars long' => [ null, NS_MAIN, str_repeat( 'A', 256 ) ],
			'256 chars long in user NS' => [ null, NS_USER, str_repeat( 'A', 256 ) ],
			'User:256 chars long' => [ null, NS_MAIN, 'User:' . str_repeat( 'A', 256 ) ],

			'512 chars long in special NS' => [
				new TitleValue( NS_SPECIAL, str_repeat( 'A', 512 ) ),
				NS_SPECIAL, str_repeat( 'A', 512 )
			],
			'Special:512 chars long' => [
				new TitleValue( NS_SPECIAL, str_repeat( 'A', 512 ) ),
				NS_MAIN, 'Special:' . str_repeat( 'A', 512 )
			],
			'513 chars long in special NS' => [ null, NS_SPECIAL, str_repeat( 'A', 513 ) ],
			'Special:513 chars long' => [ null, NS_MAIN, 'Special:' . str_repeat( 'A', 513 ) ],

			// IP addresses
			'User:000.000.000' => [
				new TitleValue( NS_USER, '000.000.000' ),
				NS_MAIN, 'User:000.000.000'
			],
			'User:000.000.000.000' => [
				new TitleValue( NS_USER, '0.0.0.0' ),
				NS_MAIN, 'User:000.000.000.000'
			],
			'000.000.000.000' => [
				new TitleValue( NS_MAIN, '000.000.000.000' ),
				NS_MAIN, '000.000.000.000'
			],
			'User:1.1.256.000' => [
				new TitleValue( NS_USER, '1.1.256.000' ),
				NS_MAIN, 'User:1.1.256.000'
			],
			'User:1.1.255.000' => [
				new TitleValue( NS_USER, '1.1.255.0' ),
				NS_MAIN, 'User:1.1.255.000'
			],
			// TODO More IP address sanitization tests
		];

		// Invalid and valid dots
		foreach ( [ '.', '..', '...' ] as $dots ) {
			foreach ( [ '?', '?/', '?/Test', 'Test/?/Test', '/?', 'Test/?', '?Test', 'Test?Test',
			'Test?' ] as $pattern ) {
				$test = str_replace( '?', $dots, $pattern );
				if ( $dots === '...' || in_array( $pattern, [ '?Test', 'Test?Test', 'Test?' ] ) ) {
					$expectedMain = new TitleValue( NS_MAIN, $test );
					$expectedUser = new TitleValue( NS_USER, $test );
				} else {
					$expectedMain = $expectedUser = null;
				}
				$ret[$test] = [ $expectedMain, NS_MAIN, $test ];
				$ret["$test in user NS"] = [ $expectedUser, NS_USER, $test ];
				$ret["User:$test"] = [ $expectedUser, NS_MAIN, "User:$test" ];
			}
		}

		// Invalid and valid tildes
		foreach ( [ '~~', '~~~' ] as $tildes ) {
			foreach ( [ '?', 'Test?', '?Test', 'Test?Test' ] as $pattern ) {
				$test = str_replace( '?', $tildes, $pattern );
				if ( $tildes === '~~' ) {
					$expectedMain = new TitleValue( NS_MAIN, $test );
					$expectedUser = new TitleValue( NS_USER, $test );
				} else {
					$expectedMain = $expectedUser = null;
				}
				$ret[$test] = [ $expectedMain, NS_MAIN, $test ];
				$ret["$test in user NS"] = [ $expectedUser, NS_USER, $test ];
				$ret["User:$test"] = [ $expectedUser, NS_MAIN, "User:$test" ];
			}
		}

		return $ret;
	}

	public static function provideGetNamespaceName() {
		return [
			[ NS_MAIN, 'Foo', 'en', '' ],
			[ NS_USER, 'Foo', 'en', 'User' ],
			[ NS_USER, 'Hansi Maier', 'de', 'Benutzer' ],

			// getGenderCache() provides a mock that considers first
			// names ending in "a" to be female.
			[ NS_USER, 'Lisa Müller', 'de', 'Benutzerin' ],
		];
	}

	/**
	 * @dataProvider provideGetNamespaceName
	 */
	public function testGetNamespaceName( $namespace, $text, $lang, $expected ) {
		$codec = $this->makeCodec( $lang );
		$name = $codec->getNamespaceName( $namespace, $text );

		$this->assertEquals( $expected, $name );
	}
}
PK       ! _bG0 0   title/TitleTest.phpnu Iw        <?php

use MediaWiki\Cache\BacklinkCache;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Language\RawMessage;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\MalformedTitleException;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use MediaWiki\Utils\MWTimestamp;
use Wikimedia\Assert\PreconditionException;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * @group Database
 * @group Title
 */
class TitleTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;
	use LinkCacheTestTrait;

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

		$this->mergeMwGlobalArrayValue(
			'wgExtraNamespaces',
			[
				12302 => 'TEST-JS',
				12303 => 'TEST-JS_TALK',
			]
		);
		$this->mergeMwGlobalArrayValue(
			'wgNamespaceContentModels',
			[
				12302 => CONTENT_MODEL_JAVASCRIPT,
			]
		);

		$this->overrideConfigValues( [
			MainConfigNames::AllowUserJs => false,
			MainConfigNames::DefaultLanguageVariant => false,
			MainConfigNames::MetaNamespace => 'Project',
			MainConfigNames::Server => 'https://example.org',
			MainConfigNames::CanonicalServer => 'https://example.org',
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::LanguageCode => 'en',
			// For testSecureAndSplitValid, testSecureAndSplitInvalid
			MainConfigNames::LocalInterwikis => [ 'localtestiw' ],
		] );
		$this->setUserLang( 'en' );

		// Define valid interwiki prefixes and their configuration
		$interwikiLookup = $this->getDummyInterwikiLookup( [
			// testSecureAndSplitValid, testSecureAndSplitInvalid
			[ 'iw_prefix' => 'localtestiw', 'iw_url' => 'localtestiw' ],
			[ 'iw_prefix' => 'remotetestiw', 'iw_url' => 'remotetestiw' ],

			// testSubpages
			'wiki',

			// testIsValid
			'wikipedia',

			// testIsValidRedirectTarget
			'acme',

			// testGetFragmentForURL
			[ 'iw_prefix' => 'de', 'iw_local' => 1 ],
			[ 'iw_prefix' => 'zz', 'iw_local' => 0 ],

			// Some tests use interwikis - define valid prefixes and their configuration
			[ 'iw_prefix' => 'acme', 'iw_url' => 'https://acme.test/$1' ],
			[ 'iw_prefix' => 'yy', 'iw_url' => 'https://yy.wiki.test/wiki/$1', 'iw_local' => true ]
		] );
		$this->setService( 'InterwikiLookup', $interwikiLookup );
	}

	protected function tearDown(): void {
		Title::clearCaches();
		parent::tearDown();
		// delete dummy pages
		$this->getNonexistingTestPage( 'UTest1' );
		$this->getNonexistingTestPage( 'UTest2' );
	}

	public static function provideInNamespace() {
		return [
			[ 'Main Page', NS_MAIN, true ],
			[ 'Main Page', NS_TALK, false ],
			[ 'Main Page', NS_USER, false ],
			[ 'User:Foo', NS_USER, true ],
			[ 'User:Foo', NS_USER_TALK, false ],
			[ 'User:Foo', NS_TEMPLATE, false ],
			[ 'User_talk:Foo', NS_USER_TALK, true ],
			[ 'User_talk:Foo', NS_USER, false ],
		];
	}

	/**
	 * @dataProvider provideInNamespace
	 * @covers \MediaWiki\Title\Title::inNamespace
	 */
	public function testInNamespace( $title, $ns, $expectedBool ) {
		$title = Title::newFromText( $title );
		$this->assertEquals( $expectedBool, $title->inNamespace( $ns ) );
	}

	/**
	 * @covers \MediaWiki\Title\Title::inNamespaces
	 */
	public function testInNamespaces() {
		$mainpage = Title::newFromText( 'Main Page' );
		$this->assertTrue( $mainpage->inNamespaces( NS_MAIN, NS_USER ) );
		$this->assertTrue( $mainpage->inNamespaces( [ NS_MAIN, NS_USER ] ) );
		$this->assertTrue( $mainpage->inNamespaces( [ NS_USER, NS_MAIN ] ) );
		$this->assertFalse( $mainpage->inNamespaces( [ NS_PROJECT, NS_TEMPLATE ] ) );
	}

	public static function provideHasSubjectNamespace() {
		return [
			[ 'Main Page', NS_MAIN, true ],
			[ 'Main Page', NS_TALK, true ],
			[ 'Main Page', NS_USER, false ],
			[ 'User:Foo', NS_USER, true ],
			[ 'User:Foo', NS_USER_TALK, true ],
			[ 'User:Foo', NS_TEMPLATE, false ],
			[ 'User_talk:Foo', NS_USER_TALK, true ],
			[ 'User_talk:Foo', NS_USER, true ],
		];
	}

	/**
	 * @dataProvider provideHasSubjectNamespace
	 * @covers \MediaWiki\Title\Title::hasSubjectNamespace
	 */
	public function testHasSubjectNamespace( $title, $ns, $expectedBool ) {
		$title = Title::newFromText( $title );
		$this->assertEquals( $expectedBool, $title->hasSubjectNamespace( $ns ) );
	}

	public function dataGetContentModel() {
		return [
			[ 'Help:Foo', CONTENT_MODEL_WIKITEXT ],
			[ 'Help:Foo.js', CONTENT_MODEL_WIKITEXT ],
			[ 'Help:Foo/bar.js', CONTENT_MODEL_WIKITEXT ],
			[ 'User:Foo', CONTENT_MODEL_WIKITEXT ],
			[ 'User:Foo.js', CONTENT_MODEL_WIKITEXT ],
			[ 'User:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ],
			[ 'User:Foo/bar.css', CONTENT_MODEL_CSS ],
			[ 'User talk:Foo/bar.css', CONTENT_MODEL_WIKITEXT ],
			[ 'User:Foo/bar.js.xxx', CONTENT_MODEL_WIKITEXT ],
			[ 'User:Foo/bar.xxx', CONTENT_MODEL_WIKITEXT ],
			[ 'MediaWiki:Foo.js', CONTENT_MODEL_JAVASCRIPT ],
			[ 'MediaWiki:Foo.css', CONTENT_MODEL_CSS ],
			[ 'MediaWiki:Foo/bar.css', CONTENT_MODEL_CSS ],
			[ 'MediaWiki:Foo.JS', CONTENT_MODEL_WIKITEXT ],
			[ 'MediaWiki:Foo.CSS', CONTENT_MODEL_WIKITEXT ],
			[ 'MediaWiki:Foo.css.xxx', CONTENT_MODEL_WIKITEXT ],
			[ 'TEST-JS:Foo', CONTENT_MODEL_JAVASCRIPT ],
			[ 'TEST-JS:Foo.js', CONTENT_MODEL_JAVASCRIPT ],
			[ 'TEST-JS:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ],
			[ 'TEST-JS_TALK:Foo.js', CONTENT_MODEL_WIKITEXT ],
		];
	}

	/**
	 * @dataProvider dataGetContentModel
	 * @covers \MediaWiki\Title\Title::getContentModel
	 */
	public function testGetContentModel( $title, $expectedModelId ) {
		$title = Title::newFromText( $title );
		$this->assertEquals( $expectedModelId, $title->getContentModel() );
	}

	/**
	 * @dataProvider dataGetContentModel
	 * @covers \MediaWiki\Title\Title::hasContentModel
	 */
	public function testHasContentModel( $title, $expectedModelId ) {
		$title = Title::newFromText( $title );
		$this->assertTrue( $title->hasContentModel( $expectedModelId ) );
	}

	public static function provideIsSiteConfigPage() {
		return [
			[ 'Help:Foo', false ],
			[ 'Help:Foo.js', false ],
			[ 'Help:Foo/bar.js', false ],
			[ 'User:Foo', false ],
			[ 'User:Foo.js', false ],
			[ 'User:Foo/bar.js', false ],
			[ 'User:Foo/bar.json', false ],
			[ 'User:Foo/bar.css', false ],
			[ 'User:Foo/bar.JS', false ],
			[ 'User:Foo/bar.JSON', false ],
			[ 'User:Foo/bar.CSS', false ],
			[ 'User talk:Foo/bar.css', false ],
			[ 'User:Foo/bar.js.xxx', false ],
			[ 'User:Foo/bar.xxx', false ],
			[ 'MediaWiki:Foo.js', 'javascript' ],
			[ 'MediaWiki:Foo.json', 'json' ],
			[ 'MediaWiki:Foo.css', 'css' ],
			[ 'MediaWiki:Foo.JS', false ],
			[ 'MediaWiki:Foo.JSON', false ],
			[ 'MediaWiki:Foo.CSS', false ],
			[ 'MediaWiki:Foo/bar.css', 'css' ],
			[ 'MediaWiki:Foo.css.xxx', false ],
			[ 'TEST-JS:Foo', false ],
			[ 'TEST-JS:Foo.js', false ],
		];
	}

	/**
	 * @dataProvider provideIsSiteConfigPage
	 * @covers \MediaWiki\Title\Title::isSiteConfigPage
	 * @covers \MediaWiki\Title\Title::isSiteJsConfigPage
	 * @covers \MediaWiki\Title\Title::isSiteJsonConfigPage
	 * @covers \MediaWiki\Title\Title::isSiteCssConfigPage
	 */
	public function testSiteConfigPage( $title, $expected ) {
		$title = Title::newFromText( $title );

		// $expected is either false or the relevant type ('javascript', 'json', 'css')
		$this->assertSame(
			$expected !== false,
			$title->isSiteConfigPage()
		);
		$this->assertSame(
			$expected === 'javascript',
			$title->isSiteJsConfigPage()
		);
		$this->assertSame(
			$expected === 'json',
			$title->isSiteJsonConfigPage()
		);
		$this->assertSame(
			$expected === 'css',
			$title->isSiteCssConfigPage()
		);
	}

	public static function provideIsUserConfigPage() {
		return [
			[ 'Help:Foo', false ],
			[ 'Help:Foo.js', false ],
			[ 'Help:Foo/bar.js', false ],
			[ 'User:Foo', false ],
			[ 'User:Foo.js', false ],
			[ 'User:Foo/bar.js', 'javascript' ],
			[ 'User:Foo/bar.JS', false ],
			[ 'User:Foo/bar.json', 'json' ],
			[ 'User:Foo/bar.JSON', false ],
			[ 'User:Foo/bar.css', 'css' ],
			[ 'User:Foo/bar.CSS', false ],
			[ 'User talk:Foo/bar.css', false ],
			[ 'User:Foo/bar.js.xxx', false ],
			[ 'User:Foo/bar.xxx', false ],
			[ 'MediaWiki:Foo.js', false ],
			[ 'MediaWiki:Foo.json', false ],
			[ 'MediaWiki:Foo.css', false ],
			[ 'MediaWiki:Foo.JS', false ],
			[ 'MediaWiki:Foo.JSON', false ],
			[ 'MediaWiki:Foo.CSS', false ],
			[ 'MediaWiki:Foo.css.xxx', false ],
			[ 'TEST-JS:Foo', false ],
			[ 'TEST-JS:Foo.js', false ],
		];
	}

	/**
	 * @dataProvider provideIsUserConfigPage
	 * @covers \MediaWiki\Title\Title::isUserConfigPage
	 * @covers \MediaWiki\Title\Title::isUserJsConfigPage
	 * @covers \MediaWiki\Title\Title::isUserJsonConfigPage
	 * @covers \MediaWiki\Title\Title::isUserCssConfigPage
	 */
	public function testIsUserConfigPage( $title, $expected ) {
		$title = Title::newFromText( $title );

		// $expected is either false or the relevant type ('javascript', 'json', 'css')
		$this->assertSame(
			$expected !== false,
			$title->isUserConfigPage()
		);
		$this->assertSame(
			$expected === 'javascript',
			$title->isUserJsConfigPage()
		);
		$this->assertSame(
			$expected === 'json',
			$title->isUserJsonConfigPage()
		);
		$this->assertSame(
			$expected === 'css',
			$title->isUserCssConfigPage()
		);
	}

	public static function provideIsWikitextPage() {
		return [
			[ 'Help:Foo', true ],
			[ 'Help:Foo.js', true ],
			[ 'Help:Foo/bar.js', true ],
			[ 'User:Foo', true ],
			[ 'User:Foo.js', true ],
			[ 'User:Foo/bar.js', false ],
			[ 'User:Foo/bar.json', false ],
			[ 'User:Foo/bar.css', false ],
			[ 'User talk:Foo/bar.css', true ],
			[ 'User:Foo/bar.js.xxx', true ],
			[ 'User:Foo/bar.xxx', true ],
			[ 'MediaWiki:Foo.js', false ],
			[ 'User:Foo/bar.JS', true ],
			[ 'User:Foo/bar.JSON', true ],
			[ 'User:Foo/bar.CSS', true ],
			[ 'MediaWiki:Foo.json', false ],
			[ 'MediaWiki:Foo.css', false ],
			[ 'MediaWiki:Foo.JS', true ],
			[ 'MediaWiki:Foo.JSON', true ],
			[ 'MediaWiki:Foo.CSS', true ],
			[ 'MediaWiki:Foo.css.xxx', true ],
			[ 'TEST-JS:Foo', false ],
			[ 'TEST-JS:Foo.js', false ],
		];
	}

	/**
	 * @dataProvider provideIsWikitextPage
	 * @covers \MediaWiki\Title\Title::isWikitextPage
	 */
	public function testIsWikitextPage( $title, $expectedBool ) {
		$title = Title::newFromText( $title );
		$this->assertEquals( $expectedBool, $title->isWikitextPage() );
	}

	public static function provideGetOtherPage() {
		return [
			[ 'Main Page', 'Talk:Main Page' ],
			[ 'Talk:Main Page', 'Main Page' ],
			[ 'Help:Main Page', 'Help talk:Main Page' ],
			[ 'Help talk:Main Page', 'Help:Main Page' ],
			[ 'Special:FooBar', null ],
			[ 'Media:File.jpg', null ],
		];
	}

	/**
	 * @dataProvider provideGetOtherpage
	 * @covers \MediaWiki\Title\Title::getOtherPage
	 *
	 * @param string $text
	 * @param string|null $expected
	 */
	public function testGetOtherPage( $text, $expected ) {
		if ( $expected === null ) {
			$this->expectException( MWException::class );
		}

		$title = Title::newFromText( $text );
		$this->assertEquals( $expected, $title->getOtherPage()->getPrefixedText() );
	}

	/**
	 * @covers \MediaWiki\Title\Title::clearCaches
	 */
	public function testClearCaches() {
		$linkCache = $this->getServiceContainer()->getLinkCache();

		$title1 = Title::newFromText( 'Foo' );
		$this->addGoodLinkObject( 23, $title1 );

		Title::clearCaches();

		$title2 = Title::newFromText( 'Foo' );
		$this->assertNotSame( $title1, $title2, 'title cache should be empty' );
		$this->assertSame( 0, $linkCache->getGoodLinkID( 'Foo' ), 'link cache should be empty' );
	}

	/**
	 * @covers \MediaWiki\Title\Title::getFieldFromPageStore
	 */
	public function testUseCaches() {
		$title1 = Title::makeTitle( NS_MAIN, __METHOD__ . '998724352' );
		$this->addGoodLinkObject( 23, $title1, 7, 0, 42 );

		// Ensure that getLatestRevID uses the LinkCache even after
		// the article ID is known (T296063#7520023).
		$this->assertSame( 23, $title1->getArticleID() );
		$this->assertSame( 42, $title1->getLatestRevID() );
	}

	public static function provideGetLinkURL() {
		yield 'Simple' => [
			'/wiki/Goats',
			NS_MAIN,
			'Goats'
		];

		yield 'Fragment' => [
			'/wiki/Goats#Goatificatiön',
			NS_MAIN,
			'Goats',
			'Goatificatiön'
		];

		yield 'Fragment only (query is ignored)' => [
			'#Goatificatiön',
			NS_MAIN,
			'',
			'Goatificatiön',
			'',
			[
				'a' => 1,
			]
		];

		yield 'Unknown interwiki with fragment' => [
			'https://xx.wiki.test/wiki/xyzzy:Goats#Goatificatiön',
			NS_MAIN,
			'Goats',
			'Goatificatiön',
			'xyzzy'
		];

		yield 'Interwiki with fragment' => [
			'https://acme.test/Goats#Goatificati.C3.B6n',
			NS_MAIN,
			'Goats',
			'Goatificatiön',
			'acme'
		];

		yield 'Interwiki with query' => [
			'https://acme.test/Goats?a=1&b=blank+blank#Goatificati.C3.B6n',
			NS_MAIN,
			'Goats',
			'Goatificatiön',
			'acme',
			[
				'a' => 1,
				'b' => 'blank blank'
			]
		];

		yield 'Local interwiki with fragment' => [
			'https://yy.wiki.test/wiki/Goats#Goatificatiön',
			NS_MAIN,
			'Goats',
			'Goatificatiön',
			'yy'
		];
	}

	/**
	 * @dataProvider provideGetLinkURL
	 *
	 * @covers \MediaWiki\Title\Title::getLinkURL
	 * @covers \MediaWiki\Title\Title::getFullURL
	 * @covers \MediaWiki\Title\Title::getLocalURL
	 * @covers \MediaWiki\Title\Title::getFragmentForURL
	 */
	public function testGetLinkURL(
		$expected,
		$ns,
		$title,
		$fragment = '',
		$interwiki = '',
		$query = '',
		$query2 = false,
		$proto = false
	) {
		$this->overrideConfigValues( [
			MainConfigNames::Server => 'https://xx.wiki.test',
			MainConfigNames::ExternalInterwikiFragmentMode => 'legacy',
			MainConfigNames::FragmentMode => [ 'html5', 'legacy' ]
		] );

		$title = Title::makeTitle( $ns, $title, $fragment, $interwiki );
		$this->assertSame( $expected, $title->getLinkURL( $query, $query2, $proto ) );
	}

	public static function provideProperPage() {
		return [
			[ NS_MAIN, 'Test' ],
			[ NS_MAIN, 'User' ],
		];
	}

	/**
	 * @dataProvider provideProperPage
	 * @covers \MediaWiki\Title\Title::toPageIdentity
	 */
	public function testToPageIdentity( $ns, $text ) {
		$title = Title::makeTitle( $ns, $text );

		$page = $title->toPageIdentity();

		$this->assertNotSame( $title, $page );
		$this->assertSame( $title->getId(), $page->getId() );
		$this->assertSame( $title->getNamespace(), $page->getNamespace() );
		$this->assertSame( $title->getDBkey(), $page->getDBkey() );
		$this->assertSame( $title->getWikiId(), $page->getWikiId() );
	}

	/**
	 * @dataProvider provideProperPage
	 * @covers \MediaWiki\Title\Title::toPageRecord
	 */
	public function testToPageRecord( $ns, $text ) {
		$title = Title::makeTitle( $ns, $text );
		$wikiPage = $this->getExistingTestPage( $title );

		$record = $title->toPageRecord();

		$this->assertNotSame( $title, $record );
		$this->assertNotSame( $title, $wikiPage );

		$this->assertSame( $title->getId(), $record->getId() );
		$this->assertSame( $title->getNamespace(), $record->getNamespace() );
		$this->assertSame( $title->getDBkey(), $record->getDBkey() );
		$this->assertSame( $title->getWikiId(), $record->getWikiId() );

		$this->assertSame( $title->getLatestRevID(), $record->getLatest() );
		$this->assertSame( MWTimestamp::convert( TS_MW, $title->getTouched() ), $record->getTouched() );
		$this->assertSame( $title->isNewPage(), $record->isNew() );
		$this->assertSame( $title->isRedirect(), $record->isRedirect() );
		$this->assertSame( $title->getTouched(), $record->getTouched() );
	}

	/**
	 * @dataProvider provideImproperPage
	 * @covers \MediaWiki\Title\Title::toPageRecord
	 */
	public function testToPageRecord_fail( $ns, $text, $fragment = '', $interwiki = '' ) {
		$title = Title::makeTitle( $ns, $text, $fragment, $interwiki );

		$this->expectException( PreconditionException::class );
		$title->toPageRecord();
	}

	public static function provideImproperPage() {
		return [
			[ NS_MAIN, '' ],
			[ NS_MAIN, '<>' ],
			[ NS_MAIN, '|' ],
			[ NS_MAIN, '#' ],
			[ NS_PROJECT, '#test' ],
			[ NS_MAIN, '', 'test', 'acme' ],
			[ NS_MAIN, ' Test' ],
			[ NS_MAIN, '_Test' ],
			[ NS_MAIN, 'Test ' ],
			[ NS_MAIN, 'Test_' ],
			[ NS_MAIN, "Test\nthis" ],
			[ NS_MAIN, "Test\tthis" ],
			[ -33, 'Test' ],
			[ 77663399, 'Test' ],

			// Valid but can't exist
			[ NS_MAIN, '', 'test' ],
			[ NS_SPECIAL, 'Test' ],
			[ NS_MAIN, 'Test', '', 'acme' ],
		];
	}

	/**
	 * @dataProvider provideImproperPage
	 * @covers \MediaWiki\Title\Title::getId
	 */
	public function testGetId_fail( $ns, $text, $fragment = '', $interwiki = '' ) {
		$title = Title::makeTitle( $ns, $text, $fragment, $interwiki );

		$this->expectException( PreconditionException::class );
		$title->getId();
	}

	/**
	 * @dataProvider provideImproperPage
	 * @covers \MediaWiki\Title\Title::getId
	 */
	public function testGetId_fragment() {
		$title = Title::makeTitle( NS_MAIN, 'Test', 'References' );

		// should not throw
		$this->assertIsInt( $title->getId() );
	}

	/**
	 * @dataProvider provideImproperPage
	 * @covers \MediaWiki\Title\Title::toPageIdentity
	 */
	public function testToPageIdentity_fail( $ns, $text, $fragment = '', $interwiki = '' ) {
		$title = Title::makeTitle( $ns, $text, $fragment, $interwiki );

		$this->expectException( PreconditionException::class );
		$title->toPageIdentity();
	}

	public static function provideMakeTitle() {
		yield 'main namespace' => [ 'Foo', NS_MAIN, 'Foo' ];
		yield 'user namespace' => [ 'User:Foo', NS_USER, 'Foo' ];
		yield 'fragment' => [ 'Foo#Section', NS_MAIN, 'Foo', 'Section' ];
		yield 'only fragment' => [ '#Section', NS_MAIN, '', 'Section' ];
		yield 'interwiki' => [ 'acme:Foo', NS_MAIN, 'Foo', '', 'acme' ];
		yield 'normalized underscores' => [ 'Foo Bar', NS_MAIN, 'Foo_Bar' ];
	}

	/**
	 * @dataProvider provideMakeTitle
	 * @covers \MediaWiki\Title\Title::makeTitle
	 */
	public function testMakeTitle( $expected, $ns, $text, $fragment = '', $interwiki = '' ) {
		$title = Title::makeTitle( $ns, $text, $fragment, $interwiki );

		$this->assertTrue( $title->isValid() );
		$this->assertSame( $expected, $title->getFullText() );
	}

	public static function provideMakeTitle_invalid() {
		yield 'bad namespace' => [ 'Special:Badtitle/NS-1234:Foo', -1234, 'Foo' ];
		yield 'lower case' => [ 'User:foo', NS_USER, 'foo' ];
		yield 'empty' => [ '', NS_MAIN, '' ];
		yield 'bad character' => [ 'Foo|Bar', NS_MAIN, 'Foo|Bar' ];
		yield 'bad interwiki' => [ 'qwerty:Foo', NS_MAIN, 'Foo', '', 'qwerty' ];
	}

	/**
	 * @dataProvider provideMakeTitle_invalid
	 * @covers \MediaWiki\Title\Title::makeTitle
	 */
	public function testMakeTitle_invalid( $expected, $ns, $text, $fragment = '', $interwiki = '' ) {
		$title = Title::makeTitle( $ns, $text, $fragment, $interwiki );

		$this->assertFalse( $title->isValid() );
		$this->assertSame( $expected, $title->getFullText() );
	}

	public static function provideMakeName() {
		yield 'main namespace' => [ 'Foo', NS_MAIN, 'Foo' ];
		yield 'user namespace' => [ 'User:Foo', NS_USER, 'Foo' ];
		yield 'fragment' => [ 'Foo#Section', NS_MAIN, 'Foo', 'Section' ];
		yield 'only fragment' => [ '#Section', NS_MAIN, '', 'Section' ];
		yield 'interwiki' => [ 'acme:Foo', NS_MAIN, 'Foo', '', 'acme' ];
		yield 'bad namespace' => [ 'Special:Badtitle/NS-1234:Foo', -1234, 'Foo' ];
		yield 'lower case' => [ 'User:foo', NS_USER, 'foo' ];
		yield 'empty' => [ '', NS_MAIN, '' ];
		yield 'bad character' => [ 'Foo|Bar', NS_MAIN, 'Foo|Bar' ];
		yield 'bad interwiki' => [ 'qwerty:Foo', NS_MAIN, 'Foo', '', 'qwerty' ];
	}

	/**
	 * @dataProvider provideMakeName
	 * @covers \MediaWiki\Title\Title::makeName
	 */
	public function testMakeName( $expected, $ns, $text, $fragment = '', $interwiki = '' ) {
		$titleName = Title::makeName( $ns, $text, $fragment, $interwiki );

		$this->assertSame( $expected, $titleName );
	}

	public static function provideMakeTitleSafe() {
		yield 'main namespace' => [ 'Foo', NS_MAIN, 'Foo' ];
		yield 'user namespace' => [ 'User:Foo', NS_USER, 'Foo' ];
		yield 'fragment' => [ 'Foo#Section', NS_MAIN, 'Foo', 'Section' ];
		yield 'only fragment' => [ '#Section', NS_MAIN, '', 'Section' ];
		yield 'interwiki' => [ 'acme:Foo', NS_MAIN, 'Foo', '', 'acme' ];

		// Normalize
		yield 'normalized underscores' => [ 'Foo Bar', NS_MAIN, 'Foo_Bar' ];
		yield 'lower case' => [ 'User:Foo', NS_USER, 'foo' ];

		// Bad interwiki becomes part of the title text. Is this intentional?
		yield 'bad interwiki' => [ 'Qwerty:Foo', NS_MAIN, 'Foo', '', 'qwerty' ];
	}

	/**
	 * @dataProvider provideMakeTitleSafe
	 * @covers \MediaWiki\Title\Title::makeTitleSafe
	 */
	public function testMakeTitleSafe( $expected, $ns, $text, $fragment = '', $interwiki = '' ) {
		$title = Title::makeTitleSafe( $ns, $text, $fragment, $interwiki );

		$this->assertTrue( $title->isValid() );
		$this->assertSame( $expected, $title->getFullText() );
	}

	public static function provideMakeTitleSafe_invalid() {
		yield 'bad namespace' => [ -1234, 'Foo' ];
		yield 'empty' => [ '', NS_MAIN, '' ];
		yield 'bad character' => [ NS_MAIN, 'Foo|Bar' ];
	}

	/**
	 * @dataProvider provideMakeTitleSafe_invalid
	 * @covers \MediaWiki\Title\Title::makeTitleSafe
	 */
	public function testMakeTitleSafe_invalid( $ns, $text, $fragment = '', $interwiki = '' ) {
		$title = Title::makeTitleSafe( $ns, $text, $fragment, $interwiki );

		$this->assertNull( $title );
	}

	/**
	 * @covers \MediaWiki\Title\Title::getContentModel
	 * @covers \MediaWiki\Title\Title::setContentModel
	 * @covers \MediaWiki\Title\Title::uncache
	 */
	public function testSetContentModel() {
		// NOTE: must use newFromText to test behavior of internal instance cache (T281337)
		$title = Title::newFromText( 'Foo' );

		$title->setContentModel( CONTENT_MODEL_UNKNOWN );
		$this->assertSame( CONTENT_MODEL_UNKNOWN, $title->getContentModel() );

		// Ensure that the instance we get back from newFromText isn't the modified one.
		$title = Title::newFromText( 'Foo' );
		$this->assertNotSame( CONTENT_MODEL_UNKNOWN, $title->getContentModel() );
	}

	/**
	 * @covers \MediaWiki\Title\Title::newFromID
	 * @covers \MediaWiki\Title\Title::newFromRow
	 */
	public function testNewFromId() {
		// First id
		$existingPage1 = $this->getExistingTestPage( 'UTest1' );
		$existingTitle1 = $existingPage1->getTitle();
		$existingId1 = $existingTitle1->getId();

		$this->assertGreaterThan( 0, $existingId1, 'Existing test page should have a positive id' );

		$newFromId1 = Title::newFromID( $existingId1 );
		$this->assertInstanceOf( Title::class, $newFromId1, 'newFromID returns a title for an existing id' );
		$this->assertTrue(
			$newFromId1->equals( $existingTitle1 ),
			'newFromID returns the correct title'
		);

		// Second id
		$existingPage2 = $this->getExistingTestPage( 'UTest2' );
		$existingTitle2 = $existingPage2->getTitle();
		$existingId2 = $existingTitle2->getId();

		$this->assertGreaterThan( 0, $existingId2, 'Existing test page should have a positive id' );

		$newFromId2 = Title::newFromID( $existingId2 );
		$this->assertInstanceOf( Title::class, $newFromId2, 'newFromID returns a title for an existing id' );
		$this->assertTrue(
			$newFromId2->equals( $existingTitle2 ),
			'newFromID returns the correct title'
		);
	}

	/**
	 * @covers \MediaWiki\Title\Title::newFromID
	 */
	public function testNewFromMissingId() {
		// Testing return of null for an id that does not exist
		$maxPageId = (int)$this->getDb()->newSelectQueryBuilder()
			->select( 'max(page_id)' )
			->from( 'page' )
			->caller( __METHOD__ )->fetchField();
		$res = Title::newFromID( $maxPageId + 1 );
		$this->assertNull( $res, 'newFromID returns null for missing ids' );
	}

	public static function provideValidSecureAndSplit() {
		return [
			[ 'Sandbox' ],
			[ 'A "B"' ],
			[ 'A \'B\'' ],
			[ '.com' ],
			[ '~' ],
			[ '#' ],
			[ '"' ],
			[ '\'' ],
			[ 'Talk:Sandbox' ],
			[ 'Talk:Foo:Sandbox' ],
			[ 'File:Example.svg' ],
			[ 'File_talk:Example.svg' ],
			[ 'Foo/.../Sandbox' ],
			[ 'Sandbox/...' ],
			[ 'A~~' ],
			[ ':A' ],
			// Length is 256 total, but only title part matters
			[ 'Category:' . str_repeat( 'x', 248 ) ],
			[ str_repeat( 'x', 252 ) ],
			// interwiki prefix
			[ 'localtestiw: #anchor' ],
			[ 'localtestiw:' ],
			[ 'localtestiw:foo' ],
			[ 'localtestiw: foo # anchor' ],
			[ 'localtestiw: Talk: Sandbox # anchor' ],
			[ 'remotetestiw:' ],
			[ 'remotetestiw: Talk: # anchor' ],
			[ 'remotetestiw: #bar' ],
			[ 'remotetestiw: Talk:' ],
			[ 'remotetestiw: Talk: Foo' ],
			[ 'localtestiw:remotetestiw:' ],
			[ 'localtestiw:remotetestiw:foo' ]
		];
	}

	public static function provideInvalidSecureAndSplit() {
		return [
			[ '', 'title-invalid-empty' ],
			[ ':', 'title-invalid-empty' ],
			[ '__  __', 'title-invalid-empty' ],
			[ '  __  ', 'title-invalid-empty' ],
			// Bad characters forbidden regardless of wgLegalTitleChars
			[ 'A [ B', 'title-invalid-characters' ],
			[ 'A ] B', 'title-invalid-characters' ],
			[ 'A { B', 'title-invalid-characters' ],
			[ 'A } B', 'title-invalid-characters' ],
			[ 'A < B', 'title-invalid-characters' ],
			[ 'A > B', 'title-invalid-characters' ],
			[ 'A | B', 'title-invalid-characters' ],
			[ "A \t B", 'title-invalid-characters' ],
			[ "A \n B", 'title-invalid-characters' ],
			// URL encoding
			[ 'A%20B', 'title-invalid-characters' ],
			[ 'A%23B', 'title-invalid-characters' ],
			[ 'A%2523B', 'title-invalid-characters' ],
			// XML/HTML character entity references
			// Note: Commented out because they are not marked invalid by the PHP test as
			// Title::newFromText runs Sanitizer::decodeCharReferencesAndNormalize first.
			// 'A &eacute; B',
			// Subject of NS_TALK does not roundtrip to NS_MAIN
			[ 'Talk:File:Example.svg', 'title-invalid-talk-namespace' ],
			// Directory navigation
			[ '.', 'title-invalid-relative' ],
			[ '..', 'title-invalid-relative' ],
			[ './Sandbox', 'title-invalid-relative' ],
			[ '../Sandbox', 'title-invalid-relative' ],
			[ 'Foo/./Sandbox', 'title-invalid-relative' ],
			[ 'Foo/../Sandbox', 'title-invalid-relative' ],
			[ 'Sandbox/.', 'title-invalid-relative' ],
			[ 'Sandbox/..', 'title-invalid-relative' ],
			// Tilde
			[ 'A ~~~ Name', 'title-invalid-magic-tilde' ],
			[ 'A ~~~~ Signature', 'title-invalid-magic-tilde' ],
			[ 'A ~~~~~ Timestamp', 'title-invalid-magic-tilde' ],
			// Length
			[ str_repeat( 'x', 256 ), 'title-invalid-too-long' ],
			// Namespace prefix without actual title
			[ 'Talk:', 'title-invalid-empty' ],
			[ 'Talk:#', 'title-invalid-empty' ],
			[ 'Category: ', 'title-invalid-empty' ],
			[ 'Category: #bar', 'title-invalid-empty' ],
			// interwiki prefix
			[ 'localtestiw: Talk: # anchor', 'title-invalid-empty' ],
			[ 'localtestiw: Talk:', 'title-invalid-empty' ]
		];
	}

	/**
	 * See also mediawiki.Title.test.js
	 * @covers \MediaWiki\Title\Title::secureAndSplit
	 * @dataProvider provideValidSecureAndSplit
	 * @note This mainly tests MediaWikiTitleCodec::parseTitle().
	 */
	public function testSecureAndSplitValid( $text ) {
		$this->assertInstanceOf( Title::class, Title::newFromText( $text ), "Valid: $text" );
	}

	/**
	 * See also mediawiki.Title.test.js
	 * @covers \MediaWiki\Title\Title::secureAndSplit
	 * @dataProvider provideInvalidSecureAndSplit
	 * @note This mainly tests MediaWikiTitleCodec::parseTitle().
	 */
	public function testSecureAndSplitInvalid( $text, $expectedErrorMessage ) {
		try {
			Title::newFromTextThrow( $text ); // should throw
			$this->fail( "Title::newFromTextThrow should have thrown with $text" );
		} catch ( MalformedTitleException $ex ) {
			$this->assertEquals( $expectedErrorMessage, $ex->getErrorMessage(), "Invalid: $text" );
		}
	}

	public static function provideSpecialNamesWithAndWithoutParameter() {
		return [
			[ 'Special:Version', null ],
			[ 'Special:Version/', '' ],
			[ 'Special:Version/param', 'param' ],
		];
	}

	/**
	 * @dataProvider provideSpecialNamesWithAndWithoutParameter
	 * @covers \MediaWiki\Title\Title::fixSpecialName
	 */
	public function testFixSpecialNameRetainsParameter( $text, $expectedParam ) {
		$title = Title::newFromText( $text );
		$fixed = $title->fixSpecialName();
		$stuff = explode( '/', $fixed->getDBkey(), 2 );
		$par = $stuff[1] ?? null;
		$this->assertEquals(
			$expectedParam,
			$par,
			"T33100 regression check: Title->fixSpecialName() should preserve parameter"
		);
	}

	public function flattenErrorsArray( $errors ) {
		$result = [];
		foreach ( $errors as $error ) {
			$result[] = $error[0];
		}

		return $result;
	}

	public static function provideGetPageViewLanguage() {
		# Format:
		# - expected
		# - Title name
		# - content language (expected in most cases)
		# - wgLang (on some specific pages)
		# - wgDefaultLanguageVariant
		return [
			[ 'fr', 'Help:I_need_somebody', 'fr', 'fr', false ],
			[ 'es', 'Help:I_need_somebody', 'es', 'zh-tw', false ],
			[ 'zh', 'Help:I_need_somebody', 'zh', 'zh-tw', false ],

			[ 'es', 'Help:I_need_somebody', 'es', 'zh-tw', 'zh-cn' ],
			[ 'es', 'MediaWiki:About', 'es', 'zh-tw', 'zh-cn' ],
			[ 'es', 'MediaWiki:About/', 'es', 'zh-tw', 'zh-cn' ],
			[ 'de', 'MediaWiki:About/de', 'es', 'zh-tw', 'zh-cn' ],
			[ 'en', 'MediaWiki:Common.js', 'es', 'zh-tw', 'zh-cn' ],
			[ 'en', 'MediaWiki:Common.css', 'es', 'zh-tw', 'zh-cn' ],
			[ 'en', 'User:JohnDoe/Common.js', 'es', 'zh-tw', 'zh-cn' ],
			[ 'en', 'User:JohnDoe/Monobook.css', 'es', 'zh-tw', 'zh-cn' ],

			[ 'zh-cn', 'Help:I_need_somebody', 'zh', 'zh-tw', 'zh-cn' ],
			[ 'zh', 'MediaWiki:About', 'zh', 'zh-tw', 'zh-cn' ],
			[ 'zh', 'MediaWiki:About/', 'zh', 'zh-tw', 'zh-cn' ],
			[ 'de', 'MediaWiki:About/de', 'zh', 'zh-tw', 'zh-cn' ],
			[ 'zh-cn', 'MediaWiki:About/zh-cn', 'zh', 'zh-tw', 'zh-cn' ],
			[ 'zh-tw', 'MediaWiki:About/zh-tw', 'zh', 'zh-tw', 'zh-cn' ],
			[ 'en', 'MediaWiki:Common.js', 'zh', 'zh-tw', 'zh-cn' ],
			[ 'en', 'MediaWiki:Common.css', 'zh', 'zh-tw', 'zh-cn' ],
			[ 'en', 'User:JohnDoe/Common.js', 'zh', 'zh-tw', 'zh-cn' ],
			[ 'en', 'User:JohnDoe/Monobook.css', 'zh', 'zh-tw', 'zh-cn' ],

			[ 'zh-tw', 'Special:NewPages', 'es', 'zh-tw', 'zh-cn' ],
			[ 'zh-tw', 'Special:NewPages', 'zh', 'zh-tw', 'zh-cn' ],

		];
	}

	/**
	 * Superseded by OutputPageTest::testGetJsVarsAboutPageLang
	 *
	 * @dataProvider provideGetPageViewLanguage
	 * @covers \MediaWiki\Title\Title::getPageViewLanguage
	 */
	public function testGetPageViewLanguage( $expected, $titleText, $contLang,
		$lang, $variant, $msg = ''
	) {
		// Setup environment for this test
		$this->overrideConfigValues( [
			MainConfigNames::DefaultLanguageVariant => $variant,
			MainConfigNames::AllowUserJs => true,
		] );
		$this->setUserLang( $lang );
		$this->overrideConfigValue( MainConfigNames::LanguageCode, $contLang );

		$title = Title::newFromText( $titleText );
		$this->assertInstanceOf( Title::class, $title,
			"Test must be passed a valid title text, you gave '$titleText'"
		);
		$this->hideDeprecated( Title::class . '::getPageViewLanguage' );
		$this->assertEquals( $expected,
			$title->getPageViewLanguage()->getCode(),
			$msg
		);
	}

	public static function provideSubpage() {
		// NOTE: avoid constructing Title objects in the provider, since it may access the database.
		return [
			[ 'Foo', 'x', new TitleValue( NS_MAIN, 'Foo/x' ) ],
			[ 'Foo#bar', 'x', new TitleValue( NS_MAIN, 'Foo/x' ) ],
			[ 'User:Foo', 'x', new TitleValue( NS_USER, 'Foo/x' ) ],
			[ 'wiki:User:Foo', 'x', new TitleValue( NS_MAIN, 'User:Foo/x', '', 'wiki' ) ],
		];
	}

	/**
	 * @dataProvider provideSubpage
	 * @covers \MediaWiki\Title\Title::getSubpage
	 */
	public function testSubpage( $title, $sub, LinkTarget $expected ) {
		$title = Title::newFromText( $title );
		$expected = Title::newFromLinkTarget( $expected );
		$actual = $title->getSubpage( $sub );

		// NOTE: convert to string for comparison
		$this->assertSame( $expected->getPrefixedText(), $actual->getPrefixedText(), 'text form' );
		$this->assertTrue( $expected->equals( $actual ), 'Title equality' );
	}

	public static function provideIsAlwaysKnown() {
		return [
			[ 'Some nonexistent page' . wfRandomString(), false ],
			[ 'Some existent page', false, true ],
			[ '#test', true ],
			[ 'Special:BlankPage', true ],
			[ 'Special:SomeNonexistentSpecialPage', false ],
			[ 'MediaWiki:Parentheses', true ],
			[ 'MediaWiki:Some nonexistent message', false ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::isAlwaysKnown
	 * @dataProvider provideIsAlwaysKnown
	 * @param string $page
	 * @param bool $isKnown
	 * @param bool $createIfNotExists
	 */
	public function testIsAlwaysKnown( $page, $isKnown, bool $createIfNotExists = false ) {
		if ( $createIfNotExists ) {
			$this->getExistingTestPage( $page );
		}
		$title = Title::newFromText( $page );
		$this->assertEquals( $isKnown, $title->isAlwaysKnown() );
	}

	public static function provideIsValid() {
		return [
			[ Title::makeTitle( NS_MAIN, '' ), false ],
			[ Title::makeTitle( NS_MAIN, '<>' ), false ],
			[ Title::makeTitle( NS_MAIN, '|' ), false ],
			[ Title::makeTitle( NS_MAIN, '#' ), false ],
			[ Title::makeTitle( NS_PROJECT, '#' ), false ],
			[ Title::makeTitle( NS_MAIN, '', 'test' ), true ],
			[ Title::makeTitle( NS_PROJECT, '#test' ), false ],
			[ Title::makeTitle( NS_MAIN, '', 'test', 'wikipedia' ), true ],
			[ Title::makeTitle( NS_MAIN, 'Test', '', 'wikipedia' ), true ],
			[ Title::makeTitle( NS_MAIN, 'Test' ), true ],
			[ Title::makeTitle( NS_SPECIAL, 'Test' ), true ],
			[ Title::makeTitle( NS_MAIN, ' Test' ), false ],
			[ Title::makeTitle( NS_MAIN, '_Test' ), false ],
			[ Title::makeTitle( NS_MAIN, 'Test ' ), false ],
			[ Title::makeTitle( NS_MAIN, 'Test_' ), false ],
			[ Title::makeTitle( NS_MAIN, "Test\nthis" ), false ],
			[ Title::makeTitle( NS_MAIN, "Test\tthis" ), false ],
			[ Title::makeTitle( -33, 'Test' ), false ],
			[ Title::makeTitle( 77663399, 'Test' ), false ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::isValid
	 * @dataProvider provideIsValid
	 * @param Title $title
	 * @param bool $isValid
	 */
	public function testIsValid( Title $title, $isValid ) {
		$this->assertEquals( $isValid, $title->isValid(), $title->getFullText() );
	}

	public static function provideIsValidRedirectTarget() {
		return [
			[ Title::makeTitle( NS_MAIN, '' ), false ],
			[ Title::makeTitle( NS_MAIN, '', 'test' ), false ],
			[ Title::makeTitle( NS_MAIN, 'Foo', 'test' ), true ],
			[ Title::makeTitle( NS_MAIN, '<>' ), false ],
			[ Title::makeTitle( NS_MAIN, '_' ), false ],
			[ Title::makeTitle( NS_MAIN, 'Test', '', 'acme' ), true ],
			[ Title::makeTitle( NS_SPECIAL, 'UserLogout' ), false ],
			[ Title::makeTitle( NS_SPECIAL, 'RecentChanges' ), true ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::isValidRedirectTarget
	 * @dataProvider provideIsValidRedirectTarget
	 * @param Title $title
	 * @param bool $isValid
	 */
	public function testIsValidRedirectTarget( Title $title, $isValid ) {
		// InterwikiLookup is configured in setUp()
		$this->assertEquals( $isValid, $title->isValidRedirectTarget(), $title->getFullText() );
	}

	public static function provideCanExist() {
		return [
			[ Title::makeTitle( NS_MAIN, '' ), false ],
			[ Title::makeTitle( NS_MAIN, '<>' ), false ],
			[ Title::makeTitle( NS_MAIN, '|' ), false ],
			[ Title::makeTitle( NS_MAIN, '#' ), false ],
			[ Title::makeTitle( NS_PROJECT, '#test' ), false ],
			[ Title::makeTitle( NS_MAIN, '', 'test', 'acme' ), false ],
			[ Title::makeTitle( NS_MAIN, 'Test' ), true ],
			[ Title::makeTitle( NS_MAIN, ' Test' ), false ],
			[ Title::makeTitle( NS_MAIN, '_Test' ), false ],
			[ Title::makeTitle( NS_MAIN, 'Test ' ), false ],
			[ Title::makeTitle( NS_MAIN, 'Test_' ), false ],
			[ Title::makeTitle( NS_MAIN, "Test\nthis" ), false ],
			[ Title::makeTitle( NS_MAIN, "Test\tthis" ), false ],
			[ Title::makeTitle( -33, 'Test' ), false ],
			[ Title::makeTitle( 77663399, 'Test' ), false ],

			// Valid but can't exist
			[ Title::makeTitle( NS_MAIN, '', 'test' ), false ],
			[ Title::makeTitle( NS_SPECIAL, 'Test' ), false ],
			[ Title::makeTitle( NS_MAIN, 'Test', '', 'acme' ), false ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::canExist
	 * @dataProvider provideCanExist
	 * @param Title $title
	 * @param bool $canExist
	 */
	public function testCanExist( Title $title, $canExist ) {
		$this->assertEquals( $canExist, $title->canExist(), $title->getFullText() );
	}

	/**
	 * @covers \MediaWiki\Title\Title::isAlwaysKnown
	 */
	public function testIsAlwaysKnownOnInterwiki() {
		$title = Title::makeTitle( NS_MAIN, 'Interwiki link', '', 'externalwiki' );
		$this->assertTrue( $title->isAlwaysKnown() );
	}

	public static function provideGetSkinFromConfigSubpage() {
		return [
			[ 'User:Foo', '' ],
			[ 'User:Foo.css', '' ],
			[ 'User:Foo/', '' ],
			[ 'User:Foo/bar', '' ],
			[ 'User:Foo./bar', '' ],
			[ 'User:Foo/bar.', 'bar' ],
			[ 'User:Foo/bar.css', 'bar' ],
			[ '/bar.css', '' ],
			[ '//bar.css', 'bar' ],
			[ '.css', '' ],
		];
	}

	/**
	 * @dataProvider provideGetSkinFromConfigSubpage
	 * @covers \MediaWiki\Title\Title::getSkinFromConfigSubpage
	 */
	public function testGetSkinFromConfigSubpage( $title, $expected ) {
		$title = Title::newFromText( $title );
		$this->assertSame( $expected, $title->getSkinFromConfigSubpage() );
	}

	/**
	 * @covers \MediaWiki\Title\Title::getWikiId
	 */
	public function testGetWikiId() {
		$title = Title::newFromText( 'Foo' );
		$this->assertFalse( $title->getWikiId() );
	}

	/**
	 * @covers \MediaWiki\Title\Title::getFragment
	 * @covers \MediaWiki\Title\Title::getFragment
	 * @covers \MediaWiki\Title\Title::uncache
	 */
	public function testSetFragment() {
		// NOTE: must use newFromText to test behavior of internal instance cache (T281337)
		$title = Title::newFromText( 'Foo' );

		$title->setFragment( '#Xyzzy' );
		$this->assertSame( 'Xyzzy', $title->getFragment() );

		// Ensure that the instance we get back from newFromText isn't the modified one.
		$title = Title::newFromText( 'Foo' );
		$this->assertNotSame( 'Xyzzy', $title->getFragment() );
	}

	/**
	 * @covers \MediaWiki\Title\Title::__clone
	 */
	public function testClone() {
		// NOTE: must use newFromText to test behavior of internal instance cache (T281337)
		$title = Title::newFromText( 'Foo' );

		$clone = clone $title;
		$clone->setFragment( '#Xyzzy' );

		// Ensure that the instance we get back from newFromText is the original one
		$title2 = Title::newFromText( 'Foo' );
		$this->assertSame( $title, $title2 );
	}

	public static function provideBaseTitleCases() {
		return [
			# Namespace, Title text, expected base
			[ NS_USER, 'John_Doe', 'John Doe' ],
			[ NS_USER, 'John_Doe/subOne/subTwo', 'John Doe/subOne' ],
			[ NS_USER, 'Foo / Bar / Baz', 'Foo / Bar ' ],
			[ NS_USER, 'Foo/', 'Foo' ],
			[ NS_USER, 'Foo/Bar/', 'Foo/Bar' ],
			[ NS_USER, '/', '/' ],
			[ NS_USER, '//', '/' ],
			[ NS_USER, '/oops/', '/oops' ],
			[ NS_USER, '/indeed', '/indeed' ],
			[ NS_USER, '//indeed', '/' ],
			[ NS_USER, '/Ramba/Zamba/Mamba/', '/Ramba/Zamba/Mamba' ],
			[ NS_USER, '//x//y//z//', '//x//y//z/' ],
		];
	}

	/**
	 * @dataProvider provideBaseTitleCases
	 * @covers \MediaWiki\Title\Title::getBaseText
	 */
	public function testGetBaseText( $namespace, $title, $expected ) {
		$title = Title::makeTitle( $namespace, $title );
		$this->assertSame( $expected, $title->getBaseText() );
	}

	/**
	 * @dataProvider provideBaseTitleCases
	 * @covers \MediaWiki\Title\Title::getBaseTitle
	 */
	public function testGetBaseTitle( $namespace, $title, $expected ) {
		$title = Title::makeTitle( $namespace, $title );
		$base = $title->getBaseTitle();
		$this->assertTrue( $base->isValid() );
		$this->assertTrue(
			$base->equals( Title::makeTitleSafe( $title->getNamespace(), $expected ) )
		);
	}

	/**
	 * Don't explode on invalid titles (T290194).
	 * @covers \MediaWiki\Title\Title::getBaseTitle
	 */
	public function testGetBaseTitle_invalid() {
		$title = Title::makeTitle( -23, 'Test' );
		$base = $title->getBaseTitle();
		$this->assertSame( $title, $base );
	}

	public static function provideRootTitleCases() {
		return [
			# Namespace, Title, expected base
			[ NS_USER, 'John_Doe', 'John Doe' ],
			[ NS_USER, 'John_Doe/subOne/subTwo', 'John Doe' ],
			[ NS_USER, 'Foo / Bar / Baz', 'Foo ' ],
			[ NS_USER, 'Foo/', 'Foo' ],
			[ NS_USER, 'Foo/Bar/', 'Foo' ],
			[ NS_USER, '/', '/' ],
			[ NS_USER, '//', '/' ],
			[ NS_USER, '/oops/', '/oops' ],
			[ NS_USER, '/Ramba/Zamba/Mamba/', '/Ramba' ],
			[ NS_USER, '//x//y//z//', '//x' ],
			[ NS_TALK, '////', '///' ],
			[ NS_TEMPLATE, '////', '///' ],
			[ NS_TEMPLATE, 'Foo////', 'Foo' ],
			[ NS_TEMPLATE, 'Foo////Bar', 'Foo' ],
		];
	}

	/**
	 * @dataProvider provideRootTitleCases
	 * @covers \MediaWiki\Title\Title::getRootText
	 */
	public function testGetRootText( $namespace, $title, $expected ) {
		$title = Title::makeTitle( $namespace, $title );
		$this->assertEquals( $expected, $title->getRootText() );
	}

	/**
	 * @dataProvider provideRootTitleCases
	 * @covers \MediaWiki\Title\Title::getRootTitle
	 */
	public function testGetRootTitle( $namespace, $title, $expected ) {
		$title = Title::makeTitle( $namespace, $title );
		$root = $title->getRootTitle();
		$this->assertTrue( $root->isValid() );
		$this->assertTrue(
			$root->equals( Title::makeTitleSafe( $title->getNamespace(), $expected ) )
		);
	}

	/**
	 * Don't explode on invalid titles (T290194).
	 * @covers \MediaWiki\Title\Title::getRootTitle
	 */
	public function testGetRootTitle_invalid() {
		$title = Title::makeTitle( -23, 'Test' );
		$base = $title->getRootTitle();
		$this->assertSame( $title, $base );
	}

	public static function provideSubpageTitleCases() {
		return [
			# Namespace, Title, expected base
			[ NS_USER, 'John_Doe', 'John Doe' ],
			[ NS_USER, 'John_Doe/subOne/subTwo', 'subTwo' ],
			[ NS_USER, 'John_Doe/subOne', 'subOne' ],
			[ NS_USER, '/', '/' ],
			[ NS_USER, '//', '' ],
			[ NS_USER, '/oops/', '' ],
			[ NS_USER, '/indeed', '/indeed' ],
			[ NS_USER, '//indeed', 'indeed' ],
			[ NS_USER, '/Ramba/Zamba/Mamba/', '' ],
			[ NS_USER, '//x//y//z//', '' ],
			[ NS_TEMPLATE, 'Foo', 'Foo' ],
			[ NS_CATEGORY, 'Foo', 'Foo' ],
			[ NS_MAIN, 'Bar', 'Bar' ],
		];
	}

	/**
	 * @dataProvider provideSubpageTitleCases
	 * @covers \MediaWiki\Title\Title::getSubpageText
	 */
	public function testGetSubpageText( $namespace, $title, $expected ) {
		$title = Title::makeTitle( $namespace, $title );
		$this->assertEquals( $expected, $title->getSubpageText() );
	}

	public static function provideGetTitleValue() {
		return [
			[ 'Foo' ],
			[ 'Foo#bar' ],
			[ 'User:Hansi_Maier' ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::getTitleValue
	 * @dataProvider provideGetTitleValue
	 */
	public function testGetTitleValue( $text ) {
		$title = Title::newFromText( $text );
		$value = $title->getTitleValue();

		$dbkey = str_replace( ' ', '_', $value->getText() );
		$this->assertEquals( $title->getDBkey(), $dbkey );
		$this->assertEquals( $title->getNamespace(), $value->getNamespace() );
		$this->assertEquals( $title->getFragment(), $value->getFragment() );
	}

	public static function provideGetFragment() {
		return [
			[ 'Foo', '' ],
			[ 'Foo#bar', 'bar' ],
			[ 'Foo#bär', 'bär' ],

			// Inner whitespace is normalized
			[ 'Foo#bar_bar', 'bar bar' ],
			[ 'Foo#bar bar', 'bar bar' ],
			[ 'Foo#bar   bar', 'bar bar' ],

			// Leading whitespace is kept, trailing whitespace is trimmed.
			// XXX: Is this really want we want?
			[ 'Foo#_bar_bar_', ' bar bar' ],
			[ 'Foo# bar bar ', ' bar bar' ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::getFragment
	 * @dataProvider provideGetFragment
	 *
	 * @param string $full
	 * @param string $fragment
	 */
	public function testGetFragment( $full, $fragment ) {
		$title = Title::newFromText( $full );
		$this->assertEquals( $fragment, $title->getFragment() );
	}

	/**
	 * @covers \MediaWiki\Title\Title::exists
	 */
	public function testExists() {
		$title = Title::makeTitle( NS_PROJECT, 'New page' );
		$linkCache = $this->getServiceContainer()->getLinkCache();

		$article = new Article( $title );
		$page = $article->getPage();
		$page->doUserEditContent(
			new WikitextContent( 'Some [[link]]' ),
			$this->getTestSysop()->getUser(),
			'summary'
		);

		// Tell Title it doesn't know whether it exists
		$title->mArticleID = -1;

		// Tell the link cache it doesn't exist when it really does
		$linkCache->clearLink( $title );
		$linkCache->addBadLinkObj( $title );

		$this->assertFalse(
			$title->exists(),
			'exists() should rely on link cache unless READ_LATEST is used'
		);
		$this->assertTrue(
			$title->exists( IDBAccessObject::READ_LATEST ),
			'exists() should re-query database when READ_LATEST is used'
		);
	}

	/**
	 * @covers \MediaWiki\Title\Title::getArticleID
	 * @covers \MediaWiki\Title\Title::getId
	 */
	public function testGetArticleID() {
		$title = Title::makeTitle( NS_PROJECT, __METHOD__ );
		$this->assertSame( 0, $title->getArticleID() );
		$this->assertSame( $title->getArticleID(), $title->getId() );

		$article = new Article( $title );
		$page = $article->getPage();
		$page->doUserEditContent(
			new WikitextContent( 'Some [[link]]' ),
			$this->getTestSysop()->getUser(),
			'summary'
		);

		$this->assertGreaterThan( 0, $title->getArticleID() );
		$this->assertSame( $title->getArticleID(), $title->getId() );
	}

	public static function provideNonProperTitles() {
		return [
			'section link' => [ Title::makeTitle( NS_MAIN, '', 'Section' ) ],
			'empty' => [ Title::makeTitle( NS_MAIN, '' ) ],
			'bad chars' => [ Title::makeTitle( NS_MAIN, '_|_' ) ],
			'empty in namspace' => [ Title::makeTitle( NS_USER, '' ) ],
			'special' => [ Title::makeTitle( NS_SPECIAL, 'RecentChanges' ) ],
			'interwiki' => [ Title::makeTitle( NS_MAIN, 'Test', '', 'acme' ) ],
		];
	}

	/**
	 * @dataProvider provideNonProperTitles
	 * @covers \MediaWiki\Title\Title::getArticleID
	 */
	public function testGetArticleIDFromNonProperTitle( $title ) {
		// make sure nothing explodes
		$this->assertSame( 0, $title->getArticleID() );
	}

	public static function provideCanHaveTalkPage() {
		return [
			'User page has talk page' => [
				Title::makeTitle( NS_USER, 'Jane' ), true
			],
			'Talke page has talk page' => [
				Title::makeTitle( NS_TALK, 'Foo' ), true
			],
			'Special page cannot have talk page' => [
				Title::makeTitle( NS_SPECIAL, 'Thing' ), false
			],
			'Virtual namespace cannot have talk page' => [
				Title::makeTitle( NS_MEDIA, 'Kitten.jpg' ), false
			],
			'Relative link has no talk page' => [
				Title::makeTitle( NS_MAIN, '', 'Kittens' ), false
			],
			'Interwiki link has no talk page' => [
				Title::makeTitle( NS_MAIN, 'Kittens', '', 'acme' ), false
			],
		];
	}

	public static function provideGetTalkPage_good() {
		return [
			[ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ],
			[ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ],
		];
	}

	public static function provideGetTalkPage_bad() {
		return [
			[ Title::makeTitle( NS_SPECIAL, 'Test' ) ],
			[ Title::makeTitle( NS_MEDIA, 'Test' ) ],
		];
	}

	public static function provideGetTalkPage_broken() {
		// These cases *should* be bad, but are not treated as bad, for backwards compatibility.
		// See discussion on T227817.
		return [
			[
				Title::makeTitle( NS_MAIN, '', 'Kittens' ),
				Title::makeTitle( NS_TALK, '' ), // Section is lost!
				false,
			],
			[
				Title::makeTitle( NS_MAIN, 'Kittens', '', 'acme' ),
				Title::makeTitle( NS_TALK, 'Kittens', '' ), // Interwiki prefix is lost!
				true,
			],
		];
	}

	public static function provideGetSubjectPage_good() {
		return [
			[ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_MAIN, 'Test' ) ],
			[ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_MAIN, 'Test' ) ],
		];
	}

	public static function provideGetOtherPage_good() {
		return [
			[ Title::makeTitle( NS_MAIN, 'Test' ), Title::makeTitle( NS_TALK, 'Test' ) ],
			[ Title::makeTitle( NS_TALK, 'Test' ), Title::makeTitle( NS_MAIN, 'Test' ) ],
		];
	}

	/**
	 * @dataProvider provideCanHaveTalkPage
	 * @covers \MediaWiki\Title\Title::canHaveTalkPage
	 *
	 * @param Title $title
	 * @param bool $expected
	 */
	public function testCanHaveTalkPage( Title $title, $expected ) {
		$actual = $title->canHaveTalkPage();
		$this->assertSame( $expected, $actual, $title->getPrefixedDBkey() );
	}

	/**
	 * @dataProvider provideGetTalkPage_good
	 * @covers \MediaWiki\Title\Title::getTalkPageIfDefined
	 */
	public function testGetTalkPage_good( Title $title, Title $expected ) {
		$actual = $title->getTalkPage();
		$this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() );
	}

	/**
	 * @dataProvider provideGetTalkPage_bad
	 * @covers \MediaWiki\Title\Title::getTalkPageIfDefined
	 */
	public function testGetTalkPage_bad( Title $title ) {
		$this->expectException( MWException::class );
		$title->getTalkPage();
	}

	/**
	 * @dataProvider provideGetTalkPage_broken
	 * @covers \MediaWiki\Title\Title::getTalkPageIfDefined
	 */
	public function testGetTalkPage_broken( Title $title, Title $expected, $valid ) {
		// NOTE: Eventually we want to throw in this case. But while there is still code that
		// calls this method without checking, we want to avoid fatal errors.
		// See discussion on T227817.
		$result = @$title->getTalkPage();
		$this->assertTrue( $expected->equals( $result ) );
		$this->assertSame( $valid, $result->isValid() );
	}

	/**
	 * @dataProvider provideGetTalkPage_good
	 * @covers \MediaWiki\Title\Title::getTalkPageIfDefined
	 */
	public function testGetTalkPageIfDefined_good( Title $title, Title $expected ) {
		$actual = $title->getTalkPageIfDefined();
		$this->assertNotNull( $actual, $title->getPrefixedDBkey() );
		$this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() );
	}

	/**
	 * @dataProvider provideGetTalkPage_bad
	 * @covers \MediaWiki\Title\Title::getTalkPageIfDefined
	 */
	public function testGetTalkPageIfDefined_bad( Title $title ) {
		$talk = $title->getTalkPageIfDefined();
		$this->assertNull(
			$talk,
			$title->getPrefixedDBkey()
		);
	}

	/**
	 * @dataProvider provideGetSubjectPage_good
	 * @covers \MediaWiki\Title\Title::getSubjectPage
	 */
	public function testGetSubjectPage_good( Title $title, Title $expected ) {
		$actual = $title->getSubjectPage();
		$this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() );
	}

	/**
	 * @dataProvider provideGetOtherPage_good
	 * @covers \MediaWiki\Title\Title::getOtherPage
	 */
	public function testGetOtherPage_good( Title $title, Title $expected ) {
		$actual = $title->getOtherPage();
		$this->assertTrue( $expected->equals( $actual ), $title->getPrefixedDBkey() );
	}

	/**
	 * @dataProvider provideGetTalkPage_bad
	 * @covers \MediaWiki\Title\Title::getOtherPage
	 */
	public function testGetOtherPage_bad( Title $title ) {
		$this->expectException( MWException::class );
		$title->getOtherPage();
	}

	public static function provideIsMovable() {
		return [
			'Simple title' => [ 'Foo', true ],
			// @todo Should these next two really be true?
			'Empty name' => [ Title::makeTitle( NS_MAIN, '' ), true ],
			'Invalid name' => [ Title::makeTitle( NS_MAIN, '<' ), true ],
			'Interwiki' => [ Title::makeTitle( NS_MAIN, 'Test', '', 'otherwiki' ), false ],
			'Special page' => [ 'Special:FooBar', false ],
			'Aborted by hook' => [ 'Hooked in place', false,
				static function ( Title $title, &$result ) {
					$result = false;
				}
			],
		];
	}

	/**
	 * @dataProvider provideIsMovable
	 * @covers \MediaWiki\Title\Title::isMovable
	 *
	 * @param string|Title $title
	 * @param bool $expected
	 * @param callable|null $hookCallback For TitleIsMovable
	 */
	public function testIsMovable( $title, $expected, $hookCallback = null ) {
		if ( $hookCallback ) {
			$this->setTemporaryHook( 'TitleIsMovable', $hookCallback );
		}
		if ( is_string( $title ) ) {
			$title = Title::newFromText( $title );
		}

		$this->assertSame( $expected, $title->isMovable() );
	}

	public static function provideGetPrefixedText() {
		return [
			// ns = 0
			[
				Title::makeTitle( NS_MAIN, 'Foo bar' ),
				'Foo bar'
			],
			// ns = 2
			[
				Title::makeTitle( NS_USER, 'Foo bar' ),
				'User:Foo bar'
			],
			// ns = 3
			[
				Title::makeTitle( NS_USER_TALK, 'Foo bar' ),
				'User talk:Foo bar'
			],
			// fragment not included
			[
				Title::makeTitle( NS_MAIN, 'Foo bar', 'fragment' ),
				'Foo bar'
			],
			// ns = -2
			[
				Title::makeTitle( NS_MEDIA, 'Foo bar' ),
				'Media:Foo bar'
			],
			// non-existent namespace
			[
				Title::makeTitle( 100777, 'Foo bar' ),
				'Special:Badtitle/NS100777:Foo bar'
			],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::getPrefixedText
	 * @dataProvider provideGetPrefixedText
	 */
	public function testGetPrefixedText( Title $title, $expected ) {
		$this->assertEquals( $expected, $title->getPrefixedText() );
	}

	public static function provideGetPrefixedDBKey() {
		return [
			// ns = 0
			[
				Title::makeTitle( NS_MAIN, 'Foo_bar' ),
				'Foo_bar'
			],
			// ns = 2
			[
				Title::makeTitle( NS_USER, 'Foo_bar' ),
				'User:Foo_bar'
			],
			// ns = 3
			[
				Title::makeTitle( NS_USER_TALK, 'Foo_bar' ),
				'User_talk:Foo_bar'
			],
			// fragment not included
			[
				Title::makeTitle( NS_MAIN, 'Foo_bar', 'fragment' ),
				'Foo_bar'
			],
			// ns = -2
			[
				Title::makeTitle( NS_MEDIA, 'Foo_bar' ),
				'Media:Foo_bar'
			],
			// non-existent namespace
			[
				Title::makeTitle( 100777, 'Foo_bar' ),
				'Special:Badtitle/NS100777:Foo_bar'
			],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::getPrefixedDBKey
	 * @dataProvider provideGetPrefixedDBKey
	 */
	public function testGetPrefixedDBKey( Title $title, $expected ) {
		$this->assertEquals( $expected, $title->getPrefixedDBkey() );
	}

	public static function provideGetFragmentForURL() {
		return [
			[ 'Foo', '' ],
			[ 'Foo#ümlåût', '#ümlåût' ],
			[ 'de:Foo#Bå®', '#Bå®' ],
			[ 'zz:Foo#тест', '#.D1.82.D0.B5.D1.81.D1.82' ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::getFragmentForURL
	 * @dataProvider provideGetFragmentForURL
	 *
	 * @param string $titleStr
	 * @param string $expected
	 */
	public function testGetFragmentForURL( $titleStr, $expected ) {
		$this->overrideConfigValues( [
			MainConfigNames::FragmentMode => [ 'html5' ],
			MainConfigNames::ExternalInterwikiFragmentMode => 'legacy',
		] );
		// InterwikiLookup is configured in setUp()

		$title = Title::newFromText( $titleStr );
		self::assertEquals( $expected, $title->getFragmentForURL() );
	}

	public static function provideIsRawHtmlMessage() {
		return [
			[ 'MediaWiki:Foobar', true ],
			[ 'MediaWiki:Foo bar', true ],
			[ 'MediaWiki:Foo-bar', true ],
			[ 'MediaWiki:foo bar', true ],
			[ 'MediaWiki:foo-bar', true ],
			[ 'MediaWiki:foobar', true ],
			[ 'MediaWiki:some-other-message', false ],
			[ 'Main Page', false ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::isRawHtmlMessage
	 * @dataProvider provideIsRawHtmlMessage
	 */
	public function testIsRawHtmlMessage( $textForm, $expected ) {
		$this->overrideConfigValue(
			MainConfigNames::RawHtmlMessages,
			[
				'foobar',
				'foo_bar',
				'foo-bar',
			]
		);

		$title = Title::newFromText( $textForm );
		$this->assertSame( $expected, $title->isRawHtmlMessage() );
	}

	/**
	 * @covers \MediaWiki\Title\Title::newMainPage
	 */
	public function testNewMainPage() {
		$mock = $this->createMock( MessageCache::class );
		$mock->method( 'get' )->willReturn( 'Foresheet' );
		$mock->method( 'transform' )->willReturn( 'Foresheet' );

		$this->setService( 'MessageCache', $mock );

		$this->assertSame(
			'Foresheet',
			Title::newMainPage()->getText()
		);
	}

	/**
	 * Regression test for T297571
	 *
	 * @covers \MediaWiki\Title\Title::newMainPage
	 */
	public function testNewMainPageNoRecursion() {
		$mock = $this->createMock( MessageCache::class );
		$mock->method( 'get' )->willReturn( 'localtestiw:' );
		$mock->method( 'transform' )->willReturn( 'localtestiw:' );
		$this->setService( 'MessageCache', $mock );

		$this->assertSame(
			'Main Page',
			Title::newMainPage()->getPrefixedText()
		);
	}

	/**
	 * @covers \MediaWiki\Title\Title::newMainPage
	 */
	public function testNewMainPageWithLocal() {
		$local = $this->createMock( MessageLocalizer::class );
		$local->method( 'msg' )->willReturn( new RawMessage( 'Prime Article' ) );

		$this->assertSame(
			'Prime Article',
			Title::newMainPage( $local )->getText()
		);
	}

	/**
	 * @covers \MediaWiki\Title\Title::getTitleProtection
	 */
	public function testGetTitleProtection() {
		$title = $this->getNonexistingTestPage( 'UTest1' )->getTitle();
		$this->hideDeprecated( Title::class . '::getTitleProtection' );
		$this->assertFalse( $title->getTitleProtection() );
	}

	/**
	 * @covers \MediaWiki\Title\Title::deleteTitleProtection
	 */
	public function testDeleteTitleProtection() {
		$title = $this->getExistingTestPage( 'UTest1' )->getTitle();
		$this->hideDeprecated( Title::class . '::getTitleProtection' );
		$this->assertFalse( $title->getTitleProtection() );
	}

	/**
	 * @covers \MediaWiki\Page\PageStore::getSubpages
	 */
	public function testGetSubpages() {
		$existingPage = $this->getExistingTestPage();
		$title = $existingPage->getTitle();

		$this->overrideConfigValue(
			MainConfigNames::NamespacesWithSubpages,
			[ $title->getNamespace() => true ]
		);

		$this->getExistingTestPage( $title->getSubpage( 'A' ) );
		$this->getExistingTestPage( $title->getSubpage( 'B' ) );

		$notQuiteSubpageTitle = $title->getPrefixedDBkey() . 'X'; // no slash!
		$this->getExistingTestPage( $notQuiteSubpageTitle );

		$subpages = iterator_to_array( $title->getSubpages() );

		$this->assertCount( 2, $subpages );
		$this->assertCount( 1, $title->getSubpages( 1 ) );
	}

	/**
	 * @covers \MediaWiki\Page\PageStore::getSubpages
	 */
	public function testGetSubpages_disabled() {
		$this->overrideConfigValue( MainConfigNames::NamespacesWithSubpages, [] );

		$existingPage = $this->getExistingTestPage();
		$title = $existingPage->getTitle();

		$this->getExistingTestPage( $title->getSubpage( 'A' ) );
		$this->getExistingTestPage( $title->getSubpage( 'B' ) );

		$this->assertSame( [], $title->getSubpages() );
	}

	public static function provideNamespaces() {
		// For ->isExternal() code path, construct a title with interwiki
		$title = Title::makeTitle( NS_FILE, 'test', 'frag', 'meta' );
		return [
			[ NS_MAIN, '' ],
			[ NS_FILE, 'File' ],
			[ NS_MEDIA, 'Media' ],
			[ NS_TALK, 'Talk' ],
			[ NS_CATEGORY, 'Category' ],
			[ $title, 'File' ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::getNsText
	 * @dataProvider provideNamespaces
	 */
	public function testGetNsText( $namespace, $expected ) {
		if ( $namespace instanceof Title ) {
			$this->assertSame( $expected, $namespace->getNsText() );
		} else {
			$actual = Title::makeTitle( $namespace, 'Title_test' )->getNsText();
			$this->assertSame( $expected, $actual );
		}
	}

	public static function providePagesWithSubjects() {
		return [
			[ Title::makeTitle( NS_USER_TALK, 'User_test' ), 'User' ],
			[ Title::makeTitle( NS_PROJECT, 'Test' ), 'Project' ],
			[ Title::makeTitle( NS_MAIN, 'Test' ), '' ],
			[ Title::makeTitle( NS_CATEGORY, 'Cat_test' ), 'Category' ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::getSubjectNsText
	 * @dataProvider providePagesWithSubjects
	 */
	public function testGetSubjectNsText( Title $title, $expected ) {
		$actual = $title->getSubjectNsText();
		$this->assertSame( $expected, $actual );
	}

	public static function provideTitlesWithTalkPages() {
		return [
			[ Title::makeTitle( NS_HELP, 'Help page' ), 'Help_talk' ],
			[ Title::newMainPage(), 'Talk' ],
			[ Title::makeTitle( NS_PROJECT, 'Test' ), 'Project_talk' ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::getTalkNsText
	 * @dataProvider provideTitlesWithTalkPages
	 */
	public function testGetTalkNsText( Title $title, $expected ) {
		$actual = $title->getTalkNsText();
		$this->assertSame( $expected, $actual );
	}

	/**
	 * @covers \MediaWiki\Title\Title::isSpecial
	 */
	public function testIsSpecial() {
		$title = Title::makeTitle( NS_SPECIAL, 'Recentchanges/Subpage' );
		$this->assertTrue( $title->isSpecial( 'Recentchanges' ) );
	}

	/**
	 * @covers \MediaWiki\Title\Title::isSpecial
	 */
	public function testIsNotSpecial() {
		$title = Title::newFromText( 'NotSpecialPage/Subpage', NS_SPECIAL );
		$this->assertFalse( $title->isSpecial( 'NotSpecialPage' ) );
	}

	/**
	 * @covers \MediaWiki\Title\Title::isTalkPage
	 */
	public function testIsTalkPage() {
		$title = Title::newFromText( 'Talk page', NS_TALK );
		$this->assertTrue( $title->isTalkPage() );

		$titleNotInTalkNs = Title::makeTitle( NS_HELP, 'Test' );
		$this->assertFalse( $titleNotInTalkNs->isTalkPage() );
	}

	/**
	 * @coversNothing
	 */
	public function testGetBacklinkCache() {
		$blcFactory = $this->getServiceContainer()->getBacklinkCacheFactory();
		$backlinkCache = $blcFactory->getBacklinkCache( Title::makeTitle( NS_FILE, 'Test' ) );
		$this->assertInstanceOf( BacklinkCache::class, $backlinkCache );
	}

	public static function provideNsWithSubpagesSupport() {
		return [
			[ NS_HELP, 'Mainhelp', 'Mainhelp/Subhelp' ],
			[ NS_USER, 'Mainuser', 'Mainuser/Subuser' ],
			[ NS_TALK, 'Maintalk', 'Maintalk/Subtalk' ],
			[ NS_PROJECT, 'Mainproject', 'Mainproject/Subproject' ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::isSubpage
	 * @covers \MediaWiki\Title\Title::isSubpageOf
	 * @dataProvider provideNsWithSubpagesSupport
	 */
	public function testIsSubpageOfWithNamespacesSubpages( $namespace, $pageName, $subpageName ) {
		$page = Title::makeTitle( $namespace, $pageName, '', 'meta' );
		$subPage = Title::makeTitle( $namespace, $subpageName, '', 'meta' );

		$this->assertTrue( $subPage->isSubpageOf( $page ) );
		$this->assertTrue( $subPage->isSubpage() );
	}

	public static function provideNsWithNoSubpages() {
		return [
			[ NS_CATEGORY, 'Maincat', 'Maincat/Subcat' ],
			[ NS_MAIN, 'Mainpage', 'Mainpage/Subpage' ]
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::isSubpage
	 * @covers \MediaWiki\Title\Title::isSubpageOf
	 * @dataProvider provideNsWithNoSubpages
	 */
	public function testIsSubpageOfWithoutNamespacesSubpages( $namespace, $pageName, $subpageName ) {
		$page = Title::makeTitle( $namespace, $pageName, '', 'meta' );
		$subPage = Title::makeTitle( $namespace, $subpageName, '', 'meta' );

		$this->assertFalse( $page->isSubpageOf( $page ) );
		$this->assertFalse( $subPage->isSubpage() );
	}

	public static function provideTitleEditURLs() {
		return [
			[ Title::makeTitle( NS_MAIN, 'Title' ), '/w/index.php?title=Title&action=edit' ],
			[ Title::makeTitle( NS_HELP, 'Test', '', 'mw' ), '' ],
			[ Title::makeTitle( NS_HELP, 'Test' ), '/w/index.php?title=Help:Test&action=edit' ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::getEditURL
	 * @dataProvider provideTitleEditURLs
	 */
	public function testGetEditURL( Title $title, $expected ) {
		$actual = $title->getEditURL();
		$this->assertSame( $expected, $actual );
	}

	public static function provideTitleEditURLsWithActionPaths() {
		return [
			[ Title::newFromText( 'Title', NS_MAIN ), '/wiki/edit/Title' ],
			[ Title::makeTitle( NS_HELP, 'Test', '', 'mw' ), '' ],
			[ Title::newFromText( 'Test', NS_HELP ), '/wiki/edit/Help:Test' ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::getEditURL
	 * @dataProvider provideTitleEditURLsWithActionPaths
	 */
	public function testGetEditUrlWithActionPaths( Title $title, $expected ) {
		$this->overrideConfigValue( MainConfigNames::ActionPaths, [ 'edit' => '/wiki/edit/$1' ] );
		$actual = $title->getEditURL();
		$this->assertSame( $expected, $actual );
	}

	/**
	 * @covers \MediaWiki\Title\Title::isMainPage
	 * @covers \MediaWiki\Title\Title::equals
	 */
	public function testIsMainPage() {
		$this->assertTrue( Title::newMainPage()->isMainPage() );
	}

	/**
	 * @covers \MediaWiki\Title\Title::isMainPage
	 * @covers \MediaWiki\Title\Title::equals
	 * @dataProvider provideMainPageTitles
	 */
	public function testIsNotMainPage( Title $title, $expected ) {
		$this->assertSame( $expected, $title->isMainPage() );
	}

	public static function provideMainPageTitles() {
		return [
			[ Title::makeTitle( NS_MAIN, 'Test' ), false ],
			[ Title::makeTitle( NS_CATEGORY, 'mw:Category' ), false ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::getPrefixedURL
	 * @covers \MediaWiki\Title\Title::prefix
	 * @dataProvider provideDataForTestGetPrefixedURL
	 */
	public function testGetPrefixedURL( Title $title, $expected ) {
		$actual = $title->getPrefixedURL();

		$this->assertSame( $expected, $actual );
	}

	public static function provideDataForTestGetPrefixedURL() {
		return [
			[ Title::makeTitle( NS_FILE, 'Title' ), 'File:Title' ],
			[ Title::makeTitle( NS_MEDIA, 'Title' ), 'Media:Title' ],
			[ Title::makeTitle( NS_CATEGORY, 'Title' ), 'Category:Title' ],
			[ Title::makeTitle( NS_FILE, 'Title with spaces' ), 'File:Title_with_spaces' ],
			[
				Title::makeTitle( NS_FILE, 'Title with spaces', '', 'mw' ),
				'mw:File:Title_with_spaces'
			],
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::__toString
	 */
	public function testToString() {
		$title = Title::makeTitle( NS_USER, 'User test' );

		$this->assertSame( 'User:User test', (string)$title );
	}

	/**
	 * @covers \MediaWiki\Title\Title::getFullText
	 * @dataProvider provideDataForTestGetFullText
	 */
	public function testGetFullText( Title $title, $expected ) {
		$actual = $title->getFullText();

		$this->assertSame( $expected, $actual );
	}

	public static function provideDataForTestGetFullText() {
		return [
			[ Title::makeTitle( NS_TALK, 'Test' ), 'Talk:Test' ],
			[ Title::makeTitle( NS_HELP, 'Test', 'frag' ), 'Help:Test#frag' ],
			[ Title::makeTitle( NS_TALK, 'Test', 'frag', 'phab' ), 'phab:Talk:Test#frag' ],
		];
	}

	public static function provideIsSamePageAs() {
		$title = Title::makeTitle( 0, 'Foo' );
		$title->resetArticleID( 1 );
		yield '(PageIdentityValue) same text, title has ID 0' => [
			$title,
			new PageIdentityValue( 1, 0, 'Foo', PageIdentity::LOCAL ),
			true
		];

		$title = Title::makeTitle( 1, 'Bar_Baz' );
		$title->resetArticleID( 0 );
		yield '(PageIdentityValue) same text, PageIdentityValue has ID 0' => [
			$title,
			new PageIdentityValue( 0, 1, 'Bar_Baz', PageIdentity::LOCAL ),
			true
		];

		$title = Title::makeTitle( 0, 'Foo' );
		$title->resetArticleID( 0 );
		yield '(PageIdentityValue) different text, both IDs are 0' => [
			$title,
			new PageIdentityValue( 0, 0, 'Foozz', PageIdentity::LOCAL ),
			false
		];

		$title = Title::makeTitle( 0, 'Foo' );
		$title->resetArticleID( 0 );
		yield '(PageIdentityValue) different namespace' => [
			$title,
			new PageIdentityValue( 0, 1, 'Foo', PageIdentity::LOCAL ),
			false
		];

		$title = Title::makeTitle( 0, 'Foo', '' );
		$title->resetArticleID( 1 );
		yield '(PageIdentityValue) different wiki, different ID' => [
			$title,
			new PageIdentityValue( 1, 0, 'Foo', 'bar' ),
			false
		];

		$title = Title::makeTitle( 0, 'Foo', '' );
		$title->resetArticleID( 0 );
		yield '(PageIdentityValue) different wiki, both IDs are 0' => [
			$title,
			new PageIdentityValue( 0, 0, 'Foo', 'bar' ),
			false
		];
	}

	/**
	 * @covers \MediaWiki\Title\Title::isSamePageAs
	 * @dataProvider provideIsSamePageAs
	 */
	public function testIsSamePageAs( Title $firstValue, $secondValue, $expectedSame ) {
		$this->assertSame(
			$expectedSame,
			$firstValue->isSamePageAs( $secondValue )
		);
	}

	/**
	 * @covers \MediaWiki\Title\Title::getArticleID
	 * @covers \MediaWiki\Title\Title::getId
	 * @covers \MediaWiki\Title\Title::getLength
	 * @covers \MediaWiki\Title\Title::getLatestRevID
	 * @covers \MediaWiki\Title\Title::exists
	 * @covers \MediaWiki\Title\Title::isNewPage
	 * @covers \MediaWiki\Title\Title::isRedirect
	 * @covers \MediaWiki\Title\Title::getTouched
	 * @covers \MediaWiki\Title\Title::getContentModel
	 * @covers \MediaWiki\Title\Title::getFieldFromPageStore
	 */
	public function testGetFieldsOfNonExistingPage() {
		$title = Title::makeTitle( NS_MAIN, 'ThisDoesNotExist-92347852349' );

		$this->assertSame( 0, $title->getArticleID() );
		$this->assertSame( 0, $title->getId() );
		$this->assertSame( 0, $title->getLength() );
		$this->assertSame( 0, $title->getLatestRevID() );
		$this->assertFalse( $title->exists() );
		$this->assertFalse( $title->isNewPage() );
		$this->assertFalse( $title->isRedirect() );
		$this->assertFalse( $title->getTouched() );
		$this->assertNotEmpty( $title->getContentModel() );
	}

	/**
	 * @covers \MediaWiki\Title\Title::getDefaultSystemMessage
	 */
	public function testGetDefaultSystemMessage() {
		$title = Title::makeTitle( NS_MEDIAWIKI, 'Logouttext' );

		$this->assertInstanceOf( Message::class, $title->getDefaultSystemMessage() );
		$this->assertStringContainsString( 'You are now logged out', $title->getDefaultMessageText() );
	}

	/**
	 * @covers \MediaWiki\Title\Title::getDefaultSystemMessage
	 */
	public function testGetDefaultSystemMessageReturnsNull() {
		$title = Title::makeTitle( NS_MAIN, 'Some title' );

		$this->assertNull( $title->getDefaultSystemMessage() );
	}

}
PK       ! 2Ҧ
  
  %  title/NaiveImportTitleFactoryTest.phpnu Iw        <?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
 * @author This, that and the other
 */

use MediaWiki\MainConfigNames;
use MediaWiki\Title\ForeignTitle;
use MediaWiki\Title\NaiveImportTitleFactory;
use MediaWiki\Title\Title;

/**
 * @covers \MediaWiki\Title\NaiveImportTitleFactory
 *
 * @group Title
 *
 * TODO convert to unit tests
 */
class NaiveImportTitleFactoryTest extends MediaWikiIntegrationTestCase {

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

		$this->overrideConfigValues( [
			MainConfigNames::LanguageCode => 'en',
			MainConfigNames::ExtraNamespaces => [ 100 => 'Portal' ],
		] );
	}

	public static function basicProvider() {
		return [
			[
				new ForeignTitle( 0, '', 'MainNamespaceArticle' ),
				'MainNamespaceArticle'
			],
			[
				new ForeignTitle( null, '', 'MainNamespaceArticle' ),
				'MainNamespaceArticle'
			],
			[
				new ForeignTitle( 1, 'Discussion', 'Nice_talk' ),
				'Talk:Nice_talk'
			],
			[
				new ForeignTitle( 0, '', 'Bogus:Nice_talk' ),
				'Bogus:Nice_talk'
			],
			[
				new ForeignTitle( 100, 'Bogus', 'Nice_talk' ),
				'Bogus:Nice_talk' // not Portal:Nice_talk
			],
			[
				new ForeignTitle( 1, 'Bogus', 'Nice_talk' ),
				'Talk:Nice_talk' // not Bogus:Nice_talk
			],
			[
				new ForeignTitle( 100, 'Portal', 'Nice_talk' ),
				'Portal:Nice_talk'
			],
			[
				new ForeignTitle( 724, 'Portal', 'Nice_talk' ),
				'Portal:Nice_talk'
			],
			[
				new ForeignTitle( 2, 'Portal', 'Nice_talk' ),
				'User:Nice_talk'
			],
		];
	}

	/**
	 * @dataProvider basicProvider
	 */
	public function testBasic( ForeignTitle $foreignTitle, $titleText ) {
		$factory = new NaiveImportTitleFactory(
			$this->getServiceContainer()->getContentLanguage(),
			$this->getServiceContainer()->getNamespaceInfo(),
			$this->getServiceContainer()->getTitleFactory()
		);
		$testTitle = $factory->createTitleFromForeignTitle( $foreignTitle );
		$title = Title::newFromText( $titleText );

		$this->assertTrue( $title->equals( $testTitle ) );
	}
}
PK       ! zXy  y    title/TitleUrlTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Parser\Sanitizer;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\Title;

/**
 * @group Database
 *
 * @covers \MediaWiki\Title\Title::getLocalURL
 * @covers \MediaWiki\Title\Title::getLinkURL
 * @covers \MediaWiki\Title\Title::getFullURL
 * @covers \MediaWiki\Title\Title::getFullUrlForRedirect
 * @covers \MediaWiki\Title\Title::getCanonicalURL
 * @covers \MediaWiki\Title\Title::getInternalURL
 */
class TitleUrlTest extends MediaWikiLangTestCase {
	use DummyServicesTrait;

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValues( [
			MainConfigNames::Server => '//m.xx.wiki.test',
			MainConfigNames::CanonicalServer => 'https://xx.wiki.test',
			MainConfigNames::InternalServer => 'http://app23.internal',
			MainConfigNames::ArticlePath => '/wiki/$1',
			MainConfigNames::ExternalInterwikiFragmentMode => 'html5',
			MainConfigNames::FragmentMode => [ 'legacy', 'html5' ],
			MainConfigNames::MainPageIsDomainRoot => true,
			MainConfigNames::ActionPaths => [ 'edit' => '/m/edit/$1' ],
			MainConfigNames::VariantArticlePath => '/$2/$1',
			MainConfigNames::ScriptPath => '/m',
			MainConfigNames::Script => '/m/index.php',
			MainConfigNames::UsePigLatinVariant => true,
		] );

		// Some tests use interwikis - define valid prefixes and their configuration
		$interwikiLookup = $this->getDummyInterwikiLookup( [
			[ 'iw_prefix' => 'acme', 'iw_url' => 'https://acme.test/$1' ],
			[ 'iw_prefix' => 'yy', 'iw_url' => '//yy.wiki.test/wiki/$1', 'iw_local' => true ]
		] );
		$this->setService( 'InterwikiLookup', $interwikiLookup );

		$this->clearHooks( [
			'GetFullURL',
			'GetLocalURL__Article',
			'GetLocalURL__Internal',
			'GetLocalURL',
			'GetInternalURL',
			'GetCanonicalURL',
		] );
	}

	protected function tearDown(): void {
		Title::clearCaches();
		parent::tearDown();
	}

	public function testUrlsForSimpleTitle() {
		$title = Title::makeTitle( NS_USER, 'Göatee' );
		$name = $title->getPrefixedURL();

		$query = [ 'x' => 'one two', 'y' => '#' ];
		$queryString = 'x=one+two&y=%23';

		// Test getLocalURL()
		$this->assertSame(
			'/wiki/' . $name,
			$title->getLocalURL(),
			'getLocalURL()'
		);
		$this->assertSame(
			'/m/index.php?title=' . $name . '&' . $queryString,
			$title->getLocalURL( $query ),
			'getLocalURL( array )'
		);
		$this->assertSame(
			'/m/index.php?title=' . $name . '&' . $queryString,
			$title->getLocalURL( $queryString ),
			'getLocalURL( string )'
		);

		// Test getFullURL()
		$this->assertSame(
			'//m.xx.wiki.test/wiki/' . $name,
			$title->getFullURL(),
			'getFullURL()'
		);
		$this->assertSame(
			'//m.xx.wiki.test/m/index.php?title=' . $name . '&' . $queryString,
			$title->getFullURL( $query ),
			'getFullURL( array )'
		);
		$this->assertSame(
			'https://xx.wiki.test/wiki/' . $name,
			$title->getFullURL( [], false, PROTO_CANONICAL ),
			'getFullURL() with PROTO_CANONICAL'
		);
		$this->assertSame(
			'http://m.xx.wiki.test/wiki/' . $name,
			$title->getFullURL( [], false, PROTO_HTTP ),
			'getFullURL() with PROTO_HTTP'
		);
		$this->assertSame(
			'http://app23.internal/wiki/' . $name,
			$title->getFullURL( [], false, PROTO_INTERNAL ),
			'getFullURL() with PROTO_INTERNAL'
		);

		// Test getFullUrlForRedirect()
		// Note that it uses PROTO_CURRENT by default, which is 'http' for tests
		$this->assertSame(
			'http://m.xx.wiki.test/wiki/' . $name,
			$title->getFullUrlForRedirect(),
			'getFullUrlForRedirect()'
		);
		$this->assertSame(
			'http://m.xx.wiki.test/m/index.php?title=' . $name . '&' . $queryString,
			$title->getFullUrlForRedirect( $query ),
			'getFullUrlForRedirect( array )'
		);
		$this->assertSame(
			'//m.xx.wiki.test/wiki/' . $name,
			$title->getFullUrlForRedirect( [], PROTO_RELATIVE ),
			'getFullUrlForRedirect() with PROTO_RELATIVE'
		);
		$this->assertSame(
			'https://xx.wiki.test/wiki/' . $name,
			$title->getFullUrlForRedirect( [], PROTO_CANONICAL ),
			'getFullUrlForRedirect() with PROTO_CANONICAL'
		);
		$this->assertSame(
			'http://app23.internal/wiki/' . $name,
			$title->getFullUrlForRedirect( [], PROTO_INTERNAL ),
			'getFullUrlForRedirect() with PROTO_INTERNAL'
		);

		// Test getLinkURL()
		$this->assertSame(
			'/wiki/' . $name,
			$title->getLinkURL(),
			'getLinkURL()'
		);
		$this->assertSame(
			'/m/index.php?title=' . $name . '&' . $queryString,
			$title->getLinkURL( $query ),
			'getLinkURL( array )'
		);
		$this->assertSame(
			'/m/index.php?title=' . $name . '&' . $queryString,
			$title->getLinkURL( $queryString ),
			'getLinkURL( string )'
		);
		$this->assertSame(
			'//m.xx.wiki.test/wiki/' . $name,
			$title->getLinkURL( [], false, PROTO_RELATIVE ),
			'getLinkURL() with PROTO_RELATIVE'
		);
		$this->assertSame(
			'http://m.xx.wiki.test/wiki/' . $name,
			$title->getLinkURL( [], false, PROTO_CURRENT ),
			'getLinkURL() with PROTO_CURRENT'
		);
		$this->assertSame(
			'https://m.xx.wiki.test/wiki/' . $name,
			$title->getLinkURL( [], false, PROTO_HTTPS ),
			'getLinkURL() with PROTO_HTTPS'
		);
		$this->assertSame(
			'https://xx.wiki.test/wiki/' . $name,
			$title->getLinkURL( [], false, PROTO_CANONICAL ),
			'getLinkURL() with PROTO_CANONICAL'
		);
		$this->assertSame(
			'http://app23.internal/wiki/' . $name,
			$title->getLinkURL( [], false, PROTO_INTERNAL ),
			'getLinkURL() with PROTO_INTERNAL'
		);
		$this->assertSame(
			'http://app23.internal/m/index.php?title=' . $name . '&' . $queryString,
			$title->getLinkURL( $query, false, PROTO_INTERNAL ),
			'getLinkURL( array ) with PROTO_INTERNAL'
		);

		// Test getInternalURL()
		$this->assertSame(
			'http://app23.internal/wiki/' . $name,
			$title->getInternalURL(),
			'getInternalURL()'
		);
		$this->assertSame(
			'http://app23.internal/m/index.php?title=' . $name . '&' . $queryString,
			$title->getInternalURL( $query ),
			'getInternalURL( array )'
		);
		$this->assertSame(
			'http://app23.internal/m/index.php?title=' . $name . '&' . $queryString,
			$title->getInternalURL( $queryString ),
			'getInternalURL( string )'
		);

		// Test getCanonicalURL()
		$this->assertSame(
			'https://xx.wiki.test/wiki/' . $name,
			$title->getCanonicalURL(),
			'getCanonicalURL()'
		);
		$this->assertSame(
			'https://xx.wiki.test/m/index.php?title=' . $name . '&' . $queryString,
			$title->getCanonicalURL( $query ),
			'getCanonicalURL( array )'
		);
		$this->assertSame(
			'https://xx.wiki.test/m/index.php?title=' . $name . '&' . $queryString,
			$title->getCanonicalURL( $queryString ),
			'getCanonicalURL( string )'
		);
	}

	public function testUrlsWithActionPath() {
		$title = Title::makeTitle( NS_USER, 'Göatee' );
		$name = $title->getPrefixedURL();

		$query1 = [ 'action' => 'edit' ];
		$query2 = [ 'x' => 'one two', 'y' => '#' ];
		$queryString = 'x=one+two&y=%23';

		// Test getLocalURL()
		$this->assertSame(
			'/m/edit/' . $name,
			$title->getLocalURL( $query1 ),
			'getLocalURL( array )'
		);
		$this->assertSame(
			'/m/edit/' . $name . '?' . $queryString,
			$title->getLocalURL( $query1 + $query2 ),
			'getLocalURL( array + array )'
		);

		// Test getFullURL()
		$this->assertSame(
			'//m.xx.wiki.test/m/edit/' . $name . '?' . $queryString,
			$title->getFullURL( $query1 + $query2 ),
			'getFullURL( array )'
		);

		// Test getFullUrlForRedirect()
		// Note that it uses PROTO_CURRENT by default, which is 'http' for tests
		$this->assertSame(
			'http://m.xx.wiki.test/m/edit/' . $name . '?' . $queryString,
			$title->getFullUrlForRedirect( $query1 + $query2 ),
			'getFullUrlForRedirect( array )'
		);

		// Test getLinkURL()
		$this->assertSame(
			'/m/edit/' . $name . '?' . $queryString,
			$title->getLinkURL( $query1 + $query2 ),
			'getLinkURL( array + array )'
		);

		// Test getInternalURL()
		$this->assertSame(
			'http://app23.internal/m/edit/' . $name . '?' . $queryString,
			$title->getInternalURL( $query1 + $query2 ),
			'getInternalURL( array )'
		);

		// Test getCanonicalURL()
		$this->assertSame(
			'https://xx.wiki.test/m/edit/' . $name . '?' . $queryString,
			$title->getCanonicalURL( $query1 + $query2 ),
			'getCanonicalURL( array )'
		);
	}

	public function testUrlsWithVariantPath() {
		$title = Title::makeTitle( NS_USER, 'Göatee' );
		$name = $title->getPrefixedURL();

		$query1 = [ 'variant' => 'en-x-piglatin' ];
		$query2 = [ 'x' => 'one two', 'y' => '#' ];
		$queryString = 'x=one+two&y=%23';

		// Test getLocalURL()
		$this->assertSame(
			'/en-x-piglatin/' . $name,
			$title->getLocalURL( $query1 ),
			'getLocalURL( array )'
		);
		$this->assertSame( // NOTE: this could as well apply the variant path
			'/m/index.php?title=' . $name . '&variant=en-x-piglatin&' . $queryString,
			$title->getLocalURL( $query1 + $query2 ),
			'getLocalURL( array + array )'
		);

		// Test getFullURL()
		$this->assertSame(
			'//m.xx.wiki.test/en-x-piglatin/' . $name,
			$title->getFullURL( $query1 ),
			'getFullURL( array )'
		);

		// Test getFullUrlForRedirect()
		// Note that it uses PROTO_CURRENT by default, which is 'http' for tests
		$this->assertSame(
			'http://m.xx.wiki.test/en-x-piglatin/' . $name,
			$title->getFullUrlForRedirect( $query1 ),
			'getFullUrlForRedirect( array )'
		);

		// Test getLinkURL()
		$this->assertSame(
			'/en-x-piglatin/' . $name,
			$title->getLinkURL( $query1 ),
			'getLinkURL( array + array )'
		);

		// Test getInternalURL()
		$this->assertSame(
			'http://app23.internal/en-x-piglatin/' . $name,
			$title->getInternalURL( $query1 ),
			'getInternalURL( array )'
		);

		// Test getCanonicalURL()
		$this->assertSame(
			'https://xx.wiki.test/en-x-piglatin/' . $name,
			$title->getCanonicalURL( $query1 ),
			'getCanonicalURL( array )'
		);
	}

	public function testUrlsForMainPage() {
		$title = Title::newMainPage();
		$name = $title->getPrefixedURL();

		$query = [ 'x' => 'one two', 'y' => '#' ];
		$queryString = 'x=one+two&y=%23';

		// Test getLocalURL()
		$this->assertSame(
			'/',
			$title->getLocalURL(),
			'getLocalURL()'
		);
		$this->assertSame(
			'/m/index.php?title=' . $name . '&' . $queryString,
			$title->getLocalURL( $query ),
			'getLocalURL( array )'
		);

		// Test getFullURL()
		$this->assertSame(
			'//m.xx.wiki.test/',
			$title->getFullURL(),
			'getFullURL()'
		);
		$this->assertSame(
			'//m.xx.wiki.test/m/index.php?title=' . $name . '&' . $queryString,
			$title->getFullURL( $query ),
			'getFullURL( array )'
		);

		// Test getFullUrlForRedirect()
		// Note that it uses PROTO_CURRENT by default, which is 'http' for tests
		$this->assertSame(
			'http://m.xx.wiki.test/',
			$title->getFullUrlForRedirect(),
			'getFullUrlForRedirect()'
		);
		$this->assertSame(
			'http://m.xx.wiki.test/m/index.php?title=' . $name . '&' . $queryString,
			$title->getFullUrlForRedirect( $query ),
			'getFullUrlForRedirect( array )'
		);

		// Test getLinkURL()
		$this->assertSame(
			'/',
			$title->getLinkURL(),
			'getLinkURL()'
		);
		$this->assertSame(
			'/m/index.php?title=' . $name . '&' . $queryString,
			$title->getLinkURL( $query ),
			'getLinkURL( array )'
		);

		// Test getInternalURL()
		$this->assertSame(
			'http://app23.internal/',
			$title->getInternalURL(),
			'getInternalURL()'
		);
		$this->assertSame(
			'http://app23.internal/m/index.php?title=' . $name . '&' . $queryString,
			$title->getInternalURL( $query ),
			'getInternalURL( array )'
		);

		// Test getCanonicalURL()
		$this->assertSame(
			'https://xx.wiki.test/',
			$title->getCanonicalURL(),
			'getCanonicalURL()'
		);
		$this->assertSame(
			'https://xx.wiki.test/m/index.php?title=' . $name . '&' . $queryString,
			$title->getCanonicalURL( $query ),
			'getCanonicalURL( array )'
		);
	}

	public function testUrlsForTitleWithFragment() {
		$title = Title::makeTitle( NS_USER, 'Göatee', 'Sectiön' );
		$name = $title->getPrefixedURL();
		$fragment = 'Secti.C3.B6n';

		$query = [ 'x' => 'one two', 'y' => '#' ];
		$queryString = 'x=one+two&y=%23';

		// Test getLocalURL() ignores fragment
		$this->assertSame(
			'/wiki/' . $name,
			$title->getLocalURL(),
			'getLocalURL()'
		);
		$this->assertSame(
			'/m/index.php?title=' . $name . '&' . $queryString,
			$title->getLocalURL( $query ),
			'getLocalURL( array )'
		);

		// Test getFullURL() includes fragment
		$this->assertSame(
			'//m.xx.wiki.test/wiki/' . $name . '#' . $fragment,
			$title->getFullURL(),
			'getFullURL()'
		);
		$this->assertSame(
			'//m.xx.wiki.test/m/index.php?title=' . $name . '&' . $queryString . '#' . $fragment,
			$title->getFullURL( $query ),
			'getFullURL( array )'
		);
		$this->assertSame(
			'https://xx.wiki.test/wiki/' . $name . '#' . $fragment,
			$title->getFullURL( [], false, PROTO_CANONICAL ),
			'getFullURL() with PROTO_CANONICAL'
		);

		// Test getFullUrlForRedirect() includes fragment
		$this->assertSame(
			'http://m.xx.wiki.test/wiki/' . $name . '#' . $fragment,
			$title->getFullUrlForRedirect(),
			'getFullUrlForRedirect()'
		);
		$this->assertSame(
			'http://m.xx.wiki.test/m/index.php?title=' . $name . '&' . $queryString . '#' . $fragment,
			$title->getFullUrlForRedirect( $query ),
			'getFullUrlForRedirect( array )'
		);
		$this->assertSame(
			'//m.xx.wiki.test/wiki/' . $name . '#' . $fragment,
			$title->getFullUrlForRedirect( [], PROTO_RELATIVE ),
			'getFullUrlForRedirect() with PROTO_RELATIVE'
		);

		// Test getLinkURL() includes fragment
		$this->assertSame(
			'/wiki/' . $name . '#' . $fragment,
			$title->getLinkURL(),
			'getLinkURL()'
		);
		$this->assertSame(
			'/m/index.php?title=' . $name . '&' . $queryString . '#' . $fragment,
			$title->getLinkURL( $query ),
			'getLinkURL( array )'
		);
		$this->assertSame(
			'//m.xx.wiki.test/wiki/' . $name . '#' . $fragment,
			$title->getLinkURL( [], false, PROTO_RELATIVE ),
			'getLinkURL() with PROTO_RELATIVE'
		);

		// Test getInternalURL() ignores fragment
		$this->assertSame(
			'http://app23.internal/wiki/' . $name,
			$title->getInternalURL(),
			'getInternalURL()'
		);
		$this->assertSame(
			'http://app23.internal/m/index.php?title=' . $name . '&' . $queryString,
			$title->getInternalURL( $query ),
			'getInternalURL( array )'
		);

		// Test getCanonicalURL() includes fragment
		$this->assertSame(
			'https://xx.wiki.test/wiki/' . $name . '#' . $fragment,
			$title->getCanonicalURL(),
			'getCanonicalURL()'
		);
		$this->assertSame(
			'https://xx.wiki.test/m/index.php?title=' . $name . '&' . $queryString . '#' . $fragment,
			$title->getCanonicalURL( $query ),
			'getCanonicalURL( array )'
		);

		// Test $wgFragmentMode
		$this->overrideConfigValue( MainConfigNames::FragmentMode, [ 'html5', 'legacy' ] );
		$fragment = 'Sectiön';

		$this->assertSame(
			'//m.xx.wiki.test/wiki/' . $name . '#' . $fragment,
			$title->getFullURL(),
			'getFullURL()'
		);
		$this->assertSame(
			'/wiki/' . $name . '#' . $fragment,
			$title->getLinkURL(),
			'getLinkURL()'
		);
	}

	public function testUrlsWithFragmentOnly() {
		$title = Title::makeTitle( NS_MAIN, '', 'Jümp' );
		$fragment = Sanitizer::escapeIdForLink( 'Jümp' );

		$query = [ 'x' => 'one two', 'y' => '#' ];
		$queryString = 'x=one+two&y=%23';

		// Test getLocalURL() ignores fragment
		$this->assertSame( // NOTE: not useful, may change!
			'/wiki/',
			$title->getLocalURL(),
			'getLocalURL()'
		);
		$this->assertSame( // NOTE: not useful, may change!
			'/m/index.php?title=&' . $queryString,
			$title->getLocalURL( $query ),
			'getLocalURL( array )'
		);

		// Test getFullURL() includes fragment
		$this->assertSame( // NOTE: not useful, may change!
			'//m.xx.wiki.test/wiki/#' . $fragment,
			$title->getFullURL(),
			'getFullURL()'
		);
		$this->assertSame( // NOTE: not useful, may change!
			'//m.xx.wiki.test/m/index.php?title=&' . $queryString . '#' . $fragment,
			$title->getFullURL( $query ),
			'getFullURL( array )'
		);
		$this->assertSame( // NOTE: not useful, may change!
			'https://xx.wiki.test/wiki/#' . $fragment,
			$title->getFullURL( [], false, PROTO_CANONICAL ),
			'getFullURL() with PROTO_CANONICAL'
		);

		// Test getFullUrlForRedirect() includes fragment
		$this->assertSame( // NOTE: not useful, may change!
			'http://m.xx.wiki.test/wiki/#' . $fragment,
			$title->getFullUrlForRedirect(),
			'getFullUrlForRedirect()'
		);
		$this->assertSame( // NOTE: not useful, may change!
			'http://m.xx.wiki.test/m/index.php?title=&' . $queryString . '#' . $fragment,
			$title->getFullUrlForRedirect( $query ),
			'getFullUrlForRedirect( array )'
		);
		$this->assertSame( // NOTE: not useful, may change!
			'//m.xx.wiki.test/wiki/#' . $fragment,
			$title->getFullUrlForRedirect( [], PROTO_RELATIVE ),
			'getFullUrlForRedirect() with PROTO_RELATIVE'
		);

		// Test getLinkURL()
		$this->assertSame( // NOTE: this is the one useful way to handle a fragment jump
			'#' . $fragment,
			$title->getLinkURL(),
			'getLinkURL()'
		);
		$this->assertSame(
			'#' . $fragment, // NOTE: not useful, may change!
			$title->getLinkURL( $query ),
			'getLinkURL( array )'
		);
		$this->assertSame( // NOTE: not useful, may change!
			'//m.xx.wiki.test/wiki/#' . $fragment,
			$title->getLinkURL( [], false, PROTO_RELATIVE ),
			'getLinkURL() with PROTO_RELATIVE'
		);
		$this->assertSame( // NOTE: not useful, may change!
			'https://xx.wiki.test/m/index.php?title=&' . $queryString . '#' . $fragment,
			$title->getLinkURL( $query, false, PROTO_CANONICAL ),
			'getLinkURL() with PROTO_RELATIVE'
		);

		// Test getInternalURL() ignores fragment
		$this->assertSame( // NOTE: not useful, may change!
			'http://app23.internal/wiki/',
			$title->getInternalURL(),
			'getInternalURL()'
		);
		$this->assertSame( // NOTE: not useful, may change!
			'http://app23.internal/m/index.php?title=&' . $queryString,
			$title->getInternalURL( $query ),
			'getInternalURL( array )'
		);

		// Test getCanonicalURL() includes fragment
		$this->assertSame( // NOTE: not useful, may change!
			'https://xx.wiki.test/wiki/#' . $fragment,
			$title->getCanonicalURL(),
			'getCanonicalURL()'
		);
		$this->assertSame( // NOTE: not useful, may change!
			'https://xx.wiki.test/m/index.php?title=&' . $queryString . '#' . $fragment,
			$title->getCanonicalURL( $query ),
			'getCanonicalURL( array )'
		);
	}

	public function testUrlsForUnknownInterwiki() {
		// the "xyzzy" prefix is not known
		$title = Title::makeTitle( NS_MAIN, 'Foobär', '', 'xyzzy' );
		$name = $title->getPrefixedURL();

		$query = [ 'x' => 'one two', 'y' => '#' ];
		$queryString = 'x=one+two&y=%23';

		// Test getLocalURL()
		$this->assertSame(
			'/wiki/' . $name,
			$title->getLocalURL(),
			'getLocalURL()'
		);
		$this->assertSame(
			'/m/index.php?title=' . $name . '&' . $queryString,
			$title->getLocalURL( $query ),
			'getLocalURL( array )'
		);

		// Test getFullURL()
		$this->assertSame(
			'//m.xx.wiki.test/wiki/' . $name,
			$title->getFullURL(),
			'getFullURL()'
		);
		$this->assertSame(
			'//m.xx.wiki.test/m/index.php?title=' . $name . '&' . $queryString,
			$title->getFullURL( $query ),
			'getFullURL( array )'
		);
		$this->assertSame(
			'https://m.xx.wiki.test/wiki/' . $name,
			$title->getFullURL( [], false, PROTO_HTTPS ),
			'getFullURL( array ) with PROTO_HTTPS'
		);

		// Test getFullUrlForRedirect()
		// Note that it uses PROTO_CURRENT by default, which is 'http' for tests
		$this->assertSame(
			'http://m.xx.wiki.test/wiki/Special:GoToInterwiki/' . $name,
			$title->getFullUrlForRedirect(),
			'getFullUrlForRedirect()'
		);
		$this->assertSame(
			'http://m.xx.wiki.test/m/index.php?title=Special:GoToInterwiki/'
				. $name . '&' . $queryString,
			$title->getFullUrlForRedirect( $query ),
			'getFullUrlForRedirect( array )'
		);

		// Test getLinkURL()
		$this->assertSame( // NOTE: could also just be '/wiki/...'
			'//m.xx.wiki.test/wiki/' . $name,
			$title->getLinkURL(),
			'getLinkURL()'
		);
		$this->assertSame( // NOTE: could also just be '/m/...'
			'//m.xx.wiki.test/m/index.php?title=' . $name . '&' . $queryString,
			$title->getLinkURL( $query ),
			'getLinkURL( array )'
		);

		// Test getInternalURL()
		$this->assertSame(
			'http://app23.internal/wiki/' . $name,
			$title->getInternalURL(),
			'getInternalURL()'
		);
		$this->assertSame(
			'http://app23.internal/m/index.php?title=' . $name . '&' . $queryString,
			$title->getInternalURL( $query ),
			'getInternalURL( array )'
		);

		// Test getCanonicalURL()
		$this->assertSame(
			'https://xx.wiki.test/wiki/' . $name,
			$title->getCanonicalURL(),
			'getCanonicalURL()'
		);
		$this->assertSame(
			'https://xx.wiki.test/m/index.php?title=' . $name . '&' . $queryString,
			$title->getCanonicalURL( $query ),
			'getCanonicalURL( array )'
		);
	}

	public function testUrlsForExternalInterwikiWithFragment() {
		// the "acme" prefix is a known external interwiki prefix
		$title = Title::makeTitle( NS_MAIN, 'fröbnitz/foo+bar', 'Sectiön', 'acme' );
		$section = 'Sectiön';

		// NOTE: The "/" should remain unencoded even in interwiki links
		$name = wfUrlencode( $title->getDBkey() );

		$query = [ 'x' => 'one two+three', 'y' => '#' ];
		$queryString = 'x=one+two%2Bthree&y=%23';

		// Test getLocalURL()
		$this->assertSame(
			'https://acme.test/' . $name,
			$title->getLocalURL(),
			'getLocalURL()'
		);
		$this->assertSame(
			'https://acme.test/' . $name . '?' . $queryString,
			$title->getLocalURL( $query ),
			'getLocalURL( array )'
		);

		// Test getFullURL()
		$this->assertSame(
			'https://acme.test/' . $name . '#' . $section,
			$title->getFullURL(),
			'getFullURL()'
		);
		$this->assertSame(
			'https://acme.test/' . $name . '?' . $queryString . '#' . $section,
			$title->getFullURL( $query ),
			'getFullURL( array )'
		);
		$this->assertSame( // should not trigger action path
			'https://acme.test/' . $name . '?action=edit#' . $section,
			$title->getFullURL( [ 'action' => 'edit' ] ),
			'getFullURL( array )'
		);
		$this->assertSame( // should not trigger variant path
			'https://acme.test/' . $name . '?variant=en-x-piglatin#' . $section,
			$title->getFullURL( [ 'variant' => 'en-x-piglatin' ] ),
			'getFullURL( array )'
		);

		// Test getFullUrlForRedirect()
		// Note that it uses PROTO_CURRENT by default, which is 'http' for tests
		$this->assertSame(
			'http://m.xx.wiki.test/wiki/Special:GoToInterwiki/acme:' . $name,
			$title->getFullUrlForRedirect(),
			'getFullUrlForRedirect()'
		);
		$this->assertSame(
			'http://m.xx.wiki.test/m/index.php?title=Special:GoToInterwiki/acme:'
				. $name . '&' . $queryString,
			$title->getFullUrlForRedirect( $query ),
			'getFullUrlForRedirect( array )'
		);

		// Test getLinkURL() includes fragment
		$this->assertSame(
			'https://acme.test/' . $name . '#' . $section,
			$title->getLinkURL(),
			'getLinkURL()'
		);
		$this->assertSame(
			'https://acme.test/' . $name . '?' . $queryString . '#' . $section,
			$title->getLinkURL( $query ),
			'getLinkURL( array )'
		);

		// Test getInternalURL() ignores fragment
		$this->assertSame( // NOTE: the current behavior is just wrong.
			'http://app23.internalhttps//acme.test/' . $name,
			$title->getInternalURL(),
			'getInternalURL()'
		);
		$this->assertSame( // NOTE: the current behavior is just wrong.
			'http://app23.internalhttps//acme.test/' . $name . '?' . $queryString,
			$title->getInternalURL( $query ),
			'getInternalURL( array )'
		);

		// Test getCanonicalURL() includes fragment
		$this->assertSame(
			'https://acme.test/' . $name . '#' . $section,
			$title->getCanonicalURL(),
			'getCanonicalURL()'
		);
		$this->assertSame(
			'https://acme.test/' . $name . '?' . $queryString . '#' . $section,
			$title->getCanonicalURL( $query ),
			'getCanonicalURL( array )'
		);

		// Test $wgFragmentMode
		$this->overrideConfigValues( [
			MainConfigNames::FragmentMode => [ 'html5', 'legacy' ],
			MainConfigNames::ExternalInterwikiFragmentMode => 'legacy',
		] );
		$section = 'Secti.C3.B6n';

		$this->assertSame(
			'https://acme.test/' . $name . '#' . $section,
			$title->getFullURL(),
			'getFullURL()'
		);
		$this->assertSame(
			'https://acme.test/' . $name . '#' . $section,
			$title->getLinkURL(),
			'getLinkURL()'
		);
	}

	public function testUrlsForLocalInterwikiWithFragment() {
		// the "yy" prefix is a known local interwiki prefix
		$title = Title::makeTitle( NS_MAIN, 'fröbnitz', 'Sectiön', 'yy' );
		$name = wfUrlencode( $title->getDBkey() );

		// local interwikis use $wgFragmentMode, not $wgExternalInterwikiFragmentMode
		$section = 'Secti.C3.B6n';

		$query = [ 'x' => 'one two', 'y' => '#' ];
		$queryString = 'x=one+two&y=%23';

		// Test getLocalURL()
		$this->assertSame(
			'//yy.wiki.test/wiki/' . $name,
			$title->getLocalURL(),
			'getLocalURL()'
		);
		$this->assertSame(
			'//yy.wiki.test/wiki/' . $name . '?' . $queryString,
			$title->getLocalURL( $query ),
			'getLocalURL( array )'
		);

		// Test getFullURL()
		$this->assertSame(
			'//yy.wiki.test/wiki/' . $name . '#' . $section,
			$title->getFullURL(),
			'getFullURL()'
		);
		$this->assertSame(
			'//yy.wiki.test/wiki/' . $name . '?' . $queryString . '#' . $section,
			$title->getFullURL( $query ),
			'getFullURL( array )'
		);
		$this->assertSame(
			'https://yy.wiki.test/wiki/' . $name . '#' . $section,
			$title->getFullURL( [], false, PROTO_HTTPS ),
			'getFullURL( array ) with PROTO_HTTPS'
		);
		$this->assertSame(
			'https://yy.wiki.test/wiki/' . $name . '#' . $section,
			$title->getFullURL( [], false, PROTO_CANONICAL ),
			'getFullURL( array ) with PROTO_CANONICAL'
		);
		$this->assertSame(
			'http://yy.wiki.test/wiki/' . $name . '#' . $section,
			$title->getFullURL( [], false, PROTO_INTERNAL ),
			'getFullURL( array ) with PROTO_INTERNAL'
		);
		$this->assertSame( // NOTE: could as well use action path
			'//yy.wiki.test/wiki/' . $name . '?action=edit#' . $section,
			$title->getFullURL( [ 'action' => 'edit' ] ),
			'getFullURL( array )'
		);
		$this->assertSame( // NOTE: could as well use variant path
			'//yy.wiki.test/wiki/' . $name . '?variant=en-x-piglatin#' . $section,
			$title->getFullURL( [ 'variant' => 'en-x-piglatin' ] ),
			'getFullURL( array )'
		);

		// Test getFullUrlForRedirect()
		// Note that it uses PROTO_CURRENT by default, which is 'http' for tests
		$this->assertSame(
			'http://m.xx.wiki.test/wiki/Special:GoToInterwiki/yy:' . $name,
			$title->getFullUrlForRedirect(),
			'getFullUrlForRedirect()'
		);
		$this->assertSame(
			'http://m.xx.wiki.test/m/index.php?title=Special:GoToInterwiki/yy:'
			. $name . '&' . $queryString,
			$title->getFullUrlForRedirect( $query ),
			'getFullUrlForRedirect( array )'
		);

		// Test getLinkURL() includes fragment
		$this->assertSame(
			'//yy.wiki.test/wiki/' . $name . '#' . $section,
			$title->getLinkURL(),
			'getLinkURL()'
		);
		$this->assertSame(
			'//yy.wiki.test/wiki/' . $name . '?' . $queryString . '#' . $section,
			$title->getLinkURL( $query ),
			'getLinkURL( array )'
		);
		$this->assertSame(
			'https://yy.wiki.test/wiki/' . $name . '?' . $queryString . '#' . $section,
			$title->getLinkURL( $query, false, PROTO_CANONICAL ),
			'getLinkURL( array ) with PROTO_CANONICAL'
		);
		$this->assertSame(
			'http://yy.wiki.test/wiki/' . $name . '?' . $queryString . '#' . $section,
			$title->getLinkURL( $query, false, PROTO_INTERNAL ),
			'getLinkURL( array ) with PROTO_INTERNAL'
		);
		$this->assertSame(
			'https://yy.wiki.test/wiki/' . $name . '?' . $queryString . '#' . $section,
			$title->getLinkURL( $query, false, PROTO_HTTPS ),
			'getLinkURL( array ) with PROTO_HTTPS'
		);

		// Test getInternalURL() ignores fragment
		$this->assertSame( // NOTE: the current behavior is just wrong.
			'http://app23.internal//yy.wiki.test/wiki/' . $name,
			$title->getInternalURL(),
			'getInternalURL()'
		);
		$this->assertSame( // NOTE: the current behavior is just wrong.
			'http://app23.internal//yy.wiki.test/wiki/' . $name . '?' . $queryString,
			$title->getInternalURL( $query ),
			'getInternalURL( array )'
		);

		// Test getCanonicalURL() includes fragment
		$this->assertSame(
			'https://yy.wiki.test/wiki/' . $name . '#' . $section,
			$title->getCanonicalURL(),
			'getCanonicalURL()'
		);
		$this->assertSame(
			'https://yy.wiki.test/wiki/' . $name . '?' . $queryString . '#' . $section,
			$title->getCanonicalURL( $query ),
			'getCanonicalURL( array )'
		);

		// Test $wgFragmentMode
		$this->overrideConfigValues( [
			MainConfigNames::FragmentMode => [ 'html5', 'legacy' ],
			MainConfigNames::ExternalInterwikiFragmentMode => 'legacy',
		] );
		$section = 'Sectiön';

		$this->assertSame(
			'//yy.wiki.test/wiki/' . $name . '#' . $section,
			$title->getFullURL(),
			'getFullURL()'
		);
		$this->assertSame(
			'//yy.wiki.test/wiki/' . $name . '#' . $section,
			$title->getLinkURL(),
			'getLinkURL()'
		);
	}

	public function testUrlsWithHttpsPort() {
		$title = Title::makeTitle( NS_USER, 'Göatee' );
		$name = $title->getPrefixedURL();

		// NOTE: $wgHttpsPort is only supported if $wgCanonicalServer does not use HTTPS
		$this->overrideConfigValues( [
			MainConfigNames::CanonicalServer => 'http://xx.wiki.test',
			MainConfigNames::HttpsPort => '4444',
		] );

		// Test getFullURL()
		$this->assertSame(
			'//m.xx.wiki.test/wiki/' . $name,
			$title->getFullURL( [], false, PROTO_RELATIVE ),
			'getFullURL() with PROTO_RELATIVE'
		);
		$this->assertSame(
			'http://xx.wiki.test/wiki/' . $name,
			$title->getFullURL( [], false, PROTO_CANONICAL ),
			'getFullURL() with PROTO_CANONICAL'
		);
		$this->assertSame(
			'http://m.xx.wiki.test/wiki/' . $name,
			$title->getFullURL( [], false, PROTO_HTTP ),
			'getFullURL() with PROTO_HTTP'
		);
		$this->assertSame(
			'https://m.xx.wiki.test:4444/wiki/' . $name,
			$title->getFullURL( [], false, PROTO_HTTPS ),
			'getFullURL() with PROTO_HTTP'
		);
		$this->assertSame(
			'http://app23.internal/wiki/' . $name,
			$title->getFullURL( [], false, PROTO_INTERNAL ),
			'getFullURL() with PROTO_INTERNAL'
		);

		// Test getFullUrlForRedirect()
		$this->assertSame(
			'//m.xx.wiki.test/wiki/' . $name,
			$title->getFullUrlForRedirect( [], PROTO_RELATIVE ),
			'getFullUrlForRedirect() with PROTO_RELATIVE'
		);
		$this->assertSame(
			'https://m.xx.wiki.test:4444/wiki/' . $name,
			$title->getFullUrlForRedirect( [], PROTO_HTTPS ),
			'getFullUrlForRedirect() with PROTO_HTTPS'
		);

		// Test getLinkURL()
		$this->assertSame(
			'//m.xx.wiki.test/wiki/' . $name,
			$title->getLinkURL( [], false, PROTO_RELATIVE ),
			'getLinkURL() with PROTO_RELATIVE'
		);
		$this->assertSame(
			'https://m.xx.wiki.test:4444/wiki/' . $name,
			$title->getLinkURL( [], false, PROTO_HTTPS ),
			'getLinkURL() with PROTO_HTTPS'
		);
		$this->assertSame(
			'http://xx.wiki.test/wiki/' . $name,
			$title->getLinkURL( [], false, PROTO_CANONICAL ),
			'getLinkURL() with PROTO_CANONICAL'
		);
		$this->assertSame(
			'http://app23.internal/wiki/' . $name,
			$title->getLinkURL( [], false, PROTO_INTERNAL ),
			'getLinkURL() with PROTO_INTERNAL'
		);

		// Test getInternalURL()
		$this->assertSame(
			'http://app23.internal/wiki/' . $name,
			$title->getInternalURL(),
			'getInternalURL()'
		);

		// Test getCanonicalURL()
		$this->assertSame(
			'http://xx.wiki.test/wiki/' . $name,
			$title->getCanonicalURL(),
			'getCanonicalURL()'
		);
	}

}
PK       ! 	Ԏ  Ԏ    title/NamespaceInfoTest.phpnu Iw        <?php
/**
 * @author Antoine Musso
 * @copyright Copyright © 2011, Antoine Musso
 * @file
 */

use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MainConfigNames;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;

class NamespaceInfoTest extends MediaWikiIntegrationTestCase {
	use TestAllServiceOptionsUsed;

	private const TEST_EXT_NAMESPACES = [ NS_MAIN => 'No effect', NS_TALK => 'No effect', 12345 => 'Extended' ];

	/**********************************************************************************************
	 * Shared code
	 * %{
	 */

	private const DEFAULT_OPTIONS = [
		MainConfigNames::CanonicalNamespaceNames => [
			NS_TALK => 'Talk',
			NS_USER => 'User',
			NS_USER_TALK => 'User_talk',
			NS_SPECIAL => 'Special',
			NS_MEDIA => 'Media',
		],
		MainConfigNames::CapitalLinkOverrides => [],
		MainConfigNames::CapitalLinks => true,
		MainConfigNames::ContentNamespaces => [ NS_MAIN ],
		MainConfigNames::ExtraNamespaces => [],
		MainConfigNames::ExtraSignatureNamespaces => [],
		MainConfigNames::NamespaceContentModels => [],
		MainConfigNames::NamespacesWithSubpages => [
			NS_TALK => true,
			NS_USER => true,
			NS_USER_TALK => true,
		],
		MainConfigNames::NonincludableNamespaces => [],
	];

	/**
	 * @return HookContainer
	 */
	private function getHookContainer() {
		return $this->getServiceContainer()->getHookContainer();
	}

	private function newObj( array $options = [], array $extensionNamespaces = [] ): NamespaceInfo {
		return new NamespaceInfo(
			new LoggedServiceOptions(
				self::$serviceOptionsAccessLog,
				NamespaceInfo::CONSTRUCTOR_OPTIONS,
				$options, self::DEFAULT_OPTIONS
			),
			$this->getHookContainer(),
			$extensionNamespaces,
			[]
		);
	}

	// %} End shared code

	/**********************************************************************************************
	 * Basic methods
	 * %{
	 */

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::__construct
	 * @dataProvider provideConstructor
	 * @param ServiceOptions $options
	 * @param string|null $expectedExceptionText
	 */
	public function testConstructor( ServiceOptions $options, $expectedExceptionText = null ) {
		if ( $expectedExceptionText !== null ) {
			$this->expectException( \Wikimedia\Assert\PreconditionException::class );
			$this->expectExceptionMessage( $expectedExceptionText );
		}
		new NamespaceInfo( $options, $this->getHookContainer(), [], [] );
		$this->assertTrue( true );
	}

	public static function provideConstructor() {
		return [
			[ new ServiceOptions( NamespaceInfo::CONSTRUCTOR_OPTIONS, self::DEFAULT_OPTIONS ) ],
			[ new ServiceOptions( [], [] ), 'Required options missing: ' ],
			[ new ServiceOptions(
				array_merge( NamespaceInfo::CONSTRUCTOR_OPTIONS, [ 'invalid' ] ),
				self::DEFAULT_OPTIONS,
				[ 'invalid' => '' ]
			), 'Unsupported options passed: invalid' ],
		];
	}

	/**
	 * @dataProvider provideIsMovable
	 * @covers \MediaWiki\Title\NamespaceInfo::isMovable
	 *
	 * @param bool $expected
	 * @param int $ns
	 */
	public function testIsMovable( $expected, $ns ) {
		$obj = $this->newObj();
		$this->assertSame( $expected, $obj->isMovable( $ns ) );
	}

	public static function provideIsMovable() {
		return [
			'Main' => [ true, NS_MAIN ],
			'Talk' => [ true, NS_TALK ],
			'Special' => [ false, NS_SPECIAL ],
			'Nonexistent even namespace' => [ true, 1234 ],
			'Nonexistent odd namespace' => [ true, 12345 ],

			'Media' => [ false, NS_MEDIA ],
			'File' => [ true, NS_FILE ],
		];
	}

	/**
	 * @param int $ns
	 * @param bool $expected
	 * @dataProvider provideIsSubject
	 * @covers \MediaWiki\Title\NamespaceInfo::isSubject
	 */
	public function testIsSubject( $ns, $expected ) {
		$this->assertSame( $expected, $this->newObj()->isSubject( $ns ) );
	}

	/**
	 * @param int $ns
	 * @param bool $expected
	 * @dataProvider provideIsSubject
	 * @covers \MediaWiki\Title\NamespaceInfo::isTalk
	 */
	public function testIsTalk( $ns, $expected ) {
		$this->assertSame( !$expected, $this->newObj()->isTalk( $ns ) );
	}

	public static function provideIsSubject() {
		return [
			// Special namespaces
			[ NS_MEDIA, true ],
			[ NS_SPECIAL, true ],

			// Subject pages
			[ NS_MAIN, true ],
			[ NS_USER, true ],
			[ 100, true ],

			// Talk pages
			[ NS_TALK, false ],
			[ NS_USER_TALK, false ],
			[ 101, false ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::exists
	 * @dataProvider provideExists
	 * @param int $ns
	 * @param bool $expected
	 */
	public function testExists( $ns, $expected ) {
		$this->assertSame( $expected, $this->newObj()->exists( $ns ) );
	}

	public static function provideExists() {
		return [
			'Main' => [ NS_MAIN, true ],
			'Talk' => [ NS_TALK, true ],
			'Media' => [ NS_MEDIA, true ],
			'Special' => [ NS_SPECIAL, true ],
			'Nonexistent' => [ 12345, false ],
			'Negative nonexistent' => [ -12345, false ],
		];
	}

	/**
	 * Note if we add a namespace registration system with keys like 'MAIN'
	 * we should add tests here for equivalence on things like 'MAIN' == 0
	 * and 'MAIN' == NS_MAIN.
	 * @covers \MediaWiki\Title\NamespaceInfo::equals
	 */
	public function testEquals() {
		$obj = $this->newObj();
		$this->assertTrue( $obj->equals( NS_MAIN, NS_MAIN ) );
		$this->assertTrue( $obj->equals( NS_MAIN, 0 ) ); // In case we make NS_MAIN 'MAIN'
		$this->assertTrue( $obj->equals( NS_USER, NS_USER ) );
		$this->assertTrue( $obj->equals( NS_USER, 2 ) );
		$this->assertTrue( $obj->equals( NS_USER_TALK, NS_USER_TALK ) );
		$this->assertTrue( $obj->equals( NS_SPECIAL, NS_SPECIAL ) );
		$this->assertFalse( $obj->equals( NS_MAIN, NS_TALK ) );
		$this->assertFalse( $obj->equals( NS_USER, NS_USER_TALK ) );
		$this->assertFalse( $obj->equals( NS_PROJECT, NS_TEMPLATE ) );
	}

	/**
	 * @param int $ns1
	 * @param int $ns2
	 * @param bool $expected
	 * @dataProvider provideSubjectEquals
	 * @covers \MediaWiki\Title\NamespaceInfo::subjectEquals
	 */
	public function testSubjectEquals( $ns1, $ns2, $expected ) {
		$this->assertSame( $expected, $this->newObj()->subjectEquals( $ns1, $ns2 ) );
	}

	public static function provideSubjectEquals() {
		return [
			[ NS_MAIN, NS_MAIN, true ],
			// In case we make NS_MAIN 'MAIN'
			[ NS_MAIN, 0, true ],
			[ NS_USER, NS_USER, true ],
			[ NS_USER, 2, true ],
			[ NS_USER_TALK, NS_USER_TALK, true ],
			[ NS_SPECIAL, NS_SPECIAL, true ],
			[ NS_MAIN, NS_TALK, true ],
			[ NS_USER, NS_USER_TALK, true ],

			[ NS_PROJECT, NS_TEMPLATE, false ],
			[ NS_SPECIAL, NS_MAIN, false ],
			[ NS_MEDIA, NS_SPECIAL, false ],
			[ NS_SPECIAL, NS_MEDIA, false ],
		];
	}

	/**
	 * @dataProvider provideHasTalkNamespace
	 * @covers \MediaWiki\Title\NamespaceInfo::hasTalkNamespace
	 *
	 * @param int $ns
	 * @param bool $expected
	 */
	public function testHasTalkNamespace( $ns, $expected ) {
		$this->assertSame( $expected, $this->newObj()->hasTalkNamespace( $ns ) );
	}

	public static function provideHasTalkNamespace() {
		return [
			[ NS_MEDIA, false ],
			[ NS_SPECIAL, false ],

			[ NS_MAIN, true ],
			[ NS_TALK, true ],
			[ NS_USER, true ],
			[ NS_USER_TALK, true ],

			[ 100, true ],
			[ 101, true ],
		];
	}

	/**
	 * @param int $ns
	 * @param bool $expected
	 * @param array $contentNamespaces
	 * @covers \MediaWiki\Title\NamespaceInfo::isContent
	 * @dataProvider provideIsContent
	 */
	public function testIsContent( $ns, $expected, $contentNamespaces = [ NS_MAIN ] ) {
		$obj = $this->newObj( [ MainConfigNames::ContentNamespaces => $contentNamespaces ] );
		$this->assertSame( $expected, $obj->isContent( $ns ) );
	}

	public static function provideIsContent() {
		return [
			[ NS_MAIN, true ],
			[ NS_MEDIA, false ],
			[ NS_SPECIAL, false ],
			[ NS_TALK, false ],
			[ NS_USER, false ],
			[ NS_CATEGORY, false ],
			[ 100, false ],
			[ 100, true, [ NS_MAIN, 100, 252 ] ],
			[ 252, true, [ NS_MAIN, 100, 252 ] ],
			[ NS_MAIN, true, [ NS_MAIN, 100, 252 ] ],
			// NS_MAIN is always content
			[ NS_MAIN, true, [] ],
		];
	}

	/**
	 * @dataProvider provideWantSignatures
	 * @covers \MediaWiki\Title\NamespaceInfo::wantSignatures
	 *
	 * @param int $index
	 * @param bool $expected
	 */
	public function testWantSignatures( $index, $expected ) {
		$this->assertSame( $expected, $this->newObj()->wantSignatures( $index ) );
	}

	public static function provideWantSignatures() {
		return [
			'Main' => [ NS_MAIN, false ],
			'Talk' => [ NS_TALK, true ],
			'User' => [ NS_USER, false ],
			'User talk' => [ NS_USER_TALK, true ],
			'Special' => [ NS_SPECIAL, false ],
			'Media' => [ NS_MEDIA, false ],
			'Nonexistent talk' => [ 12345, true ],
			'Nonexistent subject' => [ 123456, false ],
			'Nonexistent negative odd' => [ -12345, false ],
		];
	}

	/**
	 * @dataProvider provideWantSignatures_ExtraSignatureNamespaces
	 * @covers \MediaWiki\Title\NamespaceInfo::wantSignatures
	 *
	 * @param int $index
	 * @param int $expected
	 */
	public function testWantSignatures_ExtraSignatureNamespaces( $index, $expected ) {
		$obj = $this->newObj( [ MainConfigNames::ExtraSignatureNamespaces =>
			[ NS_MAIN, NS_USER, NS_SPECIAL, NS_MEDIA, 123456, -12345 ] ] );
		$this->assertSame( $expected, $obj->wantSignatures( $index ) );
	}

	public function provideWantSignatures_ExtraSignatureNamespaces() {
		$ret = array_map(
			static function ( $arr ) {
				// We've added all these as extra signature namespaces, so expect true
				return [ $arr[0], true ];
			},
			$this->provideWantSignatures()
		);

		// Add one more that's false
		$ret['Another nonexistent subject'] = [ 12345678, false ];
		return $ret;
	}

	/**
	 * @param int $ns
	 * @param bool $expected
	 * @covers \MediaWiki\Title\NamespaceInfo::isWatchable
	 * @dataProvider provideIsWatchable
	 */
	public function testIsWatchable( $ns, $expected ) {
		$this->assertSame( $expected, $this->newObj()->isWatchable( $ns ) );
	}

	public static function provideIsWatchable() {
		return [
			// Specials namespaces are not watchable
			[ NS_MEDIA, false ],
			[ NS_SPECIAL, false ],

			// Core defined namespaces are watchables
			[ NS_MAIN, true ],
			[ NS_TALK, true ],

			// Additional, user defined namespaces are watchables
			[ 100, true ],
			[ 101, true ],
		];
	}

	/**
	 * @param int $ns
	 * @param int $expected
	 * @param array|null $namespacesWithSubpages To pass to constructor
	 * @covers \MediaWiki\Title\NamespaceInfo::hasSubpages
	 * @dataProvider provideHasSubpages
	 */
	public function testHasSubpages( $ns, $expected, ?array $namespacesWithSubpages = null ) {
		$obj = $this->newObj( $namespacesWithSubpages
			? [ MainConfigNames::NamespacesWithSubpages => $namespacesWithSubpages ]
			: [] );
		$this->assertSame( $expected, $obj->hasSubpages( $ns ) );
	}

	public static function provideHasSubpages() {
		return [
			// Special namespaces:
			[ NS_MEDIA, false ],
			[ NS_SPECIAL, false ],

			// Namespaces without subpages
			[ NS_MAIN, false ],
			[ NS_MAIN, true, [ NS_MAIN => true ] ],
			[ NS_MAIN, false, [ NS_MAIN => false ] ],

			// Some namespaces with subpages
			[ NS_TALK, true ],
			[ NS_USER, true ],
			[ NS_USER_TALK, true ],
		];
	}

	/**
	 * @dataProvider provideGetContentNamespaces
	 * @covers \MediaWiki\Title\NamespaceInfo::getContentNamespaces
	 */
	public function testGetContentNamespaces( $contentNamespaces, array $expected ) {
		$obj = $this->newObj( [ MainConfigNames::ContentNamespaces => $contentNamespaces ] );
		$this->assertSame( $expected, $obj->getContentNamespaces() );
	}

	public static function provideGetContentNamespaces() {
		return [
			// Non-array
			[ '', [ NS_MAIN ] ],
			[ false, [ NS_MAIN ] ],
			[ null, [ NS_MAIN ] ],
			[ 5, [ NS_MAIN ] ],

			// Empty array
			[ [], [ NS_MAIN ] ],

			// NS_MAIN is forced to be content even if unwanted
			[ [ NS_USER, NS_CATEGORY ], [ NS_MAIN, NS_USER, NS_CATEGORY ] ],

			// In other cases, return as-is
			[ [ NS_MAIN ], [ NS_MAIN ] ],
			[ [ NS_MAIN, NS_USER, NS_CATEGORY ], [ NS_MAIN, NS_USER, NS_CATEGORY ] ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getSubjectNamespaces
	 */
	public function testGetSubjectNamespaces() {
		$subjectsNS = $this->newObj()->getSubjectNamespaces();
		$this->assertContains( NS_MAIN, $subjectsNS,
			"Talk namespaces should have NS_MAIN" );
		$this->assertNotContains( NS_TALK, $subjectsNS,
			"Talk namespaces should have NS_TALK" );

		$this->assertNotContains( NS_MEDIA, $subjectsNS,
			"Talk namespaces should not have NS_MEDIA" );
		$this->assertNotContains( NS_SPECIAL, $subjectsNS,
			"Talk namespaces should not have NS_SPECIAL" );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getTalkNamespaces
	 */
	public function testGetTalkNamespaces() {
		$talkNS = $this->newObj()->getTalkNamespaces();
		$this->assertContains( NS_TALK, $talkNS,
			"Subject namespaces should have NS_TALK" );
		$this->assertNotContains( NS_MAIN, $talkNS,
			"Subject namespaces should not have NS_MAIN" );

		$this->assertNotContains( NS_MEDIA, $talkNS,
			"Subject namespaces should not have NS_MEDIA" );
		$this->assertNotContains( NS_SPECIAL, $talkNS,
			"Subject namespaces should not have NS_SPECIAL" );
	}

	/**
	 * @param int $ns
	 * @param bool $expected
	 * @param bool $capitalLinks To pass to constructor
	 * @param array $capitalLinkOverrides To pass to constructor
	 * @dataProvider provideIsCapitalized
	 * @covers \MediaWiki\Title\NamespaceInfo::isCapitalized
	 */
	public function testIsCapitalized(
		$ns, $expected, $capitalLinks = true, array $capitalLinkOverrides = []
	) {
		$obj = $this->newObj( [
			MainConfigNames::CapitalLinks => $capitalLinks,
			MainConfigNames::CapitalLinkOverrides => $capitalLinkOverrides,
		] );
		$this->assertSame( $expected, $obj->isCapitalized( $ns ) );
	}

	public static function provideIsCapitalized() {
		return [
			// Test default settings
			[ NS_PROJECT, true ],
			[ NS_PROJECT_TALK, true ],
			[ NS_MEDIA, true ],
			[ NS_FILE, true ],

			// Always capitalized no matter what
			[ NS_SPECIAL, true, false ],
			[ NS_USER, true, false ],
			[ NS_MEDIAWIKI, true, false ],

			// Even with an override too
			[ NS_SPECIAL, true, false, [ NS_SPECIAL => false ] ],
			[ NS_USER, true, false, [ NS_USER => false ] ],
			[ NS_MEDIAWIKI, true, false, [ NS_MEDIAWIKI => false ] ],

			// Overrides work for other namespaces
			[ NS_PROJECT, false, true, [ NS_PROJECT => false ] ],
			[ NS_PROJECT, true, false, [ NS_PROJECT => true ] ],

			// NS_MEDIA is treated like NS_FILE, and ignores NS_MEDIA overrides
			[ NS_MEDIA, false, true, [ NS_FILE => false, NS_MEDIA => true ] ],
			[ NS_MEDIA, true, false, [ NS_FILE => true, NS_MEDIA => false ] ],
			[ NS_FILE, false, true, [ NS_FILE => false, NS_MEDIA => true ] ],
			[ NS_FILE, true, false, [ NS_FILE => true, NS_MEDIA => false ] ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::hasGenderDistinction
	 */
	public function testHasGenderDistinction() {
		$obj = $this->newObj();

		// Namespaces with gender distinctions
		$this->assertTrue( $obj->hasGenderDistinction( NS_USER ) );
		$this->assertTrue( $obj->hasGenderDistinction( NS_USER_TALK ) );

		// Other ones, "genderless"
		$this->assertFalse( $obj->hasGenderDistinction( NS_MEDIA ) );
		$this->assertFalse( $obj->hasGenderDistinction( NS_SPECIAL ) );
		$this->assertFalse( $obj->hasGenderDistinction( NS_MAIN ) );
		$this->assertFalse( $obj->hasGenderDistinction( NS_TALK ) );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::isNonincludable
	 */
	public function testIsNonincludable() {
		$obj = $this->newObj( [ MainConfigNames::NonincludableNamespaces => [ NS_USER ] ] );
		$this->assertTrue( $obj->isNonincludable( NS_USER ) );
		$this->assertFalse( $obj->isNonincludable( NS_TEMPLATE ) );
	}

	/**
	 * @dataProvider provideGetNamespaceContentModel
	 * @covers \MediaWiki\Title\NamespaceInfo::getNamespaceContentModel
	 *
	 * @param int $ns
	 * @param string $expected
	 */
	public function testGetNamespaceContentModel( $ns, $expected ) {
		$obj = $this->newObj( [ MainConfigNames::NamespaceContentModels =>
			[ NS_USER => CONTENT_MODEL_WIKITEXT, 123 => CONTENT_MODEL_JSON, 1234 => 'abcdef' ],
		] );
		$this->assertSame( $expected, $obj->getNamespaceContentModel( $ns ) );
	}

	public static function provideGetNamespaceContentModel() {
		return [
			[ NS_MAIN, null ],
			[ NS_TALK, null ],
			[ NS_USER, CONTENT_MODEL_WIKITEXT ],
			[ NS_USER_TALK, null ],
			[ NS_SPECIAL, null ],
			[ 122, null ],
			[ 123, CONTENT_MODEL_JSON ],
			[ 1234, 'abcdef' ],
			[ 1235, null ],
		];
	}

	/**
	 * @dataProvider provideGetCategoryLinkType
	 * @covers \MediaWiki\Title\NamespaceInfo::getCategoryLinkType
	 *
	 * @param int $ns
	 * @param string $expected
	 */
	public function testGetCategoryLinkType( $ns, $expected ) {
		$this->assertSame( $expected, $this->newObj()->getCategoryLinkType( $ns ) );
	}

	public static function provideGetCategoryLinkType() {
		return [
			[ NS_MAIN, 'page' ],
			[ NS_TALK, 'page' ],
			[ NS_USER, 'page' ],
			[ NS_USER_TALK, 'page' ],

			[ NS_FILE, 'file' ],
			[ NS_FILE_TALK, 'page' ],

			[ NS_CATEGORY, 'subcat' ],
			[ NS_CATEGORY_TALK, 'page' ],

			[ 100, 'page' ],
			[ 101, 'page' ],
		];
	}

	// %} End basic methods

	/**********************************************************************************************
	 * getSubject/Talk/Associated
	 * %{
	 */

	/**
	 * @dataProvider provideSubjectTalk
	 * @covers \MediaWiki\Title\NamespaceInfo::getSubject
	 * @covers \MediaWiki\Title\NamespaceInfo::getSubjectPage
	 * @covers \MediaWiki\Title\NamespaceInfo::isMethodValidFor
	 * @covers \MediaWiki\Title\Title::getSubjectPage
	 *
	 * @param int $subject
	 * @param int $talk
	 */
	public function testGetSubject( $subject, $talk ) {
		$obj = $this->newObj();
		$this->assertSame( $subject, $obj->getSubject( $subject ) );
		$this->assertSame( $subject, $obj->getSubject( $talk ) );

		$subjectTitleVal = new TitleValue( $subject, 'A' );
		$talkTitleVal = new TitleValue( $talk, 'A' );
		// Object will be the same one passed in if it's a subject, different but equal object if
		// it's talk
		$this->assertSame( $subjectTitleVal, $obj->getSubjectPage( $subjectTitleVal ) );
		$this->assertEquals( $subjectTitleVal, $obj->getSubjectPage( $talkTitleVal ) );

		$subjectTitle = Title::makeTitle( $subject, 'A' );
		$talkTitle = Title::makeTitle( $talk, 'A' );
		$this->assertSame( $subjectTitle, $subjectTitle->getSubjectPage() );
		$this->assertEquals( $subjectTitle, $talkTitle->getSubjectPage() );
	}

	/**
	 * @dataProvider provideSpecialNamespaces
	 * @covers \MediaWiki\Title\NamespaceInfo::getSubject
	 * @covers \MediaWiki\Title\NamespaceInfo::getSubjectPage
	 *
	 * @param int $ns
	 */
	public function testGetSubject_special( $ns ) {
		$obj = $this->newObj();
		$this->assertSame( $ns, $obj->getSubject( $ns ) );

		$title = new TitleValue( $ns, 'A' );
		$this->assertSame( $title, $obj->getSubjectPage( $title ) );
	}

	/**
	 * @dataProvider provideSubjectTalk
	 * @covers \MediaWiki\Title\NamespaceInfo::getTalk
	 * @covers \MediaWiki\Title\NamespaceInfo::getTalkPage
	 * @covers \MediaWiki\Title\NamespaceInfo::isMethodValidFor
	 * @covers \MediaWiki\Title\Title::getTalkPage
	 *
	 * @param int $subject
	 * @param int $talk
	 */
	public function testGetTalk( $subject, $talk ) {
		$obj = $this->newObj();
		$this->assertSame( $talk, $obj->getTalk( $subject ) );
		$this->assertSame( $talk, $obj->getTalk( $talk ) );

		$subjectTitleVal = new TitleValue( $subject, 'A' );
		$talkTitleVal = new TitleValue( $talk, 'A' );
		// Object will be the same one passed in if it's a talk, different but equal object if it's
		// subject
		$this->assertEquals( $talkTitleVal, $obj->getTalkPage( $subjectTitleVal ) );
		$this->assertSame( $talkTitleVal, $obj->getTalkPage( $talkTitleVal ) );

		$subjectTitle = Title::makeTitle( $subject, 'A' );
		$talkTitle = Title::makeTitle( $talk, 'A' );
		$this->assertEquals( $talkTitle, $subjectTitle->getTalkPage() );
		$this->assertSame( $talkTitle, $talkTitle->getTalkPage() );
	}

	/**
	 * @dataProvider provideSpecialNamespaces
	 * @covers \MediaWiki\Title\NamespaceInfo::getTalk
	 * @covers \MediaWiki\Title\NamespaceInfo::isMethodValidFor
	 *
	 * @param int $ns
	 */
	public function testGetTalk_special( $ns ) {
		$this->expectException( MWException::class );
		$this->expectExceptionMessage(
			"NamespaceInfo::getTalk does not make any sense for given namespace $ns"
		);
		$this->newObj()->getTalk( $ns );
	}

	/**
	 * @dataProvider provideSpecialNamespaces
	 * @covers \MediaWiki\Title\NamespaceInfo::getAssociated
	 * @covers \MediaWiki\Title\NamespaceInfo::isMethodValidFor
	 *
	 * @param int $ns
	 */
	public function testGetAssociated_special( $ns ) {
		$this->expectException( MWException::class );
		$this->expectExceptionMessage(
			"NamespaceInfo::getAssociated does not make any sense for given namespace $ns"
		);
		$this->newObj()->getAssociated( $ns );
	}

	public static function provideCanHaveTalkPage() {
		return [
			[ new TitleValue( NS_MAIN, 'Test' ), true ],
			[ new TitleValue( NS_TALK, 'Test' ), true ],
			[ new TitleValue( NS_USER, 'Test' ), true ],
			[ new TitleValue( NS_SPECIAL, 'Test' ), false ],
			[ new TitleValue( NS_MEDIA, 'Test' ), false ],
			[ new TitleValue( NS_MAIN, '', 'Kittens' ), false ],
			[ new TitleValue( NS_MAIN, 'Kittens', '', 'acme' ), false ],
		];
	}

	/**
	 * @dataProvider provideCanHaveTalkPage
	 * @covers \MediaWiki\Title\NamespaceInfo::canHaveTalkPage
	 */
	public function testCanHaveTalkPage( LinkTarget $t, $expected ) {
		$actual = $this->newObj()->canHaveTalkPage( $t );
		$this->assertEquals( $expected, $actual, $t->getDBkey() );
	}

	public static function provideGetTalkPage_good() {
		return [
			[ new TitleValue( NS_MAIN, 'Test' ), new TitleValue( NS_TALK, 'Test' ) ],
			[ new TitleValue( NS_TALK, 'Test' ), new TitleValue( NS_TALK, 'Test' ) ],
			[ new TitleValue( NS_USER, 'Test' ), new TitleValue( NS_USER_TALK, 'Test' ) ],
		];
	}

	/**
	 * @dataProvider provideGetTalkPage_good
	 * @covers \MediaWiki\Title\NamespaceInfo::getTalk
	 * @covers \MediaWiki\Title\NamespaceInfo::getTalkPage
	 * @covers \MediaWiki\Title\NamespaceInfo::isMethodValidFor
	 */
	public function testGetTalkPage_good( LinkTarget $t, LinkTarget $expected ) {
		$actual = $this->newObj()->getTalkPage( $t );
		$this->assertEquals( $expected, $actual, $t->getDBkey() );
	}

	public static function provideGetTalkPage_bad() {
		return [
			[ new TitleValue( NS_SPECIAL, 'Test' ) ],
			[ new TitleValue( NS_MEDIA, 'Test' ) ],
			[ new TitleValue( NS_MAIN, '', 'Kittens' ) ],
			[ new TitleValue( NS_MAIN, 'Kittens', '', 'acme' ) ],
		];
	}

	/**
	 * @dataProvider provideGetTalkPage_bad
	 * @covers \MediaWiki\Title\NamespaceInfo::getTalk
	 * @covers \MediaWiki\Title\NamespaceInfo::getTalkPage
	 * @covers \MediaWiki\Title\NamespaceInfo::isMethodValidFor
	 */
	public function testGetTalkPage_bad( LinkTarget $t ) {
		$this->expectException( MWException::class );
		$this->newObj()->getTalkPage( $t );
	}

	/**
	 * @dataProvider provideGetTalkPage_bad
	 * @covers \MediaWiki\Title\NamespaceInfo::getAssociated
	 * @covers \MediaWiki\Title\NamespaceInfo::getAssociatedPage
	 * @covers \MediaWiki\Title\NamespaceInfo::isMethodValidFor
	 */
	public function testGetAssociatedPage_bad( LinkTarget $t ) {
		$this->expectException( MWException::class );
		$this->newObj()->getAssociatedPage( $t );
	}

	/**
	 * @dataProvider provideSubjectTalk
	 * @covers \MediaWiki\Title\NamespaceInfo::getAssociated
	 * @covers \MediaWiki\Title\NamespaceInfo::getAssociatedPage
	 * @covers \MediaWiki\Title\Title::getOtherPage
	 *
	 * @param int $subject
	 * @param int $talk
	 */
	public function testGetAssociated( $subject, $talk ) {
		$obj = $this->newObj();
		$this->assertSame( $talk, $obj->getAssociated( $subject ) );
		$this->assertSame( $subject, $obj->getAssociated( $talk ) );

		$subjectTitle = new TitleValue( $subject, 'A' );
		$talkTitle = new TitleValue( $talk, 'A' );
		// Object will not be the same
		$this->assertEquals( $talkTitle, $obj->getAssociatedPage( $subjectTitle ) );
		$this->assertEquals( $subjectTitle, $obj->getAssociatedPage( $talkTitle ) );

		$subjectTitle = Title::makeTitle( $subject, 'A' );
		$talkTitle = Title::makeTitle( $talk, 'A' );
		$this->assertEquals( $talkTitle, $subjectTitle->getOtherPage() );
		$this->assertEquals( $subjectTitle, $talkTitle->getOtherPage() );
	}

	public static function provideSubjectTalk() {
		return [
			// Format: [ subject, talk ]
			'Main/talk' => [ NS_MAIN, NS_TALK ],
			'User/user talk' => [ NS_USER, NS_USER_TALK ],
			'Unknown namespaces also supported' => [ 106, 107 ],
		];
	}

	public static function provideSpecialNamespaces() {
		return [
			'Special' => [ NS_SPECIAL ],
			'Media' => [ NS_MEDIA ],
			'Unknown negative index' => [ -613 ],
		];
	}

	// %} End getSubject/Talk/Associated

	/**********************************************************************************************
	 * Canonical namespaces
	 * %{
	 */

	// Default canonical namespaces
	// %{
	private function getDefaultNamespaces() {
		return [ NS_MAIN => '' ] + self::DEFAULT_OPTIONS['CanonicalNamespaceNames'];
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalNamespaces
	 */
	public function testGetCanonicalNamespaces() {
		$this->assertSame(
			$this->getDefaultNamespaces(),
			$this->newObj()->getCanonicalNamespaces()
		);
	}

	/**
	 * @dataProvider provideGetCanonicalName
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalName
	 *
	 * @param int $index
	 * @param string|bool $expected
	 */
	public function testGetCanonicalName( $index, $expected ) {
		$this->assertSame( $expected, $this->newObj()->getCanonicalName( $index ) );
	}

	public static function provideGetCanonicalName() {
		return [
			'Main' => [ NS_MAIN, '' ],
			'Talk' => [ NS_TALK, 'Talk' ],
			'With underscore not space' => [ NS_USER_TALK, 'User_talk' ],
			'Special' => [ NS_SPECIAL, 'Special' ],
			'Nonexistent' => [ 12345, false ],
			'Nonexistent negative' => [ -12345, false ],
		];
	}

	/**
	 * @dataProvider provideGetCanonicalIndex
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalIndex
	 *
	 * @param string $name
	 * @param int|null $expected
	 */
	public function testGetCanonicalIndex( $name, $expected ) {
		$this->assertSame( $expected, $this->newObj()->getCanonicalIndex( $name ) );
	}

	public static function provideGetCanonicalIndex() {
		return [
			'Main' => [ '', NS_MAIN ],
			'Talk' => [ 'talk', NS_TALK ],
			'Not lowercase' => [ 'Talk', null ],
			'With underscore' => [ 'user_talk', NS_USER_TALK ],
			'Space is not recognized for underscore' => [ 'user talk', null ],
			'0' => [ '0', null ],
		];
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getValidNamespaces
	 */
	public function testGetValidNamespaces() {
		$this->assertSame(
			[ NS_MAIN, NS_TALK, NS_USER, NS_USER_TALK ],
			$this->newObj()->getValidNamespaces()
		);
	}

	// %} End default canonical namespaces

	// No canonical namespace names
	// %{

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalNamespaces
	 */
	public function testGetCanonicalNamespaces_NoCanonicalNamespaceNames() {
		$obj = $this->newObj( [ MainConfigNames::CanonicalNamespaceNames => [] ] );

		$this->assertSame( [ NS_MAIN => '' ], $obj->getCanonicalNamespaces() );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalName
	 */
	public function testGetCanonicalName_NoCanonicalNamespaceNames() {
		$obj = $this->newObj( [ MainConfigNames::CanonicalNamespaceNames => [] ] );

		$this->assertSame( '', $obj->getCanonicalName( NS_MAIN ) );
		$this->assertFalse( $obj->getCanonicalName( NS_TALK ) );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalIndex
	 */
	public function testGetCanonicalIndex_NoCanonicalNamespaceNames() {
		$obj = $this->newObj( [ MainConfigNames::CanonicalNamespaceNames => [] ] );

		$this->assertSame( NS_MAIN, $obj->getCanonicalIndex( '' ) );
		$this->assertNull( $obj->getCanonicalIndex( 'talk' ) );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getValidNamespaces
	 */
	public function testGetValidNamespaces_NoCanonicalNamespaceNames() {
		$obj = $this->newObj( [ MainConfigNames::CanonicalNamespaceNames => [] ] );

		$this->assertSame( [ NS_MAIN ], $obj->getValidNamespaces() );
	}

	// %} End no canonical namespace names

	// Test extension namespaces
	// %{

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalNamespaces
	 */
	public function testGetCanonicalNamespaces_ExtensionNamespaces() {
		$this->assertSame(
			$this->getDefaultNamespaces() + [ 12345 => 'Extended' ],
			$this->newObj( [], self::TEST_EXT_NAMESPACES )->getCanonicalNamespaces()
		);
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalName
	 */
	public function testGetCanonicalName_ExtensionNamespaces() {
		$obj = $this->newObj( [], self::TEST_EXT_NAMESPACES );

		$this->assertSame( '', $obj->getCanonicalName( NS_MAIN ) );
		$this->assertSame( 'Talk', $obj->getCanonicalName( NS_TALK ) );
		$this->assertSame( 'Extended', $obj->getCanonicalName( 12345 ) );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalIndex
	 */
	public function testGetCanonicalIndex_ExtensionNamespaces() {
		$obj = $this->newObj( [], self::TEST_EXT_NAMESPACES );

		$this->assertSame( NS_MAIN, $obj->getCanonicalIndex( '' ) );
		$this->assertSame( NS_TALK, $obj->getCanonicalIndex( 'talk' ) );
		$this->assertSame( 12345, $obj->getCanonicalIndex( 'extended' ) );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getValidNamespaces
	 */
	public function testGetValidNamespaces_ExtensionNamespaces() {
		$this->assertSame(
			[ NS_MAIN, NS_TALK, NS_USER, NS_USER_TALK, 12345 ],
			$this->newObj( [], self::TEST_EXT_NAMESPACES )->getValidNamespaces()
		);
	}

	// %} End extension namespaces

	// Hook namespaces
	// %{

	/**
	 * @return array Expected canonical namespaces
	 */
	private function setupHookNamespaces() {
		$callback =
			static function ( &$canonicalNamespaces ) {
				$canonicalNamespaces[NS_MAIN] = 'Main';
				unset( $canonicalNamespaces[NS_MEDIA] );
				$canonicalNamespaces[123456] = 'Hooked';
			};
		$this->setTemporaryHook( 'CanonicalNamespaces', $callback );
		$expected = $this->getDefaultNamespaces();
		( $callback )( $expected );
		return $expected;
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalNamespaces
	 */
	public function testGetCanonicalNamespaces_HookNamespaces() {
		$expected = $this->setupHookNamespaces();

		$this->assertSame( $expected, $this->newObj()->getCanonicalNamespaces() );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalName
	 */
	public function testGetCanonicalName_HookNamespaces() {
		$this->setupHookNamespaces();
		$obj = $this->newObj();

		$this->assertSame( 'Main', $obj->getCanonicalName( NS_MAIN ) );
		$this->assertFalse( $obj->getCanonicalName( NS_MEDIA ) );
		$this->assertSame( 'Hooked', $obj->getCanonicalName( 123456 ) );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalIndex
	 */
	public function testGetCanonicalIndex_HookNamespaces() {
		$this->setupHookNamespaces();
		$obj = $this->newObj();

		$this->assertSame( NS_MAIN, $obj->getCanonicalIndex( 'main' ) );
		$this->assertNull( $obj->getCanonicalIndex( 'media' ) );
		$this->assertSame( 123456, $obj->getCanonicalIndex( 'hooked' ) );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getValidNamespaces
	 */
	public function testGetValidNamespaces_HookNamespaces() {
		$this->setupHookNamespaces();

		$this->assertSame(
			[ NS_MAIN, NS_TALK, NS_USER, NS_USER_TALK, 123456 ],
			$this->newObj()->getValidNamespaces()
		);
	}

	// %} End hook namespaces

	// Extra namespaces
	// %{

	/**
	 * @return NamespaceInfo
	 */
	private function setupExtraNamespaces() {
		return $this->newObj( [ MainConfigNames::ExtraNamespaces =>
			[ NS_MAIN => 'No effect', NS_TALK => 'No effect', 1234567 => 'Extra' ]
		] );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalNamespaces
	 */
	public function testGetCanonicalNamespaces_ExtraNamespaces() {
		$this->assertSame(
			$this->getDefaultNamespaces() + [ 1234567 => 'Extra' ],
			$this->setupExtraNamespaces()->getCanonicalNamespaces()
		);
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalName
	 */
	public function testGetCanonicalName_ExtraNamespaces() {
		$obj = $this->setupExtraNamespaces();

		$this->assertSame( '', $obj->getCanonicalName( NS_MAIN ) );
		$this->assertSame( 'Talk', $obj->getCanonicalName( NS_TALK ) );
		$this->assertSame( 'Extra', $obj->getCanonicalName( 1234567 ) );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalIndex
	 */
	public function testGetCanonicalIndex_ExtraNamespaces() {
		$obj = $this->setupExtraNamespaces();

		$this->assertNull( $obj->getCanonicalIndex( 'no effect' ) );
		$this->assertNull( $obj->getCanonicalIndex( 'no_effect' ) );
		$this->assertSame( 1234567, $obj->getCanonicalIndex( 'extra' ) );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getValidNamespaces
	 */
	public function testGetValidNamespaces_ExtraNamespaces() {
		$this->assertSame(
			[ NS_MAIN, NS_TALK, NS_USER, NS_USER_TALK, 1234567 ],
			$this->setupExtraNamespaces()->getValidNamespaces()
		);
	}

	// %} End extra namespaces

	// Canonical namespace caching
	// %{

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalNamespaces
	 */
	public function testGetCanonicalNamespaces_caching() {
		$obj = $this->newObj();

		// This should cache the values
		$obj->getCanonicalNamespaces();

		// Now try to alter them through nefarious means
		$this->setupHookNamespaces();

		// Should have no effect
		$this->assertSame( $this->getDefaultNamespaces(), $obj->getCanonicalNamespaces() );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalName
	 */
	public function testGetCanonicalName_caching() {
		$obj = $this->newObj();

		// This should cache the values
		$obj->getCanonicalName( NS_MAIN );

		// Now try to alter them through nefarious means
		$this->setupHookNamespaces();

		// Should have no effect
		$this->assertSame( '', $obj->getCanonicalName( NS_MAIN ) );
		$this->assertSame( 'Media', $obj->getCanonicalName( NS_MEDIA ) );
		$this->assertFalse( $obj->getCanonicalName( 12345 ) );
		$this->assertFalse( $obj->getCanonicalName( 123456 ) );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getCanonicalIndex
	 */
	public function testGetCanonicalIndex_caching() {
		$obj = $this->newObj();

		// This should cache the values
		$obj->getCanonicalIndex( '' );

		// Now try to alter them through nefarious means
		$this->setupHookNamespaces();

		// Should have no effect
		$this->assertSame( NS_MAIN, $obj->getCanonicalIndex( '' ) );
		$this->assertSame( NS_MEDIA, $obj->getCanonicalIndex( 'media' ) );
		$this->assertNull( $obj->getCanonicalIndex( 'extended' ) );
		$this->assertNull( $obj->getCanonicalIndex( 'hooked' ) );
	}

	/**
	 * @covers \MediaWiki\Title\NamespaceInfo::getValidNamespaces
	 */
	public function testGetValidNamespaces_caching() {
		$obj = $this->newObj();

		// This should cache the values
		$obj->getValidNamespaces();

		// Now try to alter through nefarious means
		$this->setupHookNamespaces();

		// Should have no effect
		$this->assertSame(
			[ NS_MAIN, NS_TALK, NS_USER, NS_USER_TALK ],
			$obj->getValidNamespaces()
		);
	}

	// %} End canonical namespace caching

	// Miscellaneous
	// %{

	/**
	 * @dataProvider provideGetValidNamespaces_misc
	 * @covers \MediaWiki\Title\NamespaceInfo::getValidNamespaces
	 *
	 * @param array $namespaces List of namespace indices to return from getCanonicalNamespaces()
	 *   (list is overwritten by a hook, so NS_MAIN doesn't have to be present)
	 * @param array $expected
	 */
	public function testGetValidNamespaces_misc( array $namespaces, array $expected ) {
		// Each namespace's name is just its index
		$this->setTemporaryHook( 'CanonicalNamespaces',
			static function ( &$canonicalNamespaces ) use ( $namespaces ) {
				$canonicalNamespaces = array_combine( $namespaces, $namespaces );
			}
		);
		$this->assertSame( $expected, $this->newObj()->getValidNamespaces() );
	}

	public static function provideGetValidNamespaces_misc() {
		return [
			'Out of order (T109137)' => [ [ 1, 0 ], [ 0, 1 ] ],
			'Alphabetical order' => [ [ 10, 2 ], [ 2, 10 ] ],
			'Negative' => [ [ -1000, -500, -2, 0 ], [ 0 ] ],
		];
	}

	// %} End miscellaneous
	// %} End canonical namespaces

	/**
	 * @coversNothing
	 */
	public function testAllServiceOptionsUsed() {
		$this->assertAllServiceOptionsUsed();
	}
}

/**
 * For really cool vim folding this needs to be at the end:
 * vim: foldmarker=%{,%} foldmethod=marker
 */
PK       !     )  title/NamespaceImportTitleFactoryTest.phpnu Iw        <?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
 * @author This, that and the other
 */

use MediaWiki\Title\ForeignTitle;
use MediaWiki\Title\NamespaceImportTitleFactory;
use MediaWiki\Title\Title;

/**
 * @covers \MediaWiki\Title\NamespaceImportTitleFactory
 *
 * @group Title
 *
 * TODO convert to unit tests
 */
class NamespaceImportTitleFactoryTest extends MediaWikiIntegrationTestCase {

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

		$this->setContentLang( 'en' );
	}

	public static function basicProvider() {
		return [
			[
				new ForeignTitle( 0, '', 'MainNamespaceArticle' ),
				0,
				'MainNamespaceArticle'
			],
			[
				new ForeignTitle( 0, '', 'MainNamespaceArticle' ),
				2,
				'User:MainNamespaceArticle'
			],
			[
				new ForeignTitle( 1, 'Discussion', 'Nice_talk' ),
				0,
				'Nice_talk'
			],
			[
				new ForeignTitle( 0, '', 'Bogus:Nice_talk' ),
				0,
				'Bogus:Nice_talk'
			],
			[
				new ForeignTitle( 0, '', 'Bogus:Nice_talk' ),
				2,
				'User:Bogus:Nice_talk'
			],
		];
	}

	/**
	 * @dataProvider basicProvider
	 */
	public function testBasic( ForeignTitle $foreignTitle, $ns, $titleText ) {
		$factory = new NamespaceImportTitleFactory(
			$this->getServiceContainer()->getNamespaceInfo(),
			$this->getServiceContainer()->getTitleFactory(),
			$ns
		);
		$testTitle = $factory->createTitleFromForeignTitle( $foreignTitle );
		$title = Title::newFromText( $titleText );

		$this->assertTrue( $title->equals( $testTitle ) );
	}
}
PK       ! arۑ	  	     title/TemplateCategoriesTest.phpnu Iw        <?php

use MediaWiki\Content\WikitextContent;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Title\Title;

/**
 * @group Database
 */
class TemplateCategoriesTest extends MediaWikiIntegrationTestCase {

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

		// Don't let PageStore hit WANObjectCache process cache for revision metadata
		$this->setMainCache( CACHE_NONE );
	}

	/**
	 * @covers \MediaWiki\Title\Title::getParentCategories
	 */
	public function testTemplateCategories() {
		$user = $this->getTestUser()->getUser();
		$this->overrideUserPermissions( $user, [ 'createpage', 'edit', 'purge', 'delete' ] );
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();

		$title = Title::makeTitle( NS_MAIN, "Categorized from template" );
		$page = $wikiPageFactory->newFromTitle( $title );
		$page->doUserEditContent(
			new WikitextContent( '{{Categorising template}}' ),
			$user,
			'Create a page with a template'
		);

		$this->assertEquals(
			[],
			$title->getParentCategories(),
			'Verify that the category doesn\'t contain the page before the template is created'
		);

		// Create template
		$template = $wikiPageFactory->newFromTitle( Title::makeTitle( NS_TEMPLATE, 'Categorising template' ) );
		$template->doUserEditContent(
			new WikitextContent( '[[Category:Solved bugs]]' ),
			$user,
			'Add a category through a template'
		);

		$this->runJobs();
		DeferredUpdates::doUpdates();

		// Make sure page is in the category
		$this->assertEquals(
			[ 'Category:Solved_bugs' => $title->getPrefixedText() ],
			$title->getParentCategories(),
			'Verify that the page is in the category after the template is created'
		);

		// Edit the template
		$template->doUserEditContent(
			new WikitextContent( '[[Category:Solved bugs 2]]' ),
			$user,
			'Change the category added by the template'
		);

		$this->runJobs();
		DeferredUpdates::doUpdates();

		// Make sure page is in the right category
		$this->assertEquals(
			[ 'Category:Solved_bugs_2' => $title->getPrefixedText() ],
			$title->getParentCategories(),
			'Verify that the page is in the right category after the template is edited'
		);

		// Now delete the template
		$this->deletePage( $template, 'Delete the template', $user );

		$this->runJobs();
		DeferredUpdates::doUpdates();

		// Make sure the page is no longer in the category
		$this->assertEquals(
			[],
			$title->getParentCategories(),
			'Verify that the page is no longer in the category after template deletion'
		);
	}
}
PK       ! E"!z
  z
  '  title/SubpageImportTitleFactoryTest.phpnu Iw        <?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
 * @author This, that and the other
 */

use MediaWiki\MainConfigNames;
use MediaWiki\Title\ForeignTitle;
use MediaWiki\Title\SubpageImportTitleFactory;
use MediaWiki\Title\Title;

/**
 * @covers \MediaWiki\Title\SubpageImportTitleFactory
 *
 * @group Title
 *
 * TODO convert to Unit tests
 */
class SubpageImportTitleFactoryTest extends MediaWikiIntegrationTestCase {

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

		$this->overrideConfigValues( [
			MainConfigNames::LanguageCode => 'en',
			MainConfigNames::NamespacesWithSubpages => [ 0 => false, 2 => true ],
		] );
	}

	private function newSubpageImportTitleFactory( Title $rootPage ) {
		return new SubpageImportTitleFactory(
			$this->getServiceContainer()->getNamespaceInfo(),
			$this->getServiceContainer()->getTitleFactory(),
			$rootPage
		);
	}

	public static function basicProvider() {
		return [
			[
				new ForeignTitle( 0, '', 'MainNamespaceArticle' ),
				Title::makeTitle( NS_USER, 'Graham' ),
				Title::makeTitle( NS_USER, 'Graham/MainNamespaceArticle' )
			],
			[
				new ForeignTitle( 1, 'Discussion', 'Nice_talk' ),
				Title::makeTitle( NS_USER, 'Graham' ),
				Title::makeTitle( NS_USER, 'Graham/Discussion:Nice_talk' )
			],
			[
				new ForeignTitle( 0, '', 'Bogus:Nice_talk' ),
				Title::makeTitle( NS_USER, 'Graham' ),
				Title::makeTitle( NS_USER, 'Graham/Bogus:Nice_talk' )
			],
		];
	}

	/**
	 * @dataProvider basicProvider
	 */
	public function testBasic( ForeignTitle $foreignTitle, Title $rootPage,
		Title $title
	) {
		$factory = $this->newSubpageImportTitleFactory( $rootPage );
		$testTitle = $factory->createTitleFromForeignTitle( $foreignTitle );

		$this->assertTrue( $testTitle->equals( $title ) );
	}

	public function testInvalidNamespace() {
		$this->expectException( InvalidArgumentException::class );
		$this->newSubpageImportTitleFactory( Title::makeTitle( NS_MAIN, 'Graham' ) );
	}
}
PK       ! >N      Message/TextFormatterTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Message;

use MediaWiki\Message\Message;
use MediaWiki\Message\TextFormatter;
use MediaWiki\Message\UserGroupMembershipParam;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;
use Wikimedia\Message\MessageValue;
use Wikimedia\Message\ParamType;
use Wikimedia\Message\ScalarParam;

/**
 * @covers \MediaWiki\Message\TextFormatter
 * @covers \Wikimedia\Message\MessageValue
 * @covers \Wikimedia\Message\ListParam
 * @covers \Wikimedia\Message\ScalarParam
 * @covers \Wikimedia\Message\MessageParam
 */
class TextFormatterTest extends MediaWikiIntegrationTestCase {
	private function createTextFormatter( $langCode,
		$includeWikitext = false,
		$format = Message::FORMAT_TEXT
	) {
		$formatter = $this->getMockBuilder( TextFormatter::class )
			->onlyMethods( [ 'createMessage' ] )
			->setConstructorArgs( [ $langCode, $format ] )
			->getMock();
		$formatter->method( 'createMessage' )
			->willReturnCallback( function ( $spec ) use ( $includeWikitext ) {
				$message = $this->getMockBuilder( Message::class )
					->setConstructorArgs( [ $spec ] )
					->onlyMethods( [ 'fetchMessage' ] )
					->getMock();

				$message->method( 'fetchMessage' )
					->willReturnCallback( static function () use ( $message, $includeWikitext ) {
						/** @var Message $message */
						$result = "{$message->getKey()} $1 $2";
						if ( $includeWikitext ) {
							$result .= " {{SITENAME}}";
						}
						return $result;
					} );

				return $message;
			} );

		return $formatter;
	}

	public function testGetLangCode() {
		$formatter = new TextFormatter( 'fr' );
		$this->assertSame( 'fr', $formatter->getLangCode() );
	}

	public function testFormatBitrate() {
		$formatter = $this->createTextFormatter( 'en' );
		$mv = ( new MessageValue( 'test' ) )->bitrateParams( 100, 200 );
		$result = $formatter->format( $mv );
		$this->assertSame( 'test 100 bps 200 bps', $result );
	}

	public function testFormatShortDuration() {
		$formatter = $this->createTextFormatter( 'en' );
		$mv = ( new MessageValue( 'test' ) )->shortDurationParams( 100, 200 );
		$result = $formatter->format( $mv );
		$this->assertSame( 'test 1 min 40 s 3 min 20 s', $result );
	}

	public function testFormatList() {
		$formatter = $this->createTextFormatter( 'en' );
		$mv = ( new MessageValue( 'test' ) )->commaListParams( [
			'a',
			new ScalarParam( ParamType::BITRATE, 100 ),
		] );
		$result = $formatter->format( $mv );
		$this->assertSame( 'test a, 100 bps $2', $result );
	}

	public static function provideTestFormatMessage() {
		yield [ ( new MessageValue( 'test' ) )
			->params( new MessageValue( 'test2', [ 'a', 'b' ] ) )
			->commaListParams( [
				'x',
				new ScalarParam( ParamType::BITRATE, 100 ),
				new MessageValue( 'test3', [ 'c', new MessageValue( 'test4', [ 'd', 'e' ] ) ] )
			] ),
			'test (test2: a, b) x(comma-separator)(bitrate-bits)(comma-separator)(test3: c, (test4: d, e))'
		];

		yield [ ( new MessageValue( 'test' ) )
			->userGroupParams( 'bot' ),
			'test (group-bot) $2'
		];

		// Deprecated, silence deprecation warnings
		@yield [ ( new MessageValue( 'test' ) )
			->objectParams(
				new UserGroupMembershipParam( 'bot', new UserIdentityValue( 1, 'user' ) )
			),
			'test (group-bot-member: user) $2'
		];
	}

	/**
	 * @dataProvider provideTestFormatMessage
	 */
	public function testFormatMessage( $message, $expected ) {
		$formatter = $this->createTextFormatter( 'qqx' );
		$result = $formatter->format( $message );
		$this->assertSame( $expected, $result );
	}

	public function testFormatMessageFormatsWikitext() {
		global $wgSitename;
		$formatter = $this->createTextFormatter( 'en', true );
		$mv = MessageValue::new( 'test' )
			->plaintextParams( '1', '2' );
		$this->assertSame( "test 1 2 $wgSitename", $formatter->format( $mv ) );
	}

	public function testFormatMessageNotWikitext() {
		$formatter = $this->createTextFormatter( 'en', true, Message::FORMAT_PLAIN );
		$mv = MessageValue::new( 'test' )
			->plaintextParams( '1', '2' );
		$this->assertSame( "test 1 2 {{SITENAME}}", $formatter->format( $mv ) );
	}
}
PK       ! d
  d
  '  Message/MessageFormatterFactoryTest.phpnu Iw        <?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
 * @since 1.42
 */

namespace MediaWiki\Tests\Message;

use MediaWiki\Message\Message;
use MediaWiki\Message\MessageFormatterFactory;
use MediaWiki\Message\TextFormatter;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Message\MessageFormatterFactory
 */
class MessageFormatterFactoryTest extends MediaWikiIntegrationTestCase {

	public function testGetTextFormatter() {
		$factoryMock = new MessageFormatterFactory( Message::FORMAT_TEXT );
		$langCodeMock = 'en';
		$formatterMock = $factoryMock->getTextFormatter( $langCodeMock );
		$this->assertInstanceOf( TextFormatter::class, $formatterMock );
		$this->assertSame( $langCodeMock, $formatterMock->getLangCode() );
	}

	public function testGetTextFormatterReturnsSameInstanceForSameLangCode() {
		$factoryMock = new MessageFormatterFactory();
		$langCodeMock = 'en';
		$formatterMock1 = $factoryMock->getTextFormatter( $langCodeMock );
		$formatterMock2 = $factoryMock->getTextFormatter( $langCodeMock );
		$this->assertSame( $formatterMock1, $formatterMock2 );
	}

	public function testGetTextFormatterReturnsDifferentInstancesForDifferentLangCodes() {
		$factoryMock = new MessageFormatterFactory( Message::FORMAT_PLAIN );
		$langCodeMock1 = 'en';
		$langCodeMock2 = 'fr';
		$formatterMock1 = $factoryMock->getTextFormatter( $langCodeMock1 );
		$formatterMock2 = $factoryMock->getTextFormatter( $langCodeMock2 );
		$this->assertNotSame( $formatterMock1, $formatterMock2 );
	}

	public function testGetTextFormatterWithDifferentFormats() {
		$factoryMock1 = new MessageFormatterFactory( Message::FORMAT_TEXT );
		$factoryMock2 = new MessageFormatterFactory( Message::FORMAT_PLAIN );
		$langCodeMock = 'en';
		$formatterMock1 = $factoryMock1->getTextFormatter( $langCodeMock );
		$formatterMock2 = $factoryMock2->getTextFormatter( $langCodeMock );
		$this->assertNotSame( $formatterMock1, $formatterMock2 );
	}
}
PK       ! q+      *  installer/patches/drop-table-updatelog.sqlnu Iw        DROP TABLE /*_*/updatelog;
PK       ! i    !  installer/DatabaseUpdaterTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Installer;

use MediaWiki\Installer\DatabaseUpdater;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Installer\DatabaseUpdater
 * @group Database
 */
class DatabaseUpdaterTest extends MediaWikiIntegrationTestCase {
	/** @dataProvider provideUpdateRowExists */
	public function testUpdateRowExists( $key, $expectedReturnValue ) {
		// Add a testing row to the updatelog table to test lookup
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'updatelog' )
			->row( [ 'ul_key' => 'test', 'ul_value' => null ] )
			->caller( __METHOD__ )
			->execute();
		// Call the method under test
		$objectUnderTest = DatabaseUpdater::newForDB(
			$this->getServiceContainer()->getDBLoadBalancer()->getMaintenanceConnectionRef( DB_PRIMARY )
		);
		$this->assertSame( $expectedReturnValue, $objectUnderTest->updateRowExists( $key ) );
	}

	public static function provideUpdateRowExists() {
		return [
			'Key is present in updatelog table' => [ 'test', true ],
			'Key is not present in updatelog table' => [ 'testing', false ],
		];
	}

	/** @dataProvider provideInsertUpdateRow */
	public function testInsertUpdateRow( $key, $val ) {
		// Call the method under test
		$objectUnderTest = DatabaseUpdater::newForDB(
			$this->getServiceContainer()->getDBLoadBalancer()->getMaintenanceConnectionRef( DB_PRIMARY )
		);
		$objectUnderTest->insertUpdateRow( $key, $val );
		// Expect that the updatelog contains the expected row
		$this->newSelectQueryBuilder()
			->select( [ 'ul_key', 'ul_value' ] )
			->from( 'updatelog' )
			->caller( __METHOD__ )
			->assertRowValue( [ $key, $val ] );
	}

	public static function provideInsertUpdateRow() {
		return [
			'Value is not null' => [ 'test', 'test' ],
			'Value is null' => [ 'testing', null ],
		];
	}
}
PK       ! k    5  installer/DatabaseUpdaterWhenUpdateLogMissingTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Installer;

use MediaWiki\Installer\DatabaseUpdater;
use MediaWikiIntegrationTestCase;
use Wikimedia\Rdbms\IMaintainableDatabase;

/**
 * @covers \MediaWiki\Installer\DatabaseUpdater
 * @group Database
 */
class DatabaseUpdaterWhenUpdateLogMissingTest extends MediaWikiIntegrationTestCase {
	public function testUpdateRowExistsWhenUpdateLogTableMissing() {
		// Check that updatelog is actually dropped, otherwise the test will still pass but not test what we want
		// it to test.
		$dbw = $this->getServiceContainer()->getDBLoadBalancer()->getMaintenanceConnectionRef( DB_PRIMARY );
		$this->assertFalse( $dbw->tableExists( 'updatelog' ) );
		// Call the method under test.
		$objectUnderTest = DatabaseUpdater::newForDB( $dbw );
		$this->assertSame( false, $objectUnderTest->updateRowExists( 'test' ) );
	}

	public function testInsertUpdateRowWhenUpdateLogTableMissing() {
		// Call the method under test
		$dbw = $this->getServiceContainer()->getDBLoadBalancer()->getMaintenanceConnectionRef( DB_PRIMARY );
		$objectUnderTest = DatabaseUpdater::newForDB( $dbw );
		$objectUnderTest->insertUpdateRow( 'test' );
		// Check that updatelog still does not exist after calling the method under test
		$this->assertFalse( $dbw->tableExists( 'updatelog' ) );
	}

	protected function getSchemaOverrides( IMaintainableDatabase $db ) {
		return [
			'drop' => [ 'updatelog' ],
			'scripts' => [ __DIR__ . '/patches/drop-table-updatelog.sql' ]
		];
	}
}
PK       ! 
  
    installer/WebInstallerTest.phpnu Iw        <?php

use MediaWiki\Installer\WebInstaller;
use MediaWiki\Request\FauxRequest;

class WebInstallerTest extends MediaWikiIntegrationTestCase {
	/**
	 * @covers \MediaWiki\Installer\WebInstaller::getAcceptLanguage
	 * @dataProvider provideGetAcceptLanguage
	 */
	public function testGetAcceptLanguage( $expected, $acceptLanguage ) {
		$request = new FauxRequest();
		$request->setHeader( 'Accept-Language', $acceptLanguage );
		$webInstaller = new WebInstaller( $request );
		$this->assertSame(
			$expected,
			$webInstaller->getAcceptLanguage()
		);
	}

	public function provideGetAcceptLanguage() {
		return [
			[ 'de-ch', 'de-LI,de-CH;q=0.8,de;q=0.5,en;q=0.3' ],
			// T189193: This should be 'de-de' or 'de'.
			[ 'de-at', 'de-DE,de-AT;q=0.8,de;q=0.5,en;q=0.3' ]
		];
	}
}
PK       ! 
R,  ,  $  installer/WebInstallerOutputTest.phpnu Iw        <?php

use MediaWiki\Installer\WebInstaller;
use MediaWiki\Installer\WebInstallerOutput;

class WebInstallerOutputTest extends MediaWikiIntegrationTestCase {
	/**
	 * @covers \MediaWiki\Installer\WebInstallerOutput::getCSS
	 */
	public function testGetCSS() {
		$_SERVER['DOCUMENT_ROOT'] = __DIR__ . '../../../';
		$installer = $this->createMock( WebInstaller::class );
		$out = new WebInstallerOutput( $installer );
		$css = $out->getCSS();
		$this->assertStringContainsString(
			'#mw-panel {',
			$css,
			'CSS for installer can be generated'
		);
	}
}
PK       ! uJ  J    languages/LanguageBhoTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 */
class LanguageBhoTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 200 ],
		];
	}
}
PK       !  QI  I    languages/LanguageHiTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 */
class LanguageHiTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 200 ],
		];
	}
}
PK       ! Oɽ      languages/LanguageSkTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 */
class LanguageSkTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'few', 2 ],
			[ 'few', 3 ],
			[ 'few', 4 ],
			[ 'other', 5 ],
			[ 'other', 11 ],
			[ 'other', 20 ],
			[ 'other', 25 ],
			[ 'other', 200 ],
		];
	}
}
PK       ! g=h      languages/LanguageMtTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 */
class LanguageMtTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'many', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'few', 0 ],
			[ 'one', 1 ],
			[ 'few', 2 ],
			[ 'few', 10 ],
			[ 'many', 11 ],
			[ 'many', 19 ],
			[ 'other', 20 ],
			[ 'other', 99 ],
			[ 'other', 100 ],
			[ 'other', 101 ],
			[ 'few', 102 ],
			[ 'few', 110 ],
			[ 'many', 111 ],
			[ 'many', 119 ],
			[ 'other', 120 ],
			[ 'other', 201 ],
		];
	}

	/**
	 * @dataProvider providePluralTwoForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPluralTwoForms( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	public static function providePluralTwoForms() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 10 ],
			[ 'other', 11 ],
			[ 'other', 19 ],
			[ 'other', 20 ],
			[ 'other', 99 ],
			[ 'other', 100 ],
			[ 'other', 101 ],
			[ 'other', 102 ],
			[ 'other', 110 ],
			[ 'other', 111 ],
			[ 'other', 119 ],
			[ 'other', 120 ],
			[ 'other', 201 ],
		];
	}
}
PK       ! C      languages/LanguageCuTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 * @covers \LanguageCu
 */
class LanguageCuTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'two', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'two', 2 ],
			[ 'few', 3 ],
			[ 'few', 4 ],
			[ 'other', 5 ],
			[ 'one', 11 ],
			[ 'other', 20 ],
			[ 'two', 22 ],
			[ 'few', 223 ],
			[ 'other', 200 ],
		];
	}
}
PK       ! 4      languages/LanguageMnTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 */
class LanguageMnTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providerGrammar
	 * @covers \MediaWiki\Language\Language::convertGrammar
	 */
	public function testGrammar( $result, $word, $case ) {
		$this->assertEquals( $result, $this->getLang()->convertGrammar( $word, $case ) );
	}

	public static function providerGrammar() {
		yield 'Wikipedia genitive' => [
			'Википедиагийн',
			'Википедиа',
			'genitive',
		];
		yield 'Wiktionary genitive' => [
			'Викитолийн',
			'Викитоль',
			'genitive',
		];
	}
}
PK       ! K      languages/LanguageMkTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * Tests for Macedonian (македонски)
 *
 * @group Language
 */
class LanguageMkTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'one', 11 ],
			[ 'one', 21 ],
			[ 'one', 411 ],
			[ 'other', 12.345 ],
			[ 'other', 20 ],
			[ 'one', 31 ],
			[ 'other', 200 ],
		];
	}
}
PK       ! 2a
  
    languages/LanguageArTest.phpnu Iw        <?php

/**
 * @group Language
 * @covers \LanguageAr
 */
class LanguageArTest extends LanguageClassesTestCase {

	/**
	 * @covers \MediaWiki\Language\Language::formatNum
	 * @dataProvider provideFormatNum
	 */
	public function testFormatNum( $num, $formatted ) {
		$this->assertEquals( $formatted, $this->getLang()->formatNum( $num ) );
	}

	public static function provideFormatNum() {
		return [
			[ '1234567', '١٬٢٣٤٬٥٦٧' ],
			[ -12.89, '−١٢٫٨٩' ],
			[ '1289.456', '١٬٢٨٩٫٤٥٦' ]
		];
	}

	/**
	 * @covers \LanguageAr::normalize
	 * @covers \MediaWiki\Language\Language::normalize
	 * @dataProvider provideNormalize
	 */
	public function testNormalize( $input, $expected ) {
		if ( $input === $expected ) {
			$this->fail( 'Expected output must differ.' );
		}

		$this->assertSame(
			$expected,
			$this->getLang()->normalize( $input ),
			'ar-normalised form'
		);
	}

	public static function provideNormalize() {
		return [
			[
				'ﷅ',
				'صمم',
			],
			[
				'ﻴ',
				'ي',
			],
			[
				'ﻬ',
				'ه',
			],
		];
	}

	/**
	 * Mostly to test the raw ascii feature.
	 * @dataProvider provideSprintfDate
	 * @covers \MediaWiki\Language\Language::sprintfDate
	 */
	public function testSprintfDate( $format, $date, $expected ) {
		$this->assertEquals( $expected, $this->getLang()->sprintfDate( $format, $date ) );
	}

	public static function provideSprintfDate() {
		return [
			[
				'xg "vs" g',
				'20120102030410',
				'يناير vs ٣'
			],
			[
				'xmY',
				'20120102030410',
				'١٤٣٣'
			],
			[
				'xnxmY',
				'20120102030410',
				'1433'
			],
			[
				'xN xmj xmn xN xmY',
				'20120102030410',
				' 7 2  ١٤٣٣'
			],
		];
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'zero', 'one', 'two', 'few', 'many', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'zero', 0 ],
			[ 'one', 1 ],
			[ 'two', 2 ],
			[ 'few', 3 ],
			[ 'few', 9 ],
			[ 'few', 110 ],
			[ 'many', 11 ],
			[ 'many', 15 ],
			[ 'many', 99 ],
			[ 'many', 9999 ],
			[ 'other', 100 ],
			[ 'other', 102 ],
			[ 'other', 1000 ],
			[ 'other', 1.7 ],
		];
	}
}
PK       ! yEP  P    languages/LanguageTlTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 */
class LanguageTlTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 0 ],
			[ 'one', 1 ],
			[ 'one', 2 ],
			[ 'other', 4 ],
			[ 'other', 6 ],
		];
	}
}
PK       ! G      languages/LanguageSeTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 */
class LanguageSeTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'two', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'two', 2 ],
			[ 'other', 3 ],
		];
	}

	/**
	 * @dataProvider providePluralTwoForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPluralTwoForms( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	public static function providePluralTwoForms() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 3 ],
		];
	}
}
PK       ! H      languages/LanguageTrTest.phpnu Iw        <?php
/**
 * @author Antoine Musso
 * @copyright Copyright © 2011, Antoine Musso
 * @file
 */

/**
 * @group Language
 * @covers \LanguageTr
 */
class LanguageTrTest extends LanguageClassesTestCase {

	/**
	 * See T30040
	 * Credits to irc://irc.freenode.net/wikipedia-tr users:
	 *  - berm
	 *  - []LuCkY[]
	 *  - Emperyan
	 * @see https://en.wikipedia.org/wiki/Dotted_and_dotless_I
	 * @dataProvider provideDottedAndDotlessI
	 * @covers \MediaWiki\Language\Language::ucfirst
	 * @covers \MediaWiki\Language\Language::lcfirst
	 */
	public function testDottedAndDotlessI( $func, $input, $inputCase, $expected ) {
		if ( $func == 'ucfirst' ) {
			$res = $this->getLang()->ucfirst( $input );
		} elseif ( $func == 'lcfirst' ) {
			$res = $this->getLang()->lcfirst( $input );
		} else {
			throw new InvalidArgumentException( __METHOD__ . " given an invalid function name '$func'" );
		}

		$msg = "Converting $inputCase case '$input' with $func should give '$expected'";

		$this->assertEquals( $expected, $res, $msg );
	}

	public static function provideDottedAndDotlessI() {
		return [
			# function, input, input case, expected
			# Case changed:
			[ 'ucfirst', 'ı', 'lower', 'I' ],
			[ 'ucfirst', 'i', 'lower', 'İ' ],
			[ 'lcfirst', 'I', 'upper', 'ı' ],
			[ 'lcfirst', 'İ', 'upper', 'i' ],

			# Already using the correct case
			[ 'ucfirst', 'I', 'upper', 'I' ],
			[ 'ucfirst', 'İ', 'upper', 'İ' ],
			[ 'lcfirst', 'ı', 'lower', 'ı' ],
			[ 'lcfirst', 'i', 'lower', 'i' ],

			# A real example taken from T30040 using
			# https://tr.wikipedia.org/wiki/%C4%B0Phone
			[ 'lcfirst', 'iPhone', 'lower', 'iPhone' ],

			# next case is valid in Turkish but are different words if we
			# consider IPhone is English!
			[ 'lcfirst', 'IPhone', 'upper', 'ıPhone' ],

		];
	}
}
PK       ! ] \  \    languages/LanguageWaTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 * @covers \LanguageWa
 */
class LanguageWaTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
		];
	}

	/**
	 * @dataProvider provideTimeAndDate
	 * @covers \LanguageWa::timeanddate
	 * @covers \LanguageWa::date
	 */
	public function testTimeAndDate( $result, $ts, $format ) {
		$this->assertEquals( $result, $this->getLang()->timeanddate( $ts, false, $format, false ) );
	}

	public static function provideTimeAndDate() {
		return [
			// Simple formats
			[ '01/01/2012 a 00:00', '20120101000000', 'walloon short' ],
			[ '2012-01-01T00:00:00', '20120101000000', 'ISO 8601' ],

			// Every date in the format that requires custom code to format
			[ '1î d\' djanvî 2012 a 00:00', '20120101000000', 'dmy' ],
			[ '2 d\' djanvî 2012 a 00:00', '20120102000000', 'dmy' ],
			[ '3 d\' djanvî 2012 a 00:00', '20120103000000', 'dmy' ],
			[ '4 di djanvî 2012 a 00:00', '20120104000000', 'dmy' ],
			[ '5 di djanvî 2012 a 00:00', '20120105000000', 'dmy' ],
			[ '6 di djanvî 2012 a 00:00', '20120106000000', 'dmy' ],
			[ '7 di djanvî 2012 a 00:00', '20120107000000', 'dmy' ],
			[ '8 di djanvî 2012 a 00:00', '20120108000000', 'dmy' ],
			[ '9 di djanvî 2012 a 00:00', '20120109000000', 'dmy' ],
			[ '10 di djanvî 2012 a 00:00', '20120110000000', 'dmy' ],
			[ '11 di djanvî 2012 a 00:00', '20120111000000', 'dmy' ],
			[ '12 di djanvî 2012 a 00:00', '20120112000000', 'dmy' ],
			[ '13 di djanvî 2012 a 00:00', '20120113000000', 'dmy' ],
			[ '14 di djanvî 2012 a 00:00', '20120114000000', 'dmy' ],
			[ '15 di djanvî 2012 a 00:00', '20120115000000', 'dmy' ],
			[ '16 di djanvî 2012 a 00:00', '20120116000000', 'dmy' ],
			[ '17 di djanvî 2012 a 00:00', '20120117000000', 'dmy' ],
			[ '18 di djanvî 2012 a 00:00', '20120118000000', 'dmy' ],
			[ '19 di djanvî 2012 a 00:00', '20120119000000', 'dmy' ],
			[ '20 d\' djanvî 2012 a 00:00', '20120120000000', 'dmy' ],
			[ '21 di djanvî 2012 a 00:00', '20120121000000', 'dmy' ],
			[ '22 d\' djanvî 2012 a 00:00', '20120122000000', 'dmy' ],
			[ '23 d\' djanvî 2012 a 00:00', '20120123000000', 'dmy' ],
			[ '24 di djanvî 2012 a 00:00', '20120124000000', 'dmy' ],
			[ '25 di djanvî 2012 a 00:00', '20120125000000', 'dmy' ],
			[ '26 di djanvî 2012 a 00:00', '20120126000000', 'dmy' ],
			[ '27 di djanvî 2012 a 00:00', '20120127000000', 'dmy' ],
			[ '28 di djanvî 2012 a 00:00', '20120128000000', 'dmy' ],
			[ '29 di djanvî 2012 a 00:00', '20120129000000', 'dmy' ],
			[ '30 di djanvî 2012 a 00:00', '20120130000000', 'dmy' ],
			[ '31 di djanvî 2012 a 00:00', '20120131000000', 'dmy' ],
			[ '1î d\' fevrî 2012 a 00:00', '20120201000000', 'dmy' ],
			[ '2 d\' fevrî 2012 a 00:00', '20120202000000', 'dmy' ],
			[ '3 d\' fevrî 2012 a 00:00', '20120203000000', 'dmy' ],
			[ '4 di fevrî 2012 a 00:00', '20120204000000', 'dmy' ],
			[ '5 di fevrî 2012 a 00:00', '20120205000000', 'dmy' ],
			[ '6 di fevrî 2012 a 00:00', '20120206000000', 'dmy' ],
			[ '7 di fevrî 2012 a 00:00', '20120207000000', 'dmy' ],
			[ '8 di fevrî 2012 a 00:00', '20120208000000', 'dmy' ],
			[ '9 di fevrî 2012 a 00:00', '20120209000000', 'dmy' ],
			[ '10 di fevrî 2012 a 00:00', '20120210000000', 'dmy' ],
			[ '11 di fevrî 2012 a 00:00', '20120211000000', 'dmy' ],
			[ '12 di fevrî 2012 a 00:00', '20120212000000', 'dmy' ],
			[ '13 di fevrî 2012 a 00:00', '20120213000000', 'dmy' ],
			[ '14 di fevrî 2012 a 00:00', '20120214000000', 'dmy' ],
			[ '15 di fevrî 2012 a 00:00', '20120215000000', 'dmy' ],
			[ '16 di fevrî 2012 a 00:00', '20120216000000', 'dmy' ],
			[ '17 di fevrî 2012 a 00:00', '20120217000000', 'dmy' ],
			[ '18 di fevrî 2012 a 00:00', '20120218000000', 'dmy' ],
			[ '19 di fevrî 2012 a 00:00', '20120219000000', 'dmy' ],
			[ '20 d\' fevrî 2012 a 00:00', '20120220000000', 'dmy' ],
			[ '21 di fevrî 2012 a 00:00', '20120221000000', 'dmy' ],
			[ '22 d\' fevrî 2012 a 00:00', '20120222000000', 'dmy' ],
			[ '23 d\' fevrî 2012 a 00:00', '20120223000000', 'dmy' ],
			[ '24 di fevrî 2012 a 00:00', '20120224000000', 'dmy' ],
			[ '25 di fevrî 2012 a 00:00', '20120225000000', 'dmy' ],
			[ '26 di fevrî 2012 a 00:00', '20120226000000', 'dmy' ],
			[ '27 di fevrî 2012 a 00:00', '20120227000000', 'dmy' ],
			[ '28 di fevrî 2012 a 00:00', '20120228000000', 'dmy' ],
			[ '29 di fevrî 2012 a 00:00', '20120229000000', 'dmy' ],
			[ '1î d\' måss 2012 a 00:00', '20120301000000', 'dmy' ],
			[ '2 d\' måss 2012 a 00:00', '20120302000000', 'dmy' ],
			[ '3 d\' måss 2012 a 00:00', '20120303000000', 'dmy' ],
			[ '4 di måss 2012 a 00:00', '20120304000000', 'dmy' ],
			[ '5 di måss 2012 a 00:00', '20120305000000', 'dmy' ],
			[ '6 di måss 2012 a 00:00', '20120306000000', 'dmy' ],
			[ '7 di måss 2012 a 00:00', '20120307000000', 'dmy' ],
			[ '8 di måss 2012 a 00:00', '20120308000000', 'dmy' ],
			[ '9 di måss 2012 a 00:00', '20120309000000', 'dmy' ],
			[ '10 di måss 2012 a 00:00', '20120310000000', 'dmy' ],
			[ '11 di måss 2012 a 00:00', '20120311000000', 'dmy' ],
			[ '12 di måss 2012 a 00:00', '20120312000000', 'dmy' ],
			[ '13 di måss 2012 a 00:00', '20120313000000', 'dmy' ],
			[ '14 di måss 2012 a 00:00', '20120314000000', 'dmy' ],
			[ '15 di måss 2012 a 00:00', '20120315000000', 'dmy' ],
			[ '16 di måss 2012 a 00:00', '20120316000000', 'dmy' ],
			[ '17 di måss 2012 a 00:00', '20120317000000', 'dmy' ],
			[ '18 di måss 2012 a 00:00', '20120318000000', 'dmy' ],
			[ '19 di måss 2012 a 00:00', '20120319000000', 'dmy' ],
			[ '20 d\' måss 2012 a 00:00', '20120320000000', 'dmy' ],
			[ '21 di måss 2012 a 00:00', '20120321000000', 'dmy' ],
			[ '22 d\' måss 2012 a 00:00', '20120322000000', 'dmy' ],
			[ '23 d\' måss 2012 a 00:00', '20120323000000', 'dmy' ],
			[ '24 di måss 2012 a 00:00', '20120324000000', 'dmy' ],
			[ '25 di måss 2012 a 00:00', '20120325000000', 'dmy' ],
			[ '26 di måss 2012 a 00:00', '20120326000000', 'dmy' ],
			[ '27 di måss 2012 a 00:00', '20120327000000', 'dmy' ],
			[ '28 di måss 2012 a 00:00', '20120328000000', 'dmy' ],
			[ '29 di måss 2012 a 00:00', '20120329000000', 'dmy' ],
			[ '30 di måss 2012 a 00:00', '20120330000000', 'dmy' ],
			[ '31 di måss 2012 a 00:00', '20120331000000', 'dmy' ],
			[ '1î d\' avri 2012 a 00:00', '20120401000000', 'dmy' ],
			[ '2 d\' avri 2012 a 00:00', '20120402000000', 'dmy' ],
			[ '3 d\' avri 2012 a 00:00', '20120403000000', 'dmy' ],
			[ '4 d\' avri 2012 a 00:00', '20120404000000', 'dmy' ],
			[ '5 d\' avri 2012 a 00:00', '20120405000000', 'dmy' ],
			[ '6 d\' avri 2012 a 00:00', '20120406000000', 'dmy' ],
			[ '7 d\' avri 2012 a 00:00', '20120407000000', 'dmy' ],
			[ '8 d\' avri 2012 a 00:00', '20120408000000', 'dmy' ],
			[ '9 d\' avri 2012 a 00:00', '20120409000000', 'dmy' ],
			[ '10 d\' avri 2012 a 00:00', '20120410000000', 'dmy' ],
			[ '11 d\' avri 2012 a 00:00', '20120411000000', 'dmy' ],
			[ '12 d\' avri 2012 a 00:00', '20120412000000', 'dmy' ],
			[ '13 d\' avri 2012 a 00:00', '20120413000000', 'dmy' ],
			[ '14 d\' avri 2012 a 00:00', '20120414000000', 'dmy' ],
			[ '15 d\' avri 2012 a 00:00', '20120415000000', 'dmy' ],
			[ '16 d\' avri 2012 a 00:00', '20120416000000', 'dmy' ],
			[ '17 d\' avri 2012 a 00:00', '20120417000000', 'dmy' ],
			[ '18 d\' avri 2012 a 00:00', '20120418000000', 'dmy' ],
			[ '19 d\' avri 2012 a 00:00', '20120419000000', 'dmy' ],
			[ '20 d\' avri 2012 a 00:00', '20120420000000', 'dmy' ],
			[ '21 d\' avri 2012 a 00:00', '20120421000000', 'dmy' ],
			[ '22 d\' avri 2012 a 00:00', '20120422000000', 'dmy' ],
			[ '23 d\' avri 2012 a 00:00', '20120423000000', 'dmy' ],
			[ '24 d\' avri 2012 a 00:00', '20120424000000', 'dmy' ],
			[ '25 d\' avri 2012 a 00:00', '20120425000000', 'dmy' ],
			[ '26 d\' avri 2012 a 00:00', '20120426000000', 'dmy' ],
			[ '27 d\' avri 2012 a 00:00', '20120427000000', 'dmy' ],
			[ '28 d\' avri 2012 a 00:00', '20120428000000', 'dmy' ],
			[ '29 d\' avri 2012 a 00:00', '20120429000000', 'dmy' ],
			[ '30 d\' avri 2012 a 00:00', '20120430000000', 'dmy' ],
			[ '1î d\' may 2012 a 00:00', '20120501000000', 'dmy' ],
			[ '2 d\' may 2012 a 00:00', '20120502000000', 'dmy' ],
			[ '3 d\' may 2012 a 00:00', '20120503000000', 'dmy' ],
			[ '4 di may 2012 a 00:00', '20120504000000', 'dmy' ],
			[ '5 di may 2012 a 00:00', '20120505000000', 'dmy' ],
			[ '6 di may 2012 a 00:00', '20120506000000', 'dmy' ],
			[ '7 di may 2012 a 00:00', '20120507000000', 'dmy' ],
			[ '8 di may 2012 a 00:00', '20120508000000', 'dmy' ],
			[ '9 di may 2012 a 00:00', '20120509000000', 'dmy' ],
			[ '10 di may 2012 a 00:00', '20120510000000', 'dmy' ],
			[ '11 di may 2012 a 00:00', '20120511000000', 'dmy' ],
			[ '12 di may 2012 a 00:00', '20120512000000', 'dmy' ],
			[ '13 di may 2012 a 00:00', '20120513000000', 'dmy' ],
			[ '14 di may 2012 a 00:00', '20120514000000', 'dmy' ],
			[ '15 di may 2012 a 00:00', '20120515000000', 'dmy' ],
			[ '16 di may 2012 a 00:00', '20120516000000', 'dmy' ],
			[ '17 di may 2012 a 00:00', '20120517000000', 'dmy' ],
			[ '18 di may 2012 a 00:00', '20120518000000', 'dmy' ],
			[ '19 di may 2012 a 00:00', '20120519000000', 'dmy' ],
			[ '20 d\' may 2012 a 00:00', '20120520000000', 'dmy' ],
			[ '21 di may 2012 a 00:00', '20120521000000', 'dmy' ],
			[ '22 d\' may 2012 a 00:00', '20120522000000', 'dmy' ],
			[ '23 d\' may 2012 a 00:00', '20120523000000', 'dmy' ],
			[ '24 di may 2012 a 00:00', '20120524000000', 'dmy' ],
			[ '25 di may 2012 a 00:00', '20120525000000', 'dmy' ],
			[ '26 di may 2012 a 00:00', '20120526000000', 'dmy' ],
			[ '27 di may 2012 a 00:00', '20120527000000', 'dmy' ],
			[ '28 di may 2012 a 00:00', '20120528000000', 'dmy' ],
			[ '29 di may 2012 a 00:00', '20120529000000', 'dmy' ],
			[ '30 di may 2012 a 00:00', '20120530000000', 'dmy' ],
			[ '31 di may 2012 a 00:00', '20120531000000', 'dmy' ],
			[ '1î d\' djun 2012 a 00:00', '20120601000000', 'dmy' ],
			[ '2 d\' djun 2012 a 00:00', '20120602000000', 'dmy' ],
			[ '3 d\' djun 2012 a 00:00', '20120603000000', 'dmy' ],
			[ '4 di djun 2012 a 00:00', '20120604000000', 'dmy' ],
			[ '5 di djun 2012 a 00:00', '20120605000000', 'dmy' ],
			[ '6 di djun 2012 a 00:00', '20120606000000', 'dmy' ],
			[ '7 di djun 2012 a 00:00', '20120607000000', 'dmy' ],
			[ '8 di djun 2012 a 00:00', '20120608000000', 'dmy' ],
			[ '9 di djun 2012 a 00:00', '20120609000000', 'dmy' ],
			[ '10 di djun 2012 a 00:00', '20120610000000', 'dmy' ],
			[ '11 di djun 2012 a 00:00', '20120611000000', 'dmy' ],
			[ '12 di djun 2012 a 00:00', '20120612000000', 'dmy' ],
			[ '13 di djun 2012 a 00:00', '20120613000000', 'dmy' ],
			[ '14 di djun 2012 a 00:00', '20120614000000', 'dmy' ],
			[ '15 di djun 2012 a 00:00', '20120615000000', 'dmy' ],
			[ '16 di djun 2012 a 00:00', '20120616000000', 'dmy' ],
			[ '17 di djun 2012 a 00:00', '20120617000000', 'dmy' ],
			[ '18 di djun 2012 a 00:00', '20120618000000', 'dmy' ],
			[ '19 di djun 2012 a 00:00', '20120619000000', 'dmy' ],
			[ '20 d\' djun 2012 a 00:00', '20120620000000', 'dmy' ],
			[ '21 di djun 2012 a 00:00', '20120621000000', 'dmy' ],
			[ '22 d\' djun 2012 a 00:00', '20120622000000', 'dmy' ],
			[ '23 d\' djun 2012 a 00:00', '20120623000000', 'dmy' ],
			[ '24 di djun 2012 a 00:00', '20120624000000', 'dmy' ],
			[ '25 di djun 2012 a 00:00', '20120625000000', 'dmy' ],
			[ '26 di djun 2012 a 00:00', '20120626000000', 'dmy' ],
			[ '27 di djun 2012 a 00:00', '20120627000000', 'dmy' ],
			[ '28 di djun 2012 a 00:00', '20120628000000', 'dmy' ],
			[ '29 di djun 2012 a 00:00', '20120629000000', 'dmy' ],
			[ '30 di djun 2012 a 00:00', '20120630000000', 'dmy' ],
			[ '1î d\' djulete 2012 a 00:00', '20120701000000', 'dmy' ],
			[ '2 d\' djulete 2012 a 00:00', '20120702000000', 'dmy' ],
			[ '3 d\' djulete 2012 a 00:00', '20120703000000', 'dmy' ],
			[ '4 di djulete 2012 a 00:00', '20120704000000', 'dmy' ],
			[ '5 di djulete 2012 a 00:00', '20120705000000', 'dmy' ],
			[ '6 di djulete 2012 a 00:00', '20120706000000', 'dmy' ],
			[ '7 di djulete 2012 a 00:00', '20120707000000', 'dmy' ],
			[ '8 di djulete 2012 a 00:00', '20120708000000', 'dmy' ],
			[ '9 di djulete 2012 a 00:00', '20120709000000', 'dmy' ],
			[ '10 di djulete 2012 a 00:00', '20120710000000', 'dmy' ],
			[ '11 di djulete 2012 a 00:00', '20120711000000', 'dmy' ],
			[ '12 di djulete 2012 a 00:00', '20120712000000', 'dmy' ],
			[ '13 di djulete 2012 a 00:00', '20120713000000', 'dmy' ],
			[ '14 di djulete 2012 a 00:00', '20120714000000', 'dmy' ],
			[ '15 di djulete 2012 a 00:00', '20120715000000', 'dmy' ],
			[ '16 di djulete 2012 a 00:00', '20120716000000', 'dmy' ],
			[ '17 di djulete 2012 a 00:00', '20120717000000', 'dmy' ],
			[ '18 di djulete 2012 a 00:00', '20120718000000', 'dmy' ],
			[ '19 di djulete 2012 a 00:00', '20120719000000', 'dmy' ],
			[ '20 d\' djulete 2012 a 00:00', '20120720000000', 'dmy' ],
			[ '21 di djulete 2012 a 00:00', '20120721000000', 'dmy' ],
			[ '22 d\' djulete 2012 a 00:00', '20120722000000', 'dmy' ],
			[ '23 d\' djulete 2012 a 00:00', '20120723000000', 'dmy' ],
			[ '24 di djulete 2012 a 00:00', '20120724000000', 'dmy' ],
			[ '25 di djulete 2012 a 00:00', '20120725000000', 'dmy' ],
			[ '26 di djulete 2012 a 00:00', '20120726000000', 'dmy' ],
			[ '27 di djulete 2012 a 00:00', '20120727000000', 'dmy' ],
			[ '28 di djulete 2012 a 00:00', '20120728000000', 'dmy' ],
			[ '29 di djulete 2012 a 00:00', '20120729000000', 'dmy' ],
			[ '30 di djulete 2012 a 00:00', '20120730000000', 'dmy' ],
			[ '31 di djulete 2012 a 00:00', '20120731000000', 'dmy' ],
			[ '1î d\' awousse 2012 a 00:00', '20120801000000', 'dmy' ],
			[ '2 d\' awousse 2012 a 00:00', '20120802000000', 'dmy' ],
			[ '3 d\' awousse 2012 a 00:00', '20120803000000', 'dmy' ],
			[ '4 d\' awousse 2012 a 00:00', '20120804000000', 'dmy' ],
			[ '5 d\' awousse 2012 a 00:00', '20120805000000', 'dmy' ],
			[ '6 d\' awousse 2012 a 00:00', '20120806000000', 'dmy' ],
			[ '7 d\' awousse 2012 a 00:00', '20120807000000', 'dmy' ],
			[ '8 d\' awousse 2012 a 00:00', '20120808000000', 'dmy' ],
			[ '9 d\' awousse 2012 a 00:00', '20120809000000', 'dmy' ],
			[ '10 d\' awousse 2012 a 00:00', '20120810000000', 'dmy' ],
			[ '11 d\' awousse 2012 a 00:00', '20120811000000', 'dmy' ],
			[ '12 d\' awousse 2012 a 00:00', '20120812000000', 'dmy' ],
			[ '13 d\' awousse 2012 a 00:00', '20120813000000', 'dmy' ],
			[ '14 d\' awousse 2012 a 00:00', '20120814000000', 'dmy' ],
			[ '15 d\' awousse 2012 a 00:00', '20120815000000', 'dmy' ],
			[ '16 d\' awousse 2012 a 00:00', '20120816000000', 'dmy' ],
			[ '17 d\' awousse 2012 a 00:00', '20120817000000', 'dmy' ],
			[ '18 d\' awousse 2012 a 00:00', '20120818000000', 'dmy' ],
			[ '19 d\' awousse 2012 a 00:00', '20120819000000', 'dmy' ],
			[ '20 d\' awousse 2012 a 00:00', '20120820000000', 'dmy' ],
			[ '21 d\' awousse 2012 a 00:00', '20120821000000', 'dmy' ],
			[ '22 d\' awousse 2012 a 00:00', '20120822000000', 'dmy' ],
			[ '23 d\' awousse 2012 a 00:00', '20120823000000', 'dmy' ],
			[ '24 d\' awousse 2012 a 00:00', '20120824000000', 'dmy' ],
			[ '25 d\' awousse 2012 a 00:00', '20120825000000', 'dmy' ],
			[ '26 d\' awousse 2012 a 00:00', '20120826000000', 'dmy' ],
			[ '27 d\' awousse 2012 a 00:00', '20120827000000', 'dmy' ],
			[ '28 d\' awousse 2012 a 00:00', '20120828000000', 'dmy' ],
			[ '29 d\' awousse 2012 a 00:00', '20120829000000', 'dmy' ],
			[ '30 d\' awousse 2012 a 00:00', '20120830000000', 'dmy' ],
			[ '31 d\' awousse 2012 a 00:00', '20120831000000', 'dmy' ],
			[ '1î d\' setimbe 2012 a 00:00', '20120901000000', 'dmy' ],
			[ '2 d\' setimbe 2012 a 00:00', '20120902000000', 'dmy' ],
			[ '3 d\' setimbe 2012 a 00:00', '20120903000000', 'dmy' ],
			[ '4 di setimbe 2012 a 00:00', '20120904000000', 'dmy' ],
			[ '5 di setimbe 2012 a 00:00', '20120905000000', 'dmy' ],
			[ '6 di setimbe 2012 a 00:00', '20120906000000', 'dmy' ],
			[ '7 di setimbe 2012 a 00:00', '20120907000000', 'dmy' ],
			[ '8 di setimbe 2012 a 00:00', '20120908000000', 'dmy' ],
			[ '9 di setimbe 2012 a 00:00', '20120909000000', 'dmy' ],
			[ '10 di setimbe 2012 a 00:00', '20120910000000', 'dmy' ],
			[ '11 di setimbe 2012 a 00:00', '20120911000000', 'dmy' ],
			[ '12 di setimbe 2012 a 00:00', '20120912000000', 'dmy' ],
			[ '13 di setimbe 2012 a 00:00', '20120913000000', 'dmy' ],
			[ '14 di setimbe 2012 a 00:00', '20120914000000', 'dmy' ],
			[ '15 di setimbe 2012 a 00:00', '20120915000000', 'dmy' ],
			[ '16 di setimbe 2012 a 00:00', '20120916000000', 'dmy' ],
			[ '17 di setimbe 2012 a 00:00', '20120917000000', 'dmy' ],
			[ '18 di setimbe 2012 a 00:00', '20120918000000', 'dmy' ],
			[ '19 di setimbe 2012 a 00:00', '20120919000000', 'dmy' ],
			[ '20 d\' setimbe 2012 a 00:00', '20120920000000', 'dmy' ],
			[ '21 di setimbe 2012 a 00:00', '20120921000000', 'dmy' ],
			[ '22 d\' setimbe 2012 a 00:00', '20120922000000', 'dmy' ],
			[ '23 d\' setimbe 2012 a 00:00', '20120923000000', 'dmy' ],
			[ '24 di setimbe 2012 a 00:00', '20120924000000', 'dmy' ],
			[ '25 di setimbe 2012 a 00:00', '20120925000000', 'dmy' ],
			[ '26 di setimbe 2012 a 00:00', '20120926000000', 'dmy' ],
			[ '27 di setimbe 2012 a 00:00', '20120927000000', 'dmy' ],
			[ '28 di setimbe 2012 a 00:00', '20120928000000', 'dmy' ],
			[ '29 di setimbe 2012 a 00:00', '20120929000000', 'dmy' ],
			[ '30 di setimbe 2012 a 00:00', '20120930000000', 'dmy' ],
			[ '1î d\' octôbe 2012 a 00:00', '20121001000000', 'dmy' ],
			[ '2 d\' octôbe 2012 a 00:00', '20121002000000', 'dmy' ],
			[ '3 d\' octôbe 2012 a 00:00', '20121003000000', 'dmy' ],
			[ '4 d\' octôbe 2012 a 00:00', '20121004000000', 'dmy' ],
			[ '5 d\' octôbe 2012 a 00:00', '20121005000000', 'dmy' ],
			[ '6 d\' octôbe 2012 a 00:00', '20121006000000', 'dmy' ],
			[ '7 d\' octôbe 2012 a 00:00', '20121007000000', 'dmy' ],
			[ '8 d\' octôbe 2012 a 00:00', '20121008000000', 'dmy' ],
			[ '9 d\' octôbe 2012 a 00:00', '20121009000000', 'dmy' ],
			[ '10 d\' octôbe 2012 a 00:00', '20121010000000', 'dmy' ],
			[ '11 d\' octôbe 2012 a 00:00', '20121011000000', 'dmy' ],
			[ '12 d\' octôbe 2012 a 00:00', '20121012000000', 'dmy' ],
			[ '13 d\' octôbe 2012 a 00:00', '20121013000000', 'dmy' ],
			[ '14 d\' octôbe 2012 a 00:00', '20121014000000', 'dmy' ],
			[ '15 d\' octôbe 2012 a 00:00', '20121015000000', 'dmy' ],
			[ '16 d\' octôbe 2012 a 00:00', '20121016000000', 'dmy' ],
			[ '17 d\' octôbe 2012 a 00:00', '20121017000000', 'dmy' ],
			[ '18 d\' octôbe 2012 a 00:00', '20121018000000', 'dmy' ],
			[ '19 d\' octôbe 2012 a 00:00', '20121019000000', 'dmy' ],
			[ '20 d\' octôbe 2012 a 00:00', '20121020000000', 'dmy' ],
			[ '21 d\' octôbe 2012 a 00:00', '20121021000000', 'dmy' ],
			[ '22 d\' octôbe 2012 a 00:00', '20121022000000', 'dmy' ],
			[ '23 d\' octôbe 2012 a 00:00', '20121023000000', 'dmy' ],
			[ '24 d\' octôbe 2012 a 00:00', '20121024000000', 'dmy' ],
			[ '25 d\' octôbe 2012 a 00:00', '20121025000000', 'dmy' ],
			[ '26 d\' octôbe 2012 a 00:00', '20121026000000', 'dmy' ],
			[ '27 d\' octôbe 2012 a 00:00', '20121027000000', 'dmy' ],
			[ '28 d\' octôbe 2012 a 00:00', '20121028000000', 'dmy' ],
			[ '29 d\' octôbe 2012 a 00:00', '20121029000000', 'dmy' ],
			[ '30 d\' octôbe 2012 a 00:00', '20121030000000', 'dmy' ],
			[ '31 d\' octôbe 2012 a 00:00', '20121031000000', 'dmy' ],
			[ '1î d\' nôvimbe 2012 a 00:00', '20121101000000', 'dmy' ],
			[ '2 d\' nôvimbe 2012 a 00:00', '20121102000000', 'dmy' ],
			[ '3 d\' nôvimbe 2012 a 00:00', '20121103000000', 'dmy' ],
			[ '4 di nôvimbe 2012 a 00:00', '20121104000000', 'dmy' ],
			[ '5 di nôvimbe 2012 a 00:00', '20121105000000', 'dmy' ],
			[ '6 di nôvimbe 2012 a 00:00', '20121106000000', 'dmy' ],
			[ '7 di nôvimbe 2012 a 00:00', '20121107000000', 'dmy' ],
			[ '8 di nôvimbe 2012 a 00:00', '20121108000000', 'dmy' ],
			[ '9 di nôvimbe 2012 a 00:00', '20121109000000', 'dmy' ],
			[ '10 di nôvimbe 2012 a 00:00', '20121110000000', 'dmy' ],
			[ '11 di nôvimbe 2012 a 00:00', '20121111000000', 'dmy' ],
			[ '12 di nôvimbe 2012 a 00:00', '20121112000000', 'dmy' ],
			[ '13 di nôvimbe 2012 a 00:00', '20121113000000', 'dmy' ],
			[ '14 di nôvimbe 2012 a 00:00', '20121114000000', 'dmy' ],
			[ '15 di nôvimbe 2012 a 00:00', '20121115000000', 'dmy' ],
			[ '16 di nôvimbe 2012 a 00:00', '20121116000000', 'dmy' ],
			[ '17 di nôvimbe 2012 a 00:00', '20121117000000', 'dmy' ],
			[ '18 di nôvimbe 2012 a 00:00', '20121118000000', 'dmy' ],
			[ '19 di nôvimbe 2012 a 00:00', '20121119000000', 'dmy' ],
			[ '20 d\' nôvimbe 2012 a 00:00', '20121120000000', 'dmy' ],
			[ '21 di nôvimbe 2012 a 00:00', '20121121000000', 'dmy' ],
			[ '22 d\' nôvimbe 2012 a 00:00', '20121122000000', 'dmy' ],
			[ '23 d\' nôvimbe 2012 a 00:00', '20121123000000', 'dmy' ],
			[ '24 di nôvimbe 2012 a 00:00', '20121124000000', 'dmy' ],
			[ '25 di nôvimbe 2012 a 00:00', '20121125000000', 'dmy' ],
			[ '26 di nôvimbe 2012 a 00:00', '20121126000000', 'dmy' ],
			[ '27 di nôvimbe 2012 a 00:00', '20121127000000', 'dmy' ],
			[ '28 di nôvimbe 2012 a 00:00', '20121128000000', 'dmy' ],
			[ '29 di nôvimbe 2012 a 00:00', '20121129000000', 'dmy' ],
			[ '30 di nôvimbe 2012 a 00:00', '20121130000000', 'dmy' ],
			[ '1î d\' decimbe 2012 a 00:00', '20121201000000', 'dmy' ],
			[ '2 d\' decimbe 2012 a 00:00', '20121202000000', 'dmy' ],
			[ '3 d\' decimbe 2012 a 00:00', '20121203000000', 'dmy' ],
			[ '4 di decimbe 2012 a 00:00', '20121204000000', 'dmy' ],
			[ '5 di decimbe 2012 a 00:00', '20121205000000', 'dmy' ],
			[ '6 di decimbe 2012 a 00:00', '20121206000000', 'dmy' ],
			[ '7 di decimbe 2012 a 00:00', '20121207000000', 'dmy' ],
			[ '8 di decimbe 2012 a 00:00', '20121208000000', 'dmy' ],
			[ '9 di decimbe 2012 a 00:00', '20121209000000', 'dmy' ],
			[ '10 di decimbe 2012 a 00:00', '20121210000000', 'dmy' ],
			[ '11 di decimbe 2012 a 00:00', '20121211000000', 'dmy' ],
			[ '12 di decimbe 2012 a 00:00', '20121212000000', 'dmy' ],
			[ '13 di decimbe 2012 a 00:00', '20121213000000', 'dmy' ],
			[ '14 di decimbe 2012 a 00:00', '20121214000000', 'dmy' ],
			[ '15 di decimbe 2012 a 00:00', '20121215000000', 'dmy' ],
			[ '16 di decimbe 2012 a 00:00', '20121216000000', 'dmy' ],
			[ '17 di decimbe 2012 a 00:00', '20121217000000', 'dmy' ],
			[ '18 di decimbe 2012 a 00:00', '20121218000000', 'dmy' ],
			[ '19 di decimbe 2012 a 00:00', '20121219000000', 'dmy' ],
			[ '20 d\' decimbe 2012 a 00:00', '20121220000000', 'dmy' ],
			[ '21 di decimbe 2012 a 00:00', '20121221000000', 'dmy' ],
			[ '22 d\' decimbe 2012 a 00:00', '20121222000000', 'dmy' ],
			[ '23 d\' decimbe 2012 a 00:00', '20121223000000', 'dmy' ],
			[ '24 di decimbe 2012 a 00:00', '20121224000000', 'dmy' ],
			[ '25 di decimbe 2012 a 00:00', '20121225000000', 'dmy' ],
			[ '26 di decimbe 2012 a 00:00', '20121226000000', 'dmy' ],
			[ '27 di decimbe 2012 a 00:00', '20121227000000', 'dmy' ],
			[ '28 di decimbe 2012 a 00:00', '20121228000000', 'dmy' ],
			[ '29 di decimbe 2012 a 00:00', '20121229000000', 'dmy' ],
			[ '30 di decimbe 2012 a 00:00', '20121230000000', 'dmy' ],
			[ '31 di decimbe 2012 a 00:00', '20121231000000', 'dmy' ],
		];
	}

}
PK       ! f       languages/LanguageTyvTest.phpnu Iw        <?php

/**
 * @group Language
 */
class LanguageTyvTest extends LanguageClassesTestCase {

	/**
	 * @dataProvider provideGrammar
	 * @covers \MediaWiki\Language\Language::convertGrammar
	 */
	public function testGrammar( $result, $word, $case ) {
		$this->assertEquals( $result, $this->getLang()->convertGrammar( $word, $case ) );
	}

	public static function provideGrammar() {
		yield 'Wikipedia genitive' => [
			'Википедияның',
			'Википедия',
			'genitive',
		];
	}
}
PK       ! b      languages/LanguageSmaTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 */
class LanguageSmaTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'two', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'two', 2 ],
			[ 'other', 3 ],
		];
	}

	/**
	 * @dataProvider providePluralTwoForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPluralTwoForms( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	public static function providePluralTwoForms() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 3 ],
		];
	}
}
PK       ! 4~h      languages/LanguageGvTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2013, Santhosh Thottingal
 * @file
 */

/**
 * Tests for Manx (Gaelg)
 *
 * @group Language
 */
class LanguageGvTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'two', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'few', 0 ],
			[ 'one', 1 ],
			[ 'two', 2 ],
			[ 'other', 3 ],
			[ 'few', 20 ],
			[ 'one', 21 ],
			[ 'two', 22 ],
			[ 'other', 23 ],
			[ 'other', 50 ],
			[ 'few', 60 ],
			[ 'few', 80 ],
			[ 'few', 100 ]
		];
	}
}
PK       ! 嫱o      languages/LanguageLvTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * Tests for Latvian
 *
 * @group Language
 */
class LanguageLvTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'zero', 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'zero', 0 ],
			[ 'one', 1 ],
			[ 'zero', 11 ],
			[ 'one', 21 ],
			[ 'zero', 411 ],
			[ 'other', 2 ],
			[ 'other', 9 ],
			[ 'zero', 12 ],
			[ 'other', 12.345 ],
			[ 'zero', 20 ],
			[ 'other', 22 ],
			[ 'one', 31 ],
			[ 'zero', 200 ],
		];
	}
}
PK       ! w      languages/LanguageMlTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2011, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 * @covers \LanguageMl
 */
class LanguageMlTest extends LanguageClassesTestCase {

	/**
	 * @dataProvider provideFormatNum
	 * @covers \MediaWiki\Language\Language::formatNum
	 */
	public function testFormatNum( $value, $result ) {
		// For T31495
		$this->assertEquals( $result, $this->getLang()->formatNum( $value ) );
	}

	public static function provideFormatNum() {
		return [
			[ '1234567', '12,34,567' ],
			[ '12345', '12,345' ],
			[ '1', '1' ],
			[ '123', '123' ],
			[ '1234', '1,234' ],
			[ '12345.56', '12,345.56' ],
			[ '12345679812345678', '12,34,56,79,81,23,45,678' ],
			[ '.12345', '.12345' ],
			[ '-1200000', '−12,00,000' ],
			[ '-98', '−98' ],
			[ -98, '−98' ],
			[ -12345678, '−1,23,45,678' ],
			[ '', '' ],
			[ null, '' ],
		];
	}

	/**
	 * @covers \LanguageMl::normalize
	 * @covers \MediaWiki\Language\Language::normalize
	 * @dataProvider provideNormalize
	 */
	public function testNormalize( $input, $expected ) {
		if ( $input === $expected ) {
			$this->fail( 'Expected output must differ.' );
		}

		$this->assertSame(
			$expected,
			$this->getLang()->normalize( $input ),
			'ml-normalised form'
		);
	}

	public static function provideNormalize() {
		return [
			[
				'ല്‍',
				'ൽ',
			],
			[
				'ര്‍',
				'ർ',
			],
			[
				'ള്‍',
				'ൾ',
			],
		];
	}
}
PK       ! 7      languages/LanguageBsTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * Tests for Bosnian (bosanski)
 *
 * @group Language
 * @covers \LanguageBs
 */
class LanguageBsTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'few', 2 ],
			[ 'few', 4 ],
			[ 'other', 5 ],
			[ 'other', 11 ],
			[ 'other', 20 ],
			[ 'one', 21 ],
			[ 'few', 24 ],
			[ 'other', 25 ],
			[ 'other', 200 ],
		];
	}
}
PK       ! :ʡL      languages/LanguageMoTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 */
class LanguageMoTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'few', 0 ],
			[ 'one', 1 ],
			[ 'few', 2 ],
			[ 'few', 19 ],
			[ 'other', 20 ],
			[ 'other', 99 ],
			[ 'other', 100 ],
			[ 'few', 101 ],
			[ 'few', 119 ],
			[ 'other', 120 ],
			[ 'other', 200 ],
			[ 'few', 201 ],
			[ 'few', 219 ],
			[ 'other', 220 ],
		];
	}
}
PK       ! 6f  f    languages/LanguageHeTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * Tests for Hebrew
 *
 * @group Language
 */
class LanguageHeTest extends LanguageClassesTestCase {
	/**
	 * The most common usage for the plural forms is two forms,
	 * for singular and plural. In this case, the second form
	 * is technically dual, but in practice it's used as plural.
	 * In some cases, usually with expressions of time, three forms
	 * are needed - singular, dual and plural.
	 * CLDR also specifies a fourth form for multiples of 10,
	 * which is very rare. It also has a mistake, because
	 * the number 10 itself is supposed to be just plural,
	 * so currently it's overridden in MediaWiki.
	 */

	// @todo the below test*PluralForms test methods can be refactored
	//  to use a single test method and data provider.

	/**
	 * @dataProvider provideTwoPluralForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testTwoPluralForms( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider provideThreePluralForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testThreePluralForms( $result, $value ) {
		$forms = [ 'one', 'two', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider provideFourPluralForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testFourPluralForms( $result, $value ) {
		$forms = [ 'one', 'two', 'many', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider provideFourPluralForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function provideTwoPluralForms() {
		return [
			[ 'other', 0 ], // Zero - plural
			[ 'one', 1 ], // Singular
			[ 'other', 2 ], // No third form provided, use it as plural
			[ 'other', 3 ], // Plural - other
			[ 'other', 10 ], // No fourth form provided, use it as plural
			[ 'other', 20 ], // No fourth form provided, use it as plural
		];
	}

	public static function provideThreePluralForms() {
		return [
			[ 'other', 0 ], // Zero - plural
			[ 'one', 1 ], // Singular
			[ 'two', 2 ], // Dual
			[ 'other', 3 ], // Plural - other
			[ 'other', 10 ], // No fourth form provided, use it as plural
			[ 'other', 20 ], // No fourth form provided, use it as plural
		];
	}

	public static function provideFourPluralForms() {
		return [
			[ 'other', 0 ], // Zero - plural
			[ 'one', 1 ], // Singular
			[ 'two', 2 ], // Dual
			[ 'other', 3 ], // Plural - other
			[ 'other', 10 ], // 10 is supposed to be plural (other), not "many"
			[ 'many', 20 ], // Fourth form provided - rare, but supported by CLDR
		];
	}

	/**
	 * @dataProvider provideGrammar
	 * @covers \MediaWiki\Language\Language::convertGrammar
	 */
	public function testGrammar( $result, $word, $case ) {
		$this->assertEquals( $result, $this->getLang()->convertGrammar( $word, $case ) );
	}

	/**
	 * The comments in the beginning of the line help avoid RTL problems
	 * with text editors.
	 */
	public static function provideGrammar() {
		return [
			[
				/* result */'וויקיפדיה',
				/* word   */'ויקיפדיה',
				/* case   */'תחילית',
			],
			[
				/* result */'וולפגנג',
				/* word   */'וולפגנג',
				/* case   */'prefixed',
			],
			[
				/* result */'קובץ',
				/* word   */'הקובץ',
				/* case   */'תחילית',
			],
			[
				/* result */'־Wikipedia',
				/* word   */'Wikipedia',
				/* case   */'תחילית',
			],
			[
				/* result */'־1995',
				/* word   */'1995',
				/* case   */'תחילית',
			],
		];
	}
}
PK       ! gHI  I    languages/LanguageFrTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 */
class LanguageFrTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 200 ],
		];
	}
}
PK       ! y&      languages/LanguageRuTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 */
class LanguageRuTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'many', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * Test explicit plural forms - n=FormN forms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testExplicitPlural() {
		$forms = [ 'one', 'few', 'many', 'other', '12=dozen' ];
		$this->assertEquals( 'dozen', $this->getLang()->convertPlural( 12, $forms ) );
		$forms = [ 'one', 'few', 'many', '100=hundred', 'other', '12=dozen' ];
		$this->assertEquals( 'hundred', $this->getLang()->convertPlural( 100, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 1 ],
			[ 'many', 11 ],
			[ 'one', 91 ],
			[ 'one', 121 ],
			[ 'few', 2 ],
			[ 'few', 3 ],
			[ 'few', 4 ],
			[ 'few', 334 ],
			[ 'many', 5 ],
			[ 'many', 15 ],
			[ 'many', 120 ],
		];
	}

	/**
	 * @dataProvider providePluralTwoForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPluralTwoForms( $result, $value ) {
		$forms = [ '1=one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	public static function providePluralTwoForms() {
		return [
			[ 'one', 1 ],
			[ 'other', 11 ],
			[ 'other', 91 ],
			[ 'other', 121 ],
		];
	}

	/**
	 * @dataProvider providerGrammar
	 * @covers \MediaWiki\Language\Language::convertGrammar
	 */
	public function testGrammar( $result, $word, $case ) {
		$this->assertEquals( $result, $this->getLang()->convertGrammar( $word, $case ) );
	}

	public static function providerGrammar() {
		yield 'Wikipedia genitive' => [
			'Википедии',
			'Википедия',
			'genitive',
		];
		yield 'Wikisource genitive' => [
			'Викитеки',
			'Викитека',
			'genitive',
		];
		yield 'Wikipedia accusative' => [
			'Википедию',
			'Википедия',
			'accusative',
		];
		yield 'Wiktionary accusative' => [
			'Викисловарь',
			'Викисловарь',
			'accusative',
		];
		yield 'Wikiquote accusative' => [
			'Викицитатник',
			'Викицитатник',
			'accusative',
		];
		yield 'Wikibooks accusative' => [
			'Викиучебник',
			'Викиучебник',
			'accusative',
		];
		yield 'Wikisource accusative' => [
			'Викитеку',
			'Викитека',
			'accusative',
		];
		yield 'Wikinews accusative' => [
			'Викиновости',
			'Викиновости',
			'accusative',
		];
		yield 'Wikiversity accusative' => [
			'Викиверситет',
			'Викиверситет',
			'accusative',
		];
		yield 'Wikispecies accusative' => [
			'Викивиды',
			'Викивиды',
			'accusative',
		];
		yield 'Wikidata accusative' => [
			'Викиданные',
			'Викиданные',
			'accusative',
		];
		yield 'Commons accusative' => [
			'Викисклад',
			'Викисклад',
			'accusative',
		];
		yield 'Wikivoyage accusative' => [
			'Викигид',
			'Викигид',
			'accusative',
		];
		yield 'Meta accusative' => [
			'Мету',
			'Мета',
			'accusative',
		];
		yield 'Incubator accusative' => [
			'Инкубатор',
			'Инкубатор',
			'accusative',
		];
		yield 'Wikisource prepositional' => [
			'Викитеке',
			'Викитека',
			'prepositional',
		];
		yield 'Commons genitive' => [
			'Викисклада',
			'Викисклад',
			'genitive',
		];
		yield 'Wikiversity genitive' => [
			'Викиверситета',
			'Викиверситет',
			'genitive',
		];
		yield 'Commons prepositional' => [
			'Викискладе',
			'Викисклад',
			'prepositional',
		];
		yield 'Wikidata prepositional' => [
			'Викиданных',
			'Викиданные',
			'prepositional',
		];
		yield 'Wikiversity prepositional' => [
			'Викиверситете',
			'Викиверситет',
			'prepositional',
		];
		yield 'Russian languagegen' => [
			'русского',
			'русский',
			'languagegen',
		];
		yield 'German languagegen' => [
			'немецкого',
			'немецкий',
			'languagegen',
		];
		yield 'Hebrew languagegen' => [
			'иврита',
			'иврит',
			'languagegen',
		];
		yield 'Esperanto languagegen' => [
			'эсперанто',
			'эсперанто',
			'languagegen',
		];
		yield 'Russian languageprep' => [
			'русском',
			'русский',
			'languageprep',
		];
		yield 'German languageprep' => [
			'немецком',
			'немецкий',
			'languageprep',
		];
		yield 'Yiddish languageprep' => [
			'идише',
			'идиш',
			'languageprep',
		];
		yield 'Esperanto languageprep' => [
			'эсперанто',
			'эсперанто',
			'languageprep',
		];
		yield 'Russian languageadverb' => [
			'по-русски',
			'русский',
			'languageadverb',
		];
		yield 'German languageadverb' => [
			'по-немецки',
			'немецкий',
			'languageadverb',
		];
		yield 'Hebrew languageadverb' => [
			'на иврите',
			'иврит',
			'languageadverb',
		];
		yield 'Esperanto languageadverb' => [
			'на эсперанто',
			'эсперанто',
			'languageadverb',
		];
		yield 'Guarani languageadverb' => [
			'на языке гуарани',
			'гуарани',
			'languageadverb',
		];
	}
}
PK       ! y2  2    languages/LanguageArqTest.phpnu Iw        <?php
/**
 * @author Joel Sahleen
 * @copyright Copyright © 2014, Joel Sahleen
 * @file
 */

/**
 * @group Language
 */
class LanguageArqTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider provideNumber
	 * @covers \MediaWiki\Language\Language::formatNum
	 */
	public function testFormatNum( $value, $result ) {
		$this->assertEquals( $result, $this->getLang()->formatNum( $value ) );
	}

	public static function provideNumber() {
		return [
			[ '1234567', '1.234.567' ],
			[ '1234567.568', '1.234.567,568' ],
			[ '-12.89', '−12,89' ]
		];
	}

}
PK       ! *A6)
  )
    languages/LanguageKaTest.phpnu Iw        <?php
/**
 * @license GPL-2.0-or-later
 * @author Amir E. Aharoni
 * @copyright Copyright © 2022, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 */
class LanguageKaTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providerGrammar
	 * @covers \MediaWiki\Language\Language::convertGrammar
	 */
	public function testGrammar( $result, $word, $case ) {
		$this->assertEquals( $result, $this->getLang()->convertGrammar( $word, $case ) );
	}

	public static function providerGrammar() {
		yield 'Wikipedia genitive' => [
			'ვიკიპედიის',
			'ვიკიპედია',
			'ნათესაობითი',
		];
		yield 'Wiktionary genitive' => [
			'ვიქსიკონის',
			'ვიქსიკონი',
			'ნათესაობითი',
		];
		yield 'Wikibooks genitive' => [
			'ვიკიწიგნების',
			'ვიკიწიგნები',
			'ნათესაობითი',
		];
		yield 'Wikiquote genitive' => [
			'ვიკიციტატის',
			'ვიკიციტატა',
			'ნათესაობითი',
		];
		yield 'Wikinews genitive' => [
			'ვიკისიახლეების',
			'ვიკისიახლეები',
			'ნათესაობითი',
		];
		yield 'Wikispecies genitive' => [
			'ვიკისახეობების',
			'ვიკისახეობები',
			'ნათესაობითი',
		];
		yield 'Wikidata genitive' => [
			'ვიკიმონაცემების',
			'ვიკიმონაცემები',
			'ნათესაობითი',
		];
		yield 'Commons genitive' => [
			'ვიკისაწყობის',
			'ვიკისაწყობი',
			'ნათესაობითი',
		];
		yield 'Wikivoyage genitive' => [
			'ვიკივოიაჟის',
			'ვიკივოიაჟი',
			'ნათესაობითი',
		];
		yield 'Meta-Wiki genitive' => [
			'მეტა-ვიკის',
			'მეტა-ვიკი',
			'ნათესაობითი',
		];
		yield 'MediaWiki genitive' => [
			'მედიავიკის',
			'მედიავიკი',
			'ნათესაობითი',
		];
		yield 'Wikiversity genitive' => [
			'ვიკივერსიტეტის',
			'ვიკივერსიტეტი',
			'ნათესაობითი',
		];
		yield 'Freedom genitive' => [
			'თავისუფლების',
			'თავისუფლება',
			'ნათესაობითი',
		];
	}
}
PK       !       languages/LanguageSlTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 * @covers \LanguageSl
 */
class LanguageSlTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providerPlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'two', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providerPlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providerPlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'two', 2 ],
			[ 'few', 3 ],
			[ 'few', 4 ],
			[ 'other', 5 ],
			[ 'other', 99 ],
			[ 'other', 100 ],
			[ 'one', 101 ],
			[ 'two', 102 ],
			[ 'few', 103 ],
			[ 'one', 201 ],
		];
	}
}
PK       ! -"t      languages/LanguageBeTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 */
class LanguageBeTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'many', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 1 ],
			[ 'many', 11 ],
			[ 'one', 91 ],
			[ 'one', 121 ],
			[ 'few', 2 ],
			[ 'few', 3 ],
			[ 'few', 4 ],
			[ 'few', 334 ],
			[ 'many', 5 ],
			[ 'many', 15 ],
			[ 'many', 120 ],
		];
	}
}
PK       ! jI  I    languages/LanguageAmTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 */
class LanguageAmTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 200 ],
		];
	}
}
PK       !       languages/LanguageCyTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 */
class LanguageCyTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'zero', 'one', 'two', 'few', 'many', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'zero', 0 ],
			[ 'one', 1 ],
			[ 'two', 2 ],
			[ 'few', 3 ],
			[ 'many', 6 ],
			[ 'other', 4 ],
			[ 'other', 5 ],
			[ 'other', 11 ],
			[ 'other', 20 ],
			[ 'other', 22 ],
			[ 'other', 223 ],
			[ 'other', 200.00 ],
		];
	}
}
PK       ! Ş      languages/LanguageHyTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * Tests for Armenian (Հայերեն)
 *
 * @group Language
 * @covers \LanguageHy
 */
class LanguageHyTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 200 ],
		];
	}
}
PK       ! t      languages/LanguageNlTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2011, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 */
class LanguageNlTest extends LanguageClassesTestCase {

	/**
	 * @covers \MediaWiki\Language\Language::formatNum
	 * @dataProvider provideFormatNum
	 */
	public function testFormatNum( $unformatted, $formatted ) {
		$this->assertEquals( $formatted, $this->getLang()->formatNum( $unformatted ) );
	}

	public static function provideFormatNum() {
		return [
			[ '1234567', '1.234.567' ],
			[ '12345', '12.345' ],
			[ '1', '1' ],
			[ '123', '123' ],
			[ '1234', '1.234' ],
			[ '12345.56', '12.345,56' ],
			[ '.1234556', ',1234556' ],
			[ '12345679812345678', '12.345.679.812.345.678' ],
			[ '.12345', ',12345' ],
			[ '-1200000', '−1.200.000' ],
			[ '-98', '−98' ],
			[ -98, '−98' ],
			[ -12345678, '−12.345.678' ],
			[ '', '' ],
			[ null, '' ]
		];
	}
}
PK       ! EUI      languages/LanguageHrTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 */
class LanguageHrTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'few', 2 ],
			[ 'few', 4 ],
			[ 'other', 5 ],
			[ 'other', 11 ],
			[ 'other', 20 ],
			[ 'one', 21 ],
			[ 'few', 24 ],
			[ 'other', 25 ],
			[ 'other', 200 ],
		];
	}
}
PK       ! !_sV
  V
    languages/LanguagePlTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 */
class LanguagePlTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'many' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'many', 0 ],
			[ 'one', 1 ],
			[ 'few', 2 ],
			[ 'few', 3 ],
			[ 'few', 4 ],
			[ 'many', 5 ],
			[ 'many', 9 ],
			[ 'many', 10 ],
			[ 'many', 11 ],
			[ 'many', 21 ],
			[ 'few', 22 ],
			[ 'few', 23 ],
			[ 'few', 24 ],
			[ 'many', 25 ],
			[ 'many', 200 ],
			[ 'many', 201 ],
		];
	}

	/**
	 * @dataProvider providePluralTwoForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPluralTwoForms( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	public static function providePluralTwoForms() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 3 ],
			[ 'other', 4 ],
			[ 'other', 5 ],
			[ 'other', 9 ],
			[ 'other', 10 ],
			[ 'other', 11 ],
			[ 'other', 21 ],
			[ 'other', 22 ],
			[ 'other', 23 ],
			[ 'other', 24 ],
			[ 'other', 25 ],
			[ 'other', 200 ],
			[ 'other', 201 ],
		];
	}

	/**
	 * @covers \MediaWiki\Language\Language::formatNum()
	 * @dataProvider provideFormatNum
	 */
	public function testFormatNum( $number, $formattedNum, $desc ) {
		$this->assertEquals(
			$formattedNum,
			$this->getLang()->formatNum( $number ),
			$desc
		);
	}

	public static function provideFormatNum() {
		return [
			[ 1000, '1000', 'No change' ],
			[ 10000, '10 000', 'Only separator transform. Separator is NO-BREAK Space, not Space' ],
			[ 1000.0001, '1000,0001',
				'No change since this is below minimumGroupingDigits, just separator transform' ],
			[ 10000.123456, '10 000,123456', 'separator transform' ],
			[ -1000, '−1000', 'No change, other than minus replacement' ],
			[ -10000, '−10 000', 'Only separator transform' ],
			[ -1000.0001, '−1000,0001',
				'No change since this is below minimumGroupingDigits, just separator transform' ],
			[ -10000.789, '−10 000,789', '' ],
		];
	}
}
PK       ! L_
  
    languages/LanguageUkTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 */
class LanguageUkTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'many', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * Test explicit plural forms - n=FormN forms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testExplicitPlural() {
		$forms = [ 'one', 'few', 'many', 'other', '12=dozen' ];
		$this->assertEquals( 'dozen', $this->getLang()->convertPlural( 12, $forms ) );
		$forms = [ 'one', 'few', 'many', '100=hundred', 'other', '12=dozen' ];
		$this->assertEquals( 'hundred', $this->getLang()->convertPlural( 100, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 1 ],
			[ 'many', 11 ],
			[ 'one', 91 ],
			[ 'one', 121 ],
			[ 'few', 2 ],
			[ 'few', 3 ],
			[ 'few', 4 ],
			[ 'few', 334 ],
			[ 'many', 5 ],
			[ 'many', 15 ],
			[ 'many', 120 ],
		];
	}

	/**
	 * @dataProvider providePluralTwoForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPluralTwoForms( $result, $value ) {
		$forms = [ '1=one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	public static function providePluralTwoForms() {
		return [
			[ 'one', 1 ],
			[ 'other', 11 ],
			[ 'other', 91 ],
			[ 'other', 121 ],
		];
	}

	/**
	 * @dataProvider providerGrammar
	 * @covers \MediaWiki\Language\Language::convertGrammar
	 */
	public function testGrammar( $result, $word, $case ) {
		$this->assertEquals( $result, $this->getLang()->convertGrammar( $word, $case ) );
	}

	public static function providerGrammar() {
		yield 'Wikipedia genitive' => [
			'Вікіпедії',
			'Вікіпедія',
			'genitive',
		];
		yield 'Wikispecies genitive' => [
			'Віківидів',
			'Віківиди',
			'genitive',
		];
		yield 'Wikiquote genitive' => [
			'Вікіцитат',
			'Вікіцитати',
			'genitive',
		];
		yield 'Wikibooks genitive' => [
			'Вікіпідручника',
			'Вікіпідручник',
			'genitive',
		];
		yield 'Wikipedia accusative' => [
			'Вікіпедію',
			'Вікіпедія',
			'accusative',
		];
		yield 'MediaWiki locative' => [
			'у MediaWiki',
			'MediaWiki',
			'locative',
		];
	}
}
PK       ! VvQ  Q    languages/LanguageZhTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @covers \LanguageZh
 */
class LanguageZhTest extends LanguageClassesTestCase {
	public function testSegmentForDiff() {
		$this->overrideConfigValue( MainConfigNames::DiffEngine, 'php' );
		$lhs = '维基';
		$rhs = '维基百科';
		$diff = TextSlotDiffRenderer::diff( $lhs, $rhs, [ 'contentLanguage' => 'zh' ] );
		// Check that only the second part is highlighted, and word segmentation markers are not present
		$this->assertStringContainsString(
			'<div>维基<ins class="diffchange diffchange-inline">百科</ins></div>',
			$diff
		);
	}
}
PK       ! r~k  k    languages/LanguageKshTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 * @covers \LanguageKsh
 */
class LanguageKshTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'other', 'zero' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'zero', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 200 ],
		];
	}
}
PK       ! Q<      languages/LanguageKkTest.phpnu Iw        <?php

/**
 * @group Language
 */
class LanguageKkTest extends LanguageClassesTestCase {

	/**
	 * @dataProvider provideGrammar
	 * @covers \MediaWiki\Language\Language::convertGrammar
	 */
	public function testGrammar( $result, $word, $case ) {
		$this->assertEquals( $result, $this->getLang()->convertGrammar( $word, $case ) );
	}

	public static function provideGrammar() {
		yield 'Wikipedia ablative' => [
			'Уикипедияден',
			'Уикипедия',
			'ablative',
		];
		yield 'Wiktionary ablative' => [
			'Уикисөздіктен',
			'Уикисөздік',
			'ablative',
		];
		yield 'Wikibooks ablative' => [
			'Уикикітаптан',
			'Уикикітап',
			'ablative',
		];
	}
}
PK       ! _      languages/LanguageDsbTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 * @covers \LanguageDsb
 */
class LanguageDsbTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'two', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'one', 101 ],
			[ 'one', 90001 ],
			[ 'two', 2 ],
			[ 'few', 3 ],
			[ 'few', 203 ],
			[ 'few', 4 ],
			[ 'other', 99 ],
			[ 'other', 555 ],
		];
	}
}
PK       ! UW$,  ,    languages/LanguageTiTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 */
class LanguageTiTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
		];
	}
}
PK       ! /I  I    languages/LanguageLnTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 */
class LanguageLnTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 200 ],
		];
	}
}
PK       ! 	  	  #  languages/LanguageBe_taraskTest.phpnu Iw        <?php

// phpcs:disable Squiz.Classes.ValidClassName.NotCamelCaps

/**
 * @group Language
 * @covers \LanguageBe_tarask
 */
class LanguageBe_taraskTest extends LanguageClassesTestCase {
	// phpcs:enable

	/**
	 * Make sure the language code we are given is indeed
	 * be-tarask. This is to ensure LanguageClassesTestCase
	 * does not give us the wrong language.
	 */
	public function testBeTaraskTestsUsesBeTaraskCode() {
		$this->assertEquals( 'be-tarask',
			$this->getLang()->getCode()
		);
	}

	/**
	 * @see T25156 & r64981
	 * @covers \MediaWiki\Language\Language::normalizeForSearch
	 */
	public function testSearchRightSingleQuotationMarkAsApostroph() {
		$this->assertEquals(
			"'",
			$this->getLang()->normalizeForSearch( '’' ),
			'T25156: U+2019 conversion to U+0027'
		);
	}

	/**
	 * @see T25156 & r64981
	 * @covers \MediaWiki\Language\Language::formatNum
	 */
	public function testFormatNum() {
		$this->assertEquals( '1 234 567', $this->getLang()->formatNum( '1234567' ) );
		$this->assertEquals( '12 345', $this->getLang()->formatNum( '12345' ) );
	}

	/**
	 * @see T25156 & r64981
	 * @covers \MediaWiki\Language\Language::formatNum
	 */
	public function testDoesNotCommafyFourDigitsNumber() {
		$this->assertSame( '1234', $this->getLang()->formatNum( '1234' ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'many', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 1 ],
			[ 'many', 11 ],
			[ 'one', 91 ],
			[ 'one', 121 ],
			[ 'few', 2 ],
			[ 'few', 3 ],
			[ 'few', 4 ],
			[ 'few', 334 ],
			[ 'many', 5 ],
			[ 'many', 15 ],
			[ 'many', 120 ],
		];
	}

	/**
	 * @dataProvider providePluralTwoForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPluralTwoForms( $result, $value ) {
		$forms = [ '1=one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	public static function providePluralTwoForms() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'other', 11 ],
			[ 'other', 91 ],
			[ 'other', 121 ],
		];
	}
}
PK       ! I      languages/LanguageRoTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 */
class LanguageRoTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'few', 0 ],
			[ 'one', 1 ],
			[ 'few', 2 ],
			[ 'few', 19 ],
			[ 'other', 20 ],
			[ 'other', 99 ],
			[ 'other', 100 ],
			[ 'few', 101 ],
			[ 'few', 119 ],
			[ 'other', 120 ],
			[ 'other', 200 ],
			[ 'few', 201 ],
			[ 'few', 219 ],
			[ 'other', 220 ],
		];
	}
}
PK       ! n~V  V    languages/LanguageApcTest.phpnu Iw        <?php

/**
 * @group Language
 */
class LanguageApcTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'two', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'few', 0 ],
			[ 'one', 1 ],
			[ 'two', 2 ],
			[ 'few', 3 ],
			[ 'few', 9 ],
			[ 'few', 10 ],
			[ 'other', 11 ],
			[ 'other', 12 ],
			[ 'other', 99 ],
			[ 'other', 100 ],
			[ 'other', 101 ],
			[ 'other', 102 ],
			[ 'few', 103 ],
			[ 'few', 104 ],
			[ 'few', 109 ],
			[ 'few', 110 ],
			[ 'other', 111 ],
			[ 'other', 112 ],
			[ 'other', 9999 ],
			[ 'other', 1000 ],
			[ 'few', 1003 ],
			[ 'other', 1.7 ],
		];
	}
}
PK       ! +)      languages/LanguageGaTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * Tests for Irish (Gaeilge)
 *
 * @group Language
 * @covers \LanguageGa
 */
class LanguageGaTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'two', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'two', 2 ],
			[ 'other', 200 ],
		];
	}
}
PK       ! b      languages/LanguageHsbTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 * @covers \LanguageHsb
 */
class LanguageHsbTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'two', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'one', 101 ],
			[ 'one', 90001 ],
			[ 'two', 2 ],
			[ 'few', 3 ],
			[ 'few', 203 ],
			[ 'few', 4 ],
			[ 'other', 99 ],
			[ 'other', 555 ],
		];
	}
}
PK       ! _n=      languages/LanguageLtTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 */
class LanguageLtTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'few', 2 ],
			[ 'few', 9 ],
			[ 'other', 10 ],
			[ 'other', 11 ],
			[ 'other', 20 ],
			[ 'one', 21 ],
			[ 'few', 32 ],
			[ 'one', 41 ],
			[ 'one', 40001 ],
		];
	}

	/**
	 * @dataProvider providePluralTwoForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testOneFewPlural( $result, $value ) {
		$forms = [ 'one', 'other' ];
		// This fails for 21, but not sure why.
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	public static function providePluralTwoForms() {
		return [
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 15 ],
			[ 'other', 20 ],
			[ 'one', 21 ],
			[ 'other', 22 ],
		];
	}
}
PK       ! TL:      languages/LanguageSrTest.phpnu Iw        <?php
/**
 * @author Antoine Musso <hashar at free dot fr>
 * @copyright Copyright © 2011, Antoine Musso <hashar at free dot fr>
 * @file
 */

/**
 * Tests for Serbian
 *
 * The language can be represented using two scripts:
 *
 *  - Latin (SR_el)
 *  - Cyrillic (SR_ec)
 *
 * Both representations seems to be bijective, hence MediaWiki can convert
 * from one script to the other.
 *
 * @group Language
 */
class LanguageSrTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 1 ],
			[ 'other', 11 ],
			[ 'one', 91 ],
			[ 'one', 121 ],
			[ 'few', 2 ],
			[ 'few', 3 ],
			[ 'few', 4 ],
			[ 'few', 334 ],
			[ 'other', 5 ],
			[ 'other', 15 ],
			[ 'other', 120 ],
		];
	}

	/**
	 * @dataProvider providePluralTwoForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPluralTwoForms( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	public static function providePluralTwoForms() {
		return [
			[ 'one', 1 ],
			[ 'other', 11 ],
			[ 'other', 4 ],
			[ 'one', 91 ],
			[ 'one', 121 ],
		];
	}

}
PK       !        languages/LanguageCsTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 */
class LanguageCsTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'few', 2 ],
			[ 'few', 3 ],
			[ 'few', 4 ],
			[ 'other', 5 ],
			[ 'other', 11 ],
			[ 'other', 20 ],
			[ 'other', 25 ],
			[ 'other', 200 ],
		];
	}
}
PK       ! $      languages/LanguageShTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * Tests for Serbocroatian (srpskohrvatski / српскохрватски)
 *
 * @group Language
 */
class LanguageShTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'few', 2 ],
			[ 'few', 4 ],
			[ 'other', 5 ],
			[ 'other', 10 ],
			[ 'other', 11 ],
			[ 'other', 12 ],
			[ 'one', 101 ],
			[ 'few', 102 ],
			[ 'other', 111 ],
		];
	}
}
PK       ! N1e--  -    languages/LanguageNsoTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * @group Language
 */
class LanguageNsoTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
		];
	}
}
PK       ! 6    "  languages/LanguageIsv_latnTest.phpnu Iw        <?php

// phpcs:disable Squiz.Classes.ValidClassName.NotCamelCaps

/**
 * @group Language
 */
class LanguageIsv_latnTest extends LanguageClassesTestCase {
	// phpcs:enable

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 1 ],
			[ 'other', 11 ],
			[ 'one', 91 ],
			[ 'one', 121 ],
			[ 'few', 2 ],
			[ 'few', 3 ],
			[ 'few', 4 ],
			[ 'few', 334 ],
			[ 'other', 5 ],
			[ 'other', 15 ],
			[ 'other', 120 ],
		];
	}

	/**
	 * @dataProvider providePluralTwoForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPluralTwoForms( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	public static function providePluralTwoForms() {
		return [
			[ 'one', 1 ],
			[ 'other', 11 ],
			[ 'other', 4 ],
			[ 'one', 91 ],
			[ 'one', 121 ],
		];
	}
}
PK       !       languages/LanguageSgsTest.phpnu Iw        <?php
/**
 * @author Amir E. Aharoni
 * @copyright Copyright © 2012, Amir E. Aharoni
 * @file
 */

/**
 * Tests for Samogitian
 *
 * @group Language
 */
class LanguageSgsTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePluralAllForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPluralAllForms( $result, $value ) {
		$forms = [ 'one', 'two', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePluralAllForms
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePluralAllForms() {
		return [
			[ 'few', 0 ],
			[ 'one', 1 ],
			[ 'two', 2 ],
			[ 'other', 3 ],
			[ 'few', 10 ],
			[ 'few', 11 ],
			[ 'few', 12 ],
			[ 'few', 19 ],
			[ 'other', 20 ],
			[ 'few', 100 ],
			[ 'one', 101 ],
			[ 'few', 111 ],
			[ 'few', 112 ],
		];
	}

	/**
	 * @dataProvider providePluralTwoForms
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPluralTwoForms( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	public static function providePluralTwoForms() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 3 ],
			[ 'other', 10 ],
			[ 'other', 11 ],
			[ 'other', 12 ],
			[ 'other', 19 ],
			[ 'other', 20 ],
			[ 'other', 100 ],
			[ 'one', 101 ],
			[ 'other', 111 ],
			[ 'other', 112 ],
		];
	}
}
PK       ! nb  b    languages/LanguageHuTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 * @covers \LanguageHu
 */
class LanguageHuTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 200 ],
		];
	}
}
PK       ! zrc  c    languages/LanguageMgTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 */
class LanguageMgTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	/**
	 * @dataProvider providePlural
	 * @covers \MediaWiki\Language\Language::getPluralRuleType
	 */
	public function testGetPluralRuleType( $result, $value ) {
		$this->assertEquals( $result, $this->getLang()->getPluralRuleType( $value ) );
	}

	public static function providePlural() {
		return [
			[ 'one', 0 ],
			[ 'one', 1 ],
			[ 'other', 2 ],
			[ 'other', 200 ],
			[ 'other', 123.3434 ],
		];
	}
}
PK       ! M]      languages/LanguageGdTest.phpnu Iw        <?php
/**
 * @author Santhosh Thottingal
 * @copyright Copyright © 2012-2013, Santhosh Thottingal
 * @file
 */

/**
 * @group Language
 */
class LanguageGdTest extends LanguageClassesTestCase {
	/**
	 * @dataProvider providerPlural
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testPlural( $result, $value ) {
		$forms = [ 'one', 'two', 'few', 'other' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	public static function providerPlural() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'two', 2 ],
			[ 'one', 11 ],
			[ 'two', 12 ],
			[ 'few', 3 ],
			[ 'few', 19 ],
			[ 'other', 200 ],
		];
	}

	/**
	 * @dataProvider providerPluralExplicit
	 * @covers \MediaWiki\Language\Language::convertPlural
	 */
	public function testExplicitPlural( $result, $value ) {
		$forms = [ 'one', 'two', 'few', 'other', '11=Form11', '12=Form12' ];
		$this->assertEquals( $result, $this->getLang()->convertPlural( $value, $forms ) );
	}

	public static function providerPluralExplicit() {
		return [
			[ 'other', 0 ],
			[ 'one', 1 ],
			[ 'two', 2 ],
			[ 'Form11', 11 ],
			[ 'Form12', 12 ],
			[ 'few', 3 ],
			[ 'few', 19 ],
			[ 'other', 200 ],
		];
	}
}
PK       ! Z      mail/MailAddressTest.phpnu Iw        <?php

use MediaWiki\User\User;
use MediaWiki\User\UserIdentityValue;

/**
 * @group Mail
 * @covers \MailAddress
 */
class MailAddressTest extends MediaWikiIntegrationTestCase {

	public function testNewFromUser() {
		if ( wfIsWindows() ) {
			$this->markTestSkipped( 'This test only works on non-Windows platforms' );
		}
		$user = $this->createMock( User::class );
		$user->method( 'getUser' )->willReturn( new UserIdentityValue( 42, 'UserName' ) );
		$user->method( 'getEmail' )->willReturn( 'foo@bar.baz' );
		$user->method( 'getRealName' )->willReturn( 'Real name' );

		$ma = MailAddress::newFromUser( $user );
		$this->assertInstanceOf( MailAddress::class, $ma );

		// No setMwGlobals() in a unit test, need some manual logic
		// Don't worry about messing with the actual value, MediaWikiUnitTestCase restores it
		global $wgEnotifUseRealName;

		$wgEnotifUseRealName = true;
		$this->assertEquals( '"Real name" <foo@bar.baz>', $ma->toString() );

		$wgEnotifUseRealName = false;
		$this->assertEquals( '"UserName" <foo@bar.baz>', $ma->toString() );
	}

	/**
	 * @dataProvider provideEquals
	 */
	public function testEquals( MailAddress $first, MailAddress $second, bool $expected ) {
		$this->assertSame( $expected, $first->equals( $second ) );
	}

	public static function provideEquals(): Generator {
		$base = new MailAddress( 'a@b.c', 'name', 'realname' );

		yield 'Different addresses' => [ $base, new MailAddress( 'xxx', 'name', 'realname' ), false ];
		yield 'Different names' => [ $base, new MailAddress( 'a@b.c', 'other name', 'realname' ), false ];
		yield 'Different real names' => [ $base, new MailAddress( 'a@b.c', 'name', 'other realname' ), false ];
		yield 'Equal' => [ $base, new MailAddress( 'a@b.c', 'name', 'realname' ), true ];
	}

	/**
	 * @dataProvider provideToString
	 */
	public function testToString( $useRealName, $address, $name, $realName, $expected ) {
		if ( wfIsWindows() ) {
			$this->markTestSkipped( 'This test only works on non-Windows platforms' );
		}
		// No setMwGlobals() in a unit test, need some manual logic
		// Don't worry about messing with the actual value, MediaWikiUnitTestCase restores it
		global $wgEnotifUseRealName;
		$wgEnotifUseRealName = $useRealName;

		$ma = new MailAddress( $address, $name, $realName );
		$this->assertEquals( $expected, $ma->toString() );
	}

	public static function provideToString() {
		return [
			[ true, 'foo@bar.baz', 'FooBar', 'Foo Bar', '"Foo Bar" <foo@bar.baz>' ],
			[ true, 'foo@bar.baz', 'UserName', null, '"UserName" <foo@bar.baz>' ],
			[ true, 'foo@bar.baz', 'AUser', 'My real name', '"My real name" <foo@bar.baz>' ],
			[ true, 'foo@bar.baz', 'AUser', 'My "real" name', '"My \"real\" name" <foo@bar.baz>' ],
			[ true, 'foo@bar.baz', 'AUser', 'My "A/B" test', '"My \"A/B\" test" <foo@bar.baz>' ],
			[ true, 'foo@bar.baz', 'AUser', 'E=MC2', '=?UTF-8?Q?E=3DMC2?= <foo@bar.baz>' ],
			// A backslash (\) should be escaped (\\). In a string literal that is \\\\ (4x).
			[ true, 'foo@bar.baz', 'AUser', 'My "B\C" test', '"My \"B\\\\C\" test" <foo@bar.baz>' ],
			[ true, 'foo@bar.baz', 'A.user.name', 'my@real.name', '"my@real.name" <foo@bar.baz>' ],
			[ false, 'foo@bar.baz', 'AUserName', 'Some real name', '"AUserName" <foo@bar.baz>' ],
			[ false, 'foo@bar.baz', '', '', 'foo@bar.baz' ],
			[ true, 'foo@bar.baz', '', '', 'foo@bar.baz' ],
			[ true, '', '', '', '' ],
		];
	}

	public function test__ToString() {
		$ma = new MailAddress( 'some@email.com', 'UserName', 'A real name' );
		$this->assertEquals( $ma->toString(), (string)$ma );
	}
}
PK       ! |5  5    mail/EmailNotificationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;

/**
 * @group Database
 * @group Mail
 * @covers \EmailNotification
 */
class EmailNotificationTest extends MediaWikiIntegrationTestCase {

	/** @var EmailNotification */
	protected $emailNotification;

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

		$this->emailNotification = new EmailNotification();

		$this->overrideConfigValue( MainConfigNames::WatchlistExpiry, true );
	}

	public function testNotifyOnPageChange(): void {
		$store = $this->getServiceContainer()->getWatchedItemStore();

		// both Alice and Bob watch 'Foobar'
		$title = Title::makeTitle( NS_MAIN, 'Foobar' );
		$alice = $this->getTestSysop()->getUser();
		$store->addWatch( $alice, $title );
		$bob = $this->getTestUser()->getUser();
		$store->addWatch( $bob, $title );

		// Alice edits the page (doesn't actually have to edit in this test).
		// Bob (as in, not Alice) should have received an email notification.
		$notifyArgs = [ $alice, $title, '20200624000000', '', false ];
		$sent = $this->emailNotification->notifyOnPageChange( ...$notifyArgs );
		static::assertTrue( $sent );

		// Alice edits again, but Bob shouldn't be notified again
		// (only one email until Bob visits the page again).
		$sent = $this->emailNotification->notifyOnPageChange( ...$notifyArgs );
		static::assertFalse( $sent );

		// Reset notification timestamp, simulating that Bob visited the page.
		$store->resetAllNotificationTimestampsForUser( $bob );

		// Bob re-watches temporarily. For testing purposes we use a past expiry,
		// so an email shouldn't be sent after Alice edits the page.
		$store->addWatch( $bob, $title, '20060123000000' );

		// Alice edits again, email should not be sent.
		$sent = $this->emailNotification->notifyOnPageChange( ...$notifyArgs );
		static::assertFalse( $sent );
	}
}
PK       ! Ԑ;9w  w    db/LBFactoryTest.phpnu Iw        <?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
 * @author Antoine Musso
 * @copyright © 2013 Antoine Musso
 * @copyright © 2013 Wikimedia Foundation Inc.
 */

use MediaWiki\WikiMap\WikiMap;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\Rdbms\ChronologyProtector;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\DatabaseDomain;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IDatabaseForOwner;
use Wikimedia\Rdbms\ILBFactory;
use Wikimedia\Rdbms\IMaintainableDatabase;
use Wikimedia\Rdbms\IReadableDatabase;
use Wikimedia\Rdbms\LBFactoryMulti;
use Wikimedia\Rdbms\LBFactorySimple;
use Wikimedia\Rdbms\LoadBalancer;
use Wikimedia\Rdbms\LoadMonitorNull;
use Wikimedia\Rdbms\MySQLPrimaryPos;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Database
 * @covers \Wikimedia\Rdbms\ChronologyProtector
 * @covers \Wikimedia\Rdbms\DatabaseMySQL
 * @covers \Wikimedia\Rdbms\DatabasePostgres
 * @covers \Wikimedia\Rdbms\DatabaseSqlite
 * @covers \Wikimedia\Rdbms\LBFactory
 * @covers \Wikimedia\Rdbms\LBFactory
 * @covers \Wikimedia\Rdbms\LBFactoryMulti
 * @covers \Wikimedia\Rdbms\LBFactorySimple
 * @covers \Wikimedia\Rdbms\LoadBalancer
 */
class LBFactoryTest extends MediaWikiIntegrationTestCase {

	private function getPrimaryServerConfig() {
		global $wgDBserver, $wgDBport, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype;
		global $wgSQLiteDataDir;

		return [
			'serverName'  => 'db1',
			'host'        => $wgDBserver,
			'port'        => $wgDBport,
			'dbname'      => $wgDBname,
			'user'        => $wgDBuser,
			'password'    => $wgDBpassword,
			'type'        => $wgDBtype,
			'dbDirectory' => $wgSQLiteDataDir,
			'load'        => 0,
			'flags'       => DBO_TRX // REPEATABLE-READ for consistency
		];
	}

	public function testLBFactorySimpleServer() {
		$servers = [ $this->getPrimaryServerConfig() ];
		$factory = new LBFactorySimple( [ 'servers' => $servers ] );
		$lb = $factory->getMainLB();

		$dbw = $lb->getConnection( DB_PRIMARY );
		$this->assertNotFalse( $dbw );
		$dbr = $lb->getConnection( DB_REPLICA );
		$this->assertNotFalse( $dbr );

		$this->assertSame( 'DEFAULT', $lb->getClusterName() );

		$factory->shutdown();
	}

	public function testLBFactorySimpleServers() {
		$primaryConfig = $this->getPrimaryServerConfig();
		$fakeReplica = [ 'serverName' => 'db2', 'load' => 100 ] + $primaryConfig;

		$servers = [
			$primaryConfig,
			$fakeReplica
		];

		$factory = new LBFactorySimple( [
			'servers' => $servers,
			'loadMonitor' => [ 'class' => LoadMonitorNull::class ],
		] );
		$lb = $factory->getMainLB();

		$dbw = $lb->getConnection( DB_PRIMARY );
		$this->assertNotFalse( $dbw );
		$dbr = $lb->getConnection( DB_REPLICA );
		$this->assertNotFalse( $dbr );

		$factory->shutdown();
	}

	public function testLBFactoryMultiConns() {
		$factory = $this->newLBFactoryMultiLBs();

		$this->assertSame( 's3', $factory->getMainLB()->getClusterName() );

		$lb = $factory->getMainLB();
		$dbw = $lb->getConnection( DB_PRIMARY );
		$this->assertNotFalse( $dbw );
		$dbr = $lb->getConnection( DB_REPLICA );
		$this->assertNotFalse( $dbr );

		// Destructor should trigger without round stage errors
		unset( $factory );
	}

	public function testLBFactoryMultiRoundCallbacks() {
		$called = 0;
		$countLBsFunc = static function ( LBFactoryMulti $factory ) {
			$count = 0;
			foreach ( $factory->getAllLBs() as $lb ) {
				++$count;
			}

			return $count;
		};

		$factory = $this->newLBFactoryMultiLBs();
		$this->assertSame( 0, $countLBsFunc( $factory ) );
		$dbw = $factory->getMainLB()->getConnection( DB_PRIMARY );
		$this->assertSame( 1, $countLBsFunc( $factory ) );
		// Test that LoadBalancer instances made during pre-commit callbacks in do not
		// throw DBTransactionError due to transaction ROUND_* stages being mismatched.
		$factory->beginPrimaryChanges( __METHOD__ );
		$dbw->onTransactionPreCommitOrIdle( static function () use ( $factory, &$called ) {
			++$called;
			// Trigger s1 LoadBalancer instantiation during "finalize" stage.
			// There is no s1wiki DB to select so it is not in getConnection(),
			// but this fools getMainLB() at least.
			$factory->getMainLB( 's1wiki' )->getConnection( DB_PRIMARY );
		} );
		$factory->commitPrimaryChanges( __METHOD__ );
		$this->assertSame( 1, $called );
		$this->assertEquals( 2, $countLBsFunc( $factory ) );
		$factory->shutdown();
		$factory->closeAll( __METHOD__ );

		$called = 0;
		$factory = $this->newLBFactoryMultiLBs();
		$this->assertSame( 0, $countLBsFunc( $factory ) );
		$dbw = $factory->getMainLB()->getConnection( DB_PRIMARY );
		$this->assertSame( 1, $countLBsFunc( $factory ) );
		// Test that LoadBalancer instances made during pre-commit callbacks in do not
		// throw DBTransactionError due to transaction ROUND_* stages being mismatched.hrow
		// DBTransactionError due to transaction ROUND_* stages being mismatched.
		$factory->beginPrimaryChanges( __METHOD__ );
		$dbw->query( "SELECT 1 as t", __METHOD__ );
		$dbw->onTransactionResolution( static function () use ( $factory, &$called ) {
			++$called;
			// Trigger s1 LoadBalancer instantiation during "finalize" stage.
			// There is no s1wiki DB to select so it is not in getConnection(),
			// but this fools getMainLB() at least.
			$factory->getMainLB( 's1wiki' )->getConnection( DB_PRIMARY );
		} );
		$factory->commitPrimaryChanges( __METHOD__ );
		$this->assertSame( 1, $called );
		$this->assertEquals( 2, $countLBsFunc( $factory ) );
		$factory->shutdown();
		$factory->closeAll( __METHOD__ );

		$factory = $this->newLBFactoryMultiLBs();
		$dbw = $factory->getMainLB()->getConnection( DB_PRIMARY );
		// DBTransactionError should not be thrown
		$ran = 0;
		$dbw->onTransactionPreCommitOrIdle( static function () use ( &$ran ) {
			++$ran;
		} );
		$factory->commitPrimaryChanges( __METHOD__ );
		$this->assertSame( 1, $ran );

		$factory->shutdown();
		$factory->closeAll( __METHOD__ );
	}

	public function testLBFactoryMultiRoundTransactionSnapshots() {
		$factory = $this->newLBFactoryMultiLBs();
		$dbr = $factory->getMainLB()->getConnection( DB_REPLICA );
		$dbw = $factory->getMainLB()->getConnection( DB_PRIMARY );

		$dbr->begin( __METHOD__, $dbr::TRANSACTION_INTERNAL );
		$this->assertSame( 1, $dbr->trxLevel() );
		$this->assertSame( 0, $dbw->trxLevel() );

		$factory->beginPrimaryChanges( __METHOD__ );
		$this->assertSame( 0, $dbr->trxLevel() );
		$this->assertSame( 0, $dbw->trxLevel() );

		$dbr->begin( __METHOD__, $dbr::TRANSACTION_INTERNAL );
		$dbw->begin( __METHOD__, $dbw::TRANSACTION_INTERNAL );
		$this->assertSame( 1, $dbr->trxLevel() );
		$this->assertSame( 1, $dbw->trxLevel() );

		$factory->commitPrimaryChanges( __METHOD__ );
		$this->assertSame( 0, $dbr->trxLevel() );
		$this->assertSame( 0, $dbw->trxLevel() );
	}

	private function newLBFactoryMultiLBs() {
		global $wgDBserver, $wgDBport, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype;
		global $wgSQLiteDataDir;

		return new LBFactoryMulti( [
			'sectionsByDB' => [
				's1wiki' => 's1',
				'DEFAULT' => 's3'
			],
			'sectionLoads' => [
				's1' => [
					'test-db3' => 0,
					'test-db4' => 100,
				],
				's3' => [
					'test-db1' => 0,
					'test-db2' => 100,
				]
			],
			'serverTemplate' => [
				'port' => $wgDBport,
				'dbname' => $wgDBname,
				'user' => $wgDBuser,
				'password' => $wgDBpassword,
				'type' => $wgDBtype,
				'dbDirectory' => $wgSQLiteDataDir,
				'flags' => DBO_DEFAULT
			],
			'hostsByName' => [
				'test-db1' => $wgDBserver,
				'test-db2' => $wgDBserver,
				'test-db3' => $wgDBserver,
				'test-db4' => $wgDBserver
			],
			'loadMonitor' => [ 'class' => LoadMonitorNull::class ],
		] );
	}

	/**
	 * @covers \Wikimedia\Rdbms\ChronologyProtector
	 */
	public function testChronologyProtector() {
		$now = microtime( true );

		$hasChangesFunc = static function ( $mockDB ) {
			$p = $mockDB->writesOrCallbacksPending();
			$last = $mockDB->lastDoneWrites();

			return is_float( $last ) || $p;
		};

		// (a) First HTTP request
		$m1Pos = new MySQLPrimaryPos( 'db1034-bin.000976/843431247', $now );
		$m2Pos = new MySQLPrimaryPos( 'db1064-bin.002400/794074907', $now );

		// Primary DB 1
		/** @var IDatabaseForOwner|\PHPUnit\Framework\MockObject\MockObject $mockDB1 */
		$mockDB1 = $this->createMock( IDatabaseForOwner::class );
		$mockDB1->method( 'writesOrCallbacksPending' )->willReturn( true );
		$mockDB1->method( 'lastDoneWrites' )->willReturn( $now );
		// Load balancer for primary DB 1
		$lb1 = $this->createMock( LoadBalancer::class );
		$lb1->method( 'getConnection' )->willReturn( $mockDB1 );
		$lb1->method( 'getServerCount' )->willReturn( 2 );
		$lb1->method( 'hasReplicaServers' )->willReturn( true );
		$lb1->method( 'hasStreamingReplicaServers' )->willReturn( true );
		$lb1->method( 'getAnyOpenConnection' )->willReturn( $mockDB1 );
		$lb1->method( 'hasOrMadeRecentPrimaryChanges' )->willReturnCallback(
			static function () use ( $mockDB1, $hasChangesFunc ) {
				return $hasChangesFunc( $mockDB1 );
			}
		);
		$lb1->method( 'getPrimaryPos' )->willReturn( $m1Pos );
		$lb1->method( 'getServerName' )->with( 0 )->willReturn( 'master1' );
		// Primary DB 2
		/** @var IDatabaseForOwner|\PHPUnit\Framework\MockObject\MockObject $mockDB2 */
		$mockDB2 = $this->createMock( IDatabaseForOwner::class );
		$mockDB2->method( 'writesOrCallbacksPending' )->willReturn( true );
		$mockDB2->method( 'lastDoneWrites' )->willReturn( $now );
		// Load balancer for primary DB 2
		$lb2 = $this->createMock( LoadBalancer::class );
		$lb2->method( 'getConnection' )->willReturn( $mockDB2 );
		$lb2->method( 'getServerCount' )->willReturn( 2 );
		$lb2->method( 'hasReplicaServers' )->willReturn( true );
		$lb2->method( 'hasStreamingReplicaServers' )->willReturn( true );
		$lb2->method( 'getAnyOpenConnection' )->willReturn( $mockDB2 );
		$lb2->method( 'hasOrMadeRecentPrimaryChanges' )->willReturnCallback(
			static function () use ( $mockDB2, $hasChangesFunc ) {
				return $hasChangesFunc( $mockDB2 );
			}
		);
		$lb2->method( 'getServerName' )->with( 0 )->willReturn( 'master2' );
		$lb2->method( 'getPrimaryPos' )->willReturn( $m2Pos );

		$bag = new HashBagOStuff();
		$cp = new ChronologyProtector( $bag, null, false );
		$cp->setRequestInfo( [
			'IPAddress' => '127.0.0.1',
			'UserAgent' => 'Totally-Not-Firefox',
			'ChronologyClientId' => 'random_id',
		] );

		$mockDB1->expects( $this->once() )->method( 'writesOrCallbacksPending' );
		$mockDB1->expects( $this->once() )->method( 'lastDoneWrites' );
		$mockDB2->expects( $this->once() )->method( 'writesOrCallbacksPending' );
		$mockDB2->expects( $this->once() )->method( 'lastDoneWrites' );

		// Nothing to wait for on first HTTP request start
		$sPos1 = $cp->getSessionPrimaryPos( $lb1 );
		$sPos2 = $cp->getSessionPrimaryPos( $lb2 );
		// Record positions in stash on first HTTP request end
		$cp->stageSessionPrimaryPos( $lb1 );
		$cp->stageSessionPrimaryPos( $lb2 );
		$cpIndex = null;
		$cp->persistSessionReplicationPositions( $cpIndex );

		$this->assertNull( $sPos1 );
		$this->assertNull( $sPos2 );
		$this->assertSame( 1, $cpIndex, "CP write index set" );

		// (b) Second HTTP request

		// Load balancer for primary DB 1
		$lb1 = $this->createMock( LoadBalancer::class );
		$lb1->method( 'getServerCount' )->willReturn( 2 );
		$lb1->method( 'hasReplicaServers' )->willReturn( true );
		$lb1->method( 'hasStreamingReplicaServers' )->willReturn( true );
		$lb1->method( 'getServerName' )->with( 0 )->willReturn( 'master1' );
		// Load balancer for primary DB 2
		$lb2 = $this->createMock( LoadBalancer::class );
		$lb2->method( 'getServerCount' )->willReturn( 2 );
		$lb2->method( 'hasReplicaServers' )->willReturn( true );
		$lb2->method( 'hasStreamingReplicaServers' )->willReturn( true );
		$lb2->method( 'getServerName' )->with( 0 )->willReturn( 'master2' );

		$cp = new ChronologyProtector( $bag, null, false );
		$cp->setRequestInfo(
		[
			'IPAddress' => '127.0.0.1',
			'UserAgent' => 'Totally-Not-Firefox',
			'ChronologyClientId' => 'random_id',
			'ChronologyPositionIndex' => $cpIndex
		] );

		// Get last positions to be reached on second HTTP request start
		$sPos1 = $cp->getSessionPrimaryPos( $lb1 );
		$sPos2 = $cp->getSessionPrimaryPos( $lb2 );
		// Shutdown (nothing to record)
		$cp->stageSessionPrimaryPos( $lb1 );
		$cp->stageSessionPrimaryPos( $lb2 );
		$cpIndex = null;
		$cp->persistSessionReplicationPositions( $cpIndex );

		$this->assertNotNull( $sPos1 );
		$this->assertNotNull( $sPos2 );
		$this->assertSame( $m1Pos->__toString(), $sPos1->__toString() );
		$this->assertSame( $m2Pos->__toString(), $sPos2->__toString() );
		$this->assertNull( $cpIndex, "CP write index retained" );

		$this->assertEquals( 'random_id', $cp->getClientId() );
	}

	private function newLBFactoryMulti( array $baseOverride = [], array $serverOverride = [] ) {
		global $wgDBserver, $wgDBuser, $wgDBpassword, $wgDBname, $wgDBprefix, $wgDBtype;
		global $wgSQLiteDataDir;

		return new LBFactoryMulti( $baseOverride + [
			'sectionsByDB' => [],
			'sectionLoads' => [
				'DEFAULT' => [
					'test-db1' => 1,
				],
			],
			'serverTemplate' => $serverOverride + [
				'dbname' => $wgDBname,
				'tablePrefix' => $wgDBprefix,
				'user' => $wgDBuser,
				'password' => $wgDBpassword,
				'type' => $wgDBtype,
				'dbDirectory' => $wgSQLiteDataDir,
				'flags' => DBO_DEFAULT
			],
			'hostsByName' => [
				'test-db1' => $wgDBserver,
			],
			'loadMonitor' => [ 'class' => LoadMonitorNull::class ],
			'localDomain' => new DatabaseDomain( $wgDBname, null, $wgDBprefix ),
			'agent' => 'MW-UNIT-TESTS'
		] );
	}

	public function testNiceDomains() {
		global $wgDBname;

		if ( $this->getDb()->databasesAreIndependent() ) {
			self::markTestSkipped( "Skipping tests about selecting DBs: not applicable" );
			return;
		}

		$factory = $this->newLBFactoryMulti(
			[],
			[]
		);
		$lb = $factory->getMainLB();

		$db = $lb->getConnection( DB_PRIMARY );
		$this->assertEquals(
			WikiMap::getCurrentWikiId(),
			$db->getDomainID()
		);
		unset( $db );

		/** @var IMaintainableDatabase $db */
		$db = $lb->getConnection( DB_PRIMARY, [], $lb::DOMAIN_ANY );

		$this->assertSame(
			'',
			$db->getDomainID(),
			'Null domain ID handle used'
		);
		$this->assertNull(
			$db->getDBname(),
			'Null domain ID handle used'
		);
		$this->assertSame(
			'',
			$db->tablePrefix(),
			'Main domain ID handle used; prefix is empty though'
		);
		$this->assertEquals(
			$this->quoteTable( $db, 'page' ),
			$db->tableName( 'page' ),
			"Correct full table name"
		);
		$this->assertEquals(
			$this->quoteTable( $db, $wgDBname ) . '.' . $this->quoteTable( $db, 'page' ),
			$db->tableName( "$wgDBname.page" ),
			"Correct full table name"
		);
		$this->assertEquals(
			$this->quoteTable( $db, 'nice_db' ) . '.' . $this->quoteTable( $db, 'page' ),
			$db->tableName( 'nice_db.page' ),
			"Correct full table name"
		);

		unset( $db );

		$factory->setLocalDomainPrefix( 'my_' );
		$db = $lb->getConnection( DB_PRIMARY ); // local domain connection

		$this->assertEquals( $wgDBname, $db->getDBname() );
		$this->assertEquals(
			"$wgDBname-my_",
			$db->getDomainID()
		);
		$this->assertEquals(
			$this->quoteTable( $db, 'my_page' ),
			$db->tableName( 'page' ),
			"Correct full table name"
		);
		$this->assertEquals(
			$this->quoteTable( $db, 'other_nice_db' ) . '.' . $this->quoteTable( $db, 'page' ),
			$db->tableName( 'other_nice_db.page' ),
			"Correct full table name"
		);

		$factory->closeAll( __METHOD__ );
		$factory->destroy();
	}

	public function testTrickyDomain() {
		global $wgDBname;

		if ( $this->getDb()->databasesAreIndependent() ) {
			self::markTestSkipped( "Skipping tests about selecting DBs: not applicable" );
			return;
		}

		$dbname = 'unittest-domain'; // explodes if DB is selected
		$factory = $this->newLBFactoryMulti(
			[ 'localDomain' => ( new DatabaseDomain( $dbname, null, '' ) )->getId() ],
			[
				'dbname' => 'do_not_select_me' // explodes if DB is selected
			]
		);
		$lb = $factory->getMainLB();
		/** @var IMaintainableDatabase $db */
		$db = $lb->getConnection( DB_PRIMARY, [], $lb::DOMAIN_ANY );

		$this->assertSame( '', $db->getDomainID(), "Null domain used" );

		$this->assertEquals(
			$this->quoteTable( $db, 'page' ),
			$db->tableName( 'page' ),
			"Correct full table name"
		);

		$this->assertEquals(
			$this->quoteTable( $db, $dbname ) . '.' . $this->quoteTable( $db, 'page' ),
			$db->tableName( "$dbname.page" ),
			"Correct full table name"
		);

		$this->assertEquals(
			$this->quoteTable( $db, 'nice_db' ) . '.' . $this->quoteTable( $db, 'page' ),
			$db->tableName( 'nice_db.page' ),
			"Correct full table name"
		);

		unset( $db );

		$factory->setLocalDomainPrefix( 'my_' );
		$db = $lb->getConnection( DB_PRIMARY, [], "$wgDBname-my_" );

		$this->assertEquals(
			$this->quoteTable( $db, 'my_page' ),
			$db->tableName( 'page' ),
			"Correct full table name"
		);
		$this->assertEquals(
			$this->quoteTable( $db, 'other_nice_db' ) . '.' . $this->quoteTable( $db, 'page' ),
			$db->tableName( 'other_nice_db.page' ),
			"Correct full table name"
		);
		$this->assertEquals(
			$this->quoteTable( $db, 'garbage-db' ) . '.' . $this->quoteTable( $db, 'page' ),
			$db->tableName( 'garbage-db.page' ),
			"Correct full table name"
		);

		$factory->closeAll( __METHOD__ );
		$factory->destroy();
	}

	public function testInvalidSelectDB() {
		if ( $this->getDb()->databasesAreIndependent() ) {
			$this->markTestSkipped( "Not applicable per databasesAreIndependent()" );
		}

		$dbname = 'unittest-domain'; // explodes if DB is selected
		$factory = $this->newLBFactoryMulti(
			[ 'localDomain' => ( new DatabaseDomain( $dbname, null, '' ) )->getId() ],
			[
				'dbname' => 'do_not_select_me' // explodes if DB is selected
			]
		);
		$lb = $factory->getMainLB();
		/** @var IDatabase $db */
		$db = $lb->getConnection( DB_PRIMARY, [], $lb::DOMAIN_ANY );

		$this->expectException( \Wikimedia\Rdbms\DBUnexpectedError::class );
		$db->selectDomain( 'garbagedb' );
	}

	public function testInvalidSelectDBIndependent() {
		$dbname = 'unittest-domain'; // explodes if DB is selected
		$factory = $this->newLBFactoryMulti(
			[ 'localDomain' => ( new DatabaseDomain( $dbname, null, '' ) )->getId() ],
			[
				// Explodes with SQLite and Postgres during open/USE
				'dbname' => 'bad_dir/do_not_select_me'
			]
		);
		$lb = $factory->getMainLB();

		// FIXME: this should probably be lower (T235311)
		$this->expectException( \Wikimedia\Rdbms\DBConnectionError::class );
		if ( !$factory->getMainLB()->getServerAttributes( 0 )[Database::ATTR_DB_IS_FILE] ) {
			$this->markTestSkipped( "Not applicable per ATTR_DB_IS_FILE" );
		}

		/** @var IDatabase $db */
		$this->assertNotNull( $lb->getConnectionInternal( DB_PRIMARY, [], $lb::DOMAIN_ANY ) );
	}

	public function testInvalidSelectDBIndependent2() {
		$dbname = 'unittest-domain'; // explodes if DB is selected
		$factory = $this->newLBFactoryMulti(
			[ 'localDomain' => ( new DatabaseDomain( $dbname, null, '' ) )->getId() ],
			[
				// Explodes with SQLite and Postgres during open/USE
				'dbname' => 'bad_dir/do_not_select_me'
			]
		);
		$lb = $factory->getMainLB();

		// FIXME: this should probably be lower (T235311)
		$this->expectException( \Wikimedia\Rdbms\DBExpectedError::class );
		if ( !$lb->getConnection( DB_PRIMARY )->databasesAreIndependent() ) {
			$this->markTestSkipped( "Not applicable per databasesAreIndependent()" );
		}

		$db = $lb->getConnectionInternal( DB_PRIMARY );
		$db->selectDomain( 'garbage-db' );
	}

	public function testRedefineLocalDomain() {
		global $wgDBname;

		if ( $this->getDb()->databasesAreIndependent() ) {
			self::markTestSkipped( "Skipping tests about selecting DBs: not applicable" );
			return;
		}

		$factory = $this->newLBFactoryMulti(
			[],
			[]
		);
		$lb = $factory->getMainLB();

		$conn1 = $lb->getConnection( DB_PRIMARY );
		$this->assertEquals(
			WikiMap::getCurrentWikiId(),
			$conn1->getDomainID()
		);
		unset( $conn1 );

		$factory->redefineLocalDomain( 'somedb-prefix_' );
		$this->assertEquals( 'somedb-prefix_', $factory->getLocalDomainID() );

		$domain = new DatabaseDomain( $wgDBname, null, 'pref_' );
		$factory->redefineLocalDomain( $domain );

		/** @var LoadBalancer $lbWrapper */
		$lbWrapper = TestingAccessWrapper::newFromObject( $lb );
		$n = iterator_count( $lbWrapper->getOpenConnections() );
		$this->assertSame( 0, $n, "Connections closed" );

		$conn2 = $lb->getConnection( DB_PRIMARY );
		$this->assertEquals(
			$domain->getId(),
			$conn2->getDomainID()
		);
		unset( $conn2 );

		$factory->closeAll( __METHOD__ );
		$factory->destroy();
	}

	public function testVirtualDomains() {
		$baseOverrides = [
			'localDomain' => ( new DatabaseDomain( 'localdomain', null, '' ) )->getId(),
			'sectionLoads' => [
				'DEFAULT' => [
					'test-db1' => 1,
				],
				'shareddb' => [
					'test-db1' => 1,
				],
			],
			'externalLoads' => [
				'extension1' => [
					'test-db1' => 1,
				],
			],
			'virtualDomains' => [ 'virtualdomain1', 'virtualdomain2', 'virtualdomain3', 'virtualdomain4' ],
			'virtualDomainsMapping' => [
				'virtualdomain1' => [ 'db' => 'extdomain', 'cluster' => 'extension1' ],
				'virtualdomain2' => [ 'db' => false, 'cluster' => 'extension1' ],
				'virtualdomain3' => [ 'db' => 'shareddb' ],
			]
		];
		$factory = $this->newLBFactoryMulti( $baseOverrides );
		$db1 = $factory->getPrimaryDatabase( 'virtualdomain1' );
		$this->assertEquals(
			'extdomain',
			$db1->getDomainID()
		);
		$this->assertEquals(
			'extension1',
			$factory->getLoadBalancer( 'virtualdomain1' )->getClusterName()
		);

		$db2 = $factory->getPrimaryDatabase( 'virtualdomain2' );
		$this->assertEquals(
			'localdomain',
			$db2->getDomainID()
		);
		$this->assertEquals(
			'extension1',
			$factory->getLoadBalancer( 'virtualdomain2' )->getClusterName()
		);

		$db3 = $factory->getPrimaryDatabase( 'virtualdomain3' );
		$this->assertEquals(
			'shareddb',
			$db3->getDomainID()
		);
		$this->assertEquals(
			'DEFAULT',
			$factory->getLoadBalancer( 'virtualdomain3' )->getClusterName()
		);

		$db3 = $factory->getPrimaryDatabase( 'virtualdomain4' );
		$this->assertEquals(
			'localdomain',
			$db3->getDomainID()
		);
		$this->assertEquals(
			'DEFAULT',
			$factory->getLoadBalancer( 'virtualdomain4' )->getClusterName()
		);
	}

	private function quoteTable( IReadableDatabase $db, $table ) {
		if ( $db->getType() === 'sqlite' ) {
			return $table;
		} else {
			return $db->addIdentifierQuotes( $table );
		}
	}

	public function testGetChronologyProtectorTouched() {
		$store = new HashBagOStuff;
		$chronologyProtector = new ChronologyProtector( $store, '', false );
		$chronologyProtector->setRequestInfo( [ 'ChronologyClientId' => 'ii' ] );

		// 2019-02-05T05:03:20Z
		$mockWallClock = 1549343000.0;
		$priorTime = $mockWallClock; // reference time
		$chronologyProtector->setMockTime( $mockWallClock );

		$cpWrap = TestingAccessWrapper::newFromObject( $chronologyProtector );
		$cpWrap->store->set(
			$cpWrap->key,
			$cpWrap->mergePositions(
				false,
				[],
				[ ILBFactory::CLUSTER_MAIN_DEFAULT => $priorTime ]
			),
			3600
		);

		$lbFactory = $this->newLBFactoryMulti( [ 'chronologyProtector' => $chronologyProtector ] );
		$mockWallClock += 1.0;
		$touched = $chronologyProtector->getTouched( $lbFactory->getMainLB() );
		$this->assertEquals( $priorTime, $touched );
	}

	public function testReconfigureWithOneReplica() {
		$primaryConfig = $this->getPrimaryServerConfig();
		$fakeReplica = [ 'load' => 100, 'serverName' => 'replica' ] + $primaryConfig;

		$conf = [ 'servers' => [
			$primaryConfig,
			$fakeReplica
		] ];

		// Configure an LBFactory with one replica
		$factory = new LBFactorySimple( $conf );
		$lb = $factory->getMainLB();
		$this->assertSame( 2, $lb->getServerCount() );

		$con = $lb->getConnectionInternal( DB_REPLICA );
		$ref = $lb->getConnection( DB_REPLICA );

		// Call reconfigure with the same config, should have no effect
		$factory->reconfigure( $conf );
		$this->assertSame( 2, $lb->getServerCount() );
		$this->assertTrue( $con->isOpen() );
		$this->assertTrue( $ref->isOpen() );

		// Call reconfigure with empty config, should have no effect
		$factory->reconfigure( [] );
		$this->assertSame( 2, $lb->getServerCount() );
		$this->assertTrue( $con->isOpen() );
		$this->assertTrue( $ref->isOpen() );

		// Reconfigure the LBFactory to only have a single server.
		$conf['servers'] = [ $this->getPrimaryServerConfig() ];
		$factory->reconfigure( $conf );

		// The LoadBalancer should have been reconfigured automatically.
		$this->assertSame( 1, $lb->getServerCount() );

		// Reconfiguring should not close connections immediately.
		$this->assertTrue( $con->isOpen() );

		// Connection refs should detect the config change, close the old connection,
		// and get a new connection.
		$this->assertTrue( $ref->isOpen() );

		// The old connection should have been closed by DBConnRef.
		$this->assertFalse( $con->isOpen() );
	}

	public function testReconfigureWithThreeReplicas() {
		$primaryConfig = $this->getPrimaryServerConfig();
		$replica1Config = [ 'serverName' => 'db2', 'load' => 0 ] + $primaryConfig;
		$replica2Config = [ 'serverName' => 'db3', 'load' => 1 ] + $primaryConfig;
		$replica3Config = [ 'serverName' => 'db4', 'load' => 1 ] + $primaryConfig;

		$conf = [ 'servers' => [
			$primaryConfig,
			$replica1Config,
			$replica2Config,
			$replica3Config
		] ];

		// Configure an LBFactory with two replicas
		$factory = new LBFactorySimple( $conf );
		$lb = $factory->getMainLB();
		$this->assertSame( 4, $lb->getServerCount() );
		$this->assertSame( 'db1', $lb->getServerName( 0 ) );
		$this->assertSame( 'db2', $lb->getServerName( 1 ) );
		$this->assertSame( 'db3', $lb->getServerName( 2 ) );
		$this->assertSame( 'db4', $lb->getServerName( 3 ) );

		$con = $lb->getConnectionInternal( DB_REPLICA );
		$ref = $lb->getConnection( DB_REPLICA );

		// Call reconfigure with the same config, should have no effect
		$factory->reconfigure( $conf );
		$this->assertSame( 4, $lb->getServerCount() );
		$this->assertSame( 'db1', $lb->getServerName( 0 ) );
		$this->assertSame( 'db2', $lb->getServerName( 1 ) );
		$this->assertSame( 'db3', $lb->getServerName( 2 ) );
		$this->assertSame( 'db4', $lb->getServerName( 3 ) );
		$this->assertTrue( $con->isOpen() );
		$this->assertTrue( $ref->isOpen() );

		// Call reconfigure with empty config, should have no effect
		$factory->reconfigure( [] );
		$this->assertSame( 4, $lb->getServerCount() );
		$this->assertSame( 'db1', $lb->getServerName( 0 ) );
		$this->assertSame( 'db2', $lb->getServerName( 1 ) );
		$this->assertSame( 'db3', $lb->getServerName( 2 ) );
		$this->assertSame( 'db4', $lb->getServerName( 3 ) );
		$this->assertTrue( $con->isOpen() );
		$this->assertTrue( $ref->isOpen() );

		// Reconfigure the LBFactory to only have a two servers (server indexes shifted).
		$conf['servers'] = [ $primaryConfig, $replica2Config, $replica3Config ];
		$factory->reconfigure( $conf );
		// The LoadBalancer should have been reconfigured automatically.
		$this->assertSame( 3, $lb->getServerCount() );
		$this->assertSame( 'db1', $lb->getServerName( 0 ) );
		$this->assertSame( false, $lb->getServerInfo( 1 ) );
		$this->assertSame( 'db3', $lb->getServerName( 2 ) );
		$this->assertSame( 'db4', $lb->getServerName( 3 ) );
		// Reconfiguring should not close connections immediately.
		$this->assertTrue( $con->isOpen() );
		// Connection refs should detect the config change, close the old connection,
		// and get a new connection.
		$this->assertTrue( $ref->isOpen() );
		// The old connection should have been closed by DBConnRef.
		$this->assertFalse( $con->isOpen() );
	}

	public function testAutoReconfigure() {
		$primaryConfig = $this->getPrimaryServerConfig();
		$fakeReplica = [ 'load' => 100, 'serverName' => 'replica1' ] + $primaryConfig;

		$conf = [
			'servers' => [
				$primaryConfig,
				$fakeReplica
			],
		];

		// The config callback should return $conf, reflecting changes
		// made to the local variable.
		$conf['configCallback'] = static function () use ( &$conf ) {
			static $calls = 0;
			$calls++;
			if ( $calls == 1 ) {
				return $conf;
			} else {
				unset( $conf['servers'][1] );
				return $conf;
			}
		};

		// Configure an LBFactory with one replica
		$factory = new LBFactorySimple( $conf );

		$lb = $factory->getMainLB();
		$this->assertSame( 2, $lb->getServerCount() );

		$con = $lb->getConnectionInternal( DB_REPLICA );
		$ref = $lb->getConnection( DB_REPLICA );

		// Nothing changed, autoReconfigure() should do nothing.
		$factory->autoReconfigure();

		$this->assertSame( 2, $lb->getServerCount() );
		$this->assertTrue( $con->isOpen() );
		$this->assertTrue( $ref->isOpen() );

		// Now autoReconfigure() should detect the change and reconfigure all LoadBalancers.
		$factory->autoReconfigure();

		// The LoadBalancer should have been reconfigured now.
		$this->assertSame( 1, $lb->getServerCount() );

		// Reconfiguring should not close connections immediately.
		$this->assertTrue( $con->isOpen() );

		// Connection refs should detect the config change, close the old connection,
		// and get a new connection.
		$this->assertTrue( $ref->isOpen() );

		// The old connection should have been called by DBConnRef.
		$this->assertFalse( $con->isOpen() );
	}

	public function testSetWaitForReplicationListener() {
		$factory = $this->newLBFactoryMultiLBs();

		$allLBs = iterator_to_array( $factory->getAllLBs() );
		$this->assertCount( 0, $allLBs );

		$runs = 0;
		$callback = static function () use ( &$runs ) {
			++$runs;
		};
		$factory->setWaitForReplicationListener( 'test', $callback );

		$this->assertSame( 0, $runs );
		$factory->waitForReplication();
		$this->assertSame( 1, $runs );

		$factory->getMainLB();
		$allLBs = iterator_to_array( $factory->getAllLBs() );
		$this->assertCount( 1, $allLBs );
		$factory->waitForReplication();
		$this->assertSame( 2, $runs );
	}
}
PK       ! 0Zl  Zl    db/LoadBalancerTest.phpnu Iw        <?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 Wikimedia\Rdbms\ChronologyProtector;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\DatabaseDomain;
use Wikimedia\Rdbms\DBConnRef;
use Wikimedia\Rdbms\DBReadOnlyRoleError;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IMaintainableDatabase;
use Wikimedia\Rdbms\LoadBalancer;
use Wikimedia\Rdbms\LoadMonitorNull;
use Wikimedia\Rdbms\ServerInfo;
use Wikimedia\Rdbms\TransactionManager;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Database
 * @group medium
 * @covers \Wikimedia\Rdbms\LoadBalancer
 * @covers \Wikimedia\Rdbms\ServerInfo
 */
class LoadBalancerTest extends MediaWikiIntegrationTestCase {
	private function makeServerConfig( $flags = DBO_DEFAULT ) {
		global $wgDBserver, $wgDBport, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype;
		global $wgSQLiteDataDir;

		return [
			'host' => $wgDBserver,
			'port' => $wgDBport,
			'serverName' => 'testhost',
			'dbname' => $wgDBname,
			'tablePrefix' => self::dbPrefix(),
			'user' => $wgDBuser,
			'password' => $wgDBpassword,
			'type' => $wgDBtype,
			'dbDirectory' => $wgSQLiteDataDir,
			'load' => 0,
			'flags' => $flags
		];
	}

	public function testWithoutReplica() {
		global $wgDBname;

		$called = false;
		$chronologyProtector = $this->createMock( ChronologyProtector::class );
		$chronologyProtector->method( 'getSessionPrimaryPos' )
			->willReturnCallback(
				static function () use ( &$called ) {
					$called = true;
				}
			);
		$lb = new LoadBalancer( [
			// Simulate web request with DBO_TRX
			'servers' => [ $this->makeServerConfig( DBO_TRX ) ],
			'logger' => MediaWiki\Logger\LoggerFactory::getInstance( 'rdbms' ),
			'localDomain' => new DatabaseDomain( $wgDBname, null, self::dbPrefix() ),
			'chronologyProtector' => $chronologyProtector,
			'clusterName' => 'xyz'
		] );

		$this->assertSame( 1, $lb->getServerCount() );
		$this->assertFalse( $lb->hasReplicaServers() );
		$this->assertFalse( $lb->hasStreamingReplicaServers() );
		$this->assertSame( 'xyz', $lb->getClusterName() );

		$ld = DatabaseDomain::newFromId( $lb->getLocalDomainID() );
		$this->assertSame( $wgDBname, $ld->getDatabase(), 'local domain DB set' );
		$this->assertSame( self::dbPrefix(), $ld->getTablePrefix(), 'local domain prefix set' );
		$this->assertSame( 'my_test_wiki', $lb->resolveDomainID( 'my_test_wiki' ) );
		$this->assertSame( $ld->getId(), $lb->resolveDomainID( false ) );
		$this->assertSame( $ld->getId(), $lb->resolveDomainID( $ld ) );
		$this->assertFalse( $called );

		$dbw = $lb->getConnection( DB_PRIMARY );
		$dbw->getServerName();
		$this->assertFalse( $called, "getServerName() optimized for DB_PRIMARY" );

		$dbw->ensureConnection();
		$this->assertFalse( $called, "Session replication pos not used with single server" );
		$this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on master" );
		$this->assertWriteAllowed( $dbw );

		$dbr = $lb->getConnection( DB_REPLICA );
		$this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on replica" );

		if ( !$lb->getServerAttributes( ServerInfo::WRITER_INDEX )[Database::ATTR_DB_LEVEL_LOCKING] ) {
			$dbwAC1 = $lb->getConnection( DB_PRIMARY, [], false, $lb::CONN_TRX_AUTOCOMMIT );
			$this->assertFalse(
				$dbwAC1->getFlag( $dbw::DBO_TRX ),
				"No DBO_TRX with CONN_TRX_AUTOCOMMIT"
			);
			$this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on master" );
			$this->assertUnsharedHandle( $dbw, $dbwAC1, "CONN_TRX_AUTOCOMMIT separate connection" );

			$dbrAC1 = $lb->getConnection( DB_REPLICA, [], false, $lb::CONN_TRX_AUTOCOMMIT );
			$this->assertFalse(
				$dbrAC1->getFlag( $dbw::DBO_TRX ),
				"No DBO_TRX with CONN_TRX_AUTOCOMMIT"
			);
			$this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on replica" );
			$this->assertUnsharedHandle( $dbr, $dbrAC1, "CONN_TRX_AUTOCOMMIT separate connection" );

			$dbwAC2 = $lb->getConnection( DB_PRIMARY, [], false, $lb::CONN_TRX_AUTOCOMMIT );
			$dbwAC2->ensureConnection();
			$this->assertSharedHandle( $dbwAC2, $dbwAC1, "CONN_TRX_AUTOCOMMIT reuses connections" );

			$dbrAC2 = $lb->getConnection( DB_REPLICA, [], false, $lb::CONN_TRX_AUTOCOMMIT );
			$dbrAC2->ensureConnection();
			$this->assertSharedHandle( $dbrAC2, $dbrAC1, "CONN_TRX_AUTOCOMMIT reuses connections" );
		}

		$lb->closeAll( __METHOD__ );
	}

	public function testWithReplica() {
		// Simulate web request with DBO_TRX
		$lb = $this->newMultiServerLocalLoadBalancer( [], [ 'flags' => DBO_TRX ] );

		$this->assertSame( 8, $lb->getServerCount() );
		$this->assertTrue( $lb->hasReplicaServers() );
		$this->assertTrue( $lb->hasStreamingReplicaServers() );
		$this->assertSame( 'main-test-cluster', $lb->getClusterName() );

		for ( $i = 0; $i < $lb->getServerCount(); ++$i ) {
			$this->assertIsString( $lb->getServerName( $i ) );
			$this->assertIsArray( $lb->getServerInfo( $i ) );
			$this->assertIsString( $lb->getServerType( $i ) );
			$this->assertIsArray( $lb->getServerAttributes( $i ) );
		}

		$dbw = $lb->getConnection( DB_PRIMARY );
		$dbw->ensureConnection();
		$wConn = TestingAccessWrapper::newFromObject( $dbw )->conn;
		$wConnWrap = TestingAccessWrapper::newFromObject( $wConn );

		$this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on primary" );
		$this->assertWriteAllowed( $dbw );

		$dbr = $lb->getConnection( DB_REPLICA );
		$dbr->ensureConnection();
		$rConn = TestingAccessWrapper::newFromObject( $dbr )->conn;
		$rConnWrap = TestingAccessWrapper::newFromObject( $rConn );

		$this->assertTrue( $dbr->isReadOnly(), 'replica shows as replica' );
		$this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX set on replica" );
		$this->assertSame( $dbr->getLBInfo( 'serverIndex' ), $lb->getReaderIndex() );

		if ( !$lb->getServerAttributes( ServerInfo::WRITER_INDEX )[Database::ATTR_DB_LEVEL_LOCKING] ) {
			$dbwAC1 = $lb->getConnection( DB_PRIMARY, [], false, $lb::CONN_TRX_AUTOCOMMIT );
			$this->assertFalse(
				$dbwAC1->getFlag( $dbw::DBO_TRX ),
				"No DBO_TRX with CONN_TRX_AUTOCOMMIT"
			);
			$this->assertTrue( $dbw->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on master" );
			$this->assertUnsharedHandle( $dbw, $dbwAC1, "CONN_TRX_AUTOCOMMIT separate connection" );

			$dbrAC1 = $lb->getConnection( DB_REPLICA, [], false, $lb::CONN_TRX_AUTOCOMMIT );
			$this->assertFalse(
				$dbrAC1->getFlag( $dbw::DBO_TRX ),
				"No DBO_TRX with CONN_TRX_AUTOCOMMIT"
			);
			$this->assertTrue( $dbr->getFlag( $dbw::DBO_TRX ), "DBO_TRX still set on replica" );
			$this->assertUnsharedHandle( $dbr, $dbrAC1, "CONN_TRX_AUTOCOMMIT separate connection" );

			$dbwAC2 = $lb->getConnection( DB_PRIMARY, [], false, $lb::CONN_TRX_AUTOCOMMIT );
			$dbwAC2->ensureConnection();
			$this->assertSharedHandle( $dbwAC2, $dbwAC1, "CONN_TRX_AUTOCOMMIT reuses connections" );

			$dbrAC2 = $lb->getConnection( DB_REPLICA, [], false, $lb::CONN_TRX_AUTOCOMMIT );
			$dbrAC2->ensureConnection();
			$this->assertSharedHandle( $dbrAC2, $dbrAC1, "CONN_TRX_AUTOCOMMIT reuses connections" );
		}

		$lb->closeAll( __METHOD__ );
	}

	private function assertSharedHandle( DBConnRef $connRef1, DBConnRef $connRef2, $msg ) {
		$connRef1Wrap = TestingAccessWrapper::newFromObject( $connRef1 );
		$connRef2Wrap = TestingAccessWrapper::newFromObject( $connRef2 );
		$this->assertSame( $connRef1Wrap->conn, $connRef2Wrap->conn, $msg );
	}

	private function assertUnsharedHandle( DBConnRef $connRef1, DBConnRef $connRef2, $msg ) {
		$connRef1Wrap = TestingAccessWrapper::newFromObject( $connRef1 );
		$connRef2Wrap = TestingAccessWrapper::newFromObject( $connRef2 );
		$this->assertNotSame( $connRef1Wrap->conn, $connRef2Wrap->conn, $msg );
	}

	private function newSingleServerLocalLoadBalancer() {
		global $wgDBname;

		return new LoadBalancer( [
			'servers' => [ $this->makeServerConfig() ],
			'localDomain' => new DatabaseDomain( $wgDBname, null, self::dbPrefix() ),
			'cliMode' => false
		] );
	}

	private function newMultiServerLocalLoadBalancer(
		$lbExtra = [], $srvExtra = [], $masterOnly = false
	) {
		global $wgDBserver, $wgDBport, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype;
		global $wgSQLiteDataDir;

		$servers = [
			// Primary DB
			0 => $srvExtra + [
					'serverName' => 'db0',
					'host' => $wgDBserver,
					'port' => $wgDBport,
					'dbname' => $wgDBname,
					'tablePrefix' => self::dbPrefix(),
					'user' => $wgDBuser,
					'password' => $wgDBpassword,
					'type' => $wgDBtype,
					'dbDirectory' => $wgSQLiteDataDir,
					'load' => $masterOnly ? 100 : 0,
				],
			// Main replica DBs
			1 => $srvExtra + [
					'serverName' => 'db1',
					'host' => $wgDBserver,
					'port' => $wgDBport,
					'dbname' => $wgDBname,
					'tablePrefix' => self::dbPrefix(),
					'user' => $wgDBuser,
					'password' => $wgDBpassword,
					'type' => $wgDBtype,
					'dbDirectory' => $wgSQLiteDataDir,
					'load' => $masterOnly ? 0 : 100,
				],
			2 => $srvExtra + [
					'serverName' => 'db2',
					'host' => $wgDBserver,
					'port' => $wgDBport,
					'dbname' => $wgDBname,
					'tablePrefix' => self::dbPrefix(),
					'user' => $wgDBuser,
					'password' => $wgDBpassword,
					'type' => $wgDBtype,
					'dbDirectory' => $wgSQLiteDataDir,
					'load' => $masterOnly ? 0 : 100,
				],
			// RC replica DBs
			3 => $srvExtra + [
					'serverName' => 'db3',
					'host' => $wgDBserver,
					'port' => $wgDBport,
					'dbname' => $wgDBname,
					'tablePrefix' => self::dbPrefix(),
					'user' => $wgDBuser,
					'password' => $wgDBpassword,
					'type' => $wgDBtype,
					'dbDirectory' => $wgSQLiteDataDir,
					'load' => 0,
					'groupLoads' => [
						'foo' => 100,
						'bar' => 100
					],
				],
			// Logging replica DBs
			4 => $srvExtra + [
					'serverName' => 'db4',
					'host' => $wgDBserver,
					'port' => $wgDBport,
					'dbname' => $wgDBname,
					'tablePrefix' => self::dbPrefix(),
					'user' => $wgDBuser,
					'password' => $wgDBpassword,
					'type' => $wgDBtype,
					'dbDirectory' => $wgSQLiteDataDir,
					'load' => 0,
					'groupLoads' => [
						'baz' => 100
					],
				],
			5 => $srvExtra + [
					'serverName' => 'db5',
					'host' => $wgDBserver,
					'port' => $wgDBport,
					'dbname' => $wgDBname,
					'tablePrefix' => self::dbPrefix(),
					'user' => $wgDBuser,
					'password' => $wgDBpassword,
					'type' => $wgDBtype,
					'dbDirectory' => $wgSQLiteDataDir,
					'load' => 0,
					'groupLoads' => [
						'baz' => 100
					],
				],
			// Maintenance query replica DBs
			6 => $srvExtra + [
					'serverName' => 'db6',
					'host' => $wgDBserver,
					'port' => $wgDBport,
					'dbname' => $wgDBname,
					'tablePrefix' => self::dbPrefix(),
					'user' => $wgDBuser,
					'password' => $wgDBpassword,
					'type' => $wgDBtype,
					'dbDirectory' => $wgSQLiteDataDir,
					'load' => 0,
					'groupLoads' => [
						'vslow' => 100
					],
				],
			// Replica DB that only has a copy of some static tables
			7 => $srvExtra + [
					'serverName' => 'db7',
					'host' => $wgDBserver,
					'port' => $wgDBport,
					'dbname' => $wgDBname,
					'tablePrefix' => self::dbPrefix(),
					'user' => $wgDBuser,
					'password' => $wgDBpassword,
					'type' => $wgDBtype,
					'dbDirectory' => $wgSQLiteDataDir,
					'load' => 0,
					'groupLoads' => [
						'archive' => 100
					],
					'is static' => true
				]
		];

		return new LoadBalancer( $lbExtra + [
			'servers' => $servers,
			'localDomain' => new DatabaseDomain( $wgDBname, null, self::dbPrefix() ),
			'logger' => MediaWiki\Logger\LoggerFactory::getInstance( 'rdbms' ),
			'loadMonitor' => [ 'class' => LoadMonitorNull::class ],
			'clusterName' => 'main-test-cluster'
		] );
	}

	private function assertWriteAllowed( IMaintainableDatabase $db ) {
		$table = $db->tableName( 'some_table' );
		// Trigger a transaction so that rollback() will remove all the tables.
		// Don't do this for MySQL as it auto-commits transactions for DDL
		// statements such as CREATE TABLE.
		$useAtomicSection = in_array( $db->getType(), [ 'sqlite', 'postgres' ], true );
		/** @var Database $db */
		try {
			$db->dropTable( 'some_table' );
			$this->assertNotEquals( TransactionManager::STATUS_TRX_ERROR, $db->trxStatus() );

			if ( $useAtomicSection ) {
				$db->startAtomic( __METHOD__ );
			}
			// Use only basic SQL and trivial types for these queries for compatibility
			$this->assertNotFalse(
				$db->query( "CREATE TABLE $table (id INT, time INT)", __METHOD__ ),
				"table created"
			);
			$this->assertNotEquals( TransactionManager::STATUS_TRX_ERROR, $db->trxStatus() );
			$this->assertNotFalse(
				$db->query( "DELETE FROM $table WHERE id=57634126", __METHOD__ ),
				"delete query"
			);
			$this->assertNotEquals( TransactionManager::STATUS_TRX_ERROR, $db->trxStatus() );
		} finally {
			if ( !$useAtomicSection ) {
				// Drop the table to clean up, ignoring any error.
				$db->dropTable( 'some_table' );
			}
			// Rollback the atomic section for sqlite's benefit.
			$db->rollback( __METHOD__, 'flush' );
			$this->assertNotEquals( TransactionManager::STATUS_TRX_ERROR, $db->trxStatus() );
		}
	}

	public function testServerAttributes() {
		$servers = [
			[ // master
				'dbname' => 'my_unittest_wiki',
				'tablePrefix' => self::DB_PREFIX,
				'type' => 'sqlite',
				'dbDirectory' => "some_directory",
				'load' => 0
			]
		];

		$lb = new LoadBalancer( [
			'servers' => $servers,
			'localDomain' => new DatabaseDomain( 'my_unittest_wiki', null, self::DB_PREFIX ),
			'loadMonitor' => [ 'class' => LoadMonitorNull::class ]
		] );

		$this->assertTrue( $lb->getServerAttributes( 0 )[Database::ATTR_DB_LEVEL_LOCKING] );

		$servers = [
			[ // master
				'serverName' => 'db1',
				'host' => 'db1001',
				'user' => 'wikiuser',
				'password' => 'none',
				'dbname' => 'my_unittest_wiki',
				'tablePrefix' => self::DB_PREFIX,
				'type' => 'mysql',
				'load' => 100
			],
			[ // emulated replica
				'serverName' => 'db2',
				'host' => 'db1002',
				'user' => 'wikiuser',
				'password' => 'none',
				'dbname' => 'my_unittest_wiki',
				'tablePrefix' => self::DB_PREFIX,
				'type' => 'mysql',
				'load' => 100
			]
		];

		$lb = new LoadBalancer( [
			'servers' => $servers,
			'localDomain' => new DatabaseDomain( 'my_unittest_wiki', null, self::DB_PREFIX ),
			'loadMonitor' => [ 'class' => LoadMonitorNull::class ]
		] );

		$this->assertFalse( $lb->getServerAttributes( 1 )[Database::ATTR_DB_LEVEL_LOCKING] );
	}

	public function testOpenConnection() {
		$lb = $this->newSingleServerLocalLoadBalancer();
		$i = ServerInfo::WRITER_INDEX;

		$this->assertFalse( $lb->getAnyOpenConnection( $i ) );
		$this->assertFalse( $lb->getAnyOpenConnection( $i, $lb::CONN_TRX_AUTOCOMMIT ) );

		// Get two live round-aware handles
		$raConnRef1 = $lb->getConnection( $i );
		$raConnRef1->ensureConnection();
		$raConnRef1Wrapper = TestingAccessWrapper::newFromObject( $raConnRef1 );
		$raConnRef2 = $lb->getConnection( $i );
		$raConnRef2->ensureConnection();
		$raConnRef2Wrapper = TestingAccessWrapper::newFromObject( $raConnRef2 );

		$this->assertNotNull( $raConnRef1Wrapper->conn );
		$this->assertSame( $raConnRef1Wrapper->conn, $raConnRef2Wrapper->conn );
		$this->assertTrue( $raConnRef1Wrapper->conn->getFlag( DBO_TRX ) );

		// Get two live autocommit handles
		$acConnRef1 = $lb->getConnection( $i, [], false, $lb::CONN_TRX_AUTOCOMMIT );
		$acConnRef1->ensureConnection();
		$acConnRef1Wrapper = TestingAccessWrapper::newFromObject( $acConnRef1 );
		$acConnRef2 = $lb->getConnection( $i, [], false, $lb::CONN_TRX_AUTOCOMMIT );
		$acConnRef2->ensureConnection();
		$acConnRef2Wrapper = TestingAccessWrapper::newFromObject( $acConnRef2 );

		$this->assertNotNull( $acConnRef1Wrapper->conn );
		$this->assertSame( $acConnRef1Wrapper->conn, $acConnRef2Wrapper->conn );

		$this->assertNotFalse( $lb->getAnyOpenConnection( $i ) );

		$lb->closeAll( __METHOD__ );
	}

	public function testReconfigure() {
		$serverA = $this->makeServerConfig();
		$serverA['serverName'] = 'test_one';

		$serverB = $this->makeServerConfig();
		$serverB['serverName'] = 'test_two';
		$conf = [
			'servers' => [ $serverA, $serverB ],
			'clusterName' => 'A',
			'localDomain' => $this->getDb()->getDomainID()
		];

		$lb = new LoadBalancer( $conf );
		$this->assertSame( 2, $lb->getServerCount() );

		$con = $lb->getConnectionInternal( DB_PRIMARY );
		$ref = $lb->getConnection( DB_PRIMARY );

		$this->assertTrue( $con->isOpen() );
		$this->assertTrue( $ref->isOpen() );

		// Depool the second server
		$conf['servers'] = [ $serverA ];
		$lb->reconfigure( $conf );
		$this->assertSame( 1, $lb->getServerCount() );

		// Reconfiguring should not close connections immediately.
		$this->assertTrue( $con->isOpen() );

		// Connection refs should detect the config change, close the old connection,
		// and get a new connection.
		$this->assertTrue( $ref->isOpen() );

		// The old connection should have been called by DBConnRef.
		$this->assertFalse( $con->isOpen() );
	}

	public function testTransactionCallbackChains() {
		global $wgDBserver, $wgDBport, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype;
		global $wgSQLiteDataDir;

		$servers = [
			[
				'host' => $wgDBserver,
				'port' => $wgDBport,
				'dbname' => $wgDBname,
				'tablePrefix' => self::dbPrefix(),
				'user' => $wgDBuser,
				'password' => $wgDBpassword,
				'type' => $wgDBtype,
				'dbDirectory' => $wgSQLiteDataDir,
				'load' => 0,
				'flags' => DBO_TRX // simulate a web request with DBO_TRX
			],
		];

		$lb = new LoadBalancer( [
			'servers' => $servers,
			'localDomain' => new DatabaseDomain( $wgDBname, null, self::dbPrefix() )
		] );
		/** @var LoadBalancer $lbWrapper */
		$lbWrapper = TestingAccessWrapper::newFromObject( $lb );

		$conn1 = $lb->getConnection( ServerInfo::WRITER_INDEX, [], false );
		$count = iterator_count( $lbWrapper->getOpenPrimaryConnections() );
		$this->assertSame( 0, $count, 'Connection handle count' );
		$conn1->getServerName();
		$count = iterator_count( $lbWrapper->getOpenPrimaryConnections() );
		$this->assertSame( 0, $count, 'Connection handle count' );
		$conn1->ensureConnection();

		$conn2 = $lb->getConnection( ServerInfo::WRITER_INDEX, [], '' );
		$count = iterator_count( $lbWrapper->getOpenPrimaryConnections() );
		$this->assertSame( 1, $count, 'Connection handle count' );
		$conn2->getServerName();
		$count = iterator_count( $lbWrapper->getOpenPrimaryConnections() );
		$this->assertSame( 1, $count, 'Connection handle count' );
		$conn2->ensureConnection();

		$count = iterator_count( $lbWrapper->getOpenPrimaryConnections() );
		$this->assertSame( 1, $count, 'Connection handle count' );

		$tlCalls = 0;
		$lb->setTransactionListener( 'test-listener', static function () use ( &$tlCalls ) {
			++$tlCalls;
		} );

		$lb->beginPrimaryChanges( __METHOD__ );
		$bc = array_fill_keys( [ 'a', 'b', 'c', 'd' ], 0 );
		$conn1->onTransactionPreCommitOrIdle( static function () use ( &$bc, $conn1, $conn2 ) {
			$bc['a'] = 1;
			$conn2->onTransactionPreCommitOrIdle( static function () use ( &$bc, $conn1 ) {
				$bc['b'] = 1;
				$conn1->onTransactionPreCommitOrIdle( static function () use ( &$bc, $conn1 ) {
					$bc['c'] = 1;
					$conn1->onTransactionPreCommitOrIdle( static function () use ( &$bc ) {
						$bc['d'] = 1;
					} );
				} );
			} );
		} );
		$lb->finalizePrimaryChanges();
		$lb->approvePrimaryChanges( 0 );
		$lb->commitPrimaryChanges( __METHOD__ );
		$lb->runPrimaryTransactionIdleCallbacks();
		$lb->runPrimaryTransactionListenerCallbacks();

		$this->assertSame( array_fill_keys( [ 'a', 'b', 'c', 'd' ], 1 ), $bc );
		$this->assertSame( 1, $tlCalls );

		$tlCalls = 0;
		$lb->beginPrimaryChanges( __METHOD__ );
		$ac = array_fill_keys( [ 'a', 'b', 'c', 'd' ], 0 );
		$conn1->onTransactionCommitOrIdle( static function () use ( &$ac, $conn1, $conn2 ) {
			$ac['a'] = 1;
			$conn2->onTransactionCommitOrIdle( static function () use ( &$ac, $conn1 ) {
				$ac['b'] = 1;
				$conn1->onTransactionCommitOrIdle( static function () use ( &$ac, $conn1 ) {
					$ac['c'] = 1;
					$conn1->onTransactionCommitOrIdle( static function () use ( &$ac ) {
						$ac['d'] = 1;
					} );
				} );
			} );
		} );
		$lb->finalizePrimaryChanges();
		$lb->approvePrimaryChanges( 0 );
		$lb->commitPrimaryChanges( __METHOD__ );
		$lb->runPrimaryTransactionIdleCallbacks();
		$lb->runPrimaryTransactionListenerCallbacks();

		$this->assertSame( array_fill_keys( [ 'a', 'b', 'c', 'd' ], 1 ), $ac );
		$this->assertSame( 1, $tlCalls );

		$conn1->lock( 'test_lock_' . mt_rand(), __METHOD__, 0 );
		$lb->flushPrimarySessions( __METHOD__ );
		$this->assertSame( TransactionManager::STATUS_TRX_NONE, $conn1->trxStatus() );
		$this->assertSame( TransactionManager::STATUS_TRX_NONE, $conn2->trxStatus() );
	}

	public function testForbiddenWritesNoRef() {
		// Simulate web request with DBO_TRX
		$lb = $this->newMultiServerLocalLoadBalancer( [], [ 'flags' => DBO_TRX ] );

		$dbr = $lb->getConnection( DB_REPLICA );
		$this->assertTrue( $dbr->isReadOnly(), 'replica shows as replica' );
		$this->expectException( DBReadOnlyRoleError::class );
		$dbr->newDeleteQueryBuilder()
			->deleteFrom( 'some_table' )
			->where( [ 'id' => 57634126 ] )
			->caller( __METHOD__ )
			->execute();

		// FIXME: not needed?
		$lb->closeAll( __METHOD__ );
	}

	public function testDBConnRefReadsMasterAndReplicaRoles() {
		$lb = $this->newSingleServerLocalLoadBalancer();

		$rConn = $lb->getConnection( DB_REPLICA );
		$wConn = $lb->getConnection( DB_PRIMARY );
		$wConn2 = $lb->getConnection( 0 );

		$v = [ 'value' => '1', '1' ];
		$sql = 'SELECT MAX(1) AS value';
		foreach ( [ $rConn, $wConn, $wConn2 ] as $conn ) {
			$conn->clearFlag( $conn::DBO_TRX );

			$res = $conn->query( $sql, __METHOD__ );
			$this->assertEquals( $v, $res->fetchRow() );

			$res = $conn->query( $sql, __METHOD__, $conn::QUERY_REPLICA_ROLE );
			$this->assertEquals( $v, $res->fetchRow() );
		}

		$wConn->getScopedLockAndFlush( 'key', __METHOD__, 1 );
		$wConn2->getScopedLockAndFlush( 'key2', __METHOD__, 1 );
	}

	public function testDBConnRefWritesReplicaRole() {
		$lb = $this->newSingleServerLocalLoadBalancer();

		$rConn = $lb->getConnection( DB_REPLICA );

		$this->expectException( DBReadOnlyRoleError::class );
		$rConn->query( 'DELETE FROM sometesttable WHERE 1=0' );
	}

	public function testDBConnRefWritesReplicaRoleIndex() {
		$lb = $this->newMultiServerLocalLoadBalancer();

		$rConn = $lb->getConnection( 1 );

		$this->expectException( DBReadOnlyRoleError::class );
		$rConn->query( 'DELETE FROM sometesttable WHERE 1=0' );
	}

	public function testDBConnRefWritesReplicaRoleInsert() {
		$lb = $this->newMultiServerLocalLoadBalancer();

		$rConn = $lb->getConnection( DB_REPLICA );

		$this->expectException( DBReadOnlyRoleError::class );
		$rConn->insert( 'test', [ 't' => 1 ], __METHOD__ );
	}

	public function testGetConnectionRefDefaultGroup() {
		$lb = $this->newMultiServerLocalLoadBalancer( [ 'defaultGroup' => 'vslow' ] );
		$lbWrapper = TestingAccessWrapper::newFromObject( $lb );

		$rVslow = $lb->getConnection( DB_REPLICA );
		$vslowIndexPicked = $rVslow->getLBInfo( 'serverIndex' );

		$this->assertSame( $vslowIndexPicked, $lbWrapper->getExistingReaderIndex( 'vslow' ) );
	}

	public function testGetConnectionRefUnknownDefaultGroup() {
		$lb = $this->newMultiServerLocalLoadBalancer( [ 'defaultGroup' => 'invalid' ] );

		$this->assertInstanceOf(
			IDatabase::class,
			$lb->getConnection( DB_REPLICA )
		);
	}

	public function testQueryGroupIndex() {
		$lb = $this->newMultiServerLocalLoadBalancer( [ 'defaultGroup' => false ] );
		/** @var LoadBalancer $lbWrapper */
		$lbWrapper = TestingAccessWrapper::newFromObject( $lb );

		$rGeneric = $lb->getConnection( DB_REPLICA );
		$mainIndexPicked = $rGeneric->getLBInfo( 'serverIndex' );

		$this->assertSame(
			$mainIndexPicked,
			$lbWrapper->getExistingReaderIndex( $lb::GROUP_GENERIC )
		);
		$this->assertContains( $mainIndexPicked, [ 1, 2 ] );
		for ( $i = 0; $i < 300; ++$i ) {
			$rLog = $lb->getConnection( DB_REPLICA, [] );
			$this->assertSame(
				$mainIndexPicked,
				$rLog->getLBInfo( 'serverIndex' ),
				"Main index unchanged" );
		}

		$rRC = $lb->getConnection( DB_REPLICA, [ 'foo' ] );
		$rWL = $lb->getConnection( DB_REPLICA, [ 'bar' ] );
		$rRCMaint = $lb->getMaintenanceConnectionRef( DB_REPLICA, [ 'foo' ] );
		$rWLMaint = $lb->getMaintenanceConnectionRef( DB_REPLICA, [ 'bar' ] );

		$this->assertSame( 3, $rRC->getLBInfo( 'serverIndex' ) );
		$this->assertSame( 3, $rWL->getLBInfo( 'serverIndex' ) );
		$this->assertSame( 3, $rRCMaint->getLBInfo( 'serverIndex' ) );
		$this->assertSame( 3, $rWLMaint->getLBInfo( 'serverIndex' ) );

		$rLog = $lb->getConnection( DB_REPLICA, [ 'baz', 'bar' ] );
		$logIndexPicked = $rLog->getLBInfo( 'serverIndex' );

		$this->assertSame( $logIndexPicked, $lbWrapper->getExistingReaderIndex( 'baz' ) );
		$this->assertContains( $logIndexPicked, [ 4, 5 ] );

		for ( $i = 0; $i < 300; ++$i ) {
			$rLog = $lb->getConnection( DB_REPLICA, [ 'baz', 'bar' ] );
			$this->assertSame(
				$logIndexPicked, $rLog->getLBInfo( 'serverIndex' ), "Index unchanged" );
		}

		$rVslow = $lb->getConnection( DB_REPLICA, [ 'vslow', 'baz' ] );
		$vslowIndexPicked = $rVslow->getLBInfo( 'serverIndex' );

		$this->assertSame( $vslowIndexPicked, $lbWrapper->getExistingReaderIndex( 'vslow' ) );
		$this->assertSame( 6, $vslowIndexPicked );
	}

	public function testNonZeroMasterLoad() {
		$lb = $this->newMultiServerLocalLoadBalancer( [], [ 'flags' => DBO_DEFAULT ], true );
		// Make sure that no infinite loop occurs (T226678)
		$rGeneric = $lb->getConnection( DB_REPLICA );
		$this->assertSame( ServerInfo::WRITER_INDEX, $rGeneric->getLBInfo( 'serverIndex' ) );
	}

	public function testSetDomainAliases() {
		$lb = $this->newMultiServerLocalLoadBalancer();
		$origDomain = $lb->getLocalDomainID();

		$this->assertSame( $origDomain, $lb->resolveDomainID( false ) );
		$this->assertSame( "db-prefix_", $lb->resolveDomainID( "db-prefix_" ) );

		$lb->setDomainAliases( [
			'alias-db' => 'realdb',
			'alias-db-prefix_' => 'realdb-realprefix_'
		] );

		$this->assertSame( 'realdb', $lb->resolveDomainID( 'alias-db' ) );
		$this->assertSame( "realdb-realprefix_", $lb->resolveDomainID( "alias-db-prefix_" ) );
	}

	public function testClusterName() {
		global $wgDBname;
		$chronologyProtector = $this->createMock( ChronologyProtector::class );
		$lb1 = new LoadBalancer( [
			'servers' => [ $this->makeServerConfig() ],
			'logger' => MediaWiki\Logger\LoggerFactory::getInstance( 'rdbms' ),
			'localDomain' => new DatabaseDomain( $wgDBname, null, self::dbPrefix() ),
			'chronologyProtector' => $chronologyProtector,
			'clusterName' => 'xx'
		] );
		$this->assertSame( 'xx', $lb1->getClusterName() );

		$lb2 = new LoadBalancer( [
			'servers' => [ $this->makeServerConfig() ],
			'logger' => MediaWiki\Logger\LoggerFactory::getInstance( 'rdbms' ),
			'localDomain' => new DatabaseDomain( $wgDBname, null, self::dbPrefix() ),
			'chronologyProtector' => $chronologyProtector,
			'clusterName' => null
		] );
		$this->assertSame( 'testhost', $lb2->getClusterName() );
	}
}
PK       ! GV      db/DatabaseTestHelper.phpnu Iw        <?php

use MediaWiki\Tests\Unit\Libs\Rdbms\AddQuoterMock;
use MediaWiki\Tests\Unit\Libs\Rdbms\SQLPlatformTestHelper;
use Psr\Log\NullLogger;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\Database\DatabaseFlags;
use Wikimedia\Rdbms\DatabaseDomain;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\Rdbms\QueryStatus;
use Wikimedia\Rdbms\Replication\ReplicationReporter;
use Wikimedia\Rdbms\TransactionProfiler;
use Wikimedia\RequestTimeout\RequestTimeout;

/**
 * Helper for testing the methods from the Database class
 * @since 1.22
 */
class DatabaseTestHelper extends Database {

	/**
	 * @var string __CLASS__ of the test suite,
	 * used to determine, if the function name is passed every time to query()
	 */
	protected string $testName;

	/**
	 * @var string[] Array of lastSqls passed to query(),
	 * This is an array since some methods in Database can do more than one
	 * query. Cleared when calling getLastSqls().
	 */
	protected $lastSqls = [];

	/** @var array Stack of result maps */
	protected $nextResMapQueue = [];

	/** @var array|null */
	protected $lastResMap = null;

	/**
	 * @var string[] Array of tables to be considered as existing by tableExist()
	 * Use setExistingTables() to alter.
	 */
	protected $tablesExists;

	/** @var int[] */
	protected $forcedAffectedCountQueue = [];

	public function __construct( string $testName, array $opts = [] ) {
		$params = $opts + [
			'host' => null,
			'user' => null,
			'password' => null,
			'dbname' => null,
			'schema' => null,
			'tablePrefix' => '',
			'flags' => 0,
			'cliMode' => true,
			'agent' => '',
			'serverName' => null,
			'topologyRole' => null,
			'srvCache' => new HashBagOStuff(),
			'profiler' => null,
			'trxProfiler' => new TransactionProfiler(),
			'logger' => new NullLogger(),
			'errorLogger' => static function ( Exception $e ) {
				wfWarn( get_class( $e ) . ': ' . $e->getMessage() );
			},
			'deprecationLogger' => static function ( $msg ) {
				wfWarn( $msg );
			},
			'criticalSectionProvider' =>
				RequestTimeout::singleton()->createCriticalSectionProvider( 120 )
		];
		parent::__construct( $params );

		$this->testName = $testName;
		$this->platform = new SQLPlatformTestHelper( new AddQuoterMock() );
		$this->flagsHolder = new DatabaseFlags( 0 );
		$this->replicationReporter = new ReplicationReporter(
			$params['topologyRole'],
			$params['logger'],
			$params['srvCache']
		);

		$this->currentDomain = DatabaseDomain::newUnspecified();
		$this->open( 'localhost', 'testuser', 'password', 'testdb', null, '' );
	}

	/**
	 * Returns SQL queries grouped by '; '
	 * Clear the list of queries that have been done so far.
	 * @return string
	 */
	public function getLastSqls() {
		$lastSqls = implode( '; ', $this->lastSqls );
		$this->lastSqls = [];

		return $lastSqls;
	}

	public function setExistingTables( $tablesExists ) {
		$this->tablesExists = (array)$tablesExists;
	}

	/**
	 * @param mixed $res Use an array of row arrays to set row result
	 * @param int $errno Error number
	 * @param string $error Error text
	 * @param array $options
	 *  - isKnownStatementRollbackError: Return value for isKnownStatementRollbackError()
	 */
	public function forceNextResult( $res, $errno = 0, $error = '', $options = [] ) {
		$this->nextResMapQueue[] = [
			'res' => $res,
			'errno' => $errno,
			'error' => $error
		] + $options;
	}

	protected function addSql( $sql ) {
		// clean up spaces before and after some words and the whole string
		$this->lastSqls[] = trim( preg_replace(
			'/\s{2,}(?=FROM|WHERE|GROUP BY|ORDER BY|LIMIT)|(?<=SELECT|INSERT|UPDATE)\s{2,}/',
			' ', $sql
		) );
	}

	protected function checkFunctionName( $fname ) {
		if ( $fname === 'Wikimedia\\Rdbms\\Database::close' ) {
			return; // no $fname parameter
		}

		// Handle some internal calls from the Database class
		$check = $fname;
		if ( preg_match(
			'/^Wikimedia\\\\Rdbms\\\\Database::(?:query|beginIfImplied) \((.+)\)$/',
			$fname,
			$m
		) ) {
			$check = $m[1];
		}

		if ( !str_starts_with( $check, $this->testName ) ) {
			throw new LogicException( 'function name does not start with test class. ' .
				$fname . ' vs. ' . $this->testName . '. ' .
				'Please provide __METHOD__ to database methods.' );
		}
	}

	public function strencode( $s ) {
		// Choose apos to avoid handling of escaping double quotes in quoted text
		return str_replace( "'", "\'", $s );
	}

	public function query( $sql, $fname = '', $flags = 0 ) {
		$this->checkFunctionName( $fname );

		return parent::query( $sql, $fname, $flags );
	}

	public function tableExists( $table, $fname = __METHOD__ ) {
		[ $db, $pt ] = $this->platform->getDatabaseAndTableIdentifier( $table );
		if ( isset( $this->sessionTempTables[$db][$pt] ) ) {
			return true; // already known to exist
		}

		$this->checkFunctionName( $fname );

		return in_array( $table, (array)$this->tablesExists );
	}

	public function getType() {
		return 'test';
	}

	public function open( $server, $user, $password, $db, $schema, $tablePrefix ) {
		$this->conn = (object)[ 'test' ];

		return true;
	}

	protected function lastInsertId() {
		return -1;
	}

	public function lastErrno() {
		return $this->lastResMap ? $this->lastResMap['errno'] : -1;
	}

	public function lastError() {
		return $this->lastResMap ? $this->lastResMap['error'] : 'test';
	}

	protected function isKnownStatementRollbackError( $errno ) {
		return ( $this->lastResMap['errno'] ?? 0 ) === $errno
			? ( $this->lastResMap['isKnownStatementRollbackError'] ?? false )
			: false;
	}

	public function fieldInfo( $table, $field ) {
		return false;
	}

	public function indexInfo( $table, $index, $fname = 'Database::indexInfo' ) {
		return false;
	}

	public function getSoftwareLink() {
		return 'test';
	}

	public function getServerVersion() {
		return 'test';
	}

	public function getServerInfo() {
		return 'test';
	}

	public function ping( &$rtt = null ) {
		$rtt = 0.0;
		return true;
	}

	protected function closeConnection() {
		return true;
	}

	public function setNextQueryAffectedRowCounts( array $counts ) {
		$this->forcedAffectedCountQueue = $counts;
	}

	protected function doSingleStatementQuery( string $sql ): QueryStatus {
		$sql = preg_replace( '< /\* .+?  \*/>', '', $sql );
		$this->addSql( $sql );

		if ( $this->nextResMapQueue ) {
			$this->lastResMap = array_shift( $this->nextResMapQueue );
			if ( !$this->lastResMap['errno'] && $this->forcedAffectedCountQueue ) {
				$count = array_shift( $this->forcedAffectedCountQueue );
				$this->lastQueryAffectedRows = $count;
			}
		} else {
			$this->lastResMap = [ 'res' => [], 'errno' => 0, 'error' => '' ];
		}
		$res = $this->lastResMap['res'];

		return new QueryStatus(
			is_bool( $res ) ? $res : new FakeResultWrapper( $res ),
			$this->affectedRows(),
			$this->lastError(),
			$this->lastErrno()
		);
	}
}
PK       ! z    )  linkeddata/PageDataRequestHandlerTest.phpnu Iw        <?php

use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\LinkedData\PageDataRequestHandler;
use MediaWiki\Output\OutputPage;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\FauxResponse;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleFactory;

/**
 * @covers \MediaWiki\LinkedData\PageDataRequestHandler
 * @group PageData
 */
class PageDataRequestHandlerTest extends \MediaWikiLangTestCase {

	/**
	 * @var Title
	 */
	private $interfaceTitle;

	/**
	 * @var int
	 */
	private $obLevel;

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

		$this->interfaceTitle = Title::newFromText( __CLASS__ );
		// Force the content model to avoid DB queries.
		$this->interfaceTitle->setContentModel( CONTENT_MODEL_WIKITEXT );
		$this->obLevel = ob_get_level();
	}

	protected function tearDown(): void {
		$obLevel = ob_get_level();

		while ( ob_get_level() > $this->obLevel ) {
			ob_end_clean();
		}

		if ( $obLevel !== $this->obLevel ) {
			$this->fail( "Test changed output buffer level: was {$this->obLevel}" .
				"before test, but $obLevel after test."
			);
		}

		parent::tearDown();
	}

	/**
	 * @return PageDataRequestHandler
	 */
	protected function newHandler() {
		return new PageDataRequestHandler();
	}

	/**
	 * @param array $params
	 * @param string[] $headers
	 *
	 * @return OutputPage
	 */
	protected function makeOutputPage( array $params, array $headers ) {
		// construct request
		$request = new FauxRequest( $params );
		$request->response()->header( 'Status: 200 OK', true, 200 ); // init/reset

		foreach ( $headers as $name => $value ) {
			$request->setHeader( strtoupper( $name ), $value );
		}

		// construct Context and OutputPage
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setRequest( $request );

		$output = new OutputPage( $context );
		$output->setTitle( $this->interfaceTitle );
		$context->setOutput( $output );

		return $output;
	}

	public static function handleRequestProvider() {
		$cases = [];

		$cases[] = [ '', [], [], 'Invalid title', 400 ];

		$cases[] = [
			'',
			[ 'target' => 'Helsinki' ],
			[],
			'',
			303,
			[ 'Location' => '?title=Helsinki&action=raw' ]
		];

		$subpageCases = [];
		foreach ( $cases as $c ) {
			$case = $c;
			$case[0] = 'main/';

			if ( isset( $case[1]['target'] ) ) {
				$case[0] .= $case[1]['target'];
				unset( $case[1]['target'] );
			}

			$subpageCases[] = $case;
		}

		$cases = array_merge( $cases, $subpageCases );

		$cases[] = [
			'',
			[ 'target' => 'Helsinki' ],
			[ 'Accept' => 'text/HTML' ],
			'',
			303,
			[ 'Location' => '/wiki/Helsinki' ]
		];

		$cases[] = [
			'',
			[
				'target' => 'Helsinki',
				'revision' => '4242',
			],
			[ 'Accept' => 'text/HTML' ],
			'',
			303,
			[ 'Location' => '?title=Helsinki&oldid=4242' ]
		];

		$cases[] = [
			'/Helsinki',
			[],
			[],
			'',
			303,
			[ 'Location' => '?title=Helsinki&action=raw' ]
		];

		// #31: /Q5 with "Accept: text/foobar" triggers a 406
		$cases[] = [
			'main/Helsinki',
			[],
			[ 'Accept' => 'text/foobar' ],
			'No matching format found',
			406,
		];

		$cases[] = [
			'no slash',
			[],
			[ 'Accept' => 'text/HTML' ],
			'Invalid title',
			400,
		];

		$cases[] = [
			'main',
			[],
			[ 'Accept' => 'text/HTML' ],
			'Invalid title',
			400,
		];

		$cases[] = [
			'xyz/Helsinki',
			[],
			[ 'Accept' => 'text/HTML' ],
			'Invalid title',
			400,
		];

		$cases[] = [
			'main/Helsinki',
			[],
			[ 'Accept' => 'text/HTML' ],
			'',
			303,
			[ 'Location' => '/wiki/Helsinki' ]
		];

		$cases[] = [
			'/Helsinki',
			[],
			[ 'Accept' => 'text/HTML' ],
			'',
			303,
			[ 'Location' => '/wiki/Helsinki' ]
		];

		$cases[] = [
			'main/AC/DC',
			[],
			[ 'Accept' => 'text/HTML' ],
			'',
			303,
			[ 'Location' => '/wiki/AC/DC' ]
		];

		return $cases;
	}

	/**
	 * @dataProvider handleRequestProvider
	 *
	 * @param string $subpage The subpage to request (or '')
	 * @param array $params Request parameters
	 * @param array $headers Request headers
	 * @param string $expectedOutput
	 * @param int $expectedStatusCode Expected HTTP status code.
	 * @param string[] $expectedHeaders Expected HTTP response headers.
	 */
	public function testHandleRequest(
		$subpage,
		array $params,
		array $headers,
		$expectedOutput = '',
		$expectedStatusCode = 200,
		array $expectedHeaders = []
	) {
		$titleFactory = $this->createMock( TitleFactory::class );
		$titleFactory->method( 'newFromTextThrow' )->willReturnCallback( static function ( $text, $ns ) {
			// Force the content model to avoid DB queries.
			$ret = Title::newFromTextThrow( $text, $ns );
			$ret->setContentModel( CONTENT_MODEL_WIKITEXT );
			return $ret;
		} );
		$this->setService( 'TitleFactory', $titleFactory );
		$output = $this->makeOutputPage( $params, $headers );
		$request = $output->getRequest();

		/** @var FauxResponse $response */
		$response = $request->response();

		// construct handler
		$handler = $this->newHandler();

		try {
			ob_start();
			$handler->handleRequest( $subpage, $request, $output );

			if ( $output->getRedirect() !== '' ) {
				// hack to apply redirect to web response
				$output->output();
			}

			$text = ob_get_clean();

			$this->assertEquals( $expectedStatusCode, $response->getStatusCode(), 'status code' );
			$this->assertSame( $expectedOutput, $text, 'output' );

			foreach ( $expectedHeaders as $name => $exp ) {
				$value = $response->getHeader( $name );
				$this->assertNotNull( $value, "header: $name" );
				$this->assertIsString( $value, "header: $name" );
				$this->assertStringEndsWith( $exp, $value, "header: $name" );
			}
		} catch ( HttpError $e ) {
			ob_end_clean();
			$this->assertEquals( $expectedStatusCode, $e->getStatusCode(), 'status code' );
			$this->assertStringContainsString( $expectedOutput, $e->getHTML(), 'error output' );
		}

		// We always set "Access-Control-Allow-Origin: *"
		$this->assertSame( '*', $response->getHeader( 'Access-Control-Allow-Origin' ) );
	}

	public static function provideHttpContentNegotiation() {
		$helsinki = Title::makeTitle( NS_MAIN, 'Helsinki' );
		// Force the content model to avoid DB queries.
		$helsinki->setContentModel( CONTENT_MODEL_WIKITEXT );
		return [
			'Accept Header of HTML' => [
				$helsinki,
				[ 'ACCEPT' => 'text/html' ], // headers
				'Helsinki'
			],
			'Accept Header without weights' => [
				$helsinki,
				[ 'ACCEPT' => '*/*, text/html, text/x-wiki' ],
				'Helsinki&action=raw'
			],
			'Accept Header with weights' => [
				$helsinki,
				[ 'ACCEPT' => 'text/*; q=0.5, text/json; q=0.7, application/rdf+xml; q=0.8' ],
				'Helsinki&action=raw'
			],
			'Accept Header accepting evertyhing and HTML' => [
				$helsinki,
				[ 'ACCEPT' => 'text/html, */*' ],
				'Helsinki&action=raw'
			],
			'No Accept Header' => [
				$helsinki,
				[],
				'Helsinki&action=raw'
			],
		];
	}

	/**
	 * @dataProvider provideHttpContentNegotiation
	 *
	 * @param Title $title
	 * @param array $headers Request headers
	 * @param string $expectedRedirectSuffix Expected suffix of the HTTP Location header.
	 */
	public function testHttpContentNegotiation(
		Title $title,
		array $headers,
		$expectedRedirectSuffix
	) {
		/** @var FauxResponse $response */
		$output = $this->makeOutputPage( [], $headers );
		$request = $output->getRequest();

		$handler = $this->newHandler();
		$handler->httpContentNegotiation( $request, $output, $title );

		$this->assertStringEndsWith(
			$expectedRedirectSuffix,
			$output->getRedirect(),
			'redirect target'
		);
	}
}
PK       ! `ΏR  R  !  Revision/RenderedRevisionTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Revision;

use InvalidArgumentException;
use LogicException;
use MediaWiki\Content\Content;
use MediaWiki\Content\Renderer\ContentRenderer;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageReference;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\MutableRevisionSlots;
use MediaWiki\Revision\RenderedRevision;
use MediaWiki\Revision\RevisionArchiveRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Revision\RevisionStoreRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Revision\SuppressedDataException;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Revision\RenderedRevision
 * @group Database
 */
class RenderedRevisionTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;

	/** @var callable */
	private $combinerCallback;

	/** @var ContentRenderer */
	private $contentRenderer;

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

		$this->combinerCallback = function ( RenderedRevision $rr, array $hints = [] ) {
			return $this->combineOutput( $rr, $hints );
		};

		$this->contentRenderer = $this->getServiceContainer()->getContentRenderer();
	}

	private function combineOutput( RenderedRevision $rrev, array $hints = [] ) {
		// NOTE: the is a slightly simplified version of RevisionRenderer::combineSlotOutput

		$withHtml = $hints['generate-html'] ?? true;

		$revision = $rrev->getRevision();
		$slots = $revision->getSlots()->getSlots();

		$combinedOutput = new ParserOutput( null );
		$slotOutput = [];
		foreach ( $slots as $role => $slot ) {
			$out = $rrev->getSlotParserOutput( $role, $hints );
			$slotOutput[$role] = $out;

			$combinedOutput->mergeInternalMetaDataFrom( $out );
			$combinedOutput->mergeTrackingMetaDataFrom( $out );
		}

		if ( $withHtml ) {
			$html = '';
			/** @var ParserOutput $out */
			foreach ( $slotOutput as $role => $out ) {

				if ( $html !== '' ) {
					// skip header for the first slot
					$html .= "(($role))";
				}

				$html .= $out->getRawText();
				$combinedOutput->mergeHtmlMetaDataFrom( $out );
			}

			$combinedOutput->setRawText( $html );
		}

		return $combinedOutput;
	}

	/**
	 * @param string $class
	 * @param PageIdentity $page
	 * @param null|int $id
	 * @param int $visibility
	 * @param Content[]|null $content
	 * @return RevisionRecord
	 */
	private function getMockRevision(
		$class,
		$page,
		$id = null,
		$visibility = 0,
		?array $content = null
	) {
		$frank = new UserIdentityValue( 9, 'Frank' );

		if ( !$content ) {
			$text = "";
			$text .= "* page:{{PAGENAME}}!\n";
			$text .= "* rev:{{REVISIONID}}!\n";
			$text .= "* user:{{REVISIONUSER}}!\n";
			$text .= "* time:{{REVISIONTIMESTAMP}}!\n";
			$text .= "* [[Link It]]\n";

			$content = [ SlotRecord::MAIN => new WikitextContent( $text ) ];
		}

		/** @var MockObject|RevisionRecord $mock */
		$mock = $this->getMockBuilder( $class )
			->disableOriginalConstructor()
			->onlyMethods( [
				'getId',
				'getPageId',
				'getPageAsLinkTarget',
				'getPage',
				'getUser',
				'getVisibility',
				'getTimestamp',
			] )->getMock();

		$mock->method( 'getId' )->willReturn( $id );
		$mock->method( 'getPageId' )->willReturn( $page->getId() );
		$mock->method( 'getPageAsLinkTarget' )->willReturn( TitleValue::castPageToLinkTarget( $page ) );
		$mock->method( 'getPage' )->willReturn( $page );
		$mock->method( 'getUser' )->willReturn( $frank );
		$mock->method( 'getVisibility' )->willReturn( $visibility );
		$mock->method( 'getTimestamp' )->willReturn( '20180101000003' );

		/** @var object $mockAccess */
		$mockAccess = TestingAccessWrapper::newFromObject( $mock );
		$mockAccess->mSlots = new MutableRevisionSlots();

		foreach ( $content as $role => $cnt ) {
			$mockAccess->mSlots->setContent( $role, $cnt );
		}

		return $mock;
	}

	public function testConstructorInvalidArguments() {
		$rev = $this->getMockRevision(
			RevisionStoreRecord::class,
			PageIdentityValue::localIdentity( 0, NS_MAIN, __METHOD__ )
		);
		$options = ParserOptions::newFromAnon();

		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage(
			'User must be specified when setting audience to FOR_THIS_USER'
		);
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback,
			RevisionRecord::FOR_THIS_USER
		);
	}

	public function testGetRevisionParserOutput_new() {
		$rev = $this->getMockRevision(
			RevisionStoreRecord::class,
			PageIdentityValue::localIdentity( 0, NS_MAIN, 'RenderTestPage' )
		);

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback
		);

		$this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );

		$this->assertSame( $rev, $rr->getRevision() );
		$this->assertSame( $options, $rr->getOptions() );

		$html = $rr->getRevisionParserOutput()->getRawText();

		$this->assertStringContainsString( 'page:RenderTestPage!', $html );
		$this->assertStringContainsString( 'user:Frank!', $html );
		$this->assertStringContainsString( 'time:20180101000003!', $html );
	}

	public function testGetRevisionParserOutput_previewWithSelfTransclusion() {
		$title = PageIdentityValue::localIdentity( 0, NS_MAIN, __METHOD__ );
		$name = $this->getServiceContainer()->getTitleFormatter()->getPrefixedText( $title );

		$text = "(ONE)<includeonly>(TWO)</includeonly><noinclude>#{{:$name}}#</noinclude>";

		$content = [
			SlotRecord::MAIN => new WikitextContent( $text )
		];

		$rev = $this->getMockRevision( RevisionStoreRecord::class, $title, null, 0, $content );

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback
		);

		$html = $rr->getRevisionParserOutput()->getRawText();
		$this->assertStringContainsString( '(ONE)#(ONE)(TWO)#', $html );
	}

	public function testGetRevisionParserOutput_current() {
		$rev = $this->getMockRevision(
			RevisionStoreRecord::class,
			PageIdentityValue::localIdentity( 0, NS_MAIN, 'RenderTestPage' ),
			21
		);

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback
		);

		$this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );

		$this->assertSame( $rev, $rr->getRevision() );
		$this->assertSame( $options, $rr->getOptions() );

		$html = $rr->getRevisionParserOutput()->getRawText();

		$this->assertStringContainsString( 'page:RenderTestPage!', $html );
		$this->assertStringContainsString( 'rev:21!', $html );
		$this->assertStringContainsString( 'user:Frank!', $html );
		$this->assertStringContainsString( 'time:20180101000003!', $html );

		$this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getRawText() );
	}

	public function testGetRevisionParserOutput_old() {
		$rev = $this->getMockRevision(
			RevisionStoreRecord::class,
			PageIdentityValue::localIdentity( 7, NS_MAIN, 'RenderTestPage' ),
			11
		);

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback
		);

		$this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );

		$this->assertSame( $rev, $rr->getRevision() );
		$this->assertSame( $options, $rr->getOptions() );

		$html = $rr->getRevisionParserOutput()->getRawText();

		$this->assertStringContainsString( 'page:RenderTestPage!', $html );
		$this->assertStringContainsString( 'rev:11!', $html );
		$this->assertStringContainsString( 'user:Frank!', $html );
		$this->assertStringContainsString( 'time:20180101000003!', $html );

		$this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getRawText() );
	}

	public function testGetRevisionParserOutput_archive() {
		$rev = $this->getMockRevision(
			RevisionArchiveRecord::class,
			PageIdentityValue::localIdentity( 7, NS_MAIN, 'RenderTestPage' ),
			11
		);

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback,
			RevisionRecord::RAW
		);

		$this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );

		$this->assertSame( $rev, $rr->getRevision() );
		$this->assertSame( $options, $rr->getOptions() );

		$html = $rr->getRevisionParserOutput()->getRawText();

		$this->assertStringContainsString( 'page:RenderTestPage!', $html );
		$this->assertStringContainsString( 'rev:11!', $html );
		$this->assertStringContainsString( 'user:Frank!', $html );
		$this->assertStringContainsString( 'time:20180101000003!', $html );

		$this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getRawText() );
	}

	public function testGetRevisionParserOutput_suppressed() {
		$rev = $this->getMockRevision(
			RevisionStoreRecord::class,
			PageIdentityValue::localIdentity( 7, NS_MAIN, 'RenderTestPage' ),
			11,
			RevisionRecord::DELETED_TEXT
		);

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback
		);

		$this->expectException( SuppressedDataException::class );
		$rr->getRevisionParserOutput();
	}

	public function testGetRevisionParserOutput_privileged() {
		$rev = $this->getMockRevision(
			RevisionStoreRecord::class,
			PageIdentityValue::localIdentity( 7, NS_MAIN, 'RenderTestPage' ),
			11,
			RevisionRecord::DELETED_TEXT
		);

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback,
			RevisionRecord::FOR_THIS_USER,
			$this->mockRegisteredUltimateAuthority()
		);

		$this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );

		$this->assertSame( $rev, $rr->getRevision() );
		$this->assertSame( $options, $rr->getOptions() );

		$html = $rr->getRevisionParserOutput()->getRawText();

		// Suppressed content should be visible for sysops
		$this->assertStringContainsString( 'page:RenderTestPage!', $html );
		$this->assertStringContainsString( 'rev:11!', $html );
		$this->assertStringContainsString( 'user:Frank!', $html );
		$this->assertStringContainsString( 'time:20180101000003!', $html );

		$this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getRawText() );
	}

	public function testGetRevisionParserOutput_raw() {
		$rev = $this->getMockRevision(
			RevisionStoreRecord::class,
			PageIdentityValue::localIdentity( 7, NS_MAIN, 'RenderTestPage' ),
			11,
			RevisionRecord::DELETED_TEXT
		);

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback,
			RevisionRecord::RAW
		);

		$this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );

		$this->assertSame( $rev, $rr->getRevision() );
		$this->assertSame( $options, $rr->getOptions() );

		$html = $rr->getRevisionParserOutput()->getRawText();

		// Suppressed content should be visible for sysops
		$this->assertStringContainsString( 'page:RenderTestPage!', $html );
		$this->assertStringContainsString( 'rev:11!', $html );
		$this->assertStringContainsString( 'user:Frank!', $html );
		$this->assertStringContainsString( 'time:20180101000003!', $html );

		$this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getRawText() );
	}

	public function testGetRevisionParserOutput_multi() {
		$content = [
			SlotRecord::MAIN => new WikitextContent( '[[Kittens]]' ),
			'aux' => new WikitextContent( '[[Goats]]' ),
		];

		$rev = $this->getMockRevision(
			RevisionStoreRecord::class,
			PageIdentityValue::localIdentity( 7, NS_MAIN, 'RenderTestPage' ),
			11,
			0,
			$content );

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback
		);

		$combinedOutput = $rr->getRevisionParserOutput();
		$mainOutput = $rr->getSlotParserOutput( SlotRecord::MAIN );
		$auxOutput = $rr->getSlotParserOutput( 'aux' );

		$combinedHtml = $combinedOutput->getRawText();
		$mainHtml = $mainOutput->getRawText();
		$auxHtml = $auxOutput->getRawText();

		$this->assertStringContainsString( 'Kittens', $mainHtml );
		$this->assertStringContainsString( 'Goats', $auxHtml );
		$this->assertStringNotContainsString( 'Goats', $mainHtml );
		$this->assertStringNotContainsString( 'Kittens', $auxHtml );
		$this->assertStringContainsString( 'Kittens', $combinedHtml );
		$this->assertStringContainsString( 'Goats', $combinedHtml );
		$this->assertStringContainsString( 'aux', $combinedHtml, 'slot section header' );

		$combinedLinks = $combinedOutput->getLinks();
		$mainLinks = $mainOutput->getLinks();
		$auxLinks = $auxOutput->getLinks();
		$this->assertTrue( isset( $combinedLinks[NS_MAIN]['Kittens'] ), 'links from main slot' );
		$this->assertTrue( isset( $combinedLinks[NS_MAIN]['Goats'] ), 'links from aux slot' );
		$this->assertFalse( isset( $mainLinks[NS_MAIN]['Goats'] ), 'no aux links in main' );
		$this->assertFalse( isset( $auxLinks[NS_MAIN]['Kittens'] ), 'no main links in aux' );
	}

	public function testGetRevisionParserOutput_incompleteNoId() {
		$rev = new MutableRevisionRecord(
			PageIdentityValue::localIdentity( 7, NS_MAIN, 'RenderTestPage' )
		);

		$text = "";
		$text .= "* page:{{PAGENAME}}!\n";
		$text .= "* rev:{{REVISIONID}}!\n";
		$text .= "* user:{{REVISIONUSER}}!\n";
		$text .= "* time:{{REVISIONTIMESTAMP}}!\n";

		$rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback
		);

		// MutableRevisionRecord without ID should be used by the parser.
		// USeful for fake
		$html = $rr->getRevisionParserOutput()->getRawText();

		$this->assertStringContainsString( 'page:RenderTestPage!', $html );
		$this->assertStringContainsString( 'rev:!', $html );
		$this->assertStringContainsString( 'user:!', $html );
		// Per parser docs, if revision object does not contain a timestamp
		// then parser uses current time. Hence don't expect time to be
		// empty or a specific time.
		$this->assertStringContainsString( 'time:2', $html );
	}

	public function testGetRevisionParserOutput_incompleteWithId() {
		$page = PageIdentityValue::localIdentity( 7, NS_MAIN, 'RenderTestPage' );
		$rev = new MutableRevisionRecord( $page );
		$rev->setId( 21 );

		$text = "";
		$text .= "* page:{{PAGENAME}}!\n";
		$text .= "* rev:{{REVISIONID}}!\n";
		$text .= "* user:{{REVISIONUSER}}!\n";
		$text .= "* time:{{REVISIONTIMESTAMP}}!\n";

		$rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );

		$actualRevision = $this->getMockRevision(
			RevisionStoreRecord::class,
			$page,
			21,
			RevisionRecord::DELETED_TEXT
		);

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback
		);

		// MutableRevisionRecord with ID should not be used by the parser,
		// revision should be loaded instead!
		$revisionStore = $this->createMock( RevisionStore::class );

		$revisionStore->expects( $this->once() )
			->method( 'getKnownCurrentRevision' )
			->willReturn( $actualRevision );

		$this->setService( 'RevisionStore', $revisionStore );

		$html = $rr->getRevisionParserOutput()->getRawText();

		$this->assertStringContainsString( 'page:RenderTestPage!', $html );
		$this->assertStringContainsString( 'rev:21!', $html );
		$this->assertStringContainsString( 'user:Frank!', $html );
		$this->assertStringContainsString( 'time:20180101000003!', $html );
	}

	public function testSetRevisionParserOutput() {
		$rev = $this->getMockRevision(
			RevisionStoreRecord::class,
			PageIdentityValue::localIdentity( 3, NS_MAIN, 'RenderTestPage' )
		);

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback
		);

		$output = new ParserOutput( 'Kittens' );
		$rr->setRevisionParserOutput( $output );

		$this->assertSame( $output, $rr->getRevisionParserOutput() );
		$this->assertSame( 'Kittens', $rr->getRevisionParserOutput()->getRawText() );

		$this->assertSame( $output, $rr->getSlotParserOutput( SlotRecord::MAIN ) );
		$this->assertSame( 'Kittens', $rr->getSlotParserOutput( SlotRecord::MAIN )
			->getRawText() );
	}

	public function testNoHtml() {
		$content = new WikitextContent( 'whatever' );

		/** @var MockObject|ContentRenderer $mockContentRenderer */
		$mockContentRenderer = $this->getMockBuilder( ContentRenderer::class )
			->onlyMethods( [ 'getParserOutput' ] )
			->disableOriginalConstructor()
			->getMock();
		$mockContentRenderer->method( 'getParserOutput' )
			->willReturnCallback( function ( Content $content, PageReference $page, $revId = null,
				?ParserOptions $options = null, $hints = []
			) {
				if ( is_bool( $hints ) ) {
					$hints = [ 'generate-html' => $hints ];
				}
				$generateHtml = $hints['generate-html'] ?? true;
				if ( !$generateHtml ) {
					return new ParserOutput( null );
				} else {
					$this->fail( 'Should not be called with $generateHtml == true' );
					return null; // never happens, make analyzer happy
				}
			} );

		$rev = new MutableRevisionRecord(
			PageIdentityValue::localIdentity( 7, NS_MAIN, 'RenderTestPage' )
		);
		$rev->setContent( SlotRecord::MAIN, $content );
		$rev->setContent( 'aux', $content );

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$mockContentRenderer,
			$this->combinerCallback
		);

		$output = $rr->getSlotParserOutput( SlotRecord::MAIN, [ 'generate-html' => false ] );
		$this->assertFalse( $output->hasText(), 'hasText' );

		$output = $rr->getRevisionParserOutput( [ 'generate-html' => false ] );
		$this->assertFalse( $output->hasText(), 'hasText' );
	}

	public function testUpdateRevision() {
		$page = PageIdentityValue::localIdentity( 7, NS_MAIN, 'RenderTestPage' );
		$rev = new MutableRevisionRecord( $page );

		$text = "";
		$text .= "* page:{{PAGENAME}}!\n";
		$text .= "* rev:{{REVISIONID}}!\n";
		$text .= "* user:{{REVISIONUSER}}!\n";
		$text .= "* time:{{REVISIONTIMESTAMP}}!\n";

		$rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );
		$rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback
		);

		$firstOutput = $rr->getRevisionParserOutput();
		$mainOutput = $rr->getSlotParserOutput( SlotRecord::MAIN );
		$auxOutput = $rr->getSlotParserOutput( 'aux' );

		// emulate a saved revision
		$savedRev = new MutableRevisionRecord( $page );
		$savedRev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );
		$savedRev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
		$savedRev->setId( 23 ); // saved, new
		$savedRev->setUser( new UserIdentityValue( 9, 'Frank' ) );
		$savedRev->setTimestamp( '20180101000003' );

		$rr->updateRevision( $savedRev );

		$this->assertNotSame( $mainOutput, $rr->getSlotParserOutput( SlotRecord::MAIN ), 'Reset main' );
		$this->assertSame( $auxOutput, $rr->getSlotParserOutput( 'aux' ), 'Keep aux' );

		$updatedOutput = $rr->getRevisionParserOutput();
		$html = $updatedOutput->getRawText();

		$this->assertNotSame( $firstOutput, $updatedOutput, 'Reset merged' );
		$this->assertStringContainsString( 'page:RenderTestPage!', $html );
		$this->assertStringContainsString( 'rev:23!', $html );
		$this->assertStringContainsString( 'user:Frank!', $html );
		$this->assertStringContainsString( 'time:20180101000003!', $html );
		$this->assertStringContainsString( 'Goats', $html );

		$rr->updateRevision( $savedRev ); // should do nothing
		$this->assertSame( $updatedOutput, $rr->getRevisionParserOutput(), 'no more reset needed' );
	}

	public function testUpdateRevision_revIdSet() {
		$page = PageIdentityValue::localIdentity( 7, NS_MAIN, 'RenderTestPage' );
		$rev = new MutableRevisionRecord( $page );
		$rev->setId( 123 );
		$rev->setContent( SlotRecord::MAIN, new WikitextContent( 'FooBar' ) );

		$options = ParserOptions::newFromAnon();
		$rr = new RenderedRevision(
			$rev,
			$options,
			$this->contentRenderer,
			$this->combinerCallback
		);

		$newRev = new MutableRevisionRecord( $page );
		$newRev->setId( 321 ); // Different
		$newRev->setContent( SlotRecord::MAIN, new WikitextContent( 'FooBar' ) );

		$this->expectException( LogicException::class );
		$this->expectExceptionMessage(
			'RenderedRevision already has a revision with ID 123, ' .
			'can\'t update to revision with ID 321'
		);
		$rr->updateRevision( $newRev );
	}

}
PK       ! ʳ     Revision/RevisionStoreDbTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Revision;

use Exception;
use InvalidArgumentException;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\Content;
use MediaWiki\Content\FallbackContent;
use MediaWiki\Content\TextContent;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Revision\IncompleteRevisionException;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionAccessException;
use MediaWiki\Revision\RevisionArchiveRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionSlots;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Revision\RevisionStoreRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Revision\SlotRoleRegistry;
use MediaWiki\Storage\BlobStore;
use MediaWiki\Storage\SqlBlobStore;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleFactory;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use StatusValue;
use Wikimedia\Assert\PreconditionException;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\DatabaseDomain;
use Wikimedia\Rdbms\DatabaseSqlite;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\Rdbms\IDBAccessObject;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Rdbms\LoadBalancer;
use Wikimedia\Rdbms\TransactionProfiler;
use Wikimedia\TestingAccessWrapper;
use WikiPage;

/**
 * @group Database
 * @group RevisionStore
 * @covers \MediaWiki\Revision\RevisionStore
 */
class RevisionStoreDbTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;
	use TempUserTestTrait;

	/**
	 * @var Title
	 */
	private $testPageTitle;

	/**
	 * @var WikiPage
	 */
	private $testPage;

	/**
	 * @return Title
	 */
	protected function getTestPageTitle() {
		if ( $this->testPageTitle ) {
			return $this->testPageTitle;
		}

		$this->testPageTitle = Title::newFromText( 'TestPage-' . __CLASS__ );
		return $this->testPageTitle;
	}

	/**
	 * @param string|null $pageTitle whether to force-create a new page
	 * @return WikiPage
	 */
	protected function getTestPage( $pageTitle = null ) {
		if ( $pageTitle === null && $this->testPage ) {
			return $this->testPage;
		}

		$title = $pageTitle === null ? $this->getTestPageTitle() : Title::newFromText( $pageTitle );
		$page = $this->getExistingTestPage( $title );

		if ( $pageTitle === null ) {
			$this->testPage = $page;
		}
		return $page;
	}

	/**
	 * @param array $server
	 * @return LoadBalancer|MockObject
	 */
	private function getLoadBalancerMock( array $server ) {
		$domain = new DatabaseDomain( $server['dbname'], null, $server['tablePrefix'] );

		$lb = $this->getMockBuilder( LoadBalancer::class )
			->onlyMethods( [ 'reallyOpenConnection' ] )
			->setConstructorArgs( [
				[ 'servers' => [ $server ], 'localDomain' => $domain ]
			] )
			->getMock();

		$lb->method( 'reallyOpenConnection' )->willReturnCallback(
			function ( $i, DatabaseDomain $domain, array $lbInfo ) use ( $server ) {
				$conn = $this->getDatabaseMock( $server );
				foreach ( $lbInfo as $k => $v ) {
					$conn->setLBInfo( $k, $v );
				}

				return $conn;
			}
		);

		return $lb;
	}

	/**
	 * @param array $params
	 * @return Database|MockObject
	 */
	private function getDatabaseMock( array $params ) {
		$db = $this->getMockBuilder( DatabaseSqlite::class )
			->onlyMethods( [
				'select',
				'doSingleStatementQuery',
				'open',
				'closeConnection',
				'isOpen'
			] )->setConstructorArgs( [ $params ] )
			->getMock();

		$db->method( 'select' )->willReturn( new FakeResultWrapper( [] ) );
		$db->method( 'isOpen' )->willReturn( true );

		return $db;
	}

	public static function provideDomainCheck() {
		yield [ false, 'test', '' ];
		yield [ 'test', 'test', '' ];

		yield [ false, 'test', 'foo_' ];
		yield [ 'test-foo_', 'test', 'foo_' ];

		yield [ false, 'dash-test', '' ];
		yield [ 'dash?htest', 'dash-test', '' ];

		yield [ false, 'underscore_test', 'foo_' ];
		yield [ 'underscore_test-foo_', 'underscore_test', 'foo_' ];
	}

	/**
	 * @dataProvider provideDomainCheck
	 */
	public function testDomainCheck( $dbDomain, $dbName, $dbPrefix ) {
		$this->overrideConfigValues(
			[
				MainConfigNames::DBname => $dbName,
				MainConfigNames::DBprefix => $dbPrefix,
			]
		);

		$loadBalancer = $this->getLoadBalancerMock(
			[
				'host' => '*dummy*',
				'dbDirectory' => '*dummy*',
				'user' => 'test',
				'password' => 'test',
				'flags' => 0,
				'variables' => [],
				'schema' => '',
				'cliMode' => true,
				'topologyRole' => Database::ROLE_STREAMING_MASTER,
				'agent' => '',
				'serverName' => '*dummy*',
				'load' => 100,
				'srvCache' => new HashBagOStuff(),
				'profiler' => null,
				'trxProfiler' => new TransactionProfiler(),
				'errorLogger' => static function () {
				},
				'deprecationLogger' => static function () {
				},
				'type' => 'test',
				'dbname' => $dbName,
				'tablePrefix' => $dbPrefix,
			]
		);
		$db = $loadBalancer->getConnection( DB_REPLICA );

		/** @var SqlBlobStore $blobStore */
		$blobStore = $this->createMock( SqlBlobStore::class );

		$store = new RevisionStore(
			$loadBalancer,
			$blobStore,
			new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ),
			new HashBagOStuff(),
			$this->getServiceContainer()->getCommentStore(),
			$this->getServiceContainer()->getContentModelStore(),
			$this->getServiceContainer()->getSlotRoleStore(),
			$this->getServiceContainer()->getSlotRoleRegistry(),
			$this->getServiceContainer()->getActorStoreFactory()->getActorStore( $dbDomain ),
			$this->getServiceContainer()->getContentHandlerFactory(),
			$this->getServiceContainer()->getPageStore(),
			$this->getServiceContainer()->getTitleFactory(),
			$this->getServiceContainer()->getHookContainer(),
			$dbDomain
		);

		$count = $store->countRevisionsByPageId( $db, 0 );

		// Dummy check to make PhpUnit happy. We are really only interested in
		// countRevisionsByPageId not failing due to the DB domain check.
		$this->assertSame( 0, $count );
	}

	protected function assertLinkTargetsEqual( LinkTarget $l1, LinkTarget $l2 ) {
		$this->assertEquals( $l1->getDBkey(), $l2->getDBkey() );
		$this->assertEquals( $l1->getNamespace(), $l2->getNamespace() );
		$this->assertEquals( $l1->getFragment(), $l2->getFragment() );
		$this->assertEquals( $l1->getInterwiki(), $l2->getInterwiki() );
	}

	protected function assertRevisionRecordsEqual( RevisionRecord $r1, RevisionRecord $r2 ) {
		$this->assertEquals(
			$r1->getPageAsLinkTarget()->getNamespace(),
			$r2->getPageAsLinkTarget()->getNamespace()
		);

		$this->assertEquals(
			$r1->getPageAsLinkTarget()->getText(),
			$r2->getPageAsLinkTarget()->getText()
		);

		if ( $r1->getParentId() ) {
			$this->assertEquals( $r1->getParentId(), $r2->getParentId() );
		}

		$this->assertEquals( $r1->getUser( RevisionRecord::RAW )->getName(), $r2->getUser( RevisionRecord::RAW )->getName() );
		$this->assertEquals( $r1->getUser( RevisionRecord::RAW )->getId(), $r2->getUser( RevisionRecord::RAW )->getId() );
		$this->assertEquals( $r1->getComment( RevisionRecord::RAW ), $r2->getComment( RevisionRecord::RAW ) );
		$this->assertEquals( $r1->getTimestamp(), $r2->getTimestamp() );
		$this->assertEquals( $r1->getVisibility(), $r2->getVisibility() );
		$this->assertEquals( $r1->getSha1(), $r2->getSha1() );
		$this->assertEquals( $r1->getSize(), $r2->getSize() );
		$this->assertEquals( $r1->getPageId(), $r2->getPageId() );
		$this->assertArrayEquals( $r1->getSlotRoles(), $r2->getSlotRoles() );
		$this->assertEquals( $r1->getWikiId(), $r2->getWikiId() );
		$this->assertEquals( $r1->isMinor(), $r2->isMinor() );
		foreach ( $r1->getSlotRoles() as $role ) {
			$this->assertSlotRecordsEqual( $r1->getSlot( $role, RevisionRecord::RAW ), $r2->getSlot( $role, RevisionRecord::RAW ) );
			$this->assertTrue( $r1->getContent( $role, RevisionRecord::RAW )->equals( $r2->getContent( $role, RevisionRecord::RAW ) ) );
		}
		foreach ( [
			RevisionRecord::DELETED_TEXT,
			RevisionRecord::DELETED_COMMENT,
			RevisionRecord::DELETED_USER,
			RevisionRecord::DELETED_RESTRICTED,
		] as $field ) {
			$this->assertEquals( $r1->isDeleted( $field ), $r2->isDeleted( $field ) );
		}
	}

	protected function assertSlotRecordsEqual( SlotRecord $s1, SlotRecord $s2 ) {
		$this->assertSame( $s1->getRole(), $s2->getRole() );
		$this->assertSame( $s1->getModel(), $s2->getModel() );
		$this->assertSame( $s1->getFormat(), $s2->getFormat() );
		$this->assertSame( $s1->getSha1(), $s2->getSha1() );
		$this->assertSame( $s1->getSize(), $s2->getSize() );
		$this->assertTrue( $s1->getContent()->equals( $s2->getContent() ) );

		$s1->hasRevision() ? $this->assertSame( $s1->getRevision(), $s2->getRevision() ) : null;
		$s1->hasAddress() ? $this->assertSame( $s1->hasAddress(), $s2->hasAddress() ) : null;
	}

	protected function assertRevisionCompleteness( RevisionRecord $r ) {
		$this->assertTrue( $r->hasSlot( SlotRecord::MAIN ) );
		$this->assertInstanceOf( SlotRecord::class, $r->getSlot( SlotRecord::MAIN, RevisionRecord::RAW ) );
		$this->assertInstanceOf( Content::class, $r->getContent( SlotRecord::MAIN, RevisionRecord::RAW ) );

		foreach ( $r->getSlotRoles() as $role ) {
			$this->assertSlotCompleteness( $r, $r->getSlot( $role, RevisionRecord::RAW ) );
		}
	}

	protected function assertSlotCompleteness( RevisionRecord $r, SlotRecord $slot ) {
		$this->assertTrue( $slot->hasAddress() );
		$this->assertSame( $r->getId(), $slot->getRevision() );

		$this->assertInstanceOf( Content::class, $slot->getContent() );
	}

	/**
	 * @param mixed[] $details
	 *
	 * @return RevisionRecord
	 */
	private function getRevisionRecordFromDetailsArray( $details = [] ) {
		// Convert some values that can't be provided by dataProviders
		if ( isset( $details['user'] ) && $details['user'] === true ) {
			$details['user'] = $this->getTestUser()->getUser();
		}
		if ( isset( $details['page'] ) && $details['page'] === true ) {
			$details['page'] = $this->getTestPage()->getId();
		}
		if ( isset( $details['parent'] ) && $details['parent'] === true ) {
			$details['parent'] = $this->getTestPage()->getLatest();
		}

		// Create the RevisionRecord with any available data
		$rev = new MutableRevisionRecord( $this->getTestPageTitle() );
		isset( $details['slot'] ) ? $rev->setSlot( $details['slot'] ) : null;
		isset( $details['parent'] ) ? $rev->setParentId( $details['parent'] ) : null;
		isset( $details['page'] ) ? $rev->setPageId( $details['page'] ) : null;
		isset( $details['size'] ) ? $rev->setSize( $details['size'] ) : null;
		isset( $details['sha1'] ) ? $rev->setSha1( $details['sha1'] ) : null;
		isset( $details['comment'] ) ? $rev->setComment( $details['comment'] ) : null;
		isset( $details['timestamp'] ) ? $rev->setTimestamp( $details['timestamp'] ) : null;
		isset( $details['minor'] ) ? $rev->setMinorEdit( $details['minor'] ) : null;
		isset( $details['user'] ) ? $rev->setUser( $details['user'] ) : null;
		isset( $details['visibility'] ) ? $rev->setVisibility( $details['visibility'] ) : null;
		isset( $details['id'] ) ? $rev->setId( $details['id'] ) : null;

		if ( isset( $details['content'] ) ) {
			foreach ( $details['content'] as $role => $content ) {
				$rev->setContent( $role, $content );
			}
		}

		return $rev;
	}

	public function provideInsertRevisionOn_successes() {
		yield 'Bare minimum revision insertion' => [
			[
				'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
				'page' => true,
				'comment' => $this->getRandomCommentStoreComment(),
				'timestamp' => '20171117010101',
				'user' => true,
			],
		];
		yield 'Detailed revision insertion' => [
			[
				'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
				'parent' => true,
				'page' => true,
				'comment' => $this->getRandomCommentStoreComment(),
				'timestamp' => '20171117010101',
				'user' => true,
				'minor' => true,
				'visibility' => RevisionRecord::DELETED_RESTRICTED,
			],
		];
		yield 'Multi-slot revision insertion' => [
			[
				'content' => [
					SlotRecord::MAIN => new WikitextContent( 'Chicken' ),
					'aux' => new TextContent( 'Egg' ),
				],
				'page' => true,
				'comment' => $this->getRandomCommentStoreComment(),
				'timestamp' => '20171117010101',
				'user' => true,
			],
		];
	}

	protected function getRandomCommentStoreComment() {
		return CommentStoreComment::newUnsavedComment( __METHOD__ . '.' . rand( 0, 1000 ) );
	}

	/**
	 * @dataProvider provideInsertRevisionOn_successes
	 */
	public function testInsertRevisionOn_successes(
		array $revDetails = []
	) {
		$title = $this->getTestPageTitle();
		$rev = $this->getRevisionRecordFromDetailsArray( $revDetails );

		$store = $this->getServiceContainer()->getRevisionStore();
		$return = $store->insertRevisionOn( $rev, $this->getDb() );

		// is the new revision correct?
		$this->assertRevisionCompleteness( $return );
		$this->assertLinkTargetsEqual( $title, $return->getPageAsLinkTarget() );
		$this->assertRevisionRecordsEqual( $rev, $return );

		// can we load it from the store?
		$loaded = $store->getRevisionById( $return->getId() );
		$this->assertRevisionCompleteness( $loaded );
		$this->assertRevisionRecordsEqual( $return, $loaded );

		// can we find it directly in the database?
		$this->assertRevisionExistsInDatabase( $return );
	}

	protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) {
		$numberOfSlots = count( $rev->getSlotRoles() );

		// new schema is written
		$this->newSelectQueryBuilder()
			->select( 'count(*)' )
			->from( 'slots' )
			->where( [ 'slot_revision_id' => $rev->getId() ] )
			->assertFieldValue( $numberOfSlots );

		$store = $this->getServiceContainer()->getRevisionStore();
		$revQuery = $store->getSlotsQueryInfo( [ 'content' ] );

		$this->newSelectQueryBuilder()
			->queryInfo( [
				'tables' => $revQuery['tables'],
				'joins' => $revQuery['joins']
			] )
			->select( 'count(*)' )
			->where( [ 'slot_revision_id' => $rev->getId() ] )
			->assertFieldValue( $numberOfSlots );

		$row = $this->revisionRecordToRow( $rev, [] );

		// unset nulled fields
		unset( $row->rev_content_model );
		unset( $row->rev_content_format );

		// unset fake fields
		unset( $row->rev_comment_text );
		unset( $row->rev_comment_data );
		unset( $row->rev_comment_cid );
		unset( $row->rev_comment_id );

		$queryInfo = $store->getQueryInfo( [ 'user' ] );

		$row = get_object_vars( $row );

		// Use aliased fields from $queryInfo, e.g. rev_user
		$keys = array_keys( $row );
		$keys = array_combine( $keys, $keys );
		$fields = array_intersect_key( $queryInfo['fields'], $keys ) + $keys;

		// assertSelect() fails unless the orders match.
		ksort( $fields );
		ksort( $row );

		$this->newSelectQueryBuilder()
			->select( $fields )
			->queryInfo( [
				'tables' => $queryInfo['tables'],
				'joins' => $queryInfo['joins']
			] )
			->where( [ 'rev_id' => $rev->getId() ] )
			->assertResultSet( [ array_values( $row ) ] );
	}

	/**
	 * @param SlotRecord $a
	 * @param SlotRecord $b
	 */
	protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) {
		// Assert that the same blob address has been used.
		$this->assertSame( $a->getAddress(), $b->getAddress() );
		// Assert that the same content ID has been used
		$this->assertSame( $a->getContentId(), $b->getContentId() );
	}

	public function testInsertRevisionOn_blobAddressExists() {
		$title = $this->getTestPageTitle();
		$revDetails = [
			'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
			'parent' => true,
			'comment' => $this->getRandomCommentStoreComment(),
			'timestamp' => '20171117010101',
			'user' => true,
		];

		$store = $this->getServiceContainer()->getRevisionStore();

		// Insert the first revision
		$revOne = $this->getRevisionRecordFromDetailsArray( $revDetails );
		$firstReturn = $store->insertRevisionOn( $revOne, $this->getDb() );
		$this->assertLinkTargetsEqual( $title, $firstReturn->getPageAsLinkTarget() );
		$this->assertRevisionRecordsEqual( $revOne, $firstReturn );

		// Insert a second revision inheriting the same blob address
		$revDetails['slot'] = SlotRecord::newInherited( $firstReturn->getSlot( SlotRecord::MAIN ) );
		$revTwo = $this->getRevisionRecordFromDetailsArray( $revDetails );
		$secondReturn = $store->insertRevisionOn( $revTwo, $this->getDb() );
		$this->assertLinkTargetsEqual( $title, $secondReturn->getPageAsLinkTarget() );
		$this->assertRevisionRecordsEqual( $revTwo, $secondReturn );

		$firstMainSlot = $firstReturn->getSlot( SlotRecord::MAIN );
		$secondMainSlot = $secondReturn->getSlot( SlotRecord::MAIN );

		$this->assertSameSlotContent( $firstMainSlot, $secondMainSlot );

		// And that different revisions have been created.
		$this->assertNotSame( $firstReturn->getId(), $secondReturn->getId() );

		// Make sure the slot rows reference the correct revision
		$this->assertSame( $firstReturn->getId(), $firstMainSlot->getRevision() );
		$this->assertSame( $secondReturn->getId(), $secondMainSlot->getRevision() );
	}

	public function provideInsertRevisionOn_failures() {
		yield 'no slot' => [
			[
				'comment' => $this->getRandomCommentStoreComment(),
				'timestamp' => '20171117010101',
				'user' => true,
			],
			new IncompleteRevisionException( 'main slot must be provided' )
		];
		yield 'no main slot' => [
			[
				'slot' => SlotRecord::newUnsaved( 'aux', new WikitextContent( 'Turkey' ) ),
				'comment' => $this->getRandomCommentStoreComment(),
				'timestamp' => '20171117010101',
				'user' => true,
			],
			new IncompleteRevisionException( 'main slot must be provided' )
		];
		yield 'no timestamp' => [
			[
				'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
				'comment' => $this->getRandomCommentStoreComment(),
				'user' => true,
			],
			new IncompleteRevisionException( 'timestamp field must not be NULL!' )
		];
		yield 'no comment' => [
			[
				'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
				'timestamp' => '20171117010101',
				'user' => true,
			],
			new IncompleteRevisionException( 'comment must not be NULL!' )
		];
		yield 'no user' => [
			[
				'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
				'comment' => $this->getRandomCommentStoreComment(),
				'timestamp' => '20171117010101',
			],
			new IncompleteRevisionException( 'user must not be NULL!' )
		];
		yield 'size mismatch' => [
			[
				'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
				'comment' => $this->getRandomCommentStoreComment(),
				'timestamp' => '20171117010101',
				'user' => true,
				'size' => 123456
			],
			new PreconditionException( 'T239717' )
		];
		yield 'sha1 mismatch' => [
			[
				'slot' => SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'Chicken' ) ),
				'comment' => $this->getRandomCommentStoreComment(),
				'timestamp' => '20171117010101',
				'user' => true,
				'sha1' => 'DEADBEEF',
			],
			new PreconditionException( 'T239717' )
		];
	}

	/**
	 * @dataProvider provideInsertRevisionOn_failures
	 */
	public function testInsertRevisionOn_failures(
		array $revDetails,
		Exception $exception
	) {
		$rev = $this->getRevisionRecordFromDetailsArray( $revDetails );

		$store = $this->getServiceContainer()->getRevisionStore();

		$this->expectException( get_class( $exception ) );
		$this->expectExceptionMessage( $exception->getMessage() );
		$this->expectExceptionCode( $exception->getCode() );
		$store->insertRevisionOn( $rev, $this->getDb() );
	}

	public static function provideNewNullRevision() {
		yield [
			Title::newFromText( 'NewNullRevision_notAutoCreated' ),
			[ 'content' => [ SlotRecord::MAIN => new WikitextContent( 'Flubber1' ) ] ],
			CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment1' ),
			true,
		];
		yield [
			Title::newFromText( 'NewNullRevision_notAutoCreated' ),
			[ 'content' => [ SlotRecord::MAIN => new WikitextContent( 'Flubber2' ) ] ],
			CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment2', [ 'a' => 1 ] ),
			false,
		];
		yield [
			Title::newFromText( 'NewNullRevision_notAutoCreated' ),
			[
				'content' => [
					SlotRecord::MAIN => new WikitextContent( 'Chicken' ),
					'aux' => new WikitextContent( 'Omelet' ),
				],
			],
			CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment multi' ),
		];
	}

	/**
	 * @dataProvider provideNewNullRevision
	 */
	public function testNewNullRevision( Title $title, $revDetails, $comment, $minor = false ) {
		$user = $this->getMutableTestUser()->getUser();
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		if ( !$page->exists() ) {
			$page->doUserEditContent(
				new WikitextContent( __METHOD__ ),
				$this->getTestSysop()->getUser(),
				__METHOD__,
				EDIT_NEW
			);
		}

		$revDetails['page'] = $page->getId();
		$revDetails['timestamp'] = wfTimestampNow();
		$revDetails['comment'] = CommentStoreComment::newUnsavedComment( 'Base' );
		$revDetails['user'] = $user;

		$baseRev = $this->getRevisionRecordFromDetailsArray( $revDetails );
		$store = $this->getServiceContainer()->getRevisionStore();

		$dbw = $this->getDb();
		$baseRev = $store->insertRevisionOn( $baseRev, $dbw );
		$page->updateRevisionOn( $dbw, $baseRev, $page->getLatest() );

		$record = $store->newNullRevision(
			$this->getDb(),
			$title,
			$comment,
			$minor,
			$user
		);

		$this->assertEquals( $title->getNamespace(), $record->getPageAsLinkTarget()->getNamespace() );
		$this->assertEquals( $title->getDBkey(), $record->getPageAsLinkTarget()->getDBkey() );
		$this->assertEquals( $comment, $record->getComment() );
		$this->assertEquals( $minor, $record->isMinor() );
		$this->assertEquals( $user->getName(), $record->getUser()->getName() );
		$this->assertEquals( $baseRev->getId(), $record->getParentId() );

		$this->assertArrayEquals(
			$baseRev->getSlotRoles(),
			$record->getSlotRoles()
		);

		foreach ( $baseRev->getSlotRoles() as $role ) {
			$parentSlot = $baseRev->getSlot( $role );
			$slot = $record->getSlot( $role );

			$this->assertTrue( $slot->isInherited(), 'isInherited' );
			$this->assertSame( $parentSlot->getOrigin(), $slot->getOrigin(), 'getOrigin' );
			$this->assertSameSlotContent( $parentSlot, $slot );
		}
	}

	public function testNewNullRevision_nonExistingTitle() {
		$store = $this->getServiceContainer()->getRevisionStore();
		$record = $store->newNullRevision(
			$this->getDb(),
			Title::newFromText( __METHOD__ . '.iDontExist!' ),
			CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment' ),
			false,
			$this->getMutableTestUser()->getUser()
		);
		$this->assertNull( $record );
	}

	public function testGetRcIdIfUnpatrolled_returnsRecentChangesId() {
		$page = $this->getTestPage();
		$status = $page->doUserEditContent(
			new WikitextContent( __METHOD__ ),
			$this->getTestUser()->getUser(),
			__METHOD__
		);
		$revRecord = $status->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$storeRecord = $store->getRevisionById( $revRecord->getId() );
		$result = $store->getRcIdIfUnpatrolled( $storeRecord );

		$this->assertGreaterThan( 0, $result );
		$this->assertSame(
			$store->getRecentChange( $storeRecord )->getAttribute( 'rc_id' ),
			$result
		);
	}

	public function testGetRcIdIfUnpatrolled_returnsZeroIfPatrolled() {
		// This assumes that sysops are auto patrolled
		$sysop = $this->getTestSysop()->getUser();
		$page = $this->getTestPage();
		$status = $page->doUserEditContent(
			new WikitextContent( __METHOD__ ),
			$sysop,
			__METHOD__
		);
		$revRecord = $status->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$storeRecord = $store->getRevisionById( $revRecord->getId() );
		$result = $store->getRcIdIfUnpatrolled( $storeRecord );

		$this->assertSame( 0, $result );
	}

	public function testGetRecentChange() {
		$page = $this->getTestPage();
		$content = new WikitextContent( __METHOD__ );
		$status = $page->doUserEditContent(
			$content,
			$this->getTestSysop()->getUser(),
			__METHOD__
		);
		$revRecord = $status->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$storeRecord = $store->getRevisionById( $revRecord->getId() );
		$recentChange = $store->getRecentChange( $storeRecord );

		$this->assertEquals( $revRecord->getId(), $recentChange->getAttribute( 'rc_this_oldid' ) );
	}

	public function testGetRevisionById() {
		$page = $this->getTestPage();
		$content = new WikitextContent( __METHOD__ );
		$status = $page->doUserEditContent(
			$content,
			$this->getTestSysop()->getUser(),
			__METHOD__
		);
		$revRecord = $status->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$storeRecord = $store->getRevisionById( $revRecord->getId() );

		$this->assertSame( $revRecord->getId(), $storeRecord->getId() );
		$this->assertTrue( $storeRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
		$this->assertSame( __METHOD__, $storeRecord->getComment()->text );
	}

	public function testGetRevisionById_crossWiki_withPage() {
		$page = $this->getTestPage();
		$content = new WikitextContent( __METHOD__ );
		$status = $page->doUserEditContent(
			$content,
			$this->getTestSysop()->getUser(),
			__METHOD__
		);
		$revRecord = $status->getNewRevision();
		$revId = $revRecord->getId();

		// Pretend the local test DB is a sister site
		$wikiId = $this->getDb()->getDomainID();
		$store = $this->getServiceContainer()->getRevisionStoreFactory()
			->getRevisionStore( $wikiId );

		// Construct a ProperPageIdentity with the sister site's wiki Id
		$pageIdentity = new PageIdentityValue(
			$page->getId(), $page->getNamespace(), $page->getDBkey(), $wikiId
		);
		$storeRecord = $store->getRevisionById( $revId, 0, $pageIdentity );

		$this->assertSame( $wikiId, $storeRecord->getWikiId() );
		$this->assertSame( $revId, $storeRecord->getId( $wikiId ) );
		$this->assertTrue( $storeRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
		$this->assertSame( __METHOD__, $storeRecord->getComment()->text );
	}

	public function testGetRevisionById_crossWiki() {
		$page = $this->getTestPage();
		$content = new WikitextContent( __METHOD__ );
		$status = $page->doUserEditContent(
			$content,
			$this->getTestSysop()->getUser(),
			__METHOD__
		);
		$revRecord = $status->getNewRevision();
		$revId = $revRecord->getId();
		$pageId = $revRecord->getPageId();

		// Make TitleFactory always fail, since it should not be used for the cross-wiki case.
		$noOpTitleFactory = $this->createNoOpMock( TitleFactory::class );
		$this->setService( 'TitleFactory', $noOpTitleFactory );

		// Pretend the local test DB is a sister site
		$wikiId = $this->getDb()->getDomainID();
		$store = $this->getServiceContainer()->getRevisionStoreFactory()
			->getRevisionStore( $wikiId );

		$storeRecord = $store->getRevisionById( $revId );

		$this->assertSame( $wikiId, $storeRecord->getWikiId() );
		$this->assertSame( $wikiId, $storeRecord->getPage()->getWikiId() );
		$this->assertNotInstanceOf( Title::class, $storeRecord->getPage() );
		$this->assertSame( $revId, $storeRecord->getId( $wikiId ) );
		$this->assertSame( $pageId, $storeRecord->getPage()->getId( $wikiId ) );
		$this->assertTrue( $storeRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
		$this->assertSame( __METHOD__, $storeRecord->getComment()->text );
	}

	public function testGetRevisionById_undefinedContentModel() {
		$page = $this->getTestPage();
		$content = new WikitextContent( __METHOD__ );
		$status = $page->doUserEditContent(
			$content,
			$this->getTestSysop()->getUser(),
			__METHOD__
		);
		$revRecord = $status->getNewRevision();

		$mockContentHandlerFactory = $this->getDummyContentHandlerFactory();
		$this->setService( 'ContentHandlerFactory', $mockContentHandlerFactory );
		$store = $this->getServiceContainer()->getRevisionStore();

		$storeRecord = $store->getRevisionById( $revRecord->getId() );

		$this->assertSame( $revRecord->getId(), $storeRecord->getId() );

		$actualContent = $storeRecord->getSlot( SlotRecord::MAIN )->getContent();
		$this->assertInstanceOf( FallbackContent::class, $actualContent );
		$this->assertSame( __METHOD__, $actualContent->serialize() );
	}

	/**
	 * @dataProvider provideRevisionByTitle
	 *
	 * @param callable $getTitle
	 */
	public function testGetRevisionByTitle( $getTitle ) {
		$page = $this->getTestPage();
		$title = $getTitle();
		$content = new WikitextContent( __METHOD__ );
		$status = $page->doUserEditContent(
			$content,
			$this->getTestSysop()->getUser(),
			__METHOD__
		);
		$revRecord = $status->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$storeRecord = $store->getRevisionByTitle( $title );

		$this->assertSame( $revRecord->getId(), $storeRecord->getId() );
		$this->assertTrue( $storeRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
		$this->assertSame( __METHOD__, $storeRecord->getComment()->text );
	}

	public function provideRevisionByTitle() {
		return [
			[ function () {
				return $this->getTestPageTitle();
			} ],
			[ function () {
				return $this->getTestPageTitle()->toPageIdentity();
			} ]
		];
	}

	private function executeWithForeignStore( string $dbDomain, callable $callback ) {
		$services = $this->getServiceContainer();
		// Configure the load balancer to route queries for the "foreign" domain to the test DB
		$dbLoadBalancer = $services->getDBLoadBalancer();
		$dbLoadBalancer->setDomainAliases( [ $dbDomain => $dbLoadBalancer->getLocalDomainID() ] );
		$store = new RevisionStore(
			$dbLoadBalancer,
			$services->getBlobStore(),
			$services->getMainWANObjectCache(),
			new HashBagOStuff(),
			$services->getCommentStore(),
			$services->getContentModelStore(),
			$services->getSlotRoleStore(),
			$services->getSlotRoleRegistry(),
			$services->getActorStoreFactory()->getActorStore( $dbDomain ),
			$services->getContentHandlerFactory(),
			$services->getPageStoreFactory()->getPageStore( $dbDomain ),
			$services->getTitleFactory(),
			$services->getHookContainer(),
			$dbDomain
		);

		// Redefine the DBLoadBalancer service to verify Title doesn't attempt to resolve its ID
		// via getPrimaryDatabase() etc.
		$localLoadBalancerMock = $this->createMock( ILoadBalancer::class );
		$localLoadBalancerMock->expects( $this->never() )
			->method( $this->anything() );

		try {
			$this->setService( 'DBLoadBalancer', $localLoadBalancerMock );
			// There may be other code which indirectly uses the RevisionStore
			// service; make sure it picks up the external store as well.
			$this->setService( 'RevisionStore', $store );
			$callback( $store );
		} finally {
			// Restore the original load balancer to make test teardown work
			$this->setService( 'DBLoadBalancer', $dbLoadBalancer );
		}
	}

	public function testGetLatestKnownRevision_foreigh() {
		$page = $this->getTestPage();
		$status = $this->editPage( $page, __METHOD__ );
		$this->assertStatusGood( $status, 'edited a page' );
		$revRecord = $status->getNewRevision();
		$dbDomain = 'some_foreign_wiki';
		$this->executeWithForeignStore(
			$dbDomain,
			function ( RevisionStore $store ) use ( $page, $dbDomain, $revRecord ) {
				$storeRecord = $store->getKnownCurrentRevision(
					new PageIdentityValue( $page->getId(), $page->getNamespace(), $page->getDBkey(), $dbDomain )
				);
				$this->assertSame( $dbDomain, $storeRecord->getWikiId() );
				$this->assertSame( $revRecord->getId(), $storeRecord->getId( $dbDomain ) );
			} );
	}

	/**
	 * getRevisionByTitle should not use the local wiki DB (T248756)
	 */
	public function testGetRevisionByTitle_doesNotUseLocalLoadBalancerForForeignWiki() {
		$page = $this->getTestPage();
		$content = new WikitextContent( __METHOD__ );
		$comment = __METHOD__;
		$status = $page->doUserEditContent(
			$content,
			$this->getTestSysop()->getUser(),
			$comment
		);
		$revRecord = $status->getNewRevision();
		$dbDomain = 'some_foreign_wiki';
		$this->executeWithForeignStore(
			$dbDomain,
			function ( RevisionStore $store ) use ( $page, $dbDomain, $revRecord, $content, $comment ) {
				$storeRecord = $store->getRevisionByTitle(
					new PageIdentityValue(
						$page->getId(),
						$page->getTitle()->getNamespace(),
						$page->getTitle()->getDBkey(),
						$dbDomain
					)
				);
				$this->assertSame( $revRecord->getId(), $storeRecord->getId( $dbDomain ) );
				$this->assertTrue( $storeRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
				$this->assertSame( $comment, $storeRecord->getComment()->text );
			} );
	}

	public function testGetRevisionByPageId() {
		$page = $this->getTestPage();
		$content = new WikitextContent( __METHOD__ );
		$status = $page->doUserEditContent(
			$content,
			$this->getTestSysop()->getUser(),
			__METHOD__
		);
		$revRecord = $status->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$storeRecord = $store->getRevisionByPageId( $page->getId() );

		$this->assertSame( $revRecord->getId(), $storeRecord->getId() );
		$this->assertTrue( $storeRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
		$this->assertSame( __METHOD__, $storeRecord->getComment()->text );
	}

	/**
	 * @dataProvider provideRevisionByTitle
	 *
	 * @param callable $getTitle
	 */
	public function testGetRevisionByTimestamp( $getTitle ) {
		// Make sure there is 1 second between the last revision and the rev we create...
		// Otherwise we might not get the correct revision and the test may fail...
		MWTimestamp::setFakeTime( '20110401090000' );
		$page = $this->getTestPage();
		$title = $getTitle();
		MWTimestamp::setFakeTime( '20110401090001' );
		$content = new WikitextContent( __METHOD__ );
		$status = $page->doUserEditContent(
			$content,
			$this->getTestSysop()->getUser(),
			__METHOD__
		);
		$revRecord = $status->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$storeRecord = $store->getRevisionByTimestamp(
			$title,
			$revRecord->getTimestamp()
		);

		$this->assertSame( $revRecord->getId(), $storeRecord->getId() );
		$this->assertTrue( $storeRecord->getSlot( SlotRecord::MAIN )->getContent()->equals( $content ) );
		$this->assertSame( __METHOD__, $storeRecord->getComment()->text );
	}

	protected function revisionRecordToRow( RevisionRecord $revRecord, $options = [ 'page', 'user', 'comment' ] ) {
		// XXX: the WikiPage object loads another RevisionRecord from the database. Not great.
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $revRecord->getPage() );

		$revUser = $revRecord->getUser();
		$actorId = $this->getServiceContainer()
			->getActorNormalization()->findActorId( $revUser, $this->getDb() );

		$fields = [
			'rev_id' => (string)$revRecord->getId(),
			'rev_page' => (string)$revRecord->getPageId(),
			'rev_timestamp' => $this->getDb()->timestamp( $revRecord->getTimestamp() ),
			'rev_actor' => $actorId,
			'rev_user_text' => $revUser ? $revUser->getName() : '',
			'rev_user' => (string)( $revUser ? $revUser->getId() : 0 ) ?: null,
			'rev_minor_edit' => $revRecord->isMinor() ? '1' : '0',
			'rev_deleted' => (string)$revRecord->getVisibility(),
			'rev_len' => (string)$revRecord->getSize(),
			'rev_parent_id' => (string)$revRecord->getParentId(),
			'rev_sha1' => (string)$revRecord->getSha1(),
		];

		if ( in_array( 'page', $options ) ) {
			$fields += [
				'page_namespace' => (string)$page->getTitle()->getNamespace(),
				'page_title' => $page->getTitle()->getDBkey(),
				'page_id' => (string)$page->getId(),
				'page_latest' => (string)$page->getLatest(),
				'page_is_redirect' => $page->isRedirect() ? '1' : '0',
				'page_len' => (string)$page->getContent()->getSize(),
			];
		}

		if ( in_array( 'user', $options ) ) {
			$fields += [
				'user_name' => $revUser ? $revUser->getName() : ''
			];
		}

		if ( in_array( 'comment', $options ) ) {
			$revComment = $revRecord->getComment();
			$fields += [
				'rev_comment_text' => $revComment ? $revComment->text : null,
				'rev_comment_data' => $revComment ? $revComment->data : null,
				'rev_comment_cid' => $revComment ? $revComment->id : null,
			];
		}

		if ( $revRecord->getId() ) {
			$fields += [
				'rev_id' => (string)$revRecord->getId(),
			];
		}

		return (object)$fields;
	}

	public function testNewRevisionFromRowAndSlots_getQueryInfo() {
		$page = $this->getTestPage();
		$text = __METHOD__ . 'o-ö';
		$revRecord = $page->doUserEditContent(
			new WikitextContent( $text ),
			$this->getTestSysop()->getUser(),
			__METHOD__ . 'a'
		)->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$info = $store->getQueryInfo();
		$row = $this->getDb()->newSelectQueryBuilder()
			->queryInfo( $info )
			->where( [ 'rev_id' => $revRecord->getId() ] )
			->caller( __METHOD__ )
			->fetchRow();

		$info = $store->getSlotsQueryInfo( [ 'content' ] );
		$slotRows = $this->getDb()->newSelectQueryBuilder()
			->queryInfo( $info )
			->where( [ 'slot_revision_id' => $revRecord->getId() ] )
			->caller( __METHOD__ )
			->fetchResultSet();

		$storeRecord = $store->newRevisionFromRowAndSlots(
			$row,
			iterator_to_array( $slotRows ),
			0,
			$page->getTitle()
		);
		$this->assertRevisionRecordsEqual( $revRecord, $storeRecord );
		$this->assertSame( $text, $revRecord->getContent( SlotRecord::MAIN )->serialize() );
	}

	public function testNewRevisionFromRow_getQueryInfo() {
		$page = $this->getTestPage();
		$text = __METHOD__ . 'a-ä';
		$revRecord = $page->doUserEditContent(
			new WikitextContent( $text ),
			$this->getTestSysop()->getUser(),
			__METHOD__ . 'a'
		)->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$info = $store->getQueryInfo();
		$row = $this->getDb()->newSelectQueryBuilder()
			->queryInfo( $info )
			->where( [ 'rev_id' => $revRecord->getId() ] )
			->caller( __METHOD__ )
			->fetchRow();
		$storeRecord = $store->newRevisionFromRow(
			$row,
			0,
			$page->getTitle()
		);
		$this->assertRevisionRecordsEqual( $revRecord, $storeRecord );
		$this->assertSame( $text, $revRecord->getContent( SlotRecord::MAIN )->serialize() );
	}

	public function testNewRevisionFromRow_anonEdit() {
		$page = $this->getTestPage();
		$text = __METHOD__ . 'a-ä';
		$revRecord = $page->doUserEditContent(
			new WikitextContent( $text ),
			$this->getTestSysop()->getUser(),
			__METHOD__ . 'a'
		)->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$storeRecord = $store->newRevisionFromRow(
			$this->revisionRecordToRow( $revRecord ),
			0,
			$page->getTitle()
		);
		$this->assertRevisionRecordsEqual( $revRecord, $storeRecord );
		$this->assertSame( $text, $revRecord->getContent( SlotRecord::MAIN )->serialize() );
	}

	public function testNewRevisionFromRow_anonEdit_legacyEncoding() {
		$this->overrideConfigValue( MainConfigNames::LegacyEncoding, 'windows-1252' );
		$page = $this->getTestPage();
		$text = __METHOD__ . 'a-ä';
		$revRecord = $page->doUserEditContent(
			new WikitextContent( $text ),
			$this->getTestSysop()->getUser(),
			__METHOD__ . 'a'
		)->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$storeRecord = $store->newRevisionFromRow(
			$this->revisionRecordToRow( $revRecord ),
			0,
			$page->getTitle()
		);
		$this->assertRevisionRecordsEqual( $revRecord, $storeRecord );
		$this->assertSame( $text, $revRecord->getContent( SlotRecord::MAIN )->serialize() );
	}

	public function testNewRevisionFromRow_userEdit() {
		$page = $this->getTestPage();
		$text = __METHOD__ . 'b-ä';
		$revRecord = $page->doUserEditContent(
			new WikitextContent( $text ),
			$this->getTestUser()->getUser(),
			__METHOD__ . 'b'
		)->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$storeRecord = $store->newRevisionFromRow(
			$this->revisionRecordToRow( $revRecord ),
			0,
			$page->getTitle()
		);
		$this->assertRevisionRecordsEqual( $revRecord, $storeRecord );
		$this->assertSame( $text, $revRecord->getContent( SlotRecord::MAIN )->serialize() );
	}

	private function buildRevisionStore( string $text, PageIdentity $pageIdentity ) {
		$store = $this->getServiceContainer()->getRevisionStore();
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $pageIdentity );
		$orig = $page->doUserEditContent(
			new WikitextContent( $text ),
			$this->getTestSysop()->getUser(),
			__METHOD__
		)->getNewRevision();
		$this->deletePage( $page );

		$res = $store->newArchiveSelectQueryBuilder( $this->getDb() )
			->joinComment()
			->where( [ 'ar_rev_id' => $orig->getId() ] )
			->caller( __METHOD__ )->fetchResultSet();
		$this->assertIsObject( $res, 'query failed' );

		$info = $store->getSlotsQueryInfo( [ 'content' ] );
		$slotRows = $this->getDb()->newSelectQueryBuilder()
			->queryInfo( $info )
			->where( [ 'slot_revision_id' => $orig->getId() ] )
			->caller( __METHOD__ )
			->fetchResultSet();

		$row = $res->fetchObject();
		$res->free();
		return [ $store, $row, $slotRows, $orig ];
	}

	public function testNewRevisionFromArchiveRowAndSlots_getArchiveQueryInfo() {
		$text = __METHOD__ . '-bä';
		$title = Title::newFromText( __METHOD__ );
		[ $store, $row, $slotRows, $orig ] = $this->buildRevisionStore( $text, $title );
		$storeRecord = $store->newRevisionFromArchiveRowAndSlots(
			$row,
			iterator_to_array( $slotRows )
		);
		$this->assertRevisionRecordsEqual( $orig, $storeRecord );
		$this->assertSame( $text, $storeRecord->getContent( SlotRecord::MAIN, RevisionRecord::RAW )->serialize() );
	}

	public static function provideNewRevisionFromArchiveRowAndSlotsTitles() {
		return [
			[ static function () {
				return Title::newFromText( 'Test_NewRevisionFromArchiveRowAndSlotsTitles' );
			} ],
			[ static function () {
				return Title::newFromText( 'Test_NewRevisionFromArchiveRowAndSlotsTitles' )->toPageIdentity();
			} ]
		];
	}

	/**
	 * @dataProvider provideNewRevisionFromArchiveRowAndSlotsTitles
	 */
	public function testNewRevisionFromArchiveRowAndSlots_getArchiveQueryInfoWithTitle( $getPageIdentity ) {
		$text = __METHOD__ . '-bä';
		$page = $getPageIdentity();
		[ $store, $row, $slotRows, $orig ] = $this->buildRevisionStore( $text, $page );
		$storeRecord = $store->newRevisionFromArchiveRowAndSlots(
			$row,
			iterator_to_array( $slotRows ),
			0,
			$page
		);

		$this->assertRevisionRecordsEqual( $orig, $storeRecord );
		$this->assertSame( $text, $storeRecord->getContent( SlotRecord::MAIN, RevisionRecord::RAW )->serialize() );
	}

	public static function provideNewRevisionFromArchiveRowAndSlotsInArray() {
		return [
			[
				[
					'title' => Title::newFromText( 'Test_NewRevisionFromArchiveRowAndSlotsInArray' )
				]
			],
			[
				[
					'title' => Title::newFromText( 'Test_NewRevisionFromArchiveRowAndSlotsInArray' )->toPageIdentity()
				]
			],
		];
	}

	/**
	 * @dataProvider provideNewRevisionFromArchiveRowAndSlotsInArray
	 */
	public function testNewRevisionFromArchiveRowAndSlots_getArchiveQueryInfoWithTitleInArray( $array ) {
		$text = __METHOD__ . '-bä';
		$page = $array[ 'title' ];
		[ $store, $row, $slotRows, $orig ] = $this->buildRevisionStore( $text, $page );
		$storeRecord = $store->newRevisionFromArchiveRowAndSlots(
			$row,
			iterator_to_array( $slotRows ),
			0,
			null,
			$array
		);

		$this->assertRevisionRecordsEqual( $orig, $storeRecord );
		$this->assertSame( $text, $storeRecord->getContent( SlotRecord::MAIN, RevisionRecord::RAW )->serialize() );
	}

	/**
	 * @covers \MediaWiki\Revision\ArchiveSelectQueryBuilder
	 */
	public function testNewRevisionFromArchiveRow_getArchiveQueryInfo() {
		$store = $this->getServiceContainer()->getRevisionStore();
		$title = Title::newFromText( __METHOD__ );
		$text = __METHOD__ . '-bä';
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$orig = $page->doUserEditContent(
			new WikitextContent( $text ),
			$this->getTestSysop()->getUser(),
			__METHOD__
		)->getNewRevision();
		$this->deletePage( $page );

		$res = $store->newArchiveSelectQueryBuilder( $this->getDb() )
			->joinComment()
			->where( [ 'ar_rev_id' => $orig->getId() ] )
			->caller( __METHOD__ )->fetchResultSet();
		$this->assertIsObject( $res, 'query failed' );

		$row = $res->fetchObject();
		$res->free();
		$storeRecord = $store->newRevisionFromArchiveRow( $row );

		$this->assertRevisionRecordsEqual( $orig, $storeRecord );
		$this->assertSame( $text, $storeRecord->getContent( SlotRecord::MAIN, RevisionRecord::RAW )->serialize() );
	}

	public function testNewRevisionFromArchiveRow_legacyEncoding() {
		$this->overrideConfigValue( MainConfigNames::LegacyEncoding, 'windows-1252' );
		$store = $this->getServiceContainer()->getRevisionStore();
		$title = Title::newFromText( __METHOD__ );
		$text = __METHOD__ . '-bä';
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$orig = $page->doUserEditContent(
			new WikitextContent( $text ),
			$this->getTestSysop()->getUser(),
			__METHOD__
		)->getNewRevision();
		$this->deletePage( $page );

		$res = $store->newArchiveSelectQueryBuilder( $this->getDb() )
			->joinComment()
			->where( [ 'ar_rev_id' => $orig->getId() ] )
			->caller( __METHOD__ )->fetchResultSet();
		$this->assertIsObject( $res, 'query failed' );

		$row = $res->fetchObject();
		$res->free();
		$storeRecord = $store->newRevisionFromArchiveRow( $row );

		$this->assertRevisionRecordsEqual( $orig, $storeRecord );
		$this->assertSame( $text, $storeRecord->getContent( SlotRecord::MAIN, RevisionRecord::RAW )->serialize() );
	}

	public function testNewRevisionFromArchiveRow_no_user() {
		$store = $this->getServiceContainer()->getRevisionStore();

		$row = (object)[
			'ar_id' => '1',
			'ar_page_id' => '2',
			'ar_namespace' => '0',
			'ar_title' => 'Something',
			'ar_rev_id' => '2',
			'ar_timestamp' => '20180528192356',
			'ar_minor_edit' => '0',
			'ar_deleted' => '0',
			'ar_len' => '78',
			'ar_parent_id' => '0',
			'ar_sha1' => 'deadbeef',
			'ar_comment_text' => 'whatever',
			'ar_comment_data' => null,
			'ar_comment_cid' => null,
			'ar_user' => '0',
			'ar_user_text' => '', // this is the important bit
			'ar_actor' => null,
		];

		$record = @$store->newRevisionFromArchiveRow( $row );

		$this->assertInstanceOf( RevisionRecord::class, $record );
		$this->assertInstanceOf( UserIdentityValue::class, $record->getUser() );
		$this->assertSame( 'Unknown user', $record->getUser()->getName() );
	}

	/**
	 * Test for T236624.
	 */
	public function testNewRevisionFromArchiveRow_empty_actor() {
		$store = $this->getServiceContainer()->getRevisionStore();

		$row = (object)[
			'ar_id' => '1',
			'ar_page_id' => '2',
			'ar_namespace' => '0',
			'ar_title' => 'Something',
			'ar_rev_id' => '2',
			'ar_text_id' => '47',
			'ar_timestamp' => '20180528192356',
			'ar_minor_edit' => '0',
			'ar_deleted' => '0',
			'ar_len' => '78',
			'ar_parent_id' => '0',
			'ar_sha1' => 'deadbeef',
			'ar_comment_text' => 'whatever',
			'ar_comment_data' => null,
			'ar_comment_cid' => null,
			'ar_user' => '0',
			'ar_user_text' => '', // this is the important bit
			'ar_actor' => null, // we will fill this in below
			'ar_content_format' => null,
			'ar_content_model' => null,
		];

		// create an actor row for the empty user name (see also T225469)
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'actor' )
			->row( [
				'actor_user' => $row->ar_user,
				'actor_name' => $row->ar_user_text,
			] )
			->caller( __METHOD__ )
			->execute();

		$row->ar_actor = $this->getDb()->insertId();

		$record = @$store->newRevisionFromArchiveRow( $row );

		$this->assertInstanceOf( RevisionRecord::class, $record );
		$this->assertInstanceOf( UserIdentityValue::class, $record->getUser() );
		$this->assertSame( 'Unknown user', $record->getUser()->getName() );
	}

	public function testNewRevisionFromRow_no_user() {
		$store = $this->getServiceContainer()->getRevisionStore();
		$title = Title::newFromText( __METHOD__ );

		$row = (object)[
			'rev_id' => '2',
			'rev_page' => '2',
			'page_namespace' => '0',
			'page_title' => $title->getText(),
			'rev_text_id' => '47',
			'rev_timestamp' => '20180528192356',
			'rev_minor_edit' => '0',
			'rev_deleted' => '0',
			'rev_len' => '78',
			'rev_parent_id' => '0',
			'rev_sha1' => 'deadbeef',
			'rev_comment_text' => 'whatever',
			'rev_comment_data' => null,
			'rev_comment_cid' => null,
			'rev_user' => '0',
			'rev_user_text' => '', // this is the important bit
			'rev_actor' => null,
			'rev_content_format' => null,
			'rev_content_model' => null,
		];

		$record = @$store->newRevisionFromRow( $row, 0, $title );
		$this->assertNotNull( $record );
		$this->assertNotNull( $record->getUser() );
		$this->assertNotEmpty( $record->getUser()->getName() );
	}

	public function testNewRevisionFromRow_noPage() {
		$store = $this->getServiceContainer()->getRevisionStore();
		$page = $this->getExistingTestPage();

		$info = $store->getQueryInfo();
		$row = $this->getDb()->newSelectQueryBuilder()
			->queryInfo( $info )
			->where( [ 'rev_page' => $page->getId(), 'rev_id' => $page->getLatest() ] )
			->caller( __METHOD__ )
			->fetchRow();

		$record = $store->newRevisionFromRow( $row );

		$this->assertNotNull( $record );
		$this->assertTrue( $page->isSamePageAs( $record->getPage() ) );

		// NOTE: This should return a Title object for now, until we no longer have a need
		//       to frequently convert to Title.
		$this->assertInstanceOf( Title::class, $record->getPage() );
	}

	public function testNewRevisionFromRow_noPage_crossWiki() {
		$page = $this->getExistingTestPage();
		// Make TitleFactory always fail, since it should not be used for the cross-wiki case. Note, it's important
		// to do this *after* the test page has been created.
		$noOpTitleFactory = $this->createNoOpMock( TitleFactory::class );
		$this->setService( 'TitleFactory', $noOpTitleFactory );

		// Pretend the local test DB is a sister site
		$wikiId = $this->getDb()->getDomainID();
		$store = $this->getServiceContainer()->getRevisionStoreFactory()
			->getRevisionStore( $wikiId );

		$info = $store->getQueryInfo();
		$row = $this->getDb()->newSelectQueryBuilder()
			->queryInfo( $info )
			->where( [ 'rev_page' => $page->getId(), 'rev_id' => $page->getLatest() ] )
			->caller( __METHOD__ )
			->fetchRow();

		$record = $store->newRevisionFromRow( $row );

		$this->assertNotNull( $record );
		$this->assertSame( $page->getLatest(), $record->getId( $wikiId ) );

		$this->assertNotInstanceOf( Title::class, $record->getPage() );
		$this->assertSame( $page->getId(), $record->getPage()->getId( $wikiId ) );
	}

	/**
	 * @dataProvider provideInsertRevisionOn
	 *
	 * @param callable $getPageIdentity
	 */
	public function testInsertRevisionOn_archive( $getPageIdentity ) {
		// This is a round trip test for deletion and undeletion of a
		// revision row via the archive table.
		[ $title, $pageIdentity ] = $getPageIdentity();
		$store = $this->getServiceContainer()->getRevisionStore();

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$user = $this->getTestSysop()->getUser();
		$page->doUserEditContent( new WikitextContent( "First" ), $user, __METHOD__ . '-first' );
		$orig = $page->doUserEditContent( new WikitextContent( "Foo" ), $user, __METHOD__ )
			->getNewRevision();
		$this->deletePage( $page );

		// re-create page, so we can later load revisions for it
		$page->doUserEditContent( new WikitextContent( 'Two' ), $user, __METHOD__ );

		$db = $this->getDb();
		$row = $store->newArchiveSelectQueryBuilder( $db )
			->joinComment()
			->where( [ 'ar_rev_id' => $orig->getId() ] )
			->caller( __METHOD__ )->fetchRow();

		$this->assertNotFalse( $row, 'query failed' );

		$record = $store->newRevisionFromArchiveRow(
			$row,
			0,
			$pageIdentity,
			[ 'page_id' => $title->getArticleID() ]
		);

		$restored = $store->insertRevisionOn( $record, $db );

		// is the new revision correct?
		$this->assertRevisionCompleteness( $restored );
		$this->assertRevisionRecordsEqual( $record, $restored );

		// does the new revision use the original slot?
		$recMain = $record->getSlot( SlotRecord::MAIN, RevisionRecord::RAW );
		$restMain = $restored->getSlot( SlotRecord::MAIN );
		$this->assertSame( $recMain->getAddress(), $restMain->getAddress() );
		$this->assertSame( $recMain->getContentId(), $restMain->getContentId() );
		$this->assertSame( $recMain->getOrigin(), $restMain->getOrigin() );
		$this->assertSame( 'Foo', $restMain->getContent()->serialize() );

		// can we load it from the store?
		$loaded = $store->getRevisionById( $restored->getId() );
		$this->assertNotNull( $loaded );
		$this->assertRevisionCompleteness( $loaded );
		$this->assertRevisionRecordsEqual( $restored, $loaded );

		// can we find it directly in the database?
		$this->assertRevisionExistsInDatabase( $restored );
	}

	public static function provideInsertRevisionOn() {
		return [
			[ static function () {
				$pageTitle = Title::newFromText( 'Test_Insert_Revision_On' );
				return [ $pageTitle, $pageTitle ];
			} ],
			[ static function () {
				$pageTitle = Title::newFromText( 'Test_Insert_Revision_On' );
				return [ $pageTitle, $pageTitle->toPageIdentity() ];
			} ]
		];
	}

	public function testGetParentLengths() {
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( __METHOD__ ) );
		$revRecordOne = $page->doUserEditContent(
			new WikitextContent( __METHOD__ ),
			$this->getTestSysop()->getUser(),
			__METHOD__
		)->getNewRevision();
		$revRecordTwo = $page->doUserEditContent(
			new WikitextContent( __METHOD__ . '2' ),
			$this->getTestSysop()->getUser(),
			__METHOD__
		)->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$this->assertSame(
			[
				$revRecordOne->getId() => strlen( __METHOD__ ),
			],
			$store->getRevisionSizes( [ $revRecordOne->getId() ] )
		);
		$this->assertSame(
			[
				$revRecordOne->getId() => strlen( __METHOD__ ),
				$revRecordTwo->getId() => strlen( __METHOD__ ) + 1,
			],
			$store->getRevisionSizes(
				[ $revRecordOne->getId(), $revRecordTwo->getId() ]
			)
		);
	}

	public function testGetPreviousRevision() {
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( __METHOD__ ) );
		$revRecordOne = $page->doUserEditContent(
			new WikitextContent( __METHOD__ ),
			$this->getTestSysop()->getUser(),
			__METHOD__
		)->getNewRevision();
		$revRecordTwo = $page->doUserEditContent(
			new WikitextContent( __METHOD__ . '2' ),
			$this->getTestSysop()->getUser(),
			__METHOD__
		)->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$this->assertNull(
			$store->getPreviousRevision(
				$store->getRevisionById( $revRecordOne->getId() )
			)
		);
		$this->assertSame(
			$revRecordOne->getId(),
			$store->getPreviousRevision(
				$store->getRevisionById( $revRecordTwo->getId() )
			)->getId()
		);
	}

	public function testGetNextRevision() {
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( __METHOD__ ) );
		$revRecordOne = $page->doUserEditContent(
			new WikitextContent( __METHOD__ ),
			$this->getTestSysop()->getUser(),
			__METHOD__
		)->getNewRevision();
		$revRecordTwo = $page->doUserEditContent(
			new WikitextContent( __METHOD__ . '2' ),
			$this->getTestSysop()->getUser(),
			__METHOD__
		)->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$this->assertSame(
			$revRecordTwo->getId(),
			$store->getNextRevision(
				$store->getRevisionById( $revRecordOne->getId() )
		)->getId()
		);
		$this->assertNull(
			$store->getNextRevision( $store->getRevisionById( $revRecordTwo->getId() ) )
		);
	}

	public static function provideNonHistoryRevision() {
		$title = Title::newFromText( __METHOD__ );
		$rev = new MutableRevisionRecord( $title );
		yield [ $rev ];

		$user = new UserIdentityValue( 7, 'Frank' );
		$comment = CommentStoreComment::newUnsavedComment( 'Test' );
		$row = (object)[
			'ar_id' => 3,
			'ar_rev_id' => 34567,
			'ar_page_id' => 5,
			'ar_deleted' => 0,
			'ar_minor_edit' => 0,
			'ar_timestamp' => '20180101020202',
		];
		$slots = new RevisionSlots( [] );
		$rev = new RevisionArchiveRecord( $title, $user, $comment, $row, $slots );
		yield [ $rev ];
	}

	/**
	 * @dataProvider provideNonHistoryRevision
	 */
	public function testGetPreviousRevision_bad( RevisionRecord $rev ) {
		$store = $this->getServiceContainer()->getRevisionStore();
		$this->assertNull( $store->getPreviousRevision( $rev ) );
	}

	/**
	 * @dataProvider provideNonHistoryRevision
	 */
	public function testGetNextRevision_bad( RevisionRecord $rev ) {
		$store = $this->getServiceContainer()->getRevisionStore();
		$this->assertNull( $store->getNextRevision( $rev ) );
	}

	public function testGetTimestampFromId_found() {
		$page = $this->getTestPage();
		$revRecord = $page->doUserEditContent(
			new WikitextContent( __METHOD__ ),
			$this->getTestSysop()->getUser(),
			__METHOD__
		)->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$result = $store->getTimestampFromId( $revRecord->getId() );

		$this->assertSame( $revRecord->getTimestamp(), $result );
	}

	public function testGetTimestampFromId_notFound() {
		$page = $this->getTestPage();
		$revRecord = $page->doUserEditContent(
			new WikitextContent( __METHOD__ ),
			$this->getTestSysop()->getUser(),
			__METHOD__
		)->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$result = $store->getTimestampFromId( $revRecord->getId() + 1 );

		$this->assertFalse( $result );
	}

	public function testCountRevisionsByPageId() {
		$store = $this->getServiceContainer()->getRevisionStore();
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( __METHOD__ ) );
		$user = $this->getTestSysop()->getUser();

		$this->assertSame(
			0,
			$store->countRevisionsByPageId( $this->getDb(), $page->getId() )
		);
		$page->doUserEditContent( new WikitextContent( 'a' ), $user, 'a' );
		$this->assertSame(
			1,
			$store->countRevisionsByPageId( $this->getDb(), $page->getId() )
		);
		$page->doUserEditContent( new WikitextContent( 'b' ), $user, 'b' );
		$this->assertSame(
			2,
			$store->countRevisionsByPageId( $this->getDb(), $page->getId() )
		);
	}

	public function testCountRevisionsByTitle() {
		$store = $this->getServiceContainer()->getRevisionStore();
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( __METHOD__ ) );
		$user = $this->getTestSysop()->getUser();

		$this->assertSame(
			0,
			$store->countRevisionsByTitle( $this->getDb(), $page->getTitle() )
		);
		$page->doUserEditContent( new WikitextContent( 'a' ), $user, 'a' );
		$this->assertSame(
			1,
			$store->countRevisionsByTitle( $this->getDb(), $page->getTitle() )
		);
		$page->doUserEditContent( new WikitextContent( 'b' ), $user, 'b' );
		$this->assertSame(
			2,
			$store->countRevisionsByTitle( $this->getDb(), $page->getTitle() )
		);
	}

	public function testUserWasLastToEdit_false() {
		$sysop = $this->getTestSysop()->getUser();
		$page = $this->getTestPage();
		$page->doUserEditContent(
			new WikitextContent( __METHOD__ ),
			$this->getTestUser()->getUser(), // not the $sysop
			__METHOD__
		);

		$store = $this->getServiceContainer()->getRevisionStore();
		$result = $store->userWasLastToEdit(
			$this->getDb(),
			$page->getId(),
			$sysop->getId(),
			'20160101010101'
		);
		$this->assertFalse( $result );
	}

	public function testUserWasLastToEdit_true() {
		$startTime = wfTimestampNow();
		$sysop = $this->getTestSysop()->getUser();
		$page = $this->getTestPage();
		$page->doUserEditContent(
			new WikitextContent( __METHOD__ ),
			$sysop,
			__METHOD__
		);

		$store = $this->getServiceContainer()->getRevisionStore();
		$result = $store->userWasLastToEdit(
			$this->getDb(),
			$page->getId(),
			$sysop->getId(),
			$startTime
		);
		$this->assertTrue( $result );
	}

	/**
	 * @dataProvider provideRevisionByTitle
	 */
	public function testGetKnownCurrentRevision( $getPageIdentity ) {
		$page = $this->getTestPage();
		$revRecord = $page->doUserEditContent(
			new WikitextContent( __METHOD__ . 'b' ),
			$this->getTestUser()->getUser(),
			__METHOD__ . 'b'
		)->getNewRevision();
		$store = $this->getServiceContainer()->getRevisionStore();
		$storeRecord = $store->getKnownCurrentRevision(
			$getPageIdentity(),
			$revRecord->getId()
		);
		$this->assertRevisionRecordsEqual( $revRecord, $storeRecord );
	}

	/**
	 * Creates a new revision for testing caching behavior
	 *
	 * @param WikiPage $page the page for the new revision
	 * @param RevisionStore $store store object to use for creating the revision
	 * @return bool|RevisionStoreRecord the revision created, or false if missing
	 */
	private function createRevisionStoreCacheRecord( $page, $store ) {
		$user = MediaWikiIntegrationTestCase::getMutableTestUser()->getUser();
		$summary = CommentStoreComment::newUnsavedComment( __METHOD__ );
		$rev = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new WikitextContent( __METHOD__ ) )
			->saveRevision( $summary, EDIT_NEW );
		return $store->getKnownCurrentRevision( $page->getTitle(), $rev->getId() );
	}

	public function testGetKnownCurrentRevision_userNameChange() {
		$cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
		$this->setService( 'MainWANObjectCache', $cache );

		$store = $this->getServiceContainer()->getRevisionStore();
		$page = $this->getNonexistingTestPage();
		$rev = $this->createRevisionStoreCacheRecord( $page, $store );

		// Grab the user name
		$userNameBefore = $rev->getUser()->getName();

		// Change the user name in the database, "behind the back" of the cache
		$newUserName = "Renamed $userNameBefore";
		$this->getDb()->newUpdateQueryBuilder()
			->update( 'user' )
			->set( [ 'user_name' => $newUserName ] )
			->where( [ 'user_id' => $rev->getUser()->getId() ] )
			->caller( __METHOD__ )
			->execute();
		$this->getDb()->newUpdateQueryBuilder()
			->update( 'actor' )
			->set( [ 'actor_name' => $newUserName ] )
			->where( [ 'actor_user' => $rev->getUser()->getId() ] )
			->caller( __METHOD__ )
			->execute();

		// Reload the revision and regrab the user name.
		$revAfter = $store->getKnownCurrentRevision( $page->getTitle(), $rev->getId() );
		$userNameAfter = $revAfter->getUser()->getName();

		// The two user names should be different.
		// If they are the same, we are seeing a cached value, which is bad.
		$this->assertNotSame( $userNameBefore, $userNameAfter );

		// This is implied by the above assertion, but explicitly check it, for completeness
		$this->assertSame( $newUserName, $userNameAfter );
	}

	public function testGetKnownCurrentRevision_stalePageId() {
		$cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
		$this->setService( 'MainWANObjectCache', $cache );

		$store = $this->getServiceContainer()->getRevisionStore();
		$page = $this->getNonexistingTestPage();
		$rev = $this->createRevisionStoreCacheRecord( $page, $store );

		// Force bad article ID
		$title = $page->getTitle();
		$title->resetArticleID( 886655 );

		$result = $store->getKnownCurrentRevision( $title, $rev->getId() );

		$this->assertSame( $rev->getPageId(), $result->getPageId() );
	}

	public function testGetKnownCurrentRevision_wrongTitle() {
		$cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
		$this->setService( 'MainWANObjectCache', $cache );

		$store = $this->getServiceContainer()->getRevisionStore();
		$page = $this->getNonexistingTestPage();
		$rev = $this->createRevisionStoreCacheRecord( $page, $store );

		// Get title of another page
		$title = $this->getExistingTestPage( __FUNCTION__ )->getTitle();
		$result = $store->getKnownCurrentRevision( $title, $rev->getId() );

		$this->assertSame( $rev->getPageId(), $result->getPageId() );
		$this->assertTrue( $rev->getPage()->isSamePageAs( $result->getPage() ) );
	}

	public function testGetKnownCurrentRevision_revDelete() {
		$cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
		$this->setService( 'MainWANObjectCache', $cache );

		$store = $this->getServiceContainer()->getRevisionStore();
		$page = $this->getNonexistingTestPage();
		$rev = $this->createRevisionStoreCacheRecord( $page, $store );

		// Grab the deleted bitmask
		$deletedBefore = $rev->getVisibility();

		// Change the deleted bitmask in the database, "behind the back" of the cache
		$this->getDb()->newUpdateQueryBuilder()
			->update( 'revision' )
			->set( [ 'rev_deleted' => RevisionRecord::DELETED_TEXT ] )
			->where( [ 'rev_id' => $rev->getId() ] )
			->caller( __METHOD__ )
			->execute();

		// Reload the revision and regrab the visibility flag.
		$revAfter = $store->getKnownCurrentRevision( $page->getTitle(), $rev->getId() );
		$deletedAfter = $revAfter->getVisibility();

		// The two deleted flags should be different.
		// If they are the same, we are seeing a cached value, which is bad.
		$this->assertNotSame( $deletedBefore, $deletedAfter );

		// This is implied by the above assertion, but explicitly check it, for completeness
		$this->assertSame( RevisionRecord::DELETED_TEXT, $deletedAfter );
	}

	public function testNewRevisionFromRow_userNameChange() {
		$page = $this->getTestPage();
		$text = __METHOD__;
		$revRecord = $page->doUserEditContent(
			new WikitextContent( $text ),
			$this->getMutableTestUser()->getUser(),
			__METHOD__
		)->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$storeRecord = $store->newRevisionFromRow(
			$this->revisionRecordToRow( $revRecord ),
			0,
			$page->getTitle()
		);

		// Grab the user name
		$userNameBefore = $storeRecord->getUser()->getName();

		// Change the user name in the database
		$newUserName = "Renamed $userNameBefore";
		$this->getDb()->newUpdateQueryBuilder()
			->update( 'user' )
			->set( [ 'user_name' => $newUserName ] )
			->where( [ 'user_id' => $storeRecord->getUser()->getId() ] )
			->caller( __METHOD__ )
			->execute();
		$this->getDb()->newUpdateQueryBuilder()
			->update( 'actor' )
			->set( [ 'actor_name' => $newUserName ] )
			->where( [ 'actor_user' => $storeRecord->getUser()->getId() ] )
			->caller( __METHOD__ )
			->execute();

		// Reload the record, passing $fromCache as true to force fresh info from the db,
		// and regrab the user name
		$recordAfter = $store->newRevisionFromRow(
			$this->revisionRecordToRow( $revRecord ),
			0,
			$page->getTitle(),
			true
		);
		$userNameAfter = $recordAfter->getUser()->getName();

		// The two user names should be different.
		// If they are the same, we are seeing a cached value, which is bad.
		$this->assertNotSame( $userNameBefore, $userNameAfter );

		// This is implied by the above assertion, but explicitly check it, for completeness
		$this->assertSame( $newUserName, $userNameAfter );
	}

	public function testNewRevisionFromRow_revDelete() {
		$page = $this->getTestPage();
		$text = __METHOD__;
		$revRecord = $page->doUserEditContent(
			new WikitextContent( $text ),
			$this->getTestSysop()->getUser(),
			__METHOD__
		)->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$storeRecord = $store->newRevisionFromRow(
			$this->revisionRecordToRow( $revRecord ),
			0,
			$page->getTitle()
		);

		// Grab the deleted bitmask
		$deletedBefore = $storeRecord->getVisibility();

		// Change the deleted bitmask in the database
		$this->getDb()->newUpdateQueryBuilder()
			->update( 'revision' )
			->set( [ 'rev_deleted' => RevisionRecord::DELETED_TEXT ] )
			->where( [ 'rev_id' => $storeRecord->getId() ] )
			->caller( __METHOD__ )
			->execute();

		// Reload the record, passing $fromCache as true to force fresh info from the db,
		// and regrab the deleted bitmask
		$recordAfter = $store->newRevisionFromRow(
			$this->revisionRecordToRow( $revRecord ),
			0,
			$page->getTitle(),
			true
		);
		$deletedAfter = $recordAfter->getVisibility();

		// The two deleted flags should be different, because we modified the database.
		$this->assertNotSame( $deletedBefore, $deletedAfter );

		// This is implied by the above assertion, but explicitly check it, for completeness
		$this->assertSame( RevisionRecord::DELETED_TEXT, $deletedAfter );
	}

	public static function provideGetContentBlobsForBatchOptions() {
		yield 'all slots' => [ null ];
		yield 'no slots' => [ [] ];
		yield 'main slot' => [ [ SlotRecord::MAIN ] ];
	}

	/**
	 * @dataProvider provideGetContentBlobsForBatchOptions
	 */
	public function testGetContentBlobsForBatch( $slots ) {
		$page1 = $this->getTestPage();
		$text = __METHOD__ . 'b-ä';
		$editStatus = $this->editPage( $page1, $text . '1' );
		$this->assertStatusGood( $editStatus, 'must create revision 1' );
		$revRecord1 = $editStatus->getNewRevision();

		$page2 = $this->getTestPage( $page1->getTitle()->getPrefixedText() . '_other' );
		$editStatus = $this->editPage( $page2, $text . '2' );
		$this->assertStatusGood( $editStatus, 'must create revision 2' );
		$revRecord2 = $editStatus->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$result = $store->getContentBlobsForBatch(
			[ $revRecord1->getId(), $revRecord2->getId() ],
			$slots
		);
		$this->assertStatusGood( $result );

		$rowSetsByRevId = $result->getValue();
		$this->assertArrayHasKey( $revRecord1->getId(), $rowSetsByRevId );
		$this->assertArrayHasKey( $revRecord2->getId(), $rowSetsByRevId );

		$rev1rows = $rowSetsByRevId[$revRecord1->getId()];
		$rev2rows = $rowSetsByRevId[$revRecord2->getId()];

		if ( is_array( $slots ) && !in_array( SlotRecord::MAIN, $slots ) ) {
			$this->assertArrayNotHasKey( SlotRecord::MAIN, $rev1rows );
			$this->assertArrayNotHasKey( SlotRecord::MAIN, $rev2rows );
		} else {
			$this->assertArrayHasKey( SlotRecord::MAIN, $rev1rows );
			$this->assertArrayHasKey( SlotRecord::MAIN, $rev2rows );

			$mainSlotRow1 = $rev1rows[ SlotRecord::MAIN ];
			$mainSlotRow2 = $rev2rows[ SlotRecord::MAIN ];

			$this->assertSame( $text . '1', $mainSlotRow1->blob_data );
			$this->assertSame( $text . '2', $mainSlotRow2->blob_data );
		}

		// try again, with objects instead of ids:
		$result2 = $store->getContentBlobsForBatch( [
			(object)[ 'rev_id' => $revRecord1->getId() ],
			(object)[ 'rev_id' => $revRecord2->getId() ],
		], $slots );

		$this->assertStatusGood( $result2 );
		$exp1 = var_export( $result->getValue(), true );
		$exp2 = var_export( $result2->getValue(), true );
		$this->assertSame( $exp1, $exp2 );
	}

	public function testGetContentBlobsForBatch_archive() {
		$page1 = $this->getTestPage( __METHOD__ );
		$text = __METHOD__ . 'b-ä';
		$editStatus = $this->editPage( $page1, $text . '1' );
		$this->assertStatusGood( $editStatus, 'must create revision 1' );
		$revRecord1 = $editStatus->getNewRevision();
		$this->deletePage( $page1 );

		$page2 = $this->getTestPage( $page1->getTitle()->getPrefixedText() . '_other' );
		$editStatus = $this->editPage( $page2, $text . '2' );
		$this->assertStatusGood( $editStatus, 'must create revision 2' );
		$revRecord2 = $editStatus->getNewRevision();
		$this->deletePage( $page2 );

		$store = $this->getServiceContainer()->getRevisionStore();
		$result = $store->getContentBlobsForBatch( [
			(object)[ 'ar_rev_id' => $revRecord1->getId() ],
			(object)[ 'ar_rev_id' => $revRecord2->getId() ],
		] );
		$this->assertStatusGood( $result );

		$rowSetsByRevId = $result->getValue();
		$this->assertArrayHasKey( $revRecord1->getId(), $rowSetsByRevId );
		$this->assertArrayHasKey( $revRecord2->getId(), $rowSetsByRevId );
	}

	public function testGetContentBlobsForBatch_emptyBatch() {
		$rows = new FakeResultWrapper( [] );
		$result = $this->getServiceContainer()->getRevisionStore()
			->getContentBlobsForBatch( $rows );
		$this->assertStatusGood( $result );
		$this->assertStatusValue( [], $result );
	}

	public static function provideNewRevisionsFromBatchOptions() {
		yield 'No preload slots or content, single page' => [
			[ 'comment' ],
			static function () {
				$pageTitle = Title::newFromText( 'Test_New_Revision_From_Batch' );
				return [ $pageTitle, $pageTitle ];
			},
			null,
			[]
		];
		yield 'No preload slots or content, single page and with PageIdentity' => [
			[ 'comment' ],
			static function () {
				$pageTitle = Title::newFromText( 'Test_New_Revision_From_Batch' );
				return [ $pageTitle, $pageTitle->toPageIdentity() ];
			},
			null,
			[]
		];
		yield 'Preload slots and content, single page' => [
			[ 'comment' ],
			static function () {
				$pageTitle = Title::newFromText( 'Test_New_Revision_From_Batch' );
				return [ $pageTitle, $pageTitle ];
			},
			null,
			[
				'slots' => [ SlotRecord::MAIN ],
				'content' => true
			]
		];
		yield 'Ask for no slots' => [
			[ 'comment' ],
			static function () {
				$pageTitle = Title::newFromText( 'Test_New_Revision_From_Batch' );
				return [ $pageTitle, $pageTitle ];
			},
			null,
			[ 'slots' => [] ]
		];
		yield 'Ask for only-defined slots' => [
			[ 'comment' ],
			static function () {
				$pageTitle = Title::newFromText( 'Test_New_Revision_From_Batch' );
				return [ $pageTitle, $pageTitle ];
			},
			null,
			[ 'slots' => [ 'unused' ] ]
		];
		yield 'No preload slots or content, multiple pages' => [
			[ 'comment' ],
			static function () {
				$pageTitle = Title::newFromText( 'Test_New_Revision_From_Batch' );
				return [ $pageTitle, $pageTitle ];
			},
			'Other_Page',
			[]
		];
		yield 'Preload slots and content, multiple pages' => [
			[ 'comment' ],
			static function () {
				$pageTitle = Title::newFromText( 'Test_New_Revision_From_Batch' );
				return [ $pageTitle, $pageTitle ];
			},
			'Other_Page',
			[
				'slots' => [ SlotRecord::MAIN ],
				'content' => true
			]
		];
		yield 'Preload slots and content, multiple pages, preload page fields' => [
			[ 'page', 'comment' ],
			static function () {
				$pageTitle = Title::newFromText( 'Test_New_Revision_From_Batch' );
				return [ $pageTitle, $pageTitle ];
			},
			'Other_Page',
			[
				'slots' => [ SlotRecord::MAIN ],
				'content' => true
			]
		];
	}

	/**
	 * @dataProvider provideNewRevisionsFromBatchOptions
	 * @param array|null $queryOptions options to provide to revisionRecordToRow
	 * @param callable $getPageIdentity
	 * @param string|null $otherPageTitle
	 * @param array|null $options
	 */
	public function testNewRevisionsFromBatch_preloadContent(
		$queryOptions,
		$getPageIdentity,
		$otherPageTitle = null,
		array $options = []
	) {
		if ( isset( $options['slots'] ) && in_array( 'unused', $options['slots'] ) ) {
			$this->getServiceContainer()->addServiceManipulator(
				'SlotRoleRegistry',
				static function ( SlotRoleRegistry $registry ) {
					$registry->defineRoleWithModel( 'unused', CONTENT_MODEL_JSON );
				}
			);
		}

		$page1 = $this->getTestPage();
		$text = __METHOD__ . 'b-ä';
		$editStatus = $this->editPage( $page1, $text . '1' );
		$this->assertStatusGood( $editStatus, 'must create revision 1' );
		$revRecord1 = $editStatus->getNewRevision();

		$page2 = $this->getTestPage( $otherPageTitle );
		$editStatus = $this->editPage( $page2, $text . '2' );
		$this->assertStatusGood( $editStatus, 'must create revision 2' );
		$revRecord2 = $editStatus->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();
		$result = $store->newRevisionsFromBatch(
			[
				$this->revisionRecordToRow( $revRecord1, $queryOptions ),
				$this->revisionRecordToRow( $revRecord2, $queryOptions )
			],
			$options,
			0, $otherPageTitle ? null : $page1->getTitle()
		);
		$this->assertStatusGood( $result );
		/** @var RevisionRecord[] $records */
		$records = $result->getValue();
		$this->assertRevisionRecordsEqual( $revRecord1, $records[$revRecord1->getId()] );
		$this->assertRevisionRecordsEqual( $revRecord2, $records[$revRecord2->getId()] );

		$content1 = $records[$revRecord1->getId()]->getContent( SlotRecord::MAIN );
		$this->assertInstanceOf( TextContent::class, $content1 );
		$this->assertSame(
			$text . '1',
			$content1->getText()
		);
		$content2 = $records[$revRecord2->getId()]->getContent( SlotRecord::MAIN );
		$this->assertInstanceOf( TextContent::class, $content2 );
		$this->assertSame(
			$text . '2',
			$content2->getText()
		);
		$this->assertEquals(
			$page1->getTitle()->getDBkey(),
			$records[$revRecord1->getId()]->getPageAsLinkTarget()->getDBkey()
		);
		$this->assertEquals(
			$page2->getTitle()->getDBkey(),
			$records[$revRecord2->getId()]->getPageAsLinkTarget()->getDBkey()
		);
	}

	/**
	 * @dataProvider provideNewRevisionsFromBatchOptions
	 * @param array|null $queryOptions options to provide to revisionRecordToRow
	 * @param callable $getPageIdentity
	 * @param string|null $otherPageTitle
	 * @param array|null $options
	 */
	public function testNewRevisionsFromBatch_archive(
		$queryOptions,
		$getPageIdentity,
		$otherPageTitle = null,
		array $options = []
	) {
		if ( isset( $options['slots'] ) && in_array( 'unused', $options['slots'] ) ) {
			$this->getServiceContainer()->addServiceManipulator(
				'SlotRoleRegistry',
				static function ( SlotRoleRegistry $registry ) {
					$registry->defineRoleWithModel( 'unused', CONTENT_MODEL_JSON );
				}
			);
		}

		[ $title1, $pageIdentity ] = $getPageIdentity();
		$text1 = __METHOD__ . '-bä';
		$page1 = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title1 );

		$title2 = $otherPageTitle ? Title::newFromText( $otherPageTitle ) : $title1;
		$text2 = __METHOD__ . '-bö';
		$page2 = $otherPageTitle ? $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title2 ) : $page1;

		$sysop = $this->getTestSysop()->getUser();
		$revRecord1 = $page1->doUserEditContent(
			new WikitextContent( $text1 ),
			$sysop,
			__METHOD__
		)->getNewRevision();
		$revRecord2 = $page2->doUserEditContent(
			new WikitextContent( $text2 ),
			$sysop,
			__METHOD__
		)->getNewRevision();
		$this->deletePage( $page1 );

		if ( $page2 !== $page1 ) {
			$this->deletePage( $page2 );
		}

		$store = $this->getServiceContainer()->getRevisionStore();

		$rows = $store->newArchiveSelectQueryBuilder( $this->getDb() )
			->joinComment()
			->where( [ 'ar_rev_id' => [ $revRecord1->getId(), $revRecord2->getId() ] ] )
			->caller( __METHOD__ )->fetchResultSet();

		$options['archive'] = true;
		$rows = iterator_to_array( $rows );
		$result = $store->newRevisionsFromBatch(
			$rows, $options, 0, $otherPageTitle ? null : $pageIdentity );

		$this->assertStatusGood( $result );
		/** @var RevisionRecord[] $records */
		$records = $result->getValue();
		$this->assertCount( 2, $records );
		$this->assertRevisionRecordsEqual( $revRecord1, $records[$revRecord1->getId()] );
		$this->assertRevisionRecordsEqual( $revRecord2, $records[$revRecord2->getId()] );

		$content1 = $records[$revRecord1->getId()]->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
		$this->assertInstanceOf( TextContent::class, $content1 );
		$this->assertSame(
			$text1,
			$content1->getText()
		);
		$content2 = $records[$revRecord2->getId()]->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
		$this->assertInstanceOf( TextContent::class, $content2 );
		$this->assertSame(
			$text2,
			$content2->getText()
		);
		$this->assertEquals(
			$page1->getTitle()->getDBkey(),
			$records[$revRecord1->getId()]->getPageAsLinkTarget()->getDBkey()
		);
		$this->assertEquals(
			$page2->getTitle()->getDBkey(),
			$records[$revRecord2->getId()]->getPageAsLinkTarget()->getDBkey()
		);
	}

	public function testNewRevisionsFromBatch_emptyBatch() {
		$rows = new FakeResultWrapper( [] );
		$result = $this->getServiceContainer()->getRevisionStore()
			->newRevisionsFromBatch(
				$rows,
				[
					'slots' => [ SlotRecord::MAIN ],
					'content' => true
				]
			);
		$this->assertStatusGood( $result );
		$this->assertStatusValue( [], $result );
	}

	public function testNewRevisionsFromBatch_wrongTitle() {
		$page1 = $this->getTestPage();
		$text = __METHOD__ . 'b-ä';
		$editStatus = $this->editPage( $page1, $text . '1' );
		$this->assertStatusGood( $editStatus, 'must create revision 1' );
		$revRecord1 = $editStatus->getNewRevision();

		$this->expectException( InvalidArgumentException::class );
		$this->getServiceContainer()->getRevisionStore()
			->newRevisionsFromBatch(
				[ $this->revisionRecordToRow( $revRecord1 ) ],
				[],
				IDBAccessObject::READ_NORMAL,
				$this->getTestPage( 'Title_Other_Then_The_One_Revision_Belongs_To' )->getTitle()
			);
	}

	public function testNewRevisionsFromBatch_DuplicateRows() {
		$page1 = $this->getTestPage();
		$text = __METHOD__ . 'b-ä';
		$editStatus = $this->editPage( $page1, $text . '1' );
		$this->assertStatusGood( $editStatus, 'must create revision 1' );
		$revRecord1 = $editStatus->getNewRevision();

		$status = $this->getServiceContainer()->getRevisionStore()
			->newRevisionsFromBatch(
				[
					$this->revisionRecordToRow( $revRecord1 ),
					$this->revisionRecordToRow( $revRecord1 )
				]
			);

		$this->assertStatusWarning( 'internalerror_info', $status );
	}

	public function testGetRevisionIdsBetween() {
		$NUM = 5;
		$MAX = 1;
		$page = $this->getTestPage( __METHOD__ );
		$revisions = [];
		$revisionIds = [];
		for ( $revNum = 0; $revNum < $NUM; $revNum++ ) {
			$editStatus = $this->editPage( $page, 'Revision ' . $revNum );
			$this->assertStatusGood( $editStatus, 'must create revision ' . $revNum );
			$newRevision = $editStatus->getNewRevision();
			$revisions[] = $newRevision;
			$revisionIds[] = $newRevision->getId();
		}

		$revisionStore = $this->getServiceContainer()->getRevisionStore();
		$this->assertArrayEquals(
			[],
			$revisionStore->getRevisionIdsBetween( $page->getId(), $revisions[0], $revisions[0] ),
			false,
			false,
			'Must return an empty array if the same old and new revisions provided'
		);
		$this->assertArrayEquals(
			[],
			$revisionStore->getRevisionIdsBetween( $page->getId(), $revisions[0], $revisions[1] ),
			false,
			false,
			'Must return an empty array if the consecutive old and new revisions provided'
		);
		$this->assertArrayEquals(
			array_slice( $revisionIds, 1, -2 ),
			$revisionStore->getRevisionIdsBetween( $page->getId(), $revisions[0], $revisions[$NUM - 2] ),
			false,
			false,
			'The result is non-inclusive on both ends if both beginning and end are provided'
		);
		$this->assertArrayEquals(
			array_slice( $revisionIds, 1, -1 ),
			$revisionStore->getRevisionIdsBetween(
				$page->getId(),
				$revisions[0],
				$revisions[$NUM - 2],
				null,
				RevisionStore::INCLUDE_NEW
			),
			'The inclusion string options are respected'
		);
		$this->assertArrayEquals(
			array_slice( $revisionIds, 0, -1 ),
			$revisionStore->getRevisionIdsBetween(
				$page->getId(),
				$revisions[0],
				$revisions[$NUM - 2],
				null,
				[ RevisionStore::INCLUDE_BOTH ]
			),
			false,
			false,
			'The inclusion array options are respected'
		);
		$this->assertArrayEquals(
			array_slice( $revisionIds, 1 ),
			$revisionStore->getRevisionIdsBetween( $page->getId(), $revisions[0] ),
			false,
			false,
			'The result is inclusive on the end if the end is omitted'
		);

		$this->assertArrayEquals(
			array_reverse( array_slice( $revisionIds, 1, -2 ) ),
			$revisionStore->getRevisionIdsBetween(
				$page->getId(),
				$revisions[0],
				$revisions[$NUM - 2],
				null,
				[],
				RevisionStore::ORDER_NEWEST_TO_OLDEST
			),
			true,
			false,
			'$order parameter is respected'
		);
		$this->assertSame(
			$MAX + 1, // Returns array of length $max + 1 to detect truncation.
			count( $revisionStore->getRevisionIdsBetween(
				$page->getId(),
				$revisions[0],
				$revisions[$NUM - 1],
				$MAX
			) ),
			'$max is incremented to detect truncation'
		);
	}

	public function testCountRevisionsBetween() {
		$NUM = 5;
		$MAX = 1;
		$page = $this->getTestPage( __METHOD__ );
		$revisions = [];
		for ( $revNum = 0; $revNum < $NUM; $revNum++ ) {
			$editStatus = $this->editPage( $page, 'Revision ' . $revNum );
			$this->assertStatusGood( $editStatus, 'must create revision ' . $revNum );
			$revisions[] = $editStatus->getNewRevision();
		}

		$revisionStore = $this->getServiceContainer()->getRevisionStore();
		$this->assertSame( 0,
			$revisionStore->countRevisionsBetween( $page->getId(), $revisions[0], $revisions[0] ),
			'Must return 0 if the same old and new revisions provided' );
		$this->assertSame( 0,
			$revisionStore->countRevisionsBetween( $page->getId(), $revisions[0], $revisions[1] ),
			'Must return 0 if the consecutive old and new revisions provided' );
		$this->assertEquals( $NUM - 3,
			$revisionStore->countRevisionsBetween( $page->getId(), $revisions[0], $revisions[$NUM - 2] ),
			'The count is non-inclusive on both ends if both beginning and end are provided' );
		$this->assertEquals( $NUM - 2,
			$revisionStore->countRevisionsBetween( $page->getId(), $revisions[0], $revisions[$NUM - 2],
				null, RevisionStore::INCLUDE_NEW ),
			'The count string options are respected' );
		$this->assertEquals( $NUM - 1,
			$revisionStore->countRevisionsBetween( $page->getId(), $revisions[0], $revisions[$NUM - 2],
				null, [ RevisionStore::INCLUDE_BOTH ] ),
			'The count array options are respected' );
		$this->assertEquals( $NUM - 1,
			$revisionStore->countRevisionsBetween( $page->getId(), $revisions[0] ),
			'The count is inclusive on the end if the end is omitted' );
		$this->assertEquals( $NUM + 1, // There was one revision from creating a page, thus NUM + 1
			$revisionStore->countRevisionsBetween( $page->getId() ),
			'The count is inclusive if both beginning and end are omitted' );
		$this->assertEquals( $MAX + 1, // Returns $max + 1 to detect truncation.
			$revisionStore->countRevisionsBetween( $page->getId(), $revisions[0],
				$revisions[$NUM - 1], $MAX ),
			'The $max is incremented to detect truncation' );
	}

	public function testAuthorsBetween() {
		$this->disableAutoCreateTempUser();
		$NUM = 5;
		$page = $this->getTestPage( __METHOD__ );
		$users = [
			$this->getTestUser()->getUser(),
			$this->getTestUser()->getUser(),
			$this->getTestSysop()->getUser(),
			new User(),
			$this->getMutableTestUser()->getUser()
		];
		$revisions = [];
		for ( $revNum = 0; $revNum < $NUM; $revNum++ ) {
			$editStatus = $this->editPage(
				$page,
				'Revision ' . $revNum,
				'',
				NS_MAIN,
				$users[$revNum] );
			$this->assertStatusGood( $editStatus, 'must create revision ' . $revNum );
			$revisions[] = $editStatus->getNewRevision();
		}

		$revisionStore = $this->getServiceContainer()->getRevisionStore();
		$this->assertSame( 0,
			$revisionStore->countAuthorsBetween( $page->getId(), $revisions[0], $revisions[0] ),
			'countAuthorsBetween must return 0 if the same old and new revisions provided' );
		$this->assertArrayEquals( [],
			$revisionStore->getAuthorsBetween( $page->getId(), $revisions[0], $revisions[0] ),
			'getAuthorsBetween must return [] if the same old and new revisions provided' );

		$this->assertSame( 0,
			$revisionStore->countAuthorsBetween( $page->getId(), $revisions[0], $revisions[1] ),
			'countAuthorsBetween must return 0 if the consecutive old and new revisions provided' );
		$this->assertArrayEquals( [],
			$revisionStore->getAuthorsBetween( $page->getId(), $revisions[0], $revisions[1] ),
			'getAuthorsBetween must return [] if the consecutive old and new revisions provided' );

		$this->assertEquals( 2,
			$revisionStore->countAuthorsBetween( $page->getId(), $revisions[0], $revisions[$NUM - 2] ),
			'countAuthorsBetween is non-inclusive on both ends if both beginning and end are provided' );
		$result = $revisionStore->getAuthorsBetween( $page->getId(),
			$revisions[0], $revisions[$NUM - 2] );
		$this->assertCount( 2, $result,
			'getAuthorsBetween provides right number of users' );
	}

	public static function provideBetweenMethodNames() {
		yield [ 'getRevisionIdsBetween' ];
		yield [ 'countRevisionsBetween' ];
		yield [ 'countAuthorsBetween' ];
		yield [ 'getAuthorsBetween' ];
	}

	/**
	 * @dataProvider provideBetweenMethodNames
	 *
	 * @param string $method the name of the method to test
	 */
	public function testBetweenMethod_differentPages( $method ) {
		$page1 = $this->getTestPage( __METHOD__ );
		$page2 = $this->getTestPage( 'Other_Page' );
		$editStatus = $this->editPage( $page1, 'Revision 1' );
		$this->assertStatusGood( $editStatus, 'must create revision 1' );
		$rev1 = $editStatus->getNewRevision();
		$editStatus = $this->editPage( $page2, 'Revision 1' );
		$this->assertStatusGood( $editStatus, 'must create revision 1' );
		$rev2 = $editStatus->getNewRevision();

		$this->expectException( InvalidArgumentException::class );
		$this->getServiceContainer()->getRevisionStore()
			->{$method}( $page1->getId(), $rev1, $rev2 );
	}

	/**
	 * @dataProvider provideBetweenMethodNames
	 *
	 * @param string $method the name of the method to test
	 */
	public function testBetweenMethod_unsavedRevision( $method ) {
		$rev1 = new MutableRevisionRecord( $this->getTestPageTitle() );
		$rev2 = new MutableRevisionRecord( $this->getTestPageTitle() );

		$this->expectException( InvalidArgumentException::class );
		$this->getServiceContainer()->getRevisionStore()->{$method}(
			$this->getTestPage()->getId(), $rev1, $rev2 );
	}

	/**
	 * @dataProvider provideGetFirstRevision
	 * @param callable $getPageIdentity
	 */
	public function testGetFirstRevision( $getPageIdentity ) {
		[ $pageTitle, $pageIdentity ] = $getPageIdentity();
		$editStatus = $this->editPage( $pageTitle->getPrefixedDBkey(), 'First Revision' );
		$this->assertStatusGood( $editStatus, 'must create first revision' );
		$firstRevId = $editStatus->getNewRevision()->getId();
		$editStatus = $this->editPage( $pageTitle->getPrefixedText(), 'New Revision' );
		$this->assertStatusGood( $editStatus, 'must create new revision' );
		$this->assertNotSame(
			$firstRevId,
			$editStatus->getNewRevision()->getId(),
			'new revision must have different id'
		);
		$this->assertSame(
			$firstRevId,
			$this->getServiceContainer()
				->getRevisionStore()
				->getFirstRevision( $pageIdentity )
				->getId()
		);
	}

	public static function provideGetFirstRevision() {
		return [
			[ static function () {
				$pageTitle = Title::newFromText( 'Test_Get_First_Revision' );
				return [ $pageTitle, $pageTitle ];
			} ],
			[ static function () {
				$pageTitle = Title::newFromText( 'Test_Get_First_Revision' );
				return [ $pageTitle, $pageTitle->toPageIdentity() ];
			} ]
		];
	}

	public function testGetFirstRevision_nonexistent_page() {
		$this->assertNull(
			$this->getServiceContainer()
				->getRevisionStore()
				->getFirstRevision( $this->getNonexistingTestPage( __METHOD__ )->getTitle() )
		);
	}

	public static function provideInsertRevisionByAnonAssignsNewActor() {
		yield 'User' => [ '127.1.1.0', static function ( MediaWikiServices $services, string $ip ) {
			return $services->getUserFactory()->newAnonymous( $ip );
		} ];
		yield 'User identity, anon' => [ '127.1.1.1', static function ( MediaWikiServices $services, string $ip ) {
			return new UserIdentityValue( 0, $ip );
		} ];
	}

	/**
	 * @dataProvider provideInsertRevisionByAnonAssignsNewActor
	 */
	public function testInsertRevisionByAnonAssignsNewActor( string $ip, callable $userInitCallback ) {
		$this->disableAutoCreateTempUser();
		$user = $userInitCallback( $this->getServiceContainer(), $ip );

		$actorNormalization = $this->getServiceContainer()->getActorNormalization();
		$actorId = $actorNormalization->findActorId( $user, $this->getDb() );
		$this->assertNull( $actorId, 'New actor has no actor_id' );

		$page = $this->getTestPage();
		$rev = new MutableRevisionRecord( $page->getTitle() );
		$rev->setTimestamp( '20180101000000' )
			->setComment( CommentStoreComment::newUnsavedComment( 'test' ) )
			->setUser( $user )
			->setContent( SlotRecord::MAIN, new WikitextContent( 'Text' ) )
			->setPageId( $page->getId() );

		$return = $this->getServiceContainer()->getRevisionStore()->insertRevisionOn( $rev, $this->getDb() );
		$this->assertSame( $ip, $return->getUser()->getName() );

		$actorId = $actorNormalization->findActorId( $user, $this->getDb() );
		$this->assertNotNull( $actorId );
	}

	public function testGetTitle_successFromPageId() {
		$page = $this->getExistingTestPage();

		$store = $this->getServiceContainer()->getRevisionStore();
		$title = $store->getTitle( $page->getId(), 0, IDBAccessObject::READ_NORMAL );

		$this->assertTrue( $page->isSamePageAs( $title ) );
	}

	public function testGetTitle_successFromRevId() {
		$page = $this->getExistingTestPage();

		$store = $this->getServiceContainer()->getRevisionStore();
		$title = $store->getTitle( 0, $page->getLatest(), IDBAccessObject::READ_NORMAL );

		$this->assertTrue( $page->isSamePageAs( $title ) );
	}

	public function testGetTitle_failure() {
		$store = $this->getServiceContainer()->getRevisionStore();

		$this->expectException( RevisionAccessException::class );
		$store->getTitle( 113349857, 897234779, IDBAccessObject::READ_NORMAL );
	}

	public function testGetQueryInfo_NoSlotDataJoin() {
		$store = $this->getServiceContainer()->getRevisionStore();
		$queryInfo = $store->getQueryInfo();

		// with the new schema enabled, query info should not join the main slot info
		$this->assertArrayNotHasKey( 'a_slot_data', $queryInfo['tables'] );
		$this->assertArrayNotHasKey( 'a_slot_data', $queryInfo['joins'] );
	}

	public function testInsertRevisionOn_T202032() {
		// This test only makes sense for MySQL
		if ( $this->getDb()->getType() !== 'mysql' ) {
			$this->assertTrue( true );
			return;
		}

		// NOTE: must be done before checking MAX(rev_id)
		$page = $this->getTestPage();

		$maxRevId = $this->getDb()->newSelectQueryBuilder()
			->select( 'MAX(rev_id)' )
			->from( 'revision' )
			->fetchField();

		// Construct a slot row that will conflict with the insertion of the next revision ID,
		// to emulate the failure mode described in T202032. Nothing will ever read this row,
		// we just need it to trigger a primary key conflict.
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'slots' )
			->row( [
				'slot_revision_id' => $maxRevId + 1,
				'slot_role_id' => 1,
				'slot_content_id' => 0,
				'slot_origin' => 0
			] )
			->caller( __METHOD__ )
			->execute();

		$rev = new MutableRevisionRecord( $page->getTitle() );
		$rev->setTimestamp( '20180101000000' )
			->setComment( CommentStoreComment::newUnsavedComment( 'test' ) )
			->setUser( $this->getTestUser()->getUser() )
			->setContent( SlotRecord::MAIN, new WikitextContent( 'Text' ) )
			->setPageId( $page->getId() );

		$store = $this->getServiceContainer()->getRevisionStore();
		$return = $store->insertRevisionOn( $rev, $this->getDb() );

		$this->assertSame( $maxRevId + 2, $return->getId() );

		// is the new revision correct?
		$this->assertRevisionCompleteness( $return );
		$this->assertRevisionRecordsEqual( $rev, $return );

		// can we find it directly in the database?
		$this->assertRevisionExistsInDatabase( $return );

		// can we load it from the store?
		$loaded = $store->getRevisionById( $return->getId() );
		$this->assertRevisionCompleteness( $loaded );
		$this->assertRevisionRecordsEqual( $return, $loaded );
	}

	public function testGetContentBlobsForBatch_error() {
		$page1 = $this->getTestPage();
		$text = __METHOD__ . 'b-ä';
		$editStatus = $this->editPage( $page1, $text . '1' );
		$this->assertStatusGood( $editStatus, 'must create revision 1' );
		$revRecord1 = $editStatus->getNewRevision();

		$contentAddress = $revRecord1->getSlot( SlotRecord::MAIN )->getAddress();
		$blobStatus = StatusValue::newGood( [] );
		$blobStatus->warning( 'internalerror_info', 'oops!' );

		$mockBlobStore = $this->createMock( BlobStore::class );
		$mockBlobStore->method( 'getBlobBatch' )
			->willReturn( $blobStatus );

		$revStore = $this->getServiceContainer()
			->getRevisionStoreFactory()
			->getRevisionStore();
		$wrappedRevStore = TestingAccessWrapper::newFromObject( $revStore );
		$wrappedRevStore->blobStore = $mockBlobStore;

		$result = $revStore->getContentBlobsForBatch( [ $revRecord1->getId() ] );
		$this->assertStatusWarning( 'internalerror_info', $result );

		$records = $result->getValue();
		$this->assertArrayHasKey( $revRecord1->getId(), $records );

		$mainRow = $records[$revRecord1->getId()][SlotRecord::MAIN];
		$this->assertNull( $mainRow->blob_data );
		$this->assertStatusMessagesExactly(
			StatusValue::newGood()
				->warning( 'internalerror_info', 'oops!' )
				->warning( 'internalerror_info', "Couldn't find blob data for rev {$revRecord1->getId()}" ),
			$result
		);
	}

	public function testGetContentBlobsForBatchUsesGetBlobBatch() {
		$page1 = $this->getTestPage();
		$text = __METHOD__ . 'b-ä';
		$editStatus = $this->editPage( $page1, $text . '1' );
		$this->assertStatusGood( $editStatus, 'must create revision 1' );
		$revRecord1 = $editStatus->getNewRevision();

		$contentAddress = $revRecord1->getSlot( SlotRecord::MAIN )->getAddress();
		$mockBlobStore = $this->createMock( SqlBlobStore::class );
		$mockBlobStore
			->expects( $this->once() )
			->method( 'getBlobBatch' )
			->with( [ $contentAddress ], $this->anything() )
			->willReturn( StatusValue::newGood( [
				$contentAddress => 'Content_From_Mock'
			] ) );
		$mockBlobStore
			->expects( $this->never() )
			->method( 'getBlob' );

		$revStore = $this->getServiceContainer()
			->getRevisionStoreFactory()
			->getRevisionStore();
		$wrappedRevStore = TestingAccessWrapper::newFromObject( $revStore );
		$wrappedRevStore->blobStore = $mockBlobStore;

		$result = $revStore->getContentBlobsForBatch(
			[ $revRecord1->getId() ],
			[ SlotRecord::MAIN ]
		);
		$this->assertStatusGood( $result );
		$this->assertSame( 'Content_From_Mock',
			$result->getValue()[$revRecord1->getId()][SlotRecord::MAIN]->blob_data );
	}

	public function testNewRevisionsFromBatch_error() {
		$page = $this->getTestPage();
		$text = __METHOD__ . 'b-ä';
		$revRecord1 = $page->doUserEditContent(
			new WikitextContent( $text . '1' ),
			$this->getTestUser()->getUser(),
			__METHOD__ . 'b'
		)->getNewRevision();

		$invalidRow = $this->revisionRecordToRow( $revRecord1 );
		$invalidRow->rev_id = 100500;
		$result = $this->getServiceContainer()->getRevisionStore()
			->newRevisionsFromBatch(
				[ $this->revisionRecordToRow( $revRecord1 ), $invalidRow ],
				[
					'slots' => [ SlotRecord::MAIN ],
					'content' => true
				]
			);
		$this->assertStatusWarning( 'internalerror_info', $result );
		$records = $result->getValue();
		$this->assertRevisionRecordsEqual( $revRecord1, $records[$revRecord1->getId()] );
		$this->assertSame( $text . '1',
			$records[$revRecord1->getId()]->getContent( SlotRecord::MAIN )->serialize() );
		$this->assertEquals( $page->getTitle()->getDBkey(),
			$records[$revRecord1->getId()]->getPageAsLinkTarget()->getDBkey() );
		$this->assertNull( $records[$invalidRow->rev_id] );
		$this->assertStatusMessagesExactly(
			StatusValue::newGood()
				->warning( 'internalerror_info', "Couldn't find slots for rev 100500" ),
			$result
		);
	}

	public function testNewRevisionFromBatchUsesGetBlobBatch() {
		$page1 = $this->getTestPage();
		$text = __METHOD__ . 'b-ä';
		$editStatus = $this->editPage( $page1, $text . '1' );
		$this->assertStatusGood( $editStatus, 'must create revision 1' );
		$revRecord1 = $editStatus->getNewRevision();

		$contentAddress = $revRecord1->getSlot( SlotRecord::MAIN )->getAddress();
		$mockBlobStore = $this->createMock( SqlBlobStore::class );
		$mockBlobStore
			->expects( $this->once() )
			->method( 'getBlobBatch' )
			->with( [ $contentAddress ], $this->anything() )
			->willReturn( StatusValue::newGood( [
				$contentAddress => 'Content_From_Mock'
			] ) );
		$mockBlobStore
			->expects( $this->never() )
			->method( 'getBlob' );

		$revStore = $this->getServiceContainer()
			->getRevisionStoreFactory()
			->getRevisionStore();
		$wrappedRevStore = TestingAccessWrapper::newFromObject( $revStore );
		$wrappedRevStore->blobStore = $mockBlobStore;

		$result = $revStore->newRevisionsFromBatch(
			[ $this->revisionRecordToRow( $revRecord1 ) ],
			[
				'slots' => [ SlotRecord::MAIN ],
				'content' => true
			]
		);
		$this->assertStatusGood( $result );
		$content = $result->getValue()[$revRecord1->getId()]->getContent( SlotRecord::MAIN );
		$this->assertInstanceOf( TextContent::class, $content );
		$this->assertSame(
			'Content_From_Mock',
			$content->getText()
		);
	}

	public function testFindIdenticalRevision() {
		// Prepare a page with 3 revisions
		$page = $this->getExistingTestPage( __METHOD__ );
		$status = $this->editPage( $page, 'Content 1' );
		$this->assertStatusGood( $status, 'edit 1' );
		$originalRev = $status->getNewRevision();

		$this->assertStatusGood( $this->editPage( $page, 'Content 2' ), 'edit 2' );

		$status = $this->editPage( $page, 'Content 1' );
		$this->assertStatusGood( $status, 'edit 3' );
		$latestRev = $status->getNewRevision();

		$store = $this->getServiceContainer()->getRevisionStore();

		$this->assertNull( $store->findIdenticalRevision( $latestRev, 0 ) );
		$this->assertNull( $store->findIdenticalRevision( $latestRev, 1 ) );
		$foundRev = $store->findIdenticalRevision( $latestRev, 1000 );
		$this->assertSame( $originalRev->getId(), $foundRev->getId() );
	}
}
PK       ! &  &  "  Revision/RevisionQueryInfoTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Revision;

use MediaWikiIntegrationTestCase;

/**
 * Tests RevisionStore against the post-migration MCR DB schema.
 *
 * @group RevisionStore
 * @group Storage
 * @group Database
 */
class RevisionQueryInfoTest extends MediaWikiIntegrationTestCase {

	protected function getRevisionQueryFields( $returnTextIdField = true ) {
		$fields = [
			'rev_id',
			'rev_page',
			'rev_timestamp',
			'rev_minor_edit',
			'rev_deleted',
			'rev_len',
			'rev_parent_id',
			'rev_sha1',
		];
		if ( $returnTextIdField ) {
			$fields[] = 'rev_text_id';
		}
		return $fields;
	}

	protected function getArchiveQueryFields( $returnTextFields = true ) {
		$fields = [
			'ar_id',
			'ar_page_id',
			'ar_namespace',
			'ar_title',
			'ar_rev_id',
			'ar_timestamp',
			'ar_minor_edit',
			'ar_deleted',
			'ar_len',
			'ar_parent_id',
			'ar_sha1',
		];
		if ( $returnTextFields ) {
			$fields[] = 'ar_text_id';
		}
		return $fields;
	}

	protected function getCommentQueryFields( $prefix ) {
		return [
			"{$prefix}_comment_text" => "comment_{$prefix}_comment.comment_text",
			"{$prefix}_comment_data" => "comment_{$prefix}_comment.comment_data",
			"{$prefix}_comment_cid" => "comment_{$prefix}_comment.comment_id",
		];
	}

	protected function getActorQueryFields( $prefix, $tmp = false ) {
		if ( $tmp ) {
			return [
				"{$prefix}_user" => "actor_{$prefix}_user.actor_user",
				"{$prefix}_user_text" => "actor_{$prefix}_user.actor_name",
				"{$prefix}_actor" => "temp_{$prefix}_user.{$prefix}actor_actor",
			];
		} elseif ( $prefix === 'ar' ) {
			return [
				"{$prefix}_actor",
				"{$prefix}_user" => 'archive_actor.actor_user',
				"{$prefix}_user_text" => 'archive_actor.actor_name',
			];
		} else {
			return [
				"{$prefix}_actor" => "{$prefix}_actor",
				"{$prefix}_user" => "actor_{$prefix}_user.actor_user",
				"{$prefix}_user_text" => "actor_{$prefix}_user.actor_name",
			];
		}
	}

	protected function getTextQueryFields() {
		return [
			'old_text',
			'old_flags',
		];
	}

	protected function getPageQueryFields() {
		return [
			'page_namespace',
			'page_title',
			'page_id',
			'page_latest',
			'page_is_redirect',
			'page_len',
		];
	}

	protected function getUserQueryFields() {
		return [
			'user_name',
		];
	}

	protected function getContentHandlerQueryFields( $prefix ) {
		return [
			"{$prefix}_content_format",
			"{$prefix}_content_model",
		];
	}

	public function provideArchiveQueryInfo() {
		yield 'no options' => [
			[],
			[
				'tables' => [
					'archive',
					'archive_actor' => 'actor',
					'comment_ar_comment' => 'comment',
				],
				'fields' => array_merge(
					$this->getArchiveQueryFields( false ),
					$this->getActorQueryFields( 'ar' ),
					$this->getCommentQueryFields( 'ar' )
				),
				'joins' => [
					'comment_ar_comment'
						=> [ 'JOIN', 'comment_ar_comment.comment_id = ar_comment_id' ],
					'archive_actor' => [ 'JOIN', 'actor_id=ar_actor' ],
				],
			]
		];
	}

	public function provideQueryInfo() {
		// TODO: more option variations
		yield 'page and user option, actor-new' => [
			[],
			[ 'page', 'user' ],
			[
				'tables' => [
					'revision',
					'page',
					'user',
					'actor_rev_user' => 'actor',
					'comment_rev_comment' => 'comment',
				],
				'fields' => array_merge(
					$this->getRevisionQueryFields( false ),
					$this->getPageQueryFields(),
					$this->getUserQueryFields(),
					$this->getActorQueryFields( 'rev' ),
					$this->getCommentQueryFields( 'rev' )
				),
				'joins' => [
					'page' => [ 'JOIN', [ 'page_id = rev_page' ] ],
					'user' => [
						'LEFT JOIN',
						[ 'actor_rev_user.actor_user != 0', 'user_id = actor_rev_user.actor_user' ],
					],
					'comment_rev_comment' => [ 'JOIN', 'comment_rev_comment.comment_id = rev_comment_id' ],
					'actor_rev_user' => [ 'JOIN', 'actor_rev_user.actor_id = rev_actor' ]
				],
			]
		];
		yield 'no options, actor-new' => [
			[],
			[],
			[
				'tables' => [
					'revision',
					'actor_rev_user' => 'actor',
					'comment_rev_comment' => 'comment',
				],
				'fields' => array_merge(
					$this->getRevisionQueryFields( false ),
					$this->getActorQueryFields( 'rev' ),
					$this->getCommentQueryFields( 'rev' )
				),
				'joins' => [
					'comment_rev_comment' => [ 'JOIN', 'comment_rev_comment.comment_id = rev_comment_id' ],
					'actor_rev_user' => [ 'JOIN', 'actor_rev_user.actor_id = rev_actor' ],
				]
			]
		];
	}

	public static function provideSlotsQueryInfo() {
		yield 'no options' => [
			[],
			[],
			[
				'tables' => [
					'slots'
				],
				'fields' => [
					'slot_revision_id',
					'slot_content_id',
					'slot_origin',
					'slot_role_id',
				],
				'joins' => [],
				'keys' => [
					'rev_id' => 'slot_revision_id',
					'role_id' => 'slot_role_id'
				],
			]
		];
		yield 'role option' => [
			[],
			[ 'role' ],
			[
				'tables' => [
					'slots',
					'slot_roles',
				],
				'fields' => [
					'slot_revision_id',
					'slot_content_id',
					'slot_origin',
					'slot_role_id',
					'role_name',
				],
				'joins' => [
					'slot_roles' => [ 'LEFT JOIN', [ 'slot_role_id = role_id' ] ],
				],
				'keys' => [
					'rev_id' => 'slot_revision_id',
					'role_id' => 'slot_role_id'
				],
			]
		];
		yield 'content option' => [
			[],
			[ 'content' ],
			[
				'tables' => [
					'slots',
					'content',
				],
				'fields' => [
					'slot_revision_id',
					'slot_content_id',
					'slot_origin',
					'slot_role_id',
					'content_size',
					'content_sha1',
					'content_address',
					'content_model',
				],
				'joins' => [
					'content' => [ 'JOIN', [ 'slot_content_id = content_id' ] ],
				],
				'keys' => [
					'rev_id' => 'slot_revision_id',
					'role_id' => 'slot_role_id',
					'model_id' => 'content_model',
				],
			]
		];
		yield 'content and model options' => [
			[],
			[ 'content', 'model' ],
			[
				'tables' => [
					'slots',
					'content',
					'content_models',
				],
				'fields' => [
					'slot_revision_id',
					'slot_content_id',
					'slot_origin',
					'slot_role_id',
					'content_size',
					'content_sha1',
					'content_address',
					'content_model',
					'model_name',
				],
				'joins' => [
					'content' => [ 'JOIN', [ 'slot_content_id = content_id' ] ],
					'content_models' => [ 'LEFT JOIN', [ 'content_model = model_id' ] ],
				],
				'keys' => [
					'rev_id' => 'slot_revision_id',
					'role_id' => 'slot_role_id',
					'model_id' => 'content_model',
				],
			]
		];
	}

	/**
	 * @dataProvider provideQueryInfo
	 * @covers \MediaWiki\Revision\RevisionStore::getQueryInfo
	 */
	public function testRevisionStoreGetQueryInfo( $migrationStageSettings, $options, $expected ) {
		$this->overrideConfigValues( $migrationStageSettings );

		$store = $this->getServiceContainer()->getRevisionStore();

		$queryInfo = $store->getQueryInfo( $options );
		$this->assertQueryInfoEquals( $expected, $queryInfo );
	}

	/**
	 * @dataProvider provideSlotsQueryInfo
	 * @covers \MediaWiki\Revision\RevisionStore::getSlotsQueryInfo
	 */
	public function testRevisionStoreGetSlotsQueryInfo(
		$migrationStageSettings,
		$options,
		$expected
	) {
		$this->overrideConfigValues( $migrationStageSettings );

		$store = $this->getServiceContainer()->getRevisionStore();

		$queryInfo = $store->getSlotsQueryInfo( $options );
		$this->assertQueryInfoEquals( $expected, $queryInfo );
	}

	/**
	 * @dataProvider provideArchiveQueryInfo
	 * @covers \MediaWiki\Revision\RevisionStore::getArchiveQueryInfo
	 */
	public function testRevisionStoreGetArchiveQueryInfo( $migrationStageSettings, $expected ) {
		$this->overrideConfigValues( $migrationStageSettings );

		$store = $this->getServiceContainer()->getRevisionStore();

		$queryInfo = $store->getArchiveQueryInfo();
		$this->assertQueryInfoEquals( $expected, $queryInfo );
	}

	private function assertQueryInfoEquals( $expected, $queryInfo ) {
		$this->assertArrayEqualsIgnoringIntKeyOrder(
			$expected['tables'],
			$queryInfo['tables'],
			'tables'
		);
		$this->assertArrayEqualsIgnoringIntKeyOrder(
			$expected['fields'],
			$queryInfo['fields'],
			'fields'
		);
		$this->assertArrayEqualsIgnoringIntKeyOrder(
			$expected['joins'],
			$queryInfo['joins'],
			'joins'
		);
		if ( isset( $expected['keys'] ) ) {
			$this->assertArrayEqualsIgnoringIntKeyOrder(
				$expected['keys'],
				$queryInfo['keys'],
				'keys'
			);
		}
	}

	/**
	 * Assert that the two arrays passed are equal, ignoring the order of the values that integer
	 * keys.
	 *
	 * Note: Failures of this assertion can be slightly confusing as the arrays are actually
	 * split into a string key array and an int key array before assertions occur.
	 *
	 * @param array $expected
	 * @param array $actual
	 * @param string|null $message
	 */
	private function assertArrayEqualsIgnoringIntKeyOrder(
		array $expected,
		array $actual,
		$message = null
	) {
		$this->objectAssociativeSort( $expected );
		$this->objectAssociativeSort( $actual );

		// Separate the int key values from the string key values so that assertion failures are
		// easier to understand.
		$expectedIntKeyValues = [];
		$actualIntKeyValues = [];

		// Remove all int keys and re add them at the end after sorting by value
		// This will result in all int keys being in the same order with same ints at the end of
		// the array
		foreach ( $expected as $key => $value ) {
			if ( is_int( $key ) ) {
				unset( $expected[$key] );
				$expectedIntKeyValues[] = $value;
			}
		}
		foreach ( $actual as $key => $value ) {
			if ( is_int( $key ) ) {
				unset( $actual[$key] );
				$actualIntKeyValues[] = $value;
			}
		}

		$this->objectAssociativeSort( $expected );
		$this->objectAssociativeSort( $actual );

		$this->objectAssociativeSort( $expectedIntKeyValues );
		$this->objectAssociativeSort( $actualIntKeyValues );

		$this->assertEquals( $expected, $actual, $message );
		$this->assertEquals( $expectedIntKeyValues, $actualIntKeyValues, $message );
	}

}
PK       ! @7G    &  Revision/MutableRevisionRecordTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Revision;

use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use MediaWikiIntegrationTestCase;
use MockTitleTrait;
use Wikimedia\Assert\PreconditionException;

/**
 * @covers \MediaWiki\Revision\MutableRevisionRecord
 * @covers \MediaWiki\Revision\RevisionRecord
 */
class MutableRevisionRecordTest extends MediaWikiIntegrationTestCase {
	use MockTitleTrait;

	public static function provideConstructor() {
		$title = Title::makeTitle( NS_MAIN, 'Dummy' );
		$title->resetArticleID( 17 );
		yield 'local wiki, with title' => [ $title, PageIdentity::LOCAL ];
		yield 'local wiki' => [
			new PageIdentityValue( 17, NS_MAIN, 'Dummy', PageIdentity::LOCAL ),
			PageIdentity::LOCAL,
		];
		yield 'foreign wiki' => [
			new PageIdentityValue( 17, NS_MAIN, 'Dummy', 'acmewiki' ),
			'acmewiki',
			PreconditionException::class
		];
	}

	/**
	 * @dataProvider provideConstructor
	 *
	 * @param PageIdentity $page
	 * @param string|false $wikiId
	 * @param string|null $expectedException
	 */
	public function testConstructorAndGetters(
		PageIdentity $page,
		$wikiId = RevisionRecord::LOCAL,
		?string $expectedException = null
	) {
		$rec = new MutableRevisionRecord( $page, $wikiId );

		$this->assertTrue( $page->isSamePageAs( $rec->getPage() ), 'getPage' );
		$this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );

		if ( $expectedException ) {
			$this->expectException( $expectedException );
			$rec->getPageAsLinkTarget();
		} else {
			$this->assertTrue(
				TitleValue::newFromPage( $page )->isSameLinkAs( $rec->getPageAsLinkTarget() ),
				'getPageAsLinkTarget'
			);
		}
	}
}
PK       ! 2},r,  r,    Revision/RevisionStoreTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Revision;

use MediaWiki\Content\WikitextContent;
use MediaWiki\Content\WikitextContentHandler;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Revision\IncompleteRevisionException;
use MediaWiki\Revision\RevisionAccessException;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiIntegrationTestCase;
use MWException;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IDBAccessObject;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Rdbms\LBFactory;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @covers \MediaWiki\Revision\RevisionStore
 */
class RevisionStoreTest extends MediaWikiIntegrationTestCase {

	private function getRevisionStore(): RevisionStore {
		return $this->getServiceContainer()->getRevisionStore();
	}

	/**
	 * @param IDatabase $db
	 * @return MockObject|ILoadBalancer
	 */
	private function installMockLoadBalancer( IDatabase $db ) {
		$lb = $this->createNoOpMock(
			ILoadBalancer::class,
			[ 'getConnection', 'getLocalDomainID' ]
		);

		$lb->method( 'getConnection' )->willReturn( $db );
		$lb->method( 'getLocalDomainID' )->willReturn( 'fake' );

		$lbf = $this->createNoOpMock( LBFactory::class, [ 'getMainLB', 'getLocalDomainID' ] );
		$lbf->method( 'getMainLB' )->willReturn( $lb );
		$lbf->method( 'getLocalDomainID' )->willReturn( 'fake' );

		$this->setService( 'DBLoadBalancerFactory', $lbf );
		return $lb;
	}

	/**
	 * @return MockObject|IDatabase
	 */
	private function installMockDatabase() {
		$db = $this->getMockBuilder( IDatabase::class )
			->disableAutoReturnValueGeneration()
			->disableOriginalConstructor()->getMock();

		$db->method( 'getDomainId' )->willReturn( 'fake' );

		$this->installMockLoadBalancer( $db );
		return $db;
	}

	private function getDummyPageRow( $extra = [] ) {
		return (object)( $extra + [
			'page_id' => 1337,
			'page_namespace' => 0,
			'page_title' => 'Test',
			'page_is_redirect' => 0,
			'page_is_new' => 0,
			'page_touched' => MWTimestamp::now(),
			'page_links_updated' => MWTimestamp::now(),
			'page_latest' => 23948576,
			'page_len' => 2323,
			'page_content_model' => CONTENT_MODEL_WIKITEXT,
			'page_lang' => null,
		] );
	}

	public function testGetTitle_successFromPageId() {
		$db = $this->installMockDatabase();

		// First query is by page ID. Return result
		$db
			->method( 'selectRow' )
			->with(
				[ 'page' ],
				$this->anything(),
				[ 'page_id' => 1 ]
			)
			->willReturn( $this->getDummyPageRow( [
				'page_id' => '1',
				'page_namespace' => '3',
				'page_title' => 'Food',
			] ) );

		$store = $this->getRevisionStore();
		$title = $store->getTitle( 1, 2, IDBAccessObject::READ_NORMAL );

		$this->assertSame( 3, $title->getNamespace() );
		$this->assertSame( 'Food', $title->getDBkey() );
	}

	public function testGetTitle_successFromPageIdOnFallback() {
		$db = $this->installMockDatabase();

		$selectRowArgs = [
			[
				// First query, by page_id, no result
				[ 'page' ],
				[ 'page_id' => 1 ],
				false,
			],
			[
				// Second query, by rev_id, no result
				[ 0 => 'page', 'revision' => 'revision' ],
				[ 'rev_id' => 2 ],
				false,
			],
			[
				// Third query, retrying by page_id again on master
				[ 'page' ],
				[ 'page_id' => 1 ],
				$this->getDummyPageRow( [
					'page_namespace' => '2',
					'page_title' => 'Foodey',
				] )
			]
		];
		$db->expects( $this->exactly( 3 ) )
			->method( 'selectRow' )
			->willReturnCallback( function ( $table, $vars, $conds ) use ( &$selectRowArgs ) {
				[ $nextTable, $nextConds, $returnValue ] = array_shift( $selectRowArgs );
				$this->assertSame( $nextTable, $table );
				$this->assertSame( $nextConds, $conds );
				return $returnValue;
			} );

		$store = $this->getRevisionStore();
		$title = $store->getTitle( 1, 2, IDBAccessObject::READ_NORMAL );

		$this->assertSame( 2, $title->getNamespace() );
		$this->assertSame( 'Foodey', $title->getDBkey() );
	}

	public function testGetTitle_successFromRevId() {
		$db = $this->installMockDatabase();

		$selectRowArgs = [
			[
				[ 'page' ],
				[ 'page_id' => 1 ],
				false,
			],
			[
				[ 0 => 'page', 'revision' => 'revision' ],
				[ 'rev_id' => 2 ],
				$this->getDummyPageRow( [
					'page_namespace' => '1',
					'page_title' => 'Food2',
				] )
			]
		];
		// First call to Title::newFromID, faking no result (db lag?)
		// Second select using rev_id, faking no result (db lag?)
		$db->expects( $this->exactly( 2 ) )
			->method( 'selectRow' )
			->willReturnCallback( function ( $table, $vars, $conds ) use ( &$selectRowArgs ) {
				[ $nextTable, $nextConds, $returnValue ] = array_shift( $selectRowArgs );
				$this->assertSame( $nextTable, $table );
				$this->assertSame( $nextConds, $conds );
				return $returnValue;
			} );

		$store = $this->getRevisionStore();
		$title = $store->getTitle( 1, 2, IDBAccessObject::READ_NORMAL );

		$this->assertSame( 1, $title->getNamespace() );
		$this->assertSame( 'Food2', $title->getDBkey() );
	}

	public function testGetTitle_successFromRevIdOnFallback() {
		$db = $this->installMockDatabase();

		$selectRowArgs = [
			[
				// First query, by page_id, no result
				[ 'page' ],
				[ 'page_id' => 1 ],
				false,
			],
			[
				// Second query, by rev_id, no result
				[ 0 => 'page', 'revision' => 'revision' ],
				[ 'rev_id' => 2 ],
				false,
			],
			[
				// Third query, retrying by page_id again on master, still no result
				[ 'page' ],
				[ 'page_id' => 1 ],
				false,
			],
			[
				// Fourth query, by rev_id again
				[ 0 => 'page', 'revision' => 'revision' ],
				[ 'rev_id' => 2 ],
				$this->getDummyPageRow( [
					'page_namespace' => '2',
					'page_title' => 'Foodey',
				] )
			]
		];
		$db->expects( $this->exactly( 4 ) )
			->method( 'selectRow' )
			->willReturnCallback( function ( $table, $vars, $conds ) use ( &$selectRowArgs ) {
				[ $nextTable, $nextConds, $returnValue ] = array_shift( $selectRowArgs );
				$this->assertSame( $nextTable, $table );
				$this->assertSame( $nextConds, $conds );
				return $returnValue;
			} );

		$store = $this->getRevisionStore();
		$title = $store->getTitle( 1, 2, IDBAccessObject::READ_NORMAL );

		$this->assertSame( 2, $title->getNamespace() );
		$this->assertSame( 'Foodey', $title->getDBkey() );
	}

	public function testGetTitle_correctFallbackAndthrowsExceptionAfterFallbacks() {
		$db = $this->createMock( IDatabase::class );
		$mockLoadBalancer = $this->installMockLoadBalancer( $db );

		// Assert that the first call uses a REPLICA and the second falls back to master

		// RevisionStore getTitle uses getConnection
		$mockLoadBalancer->expects( $this->exactly( 4 ) )
			->method( 'getConnection' )
			->willReturnCallback( function ( $masterOrReplica ) use ( $db ) {
				static $callCounter = 0;
				$callCounter++;
				// The first call should be to a REPLICA, and the second a MASTER.
				if ( $callCounter < 3 ) {
					$this->assertSame( DB_REPLICA, $masterOrReplica );
				} else {
					$this->assertSame( DB_PRIMARY, $masterOrReplica );
				}
				return $db;
			} );

		// First and third call to Title::newFromID, faking no result
		$selectRowArgs = [
			[
				[ 'page' ],
				[ 'page_id' => 1 ]
			],
			[
				[ 0 => 'page', 'revision' => 'revision' ],
				[ 'rev_id' => 2 ]
			],
			[
				[ 'page' ],
				[ 'page_id' => 1 ]
			],
			[
				[ 0 => 'page', 'revision' => 'revision' ],
				[ 'rev_id' => 2 ]
			]
		];
		$db->expects( $this->exactly( 4 ) )
			->method( 'selectRow' )
			->willReturnCallback( function ( $table, $vars, $conds ) use ( &$selectRowArgs ) {
				[ $nextTable, $nextConds ] = array_shift( $selectRowArgs );
				$this->assertSame( $nextTable, $table );
				$this->assertSame( $nextConds, $conds );
				return false;
			} );

		$store = $this->getRevisionStore( $mockLoadBalancer );

		$this->expectException( RevisionAccessException::class );
		$store->getTitle( 1, 2, IDBAccessObject::READ_NORMAL );
	}

	public static function provideIsRevisionRow() {
		yield 'invalid row type' => [
			'row' => new class() {
			},
			'expect' => false,
		];
		yield 'invalid row' => [
			'row' => (object)[ 'blabla' => 'bla' ],
			'expect' => false,
		];
		yield 'valid row' => [
			'row' => (object)[
				'rev_id' => 321,
				'rev_page' => 123,
				'rev_timestamp' => ConvertibleTimestamp::now(),
				'rev_minor_edit' => 0,
				'rev_deleted' => 0,
				'rev_len' => 10,
				'rev_parent_id' => 123,
				'rev_sha1' => 'abc',
				'rev_comment_text' => 'blabla',
				'rev_comment_data' => 'blablabla',
				'rev_comment_cid' => 1,
				'rev_actor' => 1,
				'rev_user' => 1,
				'rev_user_text' => 'alala',
			],
			'expect' => true,
		];
	}

	/**
	 * @dataProvider provideIsRevisionRow
	 */
	public function testIsRevisionRow( $row, bool $expect ) {
		$this->assertSame( $expect, $this->getRevisionStore()->isRevisionRow( $row ) );
	}

	/**
	 */
	public function testFailOnNull() {
		$revStore = TestingAccessWrapper::newFromObject( $this->getRevisionStore() );
		// Success - not null
		$this->assertSame( 123, $revStore->failOnNull( 123, 'value' ) );

		// Failure - null throws exception
		$this->expectException( IncompleteRevisionException::class );
		$revStore->failOnNull( null, 'value' );
	}

	public static function provideFailOnEmpty() {
		yield 'null' => [ null ];
		yield 'zero' => [ 0 ];
		yield 'empty string' => [ '' ];
	}

	/**
	 * @dataProvider provideFailOnEmpty
	 */
	public function testFailOnEmpty( $emptyValue ) {
		$revStore = TestingAccessWrapper::newFromObject( $this->getRevisionStore() );
		$this->expectException( IncompleteRevisionException::class );
		$revStore->failOnEmpty( $emptyValue, 'value' );
	}

	public function testFailOnEmpty_pass() {
		$revStore = TestingAccessWrapper::newFromObject( $this->getRevisionStore() );
		$this->assertSame( 123, $revStore->failOnEmpty( 123, 'value' ) );
	}

	public static function provideCheckContent() {
		yield 'unsupported format' => [
			false,
			false,
			'Can\'t use format text/x-wiki with content model wikitext on [0:Example] role main'
		];
		yield 'invalid content' => [
			true,
			false,
			'New content for [0:Example] role main is not valid! Content model is wikitext'
		];
		yield 'valid content' => [ true, true, null ];
	}

	/**
	 * @dataProvider provideCheckContent
	 */
	public function testCheckContent( bool $isSupported, bool $isValid, ?string $error ) {
		$revStore = TestingAccessWrapper::newFromObject( $this->getRevisionStore() );
		$contentHandler = $this->createMock( WikitextContentHandler::class );
		$contentHandler->method( 'isSupportedFormat' )->willReturn( $isSupported );
		$content = $this->createMock( WikitextContent::class );
		$content->method( 'getModel' )->willReturn( CONTENT_MODEL_WIKITEXT );
		$content->method( 'getDefaultFormat' )->willReturn( CONTENT_FORMAT_WIKITEXT );
		$content->method( 'getContentHandler' )->willReturn( $contentHandler );
		$content->method( 'isValid' )->willReturn( $isValid );

		if ( $error !== null ) {
			$this->expectException( MWException::class );
			$this->expectExceptionMessage( $error );
		}
		$revStore->checkContent(
			$content,
			new PageIdentityValue( 0, NS_MAIN, 'Example', PageIdentityValue::LOCAL ),
			SlotRecord::MAIN
		);
		// Avoid issues with no assertions for the non-exception case
		$this->addToAssertionCount( 1 );
	}
}
PK       ! ?l%    &  Revision/RevisionArchiveRecordTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Revision;

use InvalidArgumentException;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\TextContent;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Revision\RevisionArchiveRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionSlots;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;
use stdClass;
use Wikimedia\Assert\PreconditionException;

/**
 * @covers \MediaWiki\Revision\RevisionArchiveRecord
 * @covers \MediaWiki\Revision\RevisionRecord
 */
class RevisionArchiveRecordTest extends MediaWikiIntegrationTestCase {

	public static function provideConstructor() {
		$user = new UserIdentityValue( 11, 'Tester' );
		$comment = CommentStoreComment::newUnsavedComment( 'Hello World' );

		$main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
		$aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
		$slots = new RevisionSlots( [ $main, $aux ] );

		$protoRow = [
			'ar_id' => '5',
			'ar_rev_id' => '7',
			'ar_page_id' => '17',
			'ar_timestamp' => '20200101000000',
			'ar_deleted' => 0,
			'ar_minor_edit' => 0,
			'ar_parent_id' => '5',
			'ar_len' => $slots->computeSize(),
			'ar_sha1' => $slots->computeSha1(),
		];

		$row = $protoRow;
		yield 'all info' => [
			new PageIdentityValue( 17, NS_MAIN, 'Dummy', 'acmewiki' ),
			$user,
			$comment,
			(object)$row,
			$slots,
			'acmewiki',
			PreconditionException::class
		];

		yield 'all info, local' => [
			new PageIdentityValue( 17, NS_MAIN, 'Dummy', PageIdentity::LOCAL ),
			$user,
			$comment,
			(object)$row,
			$slots,
		];

		$title = Title::makeTitle( NS_MAIN, 'Dummy' );
		$title->resetArticleID( 17 );

		yield 'all info, local, with Title' => [
			$title,
			$user,
			$comment,
			(object)$row,
			$slots,
		];

		$row = $protoRow;
		$row['ar_minor_edit'] = '1';
		$row['ar_deleted'] = strval( RevisionRecord::DELETED_USER );

		yield 'minor deleted' => [
			$title,
			$user,
			$comment,
			(object)$row,
			$slots
		];

		$row = $protoRow;
		unset( $row['ar_parent'] );

		yield 'no parent' => [
			$title,
			$user,
			$comment,
			(object)$row,
			$slots
		];

		$row = $protoRow;
		$row['ar_len'] = null;
		$row['ar_sha1'] = '';

		yield 'ar_len is null, ar_sha1 is ""' => [
			$title,
			$user,
			$comment,
			(object)$row,
			$slots
		];

		$row = $protoRow;
		$nonExistingTitle = Title::makeTitle( NS_MAIN, 'DummyDoesNotExist' );
		$nonExistingTitle->resetArticleID( 0 );
		yield 'no length, no hash' => [
			$nonExistingTitle,
			$user,
			$comment,
			(object)$row,
			$slots
		];
	}

	/**
	 * @dataProvider provideConstructor
	 *
	 * @param Title $page
	 * @param UserIdentity $user
	 * @param CommentStoreComment $comment
	 * @param stdClass $row
	 * @param RevisionSlots $slots
	 * @param string|false $wikiId
	 * @param string|null $expectedException
	 */
	public function testConstructorAndGetters(
		PageIdentity $page,
		UserIdentity $user,
		CommentStoreComment $comment,
		$row,
		RevisionSlots $slots,
		$wikiId = RevisionRecord::LOCAL,
		?string $expectedException = null
	) {
		$rec = new RevisionArchiveRecord( $page, $user, $comment, $row, $slots, $wikiId );

		$this->assertTrue( $page->isSamePageAs( $rec->getPage() ), 'getPage' );
		$this->assertSame( $user, $rec->getUser( RevisionRecord::RAW ), 'getUser' );
		$this->assertSame( null, $rec->getComment(), 'getComment (public)' );
		$this->assertSame( $comment, $rec->getComment( RevisionRecord::RAW ), 'getComment (raw)' );

		$this->assertSame( $slots->getSlotRoles(), $rec->getSlotRoles(), 'getSlotRoles' );
		$this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );

		$this->assertSame( (int)$row->ar_id, $rec->getArchiveId(), 'getArchiveId' );
		$this->assertSame( (int)$row->ar_rev_id, $rec->getId( $wikiId ), 'getId' );
		$this->assertSame( (int)$row->ar_page_id, $rec->getPageId( $wikiId ), 'getId' );
		$this->assertSame( $row->ar_timestamp, $rec->getTimestamp(), 'getTimestamp' );
		$this->assertSame( (int)$row->ar_deleted, $rec->getVisibility(), 'getVisibility' );
		$this->assertSame( (bool)$row->ar_minor_edit, $rec->isMinor(), 'getIsMinor' );

		if ( isset( $row->ar_parent_id ) ) {
			$this->assertSame( (int)$row->ar_parent_id, $rec->getParentId( $wikiId ), 'getParentId' );
		} else {
			$this->assertSame( 0, $rec->getParentId( $wikiId ), 'getParentId' );
		}

		if ( isset( $row->ar_len ) ) {
			$this->assertSame( (int)$row->ar_len, $rec->getSize(), 'getSize' );
		} else {
			$this->assertSame( $slots->computeSize(), $rec->getSize(), 'getSize' );
		}

		if ( !empty( $row->ar_sha1 ) ) {
			$this->assertSame( $row->ar_sha1, $rec->getSha1(), 'getSha1' );
		} else {
			$this->assertSame( $slots->computeSha1(), $rec->getSha1(), 'getSha1' );
		}

		if ( $expectedException ) {
			$this->expectException( $expectedException );
			$rec->getPageAsLinkTarget();
		} else {
			$this->assertTrue(
				TitleValue::newFromPage( $page )->isSameLinkAs( $rec->getPageAsLinkTarget() ),
				'getPageAsLinkTarget'
			);
		}
	}

	public static function provideConstructorFailure() {
		$title = Title::makeTitle( NS_MAIN, 'Dummy' );
		$title->resetArticleID( 17 );

		$user = new UserIdentityValue( 11, 'Tester' );

		$comment = CommentStoreComment::newUnsavedComment( 'Hello World' );

		$main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
		$aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
		$slots = new RevisionSlots( [ $main, $aux ] );

		$protoRow = [
			'ar_id' => '5',
			'ar_rev_id' => '7',
			'ar_page_id' => strval( $title->getArticleID() ),
			'ar_timestamp' => '20200101000000',
			'ar_deleted' => 0,
			'ar_minor_edit' => 0,
			'ar_parent_id' => '5',
			'ar_len' => $slots->computeSize(),
			'ar_sha1' => $slots->computeSha1(),
		];

		$row = $protoRow;

		yield 'mismatching wiki ID' => [
			new PageIdentityValue(
				$title->getArticleID(),
				$title->getNamespace(),
				$title->getDBkey(),
				PageIdentity::LOCAL
			),
			$user,
			$comment,
			(object)$row,
			$slots,
			'acmewiki',
			PreconditionException::class
		];

		$row = $protoRow;
		$row['ar_timestamp'] = 'kittens';

		yield 'bad timestamp' => [
			$title,
			$user,
			$comment,
			(object)$row,
			$slots
		];

		$row = $protoRow;

		yield 'bad wiki' => [
			$title,
			$user,
			$comment,
			(object)$row,
			$slots,
			12345
		];

		// NOTE: $title->getArticleID does *not* have to match ar_page_id in all cases!
	}

	/**
	 * @dataProvider provideConstructorFailure
	 *
	 * @param PageIdentity $page
	 * @param UserIdentity $user
	 * @param CommentStoreComment $comment
	 * @param stdClass $row
	 * @param RevisionSlots $slots
	 * @param string|false $wikiId
	 * @param string|null $expectedException
	 */
	public function testConstructorFailure(
		PageIdentity $page,
		UserIdentity $user,
		CommentStoreComment $comment,
		$row,
		RevisionSlots $slots,
		$wikiId = false,
		string $expectedException = InvalidArgumentException::class
	) {
		$this->expectException( $expectedException );
		new RevisionArchiveRecord( $page, $user, $comment, $row, $slots, $wikiId );
	}
}
PK       ! &v&  v&  '  Revision/ArchivedRevisionLookupTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Revision;

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\ContentHandler;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiIntegrationTestCase;

/**
 * @group Database
 * @coversDefaultClass \MediaWiki\Revision\ArchivedRevisionLookup
 * @covers ::__construct
 */
class ArchivedRevisionLookupTest extends MediaWikiIntegrationTestCase {

	/**
	 * @var int
	 */
	protected $pageId;

	/**
	 * @var PageIdentityValue
	 */
	protected $archivedPage;

	/**
	 * @var PageIdentityValue
	 */
	protected $neverExistingPage;

	/**
	 * Revision of the first (initial) edit
	 * @var RevisionRecord
	 */
	protected $firstRev;

	/**
	 * Revision of the second edit
	 * @var RevisionRecord
	 */
	protected $secondRev;

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

		$timestamp = 1635000000;
		MWTimestamp::setFakeTime( $timestamp );

		$this->neverExistingPage = PageIdentityValue::localIdentity(
			0, 0, 'ArchivedRevisionLookupTest_theNeverexistingPage' );

		// First create our dummy page
		$this->archivedPage = PageIdentityValue::localIdentity( 0, 0, 'ArchivedRevisionLookupTest_thePage' );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $this->archivedPage );
		$content = ContentHandler::makeContent(
			'testing',
			$page->getTitle(),
			CONTENT_MODEL_WIKITEXT
		);

		$user = $this->getTestUser()->getUser();
		$page->doUserEditContent( $content, $user, 'testing', EDIT_NEW | EDIT_SUPPRESS_RC );

		$this->pageId = $page->getId();
		$this->firstRev = $page->getRevisionRecord();

		$timestamp += 10;

		$revisionStore = $this->getServiceContainer()->getRevisionStore();

		$newContent = ContentHandler::makeContent(
			'Lorem Ipsum',
			$page->getTitle(),
			CONTENT_MODEL_WIKITEXT
		);

		$rev = new MutableRevisionRecord( $page );
		$rev->setUser( $user );
		$rev->setTimestamp( $timestamp );
		$rev->setContent( SlotRecord::MAIN, $newContent );
		$rev->setComment( CommentStoreComment::newUnsavedComment( 'just a test' ) );

		$this->secondRev = $revisionStore->insertRevisionOn( $rev, $this->getDb() );

		// Delete the page
		$timestamp += 10;
		MWTimestamp::setFakeTime( $timestamp );
		$this->deletePage( $page, '', $user );
	}

	protected function getExpectedArchiveRows() {
		return [
			[
				'ar_minor_edit' => '0',
				'ar_user' => (string)$this->getTestUser()->getUser()->getId(),
				'ar_user_text' => $this->getTestUser()->getUser()->getName(),
				'ar_actor' => (string)$this->getTestUser()->getUser()->getActorId(),
				'ar_len' => '11',
				'ar_deleted' => '0',
				'ar_rev_id' => strval( $this->secondRev->getId() ),
				'ar_timestamp' => $this->getDb()->timestamp( $this->secondRev->getTimestamp() ),
				'ar_sha1' => '0qdrpxl537ivfnx4gcpnzz0285yxryy',
				'ar_page_id' => strval( $this->secondRev->getPageId() ),
				'ar_comment_text' => 'just a test',
				'ar_comment_data' => null,
				'ar_comment_cid' => strval( $this->secondRev->getComment()->id ),
				'ts_tags' => null,
				'ar_id' => '2',
				'ar_namespace' => '0',
				'ar_title' => 'ArchivedRevisionLookupTest_thePage',
				'ar_parent_id' => strval( $this->secondRev->getParentId() ),
			],
			[
				'ar_minor_edit' => '0',
				'ar_user' => (string)$this->getTestUser()->getUser()->getId(),
				'ar_user_text' => $this->getTestUser()->getUser()->getName(),
				'ar_actor' => (string)$this->getTestUser()->getUser()->getActorId(),
				'ar_len' => '7',
				'ar_deleted' => '0',
				'ar_rev_id' => strval( $this->firstRev->getId() ),
				'ar_timestamp' => $this->getDb()->timestamp( $this->firstRev->getTimestamp() ),
				'ar_sha1' => 'pr0s8e18148pxhgjfa0gjrvpy8fiyxc',
				'ar_page_id' => strval( $this->firstRev->getPageId() ),
				'ar_comment_text' => 'testing',
				'ar_comment_data' => null,
				'ar_comment_cid' => strval( $this->firstRev->getComment()->id ),
				'ts_tags' => null,
				'ar_id' => '1',
				'ar_namespace' => '0',
				'ar_title' => 'ArchivedRevisionLookupTest_thePage',
				'ar_parent_id' => '0',
			],
		];
	}

	/**
	 * @covers ::listRevisions
	 */
	public function testListRevisions() {
		$lookup = $this->getServiceContainer()->getArchivedRevisionLookup();
		$revisions = $lookup->listRevisions( $this->archivedPage );
		$this->assertEquals( 2, $revisions->numRows() );
		// Get the rows as arrays
		$row0 = (array)$revisions->current();
		$row1 = (array)$revisions->fetchObject();

		$expectedRows = $this->getExpectedArchiveRows();

		$this->assertEquals(
			$expectedRows[0],
			$row0
		);
		$this->assertEquals(
			$expectedRows[1],
			$row1
		);
	}

	/**
	 * @covers ::listRevisions
	 */
	public function testListRevisions_slots() {
		$lookup = $this->getServiceContainer()->getArchivedRevisionLookup();
		$revisions = $lookup->listRevisions( $this->archivedPage );

		$revisionStore = $this->getServiceContainer()->getRevisionStore();
		$slotsQuery = $revisionStore->getSlotsQueryInfo( [ 'content' ] );

		foreach ( $revisions as $row ) {
			$this->newSelectQueryBuilder()
				->select( 'count(*)' )
				->queryInfo( [
					'tables' => $slotsQuery['tables'],
					'joins' => $slotsQuery['joins']
				] )
				->where( [ 'slot_revision_id' => $row->ar_rev_id ] )
				->assertFieldValue( 1 );
		}
	}

	/**
	 * @covers ::listRevisions
	 */
	public function testListRevisionsOffsetAndLimit() {
		$lookup = $this->getServiceContainer()->getArchivedRevisionLookup();
		$db = $this->getDb();
		$revisions = $lookup->listRevisions(
			$this->archivedPage,
			[ $db->expr( 'ar_timestamp', '<', $db->timestamp( $this->secondRev->getTimestamp() ) ) ],
			1 );
		$this->assertSame( 1, $revisions->numRows() );
		// Get the rows as arrays
		$row0 = (array)$revisions->fetchObject();

		$expectedRows = $this->getExpectedArchiveRows();

		$this->assertEquals(
			$expectedRows[1],
			$row0
		);
	}

	/**
	 * @covers ::getLastRevisionId
	 */
	public function testGetLastRevisionId() {
		$lookup = $this->getServiceContainer()->getArchivedRevisionLookup();
		$id = $lookup->getLastRevisionId( $this->archivedPage );
		$this->assertSame( $this->secondRev->getId(), $id );
		$this->assertFalse( $lookup->getLastRevisionId( $this->neverExistingPage ) );
	}

	/**
	 * @covers ::hasArchivedRevisions
	 */
	public function testHasArchivedRevisions() {
		$lookup = $this->getServiceContainer()->getArchivedRevisionLookup();
		$this->assertTrue( $lookup->hasArchivedRevisions( $this->archivedPage ) );
		$this->assertFalse( $lookup->hasArchivedRevisions( $this->neverExistingPage ) );
	}

	/**
	 * @covers ::getRevisionRecordByTimestamp
	 * @covers ::getRevisionByConditions
	 */
	public function testGetRevisionRecordByTimestamp() {
		$lookup = $this->getServiceContainer()->getArchivedRevisionLookup();
		$revRecord = $lookup->getRevisionRecordByTimestamp(
			$this->archivedPage,
			$this->secondRev->getTimestamp()
		);
		$this->assertNotNull( $revRecord );
		$this->assertSame( $this->secondRev->getId(), $revRecord->getId() );

		$revRecord = $lookup->getRevisionRecordByTimestamp(
			$this->archivedPage,
			'22991212115555'
		);
		$this->assertNull( $revRecord );
	}

	/**
	 * @covers ::getArchivedRevisionRecord
	 * @covers ::getRevisionByConditions
	 */
	public function testGetArchivedRevisionRecord() {
		$lookup = $this->getServiceContainer()->getArchivedRevisionLookup();
		$revRecord = $lookup->getArchivedRevisionRecord(
			$this->archivedPage,
			$this->secondRev->getId()
		);
		$this->assertNotNull( $revRecord );
		$this->assertSame( $this->pageId, $revRecord->getPageId() );

		$revRecord = $lookup->getArchivedRevisionRecord(
			$this->archivedPage,
			$this->secondRev->getId() + 42
		);
		$this->assertNull( $revRecord );
	}

	/**
	 * @covers ::getPreviousRevisionRecord
	 * @covers ::getRevisionByConditions
	 */
	public function testGetPreviousRevisionRecord() {
		$lookup = $this->getServiceContainer()->getArchivedRevisionLookup();

		$timestamp = wfTimestamp( TS_UNIX, $this->secondRev->getTimestamp() ) + 1;
		$prevRec = $lookup->getPreviousRevisionRecord(
			$this->archivedPage,
			wfTimestamp( TS_MW, $timestamp )
		);
		$this->assertNotNull( $prevRec );
		$this->assertEquals( $this->secondRev->getId(), $prevRec->getId() );

		$prevRec = $lookup->getPreviousRevisionRecord(
			$this->archivedPage,
			wfTimestamp( TS_MW, $this->secondRev->getTimestamp() )
		);
		$this->assertNotNull( $prevRec );
		$this->assertEquals( $this->firstRev->getId(), $prevRec->getId() );

		$prevRec = $lookup->getPreviousRevisionRecord(
			$this->neverExistingPage,
			wfTimestamp( TS_MW, $this->secondRev->getTimestamp() )
		);
		$this->assertNull( $prevRec );
	}

	/**
	 * @covers ::getPreviousRevisionRecord
	 * @covers ::getRevisionByConditions
	 */
	public function testGetPreviousRevisionRecord_recreatedPage() {
		// recreate the archived page
		$timestamp = wfTimestamp( TS_UNIX, $this->secondRev->getTimestamp() ) + 10;
		MWTimestamp::setFakeTime( $timestamp );

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $this->archivedPage );

		$content = ContentHandler::makeContent(
			'recreated page',
			$page->getTitle(),
			CONTENT_MODEL_WIKITEXT
		);
		$page->doUserEditContent(
			$content,
			$this->getTestUser()->getUser(),
			'testing',
			EDIT_NEW | EDIT_SUPPRESS_RC
		);
		$newRev = $page->getRevisionRecord();

		$lookup = $this->getServiceContainer()->getArchivedRevisionLookup();
		$prevRec = $lookup->getPreviousRevisionRecord(
			$this->archivedPage,
			wfTimestamp( TS_MW, $timestamp + 1 )
		);
		$this->assertEquals( $newRev->getId(), $prevRec->getId() );

		$prevRec = $lookup->getPreviousRevisionRecord(
			$this->archivedPage,
			wfTimestamp( TS_MW, $timestamp - 1 )
		);
		$this->assertEquals( $this->secondRev->getId(), $prevRec->getId() );
	}

}
PK       ! 3ڎH  H  !  Revision/RevisionRendererTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Revision;

use LogicException;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\Content;
use MediaWiki\Content\IContentHandlerFactory;
use MediaWiki\Content\Renderer\ContentRenderer;
use MediaWiki\Content\WikitextContent;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\MediaWikiServices;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageReference;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Revision\MainSlotRoleHandler;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionRenderer;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Revision\SlotRoleRegistry;
use MediaWiki\Storage\NameTableStore;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Title\TitleFactory;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Rdbms\SelectQueryBuilder;

/**
 * @covers \MediaWiki\Revision\RevisionRenderer
 * @group Database
 */
class RevisionRendererTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;

	/** @var PageIdentity */
	private $fakePage;

	protected function setUp(): void {
		parent::setUp();
		$this->fakePage = PageIdentityValue::localIdentity( 7, NS_MAIN, __CLASS__ );
	}

	/**
	 * @param IDatabase&MockObject $db
	 * @param int $maxRev
	 * @return IDatabase&MockObject
	 */
	private function mockDatabaseConnection( $db, $maxRev = 100 ) {
		$db->method( 'selectField' )
			->willReturnCallback(
				function ( $table, $fields, $cond ) use ( $maxRev ) {
					return $this->selectFieldCallback(
						$table,
						$fields,
						$cond,
						$maxRev
					);
				}
			);
		$db->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $db ) );

		return $db;
	}

	/**
	 * @param int $maxRev
	 * @param bool $usePrimary
	 * @param ContentRenderer|null $contentRenderer
	 * @return RevisionRenderer
	 */
	private function newRevisionRenderer(
		$maxRev = 100,
		$usePrimary = false,
		$contentRenderer = null
	) {
		$dbIndex = $usePrimary ? DB_PRIMARY : DB_REPLICA;
		$cr = $contentRenderer ?? $this->getServiceContainer()->getContentRenderer();

		/** @var ILoadBalancer|MockObject $lb */
		$lb = $this->createMock( ILoadBalancer::class );
		$lb->method( 'getConnection' )
			->with( $dbIndex )
			->willReturn( $this->mockDatabaseConnection( $this->createMock( IDatabase::class ), $maxRev ) );

		/** @var NameTableStore|MockObject $slotRoles */
		$slotRoles = $this->createMock( NameTableStore::class );
		$slotRoles->method( 'getMap' )
			->willReturn( [] );

		$roleReg = new SlotRoleRegistry( $slotRoles );
		$roleReg->defineRole( SlotRecord::MAIN, function () {
			return new MainSlotRoleHandler(
				[],
				$this->createMock( IContentHandlerFactory::class ),
				$this->createMock( HookContainer::class ),
				$this->createMock( TitleFactory::class )
			);
		} );
		$roleReg->defineRoleWithModel( 'aux', CONTENT_MODEL_WIKITEXT );

		return new RevisionRenderer( $lb, $roleReg, $cr );
	}

	private function selectFieldCallback( $table, $fields, $cond, $maxRev ) {
		if ( [ $table, $fields, $cond ] === [ [ 'revision' ], 'MAX(rev_id)', [] ] ) {
			return $maxRev;
		}

		$this->fail( 'Unexpected call to selectField' );
		throw new LogicException( 'Ooops' ); // Can't happen, make analyzer happy
	}

	public function testGetRenderedRevision_new() {
		$renderer = $this->newRevisionRenderer( 100 );

		$rev = new MutableRevisionRecord( $this->fakePage );
		$rev->setUser( new UserIdentityValue( 9, 'Frank' ) );
		$rev->setTimestamp( '20180101000003' );
		$rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );

		$text = "";
		$text .= "* page:{{PAGENAME}}\n";
		$text .= "* rev:{{REVISIONID}}\n";
		$text .= "* user:{{REVISIONUSER}}\n";
		$text .= "* time:{{REVISIONTIMESTAMP}}\n";
		$text .= "* [[Link It]]\n";

		$rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );

		$options = ParserOptions::newFromAnon();
		$rr = $renderer->getRenderedRevision( $rev, $options );

		$this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );

		$this->assertSame( $rev, $rr->getRevision() );
		$this->assertSame( $options, $rr->getOptions() );

		$html = $rr->getRevisionParserOutput()->getRawText();

		$this->assertStringContainsString( 'page:' . __CLASS__, $html );
		$this->assertStringContainsString( 'rev:101', $html ); // from speculativeRevIdCallback
		$this->assertStringContainsString( 'user:Frank', $html );
		$this->assertStringContainsString( 'time:20180101000003', $html );

		$this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getRawText() );
	}

	public function testGetRenderedRevision_current() {
		$renderer = $this->newRevisionRenderer( 100 );

		$rev = new MutableRevisionRecord( $this->fakePage );
		$rev->setId( 21 ); // current!
		$rev->setUser( new UserIdentityValue( 9, 'Frank' ) );
		$rev->setTimestamp( '20180101000003' );
		$rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );

		$text = "";
		$text .= "* page:{{PAGENAME}}\n";
		$text .= "* rev:{{REVISIONID}}\n";
		$text .= "* user:{{REVISIONUSER}}\n";
		$text .= "* time:{{REVISIONTIMESTAMP}}\n";

		$rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );

		$options = ParserOptions::newFromAnon();
		$rr = $renderer->getRenderedRevision( $rev, $options );

		$this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );

		$this->assertSame( $rev, $rr->getRevision() );
		$this->assertSame( $options, $rr->getOptions() );

		$html = $rr->getRevisionParserOutput()->getRawText();

		$this->assertStringContainsString( 'page:' . __CLASS__, $html );
		$this->assertStringContainsString( 'rev:21', $html );
		$this->assertStringContainsString( 'user:Frank', $html );
		$this->assertStringContainsString( 'time:20180101000003', $html );

		$this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getRawText() );
	}

	public function testGetRenderedRevision_master() {
		$renderer = $this->newRevisionRenderer( 100, true ); // use master

		$rev = new MutableRevisionRecord( $this->fakePage );
		$rev->setId( 21 ); // current!
		$rev->setUser( new UserIdentityValue( 9, 'Frank' ) );
		$rev->setTimestamp( '20180101000003' );
		$rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );

		$text = "";
		$text .= "* page:{{PAGENAME}}\n";
		$text .= "* rev:{{REVISIONID}}\n";
		$text .= "* user:{{REVISIONUSER}}\n";
		$text .= "* time:{{REVISIONTIMESTAMP}}\n";

		$rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );

		$options = ParserOptions::newFromAnon();
		$rr = $renderer->getRenderedRevision( $rev, $options, null, [ 'use-master' => true ] );

		$this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );

		$html = $rr->getRevisionParserOutput()->getRawText();

		$this->assertStringContainsString( 'rev:21', $html );

		$this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getRawText() );
	}

	public function testGetRenderedRevision_known() {
		$renderer = $this->newRevisionRenderer( 100, true ); // use master

		$rev = new MutableRevisionRecord( $this->fakePage );
		$rev->setId( 21 ); // current!
		$rev->setUser( new UserIdentityValue( 9, 'Frank' ) );
		$rev->setTimestamp( '20180101000003' );
		$rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );

		$text = "uncached text";
		$rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );

		$output = new ParserOutput( 'cached text' );

		$options = ParserOptions::newFromAnon();
		$rr = $renderer->getRenderedRevision(
			$rev,
			$options,
			null,
			[ 'known-revision-output' => $output ]
		);

		$this->assertSame( $output, $rr->getRevisionParserOutput() );
		$this->assertSame( 'cached text', $rr->getRevisionParserOutput()->getRawText() );
		$this->assertSame( 'cached text', $rr->getSlotParserOutput( SlotRecord::MAIN )->getRawText() );
	}

	public function testGetRenderedRevision_old() {
		$renderer = $this->newRevisionRenderer( 100 );

		$rev = new MutableRevisionRecord( $this->fakePage );
		$rev->setId( 11 ); // old!
		$rev->setUser( new UserIdentityValue( 9, 'Frank' ) );
		$rev->setTimestamp( '20180101000003' );
		$rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );

		$text = "";
		$text .= "* page:{{PAGENAME}}\n";
		$text .= "* rev:{{REVISIONID}}\n";
		$text .= "* user:{{REVISIONUSER}}\n";
		$text .= "* time:{{REVISIONTIMESTAMP}}\n";

		$rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );

		$options = ParserOptions::newFromAnon();
		$rr = $renderer->getRenderedRevision( $rev, $options );

		$this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );

		$this->assertSame( $rev, $rr->getRevision() );
		$this->assertSame( $options, $rr->getOptions() );

		$html = $rr->getRevisionParserOutput()->getRawText();

		$this->assertStringContainsString( 'page:' . __CLASS__, $html );
		$this->assertStringContainsString( 'rev:11', $html );
		$this->assertStringContainsString( 'user:Frank', $html );
		$this->assertStringContainsString( 'time:20180101000003', $html );

		$this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getRawText() );
	}

	public function testGetRenderedRevision_suppressed() {
		$renderer = $this->newRevisionRenderer( 100 );

		$rev = new MutableRevisionRecord( $this->fakePage );
		$rev->setId( 11 ); // old!
		$rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
		$rev->setUser( new UserIdentityValue( 9, 'Frank' ) );
		$rev->setTimestamp( '20180101000003' );
		$rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );

		$text = "";
		$text .= "* page:{{PAGENAME}}\n";
		$text .= "* rev:{{REVISIONID}}\n";
		$text .= "* user:{{REVISIONUSER}}\n";
		$text .= "* time:{{REVISIONTIMESTAMP}}\n";

		$rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );

		$options = ParserOptions::newFromAnon();
		$rr = $renderer->getRenderedRevision( $rev, $options );

		$this->assertNull( $rr, 'getRenderedRevision' );
	}

	public function testGetRenderedRevision_privileged() {
		$renderer = $this->newRevisionRenderer( 100 );

		$rev = new MutableRevisionRecord( $this->fakePage );
		$rev->setId( 11 ); // old!
		$rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
		$rev->setUser( new UserIdentityValue( 9, 'Frank' ) );
		$rev->setTimestamp( '20180101000003' );
		$rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );

		$text = "";
		$text .= "* page:{{PAGENAME}}\n";
		$text .= "* rev:{{REVISIONID}}\n";
		$text .= "* user:{{REVISIONUSER}}\n";
		$text .= "* time:{{REVISIONTIMESTAMP}}\n";

		$rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );

		$options = ParserOptions::newFromAnon();
		$sysop = $this->mockRegisteredUltimateAuthority();
		$rr = $renderer->getRenderedRevision( $rev, $options, $sysop );

		$this->assertNotNull( $rr, 'getRenderedRevision' );
		$this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );

		$this->assertSame( $rev, $rr->getRevision() );
		$this->assertSame( $options, $rr->getOptions() );

		$html = $rr->getRevisionParserOutput()->getRawText();

		// Suppressed content should be visible for sysops
		$this->assertStringContainsString( 'page:' . __CLASS__, $html );
		$this->assertStringContainsString( 'rev:11', $html );
		$this->assertStringContainsString( 'user:Frank', $html );
		$this->assertStringContainsString( 'time:20180101000003', $html );

		$this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getRawText() );
	}

	public function testGetRenderedRevision_raw() {
		$renderer = $this->newRevisionRenderer( 100 );

		$rev = new MutableRevisionRecord( $this->fakePage );
		$rev->setId( 11 ); // old!
		$rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
		$rev->setUser( new UserIdentityValue( 9, 'Frank' ) );
		$rev->setTimestamp( '20180101000003' );
		$rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );

		$text = "";
		$text .= "* page:{{PAGENAME}}\n";
		$text .= "* rev:{{REVISIONID}}\n";
		$text .= "* user:{{REVISIONUSER}}\n";
		$text .= "* time:{{REVISIONTIMESTAMP}}\n";

		$rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );

		$options = ParserOptions::newFromAnon();
		$rr = $renderer->getRenderedRevision(
			$rev,
			$options,
			null,
			[ 'audience' => RevisionRecord::RAW ]
		);

		$this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );

		$this->assertSame( $rev, $rr->getRevision() );
		$this->assertSame( $options, $rr->getOptions() );

		$parserOutput = $rr->getRevisionParserOutput();
		// Assert parser output recorded timestamp and parsed rev_id
		$this->assertSame( $rev->getId(), $parserOutput->getCacheRevisionId() );
		$this->assertSame( $rev->getTimestamp(), $parserOutput->getRevisionTimestamp() );

		$html = $parserOutput->getRawText();

		// Suppressed content should be visible in raw mode
		$this->assertStringContainsString( 'page:' . __CLASS__, $html );
		$this->assertStringContainsString( 'rev:11', $html );
		$this->assertStringContainsString( 'user:Frank', $html );
		$this->assertStringContainsString( 'time:20180101000003', $html );

		$this->assertSame( $html, $rr->getSlotParserOutput( SlotRecord::MAIN )->getRawText() );
	}

	public function testGetRenderedRevision_multi() {
		$renderer = $this->newRevisionRenderer();

		$rev = new MutableRevisionRecord( $this->fakePage );
		$rev->setUser( new UserIdentityValue( 9, 'Frank' ) );
		$rev->setTimestamp( '20180101000003' );
		$rev->setComment( CommentStoreComment::newUnsavedComment( '' ) );

		$rev->setContent( SlotRecord::MAIN, new WikitextContent( '[[Kittens]]' ) );
		$rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );

		$options = ParserOptions::newFromAnon();
		$rr = $renderer->getRenderedRevision( $rev, $options );

		$combinedOutput = $rr->getRevisionParserOutput();
		$mainOutput = $rr->getSlotParserOutput( SlotRecord::MAIN );
		$auxOutput = $rr->getSlotParserOutput( 'aux' );

		$pipeline = MediaWikiServices::getInstance()->getDefaultOutputPipeline();
		$combinedHtml = $pipeline->run( $combinedOutput, $options, [] )->getContentHolderText();
		$mainHtml = $pipeline->run( $mainOutput, $options, [] )->getContentHolderText();

		$this->assertNotSame( $combinedHtml, $mainHtml );

		$auxHtml = $pipeline->run( $auxOutput, $options, [] )->getContentHolderText();

		$this->assertStringContainsString( 'Kittens', $mainHtml );
		$this->assertStringContainsString( 'Goats', $auxHtml );
		$this->assertStringNotContainsString( 'Goats', $mainHtml );
		$this->assertStringNotContainsString( 'Kittens', $auxHtml );
		$this->assertStringContainsString( 'Kittens', $combinedHtml );
		$this->assertStringContainsString( 'Goats', $combinedHtml );
		$this->assertStringContainsString( '>aux<', $combinedHtml, 'slot header' );
		$this->assertStringNotContainsString(
			'<mw:slotheader',
			$combinedHtml,
			'slot header placeholder'
		);

		// make sure output wrapping works right
		$this->assertStringContainsString( 'class="mw-content-ltr mw-parser-output"', $mainHtml );
		$this->assertStringContainsString( 'class="mw-content-ltr mw-parser-output"', $auxHtml );
		$this->assertStringContainsString( 'class="mw-content-ltr mw-parser-output"', $combinedHtml );

		// there should be only one wrapper div
		$this->assertSame( 1, preg_match_all( '#class="[^"]*mw-parser-output"#', $combinedHtml ) );
		$this->assertStringNotContainsString( 'mw-parser-output"', $combinedOutput->getRawText() );

		$combinedLinks = $combinedOutput->getLinks();
		$mainLinks = $mainOutput->getLinks();
		$auxLinks = $auxOutput->getLinks();
		$this->assertTrue( isset( $combinedLinks[NS_MAIN]['Kittens'] ), 'links from main slot' );
		$this->assertTrue( isset( $combinedLinks[NS_MAIN]['Goats'] ), 'links from aux slot' );
		$this->assertFalse( isset( $mainLinks[NS_MAIN]['Goats'] ), 'no aux links in main' );
		$this->assertFalse( isset( $auxLinks[NS_MAIN]['Kittens'] ), 'no main links in aux' );

		// Same tests with Parsoid
		// T351026: We should get only main slot output in the combined output.
		// T351113 will have to update this test.
		$options = ParserOptions::newFromAnon();
		$options->setUseParsoid();
		$rr = $renderer->getRenderedRevision( $rev, $options );

		$combinedOutput = $rr->getRevisionParserOutput();
		$mainOutput = $rr->getSlotParserOutput( SlotRecord::MAIN );

		$combinedHtml = $pipeline->run( $combinedOutput, $options, [] )->getContentHolderText();
		$mainHtml = $pipeline->run( $mainOutput, $options, [] )->getContentHolderText();
		$this->assertSame( $combinedHtml, $mainHtml );
		$this->assertSame( $combinedOutput->getLinks(), $mainOutput->getLinks() );
		$this->assertStringContainsString( 'class="mw-content-ltr mw-parser-output"', $mainHtml );
		$this->assertStringContainsString( 'Kittens', $combinedHtml );
		$this->assertStringNotContainsString( 'Goats', $combinedHtml );
	}

	public function testGetRenderedRevision_noHtml() {
		$content = new WikitextContent( 'Whatever' );

		/** @var MockObject|ContentRenderer $mockContentRenderer */
		$mockContentRenderer = $this->getMockBuilder( ContentRenderer::class )
			->onlyMethods( [ 'getParserOutput' ] )
			->disableOriginalConstructor()
			->getMock();
		$mockContentRenderer->method( 'getParserOutput' )
			->willReturnCallback( function ( Content $content, PageReference $page, $revId = null,
				?ParserOptions $options = null, $hints = []
			) {
				if ( is_bool( $hints ) ) {
					$hints = [ 'generate-html' => $hints ];
				}
				$generateHtml = $hints['generate-html'] ?? true;
				if ( !$generateHtml ) {
					return new ParserOutput( null );
				} else {
					$this->fail( 'Should not be called with $generateHtml == true' );
					return null; // never happens, make analyzer happy
				}
			} );

		$renderer = $this->newRevisionRenderer( 100, false, $mockContentRenderer );

		$rev = new MutableRevisionRecord( $this->fakePage );
		$rev->setContent( SlotRecord::MAIN, $content );
		$rev->setContent( 'aux', $content );

		// NOTE: we are testing the private combineSlotOutput() callback here.
		$rr = $renderer->getRenderedRevision( $rev );

		$output = $rr->getSlotParserOutput( SlotRecord::MAIN, [ 'generate-html' => false ] );
		$this->assertFalse( $output->hasText(), 'hasText' );

		$output = $rr->getRevisionParserOutput( [ 'generate-html' => false ] );
		$this->assertFalse( $output->hasText(), 'hasText' );
	}

}
PK       ! w1  1  $  Revision/RevisionStoreRecordTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Revision;

use InvalidArgumentException;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\TextContent;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionSlots;
use MediaWiki\Revision\RevisionStoreRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;
use stdClass;
use Wikimedia\Assert\PreconditionException;
use Wikimedia\Timestamp\TimestampException;

/**
 * @covers \MediaWiki\Revision\RevisionStoreRecord
 * @covers \MediaWiki\Revision\RevisionRecord
 */
class RevisionStoreRecordTest extends MediaWikiIntegrationTestCase {

	public static function provideConstructor() {
		$user = new UserIdentityValue( 11, 'Tester' );
		$comment = CommentStoreComment::newUnsavedComment( 'Hello World' );

		$main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
		$aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
		$slots = new RevisionSlots( [ $main, $aux ] );

		$protoRow = [
			'rev_id' => '7',
			'rev_page' => '17',
			'rev_timestamp' => '20200101000000',
			'rev_deleted' => 0,
			'rev_minor_edit' => 0,
			'rev_parent_id' => '5',
			'rev_len' => $slots->computeSize(),
			'rev_sha1' => $slots->computeSha1(),
			'page_latest' => '18',
		];

		$row = $protoRow;
		yield 'all info' => [
			new PageIdentityValue( 17, NS_MAIN, 'Dummy', 'acmewiki' ),
			$user,
			$comment,
			(object)$row,
			$slots,
			'acmewiki',
			PreconditionException::class
		];

		yield 'all info, local' => [
			new PageIdentityValue( 17, NS_MAIN, 'Dummy', PageIdentity::LOCAL ),
			$user,
			$comment,
			(object)$row,
			$slots,
		];

		$title = Title::makeTitle( NS_MAIN, 'Dummy' );
		$title->resetArticleID( 17 );

		yield 'all info, local with Title' => [
			$title,
			$user,
			$comment,
			(object)$row,
			$slots,
		];

		$row = $protoRow;
		$row['rev_minor_edit'] = '1';
		$row['rev_deleted'] = strval( RevisionRecord::DELETED_USER );

		yield 'minor deleted' => [
			$title,
			$user,
			$comment,
			(object)$row,
			$slots
		];

		$row = $protoRow;
		$row['page_latest'] = $row['rev_id'];

		yield 'latest' => [
			$title,
			$user,
			$comment,
			(object)$row,
			$slots
		];

		$row = $protoRow;
		unset( $row['rev_parent'] );

		yield 'no parent' => [
			$title,
			$user,
			$comment,
			(object)$row,
			$slots
		];

		$row = $protoRow;
		$row['rev_len'] = null;
		$row['rev_sha1'] = '';

		yield 'rev_len is null, rev_sha1 is ""' => [
			$title,
			$user,
			$comment,
			(object)$row,
			$slots
		];

		$row = $protoRow;
		$nonExistingTitle = Title::makeTitle( NS_MAIN, 'DummyDoesNotExist' );
		$nonExistingTitle->resetArticleID( 0 );
		yield 'no length, no hash' => [
			$nonExistingTitle,
			$user,
			$comment,
			(object)$row,
			$slots
		];
	}

	/**
	 * @dataProvider provideConstructor
	 *
	 * @param PageIdentity $page
	 * @param UserIdentity $user
	 * @param CommentStoreComment $comment
	 * @param stdClass $row
	 * @param RevisionSlots $slots
	 * @param string|false $wikiId
	 * @param string|null $expectedException
	 */
	public function testConstructorAndGetters(
		PageIdentity $page,
		UserIdentity $user,
		CommentStoreComment $comment,
		$row,
		RevisionSlots $slots,
		$wikiId = RevisionRecord::LOCAL,
		?string $expectedException = null
	) {
		$rec = new RevisionStoreRecord( $page, $user, $comment, $row, $slots, $wikiId );

		$this->assertTrue( $page->isSamePageAs( $rec->getPage() ), 'getPage' );
		$this->assertSame( $user, $rec->getUser( RevisionRecord::RAW ), 'getUser' );
		$this->assertSame( $comment, $rec->getComment(), 'getComment' );

		$this->assertSame( $slots, $rec->getSlots(), 'getSlots' );
		$this->assertSame( $slots->getSlotRoles(), $rec->getSlotRoles(), 'getSlotRoles' );
		$this->assertSame( $slots->getSlots(), $rec->getSlots()->getSlots(), 'getSlots' );
		$this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );

		$this->assertSame( (int)$row->rev_id, $rec->getId( $wikiId ), 'getId' );
		$this->assertSame( (int)$row->rev_page, $rec->getPageId( $wikiId ), 'getId' );
		$this->assertSame( $row->rev_timestamp, $rec->getTimestamp(), 'getTimestamp' );
		$this->assertSame( (int)$row->rev_deleted, $rec->getVisibility(), 'getVisibility' );
		$this->assertSame( (bool)$row->rev_minor_edit, $rec->isMinor(), 'getIsMinor' );

		if ( isset( $row->rev_parent_id ) ) {
			$this->assertSame( (int)$row->rev_parent_id, $rec->getParentId( $wikiId ), 'getParentId' );
		} else {
			$this->assertSame( 0, $rec->getParentId( $wikiId ), 'getParentId' );
		}

		if ( isset( $row->rev_len ) ) {
			$this->assertSame( (int)$row->rev_len, $rec->getSize(), 'getSize' );
		} else {
			$this->assertSame( $slots->computeSize(), $rec->getSize(), 'getSize' );
		}

		if ( !empty( $row->rev_sha1 ) ) {
			$this->assertSame( $row->rev_sha1, $rec->getSha1(), 'getSha1' );
		} else {
			$this->assertSame( $slots->computeSha1(), $rec->getSha1(), 'getSha1' );
		}

		if ( isset( $row->page_latest ) ) {
			$this->assertSame(
				(int)$row->rev_id === (int)$row->page_latest,
				$rec->isCurrent(),
				'isCurrent'
			);
		} else {
			$this->assertSame(
				false,
				$rec->isCurrent(),
				'isCurrent'
			);
		}

		if ( $expectedException ) {
			$this->expectException( $expectedException );
			$rec->getPageAsLinkTarget();
		} else {
			$this->assertTrue(
				TitleValue::newFromPage( $page )->isSameLinkAs( $rec->getPageAsLinkTarget() ),
				'getPageAsLinkTarget'
			);
		}
	}

	public static function provideConstructorFailure() {
		$title = Title::makeTitle( NS_MAIN, 'Dummy' );
		$title->resetArticleID( 17 );

		$user = new UserIdentityValue( 11, 'Tester' );

		$comment = CommentStoreComment::newUnsavedComment( 'Hello World' );

		$main = SlotRecord::newUnsaved( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
		$aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
		$slots = new RevisionSlots( [ $main, $aux ] );

		$protoRow = [
			'rev_id' => '7',
			'rev_page' => strval( $title->getArticleID() ),
			'rev_timestamp' => '20200101000000',
			'rev_deleted' => 0,
			'rev_minor_edit' => 0,
			'rev_parent_id' => '5',
			'rev_len' => $slots->computeSize(),
			'rev_sha1' => $slots->computeSha1(),
			'page_latest' => '18',
		];

		$row = $protoRow;
		$row['rev_page'] = 99;

		yield 'page ID mismatch' => [
			$title,
			$user,
			$comment,
			(object)$row,
			$slots
		];

		$row = $protoRow;

		yield 'bad wiki' => [
			$title,
			$user,
			$comment,
			(object)$row,
			$slots,
			12345
		];
	}

	/**
	 * @dataProvider provideConstructorFailure
	 *
	 * @param PageIdentity $page
	 * @param UserIdentity $user
	 * @param CommentStoreComment $comment
	 * @param stdClass $row
	 * @param RevisionSlots $slots
	 * @param string|false $wikiId
	 */
	public function testConstructorFailure(
		PageIdentity $page,
		UserIdentity $user,
		CommentStoreComment $comment,
		$row,
		RevisionSlots $slots,
		$wikiId = false
	) {
		$this->expectException( InvalidArgumentException::class );
		new RevisionStoreRecord( $page, $user, $comment, $row, $slots, $wikiId );
	}

	public function testConstructorBadTimestamp() {
		$row = (object)[
			'rev_id' => 42,
			'rev_page' => 'Foobar',
			'rev_timestamp' => 'kittens',
		];
		$this->expectException( TimestampException::class );
		new RevisionStoreRecord(
			$this->createMock( PageIdentity::class ),
			new UserIdentityValue( 11, __CLASS__ ),
			$this->createMock( CommentStoreComment::class ),
			$row,
			$this->createMock( RevisionSlots::class ),
			false
		);
	}
}
PK       ! rzW  W    diff/DifferenceEngineTest.phpnu Iw        <?php

use MediaWiki\Config\HashConfig;
use MediaWiki\Content\Content;
use MediaWiki\Content\ContentHandler;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Output\OutputPage;
use MediaWiki\Permissions\SimpleAuthority;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentityValue;

/**
 * @covers \DifferenceEngine
 *
 * @todo tests for the rest of DifferenceEngine!
 *
 * @group Database
 * @group Diff
 *
 * @author Katie Filbert < aude.wiki@gmail.com >
 */
class DifferenceEngineTest extends MediaWikiIntegrationTestCase {
	use MockTitleTrait;

	/** @var RequestContext */
	protected $context;

	/** @var int[] */
	private static $revisions;

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

		$title = $this->getTitle();

		$this->context = new RequestContext();
		$this->context->setTitle( $title );

		$this->overrideConfigValue( MainConfigNames::DiffEngine, 'php' );

		$slotRoleRegistry = $this->getServiceContainer()->getSlotRoleRegistry();

		if ( !$slotRoleRegistry->isDefinedRole( 'derivedslot' ) ) {
			$slotRoleRegistry->defineRoleWithModel(
				'derivedslot',
				CONTENT_MODEL_WIKITEXT,
				[],
				true
			);
		}
	}

	public function addDBDataOnce() {
		self::$revisions = $this->doEdits();
	}

	/**
	 * @return Title
	 */
	protected function getTitle() {
		$namespace = $this->getDefaultWikitextNS();
		return Title::makeTitle( $namespace, 'Kitten' );
	}

	/**
	 * @return int[] Revision ids
	 */
	protected function doEdits() {
		$title = $this->getTitle();

		$strings = [
			0 => "no kittens",
			1 => "one kitten",
			2 => "two kittens",
			3 => "three kittens",
			4 => "fnord kittens",
			5 => "kitten's phone number is +1 303 503 229",
			6 => "six kittens",
			7 => "seven kittens",
		];
		$revisions = [];

		$sysop = $this->getTestSysop()->getAuthority();
		$user = $this->getTestUser()->getAuthority();
		foreach ( $strings as $i => $string ) {
			$status = $this->editPage(
				$title,
				$string,
				'edit page',
				NS_MAIN,
				$i == 6 ? $user : $sysop
			);
			$revisions[] = $status->getNewRevision()->getId();
		}

		// Normal user cannot see the fnord
		$this->revisionDelete(
			$revisions[4],
			[
				RevisionRecord::DELETED_TEXT => 1,
			],
			'Testing'
		);

		// Suppress kitten dox
		$this->revisionDelete(
			$revisions[5],
			[
				RevisionRecord::DELETED_TEXT => 1,
				RevisionRecord::DELETED_RESTRICTED => 1,
			],
			'Testing'
		);

		return $revisions;
	}

	private function expandData( $data ) {
		if ( is_array( $data ) ) {
			foreach ( $data as &$value ) {
				$value = $this->expandData( $value );
			}
		} elseif ( is_string( $data ) ) {
			$data = preg_replace_callback(
				'/rev\[([0-9]+)]/',
				static function ( $m ) {
					return self::$revisions[(int)$m[1]];
				},
				$data
			);
			$data = str_replace(
				'rev[cur]',
				self::$revisions[array_key_last( self::$revisions )],
				$data
			);
		}
		return $data;
	}

	private function expandTestArgs( $args ) {
		foreach ( $args as &$arg ) {
			$arg = $this->expandData( $arg );
		}
	}

	/**
	 * @dataProvider provideMapDiffPrevNext
	 */
	public function testMapDiffPrevNext( $expected, $old, $new, $message ) {
		$this->expandTestArgs( [ &$expected, &$old, &$new, &$message ] );
		$diffEngine = new DifferenceEngine( $this->context, $old, $new, 2, true, false );
		$diffMap = $diffEngine->mapDiffPrevNext( $old, $new );
		$this->assertEquals( $expected, $diffMap, $message );
	}

	public static function provideMapDiffPrevNext() {
		return [
			[ [ 'rev[1]', 'rev[2]' ], 'rev[2]', 'prev', 'diff=prev' ],
			[ [ 'rev[2]', 'rev[3]' ], 'rev[2]', 'next', 'diff=next' ],
			[ [ 'rev[1]', 'rev[3]' ], 'rev[1]', 'rev[3]', 'diff=rev3' ]
		];
	}

	/**
	 * @dataProvider provideLoadRevision
	 */
	public function testLoadRevisionData( $expectedOld, $expectedNew, $expectedRet, $old, $new ) {
		$this->expandTestArgs( [ &$expectedOld, &$expectedNew, &$expectedRet, &$old, &$new ] );
		$diffEngine = new DifferenceEngine( $this->context, $old, $new, 2, true, false );
		$ret = $diffEngine->loadRevisionData();
		$ret2 = $diffEngine->loadRevisionData();

		$this->assertEquals( $expectedOld, $diffEngine->getOldid() );
		$this->assertEquals( $expectedNew, $diffEngine->getNewid() );
		$this->assertEquals( $expectedRet, $ret );
		$this->assertEquals( $expectedRet, $ret2 );
	}

	public static function provideLoadRevision() {
		return [
			'diff=prev' => [ 'rev[2]', 'rev[3]', true, 'rev[3]', 'prev' ],
			'diff=next' => [ 'rev[2]', 'rev[3]', true, 'rev[2]', 'next' ],
			'diff=rev[3]' => [ 'rev[1]', 'rev[3]', true, 'rev[1]', 'rev[3]' ],
			'diff=0' => [ 'rev[1]', 'rev[cur]', true, 'rev[1]', 0 ],
			'diff=prev&oldid=<first>' => [ false, 'rev[0]', true, 'rev[0]', 'prev' ],
			'invalid' => [ 123456789, 'rev[1]', false, 123456789, 'rev[1]' ],
		];
	}

	public function testGetOldid() {
		$revs = self::$revisions;

		$diffEngine = new DifferenceEngine( $this->context, $revs[1], $revs[2], 2, true, false );
		$this->assertEquals( $revs[1], $diffEngine->getOldid(), 'diff get old id' );
	}

	public function testGetNewid() {
		$revs = self::$revisions;

		$diffEngine = new DifferenceEngine( $this->context, $revs[1], $revs[2], 2, true, false );
		$this->assertEquals( $revs[2], $diffEngine->getNewid(), 'diff get new id' );
	}

	/**
	 * @dataProvider provideGenerateContentDiffBody
	 */
	public function testGenerateContentDiffBody(
		array $oldContentArgs, array $newContentArgs, $expectedDiff
	) {
		$this->mergeMwGlobalArrayValue( 'wgContentHandlers', [
			'testing-nontext' => DummyNonTextContentHandler::class,
		] );
		$oldContent = ContentHandler::makeContent( ...$oldContentArgs );
		$newContent = ContentHandler::makeContent( ...$newContentArgs );

		$differenceEngine = new DifferenceEngine();
		$diff = $differenceEngine->generateContentDiffBody( $oldContent, $newContent );
		$this->assertSame( $expectedDiff, $this->getPlainDiff( $diff ) );
	}

	public static function provideGenerateContentDiffBody() {
		$content1 = [ 'xxx', null, CONTENT_MODEL_TEXT ];
		$content2 = [ 'yyy', null, CONTENT_MODEL_TEXT ];

		return [
			'self-diff' => [ $content1, $content1, '' ],
			'text diff' => [ $content1, $content2, '-xxx+yyy' ],
		];
	}

	public function testGenerateTextDiffBody() {
		$oldText = "aaa\nbbb\nccc";
		$newText = "aaa\nxxx\nccc";
		$expectedDiff = " aaa aaa\n-bbb+xxx\n ccc ccc";

		$differenceEngine = new DifferenceEngine();
		$diff = $differenceEngine->generateTextDiffBody( $oldText, $newText );
		$this->assertSame( $expectedDiff, $this->getPlainDiff( $diff ) );
	}

	public function testSetContent() {
		$oldContent = ContentHandler::makeContent( 'xxx', null, CONTENT_MODEL_TEXT );
		$newContent = ContentHandler::makeContent( 'yyy', null, CONTENT_MODEL_TEXT );

		$differenceEngine = new DifferenceEngine();
		$differenceEngine->setContent( $oldContent, $newContent );
		$diff = $differenceEngine->getDiffBody();
		$this->assertSame( "Line 1:\nLine 1:\n-xxx+yyy", $this->getPlainDiff( $diff ) );
	}

	public function testSetRevisions() {
		$rev1 = $this->getRevisionRecord( [ SlotRecord::MAIN => 'xxx' ] );
		$rev2 = $this->getRevisionRecord( [ SlotRecord::MAIN => 'yyy' ] );

		$differenceEngine = new DifferenceEngine();
		$differenceEngine->setRevisions( $rev1, $rev2 );
		$this->assertSame( $rev1, $differenceEngine->getOldRevision() );
		$this->assertSame( $rev2, $differenceEngine->getNewRevision() );
		$this->assertSame( true, $differenceEngine->loadRevisionData() );
		$this->assertSame( true, $differenceEngine->loadText() );

		$differenceEngine->setRevisions( null, $rev2 );
		$this->assertSame( null, $differenceEngine->getOldRevision() );
	}

	/**
	 * @dataProvider provideGetDiffBody
	 */
	public function testGetDiffBody(
		?array $oldSlots, ?array $newSlots, $slotDiffOptions, $expectedDiff
	) {
		$oldRevision = $this->getRevisionRecord( $oldSlots );
		$newRevision = $this->getRevisionRecord( $newSlots );
		if ( $expectedDiff instanceof Exception ) {
			$this->expectException( get_class( $expectedDiff ) );
			$this->expectExceptionMessage( $expectedDiff->getMessage() );
		}
		$differenceEngine = new DifferenceEngine();
		$differenceEngine->setRevisions( $oldRevision, $newRevision );
		if ( $slotDiffOptions !== null ) {
			$differenceEngine->setSlotDiffOptions( $slotDiffOptions );
		}
		if ( $expectedDiff instanceof Exception ) {
			return;
		}

		$diff = $differenceEngine->getDiffBody();
		$this->assertSame( $expectedDiff, $this->getPlainDiff( $diff ) );
	}

	public static function provideGetDiffBody() {
		$main1 = [ SlotRecord::MAIN => 'xxx' ];
		$main2 = [ SlotRecord::MAIN => 'yyy' ];
		$slot1 = [ 'slot' => 'aaa' ];
		$slot2 = [ 'slot' => 'bbb' ];
		$slot3 = [ 'derivedslot' => [ 'text' => 'aaa', 'derived' => true ] ];
		$slot4 = [ 'derivedslot' => [ 'text' => 'bbb', 'derived' => true ] ];
		$slot5 = [ 'slot' => [ 'model' => 'testing' ] ];

		return [
			'revision vs. null' => [
				null,
				$main1 + $slot1,
				null,
				'',
			],
			'revision vs. itself' => [
				$main1 + $slot1,
				$main1 + $slot1,
				null,
				'',
			],
			'different text in one slot' => [
				$main1 + $slot1,
				$main1 + $slot2,
				null,
				"slotLine 1:\nLine 1:\n-aaa+bbb",
			],
			'different text in two slots' => [
				$main1 + $slot1,
				$main2 + $slot2,
				null,
				"Line 1:\nLine 1:\n-xxx+yyy\nslotLine 1:\nLine 1:\n-aaa+bbb",
			],
			'new slot' => [
				$main1,
				$main1 + $slot1,
				null,
				"slotLine 1:\nLine 1:\n- +aaa",
			],
			'ignored difference in derived slot' => [
				$main1 + $slot3,
				$main1 + $slot4,
				null,
				'',
			],
			'incompatible slot' => [
				$main1 + $slot5,
				$main2 + $slot1,
				null,
				"Line 1:\nLine 1:\n-xxx+yyy\nslotCannot compare content models \"testing\" and \"plain text\"",
			],
			'invalid diff-type' => [
				$main1,
				$main2,
				[ 'diff-type' => 'invalid' ],
				"Line 1:\nLine 1:\n-xxx+yyy",
			]
		];
	}

	public function testRecursion() {
		// Set up a ContentHandler which will return a wrapped DifferenceEngine as
		// SlotDiffRenderer, then pass it a content which uses the same ContentHandler.
		// This tests the anti-recursion logic in DifferenceEngine::generateContentDiffBody.

		$customDifferenceEngine = $this->getMockBuilder( DifferenceEngine::class )
			->enableProxyingToOriginalMethods()
			->getMock();
		$customContentHandler = $this->getMockBuilder( ContentHandler::class )
			->setConstructorArgs( [ 'foo', [] ] )
			->onlyMethods( [ 'createDifferenceEngine' ] )
			->getMockForAbstractClass();
		$customContentHandler->method( 'createDifferenceEngine' )
			->willReturn( $customDifferenceEngine );
		/** @var ContentHandler $customContentHandler */
		$customContent = $this->getMockBuilder( Content::class )
			->onlyMethods( [ 'getContentHandler' ] )
			->getMockForAbstractClass();
		$customContent->method( 'getContentHandler' )
			->willReturn( $customContentHandler );
		/** @var Content $customContent */
		$customContent2 = clone $customContent;

		$slotDiffRenderer = $customContentHandler->getSlotDiffRenderer( RequestContext::getMain() );
		$this->expectException( Exception::class );
		$this->expectExceptionMessage(
			': could not maintain backwards compatibility. Please use a SlotDiffRenderer.'
		);
		$slotDiffRenderer->getDiff( $customContent, $customContent2 );
	}

	/**
	 * @dataProvider provideMarkPatrolledLink
	 */
	public function testMarkPatrolledLink( $group, $config, $expectedResult ) {
		$this->setUserLang( 'qqx' );
		$user = $this->getTestUser( $group )->getUser();
		$this->context->setUser( $user );
		if ( $config ) {
			$this->context->setConfig( new HashConfig( $config ) );
		}

		$page = $this->getNonexistingTestPage( 'Page1' );
		$this->assertStatusGood( $this->editPage( $page, 'Edit1' ), 'edited a page' );
		$rev1 = $page->getRevisionRecord();
		$this->assertStatusGood( $this->editPage( $page, 'Edit2' ), 'edited a page' );
		$rev2 = $page->getRevisionRecord();

		$diffEngine = new DifferenceEngine( $this->context );
		$diffEngine->setRevisions( $rev1, $rev2 );

		$html = $diffEngine->markPatrolledLink();
		$this->assertStringContainsString( $expectedResult, $html );
	}

	public static function provideMarkPatrolledLink() {
		yield 'PatrollingEnabledUserAllowed' => [
			'sysop',
			[ MainConfigNames::UseRCPatrol => true, MainConfigNames::LanguageCode => 'qxx' ],
			'Mark as patrolled'
		];

		yield 'PatrollingEnabledUserNotAllowed' => [
			null,
			[ MainConfigNames::UseRCPatrol => true, MainConfigNames::LanguageCode => 'qxx' ],
			''
		];

		yield 'PatrollingDisabledUserAllowed' => [
			'sysop',
			null,
			''
		];

		yield 'PatrollingDisabledUserNotAllowed' => [
			null,
			null,
			''
		];
	}

	/**
	 * Convert a HTML diff to a human-readable format and hopefully make the test less fragile.
	 * @param string $diff
	 * @return string
	 */
	private function getPlainDiff( $diff ) {
		$replacements = [
			html_entity_decode( '&nbsp;' ) => ' ',
			html_entity_decode( '&minus;' ) => '-',
		];
		// Preserve markers when stripping tags
		$diff = str_replace( '<td class="diff-marker"></td>', ' ', $diff );
		$diff = str_replace( '<td colspan="2"></td>', ' ', $diff );
		$diff = preg_replace( '/data-marker="([^"]*)">/', '>$1', $diff );
		return str_replace( array_keys( $replacements ), array_values( $replacements ),
			trim( strip_tags( $diff ), "\n" ) );
	}

	/**
	 * @param string[]|array[]|null $slots Array mapping slot role to content.
	 *   If the content is a string, a normal text slot will be created. If the
	 *   content is an associative array, it can have the following keys:
	 *    - derived: If present and true, the slot is a derived slot
	 *    - text: The serialized content
	 *    - model: The content model ID
	 * @return MutableRevisionRecord|null
	 */
	private function getRevisionRecord( $slots ) {
		if ( $slots === null ) {
			return null;
		}

		$contentHandlerFactory = $this->getServiceContainer()->getContentHandlerFactory();
		$title = $this->makeMockTitle( __CLASS__ );
		$revision = new MutableRevisionRecord( $title );
		foreach ( $slots as $role => $info ) {
			if ( is_string( $info ) ) {
				$info = [
					'text' => $info,
				];
			}
			$info += [ 'text' => '', 'model' => CONTENT_MODEL_TEXT ];

			if ( !$contentHandlerFactory->isDefinedModel( $info['model'] ) ) {
				$contentHandlerFactory->defineContentHandler(
					$info['model'], DummyContentHandlerForTesting::class );
			}

			$content = ContentHandler::makeContent( $info['text'], null, $info['model'] );
			if ( $info['derived'] ?? false ) {
				$slotRecord = SlotRecord::newDerived( $role, $content );
			} else {
				$slotRecord = SlotRecord::newUnsaved( $role, $content );
			}
			$revision->setSlot( $slotRecord );
		}
		return $revision;
	}

	/**
	 * @dataProvider provideRevisionHeader
	 */
	public function testRevisionHeader( $deletedFlag, $allowedAction ) {
		$revs = self::$revisions;

		if ( $deletedFlag === 'none' ) {
			$oldRevId = $revs[1];
		} elseif ( $deletedFlag === 'deleted' ) {
			$oldRevId = $revs[4];
		} elseif ( $deletedFlag === 'suppressed' ) {
			$oldRevId = $revs[5];
		}

		$context = new DerivativeContext( $this->context );
		$context->setLanguage( 'qqx' );
		$permissionSet = [];
		if ( $allowedAction !== 'none' ) {
			if ( $allowedAction === 'edit' ) {
				$permissionSet[] = 'edit';
			}
			if ( $deletedFlag === 'suppressed' ) {
				$permissionSet[] = 'suppressrevision';
			} else {
				$permissionSet[] = 'deletedtext';
			}
		}
		$context->setAuthority(
			new SimpleAuthority( $this->getTestUser()->getUser(), $permissionSet )
		);

		$diffEngine = new DifferenceEngine( $context, $oldRevId, $revs[2], 2, true, true );
		$this->assertTrue( $diffEngine->loadRevisionData() );
		$revisionHeaderHtml = $diffEngine->getRevisionHeader( $diffEngine->getOldRevision(), 'complete' );

		// Always show the timestamp
		$this->assertStringContainsString( '(revisionasof:', $revisionHeaderHtml );

		if ( $allowedAction === 'none' ) {
			$this->assertStringNotContainsString( 'oldid=' . $oldRevId, $revisionHeaderHtml );
		} else {
			$this->assertStringContainsString( 'oldid=' . $oldRevId, $revisionHeaderHtml );
		}
		if ( $allowedAction === 'edit' ) {
			$this->assertStringContainsString( '(editold)', $revisionHeaderHtml );
		} else {
			$this->assertStringNotContainsString( '(editold)', $revisionHeaderHtml );
		}
		if ( $allowedAction === 'view' ) {
			$this->assertStringContainsString( '(viewsourceold)', $revisionHeaderHtml );
		} else {
			$this->assertStringNotContainsString( '(viewsourceold)', $revisionHeaderHtml );
		}

		if ( $deletedFlag === 'none' ) {
			$this->assertStringNotContainsString( 'history-deleted', $revisionHeaderHtml );
		} else {
			$this->assertStringContainsString( 'history-deleted', $revisionHeaderHtml );
		}
		if ( $deletedFlag === 'suppressed' ) {
			$this->assertStringContainsString( 'mw-history-suppressed', $revisionHeaderHtml );
		} else {
			$this->assertStringNotContainsString( 'mw-history-suppressed', $revisionHeaderHtml );
		}
	}

	public static function provideRevisionHeader() {
		return [
			[ 'none', 'view' ],
			[ 'none', 'edit' ],
			[ 'deleted', 'none' ],
			[ 'deleted', 'view' ],
			[ 'deleted', 'edit' ],
			[ 'suppressed', 'none' ],
			[ 'suppressed', 'view' ],
			[ 'suppressed', 'edit' ],
		];
	}

	/**
	 * @dataProvider provideShowDiffPage
	 * @param array $reqParams
	 * @param array $userRights
	 * @param array $expected
	 */
	public function testShowDiffPage( $reqParams, $userRights, $expected ) {
		$this->expandTestArgs( [ &$reqParams, &$expected ] );
		$context = new DerivativeContext( $this->context );

		$authority = new SimpleAuthority(
			new UserIdentityValue( 1, 'User' ),
			$userRights
		);
		$context->setAuthority( $authority );

		$request = new FauxRequest( $reqParams );
		$context->setRequest( $request );

		$context->setLanguage( 'qqx' );

		$out = new OutputPage( $context );
		$out->enableOOUI();
		$context->setOutput( $out );

		$engine = new DifferenceEngine(
			$context,
			$request->getIntOrNull( 'oldid' ),
			$request->getVal( 'diff' ),
			0,
			false,
			$request->getInt( 'unhide' ) === 1
		);

		if ( isset( $expected['exception'] ) ) {
			$this->expectException( $expected['exception'] );
		}

		$engine->showDiffPage( $request->getBool( 'diffonly' ) );

		if ( isset( $expected['html'] ) ) {
			$this->assertMatchesRegularExpression(
				'{' . $expected['html'] . '}s',
				$out->getHTML(),
				'OutputPage::getHTML'
			);
		}
	}

	public static function provideShowDiffPage() {
		$cases = [
			'missing oldid' => [
				'params' => [
					'oldid' => '1000000',
					'diff' => 'prev',
				],
				'expected' => [
					'html' => '\(difference-missing-revision: 1000000, 1\)',
				]
			],
			'missing prev' => [
				'params' => [
					'oldid' => 'rev[0]',
					'diff' => 'prev'
				],
				'expected' => [
					'html' =>
						'\(diff-empty\).*' .
						'<div class="mw-content-ltr mw-parser-output" lang="en" dir="ltr"><p>no kittens'
				],
			],
			'normal diff=prev' => [
				'params' => [
					'oldid' => 'rev[1]',
					'diff' => 'prev'
				],
				'expected' => [
					'html' =>
						'\(viewsourceold\).*' .
						'<del class="diffchange diffchange-inline">no kittens</del>.*' .
						'<ins class="diffchange diffchange-inline">one kitten</ins>.*' .
						'<div class="mw-content-ltr mw-parser-output" lang="en" dir="ltr"><p>one kitten',
				]
			],
			'normal diff=number' => [
				'params' => [
					'oldid' => 'rev[0]',
					'diff' => 'rev[1]'
				],
				'expected' => [
					'html' =>
						'\(viewsourceold\).*' .
						'<del class="diffchange diffchange-inline">no kittens</del>.*' .
						'<ins class="diffchange diffchange-inline">one kitten</ins>.*' .
						'<div class="mw-content-ltr mw-parser-output" lang="en" dir="ltr"><p>one kitten',
				]
			],
			'user cannot read' => [
				'params' => [
					'oldid' => 'rev[1]',
					'diff' => 'prev',
				],
				'userRights' => [],
				'expected' => [
					'exception' => PermissionsError::class,
				]
			],
			'user can rollback' => [
				'params' => [
					'oldid' => 'rev[6]',
					'diff' => 'rev[7]',
				],
				'userRights' => [ 'read', 'edit', 'rollback' ],
				'expected' => [
					'html' =>
						'\(editold\).*' .
						'\(rollbacklinkcount: 1\)',
				]
			],
			'diffonly' => [
				'params' => [
					'oldid' => 'rev[1]',
					'diff' => 'prev',
					'diffonly' => '1',
				],
				'expected' => [
					'html' =>
						'<del class="diffchange diffchange-inline">no kittens</del>.*' .
						'<ins class="diffchange diffchange-inline">one kitten</ins>.*' .
						'</table>$',
				]
			],
			'deleted LHS' => [
				'params' => [
					'oldid' => 'rev[4]',
					'diff' => 'rev[1]'
				],
				'expected' => [
					'html' => '<div id="mw-diff-otitle1">.*' .
						'<span class="history-deleted">.*' .
						'<div id="mw-diff-ntitle1">.*' .
						'\(rev-deleted-no-diff\)',
				]
			],
			'deleted RHS' => [
				'params' => [
					'oldid' => 'rev[3]',
					'diff' => 'rev[4]'
				],
				'expected' => [
					'html' =>
						'<div id="mw-diff-otitle1">.*' .
						'<div id="mw-diff-ntitle1">.*' .
						'<span class="history-deleted">.*' .
						'\(rev-deleted-no-diff\)',
				]
			],
			'deleted LHS can unhide' => [
				'params' => [
					'oldid' => 'rev[4]',
					'diff' => 'rev[1]'
				],
				'userRights' => [ 'read', 'deletedtext' ],
				'expected' => [
					'html' =>
						'<div id="mw-diff-ntitle1">.*' .
						'\(rev-deleted-unhide-diff:.*' .
						'&amp;unhide=1.*',
				]
			],
			'deleted RHS with unhide' => [
				'params' => [
					'oldid' => 'rev[3]',
					'diff' => 'rev[4]',
					'unhide' => '1'
				],
				'userRights' => [ 'read', 'deletedtext' ],
				'expected' => [
					'html' =>
						'\(rev-deleted-diff-view\).*' .
						'<del class="diffchange diffchange-inline">three </del>.*' .
						'<ins class="diffchange diffchange-inline">fnord </ins>.*' .
						'<div class="mw-content-ltr mw-parser-output" lang="en" dir="ltr"><p>fnord kittens',
				]
			],
		];

		foreach ( $cases as $name => $case ) {
			yield $name => [
				$case['params'],
				$case['userRights'] ?? [ 'read' ],
				$case['expected']
			];
		}
	}
}
PK       ! (|    !  diff/TextSlotDiffRendererTest.phpnu Iw        <?php

use MediaWiki\Content\ContentHandler;
use MediaWiki\Content\TextContent;
use MediaWiki\Context\RequestContext;
use MediaWiki\Diff\TextDiffer\ManifoldTextDiffer;
use MediaWiki\Diff\TextDiffer\Wikidiff2TextDiffer;
use MediaWiki\Tests\Diff\TextDiffer\TextDifferData;
use Wikimedia\Assert\ParameterTypeException;
use Wikimedia\Stats\StatsFactory;

/**
 * @covers \TextSlotDiffRenderer
 */
class TextSlotDiffRendererTest extends MediaWikiIntegrationTestCase {

	public function setUp(): void {
		Wikidiff2TextDiffer::$fakeVersionForTesting = '1.14.1';
	}

	public function tearDown(): void {
		Wikidiff2TextDiffer::$fakeVersionForTesting = null;
	}

	public function testGetExtraCacheKeys() {
		$slotDiffRenderer = $this->getTextSlotDiffRenderer();
		$key = $slotDiffRenderer->getExtraCacheKeys();
		$slotDiffRenderer->setEngine( TextSlotDiffRenderer::ENGINE_WIKIDIFF2_INLINE );
		$inlineKey = $slotDiffRenderer->getExtraCacheKeys();

		ksort( $key );
		ksort( $inlineKey );

		$this->assertSame( [ '10-formats-and-engines' => 'php=table' ], $key );
		$this->assertSame(
			[
				'10-formats-and-engines' => 'wikidiff2=inline',
				'20-wikidiff2-version' => '1.14.1',
				'21-wikidiff2-options' => 'bc2a06be',
			],
			$inlineKey
		);
	}

	/**
	 * @dataProvider provideGetDiff
	 * @param array|null $oldContentArgs To pass to makeContent() (if not null)
	 * @param array|null $newContentArgs
	 * @param string|Exception $expectedResult
	 */
	public function testGetDiff(
		?array $oldContentArgs, ?array $newContentArgs, $expectedResult
	) {
		$this->mergeMwGlobalArrayValue( 'wgContentHandlers', [
			'testing' => DummyContentHandlerForTesting::class,
			'testing-nontext' => DummyNonTextContentHandler::class,
		] );

		$oldContent = $oldContentArgs ? self::makeContent( ...$oldContentArgs ) : null;
		$newContent = $newContentArgs ? self::makeContent( ...$newContentArgs ) : null;

		if ( $expectedResult instanceof Exception ) {
			$this->expectException( get_class( $expectedResult ) );
			$this->expectExceptionMessage( $expectedResult->getMessage() );
		}

		$slotDiffRenderer = $this->getTextSlotDiffRenderer();
		$diff = $slotDiffRenderer->getDiff( $oldContent, $newContent );
		if ( $expectedResult instanceof Exception ) {
			return;
		}
		$plainDiff = $this->getPlainDiff( $diff );
		$this->assertSame( $expectedResult, $plainDiff );
	}

	public static function provideGetDiff() {
		return [
			'same text' => [
				[ "aaa\nbbb\nccc" ],
				[ "aaa\nbbb\nccc" ],
				"",
			],
			'different text' => [
				[ "aaa\nbbb\nccc" ],
				[ "aaa\nxxx\nccc" ],
				" aaa aaa\n-bbb+xxx\n ccc ccc",
			],
			'no right content' => [
				[ "aaa\nbbb\nccc" ],
				null,
				"-aaa+ \n-bbb \n-ccc ",
			],
			'no left content' => [
				null,
				[ "aaa\nbbb\nccc" ],
				"- +aaa\n +bbb\n +ccc",
			],
			'no content' => [
				null,
				null,
				new InvalidArgumentException( '$oldContent and $newContent cannot both be null' ),
			],
			'non-text left content' => [
				[ '', 'testing-nontext' ],
				[ "aaa\nbbb\nccc" ],
				new IncompatibleDiffTypesException( 'testing-nontext', 'text' ),
			],
			'non-text right content' => [
				[ "aaa\nbbb\nccc" ],
				[ '', 'testing-nontext' ],
				new ParameterTypeException( '$newContent', 'MediaWiki\Content\TextContent|null' ),
			],
		];
	}

	// no separate test for getTextDiff() as getDiff() is just a thin wrapper around it

	/**
	 * @param string $langCode
	 * @return TextSlotDiffRenderer
	 */
	private function getTextSlotDiffRenderer( $langCode = 'en' ) {
		$slotDiffRenderer = new TextSlotDiffRenderer();
		$slotDiffRenderer->setStatsFactory( StatsFactory::newNull() );
		$lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( $langCode );
		$context = new RequestContext;
		$context->setLanguage( $lang );
		$differ = new ManifoldTextDiffer(
			$context,
			$lang,
			'php',
			null,
			[]
		);
		$slotDiffRenderer->setTextDiffer( $differ );
		$slotDiffRenderer->setFormat( 'table' );
		return $slotDiffRenderer;
	}

	/**
	 * Convert a HTML diff to a human-readable format and hopefully make the test less fragile.
	 * @param string $diff
	 * @return string
	 */
	private function getPlainDiff( $diff ) {
		$replacements = [
			html_entity_decode( '&nbsp;' ) => ' ',
			html_entity_decode( '&minus;' ) => '-',
		];
		// Preserve markers when stripping tags
		$diff = str_replace( '<td class="diff-marker"></td>', ' ', $diff );
		$diff = preg_replace( '@<td colspan="2"( class="(?:diff-side-deleted|diff-side-added)")?></td>@', ' ', $diff );
		$diff = preg_replace( '/data-marker="([^"]*)">/', '>$1', $diff );
		return str_replace( array_keys( $replacements ), array_values( $replacements ),
			trim( strip_tags( $diff ), "\n" ) );
	}

	/**
	 * @param string $str
	 * @param string $model
	 * @return null|TextContent
	 */
	private static function makeContent( $str, $model = CONTENT_MODEL_TEXT ) {
		return ContentHandler::makeContent( $str, null, $model );
	}

	public static function provideGetTablePrefix() {
		return [
			'php' => [
				TextSlotDiffRenderer::ENGINE_PHP,
				[
					TextSlotDiffRenderer::INLINE_LEGEND_KEY => '<div></div>',
					TextSlotDiffRenderer::INLINE_SWITCHER_KEY => null
				]
			],
			'wikidiff2' => [
				TextSlotDiffRenderer::ENGINE_WIKIDIFF2,
				[
					TextSlotDiffRenderer::INLINE_LEGEND_KEY =>
						'class="mw-diff-inline-legend oo-ui-element-hidden"',
					TextSlotDiffRenderer::INLINE_SWITCHER_KEY => 'mw-diffPage-inlineToggle-container'
				]
			],
			'inline' => [
				TextSlotDiffRenderer::ENGINE_WIKIDIFF2_INLINE,
				[
					TextSlotDiffRenderer::INLINE_LEGEND_KEY =>
						'class="mw-diff-inline-legend".*\(diff-inline-tooltip-ins\)',
					TextSlotDiffRenderer::INLINE_SWITCHER_KEY => 'mw-diffPage-inlineToggle-container'
				]
			]
		];
	}

	/**
	 * @dataProvider provideGetTablePrefix
	 * @param string $engine
	 * @param string[] $expectedPatterns
	 */
	public function testGetTablePrefix( $engine, $expectedPatterns ) {
		OOUI\Theme::setSingleton( new OOUI\BlankTheme() );

		$slotDiffRenderer = $this->getTextSlotDiffRenderer( 'qqx' );
		$slotDiffRenderer->setHookContainer( $this->createHookContainer() );
		$slotDiffRenderer->setEngine( $engine );
		$slotDiffRenderer->setInlineToggleEnabled();

		$context = new RequestContext;
		$context->setLanguage( 'qqx' );

		$title = $this->getServiceContainer()->getTitleFactory()->newFromText( 'Test' );
		$result = $slotDiffRenderer->getTablePrefix( $context, $title );
		$this->assertSameSize( $expectedPatterns, $result );
		foreach ( $expectedPatterns as $key => $pattern ) {
			if ( $pattern === null ) {
				$this->assertNull( $result[$key], "\$result[$key]" );
			} else {
				$this->assertMatchesRegularExpression(
					"#$pattern#", $result[$key], "\$result[$key]" );
			}
		}
	}

	public function testLocalizeDiff() {
		$slotDiffRenderer = $this->getTextSlotDiffRenderer( 'en' );
		$slotDiffRenderer->setHookContainer( $this->createHookContainer() );
		$slotDiffRenderer->setEngine( 'php' );
		$result = $slotDiffRenderer->localizeDiff( TextDifferData::PHP_TABLE );
		$this->assertStringContainsString( 'Line 1:', $result );
	}
}
PK       ! ΁E=E  E  *  diff/TextDiffer/ManifoldTextDifferTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Diff\TextDiffer\ManifoldTextDiffer;
use MediaWiki\Tests\Diff\TextDiffer\TextDifferData;

/**
 * @covers \MediaWiki\Diff\TextDiffer\ManifoldTextDiffer
 * @covers \MediaWiki\Diff\TextDiffer\BaseTextDiffer
 */
class ManifoldTextDifferTest extends MediaWikiIntegrationTestCase {
	private function createDiffer( $configVars = [] ) {
		$services = $this->getServiceContainer();
		return new ManifoldTextDiffer(
			RequestContext::getMain(),
			$services->getLanguageFactory()->getLanguage( 'en' ),
			$configVars['DiffEngine'] ?? null,
			$configVars['ExternalDiffEngine'] ?? null,
			$configVars['Wikidiff2Options'] ?? []
		);
	}

	public function testGetName() {
		$this->assertSame( 'manifold', $this->createDiffer()->getName() );
	}

	public function testGetFormats() {
		if ( extension_loaded( 'wikidiff2' ) ) {
			$formats = [ 'table', 'inline', 'unified' ];
		} else {
			$formats = [ 'table', 'unified' ];
		}
		$this->assertSame(
			$formats,
			$this->createDiffer()->getFormats()
		);
	}

	public function testHasFormat() {
		$differ = $this->createDiffer();
		$this->assertTrue( $differ->hasFormat( 'table' ) );
		if ( extension_loaded( 'wikidiff2' ) ) {
			$this->assertTrue( $differ->hasFormat( 'inline' ) );
		}
		$this->assertFalse( $differ->hasFormat( 'external' ) );
		$this->assertFalse( $differ->hasFormat( 'nonexistent' ) );
	}

	public function testHasFormatExternal() {
		if ( wfIsWindows() ) {
			$this->markTestSkipped( 'This test only works on non-Windows platforms' );
		}
		$differ = $this->createDiffer( [
			'ExternalDiffEngine' => __DIR__ . '/externalDiffTest.sh'
		] );
		$this->assertTrue( $differ->hasFormat( 'external' ) );
	}

	public function testRenderForcePhp() {
		$differ = $this->createDiffer( [
			'DiffEngine' => 'php'
		] );
		$result = $differ->render( 'foo', 'bar', 'table' );
		$this->assertSame(
			TextDifferData::PHP_TABLE,
			$result
		);
	}

	/**
	 * @requires extension wikidiff2
	 */
	public function testRenderUnforcedWikidiff2() {
		$differ = $this->createDiffer();
		$result = $differ->render( 'foo', 'bar', 'table' );
		$this->assertSame(
			TextDifferData::WIKIDIFF2_TABLE,
			$result
		);
	}

	/**
	 * @requires extension wikidiff2
	 */
	public function testRenderBatchWikidiff2External() {
		if ( !is_executable( '/bin/sh' ) ) {
			$this->markTestSkipped( 'ExternalTextDiffer can\'t pass extra ' .
				'arguments like $wgPhpCli, so it\'s hard to be platform-independent' );
		}
		$differ = $this->createDiffer( [
			'ExternalDiffEngine' => __DIR__ . '/externalDiffTest.sh'
		] );
		$result = $differ->renderBatch( 'foo', 'bar',
			[ 'table', 'inline', 'external', 'unified' ] );
		$this->assertSame(
			[
				'table' => TextDifferData::WIKIDIFF2_TABLE,
				'inline' => TextDifferData::WIKIDIFF2_INLINE,
				'external' => TextDifferData::EXTERNAL,
				'unified' => TextDifferData::PHP_UNIFIED
			],
			$result
		);
	}

	public static function provideAddRowWrapper() {
		return [
			[ 'table', false ],
			[ 'external', false ],
			[ 'unified', true ]
		];
	}

	/**
	 * @dataProvider provideAddRowWrapper
	 * @param string $format
	 * @param bool $isWrap
	 */
	public function testAddRowWrapper( $format, $isWrap ) {
		if ( wfIsWindows() ) {
			$this->markTestSkipped( 'This test only works on non-Windows platforms' );
		}
		$differ = $this->createDiffer( [
			'ExternalDiffEngine' => __DIR__ . '/externalDiffTest.sh'
		] );
		$result = $differ->addRowWrapper( $format, 'foo' );
		if ( $isWrap ) {
			$this->assertSame( '<tr><td colspan="4"><pre>foo</pre></td></tr>', $result );
		} else {
			$this->assertSame( 'foo', $result );
		}
	}
}
PK       ! [8    +  diff/TextDiffer/Wikidiff2TextDifferTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Diff\TextDiffer\TextDiffer;
use MediaWiki\Diff\TextDiffer\Wikidiff2TextDiffer;
use MediaWiki\Tests\Diff\TextDiffer\TextDifferData;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Diff\TextDiffer\Wikidiff2TextDiffer
 */
class Wikidiff2TextDifferTest extends MediaWikiIntegrationTestCase {
	private function createDiffer() {
		$differ = new Wikidiff2TextDiffer( [] );
		$localizer = RequestContext::getMain();
		$localizer->setLanguage( 'qqx' );
		$differ->setLocalizer( $localizer );
		TestingAccessWrapper::newFromObject( $differ )->haveMoveSupport = true;
		return $differ;
	}

	public static function provideRenderBatch() {
		return [
			[ false ],
			[ true ]
		];
	}

	/**
	 * @requires extension wikidiff2
	 * @dataProvider provideRenderBatch
	 * @param bool $useMultiFormat
	 */
	public function testRenderBatch( $useMultiFormat ) {
		if ( !function_exists( 'wikidiff2_multi_format_diff' ) && $useMultiFormat ) {
			$this->markTestSkipped( 'Need wikidiff2 1.14.0+' );
		}
		$oldText = 'foo';
		$newText = 'bar';
		$differ = new Wikidiff2TextDiffer( [ 'useMultiFormat' => $useMultiFormat ] );
		// Should not need a MessageLocalizer
		$result = $differ->renderBatch( $oldText, $newText, [ 'table', 'inline' ] );
		$this->assertSame(
			[
				'table' => TextDifferData::WIKIDIFF2_TABLE,
				'inline' => TextDifferData::WIKIDIFF2_INLINE
			],
			$result
		);
	}

	public function testGetName() {
		$differ = new Wikidiff2TextDiffer( [] );
		$this->assertSame( 'wikidiff2', $differ->getName() );
	}

	public function testGetFormatContext() {
		$differ = new Wikidiff2TextDiffer( [] );
		$this->assertSame( TextDiffer::CONTEXT_ROW, $differ->getFormatContext( 'table' ) );
	}

	public static function provideGetTablePrefixes() {
		return [
			[
				'table',
				'class="mw-diff-inline-legend oo-ui-element-hidden".*\(diff-inline-tooltip-ins\)'
			],
			[
				'inline',
				'class="mw-diff-inline-legend".*\(diff-inline-tooltip-ins\)'
			],
		];
	}

	/**
	 * @dataProvider provideGetTablePrefixes
	 * @param string $format
	 * @param string $pattern
	 */
	public function testGetTablePrefixes( $format, $pattern ) {
		$differ = $this->createDiffer();
		$result = $differ->getTablePrefixes( $format );
		$this->assertMatchesRegularExpression(
			'{' . $pattern . '}s',
			$result[TextSlotDiffRenderer::INLINE_LEGEND_KEY]
		);
	}

	public static function provideLocalize() {
		return [
			'normal table' => [
				'table',
				TextDifferData::WIKIDIFF2_TABLE,
				[],
				'<td colspan="2" class="diff-lineno">\(lineno: 1\)</td>'
			],
			'table with move tooltip' => [
				'table',
				// From wikidiff2 001.phpt
				'<td class="diff-marker"><a class="mw-diff-movedpara-left" href="#movedpara_7_0_rhs">&#x26AB;</a></td>',
				[],
				'title="\(diff-paragraph-moved-tonew\)"'
			],
			'table with reduced line numbers' => [
				'table',
				TextDifferData::WIKIDIFF2_TABLE,
				[ 'reducedLineNumbers' => true ],
				'<td colspan="2" class="diff-lineno"></td>'
			],
			'inline tooltip' => [
				'inline',
				TextDifferData::WIKIDIFF2_INLINE,
				[],
				'<ins title="\(diff-inline-tooltip-ins\)">'
			],
		];
	}

	/**
	 * @dataProvider provideLocalize
	 * @param string $format
	 * @param string $input
	 * @param array $options
	 * @param string $pattern
	 */
	public function testLocalize( $format, $input, $options, $pattern ) {
		$differ = $this->createDiffer();
		$result = $differ->localize( $format, $input, $options );
		$this->assertMatchesRegularExpression(
			'{' . $pattern . '}s',
			$result
		);
	}

	public static function provideAddLocalizedTitleTooltips() {
		return [
			'moved paragraph left should get new location title' => [
				'<a class="mw-diff-movedpara-left">⚫</a>',
				'<a class="mw-diff-movedpara-left" title="(diff-paragraph-moved-tonew)">⚫</a>',
			],
			'moved paragraph right should get old location title' => [
				'<a class="mw-diff-movedpara-right">⚫</a>',
				'<a class="mw-diff-movedpara-right" title="(diff-paragraph-moved-toold)">⚫</a>',
			],
			'nothing changed when key not hit' => [
				'<a class="mw-diff-movedpara-rightis">⚫</a>',
				'<a class="mw-diff-movedpara-rightis">⚫</a>',
			],
		];
	}

	/**
	 * @dataProvider provideAddLocalizedTitleTooltips
	 */
	public function testAddLocalizedTitleTooltips( $input, $expected ) {
		$differ = TestingAccessWrapper::newFromObject( $this->createDiffer() );

		$this->assertEquals( $expected, $differ->addLocalizedTitleTooltips( 'table', $input ) );
	}

}
PK       !     *  diff/TextDiffer/ExternalTextDifferTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Diff\TextDiffer;

use MediaWiki\Diff\TextDiffer\ExternalTextDiffer;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Diff\TextDiffer\ExternalTextDiffer
 */
class ExternalTextDifferTest extends MediaWikiIntegrationTestCase {
	public function testRender() {
		if ( !is_executable( '/bin/sh' ) ) {
			$this->markTestSkipped( 'ExternalTextDiffer can\'t pass extra ' .
				'arguments like $wgPhpCli, so it\'s hard to be platform-independent' );
		}
		$oldText = 'foo';
		$newText = 'bar';
		$differ = new ExternalTextDiffer( __DIR__ . '/externalDiffTest.sh' );
		$result = $differ->render( $oldText, $newText, 'external' );
		$this->assertSame( "- foo\n+ bar\n", $result );
	}
}
PK       ! Y    "  diff/TextDiffer/TextDifferData.phpnu Iw        <?php

namespace MediaWiki\Tests\Diff\TextDiffer;

class TextDifferData {
	public const EXTERNAL = "- foo\n+ bar\n";

	public const PHP_TABLE = '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1"><!--LINE 1--></td>
<td colspan="2" class="diff-lineno"><!--LINE 1--></td></tr>
<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">foo</del></div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">bar</ins></div></td></tr>
';

	public const PHP_UNIFIED = '@@ -1,1 +1,1 @@
-foo
+bar
';

	public const WIKIDIFF2_TABLE = '<tr>
  <td colspan="2" class="diff-lineno"><!--LINE 1--></td>
  <td colspan="2" class="diff-lineno"><!--LINE 1--></td>
</tr>
<tr>
  <td colspan="2" class="diff-empty diff-side-deleted"></td>
  <td class="diff-marker" data-marker="+"></td>
  <td class="diff-addedline diff-side-added"><div>bar</div></td>
</tr>
<tr>
  <td class="diff-marker" data-marker="−"></td>
  <td class="diff-deletedline diff-side-deleted"><div>foo</div></td>
  <td colspan="2" class="diff-empty diff-side-added"></td>
</tr>
';

	public const WIKIDIFF2_INLINE = '<div class="mw-diff-inline-header"><!-- LINES 1,1 --></div>
<div class="mw-diff-inline-added"><ins>bar</ins></div>
<div class="mw-diff-inline-deleted"><del>foo</del></div>
';
}
PK       ! @To@   @   #  diff/TextDiffer/externalDiffTest.shnu ̗        #!/bin/sh

printf '%s' '- '
cat $1
echo
printf "+ "
cat $2
echo
PK       ! DԀ    %  diff/TextDiffer/PhpTextDifferTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Diff\TextDiffer\PhpTextDiffer;
use MediaWiki\Tests\Diff\TextDiffer\TextDifferData;

/**
 * @covers \MediaWiki\Diff\TextDiffer\PhpTextDiffer
 * @covers \MediaWiki\Diff\TextDiffer\BaseTextDiffer
 */
class PhpTextDifferTest extends MediaWikiIntegrationTestCase {
	private function createDiffer() {
		$lang = $this->getServiceContainer()
			->getLanguageFactory()
			->getLanguage( 'en' );
		$differ = new PhpTextDiffer( $lang );

		$localizer = RequestContext::getMain();
		$localizer->setLanguage( $lang );

		$differ->setLocalizer( $localizer );
		return $differ;
	}

	public function testRender() {
		$differ = $this->createDiffer();
		$result = $differ->render( 'foo', 'bar', 'table' );
		$this->assertSame( TextDifferData::PHP_TABLE, $result );
	}

	public static function provideRenderBatch() {
		return [
			'empty' => [
				[],
				[]
			],
			'one format' => [
				[ 'table' ],
				[
					'table' => TextDifferData::PHP_TABLE,
				]
			],
			'multiple formats' => [
				[ 'table', 'unified' ],
				[
					'table' => TextDifferData::PHP_TABLE,
					'unified' => TextDifferData::PHP_UNIFIED,
				]
			],
		];
	}

	/**
	 * @dataProvider provideRenderBatch
	 * @param array $formats
	 * @param array $expected
	 */
	public function testRenderBatch( $formats, $expected ) {
		$oldText = 'foo';
		$newText = 'bar';
		$differ = $this->createDiffer();
		$result = $differ->renderBatch( $oldText, $newText, $formats );
		$this->assertSame( $expected, $result );
	}

	public function testHasFormat() {
		$differ = $this->createDiffer();
		$this->assertTrue( $differ->hasFormat( 'table' ) );
		$this->assertFalse( $differ->hasFormat( 'external' ) );
	}

	public function testAddModules() {
		$out = RequestContext::getMain()->getOutput();
		$differ = $this->createDiffer();
		$differ->addModules( $out, 'table' );
		$this->assertSame( [], $out->getModules() );
	}

	public function testGetCacheKeys() {
		$differ = $this->createDiffer();
		$result = $differ->getCacheKeys( [ 'table' ] );
		$this->assertSame( [], $result );
	}

	public static function provideLocalize() {
		return [
			[ 1, [], 'Line 1:' ],
			[ 2, [], 'Line 2:' ],
			[ 1, [ 'reducedLineNumbers' => true ], '' ],
			[ [ 3, 5 ], [ 'diff-type' => 'inline' ], 'Line 3 ⟶ 5:' ],
			[ [ 1, 5 ], [ 'diff-type' => 'inline', 'reducedLineNumbers' => true ], 'Line 1 ⟶ 5:' ],
			[ [ 1, 1 ], [ 'diff-type' => 'inline', 'reducedLineNumbers' => true ], '' ]
		];
	}

	/**
	 * @dataProvider provideLocalize
	 * @param int|int[] $line
	 * @param array $options
	 * @param string $expected
	 */
	public function testLocalize( $line, $options, $expected ) {
		$content = is_array( $line )
			? "<!-- LINES $line[0],$line[1] -->"
			: "<!--LINE $line-->";
		$differ = $this->createDiffer();
		$result = $differ->localize(
			'table',
			$content,
			$options
		);
		$this->assertSame( $expected, $result );
	}

	public function testGetTablePrefixes() {
		$this->assertSame( [], $this->createDiffer()->getTablePrefixes( 'table' ) );
	}

	public function testGetPreferredFormatBatch() {
		$this->assertSame(
			[ 'table' ],
			$this->createDiffer()->getPreferredFormatBatch( 'table' )
		);
	}
}
PK       ! 1a      diff/CustomDifferenceEngine.phpnu Iw        <?php

use MediaWiki\Content\Content;

class CustomDifferenceEngine extends DifferenceEngine {

	public function __construct() {
		parent::__construct();
	}

	public function generateContentDiffBody( Content $old, Content $new ) {
		return $old->getText() . '|' . $new->getText();
	}

	public function showDiffStyle() {
		$this->getOutput()->addModules( 'foo' );
	}

	public function getDiffBodyCacheKeyParams() {
		$params = parent::getDiffBodyCacheKeyParams();
		$params[] = 'foo';
		return $params;
	}

}
PK       ! {-M  M    diff/SlotDiffRendererTest.phpnu Iw        <?php

use MediaWiki\Content\CssContent;
use MediaWiki\Content\JsonContent;
use MediaWiki\Content\TextContent;
use MediaWiki\Content\WikitextContent;
use Wikimedia\Assert\ParameterTypeException;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \SlotDiffRenderer
 */
class SlotDiffRendererTest extends \MediaWikiIntegrationTestCase {

	/**
	 * @dataProvider provideNormalizeContents
	 */
	public function testNormalizeContents(
		$oldContent, $newContent, $allowedClasses,
		$expectedOldContent, $expectedNewContent, $expectedExceptionClass
	) {
		$slotDiffRenderer = $this->createMock( SlotDiffRenderer::class );
		try {
			// __call needs help deciding which parameter to take by reference
			call_user_func_array( [ TestingAccessWrapper::newFromObject( $slotDiffRenderer ),
				'normalizeContents' ], [ &$oldContent, &$newContent, $allowedClasses ] );
			$this->assertEquals( $expectedOldContent, $oldContent );
			$this->assertEquals( $expectedNewContent, $newContent );
		} catch ( Exception $e ) {
			if ( !$expectedExceptionClass ) {
				throw $e;
			}
			$this->assertInstanceOf( $expectedExceptionClass, $e );
		}
	}

	public static function provideNormalizeContents() {
		return [
			'both null' => [ null, null, null, null, null, InvalidArgumentException::class ],
			'left null' => [
				null, new WikitextContent( 'abc' ), null,
				new WikitextContent( '' ), new WikitextContent( 'abc' ), null,
			],
			'right null' => [
				new WikitextContent( 'def' ), null, null,
				new WikitextContent( 'def' ), new WikitextContent( '' ), null,
			],
			'type filter' => [
				new WikitextContent( 'abc' ), new WikitextContent( 'def' ), WikitextContent::class,
				new WikitextContent( 'abc' ), new WikitextContent( 'def' ), null,
			],
			'type filter (subclass)' => [
				new WikitextContent( 'abc' ), new WikitextContent( 'def' ), TextContent::class,
				new WikitextContent( 'abc' ), new WikitextContent( 'def' ), null,
			],
			'type filter (null)' => [
				new WikitextContent( 'abc' ), null, TextContent::class,
				new WikitextContent( 'abc' ), new WikitextContent( '' ), null,
			],
			'type filter failure (left)' => [
				new TextContent( 'abc' ), new WikitextContent( 'def' ), WikitextContent::class,
				// Throws incompatible exception because the right content matches the filter and the
				// left doesn't. All other kinds of mismatches should result in a parameter type exception.
				null, null, IncompatibleDiffTypesException::class,
			],
			'type filter failure (right)' => [
				new WikitextContent( 'abc' ), new TextContent( 'def' ), WikitextContent::class,
				null, null, ParameterTypeException::class,
			],
			'type filter failure (left, with null)' => [
				new TextContent( 'abc' ), null, WikitextContent::class,
				null, null, ParameterTypeException::class,
			],
			'type filter failure (right, with null)' => [
				null, new TextContent( 'def' ), WikitextContent::class,
				null, null, ParameterTypeException::class,
			],
			'type filter (array syntax)' => [
				new WikitextContent( 'abc' ), new JsonContent( 'def' ),
				[ JsonContent::class, WikitextContent::class ],
				new WikitextContent( 'abc' ), new JsonContent( 'def' ), null,
			],
			'type filter failure (array syntax)' => [
				new WikitextContent( 'abc' ), new CssContent( 'def' ),
				[ JsonContent::class, WikitextContent::class ],
				null, null, ParameterTypeException::class,
			],
		];
	}

}
PK       ! O    -  diff/DifferenceEngineSlotDiffRendererTest.phpnu Iw        <?php

use MediaWiki\Content\ContentHandler;
use MediaWiki\Output\OutputPage;

/**
 * @covers \DifferenceEngineSlotDiffRenderer
 */
class DifferenceEngineSlotDiffRendererTest extends MediaWikiIntegrationTestCase {

	public function testGetDiff() {
		$differenceEngine = new CustomDifferenceEngine();
		$slotDiffRenderer = new DifferenceEngineSlotDiffRenderer( $differenceEngine );
		$oldContent = ContentHandler::makeContent( 'xxx', null, CONTENT_MODEL_TEXT );
		$newContent = ContentHandler::makeContent( 'yyy', null, CONTENT_MODEL_TEXT );

		$diff = $slotDiffRenderer->getDiff( $oldContent, $newContent );
		$this->assertEquals( 'xxx|yyy', $diff );

		$diff = $slotDiffRenderer->getDiff( null, $newContent );
		$this->assertEquals( '|yyy', $diff );

		$diff = $slotDiffRenderer->getDiff( $oldContent, null );
		$this->assertEquals( 'xxx|', $diff );
	}

	public function testAddModules() {
		$output = $this->getMockBuilder( OutputPage::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'addModules' ] )
			->getMock();
		$output->expects( $this->once() )
			->method( 'addModules' )
			->with( 'foo' );
		$differenceEngine = new CustomDifferenceEngine();
		$slotDiffRenderer = new DifferenceEngineSlotDiffRenderer( $differenceEngine );
		$slotDiffRenderer->addModules( $output );
	}
}
PK       ! zx  x    deferred/LinksUpdateTest.phpnu Iw        <?php

use MediaWiki\Content\WikitextContent;
use MediaWiki\Debug\MWDebug;
use MediaWiki\Deferred\LinksUpdate\LinksTable;
use MediaWiki\Deferred\LinksUpdate\LinksTableGroup;
use MediaWiki\Deferred\LinksUpdate\LinksUpdate;
use MediaWiki\Interwiki\ClassicInterwikiLookup;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageReference;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Deferred\LinksUpdate\LinksUpdate
 * @covers \MediaWiki\Deferred\LinksUpdate\CategoryLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\ExternalLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\GenericPageLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\ImageLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\InterwikiLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\LangLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\LinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\LinksTableGroup
 * @covers \MediaWiki\Deferred\LinksUpdate\PageLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\PagePropsTable
 * @covers \MediaWiki\Deferred\LinksUpdate\TemplateLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\TitleLinksTable
 *
 * @group LinksUpdate
 * @group Database
 */
class LinksUpdateTest extends MediaWikiLangTestCase {
	/** @var int */
	protected static $testingPageId;

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

		// Set up 'linksupdatetest' as a interwiki prefix for testing
		// See ParserTestRunner:appendInterwikiSetup for similar test code
		static $testInterwikis = [
			[
				'iw_prefix' => 'linksupdatetest',
				'iw_url' => 'http://testing.com/wiki/$1',
				// 'iw_api' => 'http://testing.com/w/api.php',
				'iw_local' => 0,
			],
		];
		$GLOBAL_SCOPE = 2; // See ParserTestRunner::appendInterwikiSetup
		$this->overrideConfigValues( [
			MainConfigNames::InterwikiScopes => $GLOBAL_SCOPE,
			MainConfigNames::InterwikiCache =>
			ClassicInterwikiLookup::buildCdbHash( $testInterwikis, $GLOBAL_SCOPE ),
			MainConfigNames::RCWatchCategoryMembership => true,
		] );
	}

	public function addDBDataOnce() {
		$res = $this->insertPage( 'Testing' );
		self::$testingPageId = $res['id'];
		$this->insertPage( 'Some_other_page' );
		$this->insertPage( 'Template:TestingTemplate' );
	}

	protected function makeTitleAndParserOutput( $name, $id ) {
		// Force the value returned by getArticleID, even is
		// READ_LATEST is passed.

		/** @var Title|MockObject $t */
		$t = $this->getMockBuilder( Title::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getArticleID' ] )
			->getMock();
		$t->method( 'getArticleID' )->willReturn( $id );

		$tAccess = TestingAccessWrapper::newFromObject( $t );
		$tAccess->secureAndSplit( $name );

		$po = new ParserOutput();
		$po->setTitleText( $name );

		return [ $t, $po ];
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::addLink
	 */
	public function testUpdate_pagelinks() {
		/** @var Title $t */
		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );

		$po->addLink( Title::newFromText( "Foo" ) );
		$po->addLink( Title::newFromText( "Bar" ) );
		$po->addLink( Title::newFromText( "Special:Foo" ) ); // special namespace should be ignored
		$po->addLink( Title::newFromText( "linksupdatetest:Foo" ) ); // interwiki link should be ignored
		$po->addLink( Title::newFromText( "#Foo" ) ); // hash link should be ignored

		$update = $this->assertLinksUpdate(
			$t,
			$po,
			'pagelinks',
			[ 'lt_namespace', 'lt_title' ],
			[ 'pl_from' => self::$testingPageId ],
			[
				[ NS_MAIN, 'Bar' ],
				[ NS_MAIN, 'Foo' ],
			]
		);
		$this->assertArrayEquals( [
			[ NS_MAIN, 'Foo' ],
			[ NS_MAIN, 'Bar' ],
		], array_map(
			static function ( PageReference $pageReference ) {
				return [ $pageReference->getNamespace(), $pageReference->getDbKey() ];
			},
			$update->getPageReferenceArray( 'pagelinks', LinksTable::INSERTED )
		) );

		$po = new ParserOutput();
		$po->setTitleText( $t->getPrefixedText() );

		$po->addLink( Title::newFromText( "Bar" ) );
		$po->addLink( Title::newFromText( "Baz" ) );
		$po->addLink( Title::newFromText( "Talk:Baz" ) );

		$update = $this->assertLinksUpdate(
			$t,
			$po,
			'pagelinks',
			[ 'lt_namespace', 'lt_title' ],
			[ 'pl_from' => self::$testingPageId ],
			[
				[ NS_MAIN, 'Bar' ],
				[ NS_MAIN, 'Baz' ],
				[ NS_TALK, 'Baz' ],
			]
		);
		$this->assertArrayEquals( [
			[ NS_MAIN, 'Baz' ],
			[ NS_TALK, 'Baz' ],
		], array_map(
			static function ( PageReference $pageReference ) {
				return [ $pageReference->getNamespace(), $pageReference->getDbKey() ];
			},
			$update->getPageReferenceArray( 'pagelinks', LinksTable::INSERTED )
		) );
		$this->assertArrayEquals( [
			[ NS_MAIN, 'Foo' ],
		], array_map(
			static function ( PageReference $pageReference ) {
				return [ $pageReference->getNamespace(), $pageReference->getDbKey() ];
			},
			$update->getPageReferenceArray( 'pagelinks', LinksTable::DELETED )
		) );
	}

	public function testUpdate_pagelinks_move() {
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );

		$po->addLink( Title::newFromText( "Foo" ) );
		$this->assertLinksUpdate(
			$t,
			$po,
			'pagelinks',
			[ 'lt_namespace', 'lt_title', 'pl_from_namespace' ],
			[ 'pl_from' => self::$testingPageId ],
			[
				[ NS_MAIN, 'Foo', NS_MAIN ],
			]
		);

		[ $t, $po ] = $this->makeTitleAndParserOutput( "User:Testing", self::$testingPageId );
		$po->addLink( Title::newFromText( "Foo" ) );
		$this->assertMoveLinksUpdate(
			$t,
			new PageIdentityValue( 2, 0, "Foo", false ),
			$po,
			'pagelinks',
			[ 'lt_namespace', 'lt_title', 'pl_from_namespace' ],
			[ 'pl_from' => self::$testingPageId ],
			[
				[ NS_MAIN, 'Foo', NS_USER ],
			]
		);
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::addExternalLink
	 */
	public function testUpdate_externallinks() {
		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );

		$po->addExternalLink( "http://testing.com/wiki/Foo" );
		$po->addExternalLink( "http://testing.com/wiki/Bar" );

		$update = $this->assertLinksUpdate(
			$t,
			$po,
			'externallinks',
			[ 'el_to_domain_index', 'el_to_path' ],
			[ 'el_from' => self::$testingPageId ],
			[
				[ 'http://com.testing.', '/wiki/Bar' ],
				[ 'http://com.testing.', '/wiki/Foo' ],
			]
		);

		$this->assertArrayEquals( [
			"http://testing.com/wiki/Bar",
			"http://testing.com/wiki/Foo"
		], $update->getAddedExternalLinks() );

		$po = new ParserOutput();
		$po->setTitleText( $t->getPrefixedText() );
		$po->addExternalLink( 'http://testing.com/wiki/Bar' );
		$po->addExternalLink( 'http://testing.com/wiki/Baz' );
		$update = $this->assertLinksUpdate(
			$t,
			$po,
			'externallinks',
			[ 'el_to_domain_index', 'el_to_path' ],
			[ 'el_from' => self::$testingPageId ],
			[
				[ 'http://com.testing.', '/wiki/Bar' ],
				[ 'http://com.testing.', '/wiki/Baz' ],
			]
		);

		$this->assertArrayEquals( [
			"http://testing.com/wiki/Baz"
		], $update->getAddedExternalLinks() );
		$this->assertArrayEquals( [
			"http://testing.com/wiki/Foo"
		], $update->getRemovedExternalLinks() );
	}

	public function testUpdate_externallinksWrongOldEntry() {
		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );

		// Insert invalid entry from T350476
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'externallinks' )
			->row( [
				'el_from' => self::$testingPageId,
				'el_to_domain_index' => 'http://.com.testing.',
				'el_to_path' => '/',
			] )
			->row( [
				'el_from' => self::$testingPageId,
				'el_to_domain_index' => 'http://.',
				'el_to_path' => '/',
			] )
			->row( [
				'el_from' => self::$testingPageId,
				'el_to_domain_index' => '',
				'el_to_path' => null,
			] )
			->execute();

		// Test that the invalid entries are removed on LinksUpdate
		$po = new ParserOutput();
		$po->setTitleText( $t->getPrefixedText() );
		$po->addExternalLink( 'http://testing.com/wiki/Bar' );
		$po->addExternalLink( 'http://testing.com/wiki/Baz' );
		$update = $this->assertLinksUpdate(
			$t,
			$po,
			'externallinks',
			[ 'el_to_domain_index', 'el_to_path' ],
			[ 'el_from' => self::$testingPageId ],
			[
				[ 'http://com.testing.', '/wiki/Bar' ],
				[ 'http://com.testing.', '/wiki/Baz' ],
			]
		);

		$this->assertArrayEquals( [
			'http://testing.com/wiki/Bar',
			'http://testing.com/wiki/Baz',
		], $update->getAddedExternalLinks() );
		$this->assertArrayEquals( [
			'http://testing.com/',
			'http:///',
			'',
		], $update->getRemovedExternalLinks() );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::addCategory
	 */
	public function testUpdate_categorylinks() {
		/** @var ParserOutput $po */
		$this->overrideConfigValue( MainConfigNames::CategoryCollation, 'uppercase' );

		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );

		$po->addCategory( "Foo", "FOO" );
		$po->addCategory( "Bar", "BAR" );

		$this->assertLinksUpdate(
			$t,
			$po,
			'categorylinks',
			[ 'cl_to', 'cl_sortkey' ],
			[ 'cl_from' => self::$testingPageId ],
			[
				[ 'Bar', "BAR\nTESTING" ],
				[ 'Foo', "FOO\nTESTING" ]
			]
		);

		// Check category count
		$this->newSelectQueryBuilder()
			->select( [ 'cat_title', 'cat_pages' ] )
			->from( 'category' )
			->where( [ 'cat_title' => [ 'Foo', 'Bar', 'Baz' ] ] )
			->assertResultSet( [
				[ 'Bar', 1 ],
				[ 'Foo', 1 ]
			] );

		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );
		$po->addCategory( "Bar", "Bar" );
		$po->addCategory( "Baz", "Baz" );

		$this->assertLinksUpdate(
			$t,
			$po,
			'categorylinks',
			[ 'cl_to', 'cl_sortkey' ],
			[ 'cl_from' => self::$testingPageId ],
			[
				[ 'Bar', "BAR\nTESTING" ],
				[ 'Baz', "BAZ\nTESTING" ]
			]
		);

		// Check category count decrement
		$this->newSelectQueryBuilder()
			->select( [ 'cat_title', 'cat_pages' ] )
			->from( 'category' )
			->where( [ 'cat_title' => [ 'Foo', 'Bar', 'Baz' ] ] )
			->assertResultSet( [
				[ 'Bar', 1 ],
				[ 'Baz', 1 ],
			] );
	}

	public function testOnAddingAndRemovingCategory_recentChangesRowIsAdded() {
		$this->overrideConfigValue( MainConfigNames::CategoryCollation, 'uppercase' );

		$title = Title::newFromText( 'Testing' );
		$wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$wikiPage->doUserEditContent(
			new WikitextContent( '[[Category:Foo]]' ),
			$this->getTestSysop()->getUser(),
			'added category'
		);
		$this->runAllRelatedJobs();

		$this->assertRecentChangeByCategorization(
			Title::newFromText( 'Category:Foo' ),
			[ [ 'Foo', '[[:Testing]] added to category' ] ]
		);

		$wikiPage->doUserEditContent(
			new WikitextContent( '[[Category:Bar]]' ),
			$this->getTestSysop()->getUser(),
			'replaced category'
		);
		$this->runAllRelatedJobs();

		$this->assertRecentChangeByCategorization(
			Title::newFromText( 'Category:Foo' ),
			[
				[ 'Foo', '[[:Testing]] added to category' ],
				[ 'Foo', '[[:Testing]] removed from category' ],
			]
		);

		$this->assertRecentChangeByCategorization(
			Title::newFromText( 'Category:Bar' ),
			[
				[ 'Bar', '[[:Testing]] added to category' ],
			]
		);
	}

	public function testOnAddingAndRemovingCategoryToTemplates_embeddingPagesAreIgnored() {
		$this->overrideConfigValue( MainConfigNames::CategoryCollation, 'uppercase' );

		$templateTitle = Title::newFromText( 'Template:TestingTemplate' );
		$templatePage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $templateTitle );

		$wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( 'Testing' ) );
		$wikiPage->doUserEditContent(
			new WikitextContent( '{{TestingTemplate}}' ),
			$this->getTestSysop()->getUser(),
			'added template'
		);
		$this->runAllRelatedJobs();

		$otherWikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( 'Some_other_page' ) );
		$otherWikiPage->doUserEditContent(
			new WikitextContent( '{{TestingTemplate}}' ),
			$this->getTestSysop()->getUser(),
			'added template'
		);
		$this->runAllRelatedJobs();

		$this->assertRecentChangeByCategorization(
			Title::newFromText( 'Baz' ),
			[]
		);

		$templatePage->doUserEditContent(
			new WikitextContent( '[[Category:Baz]]' ),
			$this->getTestSysop()->getUser(),
			'added category'
		);
		$this->runAllRelatedJobs();

		$this->assertRecentChangeByCategorization(
			Title::newFromText( 'Baz' ),
			[ [
				'Baz',
				'[[:Template:TestingTemplate]] added to category, ' .
				'[[Special:WhatLinksHere/Template:TestingTemplate|this page is included within other pages]]'
			] ]
		);
	}

	public function testUpdate_categorylinks_move() {
		$this->overrideConfigValue( MainConfigNames::CategoryCollation, 'uppercase' );

		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Old", self::$testingPageId );

		$po->addCategory( "Bar", "BAR" );
		$po->addCategory( "Foo", "FOO" );

		$this->assertLinksUpdate(
			$t,
			$po,
			'categorylinks',
			[ 'cl_to', 'cl_sortkey' ],
			[ 'cl_from' => self::$testingPageId ],
			[
				[ 'Bar', "BAR\nOLD" ],
				[ 'Foo', "FOO\nOLD" ],
			]
		);

		// Check category count
		$this->newSelectQueryBuilder()
			->select( [ 'cat_title', 'cat_pages' ] )
			->from( 'category' )
			->where( [ 'cat_title' => [ 'Foo', 'Bar', 'Baz' ] ] )
			->assertResultSet( [
				[ 'Bar', '1' ],
				[ 'Foo', '1' ],
			] );

		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "New", self::$testingPageId );

		$po->addCategory( "Bar", "BAR" );
		$po->addCategory( "Foo", "FOO" );

		// An update to cl_sortkey is not expected if there was no move
		$this->assertLinksUpdate(
			$t,
			$po,
			'categorylinks',
			[ 'cl_to', 'cl_sortkey' ],
			[ 'cl_from' => self::$testingPageId ],
			[
				[ 'Bar', "BAR\nOLD" ],
				[ 'Foo', "FOO\nOLD" ],
			]
		);

		// Check category count
		$this->newSelectQueryBuilder()
			->select( [ 'cat_title', 'cat_pages' ] )
			->from( 'category' )
			->where( [ 'cat_title' => [ 'Foo', 'Bar', 'Baz' ] ] )
			->assertResultSet( [
				[ 'Bar', '1' ],
				[ 'Foo', '1' ],
			] );

		// A category changed on move
		$po->setCategories( [
			"Baz" => "BAZ",
			"Foo" => "FOO",
		] );

		// With move notification, update to cl_sortkey is expected
		$this->assertMoveLinksUpdate(
			$t,
			new PageIdentityValue( 2, 0, "new", false ),
			$po,
			'categorylinks',
			[ 'cl_to', 'cl_sortkey' ],
			[ 'cl_from' => self::$testingPageId ],
			[
				[ 'Baz', "BAZ\nNEW" ],
				[ 'Foo', "FOO\nNEW" ],
			]
		);

		// Check category count
		$this->newSelectQueryBuilder()
			->select( [ 'cat_title', 'cat_pages' ] )
			->from( 'category' )
			->where( [ 'cat_title' => [ 'Foo', 'Bar', 'Baz' ] ] )
			->assertResultSet( [
				[ 'Baz', '1' ],
				[ 'Foo', '1' ],
			] );
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::addInterwikiLink
	 */
	public function testUpdate_iwlinks() {
		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );

		$target1 = Title::makeTitleSafe( NS_MAIN, "T1", '', 'linksupdatetest' );
		$target2 = Title::makeTitleSafe( NS_MAIN, "T2", '', 'linksupdatetest' );
		$target3 = Title::makeTitleSafe( NS_MAIN, "T3", '', 'linksupdatetest' );
		$po->addInterwikiLink( $target1 );
		$po->addInterwikiLink( $target2 );

		$this->assertLinksUpdate(
			$t,
			$po,
			'iwlinks',
			[ 'iwl_prefix', 'iwl_title' ],
			[ 'iwl_from' => self::$testingPageId ],
			[
				[ 'linksupdatetest', 'T1' ],
				[ 'linksupdatetest', 'T2' ],
			]
		);

		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );

		$po->addInterwikiLink( $target2 );
		$po->addInterwikiLink( $target3 );

		$this->assertLinksUpdate(
			$t,
			$po,
			'iwlinks',
			[ 'iwl_prefix', 'iwl_title' ],
			[ 'iwl_from' => self::$testingPageId ],
			[
				[ 'linksupdatetest', 'T2' ],
				[ 'linksupdatetest', 'T3' ]
			]
		);
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::addTemplate
	 */
	public function testUpdate_templatelinks() {
		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );
		$linkTargetLookup = MediaWikiServices::getInstance()->getLinkTargetLookup();

		$target1 = Title::newFromText( "Template:T1" );
		$target2 = Title::newFromText( "Template:T2" );
		$target3 = Title::newFromText( "Template:T3" );

		$po->addTemplate( $target1, 23, 42 );
		$po->addTemplate( $target2, 23, 42 );

		$this->assertLinksUpdate(
			$t,
			$po,
			'templatelinks',
			[ 'tl_target_id' ],
			[ 'tl_from' => self::$testingPageId ],
			[
				[ $linkTargetLookup->acquireLinkTargetId( $target1, $this->getDb() ) ],
				[ $linkTargetLookup->acquireLinkTargetId( $target2, $this->getDb() ) ],
			]
		);

		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );

		$po->addTemplate( $target2, 23, 42 );
		$po->addTemplate( $target3, 23, 42 );

		$this->assertLinksUpdate(
			$t,
			$po,
			'templatelinks',
			[ 'tl_target_id' ],
			[ 'tl_from' => self::$testingPageId ],
			[
				[ $linkTargetLookup->acquireLinkTargetId( $target2, $this->getDb() ) ],
				[ $linkTargetLookup->acquireLinkTargetId( $target3, $this->getDb() ) ],
			]
		);
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::addImage
	 */
	public function testUpdate_imagelinks() {
		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );

		$po->addImage( new TitleValue( NS_FILE, "1.png" ) );
		$po->addImage( new TitleValue( NS_FILE, "2.png" ) );

		$this->assertLinksUpdate(
			$t,
			$po,
			'imagelinks',
			'il_to',
			[ 'il_from' => self::$testingPageId ],
			[ [ '1.png' ], [ '2.png' ] ]
		);

		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );

		$po->addImage( new TitleValue( NS_FILE, "2.png" ) );
		$po->addImage( new TitleValue( NS_FILE, "3.png" ) );

		$this->assertLinksUpdate(
			$t,
			$po,
			'imagelinks',
			'il_to',
			[ 'il_from' => self::$testingPageId ],
			[ [ '2.png' ], [ '3.png' ] ]
		);
	}

	public function testUpdate_imagelinks_move() {
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );

		$po->addImage( new TitleValue( NS_FILE, "1.png" ) );
		$po->addImage( new TitleValue( NS_FILE, "2.png" ) );

		$fromNamespace = $t->getNamespace();
		$this->assertLinksUpdate(
			$t,
			$po,
			'imagelinks',
			[ 'il_to', 'il_from_namespace' ],
			[ 'il_from' => self::$testingPageId ],
			[ [ '1.png', $fromNamespace ], [ '2.png', $fromNamespace ] ]
		);

		$oldT = $t;
		[ $t, $po ] = $this->makeTitleAndParserOutput( "User:Testing", self::$testingPageId );
		$po->addImage( new TitleValue( NS_FILE, "1.png" ) );
		$po->addImage( new TitleValue( NS_FILE, "2.png" ) );

		$fromNamespace = $t->getNamespace();
		$this->assertMoveLinksUpdate(
			$t,
			$oldT->toPageIdentity(),
			$po,
			'imagelinks',
			[ 'il_to', 'il_from_namespace' ],
			[ 'il_from' => self::$testingPageId ],
			[ [ '1.png', $fromNamespace ], [ '2.png', $fromNamespace ] ]
		);
	}

	/**
	 * @covers \MediaWiki\Parser\ParserOutput::addLanguageLink
	 */
	public function testUpdate_langlinks() {
		$this->overrideConfigValue( MainConfigNames::CapitalLinks, true );

		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );

		$po->addLanguageLink( new TitleValue( 0, '1', '', 'De' ) );
		$po->addLanguageLink( new TitleValue( 0, '1', '', 'En' ) );
		$po->addLanguageLink( new TitleValue( 0, '1', '', 'Fr' ) );

		$this->assertLinksUpdate(
			$t,
			$po,
			'langlinks',
			[ 'll_lang', 'll_title' ],
			[ 'll_from' => self::$testingPageId ],
			[
				[ 'De', '1' ],
				[ 'En', '1' ],
				[ 'Fr', '1' ]
			]
		);

		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );
		$po->addLanguageLink( new TitleValue( 0, '2', '', 'En' ) );
		$po->addLanguageLink( new TitleValue( 0, '1', '', 'Fr' ) );

		$this->assertLinksUpdate(
			$t,
			$po,
			'langlinks',
			[ 'll_lang', 'll_title' ],
			[ 'll_from' => self::$testingPageId ],
			[
				[ 'En', '2' ],
				[ 'Fr', '1' ]
			]
		);
	}

	/**
	 * @param bool $useDeprecatedApi
	 * @covers \MediaWiki\Parser\ParserOutput::setPageProperty
	 * @covers \MediaWiki\Parser\ParserOutput::setNumericPageProperty
	 * @covers \MediaWiki\Parser\ParserOutput::setUnsortedPageProperty
	 * @dataProvider provideUseDeprecatedApi
	 */
	public function testUpdate_page_props( $useDeprecatedApi ) {
		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );

		$fields = [ 'pp_propname', 'pp_value', 'pp_sortkey' ];
		$cond = [ 'pp_page' => self::$testingPageId ];

		$setNumericPageProperty = 'setNumericPageProperty';
		$setUnsortedPageProperty = 'setUnsortedPageProperty';
		if ( $useDeprecatedApi ) {
			// ::setPageProperty is deprecated when used for non-string values;
			// and when used for string values it is identical to
			// ::setUnsortedPageProperty
			$indexedPageProperty = 'setPageProperty';
			$setUnsortedPageProperty = 'setPageProperty';
			MWDebug::filterDeprecationForTest( '/::setPageProperty with non-string value/' );
		}

		$po->$setNumericPageProperty( 'deleted', 1 );
		$po->$setNumericPageProperty( 'changed', 1 );
		$this->assertLinksUpdate(
			$t, $po, 'page_props', $fields, $cond,
			[
				[ 'changed', '1', 1 ],
				[ 'deleted', '1', 1 ]
			]
		);

		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );

		// Elements of the $expected array are 3-element arrays:
		// First element is the page property name
		// Second element is the page property value
		//    (These are stringified when encoded into the database.)
		// Third element is the sort key (as a float, or null)
		$expected = [];

		if ( $useDeprecatedApi ) {
			// Using legacy API this is only coerced during LinksUpdate
			$po->setPageProperty( 'bool', true );
			$expected[] = [ "bool", true, 1.0 ];
		}

		$po->$setNumericPageProperty( 'changed', 2 );
		$expected[] = [ 'changed', 2, 2.0 ];

		$f = 4.0 + 1.0 / 4.0;
		$po->$setNumericPageProperty( "float", $f );
		$expected[] = [ "float", $f, $f ];

		$po->$setNumericPageProperty( "int", -7 );
		$expected[] = [ "int", -7, -7.0 ];

		$po->$setUnsortedPageProperty( "string", "33 bar" );
		$expected[] = [ "string", "33 bar", null ];

		if ( !$useDeprecatedApi ) {
			// A numeric string *does* get indexed if you use
			// ::setNumericPageProperty
			$po->setNumericPageProperty( "numeric-string", "33" );
			$expected[] = [ "numeric-string", 33, 33.0 ];
			// And similarly a numeric argument won't get indexed if you
			// use ::setUnsortedPageProperty
			$po->setUnsortedPageProperty( "unsorted", 33 );
			$expected[] = [ "unsorted", "33", null ];
		}

		// Note that the ::assertSelect machinery will sort by the columns
		// provided in $fields; in our case we should sort by property name
		usort( $expected, static fn ( $a, $b ): int => $a[0] <=> $b[0] );

		$update = $this->assertLinksUpdate(
			$t, $po, 'page_props', $fields, [ 'pp_page' => self::$testingPageId ], $expected );

		$expectedAssoc = [];
		foreach ( $expected as [ $name, $value ] ) {
			$expectedAssoc[$name] = $value;
		}
		$this->assertArrayEquals( $expectedAssoc, $update->getAddedProperties() );
		$this->assertArrayEquals(
			[
				'changed' => '1',
				'deleted' => '1'
			],
			$update->getRemovedProperties()
		);
	}

	public static function provideUseDeprecatedApi() {
		yield "Non-deprecated API" => [ false ];
		yield "Deprecated API" => [ true ];
	}

	// @todo test recursive, too!

	protected function assertLinksUpdate( Title $title, ParserOutput $parserOutput,
		$table, $fields, $condition, array $expectedRows
	) {
		return $this->assertMoveLinksUpdate( $title, null, $parserOutput,
			$table, $fields, $condition, $expectedRows );
	}

	protected function assertMoveLinksUpdate(
		Title $title, ?PageIdentityValue $oldTitle, ParserOutput $parserOutput,
		$table, $fields, $condition, array $expectedRows
	) {
		$update = new LinksUpdate( $title, $parserOutput );
		$update->setStrictTestMode();
		if ( $oldTitle ) {
			$update->setMoveDetails( $oldTitle );
		}
		$this->setTransactionTicket( $update );

		$update->doUpdate();

		$qb = $this->newSelectQueryBuilder()
			->select( $fields )
			->from( $table )
			->where( $condition );
		if ( $table === 'pagelinks' ) {
			$qb->join( 'linktarget', null, 'pl_target_id=lt_id' );
		}
		$qb->assertResultSet( $expectedRows );
		return $update;
	}

	protected function assertRecentChangeByCategorization(
		Title $categoryTitle, $expectedRows
	) {
		$this->newSelectQueryBuilder()
			->select( [ 'rc_title', 'comment_text' ] )
			->from( 'recentchanges' )
			->join( 'comment', null, 'comment_id = rc_comment_id' )
			->where( [
				'rc_type' => RC_CATEGORIZE,
				'rc_namespace' => NS_CATEGORY,
				'rc_title' => $categoryTitle->getDBkey(),
			] )
			->assertResultSet( $expectedRows );
	}

	private function runAllRelatedJobs() {
		$queueGroup = $this->getServiceContainer()->getJobQueueGroup();
		// phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
		while ( $job = $queueGroup->pop( 'refreshLinksPrioritized' ) ) {
			$job->run();
			$queueGroup->ack( $job );
		}
		// phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
		while ( $job = $queueGroup->pop( 'categoryMembershipChange' ) ) {
			$job->run();
			$queueGroup->ack( $job );
		}
	}

	public function testIsRecursive() {
		[ $title, $po ] = $this->makeTitleAndParserOutput( 'Test', 1 );
		$linksUpdate = new LinksUpdate( $title, $po );
		$this->assertTrue( $linksUpdate->isRecursive(), 'LinksUpdate is recursive by default' );

		$linksUpdate = new LinksUpdate( $title, $po, true );
		$this->assertTrue( $linksUpdate->isRecursive(),
			'LinksUpdate is recursive when asked to be recursive' );

		$linksUpdate = new LinksUpdate( $title, $po, false );
		$this->assertFalse( $linksUpdate->isRecursive(),
			'LinksUpdate is not recursive when asked to be not recursive' );
	}

	/**
	 * Confirm that repeatedly saving the same ParserOutput does not lead to
	 * DELETE/INSERT queries (T299662)
	 * @dataProvider provideUseDeprecatedApi
	 */
	public function testNullEdit( bool $useDeprecatedApi ) {
		$setNumericPageProperty = 'setNumericPageProperty';
		$setUnsortedPageProperty = 'setUnsortedPageProperty';
		if ( $useDeprecatedApi ) {
			$setNumericPageProperty = 'setPageProperty';
			$setUnsortedPageProperty = 'setPageProperty';
			MWDebug::filterDeprecationForTest( '/::setPageProperty with non-string value/' );
		}

		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );
		$po->addCategory( 'Test', 'Test' );
		$po->addExternalLink( 'http://www.example.com/' );
		$po->addImage( new TitleValue( NS_FILE, 'Test' ) );
		$po->addInterwikiLink( new TitleValue( 0, 'test', '', 'test' ) );
		$po->addLanguageLink( new TitleValue( 0, 'Test', '', 'en' ) );
		$po->addLink( new TitleValue( 0, 'Test' ) );
		$po->$setUnsortedPageProperty( 'string', 'x' );
		$po->$setUnsortedPageProperty( 'numeric-string', '1' );
		$po->$setNumericPageProperty( 'int', 10 );
		$po->$setNumericPageProperty( 'float', 2 / 3 );
		if ( $useDeprecatedApi ) {
			$po->setPageProperty( 'true', true );
			$po->setPageProperty( 'false', false );
			$this->expectDeprecationAndContinue( '/::setPageProperty with null value/' );
			$po->setPageProperty( 'null', null );
		} else {
			$po->$setUnsortedPageProperty( 'null', '' );
		}

		$update = new LinksUpdate( $t, $po );
		$update->setStrictTestMode();
		$this->setTransactionTicket( $update );
		$update->doUpdate();

		$time1 = $this->getDb()->lastDoneWrites();
		$this->assertGreaterThan( 0, $time1 );

		$update = new class( $t, $po ) extends LinksUpdate {
			protected function updateLinksTimestamp() {
				// Updating the timestamp is allowed, ignore
			}
		};
		$update->setStrictTestMode();
		$update->doUpdate();
		$time2 = $this->getDb()->lastDoneWrites();
		$this->assertSame( $time1, $time2 );
	}

	public static function provideNumericKeys() {
		$tables = TestingAccessWrapper::constant( LinksTableGroup::class, 'CORE_LIST' );
		foreach ( $tables as $tableName => $spec ) {
			yield [ $tableName ];
		}
	}

	/**
	 * Unit test for numeric strings in ParserOutput array keys (T301433)
	 *
	 * @dataProvider provideNumericKeys
	 */
	public function testNumericKeys( $tableName ) {
		$s = '123';
		$i = 123;

		/** @var ParserOutput $po */
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Testing", self::$testingPageId );
		$po->addCategory( $s, $s );
		$po->addExternalLink( 'https://foo.com' );
		$po->addImage( new TitleValue( NS_FILE, $s ) );
		$po->addInterwikiLink( new TitleValue( 0, $s, '', $s ) );
		$po->addLanguageLink( new TitleValue( 0, $s, '', $s ) );
		$po->addLink( new TitleValue( 0, $s ) );
		$po->setUnsortedPageProperty( $s, $s );
		$po->addTemplate( new TitleValue( 0, $s ), 1, 1 );

		$update = new LinksUpdate( $t, $po );
		/** @var LinksTableGroup $tg */
		$tg = TestingAccessWrapper::newFromObject( $update )->tableFactory;
		$table = $tg->get( $tableName );
		/** @var LinksTable $tt */
		$tt = TestingAccessWrapper::newFromObject( $table );
		$tableName = $tt->getTableName();
		foreach ( $tt->getNewLinkIDs() as $linkID ) {
			foreach ( (array)$linkID as $component ) {
				$this->assertNotSame( $i, $component,
					"Link ID of table $tableName should not be an integer " );
			}
		}
	}

	/**
	 * Integration test for numeric category names (T301433)
	 */
	public function testNumericCategory() {
		[ $t, $po ] = $this->makeTitleAndParserOutput( "Test 1", self::$testingPageId + 1 );
		$po->addCategory( '123a', '123a' );
		$update = new LinksUpdate( $t, $po );
		$this->setTransactionTicket( $update );
		$update->setStrictTestMode();
		$update->doUpdate();

		[ $t, $po ] = $this->makeTitleAndParserOutput( "Test 2", self::$testingPageId + 2 );
		$po->addCategory( '123', '123' );
		$update = new LinksUpdate( $t, $po );
		$this->setTransactionTicket( $update );
		$update->setStrictTestMode();
		$update->doUpdate();

		$this->newSelectQueryBuilder()
			->select( 'cat_pages' )
			->from( 'category' )
			->where( [ 'cat_title' => '123a' ] )
			->assertFieldValue( '1' );
	}

	private function setTransactionTicket( LinksUpdate $update ) {
		$update->setTransactionTicket(
			$this->getServiceContainer()->getConnectionProvider()->getEmptyTransactionTicket( __METHOD__ )
		);
	}
}
PK       ! zs6  6     deferred/DeferredUpdatesTest.phpnu Iw        <?php

use MediaWiki\Deferred\DeferrableUpdate;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Deferred\MergeableUpdate;
use MediaWiki\Deferred\MWCallableUpdate;
use MediaWiki\Deferred\TransactionRoundDefiningUpdate;

/**
 * @group Database
 * @covers \MediaWiki\Deferred\DeferredUpdates
 * @covers \MediaWiki\Deferred\DeferredUpdatesScopeStack
 * @covers \MediaWiki\Deferred\DeferredUpdatesScope
 */
class DeferredUpdatesTest extends MediaWikiIntegrationTestCase {

	public function testAddAndRun() {
		$update = $this->getMockBuilder( DeferrableUpdate::class )
			->onlyMethods( [ 'doUpdate' ] )->getMock();
		$update->expects( $this->once() )->method( 'doUpdate' );

		DeferredUpdates::addUpdate( $update );
		DeferredUpdates::doUpdates();
	}

	public function testAddMergeable() {
		$cleanup = DeferredUpdates::preventOpportunisticUpdates();

		$update1 = $this->getMockBuilder( MergeableUpdate::class )
			->onlyMethods( [ 'merge', 'doUpdate' ] )->getMock();
		$update1->expects( $this->once() )->method( 'merge' );
		$update1->expects( $this->never() )->method( 'doUpdate' );

		$update2 = $this->getMockBuilder( MergeableUpdate::class )
			->onlyMethods( [ 'merge', 'doUpdate' ] )->getMock();
		$update2->expects( $this->never() )->method( 'merge' );
		$update2->expects( $this->never() )->method( 'doUpdate' );

		DeferredUpdates::addUpdate( $update1 );
		DeferredUpdates::addUpdate( $update2 );
	}

	public function testAddCallableUpdate() {
		$ran = 0;
		DeferredUpdates::addCallableUpdate( static function () use ( &$ran ) {
			$ran++;
		} );
		// Opportunistic updates should be enabled, so the updates should have already executed
		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount() );

		$this->assertSame( 1, $ran, 'Update ran' );
	}

	public function testGetPendingUpdates() {
		$cleanup = DeferredUpdates::preventOpportunisticUpdates();

		$pre = DeferredUpdates::PRESEND;
		$post = DeferredUpdates::POSTSEND;
		$all = DeferredUpdates::ALL;

		$update = $this->createMock( DeferrableUpdate::class );
		$update->expects( $this->never() )
			->method( 'doUpdate' );

		DeferredUpdates::addUpdate( $update, $pre );
		$this->assertCount( 1, DeferredUpdates::getPendingUpdates( $pre ) );
		$this->assertSame( [], DeferredUpdates::getPendingUpdates( $post ) );
		$this->assertCount( 1, DeferredUpdates::getPendingUpdates( $all ) );
		$this->assertCount( 1, DeferredUpdates::getPendingUpdates() );
		DeferredUpdates::clearPendingUpdates();
		$this->assertSame( [], DeferredUpdates::getPendingUpdates() );

		DeferredUpdates::addUpdate( $update, $post );
		$this->assertSame( [], DeferredUpdates::getPendingUpdates( $pre ) );
		$this->assertCount( 1, DeferredUpdates::getPendingUpdates( $post ) );
		$this->assertCount( 1, DeferredUpdates::getPendingUpdates( $all ) );
		$this->assertCount( 1, DeferredUpdates::getPendingUpdates() );
		DeferredUpdates::clearPendingUpdates();
		$this->assertSame( [], DeferredUpdates::getPendingUpdates() );
	}

	public function testDoUpdatesWeb() {
		$cleanup = DeferredUpdates::preventOpportunisticUpdates();

		$updates = [
			'1' => "deferred update 1;\n",
			'2' => "deferred update 2;\n",
			'2-1' => "deferred update 1 within deferred update 2;\n",
			'2-2' => "deferred update 2 within deferred update 2;\n",
			'3' => "deferred update 3;\n",
			'3-1' => "deferred update 1 within deferred update 3;\n",
			'3-2' => "deferred update 2 within deferred update 3;\n",
			'3-1-1' => "deferred update 1 within deferred update 1 within deferred update 3;\n",
			'3-2-1' => "deferred update 1 within deferred update 2 with deferred update 3;\n",
		];
		DeferredUpdates::addCallableUpdate(
			static function () use ( $updates ) {
				echo $updates['1'];
			}
		);
		DeferredUpdates::addCallableUpdate(
			static function () use ( $updates ) {
				echo $updates['2'];
				DeferredUpdates::addCallableUpdate(
					static function () use ( $updates ) {
						echo $updates['2-1'];
					}
				);
				DeferredUpdates::addCallableUpdate(
					static function () use ( $updates ) {
						echo $updates['2-2'];
					}
				);
			}
		);
		DeferredUpdates::addCallableUpdate(
			static function () use ( $updates ) {
				echo $updates['3'];
				DeferredUpdates::addCallableUpdate(
					static function () use ( $updates ) {
						echo $updates['3-1'];
						DeferredUpdates::addCallableUpdate(
							static function () use ( $updates ) {
								echo $updates['3-1-1'];
							}
						);
					}
				);
				DeferredUpdates::addCallableUpdate(
					static function () use ( $updates ) {
						echo $updates['3-2'];
						DeferredUpdates::addCallableUpdate(
							static function () use ( $updates ) {
								echo $updates['3-2-1'];
							}
						);
					}
				);
			}
		);

		$this->assertEquals( 3, DeferredUpdates::pendingUpdatesCount() );

		$this->expectOutputString( implode( '', $updates ) );

		DeferredUpdates::doUpdates();

		$x = null;
		$y = null;
		DeferredUpdates::addCallableUpdate(
			static function () use ( &$x ) {
				$x = 'Sherity';
			},
			DeferredUpdates::PRESEND
		);
		DeferredUpdates::addCallableUpdate(
			static function () use ( &$y ) {
				$y = 'Marychu';
			},
			DeferredUpdates::POSTSEND
		);

		$this->assertNull( $x, "Update not run yet" );
		$this->assertNull( $y, "Update not run yet" );

		DeferredUpdates::doUpdates( DeferredUpdates::PRESEND );
		$this->assertEquals( "Sherity", $x, "PRESEND update ran" );
		$this->assertNull( $y, "POSTSEND update not run yet" );

		DeferredUpdates::doUpdates( DeferredUpdates::POSTSEND );
		$this->assertEquals( "Marychu", $y, "POSTSEND update ran" );
	}

	public function testDoUpdatesCLI() {
		$updates = [
			'1' => "deferred update 1;\n",
			'2' => "deferred update 2;\n",
			'2-1' => "deferred update 1 within deferred update 2;\n",
			'2-2' => "deferred update 2 within deferred update 2;\n",
			'3' => "deferred update 3;\n",
			'3-1' => "deferred update 1 within deferred update 3;\n",
			'3-2' => "deferred update 2 within deferred update 3;\n",
			'3-1-1' => "deferred update 1 within deferred update 1 within deferred update 3;\n",
			'3-2-1' => "deferred update 1 within deferred update 2 with deferred update 3;\n",
		];

		// clear anything
		$lbFactory = $this->getServiceContainer()->getDBLoadBalancerFactory();
		$lbFactory->commitPrimaryChanges( __METHOD__ );

		DeferredUpdates::addCallableUpdate(
			static function () use ( $updates ) {
				echo $updates['1'];
			}
		);
		DeferredUpdates::addCallableUpdate(
			static function () use ( $updates ) {
				echo $updates['2'];
				DeferredUpdates::addCallableUpdate(
					static function () use ( $updates ) {
						echo $updates['2-1'];
					}
				);
				DeferredUpdates::addCallableUpdate(
					static function () use ( $updates ) {
						echo $updates['2-2'];
					}
				);
			}
		);
		DeferredUpdates::addCallableUpdate(
			static function () use ( $updates ) {
				echo $updates['3'];
				DeferredUpdates::addCallableUpdate(
					static function () use ( $updates ) {
						echo $updates['3-1'];
						DeferredUpdates::addCallableUpdate(
							static function () use ( $updates ) {
								echo $updates['3-1-1'];
							}
						);
					}
				);
				DeferredUpdates::addCallableUpdate(
					static function () use ( $updates ) {
						echo $updates['3-2'];
						DeferredUpdates::addCallableUpdate(
							static function () use ( $updates ) {
								echo $updates['3-2-1'];
							}
						);
					}
				);
			}
		);

		$this->expectOutputString( implode( '', $updates ) );

		// Opportunistic updates should be enabled, so the updates should have already executed
		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount() );
	}

	public function testPresendAddOnPostsendRun() {
		$x = false;
		$y = false;
		// clear anything
		$lbFactory = $this->getServiceContainer()->getDBLoadBalancerFactory();
		$lbFactory->commitPrimaryChanges( __METHOD__ );

		DeferredUpdates::addCallableUpdate(
			static function () use ( &$x, &$y ) {
				$x = true;
				DeferredUpdates::addCallableUpdate(
					static function () use ( &$y ) {
						$y = true;
					},
					DeferredUpdates::PRESEND
				);
			},
			DeferredUpdates::POSTSEND
		);

		// Opportunistic updates should be enabled, so the updates should have already executed
		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount() );

		$this->assertTrue( $x, "Outer POSTSEND update ran" );
		$this->assertTrue( $y, "Nested PRESEND update ran" );
	}

	public function testRunUpdateTransactionScope() {
		$cleanup = DeferredUpdates::preventOpportunisticUpdates();

		$lbFactory = $this->getServiceContainer()->getDBLoadBalancerFactory();
		$this->assertFalse( $lbFactory->hasTransactionRound(), 'Initial state' );

		$ran = 0;
		DeferredUpdates::addCallableUpdate( function () use ( &$ran, $lbFactory ) {
			$ran++;
			$this->assertTrue( $lbFactory->hasTransactionRound(), 'Has transaction' );
		} );
		DeferredUpdates::doUpdates();

		$this->assertSame( 1, $ran, 'Update ran' );
		$this->assertFalse( $lbFactory->hasTransactionRound(), 'Final state' );
	}

	/**
	 * @covers \MediaWiki\Deferred\TransactionRoundDefiningUpdate
	 */
	public function testRunOuterScopeUpdate() {
		$cleanup = DeferredUpdates::preventOpportunisticUpdates();

		$lbFactory = $this->getServiceContainer()->getDBLoadBalancerFactory();
		$this->assertFalse( $lbFactory->hasTransactionRound(), 'Initial state' );

		$ran = 0;
		DeferredUpdates::addUpdate( new TransactionRoundDefiningUpdate(
				function () use ( &$ran, $lbFactory ) {
					$ran++;
					$this->assertFalse( $lbFactory->hasTransactionRound(), 'No transaction' );
				} )
		);
		DeferredUpdates::doUpdates();

		$this->assertSame( 1, $ran, 'Update ran' );
	}

	public function testTryOpportunisticExecute() {
		$calls = [];
		$callback1 = static function () use ( &$calls ) {
			$calls[] = 1;
		};
		$callback2 = static function () use ( &$calls ) {
			$calls[] = 2;
		};

		$lbFactory = $this->getServiceContainer()->getDBLoadBalancerFactory();
		$lbFactory->beginPrimaryChanges( __METHOD__ );

		DeferredUpdates::addCallableUpdate( $callback1 );
		$this->assertEquals( [], $calls );

		DeferredUpdates::tryOpportunisticExecute();
		$this->assertEquals( [], $calls );

		$dbw = $this->getDb();
		$dbw->onTransactionCommitOrIdle( function () use ( &$calls, $callback2 ) {
			DeferredUpdates::addCallableUpdate( $callback2 );
			$this->assertEquals( [], $calls );
			$calls[] = 'oti';
		} );
		$this->assertSame( 1, $dbw->trxLevel() );
		$this->assertEquals( [], $calls );

		$lbFactory->commitPrimaryChanges( __METHOD__ );

		$this->assertEquals( [ 'oti' ], $calls );

		DeferredUpdates::tryOpportunisticExecute();
		$this->assertEquals( [ 'oti', 1, 2 ], $calls );
	}

	/**
	 * @covers \MediaWiki\Deferred\MWCallableUpdate
	 */
	public function testCallbackUpdateRounds() {
		$lbFactory = $this->getServiceContainer()->getDBLoadBalancerFactory();

		$fname = __METHOD__;
		$called = false;
		// This confirms that DeferredUpdates sets the transaction owner in LBFactory
		// based on MWCallableUpdate::getOrigin, thus allowing the callback to control
		// over the transaction and e.g. perform a commit.
		DeferredUpdates::attemptUpdate(
			new MWCallableUpdate(
				static function () use ( $lbFactory, $fname, &$called ) {
					$lbFactory->commitPrimaryChanges( $fname );
					$called = true;
				},
				$fname
			)
		);

		$this->assertTrue( $called, "Callback ran" );
	}

	public function testNestedExecution() {
		$cleanup = DeferredUpdates::preventOpportunisticUpdates();

		$res = null;
		$resSub = null;
		$resSubSub = null;
		$resA = null;

		DeferredUpdates::clearPendingUpdates();

		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount() );
		$this->assertSame( 0, DeferredUpdates::getRecursiveExecutionStackDepth() );

		// T249069: TransactionRoundDefiningUpdate => JobRunner => DeferredUpdates::doUpdates()
		DeferredUpdates::addUpdate( new TransactionRoundDefiningUpdate(
			function () use ( &$res, &$resSub, &$resSubSub, &$resA ) {
				$res = 1;

				$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount() );
				$this->assertSame( 1, DeferredUpdates::getRecursiveExecutionStackDepth() );

				// Add update to subqueue of in-progress top-queue job
				DeferredUpdates::addCallableUpdate( function () use ( &$resSub, &$resSubSub ) {
					$resSub = 'a';

					$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount() );
					$this->assertSame( 2, DeferredUpdates::getRecursiveExecutionStackDepth() );

					// Add update to subqueue of in-progress top-queue job (not recursive)
					DeferredUpdates::addCallableUpdate( static function () use ( &$resSubSub ) {
						$resSubSub = 'b';
					} );

					$this->assertSame( 1, DeferredUpdates::pendingUpdatesCount() );
				} );

				$this->assertSame( 1, DeferredUpdates::pendingUpdatesCount() );
				$this->assertSame( 1, DeferredUpdates::getRecursiveExecutionStackDepth() );

				if ( $resSub === null && $resA === null && $resSubSub === null ) {
					$res = 418;
				}

				DeferredUpdates::doUpdates();
			}
		) );

		$this->assertSame( 1, DeferredUpdates::pendingUpdatesCount() );
		$this->assertSame( 0, DeferredUpdates::getRecursiveExecutionStackDepth() );

		DeferredUpdates::addCallableUpdate( static function () use ( &$resA ) {
			$resA = 93;
		} );

		$this->assertSame( 2, DeferredUpdates::pendingUpdatesCount() );
		$this->assertSame( 0, DeferredUpdates::getRecursiveExecutionStackDepth() );

		$this->assertNull( $resA );
		$this->assertNull( $res );
		$this->assertNull( $resSub );
		$this->assertNull( $resSubSub );

		DeferredUpdates::doUpdates();

		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount() );
		$this->assertSame( 0, DeferredUpdates::getRecursiveExecutionStackDepth() );
		$this->assertSame( 418, $res );
		$this->assertSame( 'a', $resSub );
		$this->assertSame( 'b', $resSubSub );
		$this->assertSame( 93, $resA );
	}
}
PK       ! ^BV  V    deferred/SearchUpdateTest.phpnu Iw        <?php

use MediaWiki\Deferred\SearchUpdate;
use MediaWiki\Page\PageIdentityValue;

/**
 * @group Search
 * @covers \MediaWiki\Deferred\SearchUpdate
 */
class SearchUpdateTest extends MediaWikiIntegrationTestCase {

	/**
	 * @var SearchUpdate
	 */
	private $su;

	protected function setUp(): void {
		parent::setUp();
		$pageIdentity = new PageIdentityValue( 42, NS_MAIN, 'Main_Page', PageIdentityValue::LOCAL );
		$this->su = new SearchUpdate( 0, $pageIdentity );
	}

	public function updateText( $text ) {
		return trim( $this->su->updateText( $text ) );
	}

	public function testUpdateText() {
		$this->assertEquals(
			'test',
			$this->updateText( '<div>TeSt</div>' ),
			'HTML stripped, text lowercased'
		);

		$this->assertEquals(
			'foo bar boz quux',
			$this->updateText( <<<EOT
<table style="color:red; font-size:100px">
	<tr class="scary"><td><div>foo</div></td><tr>bar</td></tr>
	<tr><td>boz</td><tr>quux</td></tr>
</table>
EOT
			), 'Stripping HTML tables' );

		$this->assertEquals(
			'a b',
			$this->updateText( 'a > b' ),
			'Handle unclosed tags'
		);

		$text = str_pad( "foo <barbarbar \n", 10000, 'x' );

		$this->assertNotEquals(
			'',
			$this->updateText( $text ),
			'T20609'
		);
	}

	/**
	 * T34712: Test if unicode quotes in article links make its search index empty
	 */
	public function testUnicodeLinkSearchIndexError() {
		$text = "text „http://example.com“ text";
		$result = $this->updateText( $text );
		$processed = preg_replace( '/Q/u', 'Q', $result );
		$this->assertTrue(
			$processed != '',
			'Link surrounded by unicode quotes should not fail UTF-8 validation'
		);
	}
}
PK       ! `l	  	     deferred/SiteStatsUpdateTest.phpnu Iw        <?php

use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Deferred\SiteStatsUpdate;
use MediaWiki\SiteStats\SiteStats;
use MediaWiki\SiteStats\SiteStatsInit;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Database
 * @covers \MediaWiki\Deferred\SiteStatsUpdate
 * @covers \MediaWiki\SiteStats\SiteStats
 * @covers \MediaWiki\SiteStats\SiteStatsInit
 */
class SiteStatsUpdateTest extends MediaWikiIntegrationTestCase {
	public function testFactoryAndMerge() {
		$update1 = SiteStatsUpdate::factory( [ 'pages' => 1, 'users' => 2 ] );
		$update2 = SiteStatsUpdate::factory( [ 'users' => 1, 'images' => 1 ] );

		$update1->merge( $update2 );
		$wrapped = TestingAccessWrapper::newFromObject( $update1 );

		$this->assertSame( 1, $wrapped->pages );
		$this->assertEquals( 3, $wrapped->users );
		$this->assertSame( 1, $wrapped->images );
		$this->assertSame( 0, $wrapped->edits );
		$this->assertSame( 0, $wrapped->articles );
	}

	public function testDoUpdate() {
		$dbw = $this->getDb();
		$statsInit = new SiteStatsInit( $dbw );
		$statsInit->refresh();

		$ei = SiteStats::edits(); // trigger load
		$pi = SiteStats::pages();
		$ui = SiteStats::users();
		$fi = SiteStats::images();
		$ai = SiteStats::articles();

		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount() );

		$dbw->begin( __METHOD__ ); // block opportunistic updates

		DeferredUpdates::addUpdate(
			SiteStatsUpdate::factory( [ 'pages' => 2, 'images' => 1, 'edits' => 2 ] )
		);
		$this->assertSame( 1, DeferredUpdates::pendingUpdatesCount() );

		// Still the same
		SiteStats::unload();
		$this->assertEquals( $pi, SiteStats::pages(), 'page count' );
		$this->assertEquals( $ei, SiteStats::edits(), 'edit count' );
		$this->assertEquals( $ui, SiteStats::users(), 'user count' );
		$this->assertEquals( $fi, SiteStats::images(), 'file count' );
		$this->assertEquals( $ai, SiteStats::articles(), 'article count' );
		$this->assertSame( 1, DeferredUpdates::pendingUpdatesCount() );

		// This also notifies DeferredUpdates to do an opportunistic run
		$dbw->commit( __METHOD__ );
		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount() );

		SiteStats::unload();
		$this->assertEquals( $pi + 2, SiteStats::pages(), 'page count' );
		$this->assertEquals( $ei + 2, SiteStats::edits(), 'edit count' );
		$this->assertEquals( $ui, SiteStats::users(), 'user count' );
		$this->assertEquals( $fi + 1, SiteStats::images(), 'file count' );
		$this->assertEquals( $ai, SiteStats::articles(), 'article count' );

		$statsInit = new SiteStatsInit();
		$statsInit->refresh();
	}
}
PK       ! ;      deferred/CdnCacheUpdateTest.phpnu Iw        <?php

use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Deferred\CdnCacheUpdate;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Title\Title;

/**
 * @covers \MediaWiki\Deferred\CdnCacheUpdate
 */
class CdnCacheUpdateTest extends MediaWikiIntegrationTestCase {

	public function testPurgeMergeWeb() {
		$cleanup = DeferredUpdates::preventOpportunisticUpdates();
		$this->setService( 'LinkBatchFactory', $this->createMock( LinkBatchFactory::class ) );

		$title = Title::newMainPage();

		$urls1 = [];
		$urls1[] = $title->getCanonicalURL( '?x=1' );
		$urls1[] = $title->getCanonicalURL( '?x=2' );
		$urls1[] = $title->getCanonicalURL( '?x=3' );
		$update1 = $this->newCdnCacheUpdate( $urls1 );

		$urls2 = [];
		$urls2[] = $title->getCanonicalURL( '?x=2' );
		$urls2[] = $title->getCanonicalURL( '?x=3' );
		$urls2[] = $title->getCanonicalURL( '?x=4' );
		$urls2[] = $title;
		$update2 = $this->newCdnCacheUpdate( $urls2 );

		$expected = [
			$title->getInternalURL(),
			$title->getInternalURL( 'action=history' ),
			$title->getCanonicalURL( '?x=1' ),
			$title->getCanonicalURL( '?x=2' ),
			$title->getCanonicalURL( '?x=3' ),
			$title->getCanonicalURL( '?x=4' ),
		];
		DeferredUpdates::addUpdate( $update1 );
		DeferredUpdates::addUpdate( $update2 );

		$this->assertEquals( $expected, $update1->getUrls() );

		/** @var CdnCacheUpdate $update */
		$update = null;
		DeferredUpdates::clearPendingUpdates();
		DeferredUpdates::addCallableUpdate( function () use ( $urls1, $urls2, &$update ) {
			$update = $this->newCdnCacheUpdate( $urls1 );
			DeferredUpdates::addUpdate( $update );
			DeferredUpdates::addUpdate( $this->newCdnCacheUpdate( $urls2 ) );
			DeferredUpdates::addUpdate(
				$this->newCdnCacheUpdate( $urls2 ),
				DeferredUpdates::PRESEND
			);
		} );
		DeferredUpdates::doUpdates();

		$this->assertEquals( $expected, $update->getUrls() );

		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount(), 'PRESEND update run' );
	}

	/**
	 * @param array $urls
	 * @return CdnCacheUpdate
	 */
	private function newCdnCacheUpdate( array $urls ) {
		return $this->getMockBuilder( CdnCacheUpdate::class )
			->setConstructorArgs( [ $urls ] )
			->onlyMethods( [ 'doUpdate' ] )
			->getMock();
	}
}
PK       ! !HO^  ^  +  deferred/RefreshSecondaryDataUpdateTest.phpnu Iw        <?php

use MediaWiki\Deferred\DataUpdate;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Deferred\RefreshSecondaryDataUpdate;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Storage\DerivedPageDataUpdater;
use MediaWiki\Title\Title;
use Psr\Log\NullLogger;
use Wikimedia\ScopedCallback;

/**
 * @covers \MediaWiki\Deferred\RefreshSecondaryDataUpdate
 * @group Database
 */
class RefreshSecondaryDataUpdateTest extends MediaWikiIntegrationTestCase {
	public function testSuccess() {
		$services = $this->getServiceContainer();
		$lbFactory = $services->getDBLoadBalancerFactory();
		$queue = $services->getJobQueueGroup()->get( 'refreshLinksPrioritized' );
		$user = $this->getTestUser()->getUser();

		$goodCalls = 0;
		$goodUpdate = $this->getMockBuilder( DataUpdate::class )
			->onlyMethods( [ 'doUpdate', 'setTransactionTicket' ] )
			->getMock();
		$goodTrxFname = get_class( $goodUpdate ) . '::doUpdate';
		$goodUpdate->method( 'doUpdate' )
			->willReturnCallback( static function () use ( &$goodCalls, $lbFactory, $goodTrxFname ) {
				// Update can commit since it owns the transaction
				$lbFactory->commitPrimaryChanges( $goodTrxFname );
				++$goodCalls;
			} );
		$goodUpdate->expects( $this->once() )
			->method( 'setTransactionTicket' );

		$updater = $this->getMockBuilder( DerivedPageDataUpdater::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getSecondaryDataUpdates' ] )
			->getMock();
		$updater->method( 'getSecondaryDataUpdates' )
			->willReturn( [ $goodUpdate ] );

		$revision = $this->getMockBuilder( MutableRevisionRecord::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getId' ] )
			->getMock();
		$revision->method( 'getId' )
			->willReturn( 42 );

		$dbw = $this->getDb();

		$dbw->startAtomic( __METHOD__ );

		$this->assertSame( 0, $queue->getSize() );
		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount() );
		$wikiPage = $services->getWikiPageFactory()->newFromTitle( Title::makeTitle( NS_MAIN, 'TestPage' ) );
		DeferredUpdates::addUpdate( new RefreshSecondaryDataUpdate(
			$lbFactory,
			$user,
			$wikiPage,
			$revision,
			$updater,
			[]
		) );
		$this->assertSame( 1, DeferredUpdates::pendingUpdatesCount() );
		$this->assertSame( 0, $queue->getSize() );

		$dbw->endAtomic( __METHOD__ ); // run updates

		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount() );
		$queue->flushCaches();
		$this->assertSame( 0, $queue->getSize(), "Nothing failed; no enqueue" );
		$this->assertSame( 1, $goodCalls );
	}

	public function testEnqueueOnFailure() {
		$services = $this->getServiceContainer();
		$lbFactory = $services->getDBLoadBalancerFactory();
		$queue = $services->getJobQueueGroup()->get( 'refreshLinksPrioritized' );
		$user = $this->getTestUser()->getUser();

		// T248189: DeferredUpdate will log the exception, don't fail because of that.
		$this->setLogger( 'exception', new NullLogger() );

		$goodCalls = 0;
		$goodUpdate = $this->getMockBuilder( DataUpdate::class )
			->onlyMethods( [ 'doUpdate', 'setTransactionTicket' ] )
			->getMock();
		$goodTrxFname = get_class( $goodUpdate ) . '::doUpdate';
		$goodUpdate->method( 'doUpdate' )
			->willReturnCallback( static function () use ( &$goodCalls, $lbFactory, $goodTrxFname ) {
				// Update can commit since it owns the transaction
				$lbFactory->commitPrimaryChanges( $goodTrxFname );
				++$goodCalls;
			} );
		$goodUpdate->expects( $this->once() )
			->method( 'setTransactionTicket' );

		$badCalls = 0;
		$badUpdate = $this->getMockBuilder( DataUpdate::class )
			->onlyMethods( [ 'doUpdate', 'setTransactionTicket' ] )
			->getMock();
		$badTrxFname = get_class( $goodUpdate ) . '::doUpdate';
		$badUpdate->expects( $this->once() )
			->method( 'setTransactionTicket' );
		$badUpdate->method( 'doUpdate' )
			->willReturnCallback( static function () use ( &$badCalls, $lbFactory, $badTrxFname ) {
				// Update can commit since it owns the transaction
				$lbFactory->commitPrimaryChanges( $badTrxFname );
				++$badCalls;
				throw new LogicException( 'We have a problem' );
			} );

		$updater = $this->getMockBuilder( DerivedPageDataUpdater::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getSecondaryDataUpdates' ] )
			->getMock();
		$updater->method( 'getSecondaryDataUpdates' )
			->willReturn( [ $goodUpdate, $badUpdate ] );

		$revision = $this->getMockBuilder( MutableRevisionRecord::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getId' ] )
			->getMock();
		$revision->method( 'getId' )
			->willReturn( 42 );

		$dbw = $this->getDb();
		$dbw->startAtomic( __METHOD__ );
		$goodCalls = 0;

		$this->assertSame( 0, $queue->getSize() );
		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount() );
		$wikiPage = $services->getWikiPageFactory()->newFromTitle( Title::makeTitle( NS_MAIN, 'TestPage' ) );
		DeferredUpdates::addUpdate( new RefreshSecondaryDataUpdate(
			$lbFactory,
			$user,
			$wikiPage,
			$revision,
			$updater,
			[]
		) );
		$this->assertSame( 1, DeferredUpdates::pendingUpdatesCount() );
		$this->assertSame( 0, $queue->getSize() );

		try {
			// Trigger deferred updates run to execute the update and secondary updates
			$dbw->endAtomic( __METHOD__ );
			// Callback rigged to fail
			$this->fail( "Expected LogicException" );
		} catch ( LogicException $e ) {
			$this->assertSame( "We have a problem", $e->getMessage() );
		}

		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount() );
		$queue->flushCaches();
		$this->assertSame( 1, $queue->getSize(), "Update failed; job enqueued" );
		$this->assertSame( 1, $goodCalls );
		$this->assertSame( 1, $badCalls );

		// Run the RefreshLinksJob
		$this->runJobs( [ 'ignoreErrorsMatchingFormat' => 'Revision %d is not current' ] );

		$queue->flushCaches();
		$this->assertSame( 0, $queue->getSize() );
	}

	/**
	 * Attempted use of onTransactionResolution() to avoid an update running on
	 * rollback shouldn't cause DeferredUpdates to fail to get a ticket.
	 */
	public function testT248003() {
		$services = $this->getServiceContainer();
		$lbFactory = $services->getDBLoadBalancerFactory();
		$user = $this->getTestUser()->getUser();

		$fname = __METHOD__;
		$dbw = $lbFactory->getMainLB()->getConnection( DB_PRIMARY );
		$dbw->setFlag( DBO_TRX, $dbw::REMEMBER_PRIOR ); // make queries trigger TRX
		$reset = new ScopedCallback( [ $dbw, 'restoreFlags' ] );

		$this->assertSame( 0, $dbw->trxLevel() );
		$dbw->newSelectQueryBuilder()
			->select( '*' )
			->from( 'page' )
			->caller( __METHOD__ )->fetchRow();
		if ( !$dbw->trxLevel() ) {
			$this->markTestSkipped( 'No implicit transaction, cannot test for T248003' );
		}
		$dbw->commit( __METHOD__, $dbw::FLUSHING_INTERNAL );
		$this->assertSame( 0, $dbw->trxLevel() );

		$goodCalls = 0;
		$goodUpdate = $this->getMockBuilder( DataUpdate::class )
			->onlyMethods( [ 'doUpdate' ] )
			->getMock();
		$goodUpdate->method( 'doUpdate' )
			->willReturnCallback( static function () use ( &$goodCalls ) {
				++$goodCalls;
			} );

		$updater = $this->getMockBuilder( DerivedPageDataUpdater::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getSecondaryDataUpdates' ] )
			->getMock();
		$updater->method( 'getSecondaryDataUpdates' )
			->willReturnCallback( static function () use ( $dbw, $fname, $goodUpdate ) {
				$dbw->newSelectQueryBuilder()
					->select( '*' )
					->from( 'page' )
					->caller( $fname )->fetchRow();
				$dbw->onTransactionResolution( static function () {
				}, $fname );

				return [ $goodUpdate ];
			} );

		$wikiPage = $this->getExistingTestPage();
		$update = new RefreshSecondaryDataUpdate(
			$lbFactory,
			$user,
			$wikiPage,
			$wikiPage->getRevisionRecord(),
			$updater,
			[]
		);
		$update->doUpdate();

		$this->assertSame( 1, $goodCalls );
	}
}
PK       ! Lޝ	    $  deferred/LinksDeletionUpdateTest.phpnu Iw        <?php

use MediaWiki\Deferred\LinksUpdate\LinksDeletionUpdate;
use MediaWiki\Deferred\LinksUpdate\LinksUpdate;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Title\TitleValue;

/**
 * @covers \MediaWiki\Deferred\LinksUpdate\LinksDeletionUpdate
 * @covers \MediaWiki\Deferred\LinksUpdate\LinksUpdate
 * @covers \MediaWiki\Deferred\LinksUpdate\CategoryLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\ExternalLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\GenericPageLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\ImageLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\InterwikiLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\LangLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\LinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\LinksTableGroup
 * @covers \MediaWiki\Deferred\LinksUpdate\PageLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\PagePropsTable
 * @covers \MediaWiki\Deferred\LinksUpdate\TemplateLinksTable
 * @covers \MediaWiki\Deferred\LinksUpdate\TitleLinksTable
 *
 * @group LinksUpdate
 * @group Database
 * ^--- make sure temporary tables are used.
 */
class LinksDeletionUpdateTest extends MediaWikiLangTestCase {
	public function testUpdate() {
		$res = $this->insertPage( 'Source' );
		$id = $res['id'];
		$title = $res['title'];
		$wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$po = new ParserOutput();
		$po->addCategory( new TitleValue( NS_CATEGORY, 'Cat' ), 'cat' );
		$po->addExternalLink( 'https://en.wikipedia.org/' );
		$po->addImage( new TitleValue( NS_FILE, 'Wiki.png' ) );
		$po->addLink( new TitleValue( 0, 'foo', '', 'iwprefix' ) );
		$po->addLanguageLink( new TitleValue( 0, 'Francais', '', 'fr' ) );
		$po->addLink( new TitleValue( 0, 'Target' ) );
		$po->setNumericPageProperty( 'int', 1 );
		$po->addTemplate( new TitleValue( NS_TEMPLATE, '!' ), 1, 1 );

		$linksUpdate = new LinksUpdate( $title, $po, false );
		$linksUpdate->setTransactionTicket(
			$this->getServiceContainer()->getConnectionProvider()->getEmptyTransactionTicket( __METHOD__ )
		);
		$linksUpdate->doUpdate();

		$tables = [
			'categorylinks' => 'cl_from',
			'externallinks' => 'el_from',
			'imagelinks' => 'il_from',
			'iwlinks' => 'iwl_from',
			'langlinks' => 'll_from',
			'pagelinks' => 'pl_from',
			'page_props' => 'pp_page',
			'templatelinks' => 'tl_from',
		];
		foreach ( $tables as $table => $fromField ) {
			$res = $this->getDb()->newSelectQueryBuilder()
				->select( [ 1 ] )
				->from( $table )
				->where( [ $fromField => $id ] )
				->caller( __METHOD__ )->fetchResultSet();
			$this->assertSame( 1, $res->numRows(), "Number of rows in table $table" );
		}

		$linksDeletionUpdate = new LinksDeletionUpdate( $wikiPage, $id );
		$linksDeletionUpdate->setTransactionTicket(
			$this->getServiceContainer()->getConnectionProvider()->getEmptyTransactionTicket( __METHOD__ )
		);
		$linksDeletionUpdate->doUpdate();

		foreach ( $tables as $table => $fromField ) {
			$res = $this->getDb()->newSelectQueryBuilder()
				->select( [ 1 ] )
				->from( $table )
				->where( [ $fromField => $id ] )
				->caller( __METHOD__ )->fetchResultSet();
			$this->assertSame( 0, $res->numRows(), "Number of rows in table $table" );
		}
	}
}
PK       ! 
      api/ApiEntryPointTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiEntryPoint;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\MockEnvironment;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers \MediaWiki\Api\ApiEntryPoint
 */
class ApiEntryPointTest extends ApiTestCase {

	public function testSimpleRequest() {
		$request = new FauxRequest();
		$request->setRequestURL( '/w/api.php' );

		$env = new MockEnvironment( $request );
		$context = $env->makeFauxContext();

		$entryPoint = new ApiEntryPoint(
			$context,
			$env,
			$this->getServiceContainer()
		);

		$entryPoint->enableOutputCapture();
		$entryPoint->run();

		$output = $entryPoint->getCapturedOutput();
		$this->assertStringContainsString( '<!DOCTYPE html>', $output );
		$this->assertStringContainsString( '<title>(pagetitle: (api-help-title))</title>', $output );

		// TODO: Check caching headers and such.
	}

}
PK       ! ~r_~8  ~8    api/ApiStashEditTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Content\CssContent;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Storage\PageEditStash;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserRigorOptions;
use Psr\Log\NullLogger;
use stdClass;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\Stats\StatsFactory;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @covers MediaWiki\Api\ApiStashEdit
 * @covers \MediaWiki\Storage\PageEditStash
 * @group API
 * @group medium
 * @group Database
 * @todo Expand tests for temporary users
 */
class ApiStashEditTest extends ApiTestCase {
	use TempUserTestTrait;

	private const CLASS_NAME = 'ApiStashEditTest';

	protected function setUp(): void {
		parent::setUp();
		// Hack to make user edit tracker survive service reset.
		// We want it's cache to persist within tests run, otherwise
		// incorrect in-process cache is being reset, and we get outdated
		// edit counts.
		$this->setService( 'UserEditTracker', $this->getServiceContainer()
			->getUserEditTracker() );
		$this->setService( 'PageEditStash', new PageEditStash(
			new HashBagOStuff( [] ),
			$this->getServiceContainer()->getConnectionProvider(),
			new NullLogger(),
			StatsFactory::newNull(),
			$this->getServiceContainer()->getUserEditTracker(),
			$this->getServiceContainer()->getUserFactory(),
			$this->getServiceContainer()->getWikiPageFactory(),
			$this->getServiceContainer()->getHookContainer(),
			PageEditStash::INITIATOR_USER
		) );
	}

	/**
	 * Make a stashedit API call with suitable default parameters
	 *
	 * @param array $params Query parameters for API request.  All are optional and will have
	 *   sensible defaults filled in.  To make a parameter actually not passed, set to null.
	 * @param User|null $user User to do the request
	 * @param string $expectedResult 'stashed', 'editconflict'
	 * @return array
	 */
	protected function doStash(
		array $params = [], ?User $user = null, $expectedResult = 'stashed'
	) {
		$params = array_merge( [
			'action' => 'stashedit',
			'title' => self::CLASS_NAME,
			'contentmodel' => 'wikitext',
			'contentformat' => 'text/x-wiki',
			'baserevid' => 0,
		], $params );
		if ( !array_key_exists( 'text', $params ) &&
			!array_key_exists( 'stashedtexthash', $params )
		) {
			$params['text'] = 'Content';
		}
		foreach ( $params as $key => $val ) {
			if ( $val === null ) {
				unset( $params[$key] );
			}
		}

		if ( isset( $params['text'] ) ) {
			$expectedText = $params['text'];
		} elseif ( isset( $params['stashedtexthash'] ) ) {
			$expectedText = $this->getStashedText( $params['stashedtexthash'] );
		}
		if ( isset( $expectedText ) ) {
			$expectedText = rtrim( str_replace( "\r\n", "\n", $expectedText ) );
			$expectedHash = sha1( $expectedText );
			$origText = $this->getStashedText( $expectedHash );
		}

		$res = $this->doApiRequestWithToken( $params, null, $user );

		$this->assertSame( $expectedResult, $res[0]['stashedit']['status'] );
		$this->assertCount( $expectedResult === 'stashed' ? 2 : 1, $res[0]['stashedit'] );

		if ( $expectedResult === 'stashed' ) {
			$hash = $res[0]['stashedit']['texthash'];

			$this->assertSame( $expectedText, $this->getStashedText( $hash ) );

			$this->assertSame( $expectedHash, $hash );

			if ( isset( $params['stashedtexthash'] ) ) {
				$this->assertSame( $expectedHash, $params['stashedtexthash'] );
			}
		} else {
			$this->assertSame( $origText, $this->getStashedText( $expectedHash ) );
		}

		$this->assertArrayNotHasKey( 'warnings', $res[0] );

		return $res;
	}

	/**
	 * Return the text stashed for $hash.
	 *
	 * @param string $hash
	 * @return string
	 */
	protected function getStashedText( $hash ) {
		return $this->getServiceContainer()->getPageEditStash()->fetchInputText( $hash );
	}

	/**
	 * Return a key that can be passed to the cache to obtain a stashed edit object.
	 *
	 * @param string $title Title of page
	 * @param string $text Content of edit
	 * @param User|null $user User who made edit
	 * @return string
	 */
	protected function getStashKey( $title = self::CLASS_NAME, $text = 'Content', ?User $user = null ) {
		$titleObj = Title::newFromText( $title );
		$content = new WikitextContent( $text );
		if ( !$user ) {
			$user = $this->getTestSysop()->getUser();
		}
		$editStash = TestingAccessWrapper::newFromObject(
			$this->getServiceContainer()->getPageEditStash() );

		return $editStash->getStashKey( $titleObj, $editStash->getContentHash( $content ), $user );
	}

	public function testBasicEdit() {
		$this->doStash();
	}

	public function testBot() {
		// @todo This restriction seems arbitrary, is there any good reason to keep it?
		$this->expectApiErrorCode( 'botsnotsupported' );

		$this->doStash( [], $this->getTestUser( [ 'bot' ] )->getUser() );
	}

	public function testUnrecognizedFormat() {
		$this->expectApiErrorCode( 'badmodelformat' );

		$this->doStash( [ 'contentformat' => 'application/json' ] );
	}

	public function testMissingTextAndStashedTextHash() {
		$this->expectApiErrorCode( 'missingparam' );
		$this->doStash( [ 'text' => null ] );
	}

	public function testStashedTextHash() {
		$res = $this->doStash();

		$this->doStash( [ 'stashedtexthash' => $res[0]['stashedit']['texthash'] ] );
	}

	public function testMalformedStashedTextHash() {
		$this->expectApiErrorCode( 'missingtext' );
		$this->doStash( [ 'stashedtexthash' => 'abc' ] );
	}

	public function testMissingStashedTextHash() {
		$this->expectApiErrorCode( 'missingtext' );
		$this->doStash( [ 'stashedtexthash' => str_repeat( '0', 40 ) ] );
	}

	public function testHashNormalization() {
		$res1 = $this->doStash( [ 'text' => "a\r\nb\rc\nd \t\n\r" ] );
		$res2 = $this->doStash( [ 'text' => "a\nb\rc\nd" ] );

		$this->assertSame( $res1[0]['stashedit']['texthash'], $res2[0]['stashedit']['texthash'] );
		$this->assertSame( "a\nb\rc\nd",
			$this->getStashedText( $res1[0]['stashedit']['texthash'] ) );
	}

	public function testNonexistentBaseRevId() {
		$this->expectApiErrorCode( 'nosuchrevid' );

		$name = ucfirst( __FUNCTION__ );
		$this->editPage( $name, '' );
		$this->doStash( [ 'title' => $name, 'baserevid' => pow( 2, 31 ) - 1 ] );
	}

	public function testPageWithNoRevisions() {
		$name = ucfirst( __FUNCTION__ );
		$revRecord = $this->editPage( $name, '' )->getNewRevision();

		$this->expectApiErrorCode( 'missingrev' );

		// Corrupt the database.  @todo Does the API really need to fail gracefully for this case?
		$this->getDb()->newUpdateQueryBuilder()
			->update( 'page' )
			->set( [ 'page_latest' => 0 ] )
			->where( [ 'page_id' => $revRecord->getPageId() ] )
			->caller( __METHOD__ )->execute();

		$this->doStash( [ 'title' => $name, 'baserevid' => $revRecord->getId() ] );
	}

	public function testExistingPage() {
		$name = ucfirst( __FUNCTION__ );
		$revRecord = $this->editPage( $name, '' )->getNewRevision();

		$this->doStash( [ 'title' => $name, 'baserevid' => $revRecord->getId() ] );
	}

	public function testInterveningEdit() {
		$this->markTestSkippedIfNoDiff3();

		$name = ucfirst( __FUNCTION__ );
		$oldRevRecord = $this->editPage( $name, "A\n\nB" )->getNewRevision();
		$this->editPage( $name, "A\n\nC" );

		$this->doStash( [
			'title' => $name,
			'baserevid' => $oldRevRecord->getId(),
			'text' => "D\n\nB",
		] );
	}

	public function testEditConflict() {
		$name = ucfirst( __FUNCTION__ );
		$oldRevRecord = $this->editPage( $name, 'A' )->getNewRevision();
		$this->editPage( $name, 'B' );

		$this->doStash( [
			'title' => $name,
			'baserevid' => $oldRevRecord->getId(),
			'text' => 'C',
		], null, 'editconflict' );
	}

	public function testMidEditContentModelMismatch() {
		$name = ucfirst( __FUNCTION__ );
		$title = Title::makeTitle( NS_MAIN, $name );
		$content = new CssContent( 'Css' );
		$performer = $this->getTestSysop()->getAuthority();
		$revRecord = $this->editPage(
			$title,
			$content,
			'',
			NS_MAIN,
			$performer
		)->getNewRevision();
		$this->editPage(
			$title,
			new WikitextContent( 'Text' ),
			'',
			NS_MAIN,
			$performer
		);

		$this->expectApiErrorCode( 'contentmodel-mismatch' );
		$this->doStash( [ 'title' => $title->getPrefixedText(), 'baserevid' => $revRecord->getId() ] );
	}

	public function testDeletedRevision() {
		$name = ucfirst( __FUNCTION__ );
		$oldRevRecord = $this->editPage( $name, 'A' )->getNewRevision();
		$this->editPage( $name, 'B' );

		$this->expectApiErrorCode( 'missingrev' );

		$this->revisionDelete( $oldRevRecord );

		$this->doStash( [
			'title' => $name,
			'baserevid' => $oldRevRecord->getId(),
			'text' => 'C',
		] );
	}

	public function testDeletedRevisionSection() {
		$name = ucfirst( __FUNCTION__ );
		$oldRevRecord = $this->editPage( $name, 'A' )->getNewRevision();
		$this->editPage( $name, 'B' );

		$this->expectApiErrorCode( 'replacefailed' );

		$this->revisionDelete( $oldRevRecord );

		$this->doStash( [
			'title' => $name,
			'baserevid' => $oldRevRecord->getId(),
			'text' => 'C',
			'section' => '1',
		] );
	}

	public function testPingLimiter() {
		$this->mergeMwGlobalArrayValue( 'wgRateLimits',
			[ 'stashedit' => [ '&can-bypass' => false, 'user' => [ 1, 60 ] ] ] );

		$this->doStash( [ 'text' => 'A' ] );

		$this->doStash( [ 'text' => 'B' ], null, 'ratelimited' );
	}

	/**
	 * Shortcut for calling PageStashEdit::checkCache() without
	 * having to create Titles and Contents in every test.
	 *
	 * @param UserIdentity $user
	 * @param string $text The text of the article
	 * @return stdClass|bool Return value of PageStashEdit::checkCache(), false if not in cache
	 */
	protected function doCheckCache( UserIdentity $user, $text = 'Content' ) {
		return $this->getServiceContainer()->getPageEditStash()->checkCache(
			Title::makeTitle( NS_MAIN, 'ApiStashEditTest' ),
			new WikitextContent( $text ),
			$user
		);
	}

	public function testCheckCache() {
		$user = $this->getMutableTestUser()->getUser();
		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		$userGroupManager = $this->getServiceContainer()->getUserGroupManager();

		$this->doStash( [], $user );

		$this->assertInstanceOf( stdClass::class, $this->doCheckCache( $user ) );

		// Another user doesn't see the cache
		$this->assertFalse(
			$this->doCheckCache( $this->getTestUser()->getUser() ),
			'Cache is user-specific'
		);

		// Nor does the original one if they become a bot
		$userGroupManager->addUserToGroup( $user, 'bot' );
		$permissionManager->invalidateUsersRightsCache();
		$this->assertFalse(
			$this->doCheckCache( $user ),
			"We assume bots don't have cache entries"
		);

		// But other groups are okay
		$userGroupManager->removeUserFromGroup( $user, 'bot' );
		$userGroupManager->addUserToGroup( $user, 'sysop' );
		$permissionManager->invalidateUsersRightsCache();
		$this->assertInstanceOf( stdClass::class, $this->doCheckCache( $user ) );
	}

	public function testCheckCacheAnon() {
		$this->disableAutoCreateTempUser();
		$user = $this->getServiceContainer()->getUserFactory()->newFromName( '174.5.4.6', UserRigorOptions::RIGOR_NONE );

		$this->doStash( [], $user );

		$this->assertInstanceOf( stdClass::class, $this->doCheckCache( $user ) );
	}

	/**
	 * Stash an edit some time in the past, for testing expiry and freshness logic.
	 *
	 * @param User $user Who's doing the editing
	 * @param string $text What text should be cached
	 * @param int $howOld How many seconds is "old" (we actually set it one second before this)
	 */
	protected function doStashOld(
		User $user, $text = 'Content', $howOld = PageEditStash::PRESUME_FRESH_TTL_SEC
	) {
		ConvertibleTimestamp::setFakeTime( ConvertibleTimestamp::now( TS_UNIX ) - $howOld - 1 );
		$this->doStash( [ 'text' => $text ], $user );
	}

	public function testCheckCacheOldNoEdits() {
		$user = $this->getTestSysop()->getUser();

		$this->doStashOld( $user );

		// Should still be good, because no intervening edits
		$this->assertInstanceOf( stdClass::class, $this->doCheckCache( $user ) );
	}

	public function testCheckCacheOldNoEditsAnon() {
		$this->disableAutoCreateTempUser();
		// Specify a made-up IP address to make sure no edits are lying around
		$user = $this->getServiceContainer()->getUserFactory()->newFromName( '172.0.2.77', UserRigorOptions::RIGOR_NONE );

		$this->doStashOld( $user );

		// Should still be good, because no intervening edits
		$this->assertInstanceOf( stdClass::class, $this->doCheckCache( $user ) );
	}

	public function testCheckCacheInterveningEdits() {
		$user = $this->getTestSysop()->getUser();

		$this->doStashOld( $user );

		// Now let's also increment our editcount
		$this->editPage( ucfirst( __FUNCTION__ ), '', '', NS_MAIN, $user );

		$user->clearInstanceCache();
		$this->assertFalse( $this->doCheckCache( $user ),
			"Cache should be invalidated when it's old and the user has an intervening edit" );
	}

	/**
	 * @dataProvider signatureProvider
	 * @param string $text Which signature to test (~~~, ~~~~, or ~~~~~)
	 * @param int $ttl Expected TTL in seconds
	 */
	public function testSignatureTtl( $text, $ttl ) {
		$this->doStash( [ 'text' => $text ] );

		$editStash = TestingAccessWrapper::newFromObject(
			$this->getServiceContainer()->getPageEditStash() );
		$cache = $editStash->cache;
		$key = $this->getStashKey( self::CLASS_NAME, $text );

		$wrapper = TestingAccessWrapper::newFromObject( $cache );

		$this->assertEqualsWithDelta( $ttl, $wrapper->bag[$key][HashBagOStuff::KEY_EXP] - time(), 1 );
	}

	public function signatureProvider() {
		return [
			'~~~' => [ '~~~', PageEditStash::MAX_SIGNATURE_TTL ],
			'~~~~' => [ '~~~~', PageEditStash::MAX_SIGNATURE_TTL ],
			'~~~~~' => [ '~~~~~', PageEditStash::MAX_SIGNATURE_TTL ],
		];
	}

	public function testIsInternal() {
		$res = $this->doApiRequest( [
			'action' => 'paraminfo',
			'modules' => 'stashedit',
		] );

		$this->assertCount( 1, $res[0]['paraminfo']['modules'] );
		$this->assertSame( true, $res[0]['paraminfo']['modules'][0]['internal'] );
	}

	public function testBusy() {
		// @todo This doesn't work because both lock acquisitions are in the same MySQL session, so
		// they don't conflict.  How do I open a different session?
		$this->markTestSkipped();

		$key = $this->getStashKey();
		$this->getDb()->lock( $key, __METHOD__, 0 );
		try {
			$this->doStash( [], null, 'busy' );
		} finally {
			$this->getDb()->unlock( $key, __METHOD__ );
		}
	}
}
PK       ! a'  '  !  api/ApiChangeContentModelTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiUsageException;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\RateLimiter;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * Tests for editing page content model via api
 *
 * @group API
 * @group Database
 * @group medium
 *
 * @covers \MediaWiki\Api\ApiChangeContentModel
 * @author DannyS712
 */
class ApiChangeContentModelTest extends ApiTestCase {
	use MockAuthorityTrait;
	use TempUserTestTrait;

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

		$this->getExistingTestPage( 'ExistingPage' );

		$this->overrideConfigValues( [
			MainConfigNames::ExtraNamespaces => [
				12312 => 'Dummy',
				12313 => 'Dummy_talk',
			],
			MainConfigNames::NamespaceContentModels => [
				12312 => 'testing',
			],
		] );
		$this->mergeMwGlobalArrayValue( 'wgContentHandlers', [
			'testing' => 'DummyContentHandlerForTesting',
		] );
	}

	public function testTitleMustExist() {
		$title = Title::makeTitle( NS_MAIN, 'ApiChangeContentModelTest::TestTitleMustExist' );

		$this->assertFalse(
			$title->exists(),
			'Check that title does not exist already'
		);

		$this->expectApiErrorCode( 'changecontentmodel-missingtitle' );

		$this->doApiRequestWithToken( [
			'action' => 'changecontentmodel',
			'title' => $title->getPrefixedText(),
			'model' => 'text'
		] );
	}

	/**
	 * Test user needs `editcontentmodel` rights
	 */
	public function testRightsNeeded() {
		$this->expectApiErrorCode( 'permissiondenied' );

		$this->doApiRequestWithToken( [
				'action' => 'changecontentmodel',
				'summary' => __METHOD__,
				'title' => 'ExistingPage',
				'model' => 'text'
			],
			null,
			$this->mockAnonAuthorityWithoutPermissions( [ 'editcontentmodel' ] ) );
	}

	/**
	 * Test that the `editcontentmodel` rate limit is enforced
	 */
	public function testRateLimitApplies() {
		$limiter = $this->createNoOpMock(
			RateLimiter::class,
			[ 'limit', 'isLimitable', ]
		);
		$limiter->method( 'limit' )
			->willReturnCallback( function ( $user, $action, $incr ) {
				if ( $action === 'editcontentmodel' ) {
					$this->assertSame( 1, $incr );
					return true;
				}
				return false;
			} );
		$limiter->method( 'isLimitable' )
			->willReturn( true );

		$this->setService( 'RateLimiter', $limiter );

		$this->setExpectedApiException( [
			'apierror-ratelimited',
			wfMessage( 'action-ratelimited' )
		] );

		$this->doApiRequestWithToken( [
			'action' => 'changecontentmodel',
			'title' => 'ExistingPage',
			'summary' => 'test',
			'model' => 'text'
		] );
	}

	/**
	 * Test that the content model needs to change
	 */
	public function testChangeNeeded() {
		$this->assertSame(
			'wikitext',
			Title::makeTitle( NS_MAIN, 'ExistingPage' )->getContentModel(),
			'`ExistingPage` should be wikitext'
		);

		$this->expectApiErrorCode( 'nochanges' );

		$this->doApiRequestWithToken( [
			'action' => 'changecontentmodel',
			'summary' => __METHOD__,
			'title' => 'ExistingPage',
			'model' => 'wikitext'
		] );
	}

	/**
	 * Test that the content needs to be valid for the requested model
	 */
	public function testInvalidContent() {
		$wikipage = $this->getExistingTestPage( 'PageWithTextThatIsNotValidJSON' );
		$invalidJSON = 'Foo\nBar\nEaster egg\nT22281';
		$wikipage->doUserEditContent(
			$wikipage->getContentHandler()->unserializeContent( $invalidJSON ),
			$this->getTestSysop()->getAuthority(),
			'EditSummaryForThisTest',
			EDIT_UPDATE | EDIT_SUPPRESS_RC
		);
		$this->assertSame(
			'wikitext',
			$wikipage->getTitle()->getContentModel(),
			'`PageWithTextThatIsNotValidJSON` should be wikitext at first'
		);

		$this->expectApiErrorCode( 'invalid-json-data' );
		$this->doApiRequestWithToken( [
				'action' => 'changecontentmodel',
				'summary' => __METHOD__,
				'title' => 'PageWithTextThatIsNotValidJSON',
				'model' => 'json'
			],
			null,
			$this->mockAnonAuthorityWithPermissions( [ 'edit', 'editcontentmodel' ] )
		);
	}

	/**
	 * Test the EditFilterMergedContent hook can be intercepted
	 *
	 * @dataProvider provideTestEditFilterMergedContent
	 * @param string|bool $customMessage Hook message, or false
	 * @param string $expectedMessage expected fatal
	 */
	public function testEditFilterMergedContent( $customMessage, $expectedMessage ) {
		$title = Title::makeTitle( NS_MAIN, 'ExistingPage' );

		$this->assertSame(
			'wikitext',
			$title->getContentModel( IDBAccessObject::READ_LATEST ),
			'`ExistingPage` should be wikitext'
		);

		$this->setTemporaryHook( 'EditFilterMergedContent',
			static function ( $unused1, $unused2, Status $status ) use ( $customMessage ) {
				if ( $customMessage !== false ) {
					$status->fatal( $customMessage );
				}
				return false;
			}
		);

		$exception = new ApiUsageException(
			null,
			Status::newFatal( $expectedMessage )
		);
		$this->expectException( ApiUsageException::class );
		$this->expectExceptionMessage( $exception->getMessage() );

		$this->doApiRequestWithToken( [
				'action' => 'changecontentmodel',
				'summary' => __METHOD__,
				'title' => 'ExistingPage',
				'model' => 'text'
			],
			null,
			$this->mockAnonAuthorityWithPermissions( [ 'edit', 'editcontentmodel' ] )
		);
	}

	public static function provideTestEditFilterMergedContent() {
		return [
			[ 'DannyS712 objects to this change!', 'DannyS712 objects to this change!' ],
			[ false, 'hookaborted' ]
		];
	}

	/**
	 * Test the ContentModelCanBeUsedOn hook can be intercepted
	 */
	public function testContentModelCanBeUsedOn() {
		$title = Title::makeTitle( NS_MAIN, 'ExistingPage' );

		$this->assertSame(
			'wikitext',
			$title->getContentModel( IDBAccessObject::READ_LATEST ),
			'`ExistingPage` should be wikitext'
		);

		$this->setTemporaryHook( 'ContentModelCanBeUsedOn',
			static function ( $unused1, $unused2, &$ok ) {
				$ok = false;
				return false;
			}
		);

		$this->expectApiErrorCode( 'changecontentmodel-cannotbeused' );

		$this->doApiRequestWithToken( [
				'action' => 'changecontentmodel',
				'summary' => __METHOD__,
				'title' => 'ExistingPage',
				'model' => 'text'
			],
			null,
			$this->mockAnonAuthorityWithPermissions( [ 'edit', 'editcontentmodel' ] )
		);
	}

	/**
	 * Test that content handler must support direct editing
	 */
	public function testNoDirectEditing() {
		$title = Title::newFromText( 'Dummy:NoDirectEditing' );

		$dummyContent = $this->getServiceContainer()
			->getContentHandlerFactory()
			->getContentHandler( 'testing' )->makeEmptyContent();
		$this->editPage(
			$title,
			$dummyContent,
			'EditSummaryForThisTest',
			NS_MAIN,
			$this->getTestSysop()->getAuthority()
		);
		$this->assertSame(
			'testing',
			$title->getContentModel( IDBAccessObject::READ_LATEST ),
			'Dummy:NoDirectEditing should start with the `testing` content model'
		);

		$this->expectApiErrorCode( 'changecontentmodel-nodirectediting' );

		$this->doApiRequestWithToken( [
				'action' => 'changecontentmodel',
				'summary' => __METHOD__,
				'title' => 'Dummy:NoDirectEditing',
				'model' => 'wikitext'
			],
			null,
			$this->mockAnonAuthorityWithPermissions( [ 'edit', 'editcontentmodel' ] )
		);
	}

	public function testCannotApplyTags() {
		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'api edit content model tag' );
		$this->expectApiErrorCode( 'tags-apply-no-permission' );

		$this->doApiRequestWithToken( [
				'action' => 'changecontentmodel',
				'summary' => __METHOD__,
				'title' => 'ExistingPage',
				'model' => 'text',
				'tags' => 'api edit content model tag',
			],
			null,
			$this->mockAnonAuthorityWithoutPermissions( [ 'applychangetags' ] ) );
	}

	/**
	 * Test that it works
	 */
	public function testEverythingWorks() {
		$this->disableAutoCreateTempUser();
		$title = Title::makeTitle( NS_MAIN, 'ExistingPage' );
		$performer = $this->mockAnonAuthorityWithPermissions(
			[ 'edit', 'editcontentmodel', 'applychangetags' ]
		);
		$this->assertSame(
			'wikitext',
			$title->getContentModel( IDBAccessObject::READ_LATEST ),
			'`ExistingPage` should be wikitext'
		);

		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'api edit content model tag' );

		$data = $this->doApiRequestWithToken( [
			'action' => 'changecontentmodel',
			'summary' => __METHOD__,
			'title' => 'ExistingPage',
			'model' => 'text',
			'tags' => 'api edit content model tag',
		], null, $performer );

		$this->assertSame(
			'text',
			$title->getContentModel( IDBAccessObject::READ_LATEST ),
			'API can successfully change the content model'
		);

		$data = $data[0]['changecontentmodel'];
		$this->assertSame( 'Success', $data['result'], 'API reports successful change' );
		$firstLogId = (int)$data['logid'];
		$firstRevId = (int)$data['revid'];
		$this->assertGreaterThan( 0, $firstLogId, 'Plausible log id generated' );
		$this->assertGreaterThan( 0, $firstRevId, 'Plausible rev id generated' );

		$data = $this->doApiRequestWithToken( [
			'action' => 'changecontentmodel',
			// no 'summary', should be optional
			'title' => 'ExistingPage',
			'model' => 'wikitext',
			'tags' => 'api edit content model tag',
		], null, $performer );

		$this->assertSame(
			'wikitext',
			$title->getContentModel( IDBAccessObject::READ_LATEST ),
			'API can also change the content model back'
		);

		$data = $data[0]['changecontentmodel'];
		$this->assertSame( 'Success', $data['result'], 'API reports successful change back' );
		$this->assertGreaterThan(
			$firstLogId,
			(int)$data['logid'],
			'Second log entry should come after the first'
		);
		$this->assertGreaterThan(
			$firstRevId,
			(int)$data['revid'],
			'Second revision should come after the first'
		);

		$this->assertSame(
			'4',
			$this->getDb()->newSelectQueryBuilder()
				->select( 'ctd_count' )
				->from( 'change_tag_def' )
				->where( [ 'ctd_name' => 'api edit content model tag' ] )
				->caller( __METHOD__ )->fetchField(),
			'There should be four uses of the `api edit content model tag` tag, '
				. 'two for the two revisions and two for the two log entries'
		);
	}
}
PK       ! B(      api/ApiEditPageTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiUsageException;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\JavaScriptContent;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Status\Status;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\User;
use MediaWiki\Utils\MWTimestamp;
use RevisionDeleter;
use Wikimedia\Rdbms\IDBAccessObject;
use WikiPage;

/**
 * Tests for MediaWiki api.php?action=edit.
 *
 * @author Daniel Kinzler
 *
 * @group API
 * @group Database
 * @group medium
 *
 * @covers \MediaWiki\Api\ApiEditPage
 */
class ApiEditPageTest extends ApiTestCase {

	use TempUserTestTrait;

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

		$this->overrideConfigValues( [
			MainConfigNames::ExtraNamespaces => [
				12312 => 'Dummy',
				12313 => 'Dummy_talk',
				12314 => 'DummyNonText',
				12315 => 'DummyNonText_talk',
			],
			MainConfigNames::NamespaceContentModels => [
				12312 => 'testing',
				12314 => 'testing-nontext',
			],
			MainConfigNames::WatchlistExpiry => true,
			MainConfigNames::WatchlistExpiryMaxDuration => '6 months',
		] );
		$this->mergeMwGlobalArrayValue( 'wgContentHandlers', [
			'testing' => 'DummyContentHandlerForTesting',
			'testing-nontext' => 'DummyNonTextContentHandler',
			'testing-serialize-error' => 'DummySerializeErrorContentHandler',
		] );
	}

	public function testEdit() {
		$name = 'Help:ApiEditPageTest_testEdit'; // assume Help namespace to default to wikitext

		// -- test new page --------------------------------------------
		$apiResult = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'text' => 'some text',
		] );
		$apiResult = $apiResult[0];

		// Validate API result data
		$this->assertArrayHasKey( 'edit', $apiResult );
		$this->assertArrayHasKey( 'result', $apiResult['edit'] );
		$this->assertSame( 'Success', $apiResult['edit']['result'] );

		$this->assertArrayHasKey( 'new', $apiResult['edit'] );
		$this->assertArrayNotHasKey( 'nochange', $apiResult['edit'] );

		$this->assertArrayHasKey( 'pageid', $apiResult['edit'] );

		// -- test existing page, no change ----------------------------
		$data = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'text' => 'some text',
		] );

		$this->assertSame( 'Success', $data[0]['edit']['result'] );

		$this->assertArrayNotHasKey( 'new', $data[0]['edit'] );
		$this->assertArrayHasKey( 'nochange', $data[0]['edit'] );

		// -- test existing page, with change --------------------------
		$data = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'text' => 'different text'
		] );

		$this->assertSame( 'Success', $data[0]['edit']['result'] );

		$this->assertArrayNotHasKey( 'new', $data[0]['edit'] );
		$this->assertArrayNotHasKey( 'nochange', $data[0]['edit'] );

		$this->assertArrayHasKey( 'oldrevid', $data[0]['edit'] );
		$this->assertArrayHasKey( 'newrevid', $data[0]['edit'] );
		$this->assertNotEquals(
			$data[0]['edit']['newrevid'],
			$data[0]['edit']['oldrevid'],
			"revision id should change after edit"
		);
	}

	/**
	 * @return array
	 */
	public static function provideEditAppend() {
		return [
			[ # 0: append
				'foo', 'append', 'bar', "foobar"
			],
			[ # 1: prepend
				'foo', 'prepend', 'bar', "barfoo"
			],
			[ # 2: append to empty page
				'', 'append', 'foo', "foo"
			],
			[ # 3: prepend to empty page
				'', 'prepend', 'foo', "foo"
			],
			[ # 4: append to non-existing page
				null, 'append', 'foo', "foo"
			],
			[ # 5: prepend to non-existing page
				null, 'prepend', 'foo', "foo"
			],
		];
	}

	/**
	 * @dataProvider provideEditAppend
	 */
	public function testEditAppend( $text, $op, $append, $expected ) {
		static $count = 0;
		$count++;

		// assume NS_HELP defaults to wikitext
		$title = Title::makeTitle( NS_HELP, "ApiEditPageTest_testEditAppend_$count" );

		// -- create page (or not) -----------------------------------------
		if ( $text !== null ) {
			[ $re ] = $this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $title->getPrefixedText(),
				'text' => $text, ] );

			$this->assertSame( 'Success', $re['edit']['result'] );
		}

		// -- try append/prepend --------------------------------------------
		[ $re ] = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			$op . 'text' => $append, ] );

		$this->assertSame( 'Success', $re['edit']['result'] );

		// -- validate -----------------------------------------------------
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$content = $page->getContent();
		$this->assertNotNull( $content, 'Page should have been created' );

		$text = $content->getText();

		$this->assertSame( $expected, $text );
	}

	/**
	 * Test editing of sections
	 */
	public function testEditSection() {
		$title = Title::makeTitle( NS_HELP, 'ApiEditPageTest_testEditSection' );
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();
		$page = $wikiPageFactory->newFromTitle( $title );
		$text = "==section 1==\ncontent 1\n==section 2==\ncontent2";
		// Preload the page with some text
		$page->doUserEditContent(
			$page->getContentHandler()->unserializeContent( $text ),
			$this->getTestSysop()->getAuthority(),
			'summary'
		);

		[ $re ] = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'section' => '1',
			'text' => "==section 1==\nnew content 1",
		] );
		$this->assertSame( 'Success', $re['edit']['result'] );
		$newtext = $wikiPageFactory->newFromTitle( $title )
			->getContent( RevisionRecord::RAW )
			->getText();
		$this->assertSame( "==section 1==\nnew content 1\n\n==section 2==\ncontent2", $newtext );

		// Test that we raise a 'nosuchsection' error
		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $title->getPrefixedText(),
				'section' => '9999',
				'text' => 'text',
			] );
			$this->fail( "Should have raised an ApiUsageException" );
		} catch ( ApiUsageException $e ) {
			$this->assertApiErrorCode( 'nosuchsection', $e );
		}
	}

	/**
	 * Test action=edit&section=new
	 * Run it twice so we test adding a new section on a
	 * page that doesn't exist (T54830) and one that
	 * does exist
	 */
	public function testEditNewSection() {
		$title = Title::makeTitle( NS_HELP, 'ApiEditPageTest_testEditNewSection' );
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();

		// Test on a page that does not already exist
		$this->assertFalse( $title->exists() );
		[ $re ] = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'section' => 'new',
			'text' => 'test',
			'summary' => 'header',
		] );

		$this->assertSame( 'Success', $re['edit']['result'] );
		// Check the page text is correct
		$text = $wikiPageFactory->newFromTitle( $title )
			->getContent( RevisionRecord::RAW )
			->getText();
		$this->assertSame( "== header ==\n\ntest", $text );

		// Now on one that does
		$this->assertTrue( $title->exists( IDBAccessObject::READ_LATEST ) );
		[ $re2 ] = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'section' => 'new',
			'text' => 'test',
			'summary' => 'header',
		] );

		$this->assertSame( 'Success', $re2['edit']['result'] );
		$text = $wikiPageFactory->newFromTitle( $title )
			->getContent( RevisionRecord::RAW )
			->getText();
		$this->assertSame( "== header ==\n\ntest\n\n== header ==\n\ntest", $text );
	}

	/**
	 * Test action=edit&section=new with different combinations of summary and sectiontitle.
	 *
	 * @dataProvider provideEditNewSectionSummarySectiontitle
	 */
	public function testEditNewSectionSummarySectiontitle(
		$sectiontitle,
		$summary,
		$expectedText,
		$expectedSummary
	) {
		static $count = 0;
		$count++;
		$title = Title::makeTitle( NS_HELP, 'ApiEditPageTest_testEditNewSectionSummarySectiontitle' . $count );

		// Test edit 1 (new page)
		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'section' => 'new',
			'text' => 'text',
			'sectiontitle' => $sectiontitle,
			'summary' => $summary,
		] );

		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();
		$wikiPage = $wikiPageFactory->newFromTitle( $title );

		// Check the page text is correct
		$savedText = $wikiPage->getContent( RevisionRecord::RAW )->getText();
		$this->assertSame( $expectedText, $savedText, 'Correct text saved (new page)' );

		// Check that the edit summary is correct
		// (when not provided or empty, there is an autogenerated summary for page creation)
		$savedSummary = $wikiPage->getRevisionRecord()->getComment( RevisionRecord::RAW )->text;
		$expectedSummaryNew = $expectedSummary ?: wfMessage( 'autosumm-new' )->rawParams( $expectedText )
			->inContentLanguage()->text();
		$this->assertSame( $expectedSummaryNew, $savedSummary, 'Correct summary saved (new page)' );

		// Clear the page
		$this->editPage( $wikiPage, '' );

		// Test edit 2 (existing page)
		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'section' => 'new',
			'text' => 'text',
			'sectiontitle' => $sectiontitle,
			'summary' => $summary,
		] );

		$wikiPage = $wikiPageFactory->newFromTitle( $title );

		// Check the page text is correct
		$savedText = $wikiPage->getContent( RevisionRecord::RAW )->getText();
		$this->assertSame( $expectedText, $savedText, 'Correct text saved (existing page)' );

		// Check that the edit summary is correct
		$savedSummary = $wikiPage->getRevisionRecord()->getComment( RevisionRecord::RAW )->text;
		$this->assertSame( $expectedSummary, $savedSummary, 'Correct summary saved (existing page)' );
	}

	public static function provideEditNewSectionSummarySectiontitle() {
		$sectiontitleCases = [
			'unset' => null,
			'empty' => '',
			'set' => 'sectiontitle',
		];
		$summaryCases = [
			'unset' => null,
			'empty' => '',
			'set' => 'summary',
		];

		$expectedTexts = [
			"text",
			"text",
			"== summary ==\n\ntext",
			"text",
			"text",
			"text",
			"== sectiontitle ==\n\ntext",
			"== sectiontitle ==\n\ntext",
			"== sectiontitle ==\n\ntext",
		];

		$expectedSummaries = [
			'',
			'',
			'/* summary */ new section',
			'',
			'',
			'summary',
			'/* sectiontitle */ new section',
			'/* sectiontitle */ new section',
			'summary',
		];

		$i = 0;
		foreach ( $sectiontitleCases as $sectiontitleDesc => $sectiontitle ) {
			foreach ( $summaryCases as $summaryDesc => $summary ) {
				$message = "sectiontitle $sectiontitleDesc, summary $summaryDesc";
				yield $message => [
					$sectiontitle,
					$summary,
					$expectedTexts[$i],
					$expectedSummaries[$i],
				];
				$i++;
			}
		}
	}

	/**
	 * Ensure we can edit through a redirect, if adding a section
	 */
	public function testEdit_redirect() {
		static $count = 0;
		$count++;

		// assume NS_HELP defaults to wikitext
		$title = Title::makeTitle( NS_HELP, "ApiEditPageTest_testEdit_redirect_$count" );
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();
		$page = $this->getExistingTestPage( $title );
		$this->forceRevisionDate( $page, '20120101000000' );

		$rtitle = Title::makeTitle( NS_HELP, "ApiEditPageTest_testEdit_redirect_r$count" );
		$rpage = $wikiPageFactory->newFromTitle( $rtitle );

		$baseTime = $page->getRevisionRecord()->getTimestamp();

		// base edit for redirect
		$rpage->doUserEditContent(
			new WikitextContent( "#REDIRECT [[{$title->getPrefixedText()}]]" ),
			$this->getTestSysop()->getUser(),
			"testing 1",
			EDIT_NEW
		);
		$this->forceRevisionDate( $rpage, '20120101000000' );

		// conflicting edit to redirect
		$rpage->doUserEditContent(
			new WikitextContent( "#REDIRECT [[{$title->getPrefixedText()}]]\n\n[[Category:Test]]" ),
			$this->getTestUser()->getUser(),
			"testing 2",
			EDIT_UPDATE
		);
		$this->forceRevisionDate( $rpage, '20120101020202' );

		// try to save edit, following the redirect
		[ $re, , ] = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $rtitle->getPrefixedText(),
			'text' => 'nix bar!',
			'basetimestamp' => $baseTime,
			'section' => 'new',
			'redirect' => true,
		] );

		$this->assertSame( 'Success', $re['edit']['result'],
			"no problems expected when following redirect" );
	}

	/**
	 * Ensure we cannot edit through a redirect, if attempting to overwrite content
	 */
	public function testEdit_redirectText() {
		static $count = 0;
		$count++;

		// assume NS_HELP defaults to wikitext
		$title = Title::makeTitle( NS_HELP, "ApiEditPageTest_testEdit_redirectText_$count" );
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();
		$page = $this->getExistingTestPage( $title );
		$this->forceRevisionDate( $page, '20120101000000' );
		$baseTime = $page->getRevisionRecord()->getTimestamp();

		$rtitle = Title::makeTitle( NS_HELP, "ApiEditPageTest_testEdit_redirectText_r$count" );
		$rpage = $wikiPageFactory->newFromTitle( $rtitle );

		// base edit for redirect
		$rpage->doUserEditContent(
			new WikitextContent( "#REDIRECT [[{$title->getPrefixedText()}]]" ),
			$this->getTestSysop()->getUser(),
			"testing 1",
			EDIT_NEW
		);
		$this->forceRevisionDate( $rpage, '20120101000000' );

		// conflicting edit to redirect
		$rpage->doUserEditContent(
			new WikitextContent( "#REDIRECT [[{$title->getPrefixedText()}]]\n\n[[Category:Test]]" ),
			$this->getTestUser()->getUser(),
			"testing 2",
			EDIT_UPDATE
		);
		$this->forceRevisionDate( $rpage, '20120101020202' );

		// try to save edit, following the redirect but without creating a section
		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $rtitle->getPrefixedText(),
				'text' => 'nix bar!',
				'basetimestamp' => $baseTime,
				'redirect' => true,
			] );

			$this->fail( 'redirect-appendonly error expected' );
		} catch ( ApiUsageException $ex ) {
			$this->assertApiErrorCode( 'redirect-appendonly', $ex );
		}
	}

	public function testEditConflict_revid() {
		static $count = 0;
		$count++;

		// assume NS_HELP defaults to wikitext
		$title = Title::makeTitle( NS_HELP, "ApiEditPageTest_testEditConflict_$count" );

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		// base edit
		$page->doUserEditContent(
			new WikitextContent( "Foo" ),
			$this->getTestSysop()->getUser(),
			"testing 1",
			EDIT_NEW
		);
		$this->forceRevisionDate( $page, '20120101000000' );
		$baseId = $page->getRevisionRecord()->getId();

		// conflicting edit
		$page->doUserEditContent(
			new WikitextContent( "Foo bar" ),
			$this->getTestUser()->getUser(),
			"testing 2",
			EDIT_UPDATE
		);
		$this->forceRevisionDate( $page, '20120101020202' );

		// try to save edit, expect conflict
		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $title->getPrefixedText(),
				'text' => 'nix bar!',
				'baserevid' => $baseId,
			], null, $this->getTestSysop()->getUser() );

			$this->fail( 'edit conflict expected' );
		} catch ( ApiUsageException $ex ) {
			$this->assertApiErrorCode( 'editconflict', $ex );
		}
	}

	public function testEditConflict_timestamp() {
		static $count = 0;
		$count++;

		// assume NS_HELP defaults to wikitext
		$title = Title::makeTitle( NS_HELP, "ApiEditPageTest_testEditConflict_$count" );

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		// base edit
		$page->doUserEditContent(
			new WikitextContent( "Foo" ),
			$this->getTestSysop()->getUser(),
			"testing 1",
			EDIT_NEW
		);
		$this->forceRevisionDate( $page, '20120101000000' );
		$baseTime = $page->getRevisionRecord()->getTimestamp();

		// conflicting edit
		$page->doUserEditContent(
			new WikitextContent( "Foo bar" ),
			$this->getTestUser()->getUser(),
			"testing 2",
			EDIT_UPDATE
		);
		$this->forceRevisionDate( $page, '20120101020202' );

		// try to save edit, expect conflict
		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $title->getPrefixedText(),
				'text' => 'nix bar!',
				'basetimestamp' => $baseTime,
			] );

			$this->fail( 'edit conflict expected' );
		} catch ( ApiUsageException $ex ) {
			$this->assertApiErrorCode( 'editconflict', $ex );
		}
	}

	/**
	 * Ensure that editing using section=new will prevent simple conflicts
	 */
	public function testEditConflict_newSection() {
		static $count = 0;
		$count++;

		// assume NS_HELP defaults to wikitext
		$title = Title::makeTitle( NS_HELP, "ApiEditPageTest_testEditConflict_newSection_$count" );

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		// base edit
		$page->doUserEditContent(
			new WikitextContent( "Foo" ),
			$this->getTestSysop()->getUser(),
			"testing 1",
			EDIT_NEW
		);
		$this->forceRevisionDate( $page, '20120101000000' );
		$baseTime = $page->getRevisionRecord()->getTimestamp();

		// conflicting edit
		$page->doUserEditContent(
			new WikitextContent( "Foo bar" ),
			$this->getTestUser()->getUser(),
			"testing 2",
			EDIT_UPDATE
		);
		$this->forceRevisionDate( $page, '20120101020202' );

		// try to save edit, expect no conflict
		[ $re, , ] = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'text' => 'nix bar!',
			'basetimestamp' => $baseTime,
			'section' => 'new',
		] );

		$this->assertSame( 'Success', $re['edit']['result'],
			"no edit conflict expected here" );
	}

	public function testEditConflict_T43990() {
		static $count = 0;
		$count++;

		/*
		 * T43990: if the target page has a newer revision than the redirect, then editing the
		 * redirect while specifying 'redirect' and *not* specifying 'basetimestamp' erroneously
		 * caused an edit conflict to be detected.
		 */

		// assume NS_HELP defaults to wikitext
		$title = Title::makeTitle( NS_HELP, "ApiEditPageTest_testEditConflict_redirect_T43990_$count" );
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();
		$page = $this->getExistingTestPage( $title );
		$this->forceRevisionDate( $page, '20120101000000' );

		$rtitle = Title::makeTitle( NS_HELP, "ApiEditPageTest_testEditConflict_redirect_T43990_r$count" );
		$rpage = $wikiPageFactory->newFromTitle( $rtitle );

		// base edit for redirect
		$rpage->doUserEditContent(
			new WikitextContent( "#REDIRECT [[{$title->getPrefixedText()}]]" ),
			$this->getTestSysop()->getUser(),
			"testing 1",
			EDIT_NEW
		);
		$this->forceRevisionDate( $rpage, '20120101000000' );

		// new edit to content
		$page->doUserEditContent(
			new WikitextContent( "Foo bar" ),
			$this->getTestUser()->getUser(),
			"testing 2",
			EDIT_UPDATE
		);
		$this->forceRevisionDate( $rpage, '20120101020202' );

		// try to save edit; should work, following the redirect.
		[ $re, , ] = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $rtitle->getPrefixedText(),
			'text' => 'nix bar!',
			'section' => 'new',
			'redirect' => true,
		] );

		$this->assertSame( 'Success', $re['edit']['result'],
			"no edit conflict expected here" );
	}

	/**
	 * @param WikiPage $page
	 * @param string|int $timestamp
	 */
	protected function forceRevisionDate( WikiPage $page, $timestamp ) {
		$dbw = $this->getDb();

		$dbw->newUpdateQueryBuilder()
			->update( 'revision' )
			->set( [ 'rev_timestamp' => $dbw->timestamp( $timestamp ) ] )
			->where( [ 'rev_id' => $page->getLatest() ] )
			->caller( __METHOD__ )->execute();

		$page->clear();
	}

	public function testCheckDirectApiEditingDisallowed_forNonTextContent() {
		$this->expectApiErrorCode( 'no-direct-editing' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => 'Dummy:ApiEditPageTest_nonTextPageEdit',
			'text' => '{"animals":["kittens!"]}'
		] );
	}

	public function testSupportsDirectApiEditing_withContentHandlerOverride() {
		$name = 'DummyNonText:ApiEditPageTest_testNonTextEdit';
		$data = 'some bla bla text';

		$result = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'text' => $data,
		] );

		$apiResult = $result[0];

		// Validate API result data
		$this->assertArrayHasKey( 'edit', $apiResult );
		$this->assertArrayHasKey( 'result', $apiResult['edit'] );
		$this->assertSame( 'Success', $apiResult['edit']['result'] );

		$this->assertArrayHasKey( 'new', $apiResult['edit'] );
		$this->assertArrayNotHasKey( 'nochange', $apiResult['edit'] );

		$this->assertArrayHasKey( 'pageid', $apiResult['edit'] );

		// validate resulting revision
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( $name ) );
		$this->assertSame( "testing-nontext", $page->getContentModel() );
		$this->assertSame( $data, $page->getContent()->serialize() );
	}

	/**
	 * This test verifies that after changing the content model
	 * of a page, undoing that edit via the API will also
	 * undo the content model change.
	 */
	public function testUndoAfterContentModelChange() {
		$name = 'Help:' . __FUNCTION__;
		$sysop = $this->getTestSysop()->getUser();
		$otherUser = $this->getTestUser()->getUser();

		$apiResult = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'text' => 'some text',
		], null, $sysop )[0];

		// Check success
		$this->assertArrayHasKey( 'edit', $apiResult );
		$this->assertArrayHasKey( 'result', $apiResult['edit'] );
		$this->assertSame( 'Success', $apiResult['edit']['result'] );
		$this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] );
		// Content model is wikitext
		$this->assertSame( 'wikitext', $apiResult['edit']['contentmodel'] );

		// Convert the page to JSON
		$apiResult = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'text' => '{}',
			'contentmodel' => 'json',
		], null, $otherUser )[0];

		// Check success
		$this->assertArrayHasKey( 'edit', $apiResult );
		$this->assertArrayHasKey( 'result', $apiResult['edit'] );
		$this->assertSame( 'Success', $apiResult['edit']['result'] );
		$this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] );
		$this->assertSame( 'json', $apiResult['edit']['contentmodel'] );

		$apiResult = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'undo' => $apiResult['edit']['newrevid']
		], null, $sysop )[0];

		// Check success
		$this->assertArrayHasKey( 'edit', $apiResult );
		$this->assertArrayHasKey( 'result', $apiResult['edit'] );
		$this->assertSame( 'Success', $apiResult['edit']['result'] );
		$this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] );
		// Check that the contentmodel is back to wikitext now.
		$this->assertSame( 'wikitext', $apiResult['edit']['contentmodel'] );
	}

	// The tests below are mostly not commented because they do exactly what
	// you'd expect from the name.

	public function testCorrectContentFormat() {
		$title = Title::makeTitle( NS_HELP, 'TestCorrectContentFormat' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'text' => 'some text',
			'contentmodel' => 'wikitext',
			'contentformat' => 'text/x-wiki',
		] );

		$this->assertTrue( $title->exists( IDBAccessObject::READ_LATEST ) );
	}

	public function testUnsupportedContentFormat() {
		$title = Title::makeTitle( NS_HELP, 'TestUnsupportedContentFormat' );

		$this->expectApiErrorCode( 'badvalue' );

		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $title->getPrefixedText(),
				'text' => 'some text',
				'contentformat' => 'nonexistent format',
			] );
		} finally {
			$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );
		}
	}

	public function testMismatchedContentFormat() {
		$title = Title::makeTitle( NS_HELP, 'TestMismatchedContentFormat' );

		$this->expectApiErrorCode( 'badformat' );

		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $title->getPrefixedText(),
				'text' => 'some text',
				'contentmodel' => 'wikitext',
				'contentformat' => 'text/plain',
			] );
		} finally {
			$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );
		}
	}

	public function testUndoToInvalidRev() {
		$title = Title::makeTitle( NS_HELP, 'TestUndoToInvalidRev' );

		$revId = $this->editPage( $title, 'Some text' )->getNewRevision()
			->getId();
		$revId++;

		$this->expectApiErrorCode( 'nosuchrevid' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'undo' => $revId,
		] );
	}

	/**
	 * Tests what happens if the undo parameter is a valid revision, but
	 * the undoafter parameter doesn't refer to a revision that exists in the
	 * database.
	 */
	public function testUndoAfterToInvalidRev() {
		// We can't just pick a large number for undoafter (as in
		// testUndoToInvalidRev above), because then MediaWiki will helpfully
		// assume we switched around undo and undoafter and we'll test the code
		// path for undo being invalid, not undoafter.  So instead we delete
		// the revision from the database.  In real life this case could come
		// up if a revision number was skipped, e.g., if two transactions try
		// to insert new revision rows at once and the first one to succeed
		// gets rolled back.
		$page = $this->getServiceContainer()->getWikiPageFactory()
			->newFromLinkTarget( new TitleValue( NS_HELP, 'TestUndoAfterToInvalidRev' ) );

		$revId1 = $this->editPage( $page, '1' )->getNewRevision()->getId();
		$revId2 = $this->editPage( $page, '2' )->getNewRevision()->getId();
		$revId3 = $this->editPage( $page, '3' )->getNewRevision()->getId();

		// Make the middle revision disappear
		$dbw = $this->getDb();
		$dbw->newDeleteQueryBuilder()
			->deleteFrom( 'revision' )
			->where( [ 'rev_id' => $revId2 ] )
			->caller( __METHOD__ )->execute();
		$dbw->newUpdateQueryBuilder()
			->update( 'revision' )
			->set( [ 'rev_parent_id' => $revId1 ] )
			->where( [ 'rev_id' => $revId3 ] )
			->caller( __METHOD__ )->execute();

		$this->expectApiErrorCode( 'nosuchrevid' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $page->getTitle()->getPrefixedText(),
			'undo' => $revId3,
			'undoafter' => $revId2,
		] );
	}

	/**
	 * Tests what happens if the undo parameter is a valid revision, but
	 * undoafter is hidden (rev_deleted).
	 */
	public function testUndoAfterToHiddenRev() {
		$page = $this->getServiceContainer()->getWikiPageFactory()
			->newFromLinkTarget( new TitleValue( NS_HELP, 'TestUndoAfterToHiddenRev' ) );
		$titleObj = $page->getTitle();

		$this->editPage( $page, '0' );

		$revId1 = $this->editPage( $page, '1' )->getNewRevision()->getId();

		$revId2 = $this->editPage( $page, '2' )->getNewRevision()->getId();

		// Hide the middle revision
		$list = RevisionDeleter::createList( 'revision',
			RequestContext::getMain(), $titleObj, [ $revId1 ] );
		// Set a user for modifying the visibility, this is needed because
		// setVisibility generates a log, which cannot be an anonymous user actor
		// when temporary accounts are enabled.
		RequestContext::getMain()->setUser( $this->getTestUser()->getUser() );
		$list->setVisibility( [
			'value' => [ RevisionRecord::DELETED_TEXT => 1 ],
			'comment' => 'Bye-bye',
		] );

		$this->expectApiErrorCode( 'nosuchrevid' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $titleObj->getPrefixedText(),
			'undo' => $revId2,
			'undoafter' => $revId1,
		] );
	}

	/**
	 * Test undo when a revision with a higher id has an earlier timestamp.
	 * This can happen if importing an old revision.
	 */
	public function testUndoWithSwappedRevisions() {
		$this->markTestSkippedIfNoDiff3();

		$page = $this->getServiceContainer()->getWikiPageFactory()
			->newFromLinkTarget( new TitleValue( NS_HELP, 'TestUndoWithSwappedRevisions' ) );
		$this->editPage( $page, '0' );

		$revId2 = $this->editPage( $page, '2' )->getNewRevision()->getId();

		$revId1 = $this->editPage( $page, '1' )->getNewRevision()->getId();

		// Now monkey with the timestamp
		$dbw = $this->getDb();
		$dbw->newUpdateQueryBuilder()
			->update( 'revision' )
			->set( [ 'rev_timestamp' => $dbw->timestamp( time() - 86400 ) ] )
			->where( [ 'rev_id' => $revId1 ] )
			->caller( __METHOD__ )->execute();

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $page->getTitle()->getPrefixedText(),
			'undo' => $revId2,
			'undoafter' => $revId1,
		] );

		$page->loadPageData( IDBAccessObject::READ_LATEST );
		$this->assertSame( '1', $page->getContent()->getText() );
	}

	public function testUndoWithConflicts() {
		$this->expectApiErrorCode( 'undofailure' );

		$page = $this->getServiceContainer()->getWikiPageFactory()
			->newFromLinkTarget( new TitleValue( NS_HELP, 'TestUndoWithConflicts' ) );
		$this->editPage( $page, '1' );

		$revId = $this->editPage( $page, '2' )->getNewRevision()->getId();

		$this->editPage( $page, '3' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $page->getTitle()->getPrefixedText(),
			'undo' => $revId,
		] );

		$page->loadPageData( IDBAccessObject::READ_LATEST );
		$this->assertSame( '3', $page->getContent()->getText() );
	}

	public function testReversedUndoAfter() {
		$this->markTestSkippedIfNoDiff3();

		$page = $this->getServiceContainer()->getWikiPageFactory()
			->newFromLinkTarget( new TitleValue( NS_HELP, 'TestReversedUndoAfter' ) );
		$this->editPage( $page, '0' );
		$revId1 = $this->editPage( $page, '1' )->getNewRevision()->getId();
		$revId2 = $this->editPage( $page, '2' )->getNewRevision()->getId();

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $page->getTitle()->getPrefixedText(),
			'undo' => $revId1,
			'undoafter' => $revId2,
		] );

		$page->loadPageData( IDBAccessObject::READ_LATEST );
		$this->assertSame( '2', $page->getContent()->getText() );
	}

	public function testUndoToRevFromDifferentPage() {
		$title1 = Title::makeTitle( NS_HELP, 'TestUndoToRevFromDifferentPage-1' );
		$this->editPage( $title1, 'Some text' );
		$revId = $this->editPage( $title1, 'Some more text' )
			->getNewRevision()->getId();

		$title2 = Title::makeTitle( NS_HELP, 'TestUndoToRevFromDifferentPage-2' );
		$this->editPage( $title2, 'Some text' );

		$this->expectApiErrorCode( 'revwrongpage' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title2->getPrefixedText(),
			'undo' => $revId,
		] );
	}

	public function testUndoAfterToRevFromDifferentPage() {
		$title1 = Title::makeTitle( NS_HELP, 'TestUndoAfterToRevFromDifferentPage-1' );
		$revId1 = $this->editPage( $title1, 'Some text' )
			->getNewRevision()->getId();

		$title2 = Title::makeTitle( NS_HELP, 'TestUndoAfterToRevFromDifferentPage-2' );
		$revId2 = $this->editPage( $title2, 'Some text' )
			->getNewRevision()->getId();

		$this->expectApiErrorCode( 'revwrongpage' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title2->getPrefixedText(),
			'undo' => $revId2,
			'undoafter' => $revId1,
		] );
	}

	public function testMd5Text() {
		$title = Title::makeTitle( NS_HELP, 'TestMd5Text' );

		$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'text' => 'Some text',
			'md5' => md5( 'Some text' ),
		] );

		$this->assertTrue( $title->exists( IDBAccessObject::READ_LATEST ) );
	}

	public function testMd5PrependText() {
		$title = Title::makeTitle( NS_HELP, 'TestMd5PrependText' );

		$this->editPage( $title, 'Some text' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'prependtext' => 'Alert: ',
			'md5' => md5( 'Alert: ' ),
		] );

		$text = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title )
			->getContent()->getText();
		$this->assertSame( 'Alert: Some text', $text );
	}

	public function testMd5AppendText() {
		$title = Title::makeTitle( NS_HELP, 'TestMd5AppendText' );

		$this->editPage( $title, 'Some text' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'appendtext' => ' is nice',
			'md5' => md5( ' is nice' ),
		] );

		$text = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title )
			->getContent()->getText();
		$this->assertSame( 'Some text is nice', $text );
	}

	public function testMd5PrependAndAppendText() {
		$title = Title::makeTitle( NS_HELP, 'TestMd5PrependAndAppendText' );

		$this->editPage( $title, 'Some text' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'prependtext' => 'Alert: ',
			'appendtext' => ' is nice',
			'md5' => md5( 'Alert:  is nice' ),
		] );

		$text = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title )
			->getContent()->getText();
		$this->assertSame( 'Alert: Some text is nice', $text );
	}

	public function testIncorrectMd5Text() {
		$name = 'Help:' . ucfirst( __FUNCTION__ );

		$this->expectApiErrorCode( 'badmd5' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'text' => 'Some text',
			'md5' => md5( '' ),
		] );
	}

	public function testIncorrectMd5PrependText() {
		$name = 'Help:' . ucfirst( __FUNCTION__ );

		$this->expectApiErrorCode( 'badmd5' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'prependtext' => 'Some ',
			'appendtext' => 'text',
			'md5' => md5( 'Some ' ),
		] );
	}

	public function testIncorrectMd5AppendText() {
		$name = 'Help:' . ucfirst( __FUNCTION__ );

		$this->expectApiErrorCode( 'badmd5' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'prependtext' => 'Some ',
			'appendtext' => 'text',
			'md5' => md5( 'text' ),
		] );
	}

	public function testCreateOnly() {
		$title = Title::makeTitle( NS_HELP, 'TestCreateOnly' );

		$this->expectApiErrorCode( 'articleexists' );

		$this->editPage( $title, 'Some text' );
		$this->assertTrue( $title->exists( IDBAccessObject::READ_LATEST ) );

		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $title->getPrefixedText(),
				'text' => 'Some more text',
				'createonly' => '',
			] );
		} finally {
			// Validate that content was not changed
			$text = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title )
				->getContent()->getText();

			$this->assertSame( 'Some text', $text );
		}
	}

	public function testNoCreate() {
		$title = Title::makeTitle( NS_HELP, 'TestNoCreate' );

		$this->expectApiErrorCode( 'missingtitle' );

		$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );

		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $title->getPrefixedText(),
				'text' => 'Some text',
				'nocreate' => '',
			] );
		} finally {
			$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );
		}
	}

	/**
	 * Appending/prepending is currently only supported for TextContent.  We
	 * test this right now, and when support is added this test should be
	 * replaced by tests that the support is correct.
	 */
	public function testAppendWithNonTextContentHandler() {
		$name = 'MediaWiki:' . ucfirst( __FUNCTION__ );

		$this->expectApiErrorCode( 'appendnotsupported' );

		$this->setTemporaryHook( 'ContentHandlerDefaultModelFor',
			static function ( Title $title, &$model ) use ( $name ) {
				if ( $title->getPrefixedText() === $name ) {
					$model = 'testing-nontext';
				}
				return true;
			}
		);

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'appendtext' => 'Some text',
		] );
	}

	public function testAppendInMediaWikiNamespace() {
		$title = Title::makeTitle( NS_MEDIAWIKI, 'TestAppendInMediaWikiNamespace' );

		$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'appendtext' => 'Some text',
		] );

		$this->assertTrue( $title->exists( IDBAccessObject::READ_LATEST ) );
	}

	public function testAppendInMediaWikiNamespaceWithSerializationError() {
		$name = 'MediaWiki:' . ucfirst( __FUNCTION__ );

		$this->expectApiErrorCode( 'parseerror' );

		$this->setTemporaryHook( 'ContentHandlerDefaultModelFor',
			static function ( Title $title, &$model ) use ( $name ) {
				if ( $title->getPrefixedText() === $name ) {
					$model = 'testing-serialize-error';
				}
				return true;
			}
		);

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'appendtext' => 'Some text',
		] );
	}

	public function testAppendNewSection() {
		$title = Title::makeTitle( NS_HELP, 'TestAppendNewSection' );

		$this->editPage( $title, 'Initial content' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'appendtext' => '== New section ==',
			'section' => 'new',
		] );

		$text = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title )
			->getContent()->getText();

		$this->assertSame( "Initial content\n\n== New section ==", $text );
	}

	public function testAppendNewSectionWithInvalidContentModel() {
		$title = Title::makeTitle( NS_HELP, 'TestAppendNewSectionWithInvalidContentModel' );

		$this->expectApiErrorCode( 'sectionsnotsupported' );

		$this->editPage( $title, 'Initial content' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'appendtext' => '== New section ==',
			'section' => 'new',
			'contentmodel' => 'text',
		] );
	}

	public function testAppendNewSectionWithTitle() {
		$title = Title::makeTitle( NS_HELP, 'TestAppendNewSectionWithTitle' );

		$this->editPage( $title, 'Initial content' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'sectiontitle' => 'My section',
			'appendtext' => 'More content',
			'section' => 'new',
		] );

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$this->assertSame( "Initial content\n\n== My section ==\n\nMore content",
			$page->getContent()->getText() );
		$comment = $page->getRevisionRecord()->getComment();
		$this->assertInstanceOf( CommentStoreComment::class, $comment );
		$this->assertSame( '/* My section */ new section', $comment->text );
	}

	public function testAppendNewSectionWithSummary() {
		$title = Title::makeTitle( NS_HELP, 'TestAppendNewSectionWithSummary' );

		$this->editPage( $title, 'Initial content' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'appendtext' => 'More content',
			'section' => 'new',
			'summary' => 'Add new section',
		] );

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$this->assertSame( "Initial content\n\n== Add new section ==\n\nMore content",
			$page->getContent()->getText() );
		// EditPage actually assumes the summary is the section name here
		$comment = $page->getRevisionRecord()->getComment();
		$this->assertInstanceOf( CommentStoreComment::class, $comment );
		$this->assertSame( '/* Add new section */ new section', $comment->text );
	}

	public function testAppendNewSectionWithTitleAndSummary() {
		$title = Title::makeTitle( NS_HELP, 'TestAppendNewSectionWithTitleAndSummary' );

		$this->editPage( $title, 'Initial content' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'sectiontitle' => 'My section',
			'appendtext' => 'More content',
			'section' => 'new',
			'summary' => 'Add new section',
		] );

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$this->assertSame( "Initial content\n\n== My section ==\n\nMore content",
			$page->getContent()->getText() );
		$comment = $page->getRevisionRecord()->getComment();
		$this->assertInstanceOf( CommentStoreComment::class, $comment );
		$this->assertSame( 'Add new section', $comment->text );
	}

	public function testAppendToSection() {
		$title = Title::makeTitle( NS_HELP, 'TestAppendToSection' );

		$this->editPage( $title, "== Section 1 ==\n\nContent\n\n" .
			"== Section 2 ==\n\nFascinating!" );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'appendtext' => ' and more content',
			'section' => '1',
		] );

		$text = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title )
			->getContent()->getText();

		$this->assertSame( "== Section 1 ==\n\nContent and more content\n\n" .
			"== Section 2 ==\n\nFascinating!", $text );
	}

	public function testAppendToFirstSection() {
		$title = Title::makeTitle( NS_HELP, 'TestAppendToFirstSection' );

		$this->editPage( $title, "Content\n\n== Section 1 ==\n\nFascinating!" );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'appendtext' => ' and more content',
			'section' => '0',
		] );

		$text = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title )
			->getContent()->getText();

		$this->assertSame( "Content and more content\n\n== Section 1 ==\n\n" .
			"Fascinating!", $text );
	}

	public function testAppendToNonexistentSection() {
		$title = Title::makeTitle( NS_HELP, 'TestAppendToNonexistentSection' );

		$this->expectApiErrorCode( 'nosuchsection' );

		$this->editPage( $title, 'Content' );

		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $title->getPrefixedText(),
				'appendtext' => ' and more content',
				'section' => '1',
			] );
		} finally {
			$text = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title )
				->getContent()->getText();

			$this->assertSame( 'Content', $text );
		}
	}

	public function testEditMalformedSection() {
		$title = Title::makeTitle( NS_HELP, 'TestEditMalformedSection' );

		$this->expectApiErrorCode( 'invalidsection' );
		$this->editPage( $title, 'Content' );

		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $title->getPrefixedText(),
				'text' => 'Different content',
				'section' => 'It is unlikely that this is valid',
			] );
		} finally {
			$text = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title )
				->getContent()->getText();

			$this->assertSame( 'Content', $text );
		}
	}

	public function testEditWithStartTimestamp() {
		$title = Title::makeTitle( NS_HELP, 'TestEditWithStartTimestamp' );
		$this->expectApiErrorCode( 'pagedeleted' );

		$startTime = MWTimestamp::convert( TS_MW, time() - 1 );

		$this->editPage( $title, 'Some text' );

		$pageObj = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$this->deletePage( $pageObj );

		$this->assertFalse( $pageObj->exists() );

		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $title->getPrefixedText(),
				'text' => 'Different text',
				'starttimestamp' => $startTime,
			] );
		} finally {
			$this->assertFalse( $pageObj->exists() );
		}
	}

	public function testEditMinor() {
		$title = Title::makeTitle( NS_HELP, 'TestEditMinor' );

		$this->editPage( $title, 'Some text' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'text' => 'Different text',
			'minor' => '',
		] );

		$revisionStore = $this->getServiceContainer()->getRevisionStore();
		$revision = $revisionStore->getRevisionByTitle( $title );
		$this->assertTrue( $revision->isMinor() );
	}

	public function testEditRecreate() {
		$title = Title::makeTitle( NS_HELP, 'TestEditRecreate' );

		$startTime = MWTimestamp::convert( TS_MW, time() - 1 );

		$this->editPage( $title, 'Some text' );

		$pageObj = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$this->deletePage( $pageObj );

		$this->assertFalse( $pageObj->exists() );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'text' => 'Different text',
			'starttimestamp' => $startTime,
			'recreate' => '',
		] );

		$this->assertTrue( $title->exists( IDBAccessObject::READ_LATEST ) );
	}

	public function testEditWatch() {
		$title = Title::makeTitle( NS_HELP, 'TestEditWatch' );
		$user = $this->getTestSysop()->getUser();
		$watchlistManager = $this->getServiceContainer()->getWatchlistManager();

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'text' => 'Some text',
			'watch' => '',
			'watchlistexpiry' => '99990123000000',
		] );

		$this->assertTrue( $title->exists( IDBAccessObject::READ_LATEST ) );
		$this->assertTrue( $watchlistManager->isWatched( $user, $title ) );
		$this->assertTrue( $watchlistManager->isTempWatched( $user, $title ) );
	}

	public function testEditUnwatch() {
		$title = Title::makeTitle( NS_HELP, 'TestEditUnwatch' );
		$user = $this->getTestSysop()->getUser();

		$watchlistManager = $this->getServiceContainer()->getWatchlistManager();
		$watchlistManager->addWatch( $user, $title );

		$this->assertFalse( $title->exists() );
		$this->assertTrue( $watchlistManager->isWatched( $user, $title ) );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'text' => 'Some text',
			'unwatch' => '',
		] );

		$this->assertTrue( $title->exists( IDBAccessObject::READ_LATEST ) );
		$this->assertFalse( $watchlistManager->isWatched( $user, $title ) );
	}

	public function testEditWithTag() {
		$name = 'Help:' . ucfirst( __FUNCTION__ );

		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'custom tag' );

		$revId = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'text' => 'Some text',
			'tags' => 'custom tag',
		] )[0]['edit']['newrevid'];

		$this->assertSame( 'custom tag', $this->getDb()->newSelectQueryBuilder()
			->select( 'ctd_name' )
			->from( 'change_tag' )
			->join( 'change_tag_def', null, 'ctd_id = ct_tag_id' )
			->where( [ 'ct_rev_id' => $revId ] )
			->caller( __METHOD__ )->fetchField() );
	}

	public function testEditWithoutTagPermission() {
		$title = Title::makeTitle( NS_HELP, 'TestEditWithoutTagPermission' );

		$this->expectApiErrorCode( 'tags-apply-no-permission' );

		$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );

		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'custom tag' );
		$this->overrideConfigValue(
			MainConfigNames::RevokePermissions,
			[ 'user' => [ 'applychangetags' => true ] ]
		);

		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $title->getPrefixedText(),
				'text' => 'Some text',
				'tags' => 'custom tag',
			] );
		} finally {
			$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );
		}
	}

	public function testEditAbortedByEditPageHookWithResult() {
		$title = Title::makeTitle( NS_HELP, 'TestEditAbortedByEditPageHookWithResult' );

		$this->setTemporaryHook( 'EditFilterMergedContent',
			static function ( $unused1, $unused2, Status $status ) {
				$status->statusData = [ 'msg' => 'A message for you!' ];
				return false;
			} );

		$res = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $title->getPrefixedText(),
			'text' => 'Some text',
		] );

		$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );
		$this->assertSame( [ 'edit' => [ 'msg' => 'A message for you!',
			'result' => 'Failure' ] ], $res[0] );
	}

	public function testEditAbortedByEditPageHookWithNoResult() {
		$title = Title::makeTitle( NS_HELP, 'TestEditAbortedByEditPageHookWithNoResult' );

		$this->expectApiErrorCode( 'hookaborted' );

		$this->setTemporaryHook( 'EditFilterMergedContent',
			static function () {
				return false;
			}
		);

		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $title->getPrefixedText(),
				'text' => 'Some text',
			] );
		} finally {
			$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );
		}
	}

	public function testEditWhileBlocked() {
		$name = 'Help:' . ucfirst( __FUNCTION__ );

		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$this->assertNull( $blockStore->newFromTarget( '127.0.0.1' ) );

		$user = $this->getTestSysop()->getUser();
		$block = new DatabaseBlock( [
			'address' => $user->getName(),
			'by' => $user,
			'reason' => 'Capriciousness',
			'timestamp' => '19370101000000',
			'expiry' => 'infinity',
			'enableAutoblock' => true,
		] );
		$blockStore->insertBlock( $block );

		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $name,
				'text' => 'Some text',
			] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( ApiUsageException $ex ) {
			$this->assertApiErrorCode( 'blocked', $ex );
			$this->assertNotNull( $blockStore->newFromTarget( '127.0.0.1' ), 'Autoblock spread' );
		}
	}

	public function testEditWhileReadOnly() {
		$name = 'Help:' . ucfirst( __FUNCTION__ );

		// Create the test user before making the DB readonly
		$user = $this->getTestSysop()->getUser();
		$this->expectApiErrorCode( 'readonly' );

		$svc = $this->getServiceContainer()->getReadOnlyMode();
		$svc->setReason( "Read-only for testing" );

		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $name,
				'text' => 'Some text',
			], null, $user );
		} finally {
			$svc->setReason( false );
		}
	}

	public function testCreateImageRedirectAnon() {
		$this->disableAutoCreateTempUser();
		$name = 'File:' . ucfirst( __FUNCTION__ );

		$this->expectApiErrorCode( 'noimageredirect-anon' );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'text' => '#REDIRECT [[File:Other file.png]]',
		], null, new User() );
	}

	public function testCreateImageRedirectLoggedIn() {
		$name = 'File:' . ucfirst( __FUNCTION__ );

		$this->expectApiErrorCode( 'noimageredirect' );

		$this->overrideConfigValue(
			MainConfigNames::RevokePermissions,
			[ 'user' => [ 'upload' => true ] ]
		);

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'text' => '#REDIRECT [[File:Other file.png]]',
		] );
	}

	public function testTooBigEdit() {
		$name = 'Help:' . ucfirst( __FUNCTION__ );

		$this->expectApiErrorCode( 'contenttoobig' );

		$this->overrideConfigValue( MainConfigNames::MaxArticleSize, 1 );

		$text = str_repeat( '!', 1025 );

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'text' => $text,
		] );
	}

	public function testProhibitedAnonymousEdit() {
		$name = 'Help:' . ucfirst( __FUNCTION__ );

		$this->expectApiErrorCode( 'permissiondenied' );

		$this->overrideConfigValue(
			MainConfigNames::RevokePermissions,
			[ '*' => [ 'edit' => true ] ]
		);

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'text' => 'Some text',
		], null, new User() );
	}

	public function testProhibitedChangeContentModel() {
		$name = 'Help:' . ucfirst( __FUNCTION__ );

		$this->expectApiErrorCode( 'cantchangecontentmodel' );

		$this->overrideConfigValue(
			MainConfigNames::RevokePermissions,
			[ 'user' => [ 'editcontentmodel' => true ] ]
		);

		$this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => $name,
			'text' => 'Some text',
			'contentmodel' => 'json',
		] );
	}

	public function testMidEditContentModelMismatch() {
		$title = Title::makeTitle( NS_HELP, 'TestMidEditContentModelMismatch' );

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		// base edit, currently in Wikitext
		$page->doUserEditContent(
			new WikitextContent( "Foo" ),
			$this->getTestSysop()->getUser(),
			"testing 1",
			EDIT_NEW
		);
		$this->forceRevisionDate( $page, '20120101000000' );
		$baseId = $page->getRevisionRecord()->getId();

		// Attempt edit in Javascript. This may happen, for instance, if we
		// started editing the base content while it was in Javascript and
		// before we save it was changed to Wikitext (base edit model).
		$page->doUserEditContent(
			new JavaScriptContent( "Bar" ),
			$this->getTestUser()->getUser(),
			"testing 2",
			EDIT_UPDATE
		);
		$this->forceRevisionDate( $page, '20120101020202' );

		// ContentHandler may throw exception if we attempt saving the above, so we will
		// handle that with contentmodel-mismatch error. Test this is the case.
		try {
			$this->doApiRequestWithToken( [
				'action' => 'edit',
				'title' => $title->getPrefixedText(),
				'text' => 'different content models!',
				'baserevid' => $baseId,
			] );
			$this->fail( "Should have raised an ApiUsageException" );
		} catch ( ApiUsageException $e ) {
			$this->assertApiErrorCode( 'contentmodel-mismatch', $e );
		}
	}
}
PK       ! K&  &  0  api/Validator/ApiParamValidatorCallbacksTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Validator;

use Generator;
use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiMessage;
use MediaWiki\Api\ApiQueryBase;
use MediaWiki\Api\Validator\ApiParamValidatorCallbacks;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\Api\ApiUploadTestCase;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use Psr\Http\Message\UploadedFileInterface;
use Wikimedia\Message\DataMessageValue;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Api\Validator\ApiParamValidatorCallbacks
 * @group API
 * @group medium
 */
class ApiParamValidatorCallbacksTest extends ApiUploadTestCase {
	use MockAuthorityTrait;

	private function getCallbacks( FauxRequest $request ): array {
		$context = $this->apiContext->newTestContext( $request, $this->mockRegisteredUltimateAuthority() );
		$main = new ApiMain( $context );
		return [ new ApiParamValidatorCallbacks( $main ), $main ];
	}

	private function filePath( $fileName ) {
		return __DIR__ . '/../../../data/media/' . $fileName;
	}

	public function testHasParam(): void {
		[ $callbacks, $main ] = $this->getCallbacks( new FauxRequest( [
			'foo' => '1',
			'bar' => '',
		] ) );

		$this->assertTrue( $callbacks->hasParam( 'foo', [] ) );
		$this->assertTrue( $callbacks->hasParam( 'bar', [] ) );
		$this->assertFalse( $callbacks->hasParam( 'baz', [] ) );

		$this->assertSame(
			[ 'foo', 'bar', 'baz' ],
			TestingAccessWrapper::newFromObject( $main )->getParamsUsed()
		);
	}

	/**
	 * @dataProvider provideGetValue
	 * @param string|null $data Value from request
	 * @param mixed $default For getValue()
	 * @param mixed $expect Expected return value
	 * @param bool $normalized Whether handleParamNormalization is called
	 */
	public function testGetValue( ?string $data, $default, $expect, bool $normalized = false ): void {
		[ $callbacks, $main ] = $this->getCallbacks( new FauxRequest( [ 'test' => $data ] ) );

		$module = $this->getMockBuilder( ApiBase::class )
			->setConstructorArgs( [ $main, 'testmodule' ] )
			->onlyMethods( [ 'handleParamNormalization' ] )
			->getMockForAbstractClass();
		$options = [ 'module' => $module ];
		if ( $normalized ) {
			$module->expects( $this->once() )->method( 'handleParamNormalization' )
				->with(
					$this->identicalTo( 'test' ),
					$this->identicalTo( $expect ),
					$this->identicalTo( $data ?? $default )
				);
		} else {
			$module->expects( $this->never() )->method( 'handleParamNormalization' );
		}

		$this->assertSame( $expect, $callbacks->getValue( 'test', $default, $options ) );
		$this->assertSame( [ 'test' ], TestingAccessWrapper::newFromObject( $main )->getParamsUsed() );
	}

	public static function provideGetValue() {
		$obj = (object)[];
		return [
			'Basic test' => [ 'foo', 'bar', 'foo', false ],
			'Default value' => [ null, 1234, 1234, false ],
			'Default value (2)' => [ null, $obj, $obj, false ],
			'No default value' => [ null, null, null, false ],
			'Multi separator' => [ "\x1ffoo\x1fbar", 1234, "\x1ffoo\x1fbar", false ],
			'Normalized' => [ "\x1ffoo\x1fba\u{0301}r", 1234, "\x1ffoo\x1fbár", true ],
		];
	}

	private function setupUploads(): void {
		$fileName = 'TestUploadStash.jpg';
		$mimeType = 'image/jpeg';
		$filePath = $this->filePath( 'yuv420.jpg' );
		$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath );

		$this->requestDataFiles['file2'] = [
			'name' => '',
			'type' => '',
			'tmp_name' => '',
			'size' => 0,
			'error' => UPLOAD_ERR_NO_FILE,
		];

		$this->requestDataFiles['file3'] = [
			'name' => 'xxx.png',
			'type' => '',
			'tmp_name' => '',
			'size' => 0,
			'error' => UPLOAD_ERR_INI_SIZE,
		];
	}

	public function testHasUpload(): void {
		$this->setupUploads();

		$request = new FauxRequest( [
			'foo' => '1',
			'bar' => '',
		] );
		$request->setUploadData( $this->requestDataFiles );
		[ $callbacks, $main ] = $this->getCallbacks( $request );

		$this->assertFalse( $callbacks->hasUpload( 'foo', [] ) );
		$this->assertFalse( $callbacks->hasUpload( 'bar', [] ) );
		$this->assertFalse( $callbacks->hasUpload( 'baz', [] ) );
		$this->assertTrue( $callbacks->hasUpload( 'file', [] ) );
		$this->assertTrue( $callbacks->hasUpload( 'file2', [] ) );
		$this->assertTrue( $callbacks->hasUpload( 'file3', [] ) );

		$this->assertSame(
			[ 'foo', 'bar', 'baz', 'file', 'file2', 'file3' ],
			TestingAccessWrapper::newFromObject( $main )->getParamsUsed()
		);
	}

	public function testGetUploadedFile(): void {
		$this->setupUploads();

		$request = new FauxRequest( [
			'foo' => '1',
			'bar' => '',
		] );
		$request->setUploadData( $this->requestDataFiles );
		[ $callbacks, $main ] = $this->getCallbacks( $request );

		$this->assertNull( $callbacks->getUploadedFile( 'foo', [] ) );
		$this->assertNull( $callbacks->getUploadedFile( 'bar', [] ) );
		$this->assertNull( $callbacks->getUploadedFile( 'baz', [] ) );

		$file = $callbacks->getUploadedFile( 'file', [] );
		$this->assertInstanceOf( UploadedFileInterface::class, $file );
		$this->assertSame( UPLOAD_ERR_OK, $file->getError() );
		$this->assertSame( 'TestUploadStash.jpg', $file->getClientFilename() );

		$file = $callbacks->getUploadedFile( 'file2', [] );
		$this->assertInstanceOf( UploadedFileInterface::class, $file );
		$this->assertSame( UPLOAD_ERR_NO_FILE, $file->getError() );

		$file = $callbacks->getUploadedFile( 'file3', [] );
		$this->assertInstanceOf( UploadedFileInterface::class, $file );
		$this->assertSame( UPLOAD_ERR_INI_SIZE, $file->getError() );
	}

	/**
	 * @dataProvider provideRecordCondition
	 * @param DataMessageValue $message
	 * @param ApiMessage|null $expect
	 * @param bool $sensitive
	 */
	public function testRecordCondition(
		DataMessageValue $message, ?ApiMessage $expect, bool $sensitive = false
	): void {
		[ $callbacks, $main ] = $this->getCallbacks( new FauxRequest( [ 'testparam' => 'testvalue' ] ) );
		$query = $main->getModuleFromPath( 'query' );
		$warnings = [];

		$module = $this->getMockBuilder( ApiQueryBase::class )
			->setConstructorArgs( [ $query, 'test' ] )
			->onlyMethods( [ 'addWarning' ] )
			->getMockForAbstractClass();
		$module->method( 'addWarning' )->willReturnCallback(
			static function ( $msg, $code, $data ) use ( &$warnings ) {
				$warnings[] = [ $msg, $code, $data ];
			}
		);
		$query->getModuleManager()->addModule( 'test', 'meta', [
			'class' => get_class( $module ),
			'factory' => static function () use ( $module ) {
				return $module;
			}
		] );

		$callbacks->recordCondition( $message, 'testparam', 'testvalue', [], [ 'module' => $module ] );

		if ( $expect ) {
			$this->assertNotCount( 0, $warnings );
			$this->assertSame(
				$expect->inLanguage( 'qqx' )->plain(),
				$warnings[0][0]->inLanguage( 'qqx' )->plain()
			);
			$this->assertSame( $expect->getApiCode(), $warnings[0][1] );
			$this->assertSame( $expect->getApiData(), $warnings[0][2] );
		} else {
			$this->assertSame( [], $warnings );
		}

		$this->assertSame(
			$sensitive ? [ 'testparam' ] : [],
			TestingAccessWrapper::newFromObject( $main )->getSensitiveParams()
		);
	}

	public static function provideRecordCondition(): Generator {
		yield 'Deprecated param' => [
			DataMessageValue::new(
				'paramvalidator-param-deprecated', [],
				'param-deprecated',
				[ 'data' => true ]
			)->plaintextParams( 'XXtestparam', 'XXtestvalue' ),
			ApiMessage::create(
				'paramvalidator-param-deprecated',
				'deprecation',
				[ 'data' => true, 'feature' => 'action=query&meta=test&testparam' ]
			)->plaintextParams( 'XXtestparam', 'XXtestvalue' )
		];

		yield 'Deprecated value' => [
			DataMessageValue::new(
				'paramvalidator-deprecated-value', [],
				'deprecated-value'
			)->plaintextParams( 'XXtestparam', 'XXtestvalue' ),
			ApiMessage::create(
				'paramvalidator-deprecated-value',
				'deprecation',
				[ 'feature' => 'action=query&meta=test&testparam=testvalue' ]
			)->plaintextParams( 'XXtestparam', 'XXtestvalue' )
		];

		yield 'Deprecated value with custom MessageValue' => [
			DataMessageValue::new(
				'some-custom-message-value', [],
				'deprecated-value',
				[ 'xyz' => 123 ]
			)->plaintextParams( 'XXtestparam', 'XXtestvalue', 'foobar' ),
			ApiMessage::create(
				'some-custom-message-value',
				'deprecation',
				[ 'xyz' => 123, 'feature' => 'action=query&meta=test&testparam=testvalue' ]
			)->plaintextParams( 'XXtestparam', 'XXtestvalue', 'foobar' )
		];

		// See ApiParamValidator::normalizeSettings()
		yield 'Deprecated value with custom Message' => [
			DataMessageValue::new(
				'some-custom-message', [],
				'deprecated-value',
				[ '💩' => 'back-compat' ]
			)->plaintextParams( 'XXtestparam', 'XXtestvalue', 'foobar' ),
			ApiMessage::create(
				'some-custom-message',
				'deprecation',
				[ 'feature' => 'action=query&meta=test&testparam=testvalue' ]
			)->plaintextParams( 'foobar' )
		];

		yield 'Sensitive param' => [
			DataMessageValue::new( 'paramvalidator-param-sensitive', [], 'param-sensitive' )
				->plaintextParams( 'XXtestparam', 'XXtestvalue' ),
			null,
			true
		];

		yield 'Arbitrary warning' => [
			DataMessageValue::new( 'some-warning', [], 'some-code', [ 'some-data' ] )
				->plaintextParams( 'XXtestparam', 'XXtestvalue', 'foobar' ),
			ApiMessage::create( 'some-warning', 'some-code', [ 'some-data' ] )
				->plaintextParams( 'XXtestparam', 'XXtestvalue', 'foobar' ),
		];
	}

	public function testUseHighLimits(): void {
		$context = $this->apiContext->newTestContext( new FauxRequest, $this->mockRegisteredUltimateAuthority() );
		$main = $this->getMockBuilder( ApiMain::class )
			->setConstructorArgs( [ $context ] )
			->onlyMethods( [ 'canApiHighLimits' ] )
			->getMock();

		$main->method( 'canApiHighLimits' )->willReturnOnConsecutiveCalls( true, false );

		$callbacks = new ApiParamValidatorCallbacks( $main );
		$this->assertTrue( $callbacks->useHighLimits( [] ) );
		$this->assertFalse( $callbacks->useHighLimits( [] ) );
	}
}
PK       ! f"  "  "  api/Validator/SubmoduleDefTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Validator;

use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiModuleManager;
use MediaWiki\Api\Validator\SubmoduleDef;
use MediaWiki\Tests\Api\MockApi;
use Wikimedia\Message\DataMessageValue;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\SimpleCallbacks;
use Wikimedia\ParamValidator\TypeDef\EnumDef;
use Wikimedia\ParamValidator\ValidationException;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Tests\ParamValidator\TypeDef\TypeDefTestCase;

/**
 * @covers \MediaWiki\Api\Validator\SubmoduleDef
 */
class SubmoduleDefTest extends TypeDefTestCase {

	protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
		return new SubmoduleDef( $callbacks, $options );
	}

	private function mockApi() {
		$api = $this->getMockBuilder( MockApi::class )
			->onlyMethods( [ 'getModuleManager' ] )
			->getMock();
		$w = TestingAccessWrapper::newFromObject( $api );
		$w->mModuleName = 'testmod';
		$w->mMainModule = new ApiMain;
		$w->mModulePrefix = 'tt';

		$w->mMainModule->getModuleManager()->addModule( 'testmod', 'action', [
			'class' => MockApi::class,
			'factory' => static function () use ( $api ) {
				return $api;
			},
		] );

		$dep = $this->getMockBuilder( MockApi::class )
			->onlyMethods( [ 'isDeprecated' ] )
			->getMock();
		$dep->method( 'isDeprecated' )->willReturn( true );
		$int = $this->getMockBuilder( MockApi::class )
			->onlyMethods( [ 'isInternal' ] )
			->getMock();
		$int->method( 'isInternal' )->willReturn( true );
		$depint = $this->getMockBuilder( MockApi::class )
			->onlyMethods( [ 'isDeprecated', 'isInternal' ] )
			->getMock();
		$depint->method( 'isDeprecated' )->willReturn( true );
		$depint->method( 'isInternal' )->willReturn( true );

		$manager = new ApiModuleManager( $api );
		$api->method( 'getModuleManager' )->willReturn( $manager );
		$manager->addModule( 'mod1', 'test', MockApi::class );
		$manager->addModule( 'mod2', 'test', MockApi::class );
		$manager->addModule( 'dep', 'test', [
			'class' => MockApi::class,
			'factory' => static function () use ( $dep ) {
				return $dep;
			},
		] );
		$manager->addModule( 'depint', 'test', [
			'class' => MockApi::class,
			'factory' => static function () use ( $depint ) {
				return $depint;
			},
		] );
		$manager->addModule( 'int', 'test', [
			'class' => MockApi::class,
			'factory' => static function () use ( $int ) {
				return $int;
			},
		] );
		$manager->addModule( 'recurse', 'test', [
			'class' => MockApi::class,
			'factory' => static function () use ( $api ) {
				return $api;
			},
		] );
		$manager->addModule( 'mod3', 'xyz', MockApi::class );

		$this->assertSame( $api, $api->getModuleFromPath( 'testmod' ) );
		$this->assertSame( $dep, $api->getModuleFromPath( 'testmod+dep' ) );
		$this->assertSame( $int, $api->getModuleFromPath( 'testmod+int' ) );
		$this->assertSame( $depint, $api->getModuleFromPath( 'testmod+depint' ) );

		return $api;
	}

	public function provideValidate() {
		$opts = [
			'module' => $this->mockApi(),
		];
		$map = [
			SubmoduleDef::PARAM_SUBMODULE_MAP => [
				'mod2' => 'testmod+mod1',
				'mod3' => 'testmod+mod3',
			],
		];

		return [
			'Basic' => [ 'mod1', 'mod1', [], $opts ],
			'Nonexistent submodule' => [
				'mod3',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badvalue', [], 'badvalue', [] ), 'test', 'mod3', []
				),
				[],
				$opts,
			],
			'Mapped' => [ 'mod3', 'mod3', $map, $opts ],
			'Mapped, not in map' => [
				'mod1',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badvalue', [], 'badvalue', [] ), 'test', 'mod1', $map
				),
				$map,
				$opts,
			],
		];
	}

	public function provideCheckSettings() {
		$opts = [
			'module' => $this->mockApi(),
		];
		$keys = [
			'Y', EnumDef::PARAM_DEPRECATED_VALUES,
			SubmoduleDef::PARAM_SUBMODULE_MAP, SubmoduleDef::PARAM_SUBMODULE_PARAM_PREFIX
		];

		return [
			'Basic test' => [
				[],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
				$opts
			],
			'Test with everything' => [
				[
					SubmoduleDef::PARAM_SUBMODULE_MAP => [
						'foo' => 'testmod+mod1', 'bar' => 'testmod+mod2'
					],
					SubmoduleDef::PARAM_SUBMODULE_PARAM_PREFIX => 'g',
				],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
				$opts
			],
			'Bad types' => [
				[
					SubmoduleDef::PARAM_SUBMODULE_MAP => false,
					SubmoduleDef::PARAM_SUBMODULE_PARAM_PREFIX => true,
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						SubmoduleDef::PARAM_SUBMODULE_MAP => 'PARAM_SUBMODULE_MAP must be an array, got boolean',
						SubmoduleDef::PARAM_SUBMODULE_PARAM_PREFIX
							=> 'PARAM_SUBMODULE_PARAM_PREFIX must be a string, got boolean',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
				$opts
			],
			'Bad values in map' => [
				[
					SubmoduleDef::PARAM_SUBMODULE_MAP => [
						'a' => 'testmod+mod1',
						'b' => false,
						'c' => null,
						'd' => 'testmod+mod7',
						'r' => 'testmod+recurse+recurse',
					],
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						'Values for PARAM_SUBMODULE_MAP must be strings, but value for "b" is boolean',
						'Values for PARAM_SUBMODULE_MAP must be strings, but value for "c" is NULL',
						'PARAM_SUBMODULE_MAP contains "testmod+mod7", which is not a valid module path',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
				$opts
			],
		];
	}

	public function provideGetEnumValues() {
		$opts = [
			'module' => $this->mockApi(),
		];

		return [
			'Basic test' => [
				[ ParamValidator::PARAM_TYPE => 'submodule' ],
				[ 'mod1', 'mod2', 'dep', 'depint', 'int', 'recurse' ],
				$opts,
			],
			'Mapped' => [
				[
					ParamValidator::PARAM_TYPE => 'submodule',
					SubmoduleDef::PARAM_SUBMODULE_MAP => [
						'mod2' => 'test+mod1',
						'mod3' => 'test+mod3',
					]
				],
				[ 'mod2', 'mod3' ],
				$opts,
			],
		];
	}

	public function provideGetInfo() {
		$opts = [
			'module' => $this->mockApi(),
		];

		return [
			'Basic' => [
				[],
				[
					'type' => [ 'mod1', 'mod2', 'recurse', 'dep', 'int', 'depint' ],
					'submodules' => [
						'mod1' => 'testmod+mod1',
						'mod2' => 'testmod+mod2',
						'recurse' => 'testmod+recurse',
						'dep' => 'testmod+dep',
						'int' => 'testmod+int',
						'depint' => 'testmod+depint',
					],
					'deprecatedvalues' => [ 'dep', 'depint' ],
					'internalvalues' => [ 'depint', 'int' ],
				],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-enum"><text>1</text><list listType="comma"><text>[[Special:ApiHelp/testmod+mod1|&lt;span dir=&quot;ltr&quot; lang=&quot;en&quot;&gt;mod1&lt;/span&gt;]]</text><text>[[Special:ApiHelp/testmod+mod2|&lt;span dir=&quot;ltr&quot; lang=&quot;en&quot;&gt;mod2&lt;/span&gt;]]</text><text>[[Special:ApiHelp/testmod+recurse|&lt;span dir=&quot;ltr&quot; lang=&quot;en&quot;&gt;recurse&lt;/span&gt;]]</text><text>[[Special:ApiHelp/testmod+dep|&lt;span dir=&quot;ltr&quot; lang=&quot;en&quot; class=&quot;apihelp-deprecated-value&quot;&gt;dep&lt;/span&gt;]]</text><text>[[Special:ApiHelp/testmod+int|&lt;span dir=&quot;ltr&quot; lang=&quot;en&quot; class=&quot;apihelp-internal-value&quot;&gt;int&lt;/span&gt;]]</text><text>[[Special:ApiHelp/testmod+depint|&lt;span dir=&quot;ltr&quot; lang=&quot;en&quot; class=&quot;apihelp-deprecated-value apihelp-internal-value&quot;&gt;depint&lt;/span&gt;]]</text></list><num>6</num></message>',
					ParamValidator::PARAM_ISMULTI => null,
				],
				$opts,
			],
			'Mapped' => [
				[
					ParamValidator::PARAM_DEFAULT => 'mod3|mod4',
					ParamValidator::PARAM_ISMULTI => true,
					SubmoduleDef::PARAM_SUBMODULE_PARAM_PREFIX => 'g',
					SubmoduleDef::PARAM_SUBMODULE_MAP => [
						'xyz' => 'testmod+dep',
						'mod3' => 'testmod+mod3',
						'mod4' => 'testmod+mod4', // doesn't exist
					],
				],
				[
					'type' => [ 'mod3', 'mod4', 'xyz' ],
					'submodules' => [
						'mod3' => 'testmod+mod3',
						'mod4' => 'testmod+mod4',
						'xyz' => 'testmod+dep',
					],
					'submoduleparamprefix' => 'g',
					'deprecatedvalues' => [ 'xyz' ],
				],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-enum"><text>2</text><list listType="comma"><text>[[Special:ApiHelp/testmod+mod3|&lt;span dir=&quot;ltr&quot; lang=&quot;en&quot;&gt;mod3&lt;/span&gt;]]</text><text>[[Special:ApiHelp/testmod+mod4|&lt;span dir=&quot;ltr&quot; lang=&quot;en&quot;&gt;mod4&lt;/span&gt;]]</text><text>[[Special:ApiHelp/testmod+dep|&lt;span dir=&quot;ltr&quot; lang=&quot;en&quot; class=&quot;apihelp-deprecated-value&quot;&gt;xyz&lt;/span&gt;]]</text></list><num>3</num></message>',
					ParamValidator::PARAM_ISMULTI => null,
				],
				$opts,
			],
		];
	}

}
PK       ! `c#MS  S  '  api/Validator/ApiParamValidatorTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Validator;

use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiMessage;
use MediaWiki\Api\ApiUsageException;
use MediaWiki\Api\Validator\ApiParamValidator;
use MediaWiki\Message\Message;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use Wikimedia\Message\DataMessageValue;
use Wikimedia\Message\MessageValue;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\TypeDef\EnumDef;
use Wikimedia\ParamValidator\TypeDef\IntegerDef;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Api\Validator\ApiParamValidator
 * @group API
 * @group medium
 */
class ApiParamValidatorTest extends ApiTestCase {
	use MockAuthorityTrait;

	private function getValidator( FauxRequest $request ): array {
		$context = $this->apiContext->newTestContext( $request, $this->mockRegisteredUltimateAuthority() );
		$main = new ApiMain( $context );
		return [
			new ApiParamValidator( $main, $this->getServiceContainer()->getObjectFactory() ),
			$main
		];
	}

	public function testKnownTypes(): void {
		[ $validator ] = $this->getValidator( new FauxRequest( [] ) );
		$this->assertSame(
			[
				'boolean', 'enum', 'expiry', 'integer', 'limit', 'namespace', 'NULL', 'password',
				'raw', 'string', 'submodule', 'tags', 'text', 'timestamp', 'title', 'user', 'upload',
			],
			$validator->knownTypes()
		);
	}

	/**
	 * @dataProvider provideNormalizeSettings
	 * @param array|mixed $settings
	 * @param array $expect
	 */
	public function testNormalizeSettings( $settings, array $expect ): void {
		[ $validator ] = $this->getValidator( new FauxRequest( [] ) );
		$this->assertEquals( $expect, $validator->normalizeSettings( $settings ) );
	}

	public static function provideNormalizeSettings(): array {
		return [
			'Basic test' => [
				[],
				[
					ParamValidator::PARAM_IGNORE_UNRECOGNIZED_VALUES => true,
					IntegerDef::PARAM_IGNORE_RANGE => true,
					ParamValidator::PARAM_TYPE => 'NULL',
				],
			],
			'Explicit ParamValidator::PARAM_IGNORE_UNRECOGNIZED_VALUES' => [
				[
					ParamValidator::PARAM_IGNORE_UNRECOGNIZED_VALUES => false,
				],
				[
					ParamValidator::PARAM_IGNORE_UNRECOGNIZED_VALUES => false,
					IntegerDef::PARAM_IGNORE_RANGE => true,
					ParamValidator::PARAM_TYPE => 'NULL',
				],
			],
			'Explicit IntegerDef::PARAM_IGNORE_RANGE' => [
				[
					IntegerDef::PARAM_IGNORE_RANGE => false,
				],
				[
					ParamValidator::PARAM_IGNORE_UNRECOGNIZED_VALUES => true,
					IntegerDef::PARAM_IGNORE_RANGE => false,
					ParamValidator::PARAM_TYPE => 'NULL',
				],
			],
			'Handle ApiBase::PARAM_RANGE_ENFORCE' => [
				[
					ApiBase::PARAM_RANGE_ENFORCE => true,
				],
				[
					ApiBase::PARAM_RANGE_ENFORCE => true,
					ParamValidator::PARAM_IGNORE_UNRECOGNIZED_VALUES => true,
					IntegerDef::PARAM_IGNORE_RANGE => false,
					ParamValidator::PARAM_TYPE => 'NULL',
				],
			],
			'Handle EnumDef::PARAM_DEPRECATED_VALUES, null' => [
				[
					EnumDef::PARAM_DEPRECATED_VALUES => [
						'null' => null,
						'true' => true,
						'string' => 'some-message',
						'array' => [ 'some-message', 'with', 'params' ],
						'Message' => ApiMessage::create(
							[ 'api-message', 'with', 'params' ], 'somecode', [ 'some-data' ]
						),
						'MessageValue' => MessageValue::new( 'message-value', [ 'with', 'params' ] ),
						'DataMessageValue' => DataMessageValue::new(
							'data-message-value', [ 'with', 'params' ], 'somecode', [ 'some-data' ]
						),
					],
				],
				[
					ParamValidator::PARAM_IGNORE_UNRECOGNIZED_VALUES => true,
					IntegerDef::PARAM_IGNORE_RANGE => true,
					EnumDef::PARAM_DEPRECATED_VALUES => [
						'null' => null,
						'true' => true,
						'string' => DataMessageValue::new( 'some-message', [], 'bogus', [ '💩' => 'back-compat' ] ),
						'array' => DataMessageValue::new(
							'some-message', [ 'with', 'params' ], 'bogus', [ '💩' => 'back-compat' ]
						),
						'Message' => DataMessageValue::new(
							'api-message', [ 'with', 'params' ], 'bogus', [ '💩' => 'back-compat' ]
						),
						'MessageValue' => MessageValue::new( 'message-value', [ 'with', 'params' ] ),
						'DataMessageValue' => DataMessageValue::new(
							'data-message-value', [ 'with', 'params' ], 'somecode', [ 'some-data' ]
						),
					],
					ParamValidator::PARAM_TYPE => 'NULL',
				],
			],
		];
	}

	/**
	 * @dataProvider provideCheckSettings
	 * @param array $params All module parameters.
	 * @param string $name Parameter to test.
	 * @param array $expect
	 */
	public function testCheckSettings( array $params, string $name, array $expect ): void {
		[ $validator, $main ] = $this->getValidator( new FauxRequest( [] ) );
		$module = $main->getModuleFromPath( 'query+allpages' );

		$mock = $this->getMockBuilder( ParamValidator::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'checkSettings' ] )
			->getMock();
		$mock->expects( $this->once() )->method( 'checkSettings' )
			->willReturnCallback( function ( $n, $settings, $options ) use ( $name, $module ) {
				$this->assertSame( "ap$name", $n );
				$this->assertSame( [ 'module' => $module ], $options );

				$ret = [ 'issues' => [ 'X' ], 'allowedKeys' => [ 'Y' ], 'messages' => [] ];
				$stack = is_array( $settings ) ? [ &$settings ] : [];
				while ( $stack ) {
					foreach ( $stack[0] as $k => $v ) {
						if ( $v instanceof MessageValue ) {
							$ret['messages'][] = $v;
						} elseif ( is_array( $v ) ) {
							$stack[] = &$stack[0][$k];
						}
					}
					array_shift( $stack );
				}
				return $ret;
			} );
		TestingAccessWrapper::newFromObject( $validator )->paramValidator = $mock;

		$this->assertEquals( $expect, $validator->checkSettings( $module, $params, $name, [] ) );
	}

	public static function provideCheckSettings() {
		$keys = [
			'Y', ApiBase::PARAM_RANGE_ENFORCE, ApiBase::PARAM_HELP_MSG, ApiBase::PARAM_HELP_MSG_APPEND,
			ApiBase::PARAM_HELP_MSG_INFO, ApiBase::PARAM_HELP_MSG_PER_VALUE, ApiBase::PARAM_TEMPLATE_VARS,
		];

		return [
			'Basic test' => [
				[ 'test' => null ],
				'test',
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'apihelp-query+allpages-param-test' ),
					],
				]
			],
			'Message mapping' => [
				[ 'test' => [
					EnumDef::PARAM_DEPRECATED_VALUES => [
						'a' => true,
						'b' => 'bbb',
						'c' => [ 'ccc', 'p1', 'p2' ],
						'd' => Message::newFromKey( 'ddd' )->plaintextParams( 'p1', 'p2' ),
					],
				] ],
				'test',
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [
						DataMessageValue::new( 'bbb', [], 'bogus', [ '💩' => 'back-compat' ] ),
						DataMessageValue::new( 'ccc', [], 'bogus', [ '💩' => 'back-compat' ] )
							->params( 'p1', 'p2' ),
						DataMessageValue::new( 'ddd', [], 'bogus', [ '💩' => 'back-compat' ] )
							->plaintextParams( 'p1', 'p2' ),
						MessageValue::new( 'apihelp-query+allpages-param-test' ),
					],
				]
			],
			'Test everything' => [
				[
					'xxx' => [
						ParamValidator::PARAM_TYPE => 'not tested here',
						ParamValidator::PARAM_ISMULTI => true
					],
					'test-{x}' => [
						ParamValidator::PARAM_TYPE => [],
						ApiBase::PARAM_RANGE_ENFORCE => true,
						ApiBase::PARAM_HELP_MSG => 'foo',
						ApiBase::PARAM_HELP_MSG_APPEND => [],
						ApiBase::PARAM_HELP_MSG_INFO => [],
						ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
						ApiBase::PARAM_TEMPLATE_VARS => [
							'x' => 'xxx',
						]
					],
				],
				'test-{x}',
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'foo' ),
					],
				]
			],
			'Bad types' => [
				[ 'test' => [
					ApiBase::PARAM_RANGE_ENFORCE => 1,
					ApiBase::PARAM_HELP_MSG => false,
					ApiBase::PARAM_HELP_MSG_APPEND => 'foo',
					ApiBase::PARAM_HELP_MSG_INFO => 'bar',
					ApiBase::PARAM_HELP_MSG_PER_VALUE => true,
					ApiBase::PARAM_TEMPLATE_VARS => false,
				] ],
				'test',
				[
					'issues' => [
						'X',
						ApiBase::PARAM_RANGE_ENFORCE => 'PARAM_RANGE_ENFORCE must be boolean, got integer',
						'Message specification for PARAM_HELP_MSG is not valid',
						ApiBase::PARAM_HELP_MSG_APPEND => 'PARAM_HELP_MSG_APPEND must be an array, got string',
						ApiBase::PARAM_HELP_MSG_INFO => 'PARAM_HELP_MSG_INFO must be an array, got string',
						ApiBase::PARAM_HELP_MSG_PER_VALUE => 'PARAM_HELP_MSG_PER_VALUE must be an array, got boolean',
						ApiBase::PARAM_TEMPLATE_VARS => 'PARAM_TEMPLATE_VARS must be an array, got boolean',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				]
			],
			'PARAM_HELP_MSG (string)' => [
				[ 'test' => [
					ApiBase::PARAM_HELP_MSG => 'foo',
				] ],
				'test',
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'foo' ),
					],
				]
			],
			'PARAM_HELP_MSG (array)' => [
				[ 'test' => [
					ApiBase::PARAM_HELP_MSG => [ 'foo', 'bar' ],
				] ],
				'test',
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'foo', [ 'bar' ] ),
					],
				]
			],
			'PARAM_HELP_MSG (Message)' => [
				[ 'test' => [
					ApiBase::PARAM_HELP_MSG => Message::newFromKey( 'foo' )->numParams( 123 ),
				] ],
				'test',
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'foo' )->numParams( 123 ),
					],
				]
			],
			'PARAM_HELP_MSG_APPEND' => [
				[ 'test' => [ ApiBase::PARAM_HELP_MSG_APPEND => [
					'foo',
					false,
					[ 'bar', 'p1', 'p2' ],
					Message::newFromKey( 'baz' )->numParams( 123 ),
				] ] ],
				'test',
				[
					'issues' => [
						'X',
						'Message specification for PARAM_HELP_MSG_APPEND[1] is not valid',
					],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'apihelp-query+allpages-param-test' ),
						MessageValue::new( 'foo' ),
						MessageValue::new( 'bar', [ 'p1', 'p2' ] ),
						MessageValue::new( 'baz' )->numParams( 123 ),
					],
				]
			],
			'PARAM_HELP_MSG_INFO' => [
				[ 'test' => [ ApiBase::PARAM_HELP_MSG_INFO => [
					'foo',
					[ false ],
					[ 'foo' ],
					[ 'bar', 'p1', 'p2' ],
				] ] ],
				'test',
				[
					'issues' => [
						'X',
						'PARAM_HELP_MSG_INFO[0] must be an array, got string',
						'PARAM_HELP_MSG_INFO[1][0] must be a string, got boolean',
					],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'apihelp-query+allpages-param-test' ),
						MessageValue::new( 'apihelp-query+allpages-paraminfo-foo' ),
						MessageValue::new( 'apihelp-query+allpages-paraminfo-bar', [ 'p1', 'p2' ] ),
					],
				]
			],
			'PARAM_HELP_MSG_PER_VALUE for non-array type' => [
				[ 'test' => [
					ParamValidator::PARAM_TYPE => 'namespace',
					ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
				] ],
				'test',
				[
					'issues' => [
						'X',
						ApiBase::PARAM_HELP_MSG_PER_VALUE
							=> 'PARAM_HELP_MSG_PER_VALUE can only be used with PARAM_TYPE as an array',
					],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'apihelp-query+allpages-param-test' ),
					],
				]
			],
			'PARAM_HELP_MSG_PER_VALUE' => [
				[ 'test' => [
					ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd' ],
					ApiBase::PARAM_HELP_MSG_PER_VALUE => [
						'a' => null,
						'b' => 'bbb',
						'c' => [ 'ccc', 'p1', 'p2' ],
						'd' => Message::newFromKey( 'ddd' )->numParams( 123 ),
						'e' => 'eee',
					],
				] ],
				'test',
				[
					'issues' => [
						'X',
						'Message specification for PARAM_HELP_MSG_PER_VALUE[a] is not valid',
						'PARAM_HELP_MSG_PER_VALUE contains "e", which is not in PARAM_TYPE.',
					],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'apihelp-query+allpages-param-test' ),
						MessageValue::new( 'bbb' ),
						MessageValue::new( 'ccc', [ 'p1', 'p2' ] ),
						MessageValue::new( 'ddd' )->numParams( 123 ),
						MessageValue::new( 'eee' ),
					],
				]
			],
			'Template-style parameter name without PARAM_TEMPLATE_VARS' => [
				[ 'test{x}' => null ],
				'test{x}',
				[
					'issues' => [
						'X',
						"Parameter name may not contain '{' or '}' without PARAM_TEMPLATE_VARS",
					],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'apihelp-query+allpages-param-test{x}' ),
					],
				]
			],
			'PARAM_TEMPLATE_VARS cannot be empty' => [
				[ 'test{x}' => [
					ApiBase::PARAM_TEMPLATE_VARS => [],
				] ],
				'test{x}',
				[
					'issues' => [
						'X',
						ApiBase::PARAM_TEMPLATE_VARS => 'PARAM_TEMPLATE_VARS cannot be the empty array',
					],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'apihelp-query+allpages-param-test{x}' ),
					],
				]
			],
			'PARAM_TEMPLATE_VARS, ok' => [
				[
					'ok' => [
						ParamValidator::PARAM_ISMULTI => true,
					],
					'ok-templated-{x}' => [
						ParamValidator::PARAM_ISMULTI => true,
						ApiBase::PARAM_TEMPLATE_VARS => [
							'x' => 'ok',
						],
					],
					'test-{a}-{b}' => [
						ApiBase::PARAM_TEMPLATE_VARS => [
							'a' => 'ok',
							'b' => 'ok-templated-{x}',
						],
					],
				],
				'test-{a}-{b}',
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'apihelp-query+allpages-param-test-{a}-{b}' ),
					],
				]
			],
			'PARAM_TEMPLATE_VARS simple errors' => [
				[
					'ok' => [
						ParamValidator::PARAM_ISMULTI => true,
					],
					'not-multi' => false,
					'test-{a}-{b}-{c}' => [
						ApiBase::PARAM_TEMPLATE_VARS => [
							'{x}' => 'ok',
							'not-in-name' => 'ok',
							'a' => false,
							'b' => 'missing',
							'c' => 'not-multi',
						],
					],
				],
				'test-{a}-{b}-{c}',
				[
					'issues' => [
						'X',
						"PARAM_TEMPLATE_VARS keys may not contain '{' or '}', got \"{x}\"",
						'Parameter name must contain PARAM_TEMPLATE_VARS key {not-in-name}',
						'PARAM_TEMPLATE_VARS[a] has invalid target type boolean',
						'PARAM_TEMPLATE_VARS[b] target parameter "missing" does not exist',
						'PARAM_TEMPLATE_VARS[c] target parameter "not-multi" must have PARAM_ISMULTI = true',
					],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'apihelp-query+allpages-param-test-{a}-{b}-{c}' ),
					],
				]
			],
			'PARAM_TEMPLATE_VARS no recursion' => [
				[
					'test-{a}' => [
						ParamValidator::PARAM_ISMULTI => true,
						ApiBase::PARAM_TEMPLATE_VARS => [
							'a' => 'test-{a}',
						],
					],
				],
				'test-{a}',
				[
					'issues' => [
						'X',
						'PARAM_TEMPLATE_VARS[a] cannot target the parameter itself'
					],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'apihelp-query+allpages-param-test-{a}' ),
					],
				]
			],
			'PARAM_TEMPLATE_VARS targeting another template, target must be a subset' => [
				[
					'ok1' => [ ParamValidator::PARAM_ISMULTI => true ],
					'ok2' => [ ParamValidator::PARAM_ISMULTI => true ],
					'test1-{a}' => [
						ApiBase::PARAM_TEMPLATE_VARS => [
							'a' => 'test2-{a}',
						],
					],
					'test2-{a}' => [
						ParamValidator::PARAM_ISMULTI => true,
						ApiBase::PARAM_TEMPLATE_VARS => [
							'a' => 'ok2',
						],
					],
				],
				'test1-{a}',
				[
					'issues' => [
						'X',
						'PARAM_TEMPLATE_VARS[a]: Target\'s PARAM_TEMPLATE_VARS must be a subset of the original',
					],
					'allowedKeys' => $keys,
					'messages' => [
						MessageValue::new( 'apihelp-query+allpages-param-test1-{a}' ),
					],
				]
			],
		];
	}

	/**
	 * @dataProvider provideGetValue
	 */
	public function testGetValue( ?string $data, $settings, $expect ): void {
		[ $validator, $main ] = $this->getValidator( new FauxRequest( [ 'aptest' => $data ] ) );
		$module = $main->getModuleFromPath( 'query+allpages' );
		$options = [
			'parse-limit' => false,
			'raw' => ( $settings[ParamValidator::PARAM_TYPE] ?? '' ) === 'raw',
		];

		if ( $expect instanceof ApiUsageException ) {
			try {
				$validator->getValue( $module, 'test', $settings, $options );
				$this->fail( 'Expected exception not thrown' );
			} catch ( ApiUsageException $e ) {
				$this->assertSame( $module->getModulePath(), $e->getModulePath() );
				$this->assertEquals( $expect->getStatusValue(), $e->getStatusValue() );
			}
		} else {
			$this->assertEquals( $expect, $validator->getValue( $module, 'test', $settings, $options ) );
		}
	}

	public static function provideGetValue(): array {
		return [
			'Basic test' => [
				'1234',
				[
					ParamValidator::PARAM_TYPE => 'integer',
				],
				1234
			],
			'Test for default' => [
				null,
				1234,
				1234
			],
			'Test no value' => [
				null,
				[
					ParamValidator::PARAM_TYPE => 'integer',
				],
				null,
			],
			'Test boolean (false)' => [
				null,
				false,
				null,
			],
			'Test boolean (true)' => [
				'',
				false,
				true,
			],
			// The 'string' type will be NFC normalized (in this case,
			// U+2001 will be converted to U+2003; see Figure 5 of
			// of https://unicode.org/reports/tr15 for more examples).
			'Test string (Unicode NFC)' => [
				"\u{2001}",
				[
					ParamValidator::PARAM_TYPE => 'string',
				],
				"\u{2003}",
			],
			// The 'raw' type bypasses Unicode NFC normalization.
			'Test string (raw)' => [
				"\u{2001}",
				[
					ParamValidator::PARAM_TYPE => 'raw',
				],
				"\u{2001}",
			],
			'Validation failure' => [
				'xyz',
				[
					ParamValidator::PARAM_TYPE => 'integer',
				],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-badinteger',
					Message::plaintextParam( 'aptest' ),
					Message::plaintextParam( 'xyz' ),
				], 'badinteger' ),
			],
		];
	}

	/**
	 * @dataProvider provideValidateValue
	 */
	public function testValidateValue( $value, $settings, $expect ): void {
		[ $validator, $main ] = $this->getValidator( new FauxRequest() );
		$module = $main->getModuleFromPath( 'query+allpages' );

		if ( $expect instanceof ApiUsageException ) {
			try {
				$validator->validateValue( $module, 'test', $value, $settings, [] );
				$this->fail( 'Expected exception not thrown' );
			} catch ( ApiUsageException $e ) {
				$this->assertSame( $module->getModulePath(), $e->getModulePath() );
				$this->assertEquals( $expect->getStatusValue(), $e->getStatusValue() );
			}
		} else {
			$this->assertEquals(
				$expect,
				$validator->validateValue( $module, 'test', $value, $settings, [] )
			);
		}
	}

	public static function provideValidateValue(): array {
		return [
			'Basic test' => [
				1234,
				[
					ParamValidator::PARAM_TYPE => 'integer',
				],
				1234
			],
			'Validation failure' => [
				1234,
				[
					ParamValidator::PARAM_TYPE => 'integer',
					IntegerDef::PARAM_IGNORE_RANGE => false,
					IntegerDef::PARAM_MAX => 10,
				],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-outofrange-max',
					Message::plaintextParam( 'aptest' ),
					Message::plaintextParam( 1234 ),
					Message::numParam( '' ),
					Message::numParam( 10 ),
				], 'outofrange', [ 'min' => null, 'curmax' => 10, 'max' => 10, 'highmax' => 10 ] ),
			],
		];
	}

	public function testGetParamInfo() {
		[ $validator, $main ] = $this->getValidator( new FauxRequest() );
		$module = $main->getModuleFromPath( 'query+allpages' );
		$dummy = (object)[];

		$settings = [
			'foo' => (object)[],
		];
		$options = [
			'bar' => (object)[],
		];

		$mock = $this->getMockBuilder( ParamValidator::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getParamInfo' ] )
			->getMock();
		$mock->expects( $this->once() )->method( 'getParamInfo' )
			->with(
				$this->identicalTo( 'aptest' ),
				$this->identicalTo( $settings ),
				$this->identicalTo( $options + [ 'module' => $module ] )
			)
			->willReturn( [ $dummy ] );

		TestingAccessWrapper::newFromObject( $validator )->paramValidator = $mock;
		$this->assertSame( [ $dummy ], $validator->getParamInfo( $module, 'test', $settings, $options ) );
	}

	public function testGetHelpInfo() {
		[ $validator, $main ] = $this->getValidator( new FauxRequest() );
		$module = $main->getModuleFromPath( 'query+allpages' );

		$settings = [
			'foo' => (object)[],
		];
		$options = [
			'bar' => (object)[],
		];

		$mock = $this->getMockBuilder( ParamValidator::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getHelpInfo' ] )
			->getMock();
		$mock->expects( $this->once() )->method( 'getHelpInfo' )
			->with(
				$this->identicalTo( 'aptest' ),
				$this->identicalTo( $settings ),
				$this->identicalTo( $options + [ 'module' => $module ] )
			)
			->willReturn( [
				'mv1' => MessageValue::new( 'parentheses', [ 'foobar' ] ),
				'mv2' => MessageValue::new( 'paramvalidator-help-continue' ),
			] );

		TestingAccessWrapper::newFromObject( $validator )->paramValidator = $mock;
		$ret = $validator->getHelpInfo( $module, 'test', $settings, $options );
		$this->assertArrayHasKey( 'mv1', $ret );
		$this->assertInstanceOf( Message::class, $ret['mv1'] );
		$this->assertEquals( '(parentheses: foobar)', $ret['mv1']->inLanguage( 'qqx' )->plain() );
		$this->assertArrayHasKey( 'mv2', $ret );
		$this->assertInstanceOf( Message::class, $ret['mv2'] );
		$this->assertEquals(
			[ 'api-help-param-continue', 'paramvalidator-help-continue' ],
			$ret['mv2']->getKeysToTry()
		);
		$this->assertCount( 2, $ret );
	}
}
PK       ! c      api/ApiUsageExceptionTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiMessage;
use MediaWiki\Api\ApiUsageException;
use MediaWiki\Message\Message;
use MediaWikiIntegrationTestCase;
use StatusValue;

/**
 * @covers \MediaWiki\Api\ApiUsageException
 */
class ApiUsageExceptionTest extends MediaWikiIntegrationTestCase {

	public function testCreateWithStatusValue_CanGetAMessageObject() {
		$messageKey = 'some-message-key';
		$messageParameter = 'some-parameter';
		$statusValue = new StatusValue();
		$statusValue->fatal( $messageKey, $messageParameter );

		$apiUsageException = new ApiUsageException( null, $statusValue );
		/** @var Message $gotMessage */
		$gotMessage = $apiUsageException->getMessageObject();

		$this->assertInstanceOf( Message::class, $gotMessage );
		$this->assertEquals( $messageKey, $gotMessage->getKey() );
		$this->assertEquals( [ $messageParameter ], $gotMessage->getParams() );
	}

	public function testNewWithMessage_ThenGetMessageObject_ReturnsApiMessageWithProvidedData() {
		$expectedMessage = new Message( 'some-message-key', [ 'some message parameter' ] );
		$expectedCode = 'some-error-code';
		$expectedData = [ 'some-error-data' ];

		$apiUsageException = ApiUsageException::newWithMessage(
			null,
			$expectedMessage,
			$expectedCode,
			$expectedData
		);
		/** @var ApiMessage $gotMessage */
		$gotMessage = $apiUsageException->getMessageObject();

		$this->assertInstanceOf( ApiMessage::class, $gotMessage );
		$this->assertEquals( $expectedMessage->getKey(), $gotMessage->getKey() );
		$this->assertEquals( $expectedMessage->getParams(), $gotMessage->getParams() );
		$this->assertEquals( $expectedCode, $gotMessage->getApiCode() );
		$this->assertEquals( $expectedData, $gotMessage->getApiData() );
	}

}
PK       ! R*:  :    api/ApiOptionsTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiOptions;
use MediaWiki\Api\ApiUsageException;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\Preferences\DefaultPreferencesFactory;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\Options\UserOptionsManager;
use MediaWiki\User\User;
use PHPUnit\Framework\MockObject\MockObject;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers \MediaWiki\Api\ApiOptions
 */
class ApiOptionsTest extends ApiTestCase {
	use MockAuthorityTrait;

	/** @var MockObject */
	private $mUserMock;
	/** @var MockObject */
	private $userOptionsManagerMock;
	/** @var ApiOptions */
	private $mTested;
	/** @var array */
	private $mSession;
	/** @var DerivativeContext */
	private $mContext;

	private const SUCCESS = [ 'options' => 'success' ];

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

		$this->mUserMock = $this->createMock( User::class );

		// No actual DB data
		$this->mUserMock->method( 'getInstanceForUpdate' )->willReturn( $this->mUserMock );

		$this->mUserMock->method( 'isAllowedAny' )->willReturn( true );

		// Create a new context
		$this->mContext = new DerivativeContext( new RequestContext() );
		$this->mContext->getContext()->setTitle( Title::makeTitle( NS_MAIN, 'Test' ) );
		$this->mContext->setAuthority(
			$this->mockUserAuthorityWithPermissions( $this->mUserMock, [ 'editmyoptions' ] )
		);

		$main = new ApiMain( $this->mContext );

		// Empty session
		$this->mSession = [];

		$this->userOptionsManagerMock = $this->createNoOpMock(
			UserOptionsManager::class,
			[ 'getOptions', 'resetOptionsByName', 'setOption', 'isOptionGlobal' ]
		);
		// Needs to return something
		$this->userOptionsManagerMock->method( 'getOptions' )->willReturn( [] );

		$preferencesFactory = $this->createNoOpMock(
			DefaultPreferencesFactory::class,
			[ 'getFormDescriptor', 'listResetKinds', 'getResetKinds', 'getOptionNamesForReset' ]
		);
		$preferencesFactory->method( 'getFormDescriptor' )
			->willReturnCallback( [ $this, 'getPreferencesFormDescription' ] );
		$preferencesFactory->method( 'listResetKinds' )->willReturn(
			[
				'registered',
				'registered-multiselect',
				'registered-checkmatrix',
				'userjs',
				'special',
				'unused'
			]
		);
		$preferencesFactory->method( 'getResetKinds' )
			->willReturnCallback( [ $this, 'getResetKinds' ] );
		$preferencesFactory->method( 'getOptionNamesForReset' )
			->willReturn( [] );

		$this->mTested = new ApiOptions( $main, 'options', $this->userOptionsManagerMock, $preferencesFactory );

		$this->mergeMwGlobalArrayValue( 'wgDefaultUserOptions', [
			'testradio' => 'option1',
		] );
	}

	public function getPreferencesFormDescription() {
		$preferences = [];

		foreach ( [ 'name', 'willBeNull', 'willBeEmpty', 'willBeHappy' ] as $k ) {
			$preferences[$k] = [
				'type' => 'text',
				'section' => 'test',
				'label' => "\u{00A0}",
			];
		}

		$preferences['testmultiselect'] = [
			'type' => 'multiselect',
			'options' => [
				'Test' => [
					'<span dir="auto">Some HTML here for option 1</span>' => 'opt1',
					'<span dir="auto">Some HTML here for option 2</span>' => 'opt2',
					'<span dir="auto">Some HTML here for option 3</span>' => 'opt3',
					'<span dir="auto">Some HTML here for option 4</span>' => 'opt4',
				],
			],
			'section' => 'test',
			'label' => "\u{00A0}",
			'prefix' => 'testmultiselect-',
			'default' => [],
		];

		$preferences['testradio'] = [
			'type' => 'radio',
			'options' => [ 'Option 1' => 'option1', 'Option 2' => 'option2' ],
			'section' => 'test',
		];

		return $preferences;
	}

	/**
	 * @param mixed $unused
	 * @param IContextSource $context
	 * @param array|null $options
	 *
	 * @return array
	 */
	public function getResetKinds( $unused, IContextSource $context, $options = null ) {
		// Match with above.
		$kinds = [
			'name' => 'registered',
			'willBeNull' => 'registered',
			'willBeEmpty' => 'registered',
			'willBeHappy' => 'registered',
			'testradio' => 'registered',
			'testmultiselect-opt1' => 'registered-multiselect',
			'testmultiselect-opt2' => 'registered-multiselect',
			'testmultiselect-opt3' => 'registered-multiselect',
			'testmultiselect-opt4' => 'registered-multiselect',
			'special' => 'special',
		];

		if ( $options === null ) {
			return $kinds;
		}

		$mapping = [];
		foreach ( $options as $key => $value ) {
			if ( isset( $kinds[$key] ) ) {
				$mapping[$key] = $kinds[$key];
			} elseif ( str_starts_with( $key, 'userjs-' ) ) {
				$mapping[$key] = 'userjs';
			} else {
				$mapping[$key] = 'unused';
			}
		}

		return $mapping;
	}

	private function getSampleRequest( $custom = [] ) {
		$request = [
			'token' => '123ABC',
			'change' => null,
			'optionname' => null,
			'optionvalue' => null,
		];

		return array_merge( $request, $custom );
	}

	private function executeQuery( $request ) {
		$this->mContext->setRequest( new FauxRequest( $request, true, $this->mSession ) );
		$this->mUserMock->method( 'getRequest' )->willReturn( $this->mContext->getRequest() );

		$this->mTested->execute();

		return $this->mTested->getResult()->getResultData( null, [ 'Strip' => 'all' ] );
	}

	public function testNoToken() {
		$request = $this->getSampleRequest( [ 'token' => null ] );

		$this->expectException( ApiUsageException::class );
		$this->executeQuery( $request );
	}

	public function testAnon() {
		$this->mUserMock
			->method( 'isRegistered' )
			->willReturn( false );

		try {
			$request = $this->getSampleRequest();

			$this->executeQuery( $request );
		} catch ( ApiUsageException $e ) {
			$this->assertApiErrorCode( 'notloggedin', $e );
			return;
		}
		$this->fail( "ApiUsageException was not thrown" );
	}

	public function testNoOptionname() {
		$this->mUserMock->method( 'isRegistered' )->willReturn( true );
		$this->mUserMock->method( 'isNamed' )->willReturn( true );

		try {
			$request = $this->getSampleRequest( [ 'optionvalue' => '1' ] );

			$this->executeQuery( $request );
		} catch ( ApiUsageException $e ) {
			$this->assertApiErrorCode( 'nooptionname', $e );
			return;
		}
		$this->fail( "ApiUsageException was not thrown" );
	}

	public function testNoChanges() {
		$this->mUserMock->method( 'isRegistered' )->willReturn( true );
		$this->mUserMock->method( 'isNamed' )->willReturn( true );
		$this->userOptionsManagerMock->expects( $this->never() )
			->method( 'resetOptionsByName' );

		$this->userOptionsManagerMock->expects( $this->never() )
			->method( 'setOption' );

		$this->mUserMock->expects( $this->never() )
			->method( 'saveSettings' );

		try {
			$request = $this->getSampleRequest();

			$this->executeQuery( $request );
		} catch ( ApiUsageException $e ) {
			$this->assertApiErrorCode( 'nochanges', $e );
			return;
		}
		$this->fail( "ApiUsageException was not thrown" );
	}

	public function userScenarios() {
		return [
			[ true, true, false ],
			[ true, false, true ],
		];
	}

	/**
	 * @dataProvider userScenarios
	 */
	public function testReset( $isRegistered, $isNamed, $expectException ) {
		$this->mUserMock->method( 'isRegistered' )->willReturn( $isRegistered );
		$this->mUserMock->method( 'isNamed' )->willReturn( $isNamed );

		if ( $expectException ) {
			$this->userOptionsManagerMock->expects( $this->never() )->method( 'resetOptionsByName' );
			$this->userOptionsManagerMock->expects( $this->never() )->method( 'setOption' );
			$this->mUserMock->expects( $this->never() )->method( 'saveSettings' );
		} else {
			$this->userOptionsManagerMock->expects( $this->once() )->method( 'resetOptionsByName' );
			$this->userOptionsManagerMock->expects( $this->never() )->method( 'setOption' );
			$this->mUserMock->expects( $this->once() )->method( 'saveSettings' );
		}
		$request = $this->getSampleRequest( [ 'reset' => '' ] );
		try {
			$response = $this->executeQuery( $request );
			if ( $expectException ) {
				$this->fail( 'Expected a "notloggedin" error.' );
			} else {
				$this->assertEquals( self::SUCCESS, $response );
			}
		} catch ( ApiUsageException $e ) {
			if ( !$expectException ) {
				$this->fail( 'Unexpected "notloggedin" error.' );
			} else {
				$this->assertApiErrorCode( 'notloggedin', $e );
			}
		}
	}

	/**
	 * @dataProvider userScenarios
	 */
	public function testResetKinds( $isRegistered, $isNamed, $expectException ) {
		$this->mUserMock->method( 'isRegistered' )->willReturn( $isRegistered );
		$this->mUserMock->method( 'isNamed' )->willReturn( $isNamed );
		if ( $expectException ) {
			$this->mUserMock->expects( $this->never() )->method( 'saveSettings' );
			$this->userOptionsManagerMock->expects( $this->never() )->method( 'resetOptionsByName' );
			$this->userOptionsManagerMock->expects( $this->never() )->method( 'setOption' );
		} else {
			$this->userOptionsManagerMock->expects( $this->once() )->method( 'resetOptionsByName' );
			$this->userOptionsManagerMock->expects( $this->never() )->method( 'setOption' );
			$this->mUserMock->expects( $this->once() )->method( 'saveSettings' );
		}
		$request = $this->getSampleRequest( [ 'reset' => '', 'resetkinds' => 'registered' ] );
		try {
			$response = $this->executeQuery( $request );
			if ( $expectException ) {
				$this->fail( "Expected an ApiUsageException" );
			} else {
				$this->assertEquals( self::SUCCESS, $response );
			}
		} catch ( ApiUsageException $e ) {
			if ( !$expectException ) {
				throw $e;
			}
			$this->assertNotNull( $e->getMessageObject() );
			$this->assertApiErrorCode( 'notloggedin', $e );
		}
	}

	/**
	 * @dataProvider userScenarios
	 */
	public function testResetChangeOption( $isRegistered, $isNamed, $expectException ) {
		$this->mUserMock->method( 'isRegistered' )->willReturn( $isRegistered );
		$this->mUserMock->method( 'isNamed' )->willReturn( $isNamed );

		if ( $expectException ) {
			$this->userOptionsManagerMock->expects( $this->never() )->method( 'resetOptionsByName' );
			$this->userOptionsManagerMock->expects( $this->never() )->method( 'setOption' );
			$this->mUserMock->expects( $this->never() )->method( 'saveSettings' );
		} else {
			$this->userOptionsManagerMock->expects( $this->once() )->method( 'resetOptionsByName' );
			$expectedOptions = [
				'willBeHappy' => 'Happy',
				'name' => 'value',
			];
			$this->userOptionsManagerMock->expects( $this->exactly( count( $expectedOptions ) ) )
				->method( 'setOption' )
				->willReturnCallback( function ( $user, $oname, $val ) use ( &$expectedOptions ) {
					$this->assertSame( $this->mUserMock, $user );
					$this->assertArrayHasKey( $oname, $expectedOptions );
					$this->assertSame( $expectedOptions[$oname], $val );
					unset( $expectedOptions[$oname] );
				} );
			$this->mUserMock->expects( $this->once() )->method( 'saveSettings' );
		}

		$args = [
			'reset' => '',
			'change' => 'willBeHappy=Happy',
			'optionname' => 'name',
			'optionvalue' => 'value'
		];

		try {
			$response = $this->executeQuery( $this->getSampleRequest( $args ) );

			if ( $expectException ) {
				$this->fail( "Expected an ApiUsageException" );
			} else {
				$this->assertEquals( self::SUCCESS, $response );
			}
		} catch ( ApiUsageException $e ) {
			if ( !$expectException ) {
				throw $e;
			}
			$this->assertNotNull( $e->getMessageObject() );
			$this->assertApiErrorCode( 'notloggedin', $e );
		}
	}

	/**
	 * @dataProvider provideOptionManupulation
	 */
	public function testOptionManupulation( array $params, array $setOptions, ?array $result = null,
		$message = ''
	) {
		$this->mUserMock->method( 'isRegistered' )->willReturn( true );
		$this->mUserMock->method( 'isNamed' )->willReturn( true );
		$this->userOptionsManagerMock->expects( $this->never() )
			->method( 'resetOptionsByName' );

		$expectedOptions = [];
		foreach ( $setOptions as [ $opt, $val ] ) {
			$expectedOptions[$opt] = $val;
		}
		$this->userOptionsManagerMock->expects( $this->exactly( count( $setOptions ) ) )
			->method( 'setOption' )
			->willReturnCallback( function ( $user, $oname, $val ) use ( &$expectedOptions ) {
				$this->assertSame( $this->mUserMock, $user );
				$this->assertArrayHasKey( $oname, $expectedOptions );
				$this->assertSame( $expectedOptions[$oname], $val );
				unset( $expectedOptions[$oname] );
			} );

		if ( $setOptions ) {
			$this->mUserMock->expects( $this->once() )
				->method( 'saveSettings' );
		} else {
			$this->mUserMock->expects( $this->never() )
				->method( 'saveSettings' );
		}

		$request = $this->getSampleRequest( $params );
		$response = $this->executeQuery( $request );

		if ( !$result ) {
			$result = self::SUCCESS;
		}
		$this->assertEquals( $result, $response, $message );
	}

	public static function provideOptionManupulation() {
		return [
			[
				[ 'change' => 'userjs-option=1' ],
				[ [ 'userjs-option', '1' ] ],
				null,
				'Setting userjs options',
			],
			[
				[ 'change' => 'willBeNull|willBeEmpty=|willBeHappy=Happy' ],
				[
					[ 'willBeNull', null ],
					[ 'willBeEmpty', '' ],
					[ 'willBeHappy', 'Happy' ],
				],
				null,
				'Basic option setting',
			],
			[
				[ 'change' => 'testradio=option2' ],
				[ [ 'testradio', 'option2' ] ],
				null,
				'Changing radio options',
			],
			[
				[ 'change' => 'testradio' ],
				[ [ 'testradio', null ] ],
				null,
				'Resetting radio options',
			],
			[
				[ 'change' => 'unknownOption=1' ],
				[],
				[
					'options' => 'success',
					'warnings' => [
						'options' => [
							'warnings' => "Validation error for \"unknownOption\": not a valid preference."
						],
					],
				],
				'Unrecognized options should be rejected',
			],
			[
				[ 'change' => 'special=1' ],
				[],
				[
					'options' => 'success',
					'warnings' => [
						'options' => [
							'warnings' => "Validation error for \"special\": cannot be set by this module."
						]
					]
				],
				'Refuse setting special options',
			],
			[
				[
					'change' => 'testmultiselect-opt1=1|testmultiselect-opt2|'
						. 'testmultiselect-opt3=|testmultiselect-opt4=0'
				],
				[
					[ 'testmultiselect-opt1', true ],
					[ 'testmultiselect-opt2', null ],
					[ 'testmultiselect-opt3', false ],
					[ 'testmultiselect-opt4', false ],
				],
				null,
				'Setting multiselect options',
			],
			[
				[ 'optionname' => 'name', 'optionvalue' => 'value' ],
				[ [ 'name', 'value' ] ],
				null,
				'Setting options via optionname/optionvalue'
			],
			[
				[ 'optionname' => 'name' ],
				[ [ 'name', null ] ],
				null,
				'Resetting options via optionname without optionvalue',
			],
			[
				[ 'optionname' => 'name', 'optionvalue' => str_repeat( '测试', 16383 ) ],
				[],
				[
					'options' => 'success',
					'warnings' => [
						'options' => [
							'warnings' => 'Validation error for "name": value too long (no more than 65,530 bytes allowed).'
						],
					],
				],
				'Options with too long value should be rejected',
			],
		];
	}
}
PK       ! 7މ      api/generateRandomImages.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Maintenance\Maintenance;

/**
 * Bootstrapping for test image file generation
 *
 * @file
 */

// Start up MediaWiki in command-line mode
require_once __DIR__ . "/../../../../maintenance/Maintenance.php";
require_once __DIR__ . "/RandomImageGenerator.php";

class GenerateRandomImages extends Maintenance {

	public function getDbType() {
		return Maintenance::DB_NONE;
	}

	public function execute() {
		$getOptSpec = [
			'minWidth::',
			'maxWidth::',
			'minHeight::',
			'maxHeight::',

			'number::',
			'format::'
		];
		$options = getopt( '', $getOptSpec );

		$format = $options['format'] ?? 'svg';
		unset( $options['format'] );

		$number = (int)( $options['number'] ?? 1 );
		unset( $options['number'] );

		$randomImageGenerator = new RandomImageGenerator( $options );
		$randomImageGenerator->writeImages( $number, $format );
	}
}

$maintClass = GenerateRandomImages::class;
require_once RUN_MAINTENANCE_IF_MAIN;
PK       ! n[
  [
    api/ApiCheckTokenTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Session\Token;

/**
 * @group API
 * @group medium
 * @covers \MediaWiki\Api\ApiCheckToken
 */
class ApiCheckTokenTest extends ApiTestCase {

	/**
	 * Test result of checking previously queried token (should be valid)
	 */
	public function testCheckTokenValid() {
		// Query token which will be checked later
		$tokens = $this->doApiRequest( [
			'action' => 'query',
			'meta' => 'tokens',
		] );

		$data = $this->doApiRequest( [
			'action' => 'checktoken',
			'type' => 'csrf',
			'token' => $tokens[0]['query']['tokens']['csrftoken'],
		], $tokens[1]->getSessionArray() );

		$this->assertEquals( 'valid', $data[0]['checktoken']['result'] );
		$this->assertArrayHasKey( 'generated', $data[0]['checktoken'] );
	}

	/**
	 * Test result of checking invalid token
	 */
	public function testCheckTokenInvalid() {
		$session = [];
		$data = $this->doApiRequest( [
			'action' => 'checktoken',
			'type' => 'csrf',
			'token' => 'invalid_token',
		], $session );

		$this->assertEquals( 'invalid', $data[0]['checktoken']['result'] );
	}

	/**
	 * Test result of checking token with negative max age (should be expired)
	 */
	public function testCheckTokenExpired() {
		// Query token which will be checked later
		$tokens = $this->doApiRequest( [
			'action' => 'query',
			'meta' => 'tokens',
		] );

		$data = $this->doApiRequest( [
			'action' => 'checktoken',
			'type' => 'csrf',
			'token' => $tokens[0]['query']['tokens']['csrftoken'],
			'maxtokenage' => -1,
		], $tokens[1]->getSessionArray() );

		$this->assertEquals( 'expired', $data[0]['checktoken']['result'] );
		$this->assertArrayHasKey( 'generated', $data[0]['checktoken'] );
	}

	/**
	 * Test if using token with incorrect suffix will produce a warning
	 */
	public function testCheckTokenSuffixWarning() {
		// Query token which will be checked later
		$tokens = $this->doApiRequest( [
			'action' => 'query',
			'meta' => 'tokens',
		] );

		// Get token and change the suffix
		$token = $tokens[0]['query']['tokens']['csrftoken'];
		$token = substr( $token, 0, -strlen( Token::SUFFIX ) ) . urldecode( Token::SUFFIX );

		$data = $this->doApiRequest( [
			'action' => 'checktoken',
			'type' => 'csrf',
			'token' => $token,
			'errorformat' => 'raw',
		], $tokens[1]->getSessionArray() );

		$this->assertEquals( 'invalid', $data[0]['checktoken']['result'] );
		$this->assertArrayHasKey( 'warnings', $data[0] );
		$this->assertCount( 1, $data[0]['warnings'] );
		$this->assertEquals( 'checktoken', $data[0]['warnings'][0]['module'] );
		$this->assertEquals( 'checktoken-percentencoding', $data[0]['warnings'][0]['code'] );
	}

}
PK       ! J!  !    api/format/ApiFormatPhpTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Format;

use MediaWiki\Api\ApiResult;

/**
 * @group API
 * @covers MediaWiki\Api\ApiFormatPhp
 */
class ApiFormatPhpTest extends ApiFormatTestBase {

	/** @inheritDoc */
	protected $printerName = 'php';

	private static function addFormatVersion( $format, $arr ) {
		foreach ( $arr as &$p ) {
			if ( !isset( $p[2] ) ) {
				$p[2] = [ 'formatversion' => $format ];
			} else {
				$p[2]['formatversion'] = $format;
			}
		}
		return $arr;
	}

	public static function provideGeneralEncoding() {
		return array_merge(
			self::addFormatVersion( 1, [
				// Basic types
				[ [ null ], 'a:1:{i:0;N;}' ],
				[ [ true ], 'a:1:{i:0;s:0:"";}' ],
				[ [ false ], 'a:0:{}' ],
				[ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ],
					'a:1:{i:0;b:1;}' ],
				[ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ],
					'a:1:{i:0;b:0;}' ],
				[ [ 42 ], 'a:1:{i:0;i:42;}' ],
				[ [ 42.5 ], 'a:1:{i:0;d:42.5;}' ],
				[ [ 1e42 ], 'a:1:{i:0;d:1.0E+42;}' ],
				[ [ 'foo' ], 'a:1:{i:0;s:3:"foo";}' ],
				[ [ 'fóo' ], 'a:1:{i:0;s:4:"fóo";}' ],

				// Arrays and objects
				[ [ [] ], 'a:1:{i:0;a:0:{}}' ],
				[ [ [ 1 ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
				[ [ [ 'x' => 1 ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
				[ [ [ 2 => 1 ] ], 'a:1:{i:0;a:1:{i:2;i:1;}}' ],
				[ [ (object)[] ], 'a:1:{i:0;a:0:{}}' ],
				[ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
				[ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
				[ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
				[ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCkvp', ApiResult::META_KVP_KEY_NAME => 'key' ] ],
					'a:1:{i:0;a:1:{i:0;a:2:{s:3:"key";s:1:"x";s:1:"*";i:1;}}}' ],
				[ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
				[ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], 'a:1:{i:0;a:2:{i:0;s:1:"a";i:1;s:1:"b";}}' ],

				// Content
				[ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
					'a:1:{s:1:"*";s:3:"foo";}' ],

				// BC Subelements
				[ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
					'a:1:{s:3:"foo";a:1:{s:1:"*";s:3:"foo";}}' ],
			] ),
			self::addFormatVersion( 2, [
				// Basic types
				[ [ null ], 'a:1:{i:0;N;}' ],
				[ [ true ], 'a:1:{i:0;b:1;}' ],
				[ [ false ], 'a:1:{i:0;b:0;}' ],
				[ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ],
					'a:1:{i:0;b:1;}' ],
				[ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ],
					'a:1:{i:0;b:0;}' ],
				[ [ 42 ], 'a:1:{i:0;i:42;}' ],
				[ [ 42.5 ], 'a:1:{i:0;d:42.5;}' ],
				[ [ 1e42 ], 'a:1:{i:0;d:1.0E+42;}' ],
				[ [ 'foo' ], 'a:1:{i:0;s:3:"foo";}' ],
				[ [ 'fóo' ], 'a:1:{i:0;s:4:"fóo";}' ],

				// Arrays and objects
				[ [ [] ], 'a:1:{i:0;a:0:{}}' ],
				[ [ [ 1 ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
				[ [ [ 'x' => 1 ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
				[ [ [ 2 => 1 ] ], 'a:1:{i:0;a:1:{i:2;i:1;}}' ],
				[ [ (object)[] ], 'a:1:{i:0;a:0:{}}' ],
				[ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
				[ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
				[ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
				[ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCkvp', ApiResult::META_KVP_KEY_NAME => 'key' ] ],
					'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
				[ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
				[ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], 'a:1:{i:0;a:2:{i:0;s:1:"a";i:1;s:1:"b";}}' ],

				// Content
				[ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
					'a:1:{s:7:"content";s:3:"foo";}' ],

				// BC Subelements
				[ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
					'a:1:{s:3:"foo";s:3:"foo";}' ],
			] )
		);
		// phpcs:enable
	}

}
PK       ! }  }    api/format/ApiFormatXmlTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Format;

use MediaWiki\Api\ApiResult;
use MediaWiki\Title\Title;

/**
 * @group API
 * @group Database
 * @covers MediaWiki\Api\ApiFormatXml
 */
class ApiFormatXmlTest extends ApiFormatTestBase {

	/** @inheritDoc */
	protected $printerName = 'xml';

	protected function setUp(): void {
		parent::setUp();
		$performer = self::getTestSysop()->getAuthority();
		$this->editPage(
			Title::makeTitle( NS_MEDIAWIKI, 'ApiFormatXmlTest.xsl' ),
			'<?xml version="1.0"?><xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" />',
			'Summary',
			NS_MAIN,
			$performer
		);
		$this->editPage(
			Title::makeTitle( NS_MEDIAWIKI, 'ApiFormatXmlTest' ),
			'Bogus',
			'Summary',
			NS_MAIN,
			$performer
		);
		$this->editPage(
			Title::makeTitle( NS_MAIN, 'ApiFormatXmlTest' ),
			'Bogus',
			'Summary',
			NS_MAIN,
			$performer
		);
	}

	public static function provideGeneralEncoding() {
		return [
			// Basic types
			[ [ null, 'a' => null ], '<?xml version="1.0"?><api><_v _idx="0" /></api>' ],
			[ [ true, 'a' => true ], '<?xml version="1.0"?><api a=""><_v _idx="0">true</_v></api>' ],
			[ [ false, 'a' => false ], '<?xml version="1.0"?><api><_v _idx="0">false</_v></api>' ],
			[ [ true, 'a' => true, ApiResult::META_BC_BOOLS => [ 0, 'a' ] ],
				'<?xml version="1.0"?><api a=""><_v _idx="0">1</_v></api>' ],
			[ [ false, 'a' => false, ApiResult::META_BC_BOOLS => [ 0, 'a' ] ],
				'<?xml version="1.0"?><api><_v _idx="0"></_v></api>' ],
			[ [ 42, 'a' => 42 ], '<?xml version="1.0"?><api a="42"><_v _idx="0">42</_v></api>' ],
			[ [ 42.5, 'a' => 42.5 ], '<?xml version="1.0"?><api a="42.5"><_v _idx="0">42.5</_v></api>' ],
			[ [ 1e42, 'a' => 1e42 ], '<?xml version="1.0"?><api a="1.0E+42"><_v _idx="0">1.0E+42</_v></api>' ],
			[ [ 'foo', 'a' => 'foo' ], '<?xml version="1.0"?><api a="foo"><_v _idx="0">foo</_v></api>' ],
			[ [ 'fóo', 'a' => 'fóo' ], '<?xml version="1.0"?><api a="fóo"><_v _idx="0">fóo</_v></api>' ],

			// Arrays and objects
			[ [ [] ], '<?xml version="1.0"?><api><_v /></api>' ],
			[ [ [ 'x' => 1 ] ], '<?xml version="1.0"?><api><_v x="1" /></api>' ],
			[ [ [ 2 => 1 ] ], '<?xml version="1.0"?><api><_v><_v _idx="2">1</_v></_v></api>' ],
			[ [ (object)[] ], '<?xml version="1.0"?><api><_v /></api>' ],
			[ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '<?xml version="1.0"?><api><_v><_v _idx="0">1</_v></_v></api>' ],
			[ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '<?xml version="1.0"?><api><_v><_v>1</_v></_v></api>' ],
			[ [ [ 'x' => 1, 'y' => [ 'z' => 1 ], ApiResult::META_TYPE => 'kvp' ] ],
				'<?xml version="1.0"?><api><_v><_v _name="x" xml:space="preserve">1</_v><_v _name="y"><z xml:space="preserve">1</z></_v></_v></api>' ],
			[ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp', ApiResult::META_INDEXED_TAG_NAME => 'i', ApiResult::META_KVP_KEY_NAME => 'key' ] ],
				'<?xml version="1.0"?><api><_v><i key="x" xml:space="preserve">1</i></_v></api>' ],
			[ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCkvp', ApiResult::META_KVP_KEY_NAME => 'key' ] ],
				'<?xml version="1.0"?><api><_v><_v key="x" xml:space="preserve">1</_v></_v></api>' ],
			[ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '<?xml version="1.0"?><api><_v x="1" /></api>' ],
			[ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], '<?xml version="1.0"?><api><_v><_v _idx="0">a</_v><_v _idx="1">b</_v></_v></api>' ],

			// Content
			[ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
				'<?xml version="1.0"?><api xml:space="preserve">foo</api>' ],

			// Specified element name
			[ [ 'foo', 'bar', ApiResult::META_INDEXED_TAG_NAME => 'itn' ],
				'<?xml version="1.0"?><api><itn>foo</itn><itn>bar</itn></api>' ],

			// Subelements
			[ [ 'a' => 1, 's' => 1, '_subelements' => [ 's' ] ],
				'<?xml version="1.0"?><api a="1"><s xml:space="preserve">1</s></api>' ],

			// Content and subelement
			[ [ 'a' => 1, 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
				'<?xml version="1.0"?><api a="1" xml:space="preserve">foo</api>' ],
			[ [ 's' => [], 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
				'<?xml version="1.0"?><api><s /><content xml:space="preserve">foo</content></api>' ],
			[
				[
					's' => 1,
					'content' => 'foo',
					ApiResult::META_CONTENT => 'content',
					ApiResult::META_SUBELEMENTS => [ 's' ]
				],
				'<?xml version="1.0"?><api><s xml:space="preserve">1</s><content xml:space="preserve">foo</content></api>'
			],

			// BC Subelements
			[ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
				'<?xml version="1.0"?><api><foo xml:space="preserve">foo</foo></api>' ],

			// Name mangling
			[ [ 'foo.bar' => 1 ], '<?xml version="1.0"?><api foo.bar="1" />' ],
			[ [ '' => 1 ], '<?xml version="1.0"?><api _="1" />' ],
			[ [ 'foo bar' => 1 ], '<?xml version="1.0"?><api _foo.20.bar="1" />' ],
			[ [ 'foo:bar' => 1 ], '<?xml version="1.0"?><api _foo.3A.bar="1" />' ],
			[ [ 'foo%.bar' => 1 ], '<?xml version="1.0"?><api _foo.25..2E.bar="1" />' ],
			[ [ '4foo' => 1, 'foo4' => 1 ], '<?xml version="1.0"?><api _4foo="1" foo4="1" />' ],
			[ [ "foo\xe3\x80\x80bar" => 1 ], '<?xml version="1.0"?><api _foo.3000.bar="1" />' ],
			[ [ 'foo:bar' => 1, ApiResult::META_PRESERVE_KEYS => [ 'foo:bar' ] ],
				'<?xml version="1.0"?><api foo:bar="1" />' ],
			[ [ 'a', 'b', ApiResult::META_INDEXED_TAG_NAME => 'foo bar' ],
				'<?xml version="1.0"?><api><_foo.20.bar>a</_foo.20.bar><_foo.20.bar>b</_foo.20.bar></api>' ],

			// includenamespace param
			[ [ 'x' => 'foo' ], '<?xml version="1.0"?><api x="foo" xmlns="http://www.mediawiki.org/xml/api/" />',
				[ 'includexmlnamespace' => 1 ] ],

			// xslt param
			[ [], '<?xml version="1.0"?><api><warnings><xml xml:space="preserve">Invalid or non-existent stylesheet specified.</xml></warnings></api>',
				[ 'xslt' => 'DoesNotExist' ] ],
			[ [], '<?xml version="1.0"?><api><warnings><xml xml:space="preserve">Stylesheet should be in the MediaWiki namespace.</xml></warnings></api>',
				[ 'xslt' => 'ApiFormatXmlTest' ] ],
			[ [], '<?xml version="1.0"?><api><warnings><xml xml:space="preserve">Stylesheet should have ".xsl" extension.</xml></warnings></api>',
				[ 'xslt' => 'MediaWiki:ApiFormatXmlTest' ] ],
			[ [],
				'<?xml version="1.0"?><?xml-stylesheet href="' .
					htmlspecialchars( Title::makeTitle( NS_MEDIAWIKI, 'ApiFormatXmlTest.xsl' )->getLocalURL( 'action=raw' ) ) .
					'" type="text/xsl" ?><api />',
				[ 'xslt' => 'MediaWiki:ApiFormatXmlTest.xsl' ] ],
		];
		// phpcs:enable
	}

}
PK       ! a'       api/format/ApiFormatNoneTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Format;

use MediaWiki\Api\ApiResult;

/**
 * @group API
 * @covers MediaWiki\Api\ApiFormatNone
 */
class ApiFormatNoneTest extends ApiFormatTestBase {

	/** @inheritDoc */
	protected $printerName = 'none';

	public static function provideGeneralEncoding() {
		return [
			// Basic types
			[ [ null ], '' ],
			[ [ true ], '' ],
			[ [ false ], '' ],
			[ [ 42 ], '' ],
			[ [ 42.5 ], '' ],
			[ [ 1e42 ], '' ],
			[ [ 'foo' ], '' ],
			[ [ 'fóo' ], '' ],

			// Arrays and objects
			[ [ [] ], '' ],
			[ [ [ 1 ] ], '' ],
			[ [ [ 'x' => 1 ] ], '' ],
			[ [ [ 2 => 1 ] ], '' ],
			[ [ (object)[] ], '' ],
			[ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '' ],
			[ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '' ],
			[ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], '' ],
			[
				[ [
					'x' => 1,
					ApiResult::META_TYPE => 'BCkvp',
					ApiResult::META_KVP_KEY_NAME => 'key'
				] ],
				''
			],
			[ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '' ],
			[ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], '' ],

			// Content
			[ [ '*' => 'foo' ], '' ],

			// BC Subelements
			[ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ], '' ],
		];
	}

}
PK       ! Z       api/format/ApiFormatTestBase.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Format;

use BadMethodCallException;
use Exception;
use MediaWiki\Api\ApiMain;
use MediaWiki\Context\RequestContext;
use MediaWiki\Request\FauxRequest;
use MediaWikiIntegrationTestCase;

abstract class ApiFormatTestBase extends MediaWikiIntegrationTestCase {

	protected function setUp(): void {
		parent::setUp();
		// These tests cover page rendering end-to-end, and run lots of extension hooks
		// that don't expect to be executed in tests.
		$this->clearHooks();
	}

	/**
	 * Name of the formatter being tested
	 * @var string
	 */
	protected $printerName;

	/**
	 * Return general data to be encoded for testing
	 * @return array See self::testGeneralEncoding
	 */
	public static function provideGeneralEncoding() {
		throw new BadMethodCallException( static::class . ' must implement ' . __METHOD__ );
	}

	/**
	 * Get the formatter output for the given input data
	 * @param array $params Query parameters
	 * @param array $data Data to encode
	 * @param array $options Options. If passed a string, the string is treated
	 *  as the 'class' option.
	 *  - name: Format name, rather than $this->printerName
	 *  - class: If set, register 'name' with this class (and 'factory', if that's set)
	 *  - factory: Used with 'class' to register at runtime
	 *  - returnPrinter: Return the printer object
	 * @return string|array The string if $options['returnPrinter'] isn't set, or an array if it is:
	 *  - text: Output text string
	 *  - printer: ApiFormatBase
	 * @throws Exception
	 */
	protected function encodeData( array $params, array $data, $options = [] ) {
		if ( is_string( $options ) ) {
			$options = [ 'class' => $options ];
		}
		$printerName = $options['name'] ?? $this->printerName;
		$flags = $options['flags'] ?? 0;

		$context = new RequestContext;
		$fauxRequest = new FauxRequest( $params, true );
		$fauxRequest->setRequestURL( 'https://' );
		$context->setRequest( $fauxRequest );
		$main = new ApiMain( $context );
		if ( isset( $options['class'] ) ) {
			$spec = [
				'class' => $options['class']
			];

			if ( isset( $options['factory'] ) ) {
				$spec['factory'] = $options['factory'];
			}

			$main->getModuleManager()->addModule( $printerName, 'format', $spec );
		}
		$result = $main->getResult();
		$result->addArrayType( null, 'default' );
		foreach ( $data as $k => $v ) {
			$result->addValue( null, $k, $v, $flags );
		}

		$ret = [];
		$printer = $main->createPrinterByName( $printerName );
		$printer->initPrinter();
		$printer->execute();
		ob_start();
		try {
			$printer->closePrinter();
			$ret['text'] = ob_get_clean();
		} catch ( Exception $ex ) {
			ob_end_clean();
			throw $ex;
		}

		if ( !empty( $options['returnPrinter'] ) ) {
			$ret['printer'] = $printer;
		}

		return count( $ret ) === 1 ? $ret['text'] : $ret;
	}

	/**
	 * @dataProvider provideGeneralEncoding
	 * @param array $data Data to be encoded
	 * @param string|Exception $expect String to expect, or exception expected to be thrown
	 * @param array $params Query parameters to set in the MediaWiki\Request\FauxRequest
	 * @param array $options Options to pass to self::encodeData()
	 */
	public function testGeneralEncoding(
		array $data, $expect, array $params = [], array $options = []
	) {
		if ( $expect instanceof Exception ) {
			$this->expectException( get_class( $expect ) );
			$this->expectExceptionMessage( $expect->getMessage() );
			$this->encodeData( $params, $data, $options ); // Should throw
		} else {
			$this->assertSame( $expect, $this->encodeData( $params, $data, $options ) );
		}
	}

}
PK       ! 68  8     api/format/ApiFormatBaseTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Format;

use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiFormatBase;
use MediaWiki\Api\ApiMain;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\TestingAccessWrapper;

/**
 * @group API
 * @group Database
 * @covers \MediaWiki\Api\ApiFormatBase
 */
class ApiFormatBaseTest extends ApiFormatTestBase {

	/** @inheritDoc */
	protected $printerName = 'mockbase';

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValue( MainConfigNames::Server, 'http://example.org' );
	}

	/**
	 * @param ApiMain|null $main
	 * @param string $format
	 * @param array $methods
	 * @return ApiFormatBase|MockObject
	 */
	public function getMockFormatter( ?ApiMain $main, $format, $methods = [] ) {
		if ( $main === null ) {
			$context = new RequestContext;
			$context->setRequest( new FauxRequest( [], true ) );
			$main = new ApiMain( $context );
		}

		$mock = $this->getMockBuilder( ApiFormatBase::class )
			->setConstructorArgs( [ $main, $format ] )
			->onlyMethods( array_unique( array_merge( $methods, [ 'getMimeType', 'execute' ] ) ) )
			->getMock();
		if ( !in_array( 'getMimeType', $methods, true ) ) {
			$mock->method( 'getMimeType' )->willReturn( 'text/x-mock' );
		}
		return $mock;
	}

	protected function encodeData( array $params, array $data, $options = [] ) {
		$options += [
			'name' => 'mock',
			'class' => ApiFormatBase::class,
			'factory' => function ( ApiMain $main, $format ) use ( $options ) {
				$mock = $this->getMockFormatter( $main, $format );
				$mock->expects( $this->once() )->method( 'execute' )
					->willReturnCallback( static function () use ( $mock ) {
						$mock->printText( "Format {$mock->getFormat()}: " );
						$mock->printText( "<b>ok</b>" );
					} );

				if ( isset( $options['status'] ) ) {
					$mock->setHttpStatus( $options['status'] );
				}

				return $mock;
			},
			'returnPrinter' => true,
		];

		$this->overrideConfigValue( MainConfigNames::ApiFrameOptions, 'DENY' );

		$ret = parent::encodeData( $params, $data, $options );
		/** @var ApiFormatBase $printer */
		$printer = $ret['printer'];
		$text = $ret['text'];

		if ( $options['name'] !== 'mockfm' ) {
			$ct = 'text/x-mock';
			$file = 'api-result.mock';
			$status = $options['status'] ?? null;
		} elseif ( isset( $params['wrappedhtml'] ) ) {
			$ct = 'text/mediawiki-api-prettyprint-wrapped';
			$file = 'api-result-wrapped.json';
			$status = null;

			// Replace varying field
			$text = preg_replace( '/"time":\d+/', '"time":1234', $text );
		} else {
			$ct = 'text/html';
			$file = 'api-result.html';
			$status = null;

			// Strip OutputPage-generated HTML
			if ( preg_match( '!<pre class="api-pretty-content">.*</pre>!s', $text, $m ) ) {
				$text = $m[0];
			}
		}

		$response = $printer->getMain()->getRequest()->response();
		$this->assertSame( "$ct; charset=utf-8", strtolower( $response->getHeader( 'Content-Type' ) ) );
		$this->assertSame( 'DENY', $response->getHeader( 'X-Frame-Options' ) );
		$this->assertSame( $file, $printer->getFilename() );
		$this->assertSame( "inline; filename=$file", $response->getHeader( 'Content-Disposition' ) );
		$this->assertSame( $status, $response->getStatusCode() );

		return $text;
	}

	public static function provideGeneralEncoding() {
		return [
			'normal' => [
				[],
				"Format MOCK: <b>ok</b>",
				[],
				[ 'name' => 'mock' ]
			],
			'normal ignores wrappedhtml' => [
				[],
				"Format MOCK: <b>ok</b>",
				[ 'wrappedhtml' => 1 ],
				[ 'name' => 'mock' ]
			],
			'HTML format' => [
				[],
				'<pre class="api-pretty-content">Format MOCK: &lt;b>ok&lt;/b></pre>',
				[],
				[ 'name' => 'mockfm' ]
			],
			'wrapped HTML format' => [
				[],
				'{"status":200,"statustext":"OK","html":"<pre class=\"api-pretty-content\">Format MOCK: &lt;b>ok&lt;/b></pre>","modules":["mediawiki.apipretty"],"continue":null,"time":1234}',
				[ 'wrappedhtml' => 1 ],
				[ 'name' => 'mockfm' ]
			],
			'normal, with set status' => [
				[],
				"Format MOCK: <b>ok</b>",
				[],
				[ 'name' => 'mock', 'status' => 400 ]
			],
			'HTML format, with set status' => [
				[],
				'<pre class="api-pretty-content">Format MOCK: &lt;b>ok&lt;/b></pre>',
				[],
				[ 'name' => 'mockfm', 'status' => 400 ]
			],
			'wrapped HTML format, with set status' => [
				[],
				'{"status":400,"statustext":"Bad Request","html":"<pre class=\"api-pretty-content\">Format MOCK: &lt;b>ok&lt;/b></pre>","modules":["mediawiki.apipretty"],"continue":null,"time":1234}',
				[ 'wrappedhtml' => 1 ],
				[ 'name' => 'mockfm', 'status' => 400 ]
			],
		];
	}

	/**
	 * @dataProvider provideFilenameEncoding
	 */
	public function testFilenameEncoding( $filename, $expect ) {
		$ret = parent::encodeData( [], [], [
			'name' => 'mock',
			'class' => ApiFormatBase::class,
			'factory' => function ( ApiMain $main, $format ) use ( $filename ) {
				$mock = $this->getMockFormatter( $main, $format, [ 'getFilename' ] );
				$mock->method( 'getFilename' )->willReturn( $filename );
				return $mock;
			},
			'returnPrinter' => true,
		] );
		$response = $ret['printer']->getMain()->getRequest()->response();

		$this->assertSame( "inline; $expect", $response->getHeader( 'Content-Disposition' ) );
	}

	public static function provideFilenameEncoding() {
		return [
			'something simple' => [
				'foo.xyz', 'filename=foo.xyz'
			],
			'more complicated, but still simple' => [
				'foo.!#$%&\'*+-^_`|~', 'filename=foo.!#$%&\'*+-^_`|~'
			],
			'Needs quoting' => [
				'foo\\bar.xyz', 'filename="foo\\\\bar.xyz"'
			],
			'Needs quoting (2)' => [
				'foo (bar).xyz', 'filename="foo (bar).xyz"'
			],
			'Needs quoting (3)' => [
				"foo\t\"b\x5car\"\0.xyz", "filename=\"foo\x5c\t\x5c\"b\x5c\x5car\x5c\"\x5c\0.xyz\""
			],
			'Non-ASCII characters' => [
				'fóo bár.🙌!',
				"filename=\"f\xF3o b\xE1r.?!\"; filename*=UTF-8''f%C3%B3o%20b%C3%A1r.%F0%9F%99%8C!"
			]
		];
	}

	public function testBasics() {
		$printer = $this->getMockFormatter( null, 'mock' );
		$this->assertTrue( $printer->canPrintErrors() );
		$this->assertSame(
			'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Data_formats',
			$printer->getHelpUrls()
		);
	}

	public function testDisable() {
		$this->overrideConfigValue( MainConfigNames::ApiFrameOptions, 'DENY' );

		$printer = $this->getMockFormatter( null, 'mock' );
		$printer->method( 'execute' )->willReturnCallback( static function () use ( $printer ) {
			$printer->printText( 'Foo' );
		} );
		$this->assertFalse( $printer->isDisabled() );
		$printer->disable();
		$this->assertTrue( $printer->isDisabled() );

		$printer->setHttpStatus( 400 );
		$printer->initPrinter();
		$printer->execute();
		ob_start();
		$printer->closePrinter();
		$this->assertSame( '', ob_get_clean() );
		$response = $printer->getMain()->getRequest()->response();
		$this->assertNull( $response->getHeader( 'Content-Type' ) );
		$this->assertNull( $response->getHeader( 'X-Frame-Options' ) );
		$this->assertNull( $response->getHeader( 'Content-Disposition' ) );
		$this->assertNull( $response->getStatusCode() );
	}

	public function testNullMimeType() {
		$this->overrideConfigValue( MainConfigNames::ApiFrameOptions, 'DENY' );

		$printer = $this->getMockFormatter( null, 'mock', [ 'getMimeType' ] );
		$printer->method( 'execute' )->willReturnCallback( static function () use ( $printer ) {
			$printer->printText( 'Foo' );
		} );
		$printer->method( 'getMimeType' )->willReturn( null );
		$this->assertNull( $printer->getMimeType() );

		$printer->initPrinter();
		$printer->execute();
		ob_start();
		$printer->closePrinter();
		$this->assertSame( 'Foo', ob_get_clean() );
		$response = $printer->getMain()->getRequest()->response();
		$this->assertNull( $response->getHeader( 'Content-Type' ) );
		$this->assertNull( $response->getHeader( 'X-Frame-Options' ) );
		$this->assertNull( $response->getHeader( 'Content-Disposition' ) );

		$printer = $this->getMockFormatter( null, 'mockfm', [ 'getMimeType' ] );
		$printer->method( 'execute' )->willReturnCallback( static function () use ( $printer ) {
			$printer->printText( 'Foo' );
		} );
		$printer->method( 'getMimeType' )->willReturn( null );
		$this->assertNull( $printer->getMimeType() );
		$this->assertTrue( $printer->getIsHtml() );

		$printer->initPrinter();
		$printer->execute();
		ob_start();
		$printer->closePrinter();
		$this->assertSame( 'Foo', ob_get_clean() );
		$response = $printer->getMain()->getRequest()->response();
		$this->assertSame(
			'text/html; charset=utf-8', strtolower( $response->getHeader( 'Content-Type' ) )
		);
		$this->assertSame( 'DENY', $response->getHeader( 'X-Frame-Options' ) );
		$this->assertSame(
			'inline; filename=api-result.html', $response->getHeader( 'Content-Disposition' )
		);
	}

	public static function provideApiFrameOptions() {
		yield 'Override ApiFrameOptions to DENY' => [ 'DENY', 'DENY' ];
		yield 'Override ApiFrameOptions to SAMEORIGIN' => [ 'SAMEORIGIN', 'SAMEORIGIN' ];
		yield 'Override ApiFrameOptions to false' => [ false, null ];
	}

	/**
	 * @dataProvider provideApiFrameOptions
	 */
	public function testApiFrameOptions( $customConfig, $expectedHeader ) {
		$this->overrideConfigValue( MainConfigNames::ApiFrameOptions, $customConfig );
		$printer = $this->getMockFormatter( null, 'mock' );
		$printer->initPrinter();
		$this->assertSame(
			$expectedHeader,
			$printer->getMain()->getRequest()->response()->getHeader( 'X-Frame-Options' )
		);
	}

	public function testForceDefaultParams() {
		$context = new RequestContext;
		$context->setRequest( new FauxRequest( [ 'foo' => '1', 'bar' => '2', 'baz' => '3' ], true ) );
		$main = new ApiMain( $context );
		$allowedParams = [
			'foo' => [],
			'bar' => [ ParamValidator::PARAM_DEFAULT => 'bar?' ],
			'baz' => 'baz!',
		];

		$printer = $this->getMockFormatter( $main, 'mock', [ 'getAllowedParams' ] );
		$printer->method( 'getAllowedParams' )->willReturn( $allowedParams );
		$this->assertEquals(
			[ 'foo' => '1', 'bar' => '2', 'baz' => '3' ],
			$printer->extractRequestParams()
		);

		$printer = $this->getMockFormatter( $main, 'mock', [ 'getAllowedParams' ] );
		$printer->method( 'getAllowedParams' )->willReturn( $allowedParams );
		$printer->forceDefaultParams();
		$this->assertEquals(
			[ 'foo' => null, 'bar' => 'bar?', 'baz' => 'baz!' ],
			$printer->extractRequestParams()
		);
	}

	public function testGetAllowedParams() {
		$printer = $this->getMockFormatter( null, 'mock' );
		$this->assertSame( [], $printer->getAllowedParams() );

		$printer = $this->getMockFormatter( null, 'mockfm' );
		$this->assertSame( [
			'wrappedhtml' => [
				ParamValidator::PARAM_DEFAULT => false,
				ApiBase::PARAM_HELP_MSG => 'apihelp-format-param-wrappedhtml',
			]
		], $printer->getAllowedParams() );
	}

	public function testGetExamplesMessages() {
		/** @var ApiFormatBase $printer */
		$printer = TestingAccessWrapper::newFromObject( $this->getMockFormatter( null, 'mock' ) );
		$this->assertSame( [
			'action=query&meta=siteinfo&siprop=namespaces&format=mock'
				=> [ 'apihelp-format-example-generic', 'MOCK' ]
		], $printer->getExamplesMessages() );

		$printer = TestingAccessWrapper::newFromObject( $this->getMockFormatter( null, 'mockfm' ) );
		$this->assertSame( [
			'action=query&meta=siteinfo&siprop=namespaces&format=mockfm'
				=> [ 'apihelp-format-example-generic', 'MOCK' ]
		], $printer->getExamplesMessages() );
	}

	/**
	 * @dataProvider provideHtmlHeader
	 */
	public function testHtmlHeader( $post, $registerNonHtml, $expect ) {
		$context = new RequestContext;
		$request = new FauxRequest( [ 'a' => 1, 'b' => 2 ], $post );
		$request->setRequestURL( '/wx/api.php' );
		$context->setRequest( $request );
		$context->setLanguage( 'qqx' );
		$main = new ApiMain( $context );
		$printer = $this->getMockFormatter( $main, 'mockfm' );
		$mm = $printer->getMain()->getModuleManager();
		$mm->addModule( 'mockfm', 'format', [
			'class' => ApiFormatBase::class,
			'factory' => static function () {
				return $mock;
			}
		] );
		if ( $registerNonHtml ) {
			$mm->addModule( 'mock', 'format', [
				'class' => ApiFormatBase::class,
				'factory' => static function () {
					return $mock;
				}
			] );
		}

		$printer->initPrinter();
		$printer->execute();
		ob_start();
		$printer->closePrinter();
		$text = ob_get_clean();
		$this->assertStringContainsString( $expect, $text );
		$this->assertSame( 'private, must-revalidate, max-age=0', $main->getContext()->getRequest()->response()->getHeader( 'Cache-Control' ) );
	}

	public static function provideHtmlIsPrivate() {
		yield [ 'private', 'private' ];
		yield [ 'public', 'anon-public-user-private' ];
	}

	/**
	 * Assert that HTML output is not cacheable (T354045).
	 * @dataProvider provideHtmlIsPrivate
	 */
	public function testHtmlIsPrivate( $moduleCacheMode, $expectedCacheMode ) {
		$context = new RequestContext;
		$request = new FauxRequest( [ 'uselang' => 'qqx' ] );
		$request->setRequestURL( '/wx/api.php' );
		$context->setRequest( $request );
		$context->setLanguage( 'qqx' );
		$main = new ApiMain( $context );

		$printer = $this->getMockFormatter( $main, 'mockfm' );
		$mm = $printer->getMain()->getModuleManager();
		$mm->addModule( 'mockfm', 'format', [
			'class' => ApiFormatBase::class,
			'factory' => static function () {
				return $mock;
			}
		] );

		// pretend the output is cacheable
		$main->setCacheMode( $moduleCacheMode );
		$printer->initPrinter();

		$mainAccess = TestingAccessWrapper::newFromObject( $main );
		$this->assertSame( $expectedCacheMode, $main->getCacheMode() );

		$mainAccess->sendCacheHeaders( false );
		$this->assertSame(
			'private, must-revalidate, max-age=0',
			$request->response()->getHeader( 'cache-control' )
		);
	}

	public static function provideHtmlHeader() {
		return [
			[ false, false, '(api-format-prettyprint-header-only-html: MOCK)' ],
			[ true, false, '(api-format-prettyprint-header-only-html: MOCK)' ],
			[ false, true, '(api-format-prettyprint-header-hyperlinked: MOCK, mock, <a rel="nofollow" class="external free" href="http://example.org/wx/api.php?a=1&amp;b=2&amp;format=mock">http://example.org/wx/api.php?a=1&amp;b=2&amp;format=mock</a>)' ],
			[ true, true, '(api-format-prettyprint-header: MOCK, mock)' ],
		];
	}

}
PK       ! V       api/format/ApiFormatJsonTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Format;

use InvalidArgumentException;
use MediaWiki\Api\ApiFormatJson;
use MediaWiki\Api\ApiResult;
use MWException;

/**
 * @group API
 * @covers \MediaWiki\Api\ApiFormatJson
 */
class ApiFormatJsonTest extends ApiFormatTestBase {

	/** @inheritDoc */
	protected $printerName = 'json';

	private static function addFormatVersion( $format, $arr ) {
		$ret = [];
		foreach ( $arr as $val ) {
			if ( !isset( $val[2] ) ) {
				$val[2] = [];
			}
			$val[2]['formatversion'] = $format;
			$ret[] = $val;
			if ( $format === 2 ) {
				// Add a test for 'latest' as well
				$val[2]['formatversion'] = 'latest';
				$ret[] = $val;
			}
		}
		return $ret;
	}

	public static function provideGeneralEncoding() {
		return array_merge(
			self::addFormatVersion( 1, [
				// Basic types
				[ [ null ], '[null]' ],
				[ [ true ], '[""]' ],
				[ [ false ], '[]' ],
				[ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ], '[true]' ],
				[ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ], '[false]' ],
				[ [ 42 ], '[42]' ],
				[ [ 42.5 ], '[42.5]' ],
				[ [ 1e42 ], '[1.0e+42]' ],
				[ [ 'foo' ], '["foo"]' ],
				[ [ 'fóo' ], '["f\u00f3o"]' ],
				[ [ 'fóo' ], '["fóo"]', [ 'utf8' => 1 ] ],

				// Arrays and objects
				[ [ [] ], '[[]]' ],
				[ [ [ 1 ] ], '[[1]]' ],
				[ [ [ 'x' => 1 ] ], '[{"x":1}]' ],
				[ [ [ 2 => 1 ] ], '[{"2":1}]' ],
				[ [ (object)[] ], '[{}]' ],
				[ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '[{"0":1}]' ],
				[ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '[[1]]' ],
				[ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], '[{"x":1}]' ],
				[
					[ [
						'x' => 1,
						ApiResult::META_TYPE => 'BCkvp',
						ApiResult::META_KVP_KEY_NAME => 'key'
					] ],
					'[[{"key":"x","*":1}]]'
				],
				[ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '[{"x":1}]' ],
				[ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], '[["a","b"]]' ],

				// Content
				[ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
					'{"*":"foo"}' ],

				// BC Subelements
				[ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
					'{"foo":{"*":"foo"}}' ],

				// Callbacks
				[ [ 1 ], '/**/myCallback([1])', [ 'callback' => 'myCallback' ] ],
			] ),
			self::addFormatVersion( 2, [
				// Basic types
				[ [ null ], '[null]' ],
				[ [ true ], '[true]' ],
				[ [ false ], '[false]' ],
				[ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ], '[true]' ],
				[ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ], '[false]' ],
				[ [ 42 ], '[42]' ],
				[ [ 42.5 ], '[42.5]' ],
				[ [ 1e42 ], '[1.0e+42]' ],
				[ [ 'foo' ], '["foo"]' ],
				[ [ 'fóo' ], '["fóo"]' ],
				[ [ 'fóo' ], '["f\u00f3o"]', [ 'ascii' => 1 ] ],

				// Arrays and objects
				[ [ [] ], '[[]]' ],
				[ [ [ 'x' => 1 ] ], '[{"x":1}]' ],
				[ [ [ 2 => 1 ] ], '[{"2":1}]' ],
				[ [ (object)[] ], '[{}]' ],
				[ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '[{"0":1}]' ],
				[ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '[[1]]' ],
				[ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], '[{"x":1}]' ],
				[
					[ [
						'x' => 1,
						ApiResult::META_TYPE => 'BCkvp',
						ApiResult::META_KVP_KEY_NAME => 'key'
					] ],
					'[{"x":1}]'
				],
				[ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '[[1]]' ],
				[
					[ [
						'a',
						'b',
						ApiResult::META_TYPE => 'BCassoc'
					] ],
					'[{"0":"a","1":"b"}]'
				],

				// Content
				[ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
					'{"content":"foo"}' ],

				// BC Subelements
				[ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
					'{"foo":"foo"}' ],

				// Callbacks
				[ [ 1 ], '/**/myCallback([1])', [ 'callback' => 'myCallback' ] ],

				// Invalid UTF-8: bytes 192, 193, and 245-255 are off-limits
				[
					[ 'foo' => "\xFF" ],
					"{\"foo\":\"\u{FFFD}\"}", // Mangled when validated (T210548)
				],
				[
					[ 'foo' => "\xFF" ],
					new MWException(
						'Internal error in ' . ApiFormatJson::class . '::execute: ' .
						'Unable to encode API result as JSON'
					),
					[],
					[ 'flags' => ApiResult::NO_VALIDATE ],
				],
				// NaN is also not allowed
				[
					[ 'foo' => NAN ],
					new InvalidArgumentException(
						'Cannot add non-finite floats to ApiResult'
					),
				],
				[
					[ 'foo' => NAN ],
					new MWException(
						'Internal error in ' . ApiFormatJson::class . '::execute: ' .
						'Unable to encode API result as JSON'
					),
					[],
					[ 'flags' => ApiResult::NO_VALIDATE ],
				],
			] )
			// @todo Test rawfm
		);
	}

}
PK       ! dJC  C    api/format/ApiFormatRawTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Format;

use MediaWiki\Api\ApiFormatJson;
use MediaWiki\Api\ApiFormatRaw;
use MediaWiki\Api\ApiMain;
use MWException;

/**
 * @group API
 * @covers \MediaWiki\Api\ApiFormatRaw
 */
class ApiFormatRawTest extends ApiFormatTestBase {

	/** @inheritDoc */
	protected $printerName = 'raw';

	/**
	 * Test basic encoding and missing mime and text exceptions
	 * @return array datasets
	 */
	public static function provideGeneralEncoding() {
		$options = [
			'class' => ApiFormatRaw::class,
			'factory' => static function ( ApiMain $main ) {
				return new ApiFormatRaw( $main, new ApiFormatJson( $main, 'json' ) );
			}
		];

		return [
			[
				[ 'mime' => 'text/plain', 'text' => 'foo' ],
				'foo',
				[],
				$options
			],
			[
				[ 'mime' => 'text/plain', 'text' => 'fóo' ],
				'fóo',
				[],
				$options
			],
			[
				[ 'text' => 'some text' ],
				new MWException( 'No MIME type set for raw formatter' ),
				[],
				$options
			],
			[
				[ 'mime' => 'text/plain' ],
				new MWException( 'No text given for raw formatter' ),
				[],
				$options
			],
			'test error fallback' => [
				[ 'mime' => 'text/plain', 'text' => 'some text', 'error' => 'some error' ],
				'{"mime":"text/plain","text":"some text","error":"some error"}',
				[],
				$options
			]
		];
	}

	/**
	 * Test specifying filename
	 */
	public function testFilename() {
		$printer = new ApiFormatRaw( new ApiMain );
		$printer->getResult()->addValue( null, 'filename', 'whatever.raw' );
		$this->assertSame( 'whatever.raw', $printer->getFilename() );
	}

	/**
	 * Test specifying filename with error fallback printer
	 */
	public function testErrorFallbackFilename() {
		$apiMain = new ApiMain;
		$printer = new ApiFormatRaw( $apiMain, new ApiFormatJson( $apiMain, 'json' ) );
		$printer->getResult()->addValue( null, 'error', 'some error' );
		$printer->getResult()->addValue( null, 'filename', 'whatever.raw' );
		$this->assertSame( 'api-result.json', $printer->getFilename() );
	}

	/**
	 * Test specifying mime
	 */
	public function testMime() {
		$printer = new ApiFormatRaw( new ApiMain );
		$printer->getResult()->addValue( null, 'mime', 'text/plain' );
		$this->assertSame( 'text/plain', $printer->getMimeType() );
	}

	/**
	 * Test specifying mime with error fallback printer
	 */
	public function testErrorFallbackMime() {
		$apiMain = new ApiMain;
		$printer = new ApiFormatRaw( $apiMain, new ApiFormatJson( $apiMain, 'json' ) );
		$printer->getResult()->addValue( null, 'error', 'some error' );
		$printer->getResult()->addValue( null, 'mime', 'text/plain' );
		$this->assertSame( 'application/json', $printer->getMimeType() );
	}

	/**
	 * Check that setting failWithHTTPError to true will result in 400 response status code
	 */
	public function testFailWithHTTPError() {
		$apiMain = null;

		$this->testGeneralEncoding(
			[ 'mime' => 'text/plain', 'text' => 'some text', 'error' => 'some error' ],
			'{"mime":"text/plain","text":"some text","error":"some error"}',
			[],
			[
				'class' => ApiFormatRaw::class,
				'factory' => static function ( ApiMain $main ) use ( &$apiMain ) {
					$apiMain = $main;
					$printer = new ApiFormatRaw( $main, new ApiFormatJson( $main, 'json' ) );
					$printer->setFailWithHTTPError( true );
					return $printer;
				}
			]
		);
		$this->assertEquals( 400, $apiMain->getRequest()->response()->getStatusCode() );
	}

}
PK       ! D۵    2  api/ApiSetNotificationTimestampIntegrationTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

/**
 * @author Addshore
 * @covers MediaWiki\Api\ApiSetNotificationTimestamp
 * @group API
 * @group medium
 * @group Database
 */
class ApiSetNotificationTimestampIntegrationTest extends ApiTestCase {

	public function testStuff() {
		$user = $this->getTestUser()->getUser();
		$watchedPageTitle = 'PageWatched';
		$pageWatched = $this->getExistingTestPage( $watchedPageTitle );
		$notWatchedPageTitle = 'PageNotWatched';
		$pageNotWatched = $this->getExistingTestPage( $notWatchedPageTitle );

		$watchlistManager = $this->getServiceContainer()->getWatchlistManager();
		$watchlistManager->addWatch( $user, $pageWatched );

		$result = $this->doApiRequestWithToken(
			[
				'action' => 'setnotificationtimestamp',
				'timestamp' => '20160101020202',
				'titles' => "$watchedPageTitle|$notWatchedPageTitle",
			],
			null,
			$user
		);

		$this->assertTrue( $result[0]['batchcomplete'] );
		$this->assertArrayEquals(
			[
				[
					'ns' => NS_MAIN,
					'title' => $watchedPageTitle,
					'notificationtimestamp' => '2016-01-01T02:02:02Z'
				],
				[
					'ns' => NS_MAIN,
					'title' => $notWatchedPageTitle,
					'notwatched' => true
				],
			],
			$result[0]['setnotificationtimestamp']
		);

		$watchedItemStore = $this->getServiceContainer()->getWatchedItemStore();
		$this->assertEquals(
			[ [ $watchedPageTitle => '20160101020202', $notWatchedPageTitle => false, ] ],
			$watchedItemStore->getNotificationTimestampsBatch(
				$user, [ $pageWatched->getTitle(), $pageNotWatched->getTitle() ] )
		);
	}

}
PK       ! v    "  api/ApiAcquireTempUserNameTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\User\TempUser\TempUserCreator;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers \MediaWiki\Api\ApiAcquireTempUserName
 */
class ApiAcquireTempUserNameTest extends ApiTestCase {
	use MockAuthorityTrait;
	use TempUserTestTrait;

	public function testExecuteDiesWhenNotEnabled() {
		$this->disableAutoCreateTempUser();
		$this->expectApiErrorCode( 'tempuserdisabled' );

		$this->doApiRequestWithToken( [
			"action" => "acquiretempusername",
		] );
	}

	public function testExecuteDiesWhenUserIsRegistered() {
		$this->enableAutoCreateTempUser();
		$this->expectApiErrorCode( 'alreadyregistered' );

		$this->doApiRequestWithToken(
			[
				'action' => 'acquiretempusername',
			],
			null,
			$this->mockRegisteredUltimateAuthority()
		);
	}

	public function testExecuteDiesWhenNameCannotBeAcquired() {
		$mockTempUserCreator = $this->createMock( TempUserCreator::class );
		$mockTempUserCreator->method( 'isEnabled' )
			->willReturn( true );
		$mockTempUserCreator->method( 'acquireAndStashName' )
			->willReturn( null );
		$this->overrideMwServices(
			null,
			[
				'TempUserCreator' => static function () use ( $mockTempUserCreator ) {
					return $mockTempUserCreator;
				}
			]
		);
		$this->expectApiErrorCode( 'tempuseracquirefailed' );

		$this->doApiRequestWithToken(
			[
				'action' => 'acquiretempusername',
			],
			null,
			$this->mockAnonUltimateAuthority()
		);
	}

	public function testExecuteForSuccessfulCall() {
		ConvertibleTimestamp::setFakeTime( '20240405060708' );
		$this->enableAutoCreateTempUser( [
			'genPattern' => '~$1',
		] );

		$this->assertArrayEquals(
			[ 'acquiretempusername' => '~2024-1' ],
			$this->doApiRequestWithToken(
				[
					'action' => 'acquiretempusername',
				],
				null,
				$this->mockAnonUltimateAuthority()
			)[0],
			true,
			true
		);
	}
}
PK       ! m      api/ApiProtectTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;

/**
 * Tests for protect API.
 *
 * @group API
 * @group Database
 * @group medium
 *
 * @covers MediaWiki\Api\ApiProtect
 */
class ApiProtectTest extends ApiTestCase {

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

		$this->overrideConfigValue( MainConfigNames::WatchlistExpiry, true );
	}

	/**
	 * @covers MediaWiki\Api\ApiProtect::execute()
	 */
	public function testProtectWithWatch(): void {
		$title = Title::makeTitle( NS_MAIN, 'TestProtectWithWatch' );

		$this->editPage( $title, 'Some text' );

		$apiResult = $this->doApiRequestWithToken( [
			'action' => 'protect',
			'title' => $title->getPrefixedText(),
			'protections' => 'edit=sysop',
			'expiry' => '55550123000000',
			'watchlist' => 'watch',
			'watchlistexpiry' => '99990123000000',
		] )[0];

		$this->assertArrayHasKey( 'protect', $apiResult );
		$this->assertSame( $title->getPrefixedText(), $apiResult['protect']['title'] );
		$this->assertTrue( $this->getServiceContainer()->getRestrictionStore()->isProtected( $title, 'edit' ) );
		$this->assertTrue( $this->getServiceContainer()->getWatchlistManager()->isTempWatched(
			$this->getTestSysop()->getUser(),
			$title
		) );
	}
}
PK       ! |_8&  &    api/ApiBlockTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\Restriction\ActionRestriction;
use MediaWiki\Block\Restriction\NamespaceRestriction;
use MediaWiki\Block\Restriction\PageRestriction;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\UltimateAuthority;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\User\User;
use MediaWiki\User\UserRigorOptions;
use MediaWiki\Utils\MWTimestamp;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers \MediaWiki\Api\ApiBlock
 */
class ApiBlockTest extends ApiTestCase {
	use MockAuthorityTrait;

	/** @var User|null */
	protected $mUser = null;
	/** @var DatabaseBlockStore */
	private $blockStore;

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

		$this->mUser = $this->getMutableTestUser()->getUser();
		$this->overrideConfigValue(
			MainConfigNames::BlockCIDRLimit,
			[
				'IPv4' => 16,
				'IPv6' => 19,
			]
		);
		$this->blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
	}

	/**
	 * @param array $extraParams Extra API parameters to pass to doApiRequest
	 * @param Authority|null $blocker User to do the blocking, null to pick arbitrarily
	 * @return array result of doApiRequest
	 */
	private function doBlock( array $extraParams = [], ?Authority $blocker = null ) {
		$this->assertNotNull( $this->mUser );

		$params = [
			'action' => 'block',
			'user' => $this->mUser->getName(),
			'reason' => 'Some reason',
		];
		if ( array_key_exists( 'userid', $extraParams ) ) {
			// Make sure we don't have both user and userid
			unset( $params['user'] );
		}
		$ret = $this->doApiRequestWithToken( array_merge( $params, $extraParams ), null, $blocker );

		$block = $this->blockStore->newFromTarget( $this->mUser->getName() );

		$this->assertInstanceOf( DatabaseBlock::class, $block, 'Block is valid' );

		$this->assertSame( $this->mUser->getName(), $block->getTargetName() );
		$this->assertSame( 'Some reason', $block->getReasonComment()->text );

		return $ret;
	}

	/**
	 * Block by username
	 */
	public function testNormalBlock() {
		$this->doBlock();
	}

	/**
	 * Block by user ID
	 */
	public function testBlockById() {
		$this->doBlock( [ 'userid' => $this->mUser->getId() ] );
	}

	/**
	 * A blocked user can't block
	 */
	public function testBlockByBlockedUser() {
		$this->expectApiErrorCode( 'ipbblocked' );

		$blocked = $this->getMutableTestUser( [ 'sysop' ] )->getUser();
		$block = new DatabaseBlock( [
			'address' => $blocked->getName(),
			'by' => $this->getTestSysop()->getUser(),
			'reason' => 'Capriciousness',
			'timestamp' => '19370101000000',
			'expiry' => 'infinity',
		] );
		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );

		$this->doBlock( [], $blocked );
	}

	public function testBlockOfNonexistentUser() {
		$this->expectApiErrorCode( 'nosuchuser' );

		$this->doBlock( [ 'user' => 'Nonexistent' ] );
	}

	public function testBlockOfNonexistentUserId() {
		$id = 948206325;
		$this->expectApiErrorCode( 'nosuchuserid' );

		$this->assertNull( $this->getServiceContainer()->getUserIdentityLookup()->getUserIdentityByUserId( $id ) );

		$this->doBlock( [ 'userid' => $id ] );
	}

	public function testBlockWithTag() {
		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'custom tag' );

		$this->doBlock( [ 'tags' => 'custom tag' ] );

		$this->assertSame( 1, (int)$this->getDb()->newSelectQueryBuilder()
			->select( 'COUNT(*)' )
			->from( 'logging' )
			->join( 'change_tag', null, 'ct_log_id = log_id' )
			->join( 'change_tag_def', null, 'ctd_id = ct_tag_id' )
			->where( [ 'log_type' => 'block', 'ctd_name' => 'custom tag' ] )
			->caller( __METHOD__ )->fetchField() );
	}

	public function testBlockWithProhibitedTag() {
		$this->expectApiErrorCode( 'tags-apply-no-permission' );

		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'custom tag' );

		$this->overrideConfigValue(
			MainConfigNames::RevokePermissions,
			[ 'user' => [ 'applychangetags' => true ] ]
		);

		$this->doBlock( [ 'tags' => 'custom tag' ] );
	}

	public function testBlockWithHide() {
		$res = $this->doBlock(
			[ 'hidename' => '' ],
			new UltimateAuthority( $this->getTestSysop()->getUser() )
		);

		$this->assertSame( '1', $this->getDb()->newSelectQueryBuilder()
			->select( 'bl_deleted' )
			->from( 'block' )
			->where( [ 'bl_id' => $res[0]['block']['id'] ] )
			->caller( __METHOD__ )->fetchField() );
	}

	public function testBlockWithProhibitedHide() {
		$performer = $this->mockUserAuthorityWithoutPermissions(
			$this->getTestUser()->getUser(),
			[ 'hideuser' ]
		);
		$this->expectApiErrorCode( 'permissiondenied' );

		$this->doBlock( [ 'hidename' => '' ], $performer );
	}

	public function testBlockWithEmailBlock() {
		$this->overrideConfigValues( [
			MainConfigNames::EnableEmail => true,
			MainConfigNames::EnableUserEmail => true,
		] );

		$res = $this->doBlock( [ 'noemail' => '' ] );
		$this->assertSame( '1', $this->getDb()->newSelectQueryBuilder()
			->select( 'bl_block_email' )
			->from( 'block' )
			->where( [ 'bl_id' => $res[0]['block']['id'] ] )
			->caller( __METHOD__ )->fetchField() );
	}

	public function testBlockWithProhibitedEmailBlock() {
		$this->overrideConfigValues( [
			MainConfigNames::EnableEmail => true,
			MainConfigNames::EnableUserEmail => true,
			MainConfigNames::RevokePermissions => [ 'sysop' => [ 'blockemail' => true ] ],
		] );

		$this->expectApiErrorCode( 'cantblock-email' );
		$this->doBlock( [ 'noemail' => '' ] );
	}

	public function testBlockWithExpiry() {
		$fakeTime = 1616432035;
		MWTimestamp::setFakeTime( $fakeTime );
		$res = $this->doBlock( [ 'expiry' => '1 day' ] );
		$expiry = $this->getDb()->newSelectQueryBuilder()
			->select( 'bl_expiry' )
			->from( 'block' )
			->where( [ 'bl_id' => $res[0]['block']['id'] ] )
			->caller( __METHOD__ )->fetchField();
		$this->assertSame( (int)wfTimestamp( TS_UNIX, $expiry ), $fakeTime + 86400 );
	}

	public function testBlockWithInvalidExpiry() {
		$this->expectApiErrorCode( 'invalidexpiry' );

		$this->doBlock( [ 'expiry' => '' ] );
	}

	public function testBlockWithoutRestrictions() {
		$this->doBlock();

		$block = $this->blockStore->newFromTarget( $this->mUser->getName() );

		$this->assertTrue( $block->isSitewide() );
		$this->assertSame( [], $block->getRestrictions() );
	}

	public function testBlockWithRestrictionsPage() {
		$title = 'Foo';
		$this->getExistingTestPage( $title );

		$this->doBlock( [
			'partial' => true,
			'pagerestrictions' => $title,
			'allowusertalk' => true,
		] );

		$block = $this->blockStore->newFromTarget( $this->mUser->getName() );

		$this->assertFalse( $block->isSitewide() );
		$this->assertInstanceOf( PageRestriction::class, $block->getRestrictions()[0] );
		$this->assertEquals( $title, $block->getRestrictions()[0]->getTitle()->getText() );
	}

	public function testBlockWithRestrictionsNamespace() {
		$namespace = NS_TALK;

		$this->doBlock( [
			'partial' => true,
			'namespacerestrictions' => $namespace,
			'allowusertalk' => true,
		] );

		$block = $this->blockStore->newFromTarget( $this->mUser->getName() );

		$this->assertInstanceOf( NamespaceRestriction::class, $block->getRestrictions()[0] );
		$this->assertEquals( $namespace, $block->getRestrictions()[0]->getValue() );
	}

	public function testBlockWithRestrictionsAction() {
		$this->overrideConfigValue(
			MainConfigNames::EnablePartialActionBlocks,
			true
		);

		$blockActionInfo = $this->getServiceContainer()->getBlockActionInfo();
		$action = 'upload';

		$this->doBlock( [
			'partial' => true,
			'actionrestrictions' => $action,
			'allowusertalk' => true,
		] );

		$block = $this->blockStore->newFromTarget( $this->mUser->getName() );

		$this->assertInstanceOf( ActionRestriction::class, $block->getRestrictions()[0] );
		$this->assertEquals( $action, $blockActionInfo->getActionFromId( $block->getRestrictions()[0]->getValue() ) );
	}

	public function testBlockingActionWithNoToken() {
		$this->expectApiErrorCode( 'missingparam' );
		$this->doApiRequest(
			[
				'action' => 'block',
				'user' => $this->mUser->getName(),
				'reason' => 'Some reason',
			],
			null,
			false,
			$this->getTestSysop()->getUser()
		);
	}

	public function testBlockWithLargeRange() {
		$this->expectApiErrorCode( 'baduser' );
		$this->doApiRequestWithToken(
			[
				'action' => 'block',
				'user' => '127.0.0.1/64',
				'reason' => 'Some reason',
			],
			null,
			$this->getTestSysop()->getUser()
		);
	}

	public function testBlockingTooManyPageRestrictions() {
		$this->expectApiErrorCode( 'toomanyvalues' );
		$this->doApiRequestWithToken(
			[
				'action' => 'block',
				'user' => $this->mUser->getName(),
				'reason' => 'Some reason',
				'partial' => true,
				'pagerestrictions' => 'One|Two|Three|Four|Five|Six|Seven|Eight|Nine|Ten|Eleven',
			],
			null,
			$this->getTestSysop()->getUser()
		);
	}

	public function testRangeBlock() {
		$this->mUser = $this->getServiceContainer()->getUserFactory()->newFromName( '128.0.0.0/16', UserRigorOptions::RIGOR_NONE );
		$this->doBlock();
	}

	public function testVeryLargeRangeBlock() {
		$this->mUser = $this->getServiceContainer()->getUserFactory()->newFromName( '128.0.0.0/1', UserRigorOptions::RIGOR_NONE );
		$this->expectApiErrorCode( 'ip_range_toolarge' );
		$this->doBlock();
	}

	public function testBlockByIdReturns() {
		// See T189073 and Ifdced735b694b85116cb0e43dadbfa8e4cdb8cab for context
		$userId = $this->mUser->getId();

		$res = $this->doBlock(
			[ 'userid' => $userId ]
		);

		$blockResult = $res[0]['block'];

		$this->assertArrayHasKey( 'user', $blockResult );
		$this->assertSame( $this->mUser->getName(), $blockResult['user'] );

		$this->assertArrayHasKey( 'userID', $blockResult );
		$this->assertSame( $userId, $blockResult['userID'] );
	}
}
PK       ! =1v  v    api/ApiParseTest.phpnu Iw        <?php

/**
 * ApiParse check functions
 *
 * 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
 */

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiUsageException;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\TitleValue;
use MockPoolCounterFailing;
use SkinFactory;
use SkinFallback;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers MediaWiki\Api\ApiParse
 */
class ApiParseTest extends ApiTestCase {
	use DummyServicesTrait;

	/** @var int */
	protected static $pageId;
	/** @var int[] */
	protected static $revIds = [];

	public function addDBDataOnce() {
		$page = $this->getServiceContainer()->getWikiPageFactory()
			->newFromLinkTarget( new TitleValue( NS_MAIN, __CLASS__ ) );
		$status = $this->editPage( $page, 'Test for revdel' );
		self::$pageId = $status->getNewRevision()->getPageId();
		self::$revIds['revdel'] = $status->getNewRevision()->getId();

		$status = $this->editPage( $page, 'Test for suppressed' );
		self::$revIds['suppressed'] = $status->getNewRevision()->getId();

		$status = $this->editPage( $page, 'Test for oldid' );
		self::$revIds['oldid'] = $status->getNewRevision()->getId();

		$status = $this->editPage( $page, 'Test for latest' );
		self::$revIds['latest'] = $status->getNewRevision()->getId();

		// Set a user for modifying the visibility, this is needed because
		// setVisibility generates a log, which cannot be an anonymous user actor
		// when temporary accounts are enabled.
		RequestContext::getMain()->setUser( $this->getTestUser()->getUser() );
		$this->revisionDelete( self::$revIds['revdel'] );
		$this->revisionDelete(
			self::$revIds['suppressed'],
			[
				RevisionRecord::DELETED_TEXT => 1,
				RevisionRecord::DELETED_RESTRICTED => 1
			]
		);
	}

	/**
	 * Assert that the given result of calling $this->doApiRequest() with
	 * action=parse resulted in $html, accounting for the boilerplate that the
	 * parser adds around the parsed page.  Also asserts that warnings match
	 * the provided $warning.
	 *
	 * @param string $expected Expected HTML
	 * @param array $res Returned from doApiRequest()
	 * @param string|null $warnings Exact value of expected warnings, null for
	 *   no warnings
	 */
	protected function assertParsedTo( $expected, array $res, $warnings = null ) {
		$this->doAssertParsedTo( $expected, $res, $warnings, [ $this, 'assertSame' ] );
	}

	/**
	 * Same as above, but asserts that the HTML matches a regexp instead of a
	 * literal string match.
	 *
	 * @param string $expected Expected HTML
	 * @param array $res Returned from doApiRequest()
	 * @param string|null $warnings Exact value of expected warnings, null for
	 *   no warnings
	 */
	protected function assertParsedToRegExp( $expected, array $res, $warnings = null ) {
		$this->doAssertParsedTo( $expected, $res, $warnings, [ $this, 'assertMatchesRegularExpression' ] );
	}

	private function doAssertParsedTo( $expected, array $res, $warnings, callable $callback ) {
		$html = $res[0]['parse']['text'];

		$expectedStart = '<div class="mw-content-ltr mw-parser-output" lang="en" dir="ltr"';
		$this->assertSame( $expectedStart, substr( $html, 0, strlen( $expectedStart ) ) );

		$html = substr( $html, strlen( $expectedStart ) );

		# Parsoid-based transformations may add ID and data-mw-parsoid-version
		# attributes to the wrapper div
		$possibleIdAttr = '/^( (id|data-mw[^=]*)="[^"]+")*>/';
		$html = preg_replace( $possibleIdAttr, '', $html );

		$possibleParserCache = '/\n<!-- Saved in (?>parser cache|RevisionOutputCache) (?>.*?\n -->)\n/';
		$html = preg_replace( $possibleParserCache, '', $html );

		if ( $res[1]->getBool( 'disablelimitreport' ) ) {
			$expectedEnd = "</div>";
			$this->assertSame( $expectedEnd, substr( $html, -strlen( $expectedEnd ) ) );

			$unexpectedEnd = '#<!-- \nNewPP limit report|' .
				'<!--\nTransclusion expansion time report#';
			$this->assertDoesNotMatchRegularExpression( $unexpectedEnd, $html );

			$html = substr( $html, 0, strlen( $html ) - strlen( $expectedEnd ) );
		} else {
			$expectedEnd = '#\n<!-- \nNewPP limit report\n(?>.+?\n-->)\n' .
				'<!--\nTransclusion expansion time report \(%,ms,calls,template\)\n(?>.*?\n-->)\n' .
				'</div>$#s';
			$this->assertMatchesRegularExpression( $expectedEnd, $html );

			$html = preg_replace( $expectedEnd, '', $html );
		}

		$callback( $expected, $html );

		if ( $warnings === null ) {
			$this->assertCount( 1, $res[0] );
		} else {
			$this->assertCount( 2, $res[0] );
			$this->assertSame( [ 'warnings' => $warnings ], $res[0]['warnings']['parse'] );
		}
	}

	/**
	 * Set up an interwiki entry for testing.
	 */
	protected function setupInterwiki() {
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'interwiki' )
			->ignore()
			->row( [
				'iw_prefix' => 'madeuplanguage',
				'iw_url' => "https://example.com/wiki/$1",
				'iw_api' => '',
				'iw_wikiid' => '',
				'iw_local' => false,
			] )
			// This deliberately conflicts with the Talk namespace
			// (T204792/T363538)
			->row( [
				'iw_prefix' => 'talk',
				'iw_url' => "https://talk.example.com/wiki/$1",
				'iw_api' => '',
				'iw_wikiid' => '',
				'iw_local' => false,
			] )
			->caller( __METHOD__ )
			->execute();

		$this->overrideConfigValue(
			MainConfigNames::ExtraInterlanguageLinkPrefixes,
			[ 'madeuplanguage', 'talk' ]
		);
	}

	/**
	 * Set up a skin for testing.
	 *
	 * @todo Should this code be in MediaWikiIntegrationTestCase or something?
	 */
	protected function setupSkin() {
		$factory = new SkinFactory( $this->getDummyObjectFactory(), [] );
		$factory->register( 'testing', 'Testing', function () {
			$skin = $this->getMockBuilder( SkinFallback::class )
				->onlyMethods( [ 'getDefaultModules' ] )
				->getMock();
			$skin->expects( $this->once() )->method( 'getDefaultModules' )
				->willReturn( [
					'styles' => [ 'core' => [ 'quux.styles' ] ],
					'core' => [ 'foo', 'bar' ],
					'content' => [ 'baz' ]
				] );
			return $skin;
		} );
		$this->setService( 'SkinFactory', $factory );
	}

	public function testParseByName() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'page' => __CLASS__,
		] );
		$this->assertParsedTo( "<p>Test for latest\n</p>", $res );

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'page' => __CLASS__,
			'disablelimitreport' => 1,
		] );
		$this->assertParsedTo( "<p>Test for latest\n</p>", $res );
	}

	public function testParseById() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'pageid' => self::$pageId,
		] );
		$this->assertParsedTo( "<p>Test for latest\n</p>", $res );
	}

	public function testParseByOldId() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'oldid' => self::$revIds['oldid'],
		] );
		$this->assertParsedTo( "<p>Test for oldid\n</p>", $res );
		$this->assertArrayNotHasKey( 'textdeleted', $res[0]['parse'] );
		$this->assertArrayNotHasKey( 'textsuppressed', $res[0]['parse'] );
	}

	public function testRevDel() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'oldid' => self::$revIds['revdel'],
		] );

		$this->assertParsedTo( "<p>Test for revdel\n</p>", $res );
		$this->assertArrayHasKey( 'textdeleted', $res[0]['parse'] );
		$this->assertArrayNotHasKey( 'textsuppressed', $res[0]['parse'] );
	}

	public function testRevDelNoPermission() {
		$this->expectApiErrorCode( 'permissiondenied' );

		$this->doApiRequest( [
			'action' => 'parse',
			'oldid' => self::$revIds['revdel'],
		], null, null, static::getTestUser()->getAuthority() );
	}

	public function testSuppressed() {
		$this->setGroupPermissions( 'sysop', 'viewsuppressed', true );

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'oldid' => self::$revIds['suppressed']
		] );

		$this->assertParsedTo( "<p>Test for suppressed\n</p>", $res );
		$this->assertArrayHasKey( 'textsuppressed', $res[0]['parse'] );
		$this->assertArrayHasKey( 'textdeleted', $res[0]['parse'] );
	}

	public function testNonexistentPage() {
		try {
			$this->doApiRequest( [
				'action' => 'parse',
				'page' => 'DoesNotExist',
			] );

			$this->fail( "API did not return an error when parsing a nonexistent page" );
		} catch ( ApiUsageException $ex ) {
			$this->assertApiErrorCode( 'missingtitle', $ex );
		}
	}

	public function testTitleProvided() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => 'Some interesting page',
			'text' => '{{PAGENAME}} has attracted my attention',
		] );

		$this->assertParsedTo( "<p>Some interesting page has attracted my attention\n</p>", $res );
	}

	public function testSection() {
		$name = ucfirst( __FUNCTION__ );

		$this->editPage( $name,
			"Intro\n\n== Section 1 ==\n\nContent 1\n\n== Section 2 ==\n\nContent 2" );

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'page' => $name,
			'section' => 1,
		] );

		$this->assertParsedToRegExp( '!<h2[^>]*>.*Section 1.*</h2>.*\n<p>Content 1\n</p>!', $res );
	}

	public function testInvalidSection() {
		$this->expectApiErrorCode( 'invalidsection' );

		$this->doApiRequest( [
			'action' => 'parse',
			'section' => 'T-new',
		] );
	}

	public function testSectionNoContent() {
		$name = ucfirst( __FUNCTION__ );

		$status = $this->editPage( $name,
			"Intro\n\n== Section 1 ==\n\nContent 1\n\n== Section 2 ==\n\nContent 2" );

		$this->expectApiErrorCode( 'missingcontent-pageid' );

		$this->getDb()->newDeleteQueryBuilder()
			->deleteFrom( 'revision' )
			->where( [ 'rev_id' => $status->getNewRevision()->getId() ] )
			->caller( __METHOD__ )
			->execute();

		// Ignore warning from WikiPage::getContentModel
		@$this->doApiRequest( [
			'action' => 'parse',
			'page' => $name,
			'section' => 1,
		] );
	}

	public function testNewSectionWithPage() {
		$this->expectApiErrorCode( 'invalidparammix' );

		$this->doApiRequest( [
			'action' => 'parse',
			'page' => __CLASS__,
			'section' => 'new',
		] );
	}

	public function testNonexistentOldId() {
		$this->expectApiErrorCode( 'nosuchrevid' );

		$this->doApiRequest( [
			'action' => 'parse',
			'oldid' => pow( 2, 31 ) - 1,
		] );
	}

	public function testUnfollowedRedirect() {
		$name = ucfirst( __FUNCTION__ );

		$this->editPage( $name, "#REDIRECT [[$name 2]]" );
		$this->editPage( "$name 2", "Some ''text''" );

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'page' => $name,
		] );

		// Can't use assertParsedTo because the parser output is different for
		// redirects
		$this->assertMatchesRegularExpression( "/Redirect to:.*$name 2/", $res[0]['parse']['text'] );
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testFollowedRedirect() {
		$name = ucfirst( __FUNCTION__ );

		$this->editPage( $name, "#REDIRECT [[$name 2]]" );
		$this->editPage( "$name 2", "Some ''text''" );

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'page' => $name,
			'redirects' => true,
		] );

		$this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res );
	}

	public function testFollowedRedirectById() {
		$name = ucfirst( __FUNCTION__ );

		$id = $this->editPage( $name, "#REDIRECT [[$name 2]]" )
			->getNewRevision()->getPageId();
		$this->editPage( "$name 2", "Some ''text''" );

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'pageid' => $id,
			'redirects' => true,
		] );

		$this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res );
	}

	public function testNonRedirectOk() {
		$name = ucfirst( __FUNCTION__ );

		$this->editPage( $name, "Some ''text''" );

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'page' => $name,
			'redirects' => true,
		] );

		$this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res );
	}

	public function testNonRedirectByIdOk() {
		$name = ucfirst( __FUNCTION__ );

		$id = $this->editPage( $name, "Some ''text''" )->getNewRevision()->getPageId();

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'pageid' => $id,
			'redirects' => true,
		] );

		$this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res );
	}

	public function testInvalidTitle() {
		$this->expectApiErrorCode( 'invalidtitle' );

		$this->doApiRequest( [
			'action' => 'parse',
			'title' => '|',
		] );
	}

	public function testTitleWithNonexistentRevId() {
		$this->expectApiErrorCode( 'nosuchrevid' );

		$this->doApiRequest( [
			'action' => 'parse',
			'title' => __CLASS__,
			'revid' => pow( 2, 31 ) - 1,
		] );
	}

	public function testTitleWithNonMatchingRevId() {
		$name = ucfirst( __FUNCTION__ );

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => $name,
			'revid' => self::$revIds['latest'],
			'text' => 'Some text',
		] );

		$this->assertParsedTo( "<p>Some text\n</p>", $res,
			'r' . self::$revIds['latest'] . " is not a revision of $name." );
	}

	public function testRevId() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'revid' => self::$revIds['latest'],
			'text' => 'My revid is {{REVISIONID}}!',
		] );

		$this->assertParsedTo( "<p>My revid is " . self::$revIds['latest'] . "!\n</p>", $res );
	}

	public function testTitleNoText() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => 'Special:AllPages',
		] );

		$this->assertParsedTo( '', $res,
			'"title" used without "text", and parsed page properties were requested. ' .
				'Did you mean to use "page" instead of "title"?' );
	}

	public function testRevidNoText() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'revid' => self::$revIds['latest'],
		] );

		$this->assertParsedTo( '', $res,
			'"revid" used without "text", and parsed page properties were requested. ' .
				'Did you mean to use "oldid" instead of "revid"?' );
	}

	public function testTextNoContentModel() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'text' => "Some ''text''",
		] );

		$this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res,
			'No "title" or "contentmodel" was given, assuming wikitext.' );
	}

	public function testSerializationError() {
		$this->expectApiErrorCode( 'parseerror' );

		$this->mergeMwGlobalArrayValue( 'wgContentHandlers',
			[ 'testing-serialize-error' => 'DummySerializeErrorContentHandler' ] );

		$this->doApiRequest( [
			'action' => 'parse',
			'text' => "Some ''text''",
			'contentmodel' => 'testing-serialize-error',
		] );
	}

	public function testNewSection() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => __CLASS__,
			'section' => 'new',
			'sectiontitle' => 'Title',
			'text' => 'Content',
		] );

		$this->assertParsedToRegExp( '!<h2[^>]*>.*Title.*</h2>.*\n<p>Content\n</p>!', $res );
	}

	public function testExistingSection() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => __CLASS__,
			'section' => 1,
			'text' => "Intro\n\n== Section 1 ==\n\nContent\n\n== Section 2 ==\n\nMore content",
		] );

		$this->assertParsedToRegExp( '!<h2[^>]*>.*Section 1.*</h2>.*\n<p>Content\n</p>!', $res );
	}

	public function testNoPst() {
		$name = ucfirst( __FUNCTION__ );

		$this->editPage( "Template:$name", "Template ''text''" );

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'text' => "{{subst:$name}}",
			'contentmodel' => 'wikitext',
		] );

		$this->assertParsedTo( "<p>{{subst:$name}}\n</p>", $res );
	}

	public function testPst() {
		$name = ucfirst( __FUNCTION__ );

		$this->editPage( "Template:$name", "Template ''text''" );

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'pst' => '',
			'text' => "{{subst:$name}}",
			'contentmodel' => 'wikitext',
			'prop' => 'text|wikitext',
		] );

		$this->assertParsedTo( "<p>Template <i>text</i>\n</p>", $res );
		$this->assertSame( "{{subst:$name}}", $res[0]['parse']['wikitext'] );
	}

	public function testOnlyPst() {
		$name = ucfirst( __FUNCTION__ );

		$this->editPage( "Template:$name", "Template ''text''" );

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'onlypst' => '',
			'text' => "{{subst:$name}}",
			'contentmodel' => 'wikitext',
			'prop' => 'text|wikitext',
			'summary' => 'Summary',
		] );

		$this->assertSame(
			[ 'parse' => [
				'text' => "Template ''text''",
				'wikitext' => "{{subst:$name}}",
				'parsedsummary' => 'Summary',
			] ],
			$res[0]
		);
	}

	/** @dataProvider providerTestParsoid */
	public function testParsoid( $parsoid, $existing, $expected ) {
		# For simplicity, ensure that [[Foo]] isn't a redlink.
		$this->editPage( "Foo", __FUNCTION__ );
		$res = $this->doApiRequest( [
			# check that we're using the contents of 'text' not the contents of
			# [[<title>]] by using pre-existing title __CLASS__ sometimes
			'title' => $existing ? __CLASS__ : 'Bar',
			'action' => 'parse',
			'text' => "[[Foo]]",
			'contentmodel' => 'wikitext',
			'parsoid' => $parsoid ?: null,
			'disablelimitreport' => true,
		] );

		$this->assertParsedToRegexp( $expected, $res );
	}

	public static function providerTestParsoid() {
		// Legacy parses, with and without pre-existing content.
		$expected = '!^<p><a href="[^"]*" title="Foo">Foo</a>\n</p>$!';
		yield [ false, false, $expected ];
		yield [ false, true, $expected ];
		// Parsoid parses, with and without pre-existing content.
		$expected = '!^<section[^>]*><p[^>]*><a rel="mw:WikiLink" href="[^"]*Foo" title="Foo"[^>]*>Foo</a></p></section>!';
		yield [ true, false, $expected ];
		yield [ true, true, $expected ];
	}

	/** @dataProvider providerTestParsoid */
	public function testUseArticle( $parsoid, $existing, $expected ) {
		# For simplicity, ensure that [[Foo]] isn't a redlink.
		$this->editPage( "Foo", __FUNCTION__ );
		# Use an ArticleParserOptions hook to set the useParsoid option
		$this->setTemporaryHook( 'ArticleParserOptions',
			static function ( $unused, $po ) use ( $parsoid ) {
				if ( $parsoid ) {
					$po->setUseParsoid();
				}
			}
		);

		$res = $this->doApiRequest( [
			# check that we're using the contents of 'text' not the contents of
			# [[<title>]] by using pre-existing title __CLASS__ sometimes
			'title' => $existing ? __CLASS__ : 'Bar',
			'action' => 'parse',
			'text' => "[[Foo]]",
			'contentmodel' => 'wikitext',
			'usearticle' => true,
			# Note that we're not passing the 'parsoid' parameter here.
			'disablelimitreport' => true,
		] );

		$this->assertParsedToRegexp( $expected, $res );
	}

	public function testHeadHtml() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'page' => __CLASS__,
			'prop' => 'headhtml',
		] );

		// Just do a rough check
		$this->assertMatchesRegularExpression( '#<!DOCTYPE.*<html.*<head.*</head>.*<body#s',
			$res[0]['parse']['headhtml'] );
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testCategoriesHtml() {
		$name = ucfirst( __FUNCTION__ );

		$this->editPage( $name, "[[Category:$name]]" );

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'page' => $name,
			'prop' => 'categorieshtml',
		] );

		$this->assertMatchesRegularExpression( "#Category.*Category:$name.*$name#",
			$res[0]['parse']['categorieshtml'] );
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testEffectiveLangLinks() {
		$hookRan = false;
		$this->setTemporaryHook( 'LanguageLinks',
			static function () use ( &$hookRan ) {
				$hookRan = true;
			}
		);

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => __CLASS__,
			'text' => '[[zh:' . __CLASS__ . ']]',
			'effectivelanglinks' => '',
		] );

		$this->assertTrue( $hookRan );
		$this->assertSame( 'The parameter "effectivelanglinks" has been deprecated.',
			$res[0]['warnings']['parse']['warnings'] );
	}

	/**
	 * @param array $arr Extra params to add to API request
	 */
	private function doTestLangLinks( array $arr = [] ) {
		$this->setTemporaryHook( 'ParserAfterParse',
			static function ( $parser ) {
				$parserOutput = $parser->getOutput();
				$parserOutput->addLanguageLink( 'talk:Page' ); // T363538
			}
		);
		$res = $this->doApiRequest( array_merge( [
			'action' => 'parse',
			'title' => 'Omelette',
			'text' => '[[madeuplanguage:Omelette]]',
			'prop' => 'langlinks',
		], $arr ) );

		$langLinks = $res[0]['parse']['langlinks'];

		$this->assertCount( 2, $langLinks );
		$this->assertSame( 'madeuplanguage', $langLinks[0]['lang'] );
		$this->assertSame( 'Omelette', $langLinks[0]['title'] );
		$this->assertSame( 'https://example.com/wiki/Omelette', $langLinks[0]['url'] );
		$this->assertSame( 'talk', $langLinks[1]['lang'] );
		$this->assertSame( 'Page', $langLinks[1]['title'] );
		$this->assertSame( 'https://talk.example.com/wiki/Page', $langLinks[1]['url'] );
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testLangLinks() {
		$this->setupInterwiki();
		$this->doTestLangLinks();
	}

	public function testLangLinksWithSkin() {
		$this->setupInterwiki();
		$this->setupSkin();
		$this->doTestLangLinks( [ 'useskin' => 'testing' ] );
	}

	public function testHeadItems() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => __CLASS__,
			'text' => '',
			'prop' => 'headitems',
		] );

		$this->assertSame( [], $res[0]['parse']['headitems'] );
		$this->assertSame(
			'"prop=headitems" is deprecated since MediaWiki 1.28. ' .
				'Use "prop=headhtml" when creating new HTML documents, ' .
				'or "prop=modules|jsconfigvars" when updating a document client-side.',
			$res[0]['warnings']['parse']['warnings']
		);
	}

	public function testHeadItemsWithSkin() {
		$this->setupSkin();

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => __CLASS__,
			'text' => '',
			'prop' => 'headitems',
			'useskin' => 'testing',
		] );

		$this->assertSame( [], $res[0]['parse']['headitems'] );
		$this->assertSame(
			'"prop=headitems" is deprecated since MediaWiki 1.28. ' .
				'Use "prop=headhtml" when creating new HTML documents, ' .
				'or "prop=modules|jsconfigvars" when updating a document client-side.',
			$res[0]['warnings']['parse']['warnings']
		);
	}

	public function testModules() {
		$this->setTemporaryHook( 'ParserAfterParse',
			static function ( $parser ) {
				$parserOutput = $parser->getOutput();
				$parserOutput->addModules( [ 'foo', 'bar' ] );
				$parserOutput->addModuleStyles( [ 'aaa', 'zzz' ] );
				$parserOutput->setJsConfigVar( 'x', 'y' );
				$parserOutput->setJsConfigVar( 'z', -3 );
			}
		);
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => __CLASS__,
			'text' => 'Content',
			'prop' => 'modules|jsconfigvars|encodedjsconfigvars',
		] );

		$this->assertSame( [ 'foo', 'bar' ], $res[0]['parse']['modules'] );
		$this->assertSame( [], $res[0]['parse']['modulescripts'] );
		$this->assertSame( [ 'aaa', 'zzz' ], $res[0]['parse']['modulestyles'] );
		$this->assertSame( [ 'x' => 'y', 'z' => -3 ], $res[0]['parse']['jsconfigvars'] );
		$this->assertSame( '{"x":"y","z":-3}', $res[0]['parse']['encodedjsconfigvars'] );
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testModulesWithSkin() {
		$this->setupSkin();

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'pageid' => self::$pageId,
			'useskin' => 'testing',
			'prop' => 'modules',
		] );
		$this->assertSame(
			[ 'foo', 'bar', 'baz' ],
			$res[0]['parse']['modules'],
			'resp.parse.modules'
		);
		$this->assertSame(
			[],
			$res[0]['parse']['modulescripts'],
			'resp.parse.modulescripts'
		);
		$this->assertSame(
			[ 'quux.styles' ],
			$res[0]['parse']['modulestyles'],
			'resp.parse.modulestyles'
		);
		$this->assertSame(
			[ 'parse' =>
				[ 'warnings' =>
					'Property "modules" was set but not "jsconfigvars" or ' .
					'"encodedjsconfigvars". Configuration variables are necessary for ' .
					'proper module usage.'
				]
			],
			$res[0]['warnings']
		);
	}

	public function testIndicators() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => __CLASS__,
			'text' =>
				'<indicator name="b">BBB!</indicator>Some text<indicator name="a">aaa</indicator>',
			'prop' => 'indicators',
		] );

		$this->assertSame(
			// It seems we return in markup order and not display order
			[ 'b' => 'BBB!', 'a' => 'aaa' ],
			$res[0]['parse']['indicators']
		);
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testIndicatorsWithSkin() {
		$this->setupSkin();

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => __CLASS__,
			'text' =>
				'<indicator name="b">BBB!</indicator>Some text<indicator name="a">aaa</indicator>',
			'prop' => 'indicators',
			'useskin' => 'testing',
		] );

		$this->assertSame(
			// Now we return in display order rather than markup order
			[
				'a' => '<div class="mw-parser-output">aaa</div>',
				'b' => '<div class="mw-parser-output">BBB!</div>',
			],
			$res[0]['parse']['indicators']
		);
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testIwlinks() {
		$this->setupInterwiki();

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => 'Omelette',
			'text' => '[[:madeuplanguage:Omelette]][[madeuplanguage:Spaghetti]]',
			'prop' => 'iwlinks',
		] );

		$iwlinks = $res[0]['parse']['iwlinks'];

		$this->assertCount( 1, $iwlinks );
		$this->assertSame( 'madeuplanguage', $iwlinks[0]['prefix'] );
		$this->assertSame( 'https://example.com/wiki/Omelette', $iwlinks[0]['url'] );
		$this->assertSame( 'madeuplanguage:Omelette', $iwlinks[0]['title'] );
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testLimitReports() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'pageid' => self::$pageId,
			'prop' => 'limitreportdata|limitreporthtml',
		] );

		// We don't bother testing the actual values here
		$this->assertIsArray( $res[0]['parse']['limitreportdata'] );
		$this->assertIsString( $res[0]['parse']['limitreporthtml'] );
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testParseTreeNonWikitext() {
		$this->expectApiErrorCode( 'notwikitext' );

		$this->doApiRequest( [
			'action' => 'parse',
			'text' => '',
			'contentmodel' => 'json',
			'prop' => 'parsetree',
		] );
	}

	public function testParseTree() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'text' => "Some ''text'' is {{nice|to have|i=think}}",
			'contentmodel' => 'wikitext',
			'prop' => 'parsetree',
		] );

		$this->assertEquals(
			'<root>Some \'\'text\'\' is <template><title>nice</title>' .
				'<part><name index="1"/><value>to have</value></part>' .
				'<part><name>i</name><equals>=</equals><value>think</value></part>' .
				'</template></root>',
			$res[0]['parse']['parsetree']
		);
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testFormatCategories() {
		$name = ucfirst( __FUNCTION__ );

		$this->editPage( "Category:$name", 'Content' );
		$this->editPage( 'Category:Hidden', '__HIDDENCAT__' );

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => __CLASS__,
			'text' => "[[Category:$name]][[Category:Foo|Sort me]][[Category:Hidden]]",
			'prop' => 'categories',
		] );

		$this->assertSame(
			[ [ 'sortkey' => '', 'category' => $name ],
				[ 'sortkey' => 'Sort me', 'category' => 'Foo', 'missing' => true ],
				[ 'sortkey' => '', 'category' => 'Hidden', 'hidden' => true ] ],
			$res[0]['parse']['categories']
		);
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testConcurrentLimitPageParse() {
		$this->overrideConfigValue(
			MainConfigNames::PoolCounterConf,
			[
				'ApiParser' => [
					'class' => MockPoolCounterFailing::class,
				]
			]
		);

		try {
			$this->doApiRequest( [
				'action' => 'parse',
				'page' => __CLASS__,
			] );
			$this->fail( "API did not return an error when concurrency exceeded" );
		} catch ( ApiUsageException $ex ) {
			$this->assertApiErrorCode( 'concurrency-limit', $ex );
		}
	}

	public function testConcurrentLimitContentParse() {
		$this->overrideConfigValue(
			MainConfigNames::PoolCounterConf,
			[
				'ApiParser' => [
					'class' => MockPoolCounterFailing::class,
				]
			]
		);

		try {
			$this->doApiRequest( [
				'action' => 'parse',
				'oldid' => self::$revIds['revdel'],
			] );
			$this->fail( "API did not return an error when concurrency exceeded" );
		} catch ( ApiUsageException $ex ) {
			$this->assertApiErrorCode( 'concurrency-limit', $ex );
		}
	}

	public function testDisplayTitle() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => 'Art&copy',
			'text' => '{{DISPLAYTITLE:art&copy}}foo',
			'prop' => 'displaytitle',
		] );

		$this->assertSame(
			'art&amp;copy',
			$res[0]['parse']['displaytitle']
		);

		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => 'Art&copy',
			'text' => 'foo',
			'prop' => 'displaytitle',
		] );

		$this->assertSame(
			'<span class="mw-page-title-main">Art&amp;copy</span>',
			$res[0]['parse']['displaytitle']
		);
	}

	public function testIncompatFormat() {
		$this->expectApiErrorCode( 'badformat-generic' );

		$this->doApiRequest( [
			'action' => 'parse',
			'prop' => 'categories',
			'title' => __CLASS__,
			'text' => '',
			'contentformat' => 'application/json',
		] );
	}

	public function testIgnoreFormatUsingPage() {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'page' => __CLASS__,
			'prop' => 'wikitext',
			'contentformat' => 'text/plain',
		] );
		$this->assertArrayHasKey( 'wikitext', $res[0]['parse'] );
	}

	public function testShouldCastNumericImageLinksToString(): void {
		$res = $this->doApiRequest( [
			'action' => 'parse',
			'title' => __CLASS__,
			'prop' => 'images',
			'text' => '[[File:1]]',
		] );
		$this->assertSame( [ '1' ], $res[0]['parse']['images'] );
	}
}
PK       ! 8`n(  (    api/ApiUploadTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use LocalRepo;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\Authority;
use MediaWiki\Title\Title;
use MediaWiki\WikiMap\WikiMap;
use RepoGroup;
use Wikimedia\FileBackend\FSFileBackend;
use Wikimedia\Mime\MimeAnalyzer;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers MediaWiki\Api\ApiUpload
 */
class ApiUploadTest extends ApiUploadTestCase {
	private ?Authority $uploader = null;

	private function filePath( $fileName ) {
		return __DIR__ . '/../../data/media/' . $fileName;
	}

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

		$this->setService( 'RepoGroup', new RepoGroup(
			[
				'class' => LocalRepo::class,
				'name' => 'temp',
				'backend' => new FSFileBackend( [
					'name' => 'temp-backend',
					'wikiId' => WikiMap::getCurrentWikiId(),
					'basePath' => $this->getNewTempDirectory()
				] )
			],
			[],
			$this->getServiceContainer()->getMainWANObjectCache(),
			$this->createMock( MimeAnalyzer::class )
		) );

		$this->overrideConfigValue( MainConfigNames::WatchlistExpiry, true );
		$this->uploader = $this->getTestUser()->getAuthority();
	}

	public function testUploadRequiresToken() {
		$this->expectApiErrorCode( 'missingparam' );
		$this->doApiRequest( [
			'action' => 'upload'
		] );
	}

	public function testUploadMissingParams() {
		$this->expectApiErrorCode( 'missingparam' );
		$this->doApiRequestWithToken( [
			'action' => 'upload',
		], null, $this->uploader );
	}

	public function testUploadWithWatch() {
		$mimeType = 'image/jpeg';
		$filePath = $this->filePath( 'yuv420.jpg' );
		$title = Title::makeTitle( NS_FILE, 'TestUpload.jpg' );
		$user = $this->uploader;

		$this->fakeUploadFile( 'file', $title->getText(), $mimeType, $filePath );
		[ $result ] = $this->doApiRequestWithToken( [
			'action' => 'upload',
			'filename' => $title->getText(),
			'file' => 'dummy content',
			'comment' => 'dummy comment',
			'text' => "This is the page text for {$title->getText()}",
			'watchlist' => 'watch',
			'watchlistexpiry' => '99990123000000',
		], null, $user );

		$this->assertArrayHasKey( 'upload', $result );
		$this->assertEquals( 'Success', $result['upload']['result'] );
		$this->assertSame( filesize( $filePath ), (int)$result['upload']['imageinfo']['size'] );
		$this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] );
		$this->assertTrue( $this->getServiceContainer()->getWatchlistManager()->isTempWatched( $user, $title ) );
	}

	public function testUploadZeroLength() {
		$filePath = $this->getNewTempFile();
		$mimeType = 'image/jpeg';
		$fileName = "ApiTestUploadZeroLength.jpg";

		$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath );

		$this->expectApiErrorCode( 'empty-file' );
		$this->doApiRequestWithToken( [
			'action' => 'upload',
			'filename' => $fileName,
			'file' => 'dummy content',
			'comment' => 'dummy comment',
			'text' => "This is the page text for $fileName",
		], null, $this->uploader );
	}

	public function testUploadSameFileName() {
		$fileName = 'TestUploadSameFileName.jpg';
		$mimeType = 'image/jpeg';
		$filePaths = [
			$this->filePath( 'yuv420.jpg' ),
			$this->filePath( 'yuv444.jpg' )
		];

		// we reuse these params
		$params = [
			'action' => 'upload',
			'filename' => $fileName,
			'file' => 'dummy content',
			'comment' => 'dummy comment',
			'text' => "This is the page text for $fileName",
		];

		// first upload .... should succeed

		$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePaths[0] );
		[ $result ] = $this->doApiRequestWithToken( $params, null,
			$this->uploader );
		$this->assertArrayHasKey( 'upload', $result );
		$this->assertEquals( 'Success', $result['upload']['result'] );

		// second upload with the same name (but different content)

		$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePaths[1] );
		[ $result ] = $this->doApiRequestWithToken( $params, null,
			$this->uploader );
		$this->assertArrayHasKey( 'upload', $result );
		$this->assertEquals( 'Warning', $result['upload']['result'] );
		$this->assertArrayHasKey( 'warnings', $result['upload'] );
		$this->assertArrayHasKey( 'exists', $result['upload']['warnings'] );
	}

	public function testUploadSameContent() {
		$fileNames = [ 'TestUploadSameContent_1.jpg', 'TestUploadSameContent_2.jpg' ];
		$mimeType = 'image/jpeg';
		$filePath = $this->filePath( 'yuv420.jpg' );

		// first upload .... should succeed
		$this->fakeUploadFile( 'file', $fileNames[0], $mimeType, $filePath );
		[ $result ] = $this->doApiRequestWithToken( [
			'action' => 'upload',
			'filename' => $fileNames[0],
			'file' => 'dummy content',
			'comment' => 'dummy comment',
			'text' => "This is the page text for {$fileNames[0]}",
		], null, $this->uploader );
		$this->assertArrayHasKey( 'upload', $result );
		$this->assertEquals( 'Success', $result['upload']['result'] );

		// second upload with the same content (but different name)
		$this->fakeUploadFile( 'file', $fileNames[1], $mimeType, $filePath );
		[ $result ] = $this->doApiRequestWithToken( [
			'action' => 'upload',
			'filename' => $fileNames[1],
			'file' => 'dummy content',
			'comment' => 'dummy comment',
			'text' => "This is the page text for {$fileNames[1]}",
		], null, $this->uploader );

		$this->assertArrayHasKey( 'upload', $result );
		$this->assertEquals( 'Warning', $result['upload']['result'] );
		$this->assertArrayHasKey( 'warnings', $result['upload'] );
		$this->assertArrayHasKey( 'duplicate', $result['upload']['warnings'] );
		$this->assertArrayEquals( [ $fileNames[0] ], $result['upload']['warnings']['duplicate'] );
		$this->assertArrayNotHasKey( 'exists', $result['upload']['warnings'] );
	}

	public function testUploadStash() {
		$fileName = 'TestUploadStash.jpg';
		$mimeType = 'image/jpeg';
		$filePath = $this->filePath( 'yuv420.jpg' );

		$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath );
		[ $result ] = $this->doApiRequestWithToken( [
			'action' => 'upload',
			'stash' => 1,
			'filename' => $fileName,
			'file' => 'dummy content',
			'comment' => 'dummy comment',
			'text' => "This is the page text for $fileName",
		], null, $this->uploader );

		$this->assertArrayHasKey( 'upload', $result );
		$this->assertEquals( 'Success', $result['upload']['result'] );
		$this->assertSame( filesize( $filePath ), (int)$result['upload']['imageinfo']['size'] );
		$this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] );
		$this->assertArrayHasKey( 'filekey', $result['upload'] );
		$this->assertEquals( $result['upload']['sessionkey'], $result['upload']['filekey'] );
		$filekey = $result['upload']['filekey'];

		// it should be visible from Special:UploadStash
		// XXX ...but how to test this, with a fake WebRequest with the session?

		// now we should try to release the file from stash
		$this->clearFakeUploads();
		[ $result ] = $this->doApiRequestWithToken( [
			'action' => 'upload',
			'filekey' => $filekey,
			'filename' => $fileName,
			'comment' => 'dummy comment',
			'text' => "This is the page text for $fileName, altered",
		], null, $this->uploader );
		$this->assertArrayHasKey( 'upload', $result );
		$this->assertEquals( 'Success', $result['upload']['result'] );
	}

	public function testUploadChunks() {
		$fileName = 'TestUploadChunks.jpg';
		$mimeType = 'image/jpeg';
		$filePath = $this->filePath( 'yuv420.jpg' );
		$fileSize = filesize( $filePath );
		$chunkSize = 20 * 1024; // The file is ~60 KiB, use 20 KiB chunks

		$this->overrideConfigValue( MainConfigNames::MinUploadChunkSize, $chunkSize );

		// Base upload params:
		$params = [
			'action' => 'upload',
			'stash' => 1,
			'filename' => $fileName,
			'filesize' => $fileSize,
			'offset' => 0,
		];

		// Upload chunks
		$handle = fopen( $filePath, "r" );
		$resultOffset = 0;
		$filekey = false;
		while ( !feof( $handle ) ) {
			$chunkData = fread( $handle, $chunkSize );

			// Upload the current chunk into the $_FILE object:
			$this->fakeUploadChunk( 'chunk', 'blob', $mimeType, $chunkData );
			if ( !$filekey ) {
				[ $result ] = $this->doApiRequestWithToken( $params, null,
					$this->uploader );
				// Make sure we got a valid chunk continue:
				$this->assertArrayHasKey( 'upload', $result );
				$this->assertArrayHasKey( 'filekey', $result['upload'] );
				$this->assertEquals( 'Continue', $result['upload']['result'] );
				$this->assertEquals( $chunkSize, $result['upload']['offset'] );

				$filekey = $result['upload']['filekey'];
				$resultOffset = $result['upload']['offset'];
			} else {
				// Filekey set to chunk session
				$params['filekey'] = $filekey;
				// Update the offset ( always add chunkSize for subquent chunks
				// should be in-sync with $result['upload']['offset'] )
				$params['offset'] += $chunkSize;
				// Make sure param offset is insync with resultOffset:
				$this->assertEquals( $resultOffset, $params['offset'] );
				// Upload current chunk
				[ $result ] = $this->doApiRequestWithToken( $params, null,
					$this->uploader );
				// Make sure we got a valid chunk continue:
				$this->assertArrayHasKey( 'upload', $result );
				$this->assertArrayHasKey( 'filekey', $result['upload'] );

				// Check if we were on the last chunk:
				if ( $params['offset'] + $chunkSize >= $fileSize ) {
					$this->assertEquals( 'Success', $result['upload']['result'] );
					break;
				} else {
					$this->assertEquals( 'Continue', $result['upload']['result'] );
					$resultOffset = $result['upload']['offset'];
				}
			}
		}
		fclose( $handle );

		// Check that we got a valid file result:
		$this->assertEquals( $fileSize, $result['upload']['imageinfo']['size'] );
		$this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] );
		$this->assertArrayHasKey( 'filekey', $result['upload'] );
		$filekey = $result['upload']['filekey'];

		// Now we should try to release the file from stash
		$this->clearFakeUploads();
		[ $result ] = $this->doApiRequestWithToken( [
			'action' => 'upload',
			'filekey' => $filekey,
			'filename' => $fileName,
			'comment' => 'dummy comment',
			'text' => "This is the page text for $fileName, altered",
		], null, $this->uploader );
		$this->assertArrayHasKey( 'upload', $result );
		$this->assertEquals( 'Success', $result['upload']['result'] );
	}
}
PK       ! H+      api/ApiUploadTestCase.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use Exception;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\File\FileDeleteForm;
use MediaWiki\Title\Title;
use Wikimedia\FileBackend\FSFile\FSFile;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * Abstract class to support upload tests
 */
abstract class ApiUploadTestCase extends ApiTestCase {

	/**
	 * @since 1.37
	 * @var array Used to fake $_FILES in tests and given to MediaWiki\Request\FauxRequest
	 */
	protected $requestDataFiles = [];

	/**
	 * Fixture -- run before every test
	 */
	protected function setUp(): void {
		parent::setUp();

		$this->overrideConfigValue( MainConfigNames::EnableUploads, true );
		$this->clearFakeUploads();
	}

	/**
	 * Helper function -- remove files and associated articles by Title
	 *
	 * @param Title $title Title to be removed
	 *
	 * @return bool
	 */
	public function deleteFileByTitle( $title ) {
		if ( $title->exists() ) {
			$file = $this->getServiceContainer()->getRepoGroup()
				->findFile( $title, [ 'ignoreRedirect' => true ] );
			$noOldArchive = ""; // yes this really needs to be set this way
			$comment = "removing for test";
			$restrictDeletedVersions = false;
			$user = $this->getTestSysop()->getUser();
			$status = FileDeleteForm::doDelete(
				$title,
				$file,
				$noOldArchive,
				$comment,
				$restrictDeletedVersions,
				$user
			);

			if ( !$status->isGood() ) {
				return false;
			}

			$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
			$this->deletePage( $page, "removing for test" );
		}

		return !( $title && $title instanceof Title && $title->exists( IDBAccessObject::READ_LATEST ) );
	}

	/**
	 * Helper function -- remove files and associated articles with a particular filename
	 *
	 * @param string $fileName Filename to be removed
	 *
	 * @return bool
	 */
	public function deleteFileByFileName( $fileName ) {
		return $this->deleteFileByTitle( Title::newFromText( $fileName, NS_FILE ) );
	}

	/**
	 * Helper function -- given a file on the filesystem, find matching
	 * content in the db (and associated articles) and remove them.
	 *
	 * @param string $filePath Path to file on the filesystem
	 *
	 * @return bool
	 */
	public function deleteFileByContent( $filePath ) {
		$hash = FSFile::getSha1Base36FromPath( $filePath );
		$dupes = $this->getServiceContainer()->getRepoGroup()->findBySha1( $hash );
		$success = true;
		foreach ( $dupes as $dupe ) {
			$success &= $this->deleteFileByTitle( $dupe->getTitle() );
		}

		return $success;
	}

	/**
	 * Fake an upload by dumping the file into temp space, and adding info to $_FILES.
	 * (This is what PHP would normally do).
	 *
	 * @param string $fieldName Name this would have in the upload form
	 * @param string $fileName Name to title this
	 * @param string $type MIME type
	 * @param string $filePath Path where to find file contents
	 *
	 * @throws Exception
	 * @return bool
	 */
	protected function fakeUploadFile( $fieldName, $fileName, $type, $filePath ) {
		$tmpName = $this->getNewTempFile();
		if ( !is_file( $filePath ) ) {
			$this->fail( "$filePath doesn't exist!" );
		}

		if ( !copy( $filePath, $tmpName ) ) {
			$this->fail( "couldn't copy $filePath to $tmpName" );
		}

		clearstatcache();
		$size = filesize( $tmpName );
		if ( $size === false ) {
			$this->fail( "couldn't stat $tmpName" );
		}

		$this->requestDataFiles[$fieldName] = [
			'name' => $fileName,
			'type' => $type,
			'tmp_name' => $tmpName,
			'size' => $size,
			'error' => UPLOAD_ERR_OK,
		];

		return true;
	}

	public function fakeUploadChunk( $fieldName, $fileName, $type, &$chunkData ) {
		$tmpName = $this->getNewTempFile();
		// copy the chunk data to temp location:
		if ( !file_put_contents( $tmpName, $chunkData ) ) {
			$this->fail( "couldn't copy chunk data to $tmpName" );
		}

		clearstatcache();
		$size = filesize( $tmpName );
		if ( $size === false ) {
			$this->fail( "couldn't stat $tmpName" );
		}

		$this->requestDataFiles[$fieldName] = [
			'name' => $fileName,
			'type' => $type,
			'tmp_name' => $tmpName,
			'size' => $size,
			'error' => UPLOAD_ERR_OK,
		];
	}

	/** @inheritDoc */
	protected function buildFauxRequest( $params, $session ) {
		$request = parent::buildFauxRequest( $params, $session );
		$request->setUploadData( $this->requestDataFiles );
		return $request;
	}

	/**
	 * Remove traces of previous fake uploads
	 */
	public function clearFakeUploads() {
		$this->requestDataFiles = [];
	}
}

/** @deprecated class alias since 1.42 */
class_alias( ApiUploadTestCase::class, 'ApiUploadTestCase' );
PK       ! ă      api/ApiDisabledTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

/**
 * @group API
 * @group medium
 *
 * @covers MediaWiki\Api\ApiDisabled
 */
class ApiDisabledTest extends ApiTestCase {
	public function testDisabled() {
		$this->mergeMwGlobalArrayValue( 'wgAPIModules',
			[ 'login' => 'ApiDisabled' ] );

		$this->expectApiErrorCode( 'moduledisabled' );

		$this->doApiRequest( [ 'action' => 'login' ] );
	}
}
PK       ! s*ƃ)  )    api/ApiTestCase.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use ArrayAccess;
use LogicException;
use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiErrorFormatter;
use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiMessage;
use MediaWiki\Api\ApiQueryTokens;
use MediaWiki\Api\ApiResult;
use MediaWiki\Api\ApiUsageException;
use MediaWiki\Context\RequestContext;
use MediaWiki\MediaWikiServices;
use MediaWiki\Message\Message;
use MediaWiki\Permissions\Authority;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Session\Session;
use MediaWiki\Session\SessionManager;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWikiLangTestCase;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Constraint\Constraint;
use ReturnTypeWillChange;

abstract class ApiTestCase extends MediaWikiLangTestCase {
	use MockAuthorityTrait;

	/** @var string */
	protected static $apiUrl;

	/** @var ApiErrorFormatter|null */
	protected static $errorFormatter = null;

	/**
	 * @var ApiTestContext
	 */
	protected $apiContext;

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

		parent::setUp();
		self::$apiUrl = $wgServer . wfScript( 'api' );

		// HACK: Avoid creating test users in the DB if the test may not need them.
		$getters = [
			'sysop' => fn () => $this->getTestSysop(),
			'uploader' => fn () => $this->getTestUser(),
		];
		$fakeUserArray = new class ( $getters ) implements ArrayAccess {
			private array $getters;
			private array $extraUsers = [];

			public function __construct( array $getters ) {
				$this->getters = $getters;
			}

			public function offsetExists( $offset ): bool {
				return isset( $this->getters[$offset] ) || isset( $this->extraUsers[$offset] );
			}

			#[ReturnTypeWillChange]
			public function offsetGet( $offset ) {
				if ( isset( $this->getters[$offset] ) ) {
					return ( $this->getters[$offset] )();
				}
				if ( isset( $this->extraUsers[$offset] ) ) {
					return $this->extraUsers[$offset];
				}
				throw new LogicException( "Requested unknown user $offset" );
			}

			public function offsetSet( $offset, $value ): void {
				$this->extraUsers[$offset] = $value;
			}

			public function offsetUnset( $offset ): void {
				unset( $this->getters[$offset] );
				unset( $this->extraUsers[$offset] );
			}
		};

		self::$users = $fakeUserArray;

		$this->setRequest( new FauxRequest( [] ) );

		$this->apiContext = new ApiTestContext();
	}

	protected function tearDown(): void {
		// Avoid leaking session over tests
		SessionManager::getGlobalSession()->clear();

		ApiBase::clearCacheForTest();

		parent::tearDown();
	}

	/**
	 * Does the API request and returns the result.
	 *
	 * @param array $params
	 * @param array|null $session
	 * @param bool $appendModule
	 * @param Authority|null $performer
	 * @param string|null $tokenType Set to a string like 'csrf' to send an
	 *   appropriate token
	 * @param string|null $paramPrefix Prefix to prepend to parameters
	 * @return array List of:
	 * - the result data (array)
	 * - the request (WebRequest)
	 * - the session data of the request (array)
	 * - if $appendModule is true, the Api module $module
	 * @throws ApiUsageException
	 */
	protected function doApiRequest( array $params, ?array $session = null,
		$appendModule = false, ?Authority $performer = null, $tokenType = null,
		$paramPrefix = null
	) {
		global $wgRequest;

		// re-use existing global session by default
		$session ??= $wgRequest->getSessionArray();

		$sessionObj = SessionManager::singleton()->getEmptySession();

		if ( $session !== null ) {
			foreach ( $session as $key => $value ) {
				$sessionObj->set( $key, $value );
			}
		}

		// set up global environment
		if ( !$performer && !$this->needsDB() ) {
			$performer = $this->mockRegisteredUltimateAuthority();
		}
		if ( $performer ) {
			$legacyUser = $this->getServiceContainer()->getUserFactory()->newFromAuthority( $performer );
			$contextUser = $legacyUser;
			// Clone the user object, because something in Session code will replace its user with "Unknown user"
			// if it doesn't exist. But that'll also change $contextUser, and the token won't match (T341953).
			$sessionUser = clone $contextUser;
		} else {
			$contextUser = $this->getTestSysop()->getUser();
			$performer = $contextUser;
			$sessionUser = $contextUser;
		}

		$sessionObj->setUser( $sessionUser );
		if ( $tokenType !== null ) {
			if ( $tokenType === 'auto' ) {
				$tokenType = ( new ApiMain() )->getModuleManager()
					->getModule( $params['action'], 'action' )->needsToken();
			}
			if ( $tokenType !== false ) {
				$params['token'] = ApiQueryTokens::getToken(
					$contextUser,
					$sessionObj,
					ApiQueryTokens::getTokenTypeSalts()[$tokenType]
				)->toString();
			}
		}

		// prepend parameters with prefix
		if ( $paramPrefix !== null && $paramPrefix !== '' ) {
			$prefixedParams = [];
			foreach ( $params as $key => $value ) {
				$prefixedParams[$paramPrefix . $key] = $value;
			}
			$params = $prefixedParams;
		}

		$wgRequest = $this->buildFauxRequest( $params, $sessionObj );
		RequestContext::getMain()->setRequest( $wgRequest );
		RequestContext::getMain()->setAuthority( $performer );
		RequestContext::getMain()->setUser( $sessionUser );

		// set up local environment
		$context = $this->apiContext->newTestContext( $wgRequest, $performer );

		$module = new ApiMain( $context, true );

		// run it!
		$module->execute();

		// construct result
		$results = [
			$module->getResult()->getResultData( null, [ 'Strip' => 'all' ] ),
			$context->getRequest(),
			$context->getRequest()->getSessionArray()
		];

		if ( $appendModule ) {
			$results[] = $module;
		}

		return $results;
	}

	/**
	 * @since 1.37
	 * @param array $params
	 * @param Session|array|null $session
	 * @return FauxRequest
	 */
	protected function buildFauxRequest( $params, $session ) {
		return new FauxRequest( $params, true, $session );
	}

	/**
	 * Convenience function to access the token parameter of doApiRequest()
	 * more succinctly.
	 *
	 * @param array $params Key-value API params
	 * @param array|null $session Session array
	 * @param Authority|null $performer A User object for the context
	 * @param string $tokenType Which token type to pass
	 * @param string|null $paramPrefix Prefix to prepend to parameters
	 * @return array Result of the API call
	 */
	protected function doApiRequestWithToken( array $params, ?array $session = null,
		?Authority $performer = null, $tokenType = 'auto', $paramPrefix = null
	) {
		return $this->doApiRequest( $params, $session, false, $performer, $tokenType, $paramPrefix );
	}

	protected static function getErrorFormatter() {
		self::$errorFormatter ??= new ApiErrorFormatter(
			new ApiResult( false ),
			MediaWikiServices::getInstance()->getLanguageFactory()->getLanguage( 'en' ),
			'none'
		);
		return self::$errorFormatter;
	}

	public static function apiExceptionHasCode( ApiUsageException $ex, $code ) {
		return (bool)array_filter(
			self::getErrorFormatter()->arrayFromStatus( $ex->getStatusValue() ),
			static function ( $e ) use ( $code ) {
				return is_array( $e ) && $e['code'] === $code;
			}
		);
	}

	/**
	 * Expect an ApiUsageException to be thrown with the given parameters, which are the same as
	 * ApiUsageException::newWithMessage()'s parameters.  This allows checking for an exception
	 * whose text is given by a message key instead of text, so as not to hard-code the message's
	 * text into test code.
	 *
	 * @deprecated since 1.43; use expectApiErrorCode() instead, it's better to test error codes than messages
	 * @param string|array|Message $msg
	 * @param string|null $code
	 * @param array|null $data
	 * @param int $httpCode
	 */
	protected function setExpectedApiException(
		$msg, $code = null, ?array $data = null, $httpCode = 0
	) {
		$expected = ApiUsageException::newWithMessage( null, $msg, $code, $data, $httpCode );
		$this->expectException( ApiUsageException::class );
		$this->expectExceptionMessage( $expected->getMessage() );
	}

	private ?string $expectedApiErrorCode;

	/**
	 * Expect an ApiUsageException that results in the given API error code to be thrown.
	 *
	 * Note that you can't mix this method with standard PHPUnit expectException() methods,
	 * as PHPUnit will catch the exception and prevent us from testing it.
	 *
	 * @since 1.41
	 * @param string $expectedCode
	 */
	protected function expectApiErrorCode( string $expectedCode ) {
		$this->expectedApiErrorCode = $expectedCode;
	}

	/**
	 * Assert that an ApiUsageException will result in the given API error code being outputted.
	 *
	 * @since 1.41
	 * @param string $expectedCode
	 * @param ApiUsageException $exception
	 * @param string $message
	 */
	protected function assertApiErrorCode( string $expectedCode, ApiUsageException $exception, string $message = '' ) {
		$constraint = new class( $expectedCode ) extends Constraint {
			private string $expectedApiErrorCode;

			public function __construct( string $expected ) {
				$this->expectedApiErrorCode = $expected;
			}

			public function toString(): string {
				return 'API error code is ';
			}

			private function getApiErrorCode( $other ) {
				if ( !$other instanceof ApiUsageException ) {
					return null;
				}
				$errors = $other->getStatusValue()->getMessages();
				if ( count( $errors ) === 0 ) {
					return '(no error)';
				} elseif ( count( $errors ) > 1 ) {
					return '(multiple errors)';
				}
				return ApiMessage::create( $errors[0] )->getApiCode();
			}

			protected function matches( $other ): bool {
				return $this->getApiErrorCode( $other ) === $this->expectedApiErrorCode;
			}

			protected function failureDescription( $other ): string {
				return sprintf(
					'%s is equal to expected API error code %s',
					$this->exporter()->export( $this->getApiErrorCode( $other ) ),
					$this->exporter()->export( $this->expectedApiErrorCode )
				);
			}
		};

		$this->assertThat( $exception, $constraint, $message );
	}

	/**
	 * @inheritDoc
	 *
	 * Adds support for expectApiErrorCode().
	 */
	protected function runTest() {
		try {
			$testResult = parent::runTest();

		} catch ( ApiUsageException $exception ) {
			if ( !isset( $this->expectedApiErrorCode ) ) {
				throw $exception;
			}

			$this->assertApiErrorCode( $this->expectedApiErrorCode, $exception );

			return null;
		}

		if ( !isset( $this->expectedApiErrorCode ) ) {
			return $testResult;
		}

		throw new AssertionFailedError(
			sprintf(
				'Failed asserting that exception with API error code "%s" is thrown',
				$this->expectedApiErrorCode
			)
		);
	}
}

/** @deprecated class alias since 1.42 */
class_alias( ApiTestCase::class, 'ApiTestCase' );
PK       ! RB      api/ApiCSPReportTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiCSPReport;
use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiResult;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWikiIntegrationTestCase;
use Psr\Log\AbstractLogger;

/**
 * @group API
 * @group medium
 * @covers \MediaWiki\Api\ApiCSPReport
 */
class ApiCSPReportTest extends MediaWikiIntegrationTestCase {

	public function testInternalReportonly() {
		$params = [
			'reportonly' => '1',
			'source' => 'internal',
		];
		$cspReport = [
			'document-uri' => 'https://doc.test/path',
			'referrer' => 'https://referrer.test/path',
			'violated-directive' => 'connet-src',
			'disposition' => 'report',
			'blocked-uri' => 'https://blocked.test/path?query',
			'line-number' => 4,
			'column-number' => 2,
			'source-file' => 'https://source.test/path?query',
		];

		$log = $this->doExecute( $params, $cspReport );

		$this->assertEquals(
			[
				[
					'[report-only] Received CSP report: ' .
						'<https://blocked.test> blocked from being loaded on <https://doc.test/path>:4',
					[
						'method' => ApiCSPReport::class . '::execute',
						'user_id' => 'logged-out',
						'user-agent' => 'Test/0.0',
						'source' => 'internal'
					]
				],
			],
			$log,
			'logged messages'
		);
	}

	public function testFalsePositiveOriginMatch() {
		$params = [
			'reportonly' => '1',
			'source' => 'internal',
		];
		$cspReport = [
			'document-uri' => 'https://doc.test/path',
			'referrer' => 'https://referrer.test/path',
			'violated-directive' => 'connet-src',
			'disposition' => 'report',
			'blocked-uri' => 'https://blocked.test/path/file?query',
			'line-number' => 4,
			'column-number' => 2,
			'source-file' => 'https://source.test/path/file?query',
		];

		$this->overrideConfigValue(
			MainConfigNames::CSPFalsePositiveUrls,
			[ 'https://blocked.test/path/' => true ]
		);
		$log = $this->doExecute( $params, $cspReport );

		$this->assertSame(
			[],
			$log,
			'logged messages'
		);
	}

	private function doExecute( array $params, array $cspReport ) {
		$log = [];
		$logger = $this->createMock( AbstractLogger::class );
		$logger->method( 'warning' )->willReturnCallback(
			static function ( $msg, $ctx ) use ( &$log ) {
				unset( $ctx['csp-report'] );
				$log[] = [ $msg, $ctx ];
			}
		);
		$this->setLogger( 'csp-report-only', $logger );

		$postBody = json_encode( [ 'csp-report' => $cspReport ] );
		$req = $this->getMockBuilder( FauxRequest::class )
			->onlyMethods( [ 'getRawInput' ] )
			->setConstructorArgs( [ $params, /* $wasPosted */ true ] )
			->getMock();
		$req->method( 'getRawInput' )->willReturn( $postBody );
		$req->setHeaders( [
			'Content-Type' => 'application/csp-report',
			'User-Agent' => 'Test/0.0'
		] );

		$services = $this->getServiceContainer();
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setRequest( $req );
		$main = new ApiMain( $context );
		$api = $this->getMockBuilder( ApiCSPReport::class )
			->setConstructorArgs( [ $main, 'mock', $services->getUrlUtils() ] )
			->onlyMethods( [ 'getParameter', 'getRequest', 'getResult' ] )
			->getMock();
		$api->method( 'getParameter' )->willReturnCallback(
			static function ( $key ) use ( $req ) {
				return $req->getRawVal( $key );
			}
		);
		$api->method( 'getRequest' )->willReturn( $req );
		$api->method( 'getResult' )->willReturn( new ApiResult( false ) );

		$api->execute();
		return $log;
	}
}
PK       ! *  *    api/ApiUnblockTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiUsageException;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Title\Title;
use MediaWiki\User\User;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers MediaWiki\Api\ApiUnblock
 */
class ApiUnblockTest extends ApiTestCase {
	/** @var User */
	private $blocker;

	/** @var User */
	private $blockee;

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

		$this->blocker = $this->getTestSysop()->getUser();
		$this->blockee = $this->getMutableTestUser()->getUser();

		// Initialize a blocked user (used by most tests, although not all)
		$block = new DatabaseBlock( [
			'address' => $this->blockee->getName(),
			'by' => $this->blocker,
		] );
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$result = $blockStore->insertBlock( $block );
		$this->assertNotFalse( $result, 'Could not insert block' );
		$blockFromDB = $blockStore->newFromID( $result['id'] );
		$this->assertInstanceOf( DatabaseBlock::class, $blockFromDB, 'Could not retrieve block' );
	}

	private function getBlockFromParams( array $params ) {
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		if ( array_key_exists( 'user', $params ) ) {
			return $blockStore->newFromTarget( $params['user'] );
		}
		if ( array_key_exists( 'userid', $params ) ) {
			return $blockStore->newFromTarget(
				$this->getServiceContainer()->getUserFactory()->newFromId( $params['userid'] )
			);
		}
		return $blockStore->newFromID( $params['id'] );
	}

	/**
	 * Try to submit the unblock API request and check that the block no longer exists.
	 *
	 * @param array $params API request query parameters
	 */
	private function doUnblock( array $params = [] ) {
		$params += [ 'action' => 'unblock' ];
		if ( !array_key_exists( 'userid', $params ) && !array_key_exists( 'id', $params ) ) {
			$params += [ 'user' => $this->blockee->getName() ];
		}

		$originalBlock = $this->getBlockFromParams( $params );

		$this->doApiRequestWithToken( $params );

		// We only check later on whether the block existed to begin with, because maybe the caller
		// expects doApiRequestWithToken to throw, in which case the block might not be expected to
		// exist to begin with.
		$this->assertInstanceOf( DatabaseBlock::class, $originalBlock, 'Block should initially exist' );
		$this->assertNull( $this->getBlockFromParams( $params ), 'Block should have been removed' );
	}

	public function testWithNoToken() {
		$this->expectException( ApiUsageException::class );
		$this->doApiRequest( [
			'action' => 'unblock',
			'user' => $this->blockee->getName(),
			'reason' => 'Some reason',
		] );
	}

	public function testNormalUnblock() {
		$this->doUnblock();
	}

	public function testUnblockNoPermission() {
		$this->expectApiErrorCode( 'permissiondenied' );

		$this->setGroupPermissions( 'sysop', 'block', false );

		$this->doUnblock();
	}

	public function testUnblockWhenBlocked() {
		$this->expectApiErrorCode( 'ipbblocked' );

		$block = new DatabaseBlock( [
			'address' => $this->blocker->getName(),
			'by' => $this->getTestUser( 'sysop' )->getUser(),
		] );
		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );

		$this->doUnblock();
	}

	public function testUnblockSelfWhenBlocked() {
		$block = new DatabaseBlock( [
			'address' => $this->blocker->getName(),
			'by' => $this->getTestUser( 'sysop' )->getUser(),
		] );
		$result = $this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );
		$this->assertNotFalse( $result, 'Could not insert block' );

		$this->doUnblock( [ 'user' => $this->blocker->getName() ] );
	}

	public function testUnblockWithTagNewBackend() {
		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'custom tag' );

		$this->doUnblock( [ 'tags' => 'custom tag' ] );

		$this->assertSame( 1, (int)$this->getDb()->newSelectQueryBuilder()
			->select( 'COUNT(*)' )
			->from( 'logging' )
			->join( 'change_tag', null, 'ct_log_id = log_id' )
			->join( 'change_tag_def', null, 'ctd_id = ct_tag_id' )
			->where( [ 'log_type' => 'block', 'ctd_name' => 'custom tag' ] )
			->caller( __METHOD__ )->fetchField() );
	}

	public function testUnblockWithProhibitedTag() {
		$this->expectApiErrorCode( 'tags-apply-no-permission' );

		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'custom tag' );

		$this->setGroupPermissions( 'user', 'applychangetags', false );

		$this->doUnblock( [ 'tags' => 'custom tag' ] );
	}

	public function testUnblockById() {
		$this->doUnblock( [ 'userid' => $this->blockee->getId() ] );
	}

	public function testUnblockByInvalidId() {
		$this->expectApiErrorCode( 'nosuchuserid' );

		$this->doUnblock( [ 'userid' => 1234567890 ] );
	}

	public function testUnblockNonexistentBlock() {
		$this->expectApiErrorCode( 'cantunblock' );

		$this->doUnblock( [ 'user' => $this->blocker ] );
	}

	public function testWatched() {
		$userPage = Title::makeTitle( NS_USER, $this->blockee->getName() );
		$this->doUnblock( [ 'watchuser' => true ] );
		$this->assertTrue( $this->getServiceContainer()->getWatchlistManager()
			->isWatched( $this->blocker, $userPage ) );
	}
}
PK       ! V:v    "  api/ApiCreateTempUserTraitTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiCreateTempUserTrait;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Request\WebRequest;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Api\ApiCreateTempUserTrait
 */
class ApiCreateTempUserTraitTest extends MediaWikiIntegrationTestCase {

	/**
	 * @dataProvider provideGetTempUserRedirectUrl
	 */
	public function testGetTempUserRedirectUrl( $params, $expected ) {
		$this->setTemporaryHook(
			'TempUserCreatedRedirect',
			static function (
				$session,
				$user,
				$returnTo,
				$returnToQuery,
				$returnToAnchor,
				&$redirectUrl
			) {
				$redirectUrl = $returnTo . $returnToQuery . $returnToAnchor;
				return false;
			}
		);

		$mock = $this->getMockForTrait( ApiCreateTempUserTrait::class );
		$mock->method( 'getHookRunner' )
			->willReturn( new HookRunner( $this->getServiceContainer()->getHookContainer() ) );
		$mock->method( 'getRequest' )
			->willReturn( $this->createMock( WebRequest::class ) );

		$url = TestingAccessWrapper::newFromObject( $mock )
			->getTempUserRedirectUrl( $params, $this->createMock( User::class ) );

		$this->assertSame( $expected, $url );
	}

	public static function provideGetTempUserRedirectUrl() {
		return [
			'Default params' => [
				[
					'returnto' => '',
					'returntoquery' => '',
					'returntoanchor' => '',
				],
				'',
			],
			'Missing returnto' => [
				[
					'returnto' => null,
					'returntoquery' => '',
					'returntoanchor' => '',
				],
				'',
			],
			'Params are parsed correctly' => [
				[
					'returnto' => 'Base',
					'returntoquery' => 'Query',
					'returntoanchor' => 'Anchor',
				],
				'BaseQuery#Anchor',
			],
			'Params are parsed correctly with anchor #' => [
				[
					'returnto' => 'Base',
					'returntoquery' => 'Query',
					'returntoanchor' => '#Anchor',
				],
				'BaseQuery#Anchor',
			],
		];
	}
}
PK       ! m2@  2@    api/ApiMoveTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiUsageException;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers MediaWiki\Api\ApiMove
 */
class ApiMoveTest extends ApiTestCase {

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

		$this->overrideConfigValue( MainConfigNames::WatchlistExpiry, true );
	}

	/**
	 * @param Title $fromTitle
	 * @param Title $toTitle
	 * @param string $id Page id of the page to move
	 * @param array|string|null $opts Options: 'noredirect' to expect no redirect
	 */
	protected function assertMoved( $fromTitle, $toTitle, $id, $opts = null ) {
		$opts = (array)$opts;

		$this->assertTrue( $toTitle->exists( IDBAccessObject::READ_LATEST ),
			"Destination {$toTitle->getPrefixedText()} does not exist" );

		if ( in_array( 'noredirect', $opts ) ) {
			$this->assertFalse( $fromTitle->exists( IDBAccessObject::READ_LATEST ),
				"Source {$fromTitle->getPrefixedText()} exists" );
		} else {
			$this->assertTrue( $fromTitle->exists( IDBAccessObject::READ_LATEST ),
				"Source {$fromTitle->getPrefixedText()} does not exist" );
			$this->assertTrue( $fromTitle->isRedirect( IDBAccessObject::READ_LATEST ),
				"Source {$fromTitle->getPrefixedText()} is not a redirect" );

			$target = $this->getServiceContainer()
				->getRevisionLookup()
				->getRevisionByTitle( $fromTitle )
				->getContent( SlotRecord::MAIN )
				->getRedirectTarget();
			$this->assertSame( $toTitle->getPrefixedText(), $target->getPrefixedText() );
		}

		$this->assertSame( $id, $toTitle->getArticleID( IDBAccessObject::READ_LATEST ) );
	}

	/**
	 * Shortcut function to create a page and return its id.
	 *
	 * @param Title $name Page to create
	 * @return int ID of created page
	 */
	protected function createPage( $name ) {
		return $this->editPage( $name, 'Content' )->getNewRevision()->getPageId();
	}

	public function testFromWithFromid() {
		$this->expectApiErrorCode( 'invalidparammix' );

		$this->doApiRequestWithToken( [
			'action' => 'move',
			'from' => 'Some page',
			'fromid' => 123,
			'to' => 'Some other page',
		] );
	}

	public function testMove() {
		$title = Title::makeTitle( NS_MAIN, 'TestMove' );
		$title2 = Title::makeTitle( NS_MAIN, 'TestMove 2' );

		$id = $this->createPage( $title );

		$res = $this->doApiRequestWithToken( [
			'action' => 'move',
			'from' => $title->getPrefixedText(),
			'to' => $title2->getPrefixedText(),
		] );

		$this->assertMoved( $title, $title2, $id );
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testMoveById() {
		$title = Title::makeTitle( NS_MAIN, 'TestMoveById' );
		$title2 = Title::makeTitle( NS_MAIN, 'TestMoveById 2' );

		$id = $this->createPage( $title );

		$res = $this->doApiRequestWithToken( [
			'action' => 'move',
			'fromid' => $id,
			'to' => $title2->getPrefixedText(),
		] );

		$this->assertMoved( $title, $title2, $id );
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testMoveAndWatch(): void {
		$title = Title::makeTitle( NS_MAIN, 'TestMoveAndWatch' );
		$title2 = Title::makeTitle( NS_MAIN, 'TestMoveAndWatch 2' );
		$this->createPage( $title );

		$this->doApiRequestWithToken( [
			'action' => 'move',
			'from' => $title->getPrefixedText(),
			'to' => $title2->getPrefixedText(),
			'watchlist' => 'watch',
			'watchlistexpiry' => '99990123000000',
		] );

		$user = $this->getTestSysop()->getUser();
		$watchlistManager = $this->getServiceContainer()->getWatchlistManager();
		$this->assertTrue( $watchlistManager->isTempWatched( $user, $title ) );
		$this->assertTrue( $watchlistManager->isTempWatched( $user, $title2 ) );
	}

	public function testMoveWithWatchUnchanged(): void {
		$title = Title::makeTitle( NS_MAIN, 'TestMoveWithWatchUnchanged' );
		$this->createPage( $title );
		$title2 = Title::makeTitle( NS_MAIN, 'TestMoveWithWatchUnchanged 2' );
		$user = $this->getTestSysop()->getUser();

		// Temporarily watch the page.
		$this->doApiRequestWithToken( [
			'action' => 'watch',
			'titles' => $title->getDBkey(),
			'expiry' => '99990123000000',
		] );

		// Fetched stored expiry (maximum duration may override '99990123000000').
		$store = $this->getServiceContainer()->getWatchedItemStore();
		$expiry = $store->getWatchedItem( $user, $title )->getExpiry();

		// Move to new location, without changing the watched state.
		$this->doApiRequestWithToken( [
			'action' => 'move',
			'from' => $title->getDBkey(),
			'to' => $title2->getDBkey(),
		] );

		// New page should have the same expiry.
		$expiry2 = $store->getWatchedItem( $user, $title2 )->getExpiry();
		$this->assertSame( wfTimestamp( TS_MW, $expiry ), $expiry2 );
	}

	public function testMoveNonexistent() {
		$this->expectApiErrorCode( 'missingtitle' );

		$this->doApiRequestWithToken( [
			'action' => 'move',
			'from' => 'Nonexistent page',
			'to' => 'Different page'
		] );
	}

	public function testMoveNonexistentId() {
		$this->expectApiErrorCode( 'nosuchpageid' );

		$this->doApiRequestWithToken( [
			'action' => 'move',
			'fromid' => pow( 2, 31 ) - 1,
			'to' => 'Different page',
		] );
	}

	public function testMoveToInvalidPageName() {
		$this->expectApiErrorCode( 'invalidtitle' );

		$title = Title::makeTitle( NS_MAIN, 'TestMoveToInvalidPageName' );
		$id = $this->createPage( $title );

		try {
			$this->doApiRequestWithToken( [
				'action' => 'move',
				'from' => $title->getPrefixedText(),
				'to' => '[',
			] );
		} finally {
			$this->assertSame( $id, $title->getArticleID( IDBAccessObject::READ_LATEST ) );
		}
	}

	public function testMoveWhileBlocked() {
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$this->assertNull( $blockStore->newFromTarget( '127.0.0.1' ) );

		$user = $this->getTestSysop()->getUser();
		$block = new DatabaseBlock( [
			'address' => $user->getName(),
			'by' => $user,
			'reason' => 'Capriciousness',
			'timestamp' => '19370101000000',
			'expiry' => 'infinity',
			'enableAutoblock' => true,
		] );
		$blockStore->insertBlock( $block );

		$title = Title::makeTitle( NS_MAIN, 'TestMoveWhileBlocked' );
		$title2 = Title::makeTitle( NS_MAIN, 'TestMoveWhileBlocked 2' );
		$id = $this->createPage( $title );

		try {
			$this->doApiRequestWithToken( [
				'action' => 'move',
				'from' => $title->getPrefixedText(),
				'to' => $title2->getPrefixedText(),
			] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( ApiUsageException $ex ) {
			$this->assertApiErrorCode( 'blocked', $ex );
			$this->assertNotNull( $blockStore->newFromTarget( '127.0.0.1' ), 'Autoblock spread' );
			$this->assertSame( $id, $title->getArticleID( IDBAccessObject::READ_LATEST ) );
		}
	}

	// @todo File moving

	public function testRateLimit() {
		$name1 = 'TestPingLimiter 1';
		$name2 = 'TestPingLimiter 2';
		$name3 = 'TestPingLimiter 3';

		$title1 = Title::makeTitle( NS_MAIN, $name1 );
		$title2 = Title::makeTitle( NS_MAIN, $name2 );

		$this->overrideConfigValue( MainConfigNames::RateLimits,
			[ 'move' => [ '&can-bypass' => false, 'user' => [ 1, 60 ] ] ]
		);

		$id = $this->createPage( $title1 );
		$res = $this->doApiRequestWithToken( [
			'action' => 'move',
			'from' => $name1,
			'to' => $name2,
		] );
		$this->assertMoved( $title1, $title2, $id );
		$this->assertArrayNotHasKey( 'warnings', $res[0] );

		try {
			$this->doApiRequestWithToken( [
				'action' => 'move',
				'from' => $name2,
				'to' => $name3,
			] );

			$this->fail( 'Rate limit was expected to trigger an exception' );
		} catch ( ApiUsageException $ex ) {
			$this->assertStatusError( 'apierror-ratelimited', $ex->getStatusValue() );
		} finally {
			$title3 = Title::makeTitle( NS_MAIN, $name3 );
			$this->assertSame( $id, $title2->getArticleID( IDBAccessObject::READ_LATEST ) );
			$this->assertFalse( $title3->exists( IDBAccessObject::READ_LATEST ),
				"\"{$title3->getPrefixedText()}\" should not exist" );
		}
	}

	public function testTagsNoPermission() {
		$this->expectApiErrorCode( 'tags-apply-no-permission' );

		$title = Title::makeTitle( NS_MAIN, 'TestTagsNoPermission' );
		$title2 = Title::makeTitle( NS_MAIN, 'TestTagsNoPermission 2' );

		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'custom tag' );

		$this->setGroupPermissions( 'user', 'applychangetags', false );

		$id = $this->createPage( $title );

		try {
			$this->doApiRequestWithToken( [
				'action' => 'move',
				'from' => $title->getPrefixedText(),
				'to' => $title2->getPrefixedText(),
				'tags' => 'custom tag',
			] );
		} finally {
			$this->assertSame( $id, $title->getArticleID( IDBAccessObject::READ_LATEST ) );
			$this->assertFalse( $title2->exists( IDBAccessObject::READ_LATEST ),
				"\"{$title2->getPrefixedText()}\" should not exist" );
		}
	}

	public function testSelfMove() {
		$this->expectApiErrorCode( 'selfmove' );

		$title = Title::makeTitle( NS_MAIN, 'TestSelfMove' );
		$this->createPage( $title );

		$this->doApiRequestWithToken( [
			'action' => 'move',
			'from' => $title->getPrefixedText(),
			'to' => $title->getPrefixedText(),
		] );
	}

	public function testMoveTalk() {
		$title = Title::makeTitle( NS_MAIN, 'TestMoveTalk' );
		$title2 = Title::makeTitle( NS_MAIN, 'TestMoveTalk 2' );
		$talkTitle = $title->getTalkPageIfDefined();
		$talkTitle2 = $title2->getTalkPageIfDefined();

		$id = $this->createPage( $title );
		$talkId = $this->createPage( $talkTitle );

		$res = $this->doApiRequestWithToken( [
			'action' => 'move',
			'from' => $title->getPrefixedText(),
			'to' => $title2->getPrefixedText(),
			'movetalk' => '',
		] );

		$this->assertMoved( $title, $title2, $id );
		$this->assertMoved( $talkTitle, $talkTitle2, $talkId );

		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testMoveTalkFailed() {
		$title = Title::makeTitle( NS_MAIN, 'TestMoveTalkFailed' );
		$title2 = Title::makeTitle( NS_MAIN, 'TestMoveTalkFailed 2' );
		$talkTitle = $title->getTalkPageIfDefined();
		$talkDestinationTitle = $title2->getTalkPageIfDefined();

		$id = $this->createPage( $title );
		$talkId = $this->createPage( $talkTitle );
		$talkDestinationId = $this->createPage( $talkDestinationTitle );

		$res = $this->doApiRequestWithToken( [
			'action' => 'move',
			'from' => $title->getPrefixedText(),
			'to' => $title2->getPrefixedText(),
			'movetalk' => '',
		] );

		$this->assertMoved( $title, $title2, $id );
		$this->assertSame( $talkId, $talkTitle->getArticleID( IDBAccessObject::READ_LATEST ) );
		$this->assertSame( $talkDestinationId,
			$talkDestinationTitle->getArticleID( IDBAccessObject::READ_LATEST ) );
		$this->assertSame( [ [
			'message' => 'articleexists',
			'params' => [ $talkDestinationTitle->getPrefixedText() ],
			'code' => 'articleexists',
			'type' => 'error',
		] ], $res[0]['move']['talkmove-errors'] );

		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testMoveSubpages() {
		$title = Title::makeTitle( NS_MAIN, 'TestMoveSubpages' );
		$title2 = Title::makeTitle( NS_MAIN, 'TestMoveSubpages 2' );

		$this->mergeMwGlobalArrayValue( 'wgNamespacesWithSubpages', [ NS_MAIN => true ] );

		$titleError = Title::makeTitle( NS_MAIN, 'TestMoveSubpages/error' );
		$idError = $this->createPage( $titleError );
		$title2Error = Title::makeTitle( NS_MAIN, 'TestMoveSubpages 2/error' );
		$id2Error = $this->createPage( $title2Error );

		$titles = [
			[ $title, $title2 ],
			[ $title->getTalkPageIfDefined(), $title2->getTalkPageIfDefined() ],
			[ Title::makeTitle( NS_MAIN, 'TestMoveSubpages/1' ), Title::makeTitle( NS_MAIN, 'TestMoveSubpages 2/1' ) ],
			[ Title::makeTitle( NS_MAIN, 'TestMoveSubpages/2' ), Title::makeTitle( NS_MAIN, 'TestMoveSubpages 2/2' ) ],
			[ Title::makeTitle( NS_TALK, 'TestMoveSubpages/1' ), Title::makeTitle( NS_TALK, 'TestMoveSubpages 2/1' ) ],
			[ Title::makeTitle( NS_TALK, 'TestMoveSubpages/3' ), Title::makeTitle( NS_TALK, 'TestMoveSubpages 2/3' ) ],
		];
		$ids = [];
		$mapTitles = [];
		foreach ( $titles as [ $from, $to ] ) {
			$ids[$from->getPrefixedText()] = $this->createPage( $from );
			$mapTitles[$from->getPrefixedText()] = $to->getPrefixedText();
		}

		$res = $this->doApiRequestWithToken( [
			'action' => 'move',
			'from' => $title->getPrefixedText(),
			'to' => $title2->getPrefixedText(),
			'movetalk' => '',
			'movesubpages' => '',
		] );

		foreach ( $titles as [ $from, $to ] ) {
			$this->assertMoved( $from, $to, $ids[$from->getPrefixedText()] );
		}

		$this->assertSame( $idError, $titleError->getArticleID( IDBAccessObject::READ_LATEST ) );
		$this->assertSame( $id2Error, $title2Error->getArticleID( IDBAccessObject::READ_LATEST ) );

		$results = array_merge( $res[0]['move']['subpages'], $res[0]['move']['subpages-talk'] );
		foreach ( $results as $arr ) {
			if ( $arr['from'] === $titleError->getPrefixedText() ) {
				$this->assertSame( [ [
					'message' => 'articleexists',
					'params' => [ $title2Error->getPrefixedText() ],
					'code' => 'articleexists',
					'type' => 'error'
				] ], $arr['errors'] );
			} else {
				$this->assertSame( $mapTitles[$arr['from']], $arr['to'] );
			}
			$this->assertCount( 2, $arr );
		}

		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testMoveNoPermission() {
		$title = Title::makeTitle( NS_MAIN, 'TestMoveNoPermission' );
		$title2 = Title::makeTitle( NS_MAIN, 'TestMoveNoPermission 2' );

		$id = $this->createPage( $title );

		$user = new User();

		try {
			$this->doApiRequestWithToken( [
				'action' => 'move',
				'from' => $title->getPrefixedText(),
				'to' => $title2->getPrefixedText(),
			], null, $user );
		} catch ( ApiUsageException $ex ) {
			// This one has two errors! So weird
			$this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'cantmove-anon' ) );
			$this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'cantmove' ) );
		} finally {
			$this->assertSame( $id, $title->getArticleID( IDBAccessObject::READ_LATEST ) );
			$this->assertFalse( $title2->exists( IDBAccessObject::READ_LATEST ),
				"\"{$title2->getPrefixedText()}\" should not exist" );
		}
	}

	public function testSuppressRedirect() {
		$title = Title::makeTitle( NS_MAIN, 'TestSuppressRedirect' );
		$title2 = Title::makeTitle( NS_MAIN, 'TestSuppressRedirect 2' );

		$id = $this->createPage( $title );

		$res = $this->doApiRequestWithToken( [
			'action' => 'move',
			'from' => $title->getPrefixedText(),
			'to' => $title2->getPrefixedText(),
			'noredirect' => '',
		] );

		$this->assertMoved( $title, $title2, $id, 'noredirect' );
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testSuppressRedirectNoPermission() {
		$title = Title::makeTitle( NS_MAIN, 'TestSuppressRedirectNoPermission' );
		$title2 = Title::makeTitle( NS_MAIN, 'TestSuppressRedirectNoPermission 2' );

		$this->setGroupPermissions( 'sysop', 'suppressredirect', false );
		$id = $this->createPage( $title );

		$res = $this->doApiRequestWithToken( [
			'action' => 'move',
			'from' => $title->getPrefixedText(),
			'to' => $title2->getPrefixedText(),
			'noredirect' => '',
		] );

		$this->assertMoved( $title, $title2, $id );
		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	public function testMoveSubpagesError() {
		$title = Title::makeTitle( NS_MAIN, 'TestMoveSubpagesError' );
		$titleBase = $title->getTalkPageIfDefined();
		$titleSub = Title::makeTitle( NS_MAIN, 'TestMoveSubpagesError/1' );
		$talkTitleSub = $titleSub->getTalkPageIfDefined();

		// Subpages are allowed in talk but not main
		$idBase = $this->createPage( $titleBase );
		$idSub = $this->createPage( $talkTitleSub );

		$res = $this->doApiRequestWithToken( [
			'action' => 'move',
			'from' => $titleBase->getPrefixedText(),
			'to' => $title->getPrefixedText(),
			'movesubpages' => '',
		] );

		$this->assertMoved( $titleBase, $title, $idBase );
		$this->assertSame( $idSub, $talkTitleSub->getArticleID( IDBAccessObject::READ_LATEST ) );
		$this->assertFalse( $titleSub->exists( IDBAccessObject::READ_LATEST ),
			"\"{$titleSub->getPrefixedText()}\" should not exist" );

		$this->assertSame( [ 'errors' => [ [
			'message' => 'namespace-nosubpages',
			'params' => [ '' ],
			'code' => 'namespace-nosubpages',
			'type' => 'error',
		] ] ], $res[0]['move']['subpages'] );

		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}
}
PK       ! ڏ4!L  L    api/ApiTestContext.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\Permissions\Authority;
use MediaWiki\Request\WebRequest;

class ApiTestContext extends RequestContext {

	/**
	 * Returns a DerivativeContext with the request variables in place
	 *
	 * @param WebRequest $request WebRequest request object including parameters and session
	 * @param Authority|null $performer
	 * @return DerivativeContext
	 */
	public function newTestContext( WebRequest $request, ?Authority $performer = null ) {
		$context = new DerivativeContext( $this );
		$context->setRequest( $request );
		if ( $performer !== null ) {
			$context->setAuthority( $performer );
		}

		return $context;
	}
}

/** @deprecated class alias since 1.42 */
class_alias( ApiTestContext::class, 'ApiTestContext' );
PK       ! v9      api/ApiBaseTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use DomainException;
use Exception;
use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiBlockInfoTrait;
use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiMessage;
use MediaWiki\Api\ApiUsageException;
use MediaWiki\Api\Validator\SubmoduleDef;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\MediaWikiServices;
use MediaWiki\Message\Message;
use MediaWiki\ParamValidator\TypeDef\NamespaceDef;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use MWException;
use StatusValue;
use Wikimedia\Message\MessageSpecifier;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\TypeDef\EnumDef;
use Wikimedia\ParamValidator\TypeDef\IntegerDef;
use Wikimedia\ParamValidator\TypeDef\StringDef;
use Wikimedia\TestingAccessWrapper;
use WikiPage;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers \MediaWiki\Api\ApiBase
 */
class ApiBaseTest extends ApiTestCase {

	protected function setUp(): void {
		parent::setUp();
		$this->setGroupPermissions( [
			'*' => [
				'read' => true,
				'edit' => true,
				'apihighlimits' => false,
			],
		] );
	}

	/**
	 * This covers a variety of stub methods that return a fixed value.
	 *
	 * @dataProvider provideStubMethods
	 */
	public function testStubMethods( $expected, $method, $args = [] ) {
		// Some of these are protected
		$mock = TestingAccessWrapper::newFromObject( new MockApi() );
		$result = $mock->$method( ...$args );
		$this->assertSame( $expected, $result );
	}

	public static function provideStubMethods() {
		return [
			[ null, 'getModuleManager' ],
			[ null, 'getCustomPrinter' ],
			[ [], 'getHelpUrls' ],
			// @todo This is actually overridden by MockApi
			// [ [], 'getAllowedParams' ],
			[ true, 'shouldCheckMaxLag' ],
			[ true, 'isReadMode' ],
			[ false, 'isWriteMode' ],
			[ false, 'mustBePosted' ],
			[ false, 'isDeprecated' ],
			[ false, 'isInternal' ],
			[ false, 'needsToken' ],
			[ null, 'getWebUITokenSalt', [ [] ] ],
			[ null, 'getConditionalRequestData', [ 'etag' ] ],
			[ null, 'dynamicParameterDocumentation' ],
		];
	}

	public function testRequireOnlyOneParameterDefault() {
		$mock = new MockApi();
		$mock->requireOnlyOneParameter(
			[ "filename" => "foo.txt", "enablechunks" => false ],
			"filename", "enablechunks"
		);
		$this->assertTrue( true );
	}

	public function testRequireOnlyOneParameterZero() {
		$mock = new MockApi();
		$this->expectException( ApiUsageException::class );
		$mock->requireOnlyOneParameter(
			[ "filename" => "foo.txt", "enablechunks" => 0 ],
			"filename", "enablechunks"
		);
	}

	public function testRequireOnlyOneParameterTrue() {
		$mock = new MockApi();
		$this->expectException( ApiUsageException::class );
		$mock->requireOnlyOneParameter(
			[ "filename" => "foo.txt", "enablechunks" => true ],
			"filename", "enablechunks"
		);
	}

	public function testRequireOnlyOneParameterMissing() {
		$this->expectApiErrorCode( 'missingparam' );
		$mock = new MockApi();
		$mock->requireOnlyOneParameter(
			[ "filename" => "foo.txt", "enablechunks" => false ],
			"foo", "bar" );
	}

	public function testRequireMaxOneParameterZero() {
		$mock = new MockApi();
		$mock->requireMaxOneParameter(
			[ 'foo' => 'bar', 'baz' => 'quz' ],
			'squirrel' );
		$this->assertTrue( true );
	}

	public function testRequireMaxOneParameterOne() {
		$mock = new MockApi();
		$mock->requireMaxOneParameter(
			[ 'foo' => 'bar', 'baz' => 'quz' ],
			'foo', 'squirrel' );
		$this->assertTrue( true );
	}

	public function testRequireMaxOneParameterTwo() {
		$this->expectApiErrorCode( 'invalidparammix' );
		$mock = new MockApi();
		$mock->requireMaxOneParameter(
			[ 'foo' => 'bar', 'baz' => 'quz' ],
			'foo', 'baz' );
	}

	public function testRequireAtLeastOneParameterZero() {
		$this->expectApiErrorCode( 'missingparam' );
		$mock = new MockApi();
		$mock->requireAtLeastOneParameter(
			[ 'a' => 'b', 'c' => 'd' ],
			'foo', 'bar' );
	}

	public function testRequireAtLeastOneParameterOne() {
		$mock = new MockApi();
		$mock->requireAtLeastOneParameter(
			[ 'a' => 'b', 'c' => 'd' ],
			'foo', 'a' );
		$this->assertTrue( true );
	}

	public function testRequireAtLeastOneParameterTwo() {
		$mock = new MockApi();
		$mock->requireAtLeastOneParameter(
			[ 'a' => 'b', 'c' => 'd' ],
			'a', 'c' );
		$this->assertTrue( true );
	}

	public function testGetTitleOrPageIdBadParams() {
		$this->expectApiErrorCode( 'invalidparammix' );
		$mock = new MockApi();
		$mock->getTitleOrPageId( [ 'title' => 'a', 'pageid' => 7 ] );
	}

	public function testGetTitleOrPageIdTitle() {
		$mock = new MockApi();
		$result = $mock->getTitleOrPageId( [ 'title' => 'Foo' ] );
		$this->assertInstanceOf( WikiPage::class, $result );
		$this->assertSame( 'Foo', $result->getTitle()->getPrefixedText() );
	}

	public function testGetTitleOrPageIdInvalidTitle() {
		$this->expectApiErrorCode( 'invalidtitle' );
		$mock = new MockApi();
		$mock->getTitleOrPageId( [ 'title' => '|' ] );
	}

	public function testGetTitleOrPageIdSpecialTitle() {
		$this->expectApiErrorCode( 'pagecannotexist' );
		$mock = new MockApi();
		$mock->getTitleOrPageId( [ 'title' => 'Special:RandomPage' ] );
	}

	public function testGetTitleOrPageIdPageId() {
		$page = $this->getExistingTestPage();
		$result = ( new MockApi() )->getTitleOrPageId(
			[ 'pageid' => $page->getId() ] );
		$this->assertInstanceOf( WikiPage::class, $result );
		$this->assertSame(
			$page->getTitle()->getPrefixedText(),
			$result->getTitle()->getPrefixedText()
		);
	}

	public function testGetTitleOrPageIdInvalidPageId() {
		$this->expectApiErrorCode( 'nosuchpageid' );
		$mock = new MockApi();
		$mock->getTitleOrPageId( [ 'pageid' => 2147483648 ] );
	}

	public function testGetTitleFromTitleOrPageIdBadParams() {
		$this->expectApiErrorCode( 'invalidparammix' );
		$mock = new MockApi();
		$mock->getTitleFromTitleOrPageId( [ 'title' => 'a', 'pageid' => 7 ] );
	}

	public function testGetTitleFromTitleOrPageIdTitle() {
		$mock = new MockApi();
		$result = $mock->getTitleFromTitleOrPageId( [ 'title' => 'Foo' ] );
		$this->assertInstanceOf( Title::class, $result );
		$this->assertSame( 'Foo', $result->getPrefixedText() );
	}

	public function testGetTitleFromTitleOrPageIdInvalidTitle() {
		$this->expectApiErrorCode( 'invalidtitle' );
		$mock = new MockApi();
		$mock->getTitleFromTitleOrPageId( [ 'title' => '|' ] );
	}

	public function testGetTitleFromTitleOrPageIdPageId() {
		$page = $this->getExistingTestPage();
		$result = ( new MockApi() )->getTitleFromTitleOrPageId(
			[ 'pageid' => $page->getId() ] );
		$this->assertInstanceOf( Title::class, $result );
		$this->assertSame( $page->getTitle()->getPrefixedText(), $result->getPrefixedText() );
	}

	public function testGetTitleFromTitleOrPageIdInvalidPageId() {
		$this->expectApiErrorCode( 'nosuchpageid' );
		$mock = new MockApi();
		$mock->getTitleFromTitleOrPageId( [ 'pageid' => 298401643 ] );
	}

	public function testGetParameter() {
		$mock = $this->getMockBuilder( MockApi::class )
			->onlyMethods( [ 'getAllowedParams' ] )
			->getMock();
		$mock->method( 'getAllowedParams' )->willReturn( [
			'foo' => [
				ParamValidator::PARAM_TYPE => [ 'value' ],
			],
			'bar' => [
				ParamValidator::PARAM_TYPE => [ 'value' ],
			],
		] );
		$wrapper = TestingAccessWrapper::newFromObject( $mock );

		$context = new DerivativeContext( $mock );
		$context->setRequest( new FauxRequest( [ 'foo' => 'bad', 'bar' => 'value' ] ) );
		$wrapper->mMainModule = new ApiMain( $context );

		// Even though 'foo' is bad, getParameter( 'bar' ) must not fail
		$this->assertSame( 'value', $wrapper->getParameter( 'bar' ) );

		// But getParameter( 'foo' ) must throw.
		try {
			$wrapper->getParameter( 'foo' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( ApiUsageException $ex ) {
			$this->assertApiErrorCode( 'badvalue', $ex );
		}

		// And extractRequestParams() must throw too.
		try {
			$mock->extractRequestParams();
			$this->fail( 'Expected exception not thrown' );
		} catch ( ApiUsageException $ex ) {
			$this->assertApiErrorCode( 'badvalue', $ex );
		}
	}

	/**
	 * @param string|null $input
	 * @param array $paramSettings
	 * @param mixed $expected
	 * @param string[] $warnings
	 * @param array $options Key-value pairs:
	 *   'parseLimits': true|false
	 *   'apihighlimits': true|false
	 *   'prefix': true|false
	 */
	private function doGetParameterFromSettings(
		$input, $paramSettings, $expected, $warnings, $options = []
	) {
		$mock = new MockApi();
		$wrapper = TestingAccessWrapper::newFromObject( $mock );
		if ( $options['prefix'] ) {
			$wrapper->mModulePrefix = 'my';
			$paramName = 'Param';
		} else {
			$paramName = 'myParam';
		}

		$context = new DerivativeContext( $mock );
		$context->setRequest( new FauxRequest(
			$input !== null ? [ 'myParam' => $input ] : [] ) );
		$wrapper->mMainModule = new ApiMain( $context );

		$parseLimits = $options['parseLimits'] ?? true;

		if ( !empty( $options['apihighlimits'] ) ) {
			$context->setUser( $this->getTestSysop()->getUser() );
		}

		// If we're testing tags, set up some tags
		if ( isset( $paramSettings[ParamValidator::PARAM_TYPE] ) &&
			$paramSettings[ParamValidator::PARAM_TYPE] === 'tags'
		) {
			$changeTagStore = $this->getServiceContainer()->getChangeTagsStore();
			$changeTagStore->defineTag( 'tag1' );
			$changeTagStore->defineTag( 'tag2' );
		}

		if ( $expected instanceof Exception ) {
			try {
				$wrapper->getParameterFromSettings( $paramName, $paramSettings,
					$parseLimits );
				$this->fail( 'No exception thrown' );
			} catch ( Exception $ex ) {
				$this->assertInstanceOf( get_class( $expected ), $ex );
				if ( $ex instanceof ApiUsageException ) {
					$this->assertEquals( $expected->getModulePath(), $ex->getModulePath() );
					$this->assertEquals( $expected->getStatusValue(), $ex->getStatusValue() );
				} else {
					$this->assertEquals( $expected->getMessage(), $ex->getMessage() );
					$this->assertEquals( $expected->getCode(), $ex->getCode() );
				}
			}
		} else {
			$result = $wrapper->getParameterFromSettings( $paramName,
				$paramSettings, $parseLimits );
			if ( isset( $paramSettings[ParamValidator::PARAM_TYPE] ) &&
				$paramSettings[ParamValidator::PARAM_TYPE] === 'timestamp' &&
				$expected === 'now'
			) {
				// Allow one second of fuzziness.  Make sure the formats are
				// correct!
				$this->assertMatchesRegularExpression( '/^\d{14}$/', $result );
				$this->assertLessThanOrEqual( 1,
					abs( wfTimestamp( TS_UNIX, $result ) - time() ),
					"Result $result differs from expected $expected by " .
					'more than one second' );
			} else {
				$this->assertSame( $expected, $result );
			}
			$actualWarnings = array_map( static function ( $warn ) {
				return $warn instanceof MessageSpecifier
					? [ $warn->getKey(), ...$warn->getParams() ]
					: $warn;
			}, $mock->warnings );
			$this->assertEquals( $warnings, $actualWarnings );
		}

		if ( !empty( $paramSettings[ParamValidator::PARAM_SENSITIVE] ) ||
			( isset( $paramSettings[ParamValidator::PARAM_TYPE] ) &&
			$paramSettings[ParamValidator::PARAM_TYPE] === 'password' )
		) {
			$mainWrapper = TestingAccessWrapper::newFromObject( $wrapper->getMain() );
			$this->assertSame( [ 'myParam' ],
				$mainWrapper->getSensitiveParams() );
		}
	}

	/**
	 * @dataProvider provideGetParameterFromSettings
	 * @see self::doGetParameterFromSettings()
	 */
	public function testGetParameterFromSettings_noprefix(
		$input, $paramSettings, $expected, $warnings, $options = []
	) {
		$options['prefix'] = false;
		$this->doGetParameterFromSettings( $input, $paramSettings, $expected, $warnings, $options );
	}

	/**
	 * @dataProvider provideGetParameterFromSettings
	 * @see self::doGetParameterFromSettings()
	 */
	public function testGetParameterFromSettings_prefix(
		$input, $paramSettings, $expected, $warnings, $options = []
	) {
		$options['prefix'] = true;
		$this->doGetParameterFromSettings( $input, $paramSettings, $expected, $warnings, $options );
	}

	public static function provideGetParameterFromSettings() {
		$warnings = [
			[ 'apiwarn-badutf8', 'myParam' ],
		];

		$c0 = '';
		$enc = '';
		for ( $i = 0; $i < 32; $i++ ) {
			$c0 .= chr( $i );
			$enc .= ( $i === 9 || $i === 10 || $i === 13 )
				? chr( $i )
				: '�';
		}

		$namespaces = MediaWikiServices::getInstance()->getNamespaceInfo()->getValidNamespaces();

		$returnArray = [
			'Basic param' => [ 'bar', null, 'bar', [] ],
			'Basic param, C0 controls' => [ $c0, null, $enc, $warnings ],
			'String param' => [ 'bar', '', 'bar', [] ],
			'String param, defaulted' => [ null, '', '', [] ],
			'String param, empty' => [ '', 'default', '', [] ],
			'String param, required, empty' => [
				'',
				[ ParamValidator::PARAM_DEFAULT => 'default', ParamValidator::PARAM_REQUIRED => true ],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-missingparam',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '' ),
				], 'missingparam' ),
				[]
			],
			'Multi-valued parameter' => [
				'a|b|c',
				[ ParamValidator::PARAM_ISMULTI => true ],
				[ 'a', 'b', 'c' ],
				[]
			],
			'Multi-valued parameter, alternative separator' => [
				"\x1fa|b\x1fc|d",
				[ ParamValidator::PARAM_ISMULTI => true ],
				[ 'a|b', 'c|d' ],
				[]
			],
			'Multi-valued parameter, other C0 controls' => [
				$c0,
				[ ParamValidator::PARAM_ISMULTI => true ],
				[ $enc ],
				$warnings
			],
			'Multi-valued parameter, other C0 controls (2)' => [
				"\x1f" . $c0,
				[ ParamValidator::PARAM_ISMULTI => true ],
				[ substr( $enc, 0, -3 ), '' ],
				$warnings
			],
			'Multi-valued parameter with limits' => [
				'a|b|c',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_ISMULTI_LIMIT1 => 3,
				],
				[ 'a', 'b', 'c' ],
				[],
			],
			'Multi-valued parameter with exceeded limits' => [
				'a|b|c',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_ISMULTI_LIMIT1 => 2,
				],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-toomanyvalues',
					Message::plaintextParam( 'myParam' ),
					Message::numParam( 2 ),
				], 'toomanyvalues', [
					'parameter' => 'myParam',
					'limit' => 2,
					'lowlimit' => 2,
					'highlimit' => 500,
				] ),
				[]
			],
			'Multi-valued parameter with exceeded limits for non-bot' => [
				'a|b|c',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_ISMULTI_LIMIT1 => 2,
					ParamValidator::PARAM_ISMULTI_LIMIT2 => 3,
				],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-toomanyvalues',
					Message::plaintextParam( 'myParam' ),
					Message::numParam( 2 ),
				], 'toomanyvalues', [
					'parameter' => 'myParam',
					'limit' => 2,
					'lowlimit' => 2,
					'highlimit' => 3,
				] ),
				[]
			],
			'Multi-valued parameter with non-exceeded limits for bot' => [
				'a|b|c',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_ISMULTI_LIMIT1 => 2,
					ParamValidator::PARAM_ISMULTI_LIMIT2 => 3,
				],
				[ 'a', 'b', 'c' ],
				[],
				[ 'apihighlimits' => true ],
			],
			'Multi-valued parameter with prohibited duplicates' => [
				'a|b|a|c',
				[ ParamValidator::PARAM_ISMULTI => true ],
				[ 'a', 'b', 'c' ],
				[],
			],
			'Multi-valued parameter with allowed duplicates' => [
				'a|a',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_ALLOW_DUPLICATES => true,
				],
				[ 'a', 'a' ],
				[],
			],
			'Empty boolean param' => [
				'',
				[ ParamValidator::PARAM_TYPE => 'boolean' ],
				true,
				[],
			],
			'Boolean param 0' => [
				'0',
				[ ParamValidator::PARAM_TYPE => 'boolean' ],
				true,
				[],
			],
			'Boolean param false' => [
				'false',
				[ ParamValidator::PARAM_TYPE => 'boolean' ],
				true,
				[],
			],
			'Deprecated parameter' => [
				'foo',
				[ ParamValidator::PARAM_DEPRECATED => true ],
				'foo',
				[ [
					'paramvalidator-param-deprecated',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( 'foo' )
				] ],
			],
			'Deprecated parameter with default, unspecified' => [
				null,
				[ ParamValidator::PARAM_DEPRECATED => true, ParamValidator::PARAM_DEFAULT => 'foo' ],
				'foo',
				[],
			],
			'Deprecated parameter with default, specified' => [
				'foo',
				[ ParamValidator::PARAM_DEPRECATED => true, ParamValidator::PARAM_DEFAULT => 'foo' ],
				'foo',
				[ [
					'paramvalidator-param-deprecated',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( 'foo' )
				] ],
			],
			'Deprecated parameter value' => [
				'a',
				[ ParamValidator::PARAM_TYPE => [ 'a' ], EnumDef::PARAM_DEPRECATED_VALUES => [ 'a' => true ] ],
				'a',
				[ [
					'paramvalidator-deprecated-value',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( 'a' )
				] ],
			],
			'Deprecated parameter value as default, unspecified' => [
				null,
				[
					ParamValidator::PARAM_TYPE => [ 'a' ],
					EnumDef::PARAM_DEPRECATED_VALUES => [ 'a' => true ],
					ParamValidator::PARAM_DEFAULT => 'a'
				],
				'a',
				[],
			],
			'Deprecated parameter value as default, specified' => [
				'a',
				[
					ParamValidator::PARAM_TYPE => [ 'a' ],
					EnumDef::PARAM_DEPRECATED_VALUES => [ 'a' => true ],
					ParamValidator::PARAM_DEFAULT => 'a'
				],
				'a',
				[ [
					'paramvalidator-deprecated-value',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( 'a' )
				] ],
			],
			'Multiple deprecated parameter values' => [
				'a|b|c|d',
				[
					ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd' ],
					EnumDef::PARAM_DEPRECATED_VALUES => [ 'b' => true, 'd' => true ],
					ParamValidator::PARAM_ISMULTI => true,
				],
				[ 'a', 'b', 'c', 'd' ],
				[
					[
						'paramvalidator-deprecated-value',
						Message::plaintextParam( 'myParam' ),
						Message::plaintextParam( 'b' )
					],
					[
						'paramvalidator-deprecated-value',
						Message::plaintextParam( 'myParam' ),
						Message::plaintextParam( 'd' )
					],
				],
			],
			'Deprecated parameter value with custom warning' => [
				'a',
				[ ParamValidator::PARAM_TYPE => [ 'a' ], EnumDef::PARAM_DEPRECATED_VALUES => [ 'a' => 'my-msg' ] ],
				'a',
				[ [ 'my-msg' ] ],
			],
			'"*" when wildcard not allowed' => [
				'*',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c' ],
				],
				[],
				[ [
					'paramvalidator-unrecognizedvalues',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '*' ),
					Message::listParam( [ Message::plaintextParam( '*' ) ], 'comma' ),
					Message::numParam( 1 ),
				] ],
			],
			'Wildcard "*"' => [
				'*',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c' ],
					ParamValidator::PARAM_ALL => true,
				],
				[ 'a', 'b', 'c' ],
				[],
			],
			'Wildcard "*" with multiples not allowed' => [
				'*',
				[
					ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c' ],
					ParamValidator::PARAM_ALL => true,
				],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-badvalue-enumnotmulti',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '*' ),
					Message::listParam( [
						Message::plaintextParam( 'a' ),
						Message::plaintextParam( 'b' ),
						Message::plaintextParam( 'c' ),
					] ),
					Message::numParam( 3 ),
				], 'badvalue' ),
				[],
			],
			'Wildcard "*" with unrestricted type' => [
				'*',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_ALL => true,
				],
				[ '*' ],
				[],
			],
			'Wildcard "x"' => [
				'x',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c' ],
					ParamValidator::PARAM_ALL => 'x',
				],
				[ 'a', 'b', 'c' ],
				[],
			],
			'Namespace with wildcard' => [
				'*',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_TYPE => 'namespace',
				],
				$namespaces,
				[],
			],
			// PARAM_ALL is ignored with namespace types.
			'Namespace with wildcard suppressed' => [
				'*',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_TYPE => 'namespace',
					ParamValidator::PARAM_ALL => false,
				],
				$namespaces,
				[],
			],
			'Namespace with wildcard "x"' => [
				'x',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_TYPE => 'namespace',
					ParamValidator::PARAM_ALL => 'x',
				],
				[],
				[ [
					'paramvalidator-unrecognizedvalues',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( 'x' ),
					Message::listParam( [ Message::plaintextParam( 'x' ) ], 'comma' ),
					Message::numParam( 1 ),
				] ],
			],
			'Password' => [
				'dDy+G?e?txnr.1:(@Ru',
				[ ParamValidator::PARAM_TYPE => 'password' ],
				'dDy+G?e?txnr.1:(@Ru',
				[],
			],
			'Sensitive field' => [
				'I am fond of pineapples',
				[ ParamValidator::PARAM_SENSITIVE => true ],
				'I am fond of pineapples',
				[],
			],
			// @todo Test actual upload
			'Namespace -1' => [
				'-1',
				[ ParamValidator::PARAM_TYPE => 'namespace' ],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-badvalue-enumnotmulti',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '-1' ),
					Message::listParam( array_map( [ Message::class, 'plaintextParam' ], $namespaces ) ),
					Message::numParam( count( $namespaces ) ),
				], 'badvalue' ),
				[],
			],
			'Extra namespace -1' => [
				'-1',
				[
					ParamValidator::PARAM_TYPE => 'namespace',
					NamespaceDef::PARAM_EXTRA_NAMESPACES => [ -1 ],
				],
				-1,
				[],
			],
			// @todo Test with PARAM_SUBMODULE_MAP unset, need
			// getModuleManager() to return something real
			'Nonexistent module' => [
				'not-a-module-name',
				[
					ParamValidator::PARAM_TYPE => 'submodule',
					SubmoduleDef::PARAM_SUBMODULE_MAP =>
						[ 'foo' => 'foo', 'bar' => 'foo+bar' ],
				],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-badvalue-enumnotmulti',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( 'not-a-module-name' ),
					Message::listParam( [
						Message::plaintextParam( 'foo' ),
						Message::plaintextParam( 'bar' ),
					] ),
					Message::numParam( 2 ),
				], 'badvalue' ),
				[],
			],
			'\\x1f with multiples not allowed' => [
				"\x1f",
				[],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-notmulti',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( "\x1f" ),
				], 'badvalue' ),
				[],
			],
			'Integer with unenforced min' => [
				'-2',
				[
					ParamValidator::PARAM_TYPE => 'integer',
					IntegerDef::PARAM_MIN => -1,
				],
				-1,
				[ [
					'paramvalidator-outofrange-min',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '-2' ),
					Message::numParam( -1 ),
					Message::numParam( '' ),
				] ],
			],
			'Integer with enforced min' => [
				'-2',
				[
					ParamValidator::PARAM_TYPE => 'integer',
					IntegerDef::PARAM_MIN => -1,
					ApiBase::PARAM_RANGE_ENFORCE => true,
				],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-outofrange-min',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '-2' ),
					Message::numParam( -1 ),
					Message::numParam( '' ),
				], 'outofrange', [ 'min' => -1, 'curmax' => null, 'max' => null, 'highmax' => null ] ),
				[],
			],
			'Integer with unenforced max' => [
				'8',
				[
					ParamValidator::PARAM_TYPE => 'integer',
					IntegerDef::PARAM_MAX => 7,
				],
				7,
				[ [
					'paramvalidator-outofrange-max',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '8' ),
					Message::numParam( '' ),
					Message::numParam( 7 ),
				] ],
			],
			'Integer with enforced max' => [
				'8',
				[
					ParamValidator::PARAM_TYPE => 'integer',
					IntegerDef::PARAM_MAX => 7,
					ApiBase::PARAM_RANGE_ENFORCE => true,
				],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-outofrange-max',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '8' ),
					Message::numParam( '' ),
					Message::numParam( 7 ),
				], 'outofrange', [ 'min' => null, 'curmax' => 7, 'max' => 7, 'highmax' => 7 ] ),
				[],
			],
			'Array of integers' => [
				'3|12|966|-1',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_TYPE => 'integer',
				],
				[ 3, 12, 966, -1 ],
				[],
			],
			'Array of integers with unenforced min/max' => [
				'3|12|966|-1',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_TYPE => 'integer',
					IntegerDef::PARAM_MIN => 0,
					IntegerDef::PARAM_MAX => 100,
				],
				[ 3, 12, 100, 0 ],
				[
					[
						'paramvalidator-outofrange-minmax',
						Message::plaintextParam( 'myParam' ),
						Message::plaintextParam( '966' ),
						Message::numParam( 0 ),
						Message::numParam( 100 ),
					],
					[
						'paramvalidator-outofrange-minmax',
						Message::plaintextParam( 'myParam' ),
						Message::plaintextParam( '-1' ),
						Message::numParam( 0 ),
						Message::numParam( 100 ),
					],
				],
			],
			'Array of integers with enforced min/max' => [
				'3|12|966|-1',
				[
					ParamValidator::PARAM_ISMULTI => true,
					ParamValidator::PARAM_TYPE => 'integer',
					IntegerDef::PARAM_MIN => 0,
					IntegerDef::PARAM_MAX => 100,
					ApiBase::PARAM_RANGE_ENFORCE => true,
				],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-outofrange-minmax',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '966' ),
					Message::numParam( 0 ),
					Message::numParam( 100 ),
				], 'outofrange', [ 'min' => 0, 'curmax' => 100, 'max' => 100, 'highmax' => 100 ] ),
				[],
			],
			'Limit with parseLimits false (numeric)' => [
				'100',
				[ ParamValidator::PARAM_TYPE => 'limit' ],
				100,
				[],
				[ 'parseLimits' => false ],
			],
			'Limit with parseLimits false (max)' => [
				'max',
				[ ParamValidator::PARAM_TYPE => 'limit' ],
				'max',
				[],
				[ 'parseLimits' => false ],
			],
			'Limit with parseLimits false (invalid)' => [
				'kitten',
				[ ParamValidator::PARAM_TYPE => 'limit' ],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-badinteger',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( 'kitten' ),
				], 'badinteger' ),
				[],
				[ 'parseLimits' => false ],
			],
			'Limit with no max, supplied "max"' => [
				'max',
				[
					ParamValidator::PARAM_TYPE => 'limit',
				],
				PHP_INT_MAX,
				[],
			],
			'Valid limit' => [
				'100',
				[
					ParamValidator::PARAM_TYPE => 'limit',
					IntegerDef::PARAM_MAX => 100,
					IntegerDef::PARAM_MAX2 => 100,
				],
				100,
				[],
			],
			'Limit max' => [
				'max',
				[
					ParamValidator::PARAM_TYPE => 'limit',
					IntegerDef::PARAM_MAX => 100,
					IntegerDef::PARAM_MAX2 => 101,
				],
				100,
				[],
			],
			'Limit max for apihighlimits' => [
				'max',
				[
					ParamValidator::PARAM_TYPE => 'limit',
					IntegerDef::PARAM_MAX => 100,
					IntegerDef::PARAM_MAX2 => 101,
				],
				101,
				[],
				[ 'apihighlimits' => true ],
			],
			'Limit too large' => [
				'101',
				[
					ParamValidator::PARAM_TYPE => 'limit',
					IntegerDef::PARAM_MAX => 100,
					IntegerDef::PARAM_MAX2 => 101,
				],
				100,
				[ [
					'paramvalidator-outofrange-minmax',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '101' ),
					Message::numParam( 0 ),
					Message::numParam( 100 ),
				] ],
			],
			'Limit okay for apihighlimits' => [
				'101',
				[
					ParamValidator::PARAM_TYPE => 'limit',
					IntegerDef::PARAM_MAX => 100,
					IntegerDef::PARAM_MAX2 => 101,
				],
				101,
				[],
				[ 'apihighlimits' => true ],
			],
			'Limit too large for apihighlimits (non-internal mode)' => [
				'102',
				[
					ParamValidator::PARAM_TYPE => 'limit',
					IntegerDef::PARAM_MAX => 100,
					IntegerDef::PARAM_MAX2 => 101,
				],
				101,
				[ [
					'paramvalidator-outofrange-minmax',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '102' ),
					Message::numParam( 0 ),
					Message::numParam( 101 ),
				] ],
				[ 'apihighlimits' => true ],
			],
			'Limit too small' => [
				'-2',
				[
					ParamValidator::PARAM_TYPE => 'limit',
					IntegerDef::PARAM_MIN => -1,
					IntegerDef::PARAM_MAX => 100,
					IntegerDef::PARAM_MAX2 => 100,
				],
				-1,
				[ [
					'paramvalidator-outofrange-minmax',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '-2' ),
					Message::numParam( -1 ),
					Message::numParam( 100 ),
				] ],
			],
			'Timestamp' => [
				wfTimestamp( TS_UNIX, '20211221122112' ),
				[ ParamValidator::PARAM_TYPE => 'timestamp' ],
				'20211221122112',
				[],
			],
			'Timestamp 0' => [
				'0',
				[ ParamValidator::PARAM_TYPE => 'timestamp' ],
				// Magic keyword
				'now',
				[ [
					'paramvalidator-unclearnowtimestamp',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '0' ),
				] ],
			],
			'Timestamp empty' => [
				'',
				[ ParamValidator::PARAM_TYPE => 'timestamp' ],
				'now',
				[ [
					'paramvalidator-unclearnowtimestamp',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '' ),
				] ],
			],
			// wfTimestamp() interprets this as Unix time
			'Timestamp 00' => [
				'00',
				[ ParamValidator::PARAM_TYPE => 'timestamp' ],
				'19700101000000',
				[],
			],
			'Timestamp now' => [
				'now',
				[ ParamValidator::PARAM_TYPE => 'timestamp' ],
				'now',
				[],
			],
			'Invalid timestamp' => [
				'a potato',
				[ ParamValidator::PARAM_TYPE => 'timestamp' ],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-badtimestamp',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( 'a potato' ),
				], 'badtimestamp' ),
				[],
			],
			'Timestamp array' => [
				'100|101',
				[
					ParamValidator::PARAM_TYPE => 'timestamp',
					ParamValidator::PARAM_ISMULTI => 1,
				],
				[ wfTimestamp( TS_MW, 100 ), wfTimestamp( TS_MW, 101 ) ],
				[],
			],
			'Expiry array' => [
				'99990123123456|8888-01-23 12:34:56|indefinite',
				[
					ParamValidator::PARAM_TYPE => 'expiry',
					ParamValidator::PARAM_ISMULTI => 1,
				],
				[ '9999-01-23T12:34:56Z', '8888-01-23T12:34:56Z', 'infinity' ],
				[],
			],
			'User' => [
				'foo_bar',
				[ ParamValidator::PARAM_TYPE => 'user' ],
				'Foo bar',
				[],
			],
			'User prefixed with "User:"' => [
				'User:foo_bar',
				[ ParamValidator::PARAM_TYPE => 'user' ],
				'Foo bar',
				[],
			],
			'Invalid username "|"' => [
				'|',
				[ ParamValidator::PARAM_TYPE => 'user' ],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-baduser',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '|' ),
				], 'baduser' ),
				[],
			],
			'Invalid username "300.300.300.300"' => [
				'300.300.300.300',
				[ ParamValidator::PARAM_TYPE => 'user' ],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-baduser',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '300.300.300.300' ),
				], 'baduser' ),
				[],
			],
			'IP range as username' => [
				'10.0.0.0/8',
				[ ParamValidator::PARAM_TYPE => 'user' ],
				'10.0.0.0/8',
				[],
			],
			'IPv6 as username' => [
				'::1',
				[ ParamValidator::PARAM_TYPE => 'user' ],
				'0:0:0:0:0:0:0:1',
				[],
			],
			'Obsolete cloaked usemod IP address as username' => [
				'1.2.3.xxx',
				[ ParamValidator::PARAM_TYPE => 'user' ],
				'1.2.3.xxx',
				[],
			],
			'Invalid username containing IP address' => [
				'This is [not] valid 1.2.3.xxx, ha!',
				[ ParamValidator::PARAM_TYPE => 'user' ],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-baduser',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( 'This is [not] valid 1.2.3.xxx, ha!' ),
				], 'baduser' ),
				[],
			],
			'External username' => [
				'M>Foo bar',
				[ ParamValidator::PARAM_TYPE => 'user' ],
				'M>Foo bar',
				[],
			],
			'Array of usernames' => [
				'foo|bar',
				[
					ParamValidator::PARAM_TYPE => 'user',
					ParamValidator::PARAM_ISMULTI => true,
				],
				[ 'Foo', 'Bar' ],
				[],
			],
			'tag' => [
				'tag1',
				[ ParamValidator::PARAM_TYPE => 'tags' ],
				[ 'tag1' ],
				[],
			],
			'Array of one tag' => [
				'tag1',
				[
					ParamValidator::PARAM_TYPE => 'tags',
					ParamValidator::PARAM_ISMULTI => true,
				],
				[ 'tag1' ],
				[],
			],
			'Array of tags' => [
				'tag1|tag2',
				[
					ParamValidator::PARAM_TYPE => 'tags',
					ParamValidator::PARAM_ISMULTI => true,
				],
				[ 'tag1', 'tag2' ],
				[],
			],
			'Invalid tag' => [
				'invalid tag',
				[ ParamValidator::PARAM_TYPE => 'tags' ],
				ApiUsageException::newWithMessage(
					null,
					[ 'tags-apply-not-allowed-one', 'invalid tag', 1 ],
					'badtags',
					[ 'disallowedtags' => [ 'invalid tag' ] ]
				),
				[],
			],
			'Unrecognized type' => [
				'foo',
				[ ParamValidator::PARAM_TYPE => 'nonexistenttype' ],
				new DomainException( "Param myParam's type is unknown - nonexistenttype" ),
				[],
			],
			'Too many bytes' => [
				'1',
				[
					StringDef::PARAM_MAX_BYTES => 0,
					StringDef::PARAM_MAX_CHARS => 0,
				],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-maxbytes',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '1' ),
					Message::numParam( 0 ),
					Message::numParam( 1 ),
				], 'maxbytes', [ 'maxbytes' => 0, 'maxchars' => 0 ] ),
				[],
			],
			'Too many chars' => [
				'§§',
				[
					StringDef::PARAM_MAX_BYTES => 4,
					StringDef::PARAM_MAX_CHARS => 1,
				],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-maxchars',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( '§§' ),
					Message::numParam( 1 ),
					Message::numParam( 2 ),
				], 'maxchars', [ 'maxbytes' => 4, 'maxchars' => 1 ] ),
				[],
			],
			'Omitted required param' => [
				null,
				[ ParamValidator::PARAM_REQUIRED => true ],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-missingparam',
					Message::plaintextParam( 'myParam' )
				], 'missingparam' ),
				[],
			],
			'Empty multi-value' => [
				'',
				[ ParamValidator::PARAM_ISMULTI => true ],
				[],
				[],
			],
			'Multi-value \x1f' => [
				"\x1f",
				[ ParamValidator::PARAM_ISMULTI => true ],
				[],
				[],
			],
			'Allowed non-multi-value with "|"' => [
				'a|b',
				[ ParamValidator::PARAM_TYPE => [ 'a|b' ] ],
				'a|b',
				[],
			],
			'Prohibited multi-value' => [
				'a|b',
				[ ParamValidator::PARAM_TYPE => [ 'a', 'b' ] ],
				ApiUsageException::newWithMessage( null, [
					'paramvalidator-badvalue-enumnotmulti',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( 'a|b' ),
					Message::listParam( [ Message::plaintextParam( 'a' ), Message::plaintextParam( 'b' ) ] ),
					Message::numParam( 2 ),
				], 'badvalue' ),
				[],
			],
		];

		$integerTests = [
			[ '+1', 1 ],
			[ '-1', -1 ],
			[ '1.5', null ],
			[ '-1.5', null ],
			[ '1abc', null ],
			[ ' 1', null ],
			[ "\t1", null, '\t1' ],
			[ "\r1", null, '\r1' ],
			[ "\f1", null, '\f1', 'badutf-8' ],
			[ "\n1", null, '\n1' ],
			[ "\v1", null, '\v1', 'badutf-8' ],
			[ "\e1", null, '\e1', 'badutf-8' ],
			[ "\x001", null, '\x001', 'badutf-8' ],
		];

		foreach ( $integerTests as $test ) {
			$desc = $test[2] ?? $test[0];
			$warnings = isset( $test[3] ) ?
				[ [ 'apiwarn-badutf8', 'myParam' ] ] : [];
			$returnArray["\"$desc\" as integer"] = [
				$test[0],
				[ ParamValidator::PARAM_TYPE => 'integer' ],
				$test[1] ?? ApiUsageException::newWithMessage( null, [
					'paramvalidator-badinteger',
					Message::plaintextParam( 'myParam' ),
					Message::plaintextParam( preg_replace( "/[\f\v\e\\0]/", '�', $test[0] ) ),
				], 'badinteger' ),
				$warnings,
			];
		}

		return $returnArray;
	}

	/**
	 * @dataProvider provideGetFinalParamDescription
	 */
	public function testGetFinalParamDescription( $paramSettings, $expectedMessages ) {
		$mock = $this->getMockBuilder( MockApi::class )
			->onlyMethods( [ 'getAllowedParams', 'getModulePath' ] )
			->getMock();
		$mock->method( 'getAllowedParams' )->willReturn( [
			'param' => $paramSettings,
		] );
		$mock->method( 'getModulePath' )->willReturn( 'test' );
		if ( $expectedMessages instanceof Exception ) {
			$this->expectExceptionObject( $expectedMessages );
		}
		$paramDescription = $mock->getFinalParamDescription();
		$this->assertArrayHasKey( 'param', $paramDescription );
		$messages = $paramDescription['param'];
		$messageKeys = array_map( static fn ( MessageSpecifier $m ) => $m->getKey(), $messages );
		$this->assertSame( $expectedMessages, $messageKeys );
	}

	public static function provideGetFinalParamDescription() {
		return [
			'default message' => [
				'settings' => [],
				'messages' => [ 'apihelp-test-param-param' ],
			],
			'custom message' => [
				'settings' => [ ApiBase::PARAM_HELP_MSG => 'foo' ],
				'messages' => [ 'foo' ],
			],
			'default per-value message' => [
				'settings' => [
					ParamValidator::PARAM_TYPE => [ 'a', 'b' ],
					ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
				],
				'messages' => [
					'apihelp-test-param-param',
					'apihelp-test-paramvalue-param-a',
					'apihelp-test-paramvalue-param-b',
				],
			],
			'custom per-value message' => [
				'settings' => [
					ParamValidator::PARAM_TYPE => [ 'a', 'b' ],
					ApiBase::PARAM_HELP_MSG_PER_VALUE => [
						'a' => 'foo',
						'b' => 'bar',
					],
				],
				'messages' => [
					'apihelp-test-param-param',
					'foo',
					'bar',
				],
			],
			'custom per-value message for strings' => [
				'settings' => [
					ParamValidator::PARAM_TYPE => 'string',
					ParamValidator::PARAM_ISMULTI => true,
					ApiBase::PARAM_HELP_MSG_PER_VALUE => [
						'a' => 'foo',
						'b' => 'bar',
					],
				],
				'messages' => [
					'apihelp-test-param-param',
					'foo',
					'bar',
				],
			],
			'must be multi-valued for per-value message' => [
				'settings' => [
					ParamValidator::PARAM_TYPE => 'string',
					ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
				],
				'messages' => new MWException(
					'Internal error in ' . ApiBase::class . '::getFinalParamDescription: '
					. 'ApiBase::PARAM_HELP_MSG_PER_VALUE may only be used when '
					. "ParamValidator::PARAM_TYPE is an array or it is 'string' "
					. 'and ParamValidator::PARAM_ISMULTI is true'
				),
			],
		];
	}

	public function testErrorArrayToStatus() {
		$this->expectDeprecationAndContinue( '/errorArrayToStatus/' );

		$mock = new MockApi();

		$msg = new Message( 'mainpage' );

		// Check empty array
		$expect = Status::newGood();
		$this->assertEquals( $expect, $mock->errorArrayToStatus( [] ) );

		// No blocked $user, so no special block handling
		$expect = Status::newGood();
		$expect->fatal( 'blockedtext' );
		$expect->fatal( 'autoblockedtext' );
		$expect->fatal( 'systemblockedtext' );
		$expect->fatal( 'mainpage' );
		$expect->fatal( $msg );
		$expect->fatal( 'parentheses', 'foobar' );
		$this->assertEquals( $expect, $mock->errorArrayToStatus( [
			[ 'blockedtext' ],
			[ 'autoblockedtext' ],
			[ 'systemblockedtext' ],
			'mainpage',
			$msg,
			[ 'parentheses', 'foobar' ],
		] ) );

		// Has a blocked $user, so special block handling
		$user = $this->getMutableTestUser()->getUser();
		$block = new DatabaseBlock( [
			'address' => $user,
			'by' => $this->getTestSysop()->getUser(),
			'reason' => __METHOD__,
			'expiry' => time() + 100500,
		] );
		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );

		$mockTrait = $this->getMockForTrait( ApiBlockInfoTrait::class );
		$language = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$mockTrait->method( 'getLanguage' )->willReturn( $language );
		$userInfoTrait = TestingAccessWrapper::newFromObject( $mockTrait );
		$blockinfo = [ 'blockinfo' => $userInfoTrait->getBlockDetails( $block ) ];

		$expect = Status::newGood();
		$expect->fatal( ApiMessage::create( 'blockedtext', 'blocked', $blockinfo ) );
		// This would normally use the 'autoblocked' code, but the codes are computed from $blockinfo
		// now rather than the message, and we're not faking it well enough
		$expect->fatal( ApiMessage::create( 'autoblockedtext', 'blocked', $blockinfo ) );
		$expect->fatal( ApiMessage::create( 'systemblockedtext', 'blocked', $blockinfo ) );
		$expect->fatal( 'mainpage' );
		$expect->fatal( $msg );
		$expect->fatal( 'parentheses', 'foobar' );
		$this->assertEquals( $expect, $mock->errorArrayToStatus( [
			[ 'blockedtext' ],
			[ 'autoblockedtext' ],
			[ 'systemblockedtext' ],
			'mainpage',
			$msg,
			[ 'parentheses', 'foobar' ],
		], $user ) );
	}

	public function testAddBlockInfoToStatus() {
		$mock = new MockApi();

		$msg = new Message( 'mainpage' );

		// Check empty array
		$expect = Status::newGood();
		$test = Status::newGood();
		$mock->addBlockInfoToStatus( $test );
		$this->assertEquals( $expect, $test );

		// No blocked $user, so no special block handling
		$expect = Status::newGood();
		$expect->fatal( 'blockedtext' );
		$expect->fatal( 'autoblockedtext' );
		$expect->fatal( 'systemblockedtext' );
		$expect->fatal( 'mainpage' );
		$expect->fatal( $msg );
		$expect->fatal( 'parentheses', 'foobar' );
		$test = clone $expect;
		$mock->addBlockInfoToStatus( $test );
		$this->assertEquals( $expect, $test );

		// Has a blocked $user, so special block handling
		$user = $this->getMutableTestUser()->getUser();
		$block = new DatabaseBlock( [
			'address' => $user,
			'by' => $this->getTestSysop()->getUser(),
			'reason' => __METHOD__,
			'expiry' => time() + 100500,
		] );
		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );

		$mockTrait = $this->getMockForTrait( ApiBlockInfoTrait::class );
		$language = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$mockTrait->method( 'getLanguage' )->willReturn( $language );
		$userInfoTrait = TestingAccessWrapper::newFromObject( $mockTrait );
		$blockinfo = [ 'blockinfo' => $userInfoTrait->getBlockDetails( $block ) ];

		$expect = Status::newGood();
		$expect->fatal( ApiMessage::create( 'blockedtext', 'blocked', $blockinfo ) );
		// This would normally use the 'autoblocked' code, but the codes are computed from $blockinfo
		// now rather than the message, and we're not faking it well enough
		$expect->fatal( ApiMessage::create( 'autoblockedtext', 'blocked', $blockinfo ) );
		$expect->fatal( ApiMessage::create( 'systemblockedtext', 'blocked', $blockinfo ) );
		$expect->fatal( 'mainpage' );
		$expect->fatal( $msg );
		$expect->fatal( 'parentheses', 'foobar' );
		$test = Status::newGood();
		$test->fatal( 'blockedtext' );
		$test->fatal( 'autoblockedtext' );
		$test->fatal( 'systemblockedtext' );
		$test->fatal( 'mainpage' );
		$test->fatal( $msg );
		$test->fatal( 'parentheses', 'foobar' );
		$mock->addBlockInfoToStatus( $test, $user );
		$this->assertEquals( $expect, $test );
	}

	public static function provideDieStatus() {
		$status = StatusValue::newGood();
		$status->error( 'foo' );
		$status->warning( 'bar' );
		yield [ $status, [ 'foo' => true, 'bar' => false ] ];

		$status = StatusValue::newGood();
		$status->warning( 'foo' );
		$status->warning( 'bar' );
		yield [ $status, [ 'foo' => true, 'bar' => true ] ];

		$status = StatusValue::newGood();
		$status->setOK( false );
		yield [ $status, [ 'unknownerror-nocode' => true ] ];

		$status = PermissionStatus::newEmpty();
		$status->setRateLimitExceeded();
		yield [ $status, [ 'ratelimited' => true ] ];

		$status = StatusValue::newFatal( 'actionthrottledtext' );
		yield [ $status, [ 'ratelimited' => true ] ];

		$status = StatusValue::newFatal( 'actionthrottled' );
		yield [ $status, [ 'ratelimited' => true ] ];

		$status = StatusValue::newFatal( 'blockedtext' );
		yield [ $status, [ 'blocked' => true ] ];

		$status = StatusValue::newFatal( 'autoblockedtext' );
		yield [ $status, [ 'autoblocked' => true ] ];
	}

	/**
	 * @dataProvider provideDieStatus
	 *
	 * @param StatusValue $status
	 * @param array $expected
	 */
	public function testDieStatus( $status, $expected ) {
		$mock = new MockApi();

		try {
			$mock->dieStatus( $status );
			$this->fail( 'Expected exception not thrown' );
		} catch ( ApiUsageException $ex ) {
			foreach ( $expected as $key => $has ) {
				$this->assertSame( $has, ApiTestCase::apiExceptionHasCode( $ex, $key ), "Exception has '$key'" );
			}
		}
	}

	/**
	 * @covers \MediaWiki\Api\ApiBase::extractRequestParams
	 */
	public function testExtractRequestParams() {
		$request = new FauxRequest( [
			'xxexists' => 'exists!',
			'xxmulti' => 'a|b|c|d|{bad}',
			'xxempty' => '',
			'xxtemplate-a' => 'A!',
			'xxtemplate-b' => 'B1|B2|B3',
			'xxtemplate-c' => '',
			'xxrecursivetemplate-b-B1' => 'X',
			'xxrecursivetemplate-b-B3' => 'Y',
			'xxrecursivetemplate-b-B4' => '?',
			'xxemptytemplate-' => 'nope',
			'foo' => 'a|b|c',
			'xxfoo' => 'a|b|c',
			'errorformat' => 'raw',
		] );
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setRequest( $request );
		$main = new ApiMain( $context );

		$mock = $this->getMockBuilder( ApiBase::class )
			->setConstructorArgs( [ $main, 'test', 'xx' ] )
			->onlyMethods( [ 'getAllowedParams' ] )
			->getMockForAbstractClass();
		$mock->method( 'getAllowedParams' )->willReturn( [
			'notexists' => null,
			'exists' => null,
			'multi' => [
				ParamValidator::PARAM_ISMULTI => true,
			],
			'empty' => [
				ParamValidator::PARAM_ISMULTI => true,
			],
			'template-{m}' => [
				ParamValidator::PARAM_ISMULTI => true,
				ApiBase::PARAM_TEMPLATE_VARS => [ 'm' => 'multi' ],
			],
			'recursivetemplate-{m}-{t}' => [
				ApiBase::PARAM_TEMPLATE_VARS => [ 't' => 'template-{m}', 'm' => 'multi' ],
			],
			'emptytemplate-{m}' => [
				ParamValidator::PARAM_ISMULTI => true,
				ApiBase::PARAM_TEMPLATE_VARS => [ 'm' => 'empty' ],
			],
			'badtemplate-{e}' => [
				ApiBase::PARAM_TEMPLATE_VARS => [ 'e' => 'exists' ],
			],
			'badtemplate2-{e}' => [
				ApiBase::PARAM_TEMPLATE_VARS => [ 'e' => 'badtemplate2-{e}' ],
			],
			'badtemplate3-{x}' => [
				ApiBase::PARAM_TEMPLATE_VARS => [ 'x' => 'foo' ],
			],
		] );

		$this->assertEquals( [
			'notexists' => null,
			'exists' => 'exists!',
			'multi' => [ 'a', 'b', 'c', 'd', '{bad}' ],
			'empty' => [],
			'template-a' => [ 'A!' ],
			'template-b' => [ 'B1', 'B2', 'B3' ],
			'template-c' => [],
			'template-d' => null,
			'recursivetemplate-a-A!' => null,
			'recursivetemplate-b-B1' => 'X',
			'recursivetemplate-b-B2' => null,
			'recursivetemplate-b-B3' => 'Y',
		], $mock->extractRequestParams() );

		$used = TestingAccessWrapper::newFromObject( $main )->getParamsUsed();
		sort( $used );
		$this->assertEquals( [
			'xxempty',
			'xxexists',
			'xxmulti',
			'xxnotexists',
			'xxrecursivetemplate-a-A!',
			'xxrecursivetemplate-b-B1',
			'xxrecursivetemplate-b-B2',
			'xxrecursivetemplate-b-B3',
			'xxtemplate-a',
			'xxtemplate-b',
			'xxtemplate-c',
			'xxtemplate-d',
		], $used );

		$warnings = $mock->getResult()->getResultData( 'warnings', [ 'Strip' => 'all' ] );
		$this->assertCount( 1, $warnings );
		$this->assertSame( 'ignoring-invalid-templated-value', $warnings[0]['code'] );
	}

}
PK       ! +w '  '  "  api/ApiContinuationManagerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiContinuationManager;
use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiResult;
use MediaWiki\Api\ApiUsageException;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\Request\FauxRequest;
use UnexpectedValueException;

/**
 * @covers \MediaWiki\Api\ApiContinuationManager
 * @group API
 */
class ApiContinuationManagerTest extends ApiTestCase {

	private static function getManager( $continue, $allModules, $generatedModules ) {
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setRequest( new FauxRequest( [ 'continue' => $continue ] ) );
		$main = new ApiMain( $context );
		return new ApiContinuationManager( $main, $allModules, $generatedModules );
	}

	public function testContinuation() {
		$allModules = [
			new MockApiQueryBase( 'mock1' ),
			new MockApiQueryBase( 'mock2' ),
			new MockApiQueryBase( 'mocklist' ),
		];
		$generator = new MockApiQueryBase( 'generator' );

		$manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
		$this->assertSame( ApiMain::class, $manager->getSource() );
		$this->assertSame( false, $manager->isGeneratorDone() );
		$this->assertSame( $allModules, $manager->getRunModules() );
		$manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] );
		$manager->addContinueParam( $allModules[2], 'mlcontinue', 2 );
		$manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 );
		$this->assertSame( [ [
			'mlcontinue' => 2,
			'm1continue' => '1|2',
			'continue' => '||mock2',
		], false ], $manager->getContinuation() );
		$this->assertSame( [
			'mock1' => [ 'm1continue' => '1|2' ],
			'mocklist' => [ 'mlcontinue' => 2 ],
			'generator' => [ 'gcontinue' => 3 ],
		], $manager->getRawContinuation() );

		$result = new ApiResult( 0 );
		$manager->setContinuationIntoResult( $result );
		$this->assertSame( [
			'mlcontinue' => 2,
			'm1continue' => '1|2',
			'continue' => '||mock2',
		], $result->getResultData( 'continue' ) );
		$this->assertSame( null, $result->getResultData( 'batchcomplete' ) );

		$manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
		$this->assertSame( false, $manager->isGeneratorDone() );
		$this->assertSame( $allModules, $manager->getRunModules() );
		$manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] );
		$manager->addGeneratorContinueParam( $generator, 'gcontinue', [ 3, 4 ] );
		$this->assertSame( [ [
			'm1continue' => '1|2',
			'continue' => '||mock2|mocklist',
		], false ], $manager->getContinuation() );
		$this->assertSame( [
			'mock1' => [ 'm1continue' => '1|2' ],
			'generator' => [ 'gcontinue' => '3|4' ],
		], $manager->getRawContinuation() );

		$manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
		$this->assertSame( false, $manager->isGeneratorDone() );
		$this->assertSame( $allModules, $manager->getRunModules() );
		$manager->addContinueParam( $allModules[2], 'mlcontinue', 2 );
		$manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 );
		$this->assertSame( [ [
			'mlcontinue' => 2,
			'gcontinue' => 3,
			'continue' => 'gcontinue||',
		], true ], $manager->getContinuation() );
		$this->assertSame( [
			'mocklist' => [ 'mlcontinue' => 2 ],
			'generator' => [ 'gcontinue' => 3 ],
		], $manager->getRawContinuation() );

		$result = new ApiResult( 0 );
		$manager->setContinuationIntoResult( $result );
		$this->assertSame( [
			'mlcontinue' => 2,
			'gcontinue' => 3,
			'continue' => 'gcontinue||',
		], $result->getResultData( 'continue' ) );
		$this->assertSame( true, $result->getResultData( 'batchcomplete' ) );

		$manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
		$this->assertSame( false, $manager->isGeneratorDone() );
		$this->assertSame( $allModules, $manager->getRunModules() );
		$manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 );
		$this->assertSame( [ [
			'gcontinue' => 3,
			'continue' => 'gcontinue||mocklist',
		], true ], $manager->getContinuation() );
		$this->assertSame( [
			'generator' => [ 'gcontinue' => 3 ],
		], $manager->getRawContinuation() );

		$manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
		$this->assertSame( false, $manager->isGeneratorDone() );
		$this->assertSame( $allModules, $manager->getRunModules() );
		$manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] );
		$manager->addContinueParam( $allModules[2], 'mlcontinue', 2 );
		$this->assertSame( [ [
			'mlcontinue' => 2,
			'm1continue' => '1|2',
			'continue' => '||mock2',
		], false ], $manager->getContinuation() );
		$this->assertSame( [
			'mock1' => [ 'm1continue' => '1|2' ],
			'mocklist' => [ 'mlcontinue' => 2 ],
		], $manager->getRawContinuation() );

		$manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
		$this->assertSame( false, $manager->isGeneratorDone() );
		$this->assertSame( $allModules, $manager->getRunModules() );
		$manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] );
		$this->assertSame( [ [
			'm1continue' => '1|2',
			'continue' => '||mock2|mocklist',
		], false ], $manager->getContinuation() );
		$this->assertSame( [
			'mock1' => [ 'm1continue' => '1|2' ],
		], $manager->getRawContinuation() );

		$manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
		$this->assertSame( false, $manager->isGeneratorDone() );
		$this->assertSame( $allModules, $manager->getRunModules() );
		$manager->addContinueParam( $allModules[2], 'mlcontinue', 2 );
		$this->assertSame( [ [
			'mlcontinue' => 2,
			'continue' => '-||mock1|mock2',
		], true ], $manager->getContinuation() );
		$this->assertSame( [
			'mocklist' => [ 'mlcontinue' => 2 ],
		], $manager->getRawContinuation() );

		$manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
		$this->assertSame( false, $manager->isGeneratorDone() );
		$this->assertSame( $allModules, $manager->getRunModules() );
		$this->assertSame( [ [], true ], $manager->getContinuation() );
		$this->assertSame( [], $manager->getRawContinuation() );

		$manager = self::getManager( '||mock2', $allModules, [ 'mock1', 'mock2' ] );
		$this->assertSame( false, $manager->isGeneratorDone() );
		$this->assertSame(
			array_values( array_diff_key( $allModules, [ 1 => 1 ] ) ),
			$manager->getRunModules()
		);

		$manager = self::getManager( '-||', $allModules, [ 'mock1', 'mock2' ] );
		$this->assertSame( true, $manager->isGeneratorDone() );
		$this->assertSame(
			array_values( array_diff_key( $allModules, [ 0 => 0, 1 => 1 ] ) ),
			$manager->getRunModules()
		);

		try {
			self::getManager( 'foo', $allModules, [ 'mock1', 'mock2' ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( ApiUsageException $ex ) {
			$this->assertApiErrorCode( 'badcontinue', $ex,
				'Expected exception'
			);
		}

		$manager = self::getManager(
			'||mock2',
			array_slice( $allModules, 0, 2 ),
			[ 'mock1', 'mock2' ]
		);
		try {
			$manager->addContinueParam( $allModules[1], 'm2continue', 1 );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame(
				'Module \'mock2\' was not supposed to have been executed, ' .
					'but it was executed anyway',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		try {
			$manager->addContinueParam( $allModules[2], 'mlcontinue', 1 );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame(
				'Module \'mocklist\' called ' . ApiContinuationManager::class . '::addContinueParam ' .
					'but was not passed to ' . ApiContinuationManager::class . '::__construct',
				$ex->getMessage(),
				'Expected exception'
			);
		}
	}

}
PK       ! s      api/ApiOpenSearchTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiOpenSearch;
use MediaWiki\Context\RequestContext;
use MediaWikiIntegrationTestCase;
use SearchEngine;
use SearchEngineConfig;
use SearchEngineFactory;
use Wikimedia\ParamValidator\ParamValidator;

/**
 * TODO convert to unit test, no integration is needed
 *
 * @covers \MediaWiki\Api\ApiOpenSearch
 */
class ApiOpenSearchTest extends MediaWikiIntegrationTestCase {
	public function testGetAllowedParams() {
		$config = $this->replaceSearchEngineConfig();
		$config->method( 'getSearchTypes' )
			->willReturn( [ 'the one ring' ] );

		[ $engine, $engineFactory ] = $this->replaceSearchEngine();

		$ctx = new RequestContext();
		$apiMain = new ApiMain( $ctx );
		$api = new ApiOpenSearch(
			$apiMain,
			'opensearch',
			$this->getServiceContainer()->getLinkBatchFactory(),
			$config,
			$engineFactory,
			$this->getServiceContainer()->getUrlUtils()
		);

		$engine->method( 'getProfiles' )
			->willReturnMap( [
				[ SearchEngine::COMPLETION_PROFILE_TYPE, $api->getUser(), [
					[
						'name' => 'normal',
						'desc-message' => 'normal-message',
						'default' => true,
					],
					[
						'name' => 'strict',
						'desc-message' => 'strict-message',
					],
				] ],
			] );

		$params = $api->getAllowedParams();

		$this->assertArrayNotHasKey( 'offset', $params );
		$this->assertArrayHasKey( 'profile', $params, print_r( $params, true ) );
		$this->assertEquals( 'normal', $params['profile'][ParamValidator::PARAM_DEFAULT] );
	}

	private function replaceSearchEngineConfig() {
		$config = $this->createMock( SearchEngineConfig::class );
		$this->setService( 'SearchEngineConfig', $config );

		return $config;
	}

	private function replaceSearchEngine() {
		$engine = $this->createMock( SearchEngine::class );
		$engineFactory = $this->createMock( SearchEngineFactory::class );
		$engineFactory->method( 'create' )
			->willReturn( $engine );
		$this->setService( 'SearchEngineFactory', $engineFactory );

		return [ $engine, $engineFactory ];
	}
}
PK       !        api/ApiBlockInfoTraitTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiBlockInfoTrait;
use MediaWiki\Block\CompositeBlock;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\SystemBlock;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWikiIntegrationTestCase;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Api\ApiBlockInfoTrait
 */
class ApiBlockInfoTraitTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;

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

		$this->setService( 'DBLoadBalancerFactory', $this->getDummyDBLoadBalancerFactory() );
	}

	/**
	 * @dataProvider provideGetBlockDetails
	 */
	public function testGetBlockDetails( $block, $expectedInfo ) {
		$language = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$mock = $this->getMockForTrait( ApiBlockInfoTrait::class );
		$mock->method( 'getLanguage' )->willReturn( $language );
		$info = TestingAccessWrapper::newFromObject( $mock )->getBlockDetails( $block );
		$subset = array_merge( [
			'blockid' => null,
			'blockedby' => '',
			'blockedbyid' => 0,
			'blockreason' => '',
			'blockexpiry' => 'infinite',
			'blockemail' => false,
			'blockowntalk' => true,
		], $expectedInfo );
		$this->assertArraySubmapSame( $subset, $info, "Matching block details" );
	}

	public static function provideGetBlockDetails() {
		return [
			'Sitewide block' => [
				new DatabaseBlock(),
				[ 'blockpartial' => false ],
			],
			'Partial block' => [
				new DatabaseBlock( [ 'sitewide' => false ] ),
				[ 'blockpartial' => true ],
			],
			'Email block' => [
				new DatabaseBlock( [ 'blockEmail' => true ] ),
				[ 'blockemail' => true ]
			],
			'System block' => [
				new SystemBlock( [ 'systemBlock' => 'proxy' ] ),
				[ 'systemblocktype' => 'proxy' ]
			],
			'Composite block' => [
				CompositeBlock::createFromBlocks(
					new DatabaseBlock( [ 'blockEmail' => false ] ),
					new DatabaseBlock( [ 'blockEmail' => true ] )
				),
				[
					'blockemail' => true,
					'blockreason' => 'There are multiple blocks against your account and/or IP address',
					'blockcomponents' => [
						[ 'blockemail' => false ],
						[ 'blockemail' => true ],
					],
				],
			],
		];
	}
}
PK       ! \˼}  }    api/ApiMessageTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use InvalidArgumentException;
use MediaWiki\Api\ApiMessage;
use MediaWiki\Api\ApiRawMessage;
use MediaWiki\Language\RawMessage;
use MediaWiki\Message\Message;
use MediaWiki\Page\PageReferenceValue;
use MediaWikiIntegrationTestCase;
use Wikimedia\TestingAccessWrapper;

/**
 * @group API
 */
class ApiMessageTest extends MediaWikiIntegrationTestCase {

	private function compareMessages( Message $msg, Message $msg2 ) {
		$this->assertSame( $msg->getKey(), $msg2->getKey(), 'getKey' );
		$this->assertSame( $msg->getKeysToTry(), $msg2->getKeysToTry(), 'getKeysToTry' );
		$this->assertSame( $msg->getParams(), $msg2->getParams(), 'getParams' );
		$this->assertSame( $msg->getLanguage(), $msg2->getLanguage(), 'getLanguage' );

		$msg = TestingAccessWrapper::newFromObject( $msg );
		$msg2 = TestingAccessWrapper::newFromObject( $msg2 );
		$this->assertSame( $msg->isInterface, $msg2->isInterface, 'interface' );
		$this->assertSame( $msg->useDatabase, $msg2->useDatabase, 'useDatabase' );
		$this->assertSame(
			$msg->contextPage ? "{$msg->contextPage->getNamespace()}:{$msg->contextPage->getDbKey()}" : null,
			$msg2->contextPage ? "{$msg->contextPage->getNamespace()}:{$msg->contextPage->getDbKey()}" : null,
			'title'
		);
	}

	/**
	 * @covers MediaWiki\Api\ApiMessageTrait
	 * @dataProvider provideCodeDefaults
	 */
	public function testCodeDefaults( $msg, $expectedCode ) {
		$apiMessage = new ApiMessage( $msg );
		$this->assertSame( $expectedCode, $apiMessage->getApiCode() );
	}

	public static function provideCodeDefaults() {
		// $msg, $expectedCode
		yield 'foo' => [ 'foo', 'foo' ];
		yield 'apierror prefix' => [ 'apierror-bar', 'bar' ];
		yield 'apiwarn prefix' => [ 'apiwarn-baz', 'baz' ];
		yield 'Weird "message key"' => [ "<foo> bar\nbaz", '_foo__bar_baz' ];
		yield 'BC string' => [ 'actionthrottledtext', 'ratelimited' ];
		yield 'array' => [ [ 'apierror-missingparam', 'param' ], 'noparam' ];
	}

	/**
	 * @covers MediaWiki\Api\ApiMessageTrait
	 * @dataProvider provideInvalidCode
	 */
	public function testInvalidCode( $code ) {
		$msg = new ApiMessage( 'foo' );
		try {
			$msg->setApiCode( $code );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertTrue( true );
		}

		try {
			new ApiMessage( 'foo', $code );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertTrue( true );
		}
	}

	public static function provideInvalidCode() {
		return [
			[ '' ],
			[ 42 ],
			[ 'A bad code' ],
			[ 'Project:A_page_title' ],
			[ "WTF\nnewlines" ],
		];
	}

	/**
	 * @covers \MediaWiki\Api\ApiMessage
	 * @covers MediaWiki\Api\ApiMessageTrait
	 */
	public function testApiMessage() {
		$msg = new Message( [ 'foo', 'bar' ], [ 'baz' ] );
		$msg->inLanguage( 'de' )
			->page(
				PageReferenceValue::localReference( NS_MAIN, 'Main_Page' )
			);
		$msg2 = new ApiMessage( $msg, 'code', [ 'data' ] );
		$this->compareMessages( $msg, $msg2 );
		$this->assertEquals( 'code', $msg2->getApiCode() );
		$this->assertEquals( [ 'data' ], $msg2->getApiData() );

		$msg2 = unserialize( serialize( $msg2 ) );
		$this->compareMessages( $msg, $msg2 );
		$this->assertEquals( 'code', $msg2->getApiCode() );
		$this->assertEquals( [ 'data' ], $msg2->getApiData() );

		$msg = new Message( [ 'foo', 'bar' ], [ 'baz' ] );
		$msg2 = new ApiMessage( [ [ 'foo', 'bar' ], 'baz' ], 'code', [ 'data' ] );
		$this->compareMessages( $msg, $msg2 );
		$this->assertEquals( 'code', $msg2->getApiCode() );
		$this->assertEquals( [ 'data' ], $msg2->getApiData() );

		$msg = new Message( 'foo' );
		$msg2 = new ApiMessage( 'foo' );
		$this->compareMessages( $msg, $msg2 );
		$this->assertEquals( 'foo', $msg2->getApiCode() );
		$this->assertEquals( [], $msg2->getApiData() );

		$msg2->setApiCode( 'code', [ 'data' ] );
		$this->assertEquals( 'code', $msg2->getApiCode() );
		$this->assertEquals( [ 'data' ], $msg2->getApiData() );
		$msg2->setApiCode( null );
		$this->assertEquals( 'foo', $msg2->getApiCode() );
		$this->assertEquals( [ 'data' ], $msg2->getApiData() );
		$msg2->setApiData( [ 'data2' ] );
		$this->assertEquals( [ 'data2' ], $msg2->getApiData() );
	}

	/**
	 * @covers \MediaWiki\Api\ApiRawMessage
	 * @covers MediaWiki\Api\ApiMessageTrait
	 */
	public function testApiRawMessage() {
		$msg = new RawMessage( 'foo', [ 'baz' ] );
		$msg->inLanguage( 'de' )->page(
			PageReferenceValue::localReference( NS_MAIN, 'Main_Page' )
		);
		$msg2 = new ApiRawMessage( $msg, 'code', [ 'data' ] );
		$this->compareMessages( $msg, $msg2 );
		$this->assertEquals( 'code', $msg2->getApiCode() );
		$this->assertEquals( [ 'data' ], $msg2->getApiData() );

		$msg2 = unserialize( serialize( $msg2 ) );
		$this->compareMessages( $msg, $msg2 );
		$this->assertEquals( 'code', $msg2->getApiCode() );
		$this->assertEquals( [ 'data' ], $msg2->getApiData() );

		$msg = new RawMessage( 'foo', [ 'baz' ] );
		$msg2 = new ApiRawMessage( [ 'foo', 'baz' ], 'code', [ 'data' ] );
		$this->compareMessages( $msg, $msg2 );
		$this->assertEquals( 'code', $msg2->getApiCode() );
		$this->assertEquals( [ 'data' ], $msg2->getApiData() );

		$msg = new RawMessage( 'foo' );
		$msg2 = new ApiRawMessage( 'foo', 'code', [ 'data' ] );
		$this->compareMessages( $msg, $msg2 );
		$this->assertEquals( 'code', $msg2->getApiCode() );
		$this->assertEquals( [ 'data' ], $msg2->getApiData() );

		$msg2->setApiCode( 'code', [ 'data' ] );
		$this->assertEquals( 'code', $msg2->getApiCode() );
		$this->assertEquals( [ 'data' ], $msg2->getApiData() );
		$msg2->setApiCode( null );
		$this->assertEquals( 'foo', $msg2->getApiCode() );
		$this->assertEquals( [ 'data' ], $msg2->getApiData() );
		$msg2->setApiData( [ 'data2' ] );
		$this->assertEquals( [ 'data2' ], $msg2->getApiData() );
	}

	/**
	 * @covers \MediaWiki\Api\ApiMessage::create
	 */
	public function testApiMessageCreate() {
		$this->assertInstanceOf( ApiMessage::class, ApiMessage::create( new Message( 'mainpage' ) ) );
		$this->assertInstanceOf(
			ApiRawMessage::class, ApiMessage::create( new RawMessage( 'mainpage' ) )
		);
		$this->assertInstanceOf( ApiMessage::class, ApiMessage::create( 'mainpage' ) );

		$msg = new ApiMessage( [ 'parentheses', 'foobar' ] );
		$msg2 = new Message( 'parentheses', [ 'foobar' ] );

		$this->assertSame( $msg, ApiMessage::create( $msg ) );
		$this->assertEquals( $msg, ApiMessage::create( $msg2 ) );
		$this->assertEquals( $msg, ApiMessage::create( [ 'parentheses', 'foobar' ] ) );
		$this->assertEquals( $msg,
			ApiMessage::create( [ 'message' => 'parentheses', 'params' => [ 'foobar' ] ] )
		);
		$this->assertSame( $msg,
			ApiMessage::create( [ 'message' => $msg, 'params' => [ 'xxx' ] ] )
		);
		$this->assertEquals( $msg,
			ApiMessage::create( [ 'message' => $msg2, 'params' => [ 'xxx' ] ] )
		);
		$this->assertSame( $msg,
			ApiMessage::create( [ 'message' => $msg ] )
		);

		$msg = new ApiRawMessage( [ 'parentheses', 'foobar' ] );
		$this->assertSame( $msg, ApiMessage::create( $msg ) );
	}

}
PK       ! 	+֥      api/ApiClearHasMsgTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Permissions\UltimateAuthority;
use MediaWiki\User\UserIdentityValue;

/**
 * @group API
 * @group medium
 * @group Database
 * @covers \MediaWiki\Api\ApiClearHasMsg
 */
class ApiClearHasMsgTest extends ApiTestCase {

	/**
	 * Test clearing hasmsg flag for current user
	 */
	public function testClearFlag() {
		$user = new UserIdentityValue( 42, __METHOD__ );
		$talkPageNotificationManager = $this->getServiceContainer()
			->getTalkPageNotificationManager();
		$talkPageNotificationManager->setUserHasNewMessages( $user );
		$this->assertTrue( $talkPageNotificationManager->userHasNewMessages( $user ) );

		$data = $this->doApiRequest(
			[ 'action' => 'clearhasmsg' ],
			[],
			false,
			new UltimateAuthority( $user )
		);

		$this->assertEquals( 'success', $data[0]['clearhasmsg'] );
		$this->assertFalse( $talkPageNotificationManager->userHasNewMessages( $user ) );
	}

}
PK       ! 9NG  G    api/ApiRollbackTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiUsageException;
use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;

/**
 * Tests for Rollback API.
 *
 * @group API
 * @group Database
 * @group medium
 *
 * @covers MediaWiki\Api\ApiRollback
 */
class ApiRollbackTest extends ApiTestCase {

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

		$this->overrideConfigValue( MainConfigNames::WatchlistExpiry, true );
	}

	public function testProtectWithWatch(): void {
		$title = Title::makeTitle( NS_MAIN, 'TestProtectWithWatch' );
		$revisionStore = $this->getServiceContainer()->getRevisionStore();

		$user = $this->getTestUser()->getUser();
		$sysop = $this->getTestSysop()->getUser();

		// Create page as sysop.
		$this->editPage( $title, 'Some text', '', NS_MAIN, $sysop );

		// Edit as non-sysop.
		$this->editPage( $title, 'Vandalism', '', NS_MAIN, $user );

		// Rollback as sysop.
		$apiResult = $this->doApiRequestWithToken( [
			'action' => 'rollback',
			'title' => $title->getPrefixedText(),
			'user' => $user->getName(),
			'watchlist' => 'watch',
			'watchlistexpiry' => '99990123000000',
		] )[0];

		// Content of latest revision should match the initial.
		$latestRev = $revisionStore->getRevisionByTitle( $title );
		$initialRev = $revisionStore->getFirstRevision( $title );
		$this->assertTrue( $latestRev->hasSameContent( $initialRev ) );
		// ...but have different rev IDs.
		$this->assertNotSame( $latestRev->getId(), $initialRev->getId() );

		// Make sure the API response looks good.
		$this->assertArrayHasKey( 'rollback', $apiResult );
		$this->assertSame( $title->getPrefixedText(), $apiResult['rollback']['title'] );

		// And that the page was temporarily watched.
		$this->assertTrue( $this->getServiceContainer()->getWatchlistManager()->isTempWatched( $sysop, $title ) );

		$recentChange = $revisionStore->getRecentChange( $latestRev );
		$this->assertSame( '0', $recentChange->getAttribute( 'rc_bot' ) );
		$this->assertSame( $sysop->getName(), $recentChange->getAttribute( 'rc_user_text' ) );
	}

	public function testRollbackMarkAsBot() {
		$revisionStore = $this->getServiceContainer()->getRevisionStore();
		$title = Title::makeTitle( NS_MAIN, 'ApiRollbackTest::testRollbackMarkAsBot' );

		$user = $this->getTestUser()->getUser();
		$sysop = $this->getTestSysop()->getUser();

		// Create page as sysop.
		$this->editPage( $title, 'Some text', '', NS_MAIN, $sysop );

		// Edit as non-sysop.
		$this->editPage( $title, 'Vandalism', '', NS_MAIN, $user );

		// Rollback as sysop.
		$apiResult = $this->doApiRequestWithToken( [
			'action' => 'rollback',
			'title' => $title->getPrefixedText(),
			'user' => $user->getName(),
			'markbot' => true
		] )[0];
		// Make sure the API response looks good.
		$this->assertArrayHasKey( 'rollback', $apiResult );
		$this->assertSame( $title->getPrefixedText(), $apiResult['rollback']['title'] );

		$recentChange = $revisionStore->getRecentChange( $revisionStore->getRevisionByTitle( $title ) );
		$this->assertSame( '1', $recentChange->getAttribute( 'rc_bot' ) );
	}

	public function testRollbackNoToken() {
		$user = $this->getTestUser()->getUser();
		$sysop = $this->getTestSysop()->getUser();

		$title = Title::makeTitle( NS_MAIN, 'ApiRollbackTest::testRollbackNoToken' );
		// Create page as sysop.
		$this->editPage( $title, 'Some text', '', NS_MAIN, $sysop );

		// Edit as non-sysop.
		$this->editPage( $title, 'Vandalism', '', NS_MAIN, $user );

		$this->expectException( ApiUsageException::class );
		$this->doApiRequest( [
			'action' => 'rollback',
			'title' => $title->getPrefixedText(),
			'user' => $user->getName(),
		] )[0];
	}
}
PK       ! x8      api/RandomImageGenerator.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

/**
 * RandomImageGenerator -- does what it says on the tin.
 * Requires Imagick, the ImageMagick library for PHP, or the command line
 * equivalent (usually 'convert').
 *
 * @file
 * @author Neil Kandalgaonkar <neilk@wikimedia.org>
 */

use Exception;
use Imagick;
use ImagickPixel;
use MediaWiki\Shell\Shell;
use SimpleXMLElement;
use UnexpectedValueException;

/**
 * RandomImageGenerator: does what it says on the tin.
 * Can fetch a random image, or also write a number of them to disk with random filenames.
 */
class RandomImageGenerator {
	/** @var int */
	private $minWidth = 16;
	/** @var int */
	private $maxWidth = 16;
	/** @var int */
	private $minHeight = 16;
	/** @var int */
	private $maxHeight = 16;

	public function __construct( $options = [] ) {
		foreach ( [ 'minWidth', 'minHeight',
			'maxWidth', 'maxHeight' ] as $property
		) {
			if ( isset( $options[$property] ) ) {
				$this->$property = $options[$property];
			}
		}
	}

	/**
	 * Writes random images with random filenames to disk in the directory you
	 * specify, or current working directory.
	 *
	 * @param int $number Number of filenames to write
	 * @param string $format Optional, must be understood by ImageMagick, such as 'jpg' or 'gif'
	 * @param string|null $dir Directory, optional (will default to current working directory)
	 * @return string[] Filenames we just wrote
	 */
	public function writeImages( int $number, string $format = 'svg', ?string $dir = null ): array {
		$filenames = $this->getRandomFilenames( $number, $format, $dir ?? getcwd() );
		$imageWriteMethod = $this->getImageWriteMethod( $format );
		foreach ( $filenames as $filename ) {
			$imageWriteMethod( $this->getImageSpec(), $format, $filename );
		}

		return $filenames;
	}

	/**
	 * Figure out how we write images. This is a factor of both format and the local system
	 *
	 * @param string $format (a typical extension like 'svg', 'jpg', etc.)
	 *
	 * @throws Exception
	 */
	private function getImageWriteMethod( string $format ): callable {
		global $wgUseImageMagick, $wgImageMagickConvertCommand;
		if ( $format === 'svg' ) {
			return [ $this, 'writeSvg' ];
		} else {
			// figure out how to write images
			global $wgExiv2Command;
			if ( class_exists( Imagick::class ) && $wgExiv2Command && is_executable( $wgExiv2Command ) ) {
				return [ $this, 'writeImageWithApi' ];
			} elseif ( $wgUseImageMagick
				&& $wgImageMagickConvertCommand
				&& is_executable( $wgImageMagickConvertCommand )
			) {
				return [ $this, 'writeImageWithCommandLine' ];
			}
		}
		throw new Exception( "RandomImageGenerator: could not find a suitable "
			. "method to write images in '$format' format" );
	}

	/**
	 * Return a number of randomly-generated filenames.
	 *
	 * Each filename uses follows the pattern "hex_timestamp_1.jpg".
	 *
	 * @return string[]
	 */
	private function getRandomFilenames( int $number, string $extension, string $dir ): array {
		$filenames = [];
		$prefix = wfRandomString( 3 ) . '_' . gmdate( 'YmdHis' ) . '_';
		foreach ( range( 1, $number ) as $offset ) {
			$filename = $prefix . $offset . '.' . $extension;
			$filenames[] = "$dir/$filename";
		}

		return $filenames;
	}

	/**
	 * Generate data representing an image of random size (within limits),
	 * consisting of randomly colored and sized upward pointing triangles
	 * against a random background color. (This data is used in the
	 * writeImage* methods).
	 */
	private function getImageSpec(): array {
		return [
			'width' => mt_rand( $this->minWidth, $this->maxWidth ),
			'height' => mt_rand( $this->minHeight, $this->maxHeight ),
			'fill' => '#f0f',
		];
	}

	/**
	 * Based on image specification, write a very simple SVG file to disk.
	 * Ignores the background spec because transparency is cool. :)
	 *
	 * @throws Exception
	 */
	private function writeSvg( array $spec, string $format, string $filename ): void {
		$svg = new SimpleXmlElement( '<svg/>' );
		$svg->addAttribute( 'xmlns', 'http://www.w3.org/2000/svg' );
		$svg->addAttribute( 'width', $spec['width'] );
		$svg->addAttribute( 'height', $spec['height'] );

		$fh = fopen( $filename, 'w' );
		if ( !$fh ) {
			throw new UnexpectedValueException( "couldn't open $filename for writing" );
		}
		fwrite( $fh, $svg->asXML() );
		if ( !fclose( $fh ) ) {
			throw new UnexpectedValueException( "couldn't close $filename" );
		}
	}

	/**
	 * Based on an image specification, write such an image to disk, using Imagick PHP extension
	 */
	private function writeImageWithApi( array $spec, string $format, string $filename ): void {
		$image = new Imagick();
		$image->newImage( $spec['width'], $spec['height'], new ImagickPixel( $spec['fill'] ) );
		$image->setImageFormat( $format );
		$image->writeImage( $filename );
	}

	/**
	 * Based on an image specification, write such an image to disk, using the
	 * command line ImageMagick program ('convert').
	 *
	 * Sample command line:
	 *  $ convert -size 100x60 xc:rgb(90,87,45) \
	 *      -draw 'fill rgb(12,34,56)   polygon 41,39 44,57 50,57 41,39' \
	 *   -draw 'fill rgb(99,123,231) circle 59,39 56,57' \
	 *   -draw 'fill rgb(240,12,32)  circle 50,21 50,3'  filename.png
	 */
	private function writeImageWithCommandLine( array $spec, string $format, string $filename ): void {
		global $wgImageMagickConvertCommand;

		$args = [
			$wgImageMagickConvertCommand,
			'-size',
			$spec['width'] . 'x' . $spec['height'],
			"xc:{$spec['fill']}",
			$filename,
		];
		Shell::command( $args )->execute();
	}

}

/** @deprecated class alias since 1.42 */
class_alias( RandomImageGenerator::class, 'RandomImageGenerator' );
PK       ! }      api/ApiPurgeTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\PermissionStatus;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers MediaWiki\Api\ApiPurge
 */
class ApiPurgeTest extends ApiTestCase {

	public function testPurgePage() {
		$existingPageTitle = 'TestPurgePageExists';
		$this->getExistingTestPage( $existingPageTitle );
		$nonexistingPageTitle = 'TestPurgePageDoesNotExists';
		$this->getNonexistingTestPage( $nonexistingPageTitle );

		[ $data ] = $this->doApiRequest( [
			'action' => 'purge',
			'titles' => "$existingPageTitle|$nonexistingPageTitle|%5D"
		] );

		$resultByTitle = [];
		foreach ( $data['purge'] as $entry ) {
			$key = $entry['title'];
			// Ignore localised or redundant field
			unset( $entry['invalidreason'] );
			unset( $entry['title'] );
			$resultByTitle[$key] = $entry;
		}

		$this->assertEquals(
			[
				$existingPageTitle => [ 'purged' => true, 'ns' => NS_MAIN ],
				$nonexistingPageTitle => [ 'missing' => true, 'ns' => NS_MAIN ],
				'%5D' => [ 'invalid' => true ],
			],
			$resultByTitle,
			'Result'
		);
	}

	public function testAuthorize() {
		$page1 = 'TestPage1';
		$page2 = 'TestPage2';
		$this->getExistingTestPage( $page1 );
		$this->getExistingTestPage( $page2 );

		$user = RequestContext::getMain()->getUser();

		$authority = $this->createNoOpMock(
			Authority::class,
			[
				'authorizeAction',
				'getUser',
				'isAllowed',
				'getBlock'
			]
		);

		$authority->method( 'getUser' )->willReturn( $user );
		$authority->method( 'getBlock' )->willReturn( null );
		$authority->method( 'isAllowed' )->willReturn( true );
		$authority->method( 'authorizeAction' )->willReturnCallback(
			static function ( string $action, PermissionStatus $status ) {
				$status->setRateLimitExceeded();

				return false;
			}
		);

		[ $data ] = $this->doApiRequest( [
			'action' => 'purge',
			'titles' => "$page1|$page2"
		], null, false, $authority );

		$this->assertNotEmpty( $data['warnings']['purge']['warnings'] );
		$warnings = $data['warnings']['purge']['warnings'];

		$this->assertStringContainsString( 'exceeded your rate limit', $warnings );
	}

	public function testAuthorizeRateLimit() {
		$page1 = 'TestPage1';
		$page2 = 'TestPage2';
		$this->getExistingTestPage( $page1 );
		$this->getExistingTestPage( $page2 );

		$authority = $this->getTestUser()->getAuthority();

		// purge is limited, linkpurge is not limited
		$this->overrideConfigValue( MainConfigNames::RateLimits,
			[ 'purge' => [ '&can-bypass' => false, 'user' => [ 1, 60 ] ] ]
		);
		[ $data ] = $this->doApiRequest( [
			'action' => 'purge',
			'titles' => "$page1|$page2",
			'forcelinkupdate' => '',
		], null, false, $authority );

		$this->assertNotEmpty( $data['warnings']['purge']['warnings'] );
		$warnings = $data['warnings']['purge']['warnings'];

		$this->assertStringContainsString( 'exceeded your rate limit', $warnings );

		// purge is not limited, linkpurge is limited
		$this->overrideConfigValue( MainConfigNames::RateLimits,
			[ 'linkpurge' => [ '&can-bypass' => false, 'user' => [ 1, 60 ] ] ]
		);
		[ $data ] = $this->doApiRequest( [
			'action' => 'purge',
			'titles' => "$page1|$page2",
			'forcelinkupdate' => '',
		], null, false, $authority );

		$this->assertNotEmpty( $data['warnings']['purge']['warnings'] );
		$warnings = $data['warnings']['purge']['warnings'];

		$this->assertStringContainsString( 'exceeded your rate limit', $warnings );
	}
}
PK       ! 9C@  C@    api/ApiPageSetTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiPageSet;
use MediaWiki\Api\ApiResult;
use MediaWiki\Context\RequestContext;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageReference;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use RuntimeException;
use Wikimedia\TestingAccessWrapper;

/**
 * @group API
 * @group medium
 * @group Database
 * @covers \MediaWiki\Api\ApiPageSet
 */
class ApiPageSetTest extends ApiTestCase {
	use DummyServicesTrait;

	public static function provideRedirectMergePolicy() {
		return [
			'By default nothing is merged' => [
				null,
				[]
			],

			'A simple merge policy adds the redirect data in' => [
				static function ( $current, $new ) {
					if ( !isset( $current['index'] ) || $new['index'] < $current['index'] ) {
						$current['index'] = $new['index'];
					}
					return $current;
				},
				[ 'index' => 1 ],
			],
		];
	}

	/**
	 * @dataProvider provideRedirectMergePolicy
	 */
	public function testRedirectMergePolicyWithArrayResult( $mergePolicy, $expect ) {
		[ $target, $pageSet ] = $this->createPageSetWithRedirect();
		$pageSet->setRedirectMergePolicy( $mergePolicy );
		$result = [
			$target->getArticleID() => []
		];
		$pageSet->populateGeneratorData( $result );
		$this->assertEquals( $expect, $result[$target->getArticleID()] );
	}

	/**
	 * @dataProvider provideRedirectMergePolicy
	 */
	public function testRedirectMergePolicyWithApiResult( $mergePolicy, $expect ) {
		[ $target, $pageSet ] = $this->createPageSetWithRedirect();
		$pageSet->setRedirectMergePolicy( $mergePolicy );
		$result = new ApiResult( false );
		$result->addValue( null, 'pages', [
			$target->getArticleID() => []
		] );
		$pageSet->populateGeneratorData( $result, [ 'pages' ] );
		$this->assertEquals(
			$expect,
			$result->getResultData( [ 'pages', $target->getArticleID() ] )
		);
	}

	private function newApiPageSet( $reqParams = [] ) {
		$request = new FauxRequest( $reqParams );
		$context = new RequestContext();
		$context->setRequest( $request );

		$main = new ApiMain( $context );
		$pageSet = new ApiPageSet( $main );

		return $pageSet;
	}

	protected function createPageSetWithRedirect( $targetContent = 'api page set test' ) {
		$target = Title::makeTitle( NS_MAIN, 'UTRedirectTarget' );
		$sourceA = Title::makeTitle( NS_MAIN, 'UTRedirectSourceA' );
		$sourceB = Title::makeTitle( NS_MAIN, 'UTRedirectSourceB' );
		$this->editPage( 'UTRedirectTarget', $targetContent );
		$this->editPage( 'UTRedirectSourceA', '#REDIRECT [[UTRedirectTarget]]' );
		$this->editPage( 'UTRedirectSourceB', '#REDIRECT [[UTRedirectTarget]]' );

		$pageSet = $this->newApiPageSet( [ 'redirects' => 1 ] );

		$pageSet->setGeneratorData( $sourceA, [ 'index' => 1 ] );
		$pageSet->setGeneratorData( $sourceB, [ 'index' => 3 ] );
		$pageSet->populateFromTitles( [ $sourceA, $sourceB ] );

		return [ $target, $pageSet ];
	}

	public function testRedirectMergePolicyRedirectLoop() {
		$this->hideDeprecated( ApiPageSet::class . '::getTitles' );

		$redirectOneTitle = 'ApiPageSetTestRedirectOne';
		$redirectTwoTitle = 'ApiPageSetTestRedirectTwo';
		$this->editPage( $redirectOneTitle, "#REDIRECT [[$redirectTwoTitle]]" );
		$this->editPage( $redirectTwoTitle, "#REDIRECT [[$redirectOneTitle]]" );
		[ $target, $pageSet ] = $this->createPageSetWithRedirect(
			"#REDIRECT [[$redirectOneTitle]]"
		);
		$pageSet->setRedirectMergePolicy( static function ( $cur, $new ) {
			throw new RuntimeException( 'unreachable, no merge when target is redirect loop' );
		} );
		// This could infinite loop in a bugged impl, but php doesn't offer
		// a great way to time constrain this.
		$result = new ApiResult( false );
		$pageSet->populateGeneratorData( $result );
		// Assert something, mostly we care that the above didn't infinite loop.
		// This verifies the page set followed our redirect chain and saw the loop.
		$this->assertEqualsCanonicalizing(
			[
				'UTRedirectSourceA', 'UTRedirectSourceB', 'UTRedirectTarget',
				$redirectOneTitle, $redirectTwoTitle,
			],
			array_map( static function ( $x ) {
				return $x->getPrefixedText();
			}, $pageSet->getTitles() )
		);
	}

	public function testHandleNormalization() {
		$pageSet = $this->newApiPageSet( [ 'titles' => "a|B|a\xcc\x8a" ] );
		$pageSet->execute();

		$this->assertSame(
			[ 0 => [ 'A' => -1, 'B' => -2, 'Å' => -3 ] ],
			$pageSet->getAllTitlesByNamespace()
		);
		$this->assertSame(
			[
				[ 'fromencoded' => true, 'from' => 'a%CC%8A', 'to' => 'å' ],
				[ 'fromencoded' => false, 'from' => 'a', 'to' => 'A' ],
				[ 'fromencoded' => false, 'from' => 'å', 'to' => 'Å' ],
			],
			$pageSet->getNormalizedTitlesAsResult()
		);
	}

	public static function provideConversionWithRedirects() {
		return [
			'convert, redirect, convert' => [
				[
					'Esttay 1' => '#REDIRECT [[Test 2]]',
					'Esttay 2' => '',
				],
				[ 'titles' => 'Test 1', 'converttitles' => 1, 'redirects' => 1 ],
				[
					[ 'from' => 'Test 1', 'to' => 'Esttay 1' ],
					[ 'from' => 'Test 2', 'to' => 'Esttay 2' ]
				],
				[ [ 'from' => 'Esttay 1', 'to' => 'Test 2' ] ],
			],

			'redirect, convert, redirect' => [
				[
					'Esttay 1' => '#REDIRECT [[Test 2]]',
					'Esttay 2' => '#REDIRECT [[Esttay 3]]',
				],
				[ 'titles' => 'Esttay 1', 'converttitles' => 1, 'redirects' => 1 ],
				[ [ 'from' => 'Test 2', 'to' => 'Esttay 2' ] ],
				[
					[ 'from' => 'Esttay 1', 'to' => 'Test 2' ],
					[ 'from' => 'Esttay 2', 'to' => 'Esttay 3' ]
				],
			],

			'self-redirect to variant, with converttitles' => [
				[ 'Esttay' => '#REDIRECT [[Test]]' ],
				[ 'titles' => 'Esttay', 'converttitles' => 1, 'redirects' => 1 ],
				[ [ 'from' => 'Test', 'to' => 'Esttay' ] ],
				[ [ 'from' => 'Esttay', 'to' => 'Test' ] ],
			],

			'self-redirect to variant, without converttitles' => [
				[ 'Esttay' => '#REDIRECT [[Test]]' ],
				[ 'titles' => 'Esttay', 'redirects' => 1 ],
				[],
				[ [ 'from' => 'Esttay', 'to' => 'Test' ] ],
			],
		];
	}

	/**
	 * @dataProvider provideConversionWithRedirects
	 */
	public function testHandleConversionWithRedirects( $pages, $params, $expectedConversion, $expectedRedirects ) {
		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, true );
		foreach ( $pages as $title => $content ) {
			$this->editPage( $title, $content );
		}

		$pageSet = $this->newApiPageSet( $params );
		$pageSet->execute();

		$this->assertSame( $expectedConversion, $pageSet->getConvertedTitlesAsResult() );
		$this->assertSame( $expectedRedirects, $pageSet->getRedirectTitlesAsResult() );
	}

	public function testSpecialRedirects() {
		$id1 = $this->editPage( 'UTApiPageSet', 'UTApiPageSet in the default language' )
			->getNewRevision()->getPageId();
		$id2 = $this->editPage( 'UTApiPageSet/de', 'UTApiPageSet in German' )
			->getNewRevision()->getPageId();

		$user = $this->getTestUser()->getUser();
		$userName = $user->getName();
		$userDbkey = str_replace( ' ', '_', $userName );
		$request = new FauxRequest( [
			'titles' => implode( '|', [
				'Special:MyContributions',
				'Special:MyPage',
				'Special:MyTalk/subpage',
				'Special:MyLanguage/UTApiPageSet',
			] ),
		] );
		$context = new RequestContext();
		$context->setRequest( $request );
		$context->setUser( $user );

		$main = new ApiMain( $context );
		$pageSet = new ApiPageSet( $main );
		$pageSet->execute();

		$this->assertEquals( [
		], $pageSet->getRedirectTitlesAsResult() );
		$this->assertEquals( [
			[ 'ns' => NS_SPECIAL, 'title' => 'Special:MyContributions', 'special' => true ],
			[ 'ns' => NS_SPECIAL, 'title' => 'Special:MyPage', 'special' => true ],
			[ 'ns' => NS_SPECIAL, 'title' => 'Special:MyTalk/subpage', 'special' => true ],
			[ 'ns' => NS_SPECIAL, 'title' => 'Special:MyLanguage/UTApiPageSet', 'special' => true ],
		], $pageSet->getInvalidTitlesAndRevisions() );
		$this->assertEquals( [
		], $pageSet->getAllTitlesByNamespace() );

		$request->setVal( 'redirects', 1 );
		$main = new ApiMain( $context );
		$pageSet = new ApiPageSet( $main );
		$pageSet->execute();

		$this->assertEquals( [
			[ 'from' => 'Special:MyPage', 'to' => "User:$userName" ],
			[ 'from' => 'Special:MyTalk/subpage', 'to' => "User talk:$userName/subpage" ],
			[ 'from' => 'Special:MyLanguage/UTApiPageSet', 'to' => 'UTApiPageSet' ],
		], $pageSet->getRedirectTitlesAsResult() );
		$this->assertEquals( [
			[ 'ns' => NS_SPECIAL, 'title' => 'Special:MyContributions', 'special' => true ],
			[ 'ns' => NS_USER, 'title' => "User:$userName", 'missing' => true ],
			[ 'ns' => NS_USER_TALK, 'title' => "User talk:$userName/subpage", 'missing' => true ],
		], $pageSet->getInvalidTitlesAndRevisions() );
		$this->assertEquals( [
			NS_MAIN => [ 'UTApiPageSet' => $id1 ],
			NS_USER => [ $userDbkey => -2 ],
			NS_USER_TALK => [ "$userDbkey/subpage" => -3 ],
		], $pageSet->getAllTitlesByNamespace() );

		$context->setLanguage( 'de' );
		$main = new ApiMain( $context );
		$pageSet = new ApiPageSet( $main );
		$pageSet->execute();

		$this->assertEquals( [
			[ 'from' => 'Special:MyPage', 'to' => "User:$userName" ],
			[ 'from' => 'Special:MyTalk/subpage', 'to' => "User talk:$userName/subpage" ],
			[ 'from' => 'Special:MyLanguage/UTApiPageSet', 'to' => 'UTApiPageSet/de' ],
		], $pageSet->getRedirectTitlesAsResult() );
		$this->assertEquals( [
			[ 'ns' => NS_SPECIAL, 'title' => 'Special:MyContributions', 'special' => true ],
			[ 'ns' => NS_USER, 'title' => "User:$userName", 'missing' => true ],
			[ 'ns' => NS_USER_TALK, 'title' => "User talk:$userName/subpage", 'missing' => true ],
		], $pageSet->getInvalidTitlesAndRevisions() );
		$this->assertEquals( [
			NS_MAIN => [ 'UTApiPageSet/de' => $id2 ],
			NS_USER => [ $userDbkey => -2 ],
			NS_USER_TALK => [ "$userDbkey/subpage" => -3 ],
		], $pageSet->getAllTitlesByNamespace() );
	}

	/**
	 * Test that ApiPageSet is calling GenderCache for provided user names to prefill the
	 * GenderCache and avoid a performance issue when loading each users' gender on its own.
	 * The test is setting the "missLimit" to 0 on the GenderCache to trigger misses logic.
	 * When the "misses" property is no longer 0 at the end of the test,
	 * something was requested which is not part of the cache. Than the test is failing.
	 */
	public function testGenderCaching() {
		// Create the test user now so that the cache will be empty later
		$this->getTestSysop()->getUser();
		// Set up the user namespace to have gender aliases to trigger the gender cache
		$this->overrideConfigValue(
			MainConfigNames::ExtraGenderNamespaces,
			[ NS_USER => [ 'male' => 'Male', 'female' => 'Female' ] ]
		);
		$this->overrideMwServices();

		// User names to test with - it is not needed that the user exists in the database
		// to trigger gender cache
		$userNames = [
			'Female',
			'Unknown',
			'Male',
		];

		// Prepare the gender cache for testing - this is a fresh instance due to service override
		$genderCache = TestingAccessWrapper::newFromObject(
			$this->getServiceContainer()->getGenderCache()
		);
		$genderCache->missLimit = 0;

		// Do an api request to trigger ApiPageSet code
		$this->doApiRequest( [
			'action' => 'query',
			'titles' => 'User:' . implode( '|User:', $userNames ),
		] );

		$this->assertSame( 0, $genderCache->misses,
			'ApiPageSet does not prefill the gender cache correctly' );
		$this->assertEquals( $userNames, array_keys( $genderCache->cache ),
			'ApiPageSet does not prefill all users into the gender cache' );
	}

	public function testPopulateFromTitles() {
		$this->hideDeprecated( ApiPageSet::class . '::getTitles' );
		$this->hideDeprecated( ApiPageSet::class . '::getGoodTitles' );
		$this->hideDeprecated( ApiPageSet::class . '::getMissingTitles' );
		$this->hideDeprecated( ApiPageSet::class . '::getGoodAndMissingTitles' );
		$this->hideDeprecated( ApiPageSet::class . '::getRedirectTitles' );
		$this->hideDeprecated( ApiPageSet::class . '::getSpecialTitles' );

		$interwikiLookup = $this->getDummyInterwikiLookup( [ 'acme' ] );
		$this->setService( 'InterwikiLookup', $interwikiLookup );

		$this->getExistingTestPage( 'ApiPageSetTest_existing' )->getTitle();
		$this->getExistingTestPage( 'ApiPageSetTest_redirect_target' )->getTitle();
		$this->getNonexistingTestPage( 'ApiPageSetTest_missing' )->getTitle();
		$redirectTitle = $this->getExistingTestPage( 'ApiPageSetTest_redirect' )->getTitle();
		$this->editPage( $redirectTitle, '#REDIRECT [[ApiPageSetTest_redirect_target]]' );

		$input = [
			'existing' => 'ApiPageSetTest_existing',
			'missing' => 'ApiPageSetTest_missing',
			'invalid' => 'ApiPageSetTest|invalid',
			'redirect' => 'ApiPageSetTest_redirect',
			'special' => 'Special:BlankPage',
			'interwiki' => 'acme:ApiPageSetTest',
		];

		$pageSet = $this->newApiPageSet( [ 'redirects' => 1 ] );
		$pageSet->populateFromTitles( $input );

		$expectedPages = [
			new TitleValue( NS_MAIN, 'ApiPageSetTest_existing' ),
			new TitleValue( NS_MAIN, 'ApiPageSetTest_redirect' ),
			new TitleValue( NS_MAIN, 'ApiPageSetTest_missing' ),

			// the redirect page and the target are included!
			new TitleValue( NS_MAIN, 'ApiPageSetTest_redirect_target' ),
		];
		$this->assertLinkTargets( Title::class, $expectedPages, $pageSet->getTitles() );
		$this->assertLinkTargets( PageIdentity::class, $expectedPages, $pageSet->getPages() );

		$expectedGood = [
			new TitleValue( NS_MAIN, 'ApiPageSetTest_existing' ),
			new TitleValue( NS_MAIN, 'ApiPageSetTest_redirect_target' )
		];
		$this->assertLinkTargets( Title::class, $expectedGood, $pageSet->getGoodTitles() );
		$this->assertLinkTargets( PageIdentity::class, $expectedGood, $pageSet->getGoodPages() );

		$expectedMissing = [ new TitleValue( NS_MAIN, 'ApiPageSetTest_missing' ) ];
		$this->assertLinkTargets(
			Title::class,
			$expectedMissing,
			$pageSet->getMissingTitles()
		);
		$this->assertLinkTargets(
			PageIdentity::class,
			$expectedMissing,
			$pageSet->getMissingPages()
		);
		$this->assertSame(
			[ NS_MAIN => [ 'ApiPageSetTest_missing' => -3 ] ],
			$pageSet->getMissingTitlesByNamespace()
		);

		$expectedGoodAndMissing = array_merge( $expectedGood, $expectedMissing );
		$this->assertLinkTargets(
			Title::class,
			$expectedGoodAndMissing,
			$pageSet->getGoodAndMissingTitles()
		);
		$this->assertLinkTargets(
			PageIdentity::class,
			$expectedGoodAndMissing,
			$pageSet->getGoodAndMissingPages()
		);

		$expectedSpecial = [ new TitleValue( NS_SPECIAL, 'BlankPage' ) ];
		$this->assertLinkTargets( Title::class, $expectedSpecial, $pageSet->getSpecialTitles() );
		$this->assertLinkTargets( PageReference::class, $expectedSpecial, $pageSet->getSpecialPages() );

		$expectedRedirects = [
			'ApiPageSetTest redirect' => new TitleValue(
				NS_MAIN, 'ApiPageSetTest_redirect_target'
			)
		];
		$this->assertLinkTargets( Title::class, $expectedRedirects, $pageSet->getRedirectTitles() );
		$this->assertLinkTargets( LinkTarget::class, $expectedRedirects, $pageSet->getRedirectTargets() );

		$this->assertSame( [ 'acme:ApiPageSetTest' => 'acme' ], $pageSet->getInterwikiTitles() );
		$this->assertSame(
			[ [ 'title' => 'acme:ApiPageSetTest', 'iw' => 'acme' ] ],
			$pageSet->getInterwikiTitlesAsResult()
		);

		$this->assertSame(
			[ -1 => [
					'title' => 'ApiPageSetTest|invalid',
					'invalidreason' => 'The requested page title contains invalid characters: "|".'
			] ],
			$pageSet->getInvalidTitlesAndReasons()
		);
	}

	/**
	 * @param string $type
	 * @param LinkTarget[] $expected
	 * @param LinkTarget[]|PageReference[] $actual
	 */
	private function assertLinkTargets( $type, $expected, $actual ) {
		reset( $actual );
		foreach ( $expected as $expKey => $exp ) {
			$act = current( $actual );
			$this->assertNotFalse( $act, 'missing entry at key $expKey: ' . $exp );

			$actKey = key( $actual );
			next( $actual );

			if ( !is_int( $expKey ) ) {
				$this->assertSame( $expKey, $actKey );
			}
			$this->assertSame( $exp->getNamespace(), $act->getNamespace() );
			$this->assertSame( $exp->getDBkey(), $act->getDBkey() );

			$this->assertInstanceOf( $type, $act );

			if ( $actual instanceof LinkTarget ) {
				$this->assertSame( $exp->getFragment(), $act->getFragment() );
				$this->assertSame( $exp->getInterwiki(), $act->getInterwiki() );
			}
		}

		$act = current( $actual );
		$this->assertFalse( $act, 'extra entry: ' . $act );
	}
}
PK       ! FH      api/ApiResultTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use AllowDynamicProperties;
use Exception;
use InvalidArgumentException;
use MediaWiki\Api\ApiErrorFormatter;
use MediaWiki\Api\ApiResult;
use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;
use RuntimeException;
use Stringable;
use UnexpectedValueException;

/**
 * @covers \MediaWiki\Api\ApiResult
 * @group API
 */
class ApiResultTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers \MediaWiki\Api\ApiResult
	 */
	public function testStaticDataMethods() {
		$arr = [];

		ApiResult::setValue( $arr, 'setValue', '1' );

		ApiResult::setValue( $arr, null, 'unnamed 1' );
		ApiResult::setValue( $arr, null, 'unnamed 2' );

		ApiResult::setValue( $arr, 'deleteValue', '2' );
		ApiResult::unsetValue( $arr, 'deleteValue' );

		ApiResult::setContentValue( $arr, 'setContentValue', '3' );

		$this->assertSame( [
			'setValue' => '1',
			'unnamed 1',
			'unnamed 2',
			ApiResult::META_CONTENT => 'setContentValue',
			'setContentValue' => '3',
		], $arr );

		ApiResult::setValue( $arr, 'setValue', '1' );
		$this->assertSame( '1', $arr['setValue'] );

		try {
			ApiResult::setValue( $arr, 'setValue', '99' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( RuntimeException $ex ) {
			$this->assertSame(
				'Attempting to add element setValue=99, existing value is 1',
				$ex->getMessage(),
				'Expected exception'
			);
		}

		try {
			ApiResult::setContentValue( $arr, 'setContentValue2', '99' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( RuntimeException $ex ) {
			$this->assertSame(
				'Attempting to set content element as setContentValue2 when setContentValue ' .
					'is already set as the content element',
				$ex->getMessage(),
				'Expected exception'
			);
		}

		ApiResult::setValue( $arr, 'setValue', '99', ApiResult::OVERRIDE );
		$this->assertSame( '99', $arr['setValue'] );

		ApiResult::setContentValue( $arr, 'setContentValue2', '99', ApiResult::OVERRIDE );
		$this->assertSame( 'setContentValue2', $arr[ApiResult::META_CONTENT] );

		$arr = [ 'foo' => 1, 'bar' => 1 ];
		ApiResult::setValue( $arr, 'top', '2', ApiResult::ADD_ON_TOP );
		ApiResult::setValue( $arr, null, '2', ApiResult::ADD_ON_TOP );
		ApiResult::setValue( $arr, 'bottom', '2' );
		ApiResult::setValue( $arr, 'foo', '2', ApiResult::OVERRIDE );
		ApiResult::setValue( $arr, 'bar', '2', ApiResult::OVERRIDE | ApiResult::ADD_ON_TOP );
		$this->assertSame( [ 0, 'top', 'foo', 'bar', 'bottom' ], array_keys( $arr ) );

		$arr = [];
		ApiResult::setValue( $arr, 'sub', [ 'foo' => 1 ] );
		ApiResult::setValue( $arr, 'sub', [ 'bar' => 1 ] );
		$this->assertSame( [ 'sub' => [ 'foo' => 1, 'bar' => 1 ] ], $arr );

		try {
			ApiResult::setValue( $arr, 'sub', [ 'foo' => 2, 'baz' => 2 ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( RuntimeException $ex ) {
			$this->assertSame(
				'Conflicting keys (foo) when attempting to merge element sub',
				$ex->getMessage(),
				'Expected exception'
			);
		}

		$arr = [];
		$title = Title::makeTitle( NS_MEDIAWIKI, "Foobar" );
		$obj = (object)[ 'foo' => 1, 'bar' => 2 ];
		ApiResult::setValue( $arr, 'title', $title );
		ApiResult::setValue( $arr, 'obj', $obj );
		$this->assertSame( [
			'title' => (string)$title,
			'obj' => [ 'foo' => 1, 'bar' => 2, ApiResult::META_TYPE => 'assoc' ],
		], $arr );

		$fh = tmpfile();
		try {
			ApiResult::setValue( $arr, 'file', $fh );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add resource (stream) to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		try {
			ApiResult::setValue( $arr, null, $fh );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add resource (stream) to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		try {
			$obj->file = $fh;
			ApiResult::setValue( $arr, 'sub', $obj );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add resource (stream) to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		try {
			$obj->file = $fh;
			ApiResult::setValue( $arr, null, $obj );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add resource (stream) to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		fclose( $fh );

		try {
			ApiResult::setValue( $arr, 'inf', INF );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add non-finite floats to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		try {
			ApiResult::setValue( $arr, null, INF );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add non-finite floats to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		try {
			ApiResult::setValue( $arr, 'nan', NAN );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add non-finite floats to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		try {
			ApiResult::setValue( $arr, null, NAN );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add non-finite floats to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}

		ApiResult::setValue( $arr, null, NAN, ApiResult::NO_VALIDATE );

		try {
			ApiResult::setValue( $arr, null, NAN, ApiResult::NO_SIZE_CHECK );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add non-finite floats to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}

		$arr = [];
		$result2 = new ApiResult( 8_388_608 );
		$result2->addValue( null, 'foo', 'bar' );
		ApiResult::setValue( $arr, 'baz', $result2 );
		$this->assertSame( [
			'baz' => [
				ApiResult::META_TYPE => 'assoc',
				'foo' => 'bar',
			]
		], $arr );

		$arr = [];
		ApiResult::setValue( $arr, 'foo', "foo\x80bar" );
		ApiResult::setValue( $arr, 'bar', "a\xcc\x81" );
		ApiResult::setValue( $arr, 'baz', 74 );
		ApiResult::setValue( $arr, null, "foo\x80bar" );
		ApiResult::setValue( $arr, null, "a\xcc\x81" );
		$this->assertSame( [
			'foo' => "foo\xef\xbf\xbdbar",
			'bar' => "\xc3\xa1",
			'baz' => 74,
			0 => "foo\xef\xbf\xbdbar",
			1 => "\xc3\xa1",
		], $arr );

		$obj = (object)[ 1 => 'one' ];
		$arr = [];
		ApiResult::setValue( $arr, 'foo', $obj );
		$this->assertSame( [
			'foo' => [
				1 => 'one',
				ApiResult::META_TYPE => 'assoc',
			]
		], $arr );
	}

	/**
	 * @covers \MediaWiki\Api\ApiResult
	 */
	public function testInstanceDataMethods() {
		$result = new ApiResult( 8_388_608 );

		$result->addValue( null, 'setValue', '1' );

		$result->addValue( null, null, 'unnamed 1' );
		$result->addValue( null, null, 'unnamed 2' );

		$result->addValue( null, 'deleteValue', '2' );
		$result->removeValue( null, 'deleteValue' );

		$result->addValue( [ 'a', 'b' ], 'deleteValue', '3' );
		$result->removeValue( [ 'a', 'b', 'deleteValue' ], null, '3' );

		$result->addContentValue( null, 'setContentValue', '3' );

		$this->assertSame( [
			'setValue' => '1',
			'unnamed 1',
			'unnamed 2',
			'a' => [ 'b' => [] ],
			'setContentValue' => '3',
			ApiResult::META_TYPE => 'assoc',
			ApiResult::META_CONTENT => 'setContentValue',
		], $result->getResultData() );
		$this->assertSame( 20, $result->getSize() );

		try {
			$result->addValue( null, 'setValue', '99' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( RuntimeException $ex ) {
			$this->assertSame(
				'Attempting to add element setValue=99, existing value is 1',
				$ex->getMessage(),
				'Expected exception'
			);
		}

		try {
			$result->addContentValue( null, 'setContentValue2', '99' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( RuntimeException $ex ) {
			$this->assertSame(
				'Attempting to set content element as setContentValue2 when setContentValue ' .
					'is already set as the content element',
				$ex->getMessage(),
				'Expected exception'
			);
		}

		$result->addValue( null, 'setValue', '99', ApiResult::OVERRIDE );
		$this->assertSame( '99', $result->getResultData( [ 'setValue' ] ) );

		$result->addContentValue( null, 'setContentValue2', '99', ApiResult::OVERRIDE );
		$this->assertSame( 'setContentValue2',
			$result->getResultData( [ ApiResult::META_CONTENT ] ) );

		$result->reset();
		$this->assertSame( [
			ApiResult::META_TYPE => 'assoc',
		], $result->getResultData() );
		$this->assertSame( 0, $result->getSize() );

		$result->addValue( null, 'foo', 1 );
		$result->addValue( null, 'bar', 1 );
		$result->addValue( null, 'top', '2', ApiResult::ADD_ON_TOP );
		$result->addValue( null, null, '2', ApiResult::ADD_ON_TOP );
		$result->addValue( null, 'bottom', '2' );
		$result->addValue( null, 'foo', '2', ApiResult::OVERRIDE );
		$result->addValue( null, 'bar', '2', ApiResult::OVERRIDE | ApiResult::ADD_ON_TOP );
		$this->assertSame( [ 0, 'top', 'foo', 'bar', 'bottom', ApiResult::META_TYPE ],
			array_keys( $result->getResultData() ) );

		$result->reset();
		$result->addValue( null, 'foo', [ 'bar' => 1 ] );
		$result->addValue( [ 'foo', 'top' ], 'x', 2, ApiResult::ADD_ON_TOP );
		$result->addValue( [ 'foo', 'bottom' ], 'x', 2 );
		$this->assertSame( [ 'top', 'bar', 'bottom' ],
			array_keys( $result->getResultData( [ 'foo' ] ) ) );

		$result->reset();
		$result->addValue( null, 'sub', [ 'foo' => 1 ] );
		$result->addValue( null, 'sub', [ 'bar' => 1 ] );
		$this->assertSame( [
			'sub' => [ 'foo' => 1, 'bar' => 1 ],
			ApiResult::META_TYPE => 'assoc',
		], $result->getResultData() );

		try {
			$result->addValue( null, 'sub', [ 'foo' => 2, 'baz' => 2 ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( RuntimeException $ex ) {
			$this->assertSame(
				'Conflicting keys (foo) when attempting to merge element sub',
				$ex->getMessage(),
				'Expected exception'
			);
		}

		$result->reset();
		$title = Title::makeTitle( NS_MEDIAWIKI, "Foobar" );
		$obj = (object)[ 'foo' => 1, 'bar' => 2 ];
		$result->addValue( null, 'title', $title );
		$result->addValue( null, 'obj', $obj );
		$this->assertSame( [
			'title' => (string)$title,
			'obj' => [ 'foo' => 1, 'bar' => 2, ApiResult::META_TYPE => 'assoc' ],
			ApiResult::META_TYPE => 'assoc',
		], $result->getResultData() );

		$fh = tmpfile();
		try {
			$result->addValue( null, 'file', $fh );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add resource (stream) to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		try {
			$result->addValue( null, null, $fh );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add resource (stream) to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		try {
			$obj->file = $fh;
			$result->addValue( null, 'sub', $obj );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add resource (stream) to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		try {
			$obj->file = $fh;
			$result->addValue( null, null, $obj );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add resource (stream) to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		fclose( $fh );

		try {
			$result->addValue( null, 'inf', INF );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add non-finite floats to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		try {
			$result->addValue( null, null, INF );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add non-finite floats to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		try {
			$result->addValue( null, 'nan', NAN );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add non-finite floats to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}
		try {
			$result->addValue( null, null, NAN );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add non-finite floats to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}

		$result->addValue( null, null, NAN, ApiResult::NO_VALIDATE );

		try {
			$result->addValue( null, null, NAN, ApiResult::NO_SIZE_CHECK );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Cannot add non-finite floats to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}

		$result->reset();
		$result->addParsedLimit( 'foo', 12 );
		$this->assertSame( [
			'limits' => [ 'foo' => 12 ],
			ApiResult::META_TYPE => 'assoc',
		], $result->getResultData() );
		$result->addParsedLimit( 'foo', 13 );
		$this->assertSame( [
			'limits' => [ 'foo' => 13 ],
			ApiResult::META_TYPE => 'assoc',
		], $result->getResultData() );
		$this->assertSame( null, $result->getResultData( [ 'foo', 'bar', 'baz' ] ) );
		$this->assertSame( 13, $result->getResultData( [ 'limits', 'foo' ] ) );
		try {
			$result->getResultData( [ 'limits', 'foo', 'bar' ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Path limits.foo is not an array',
				$ex->getMessage(),
				'Expected exception'
			);
		}

		// Add two values and some metadata, but ensure metadata is not counted
		$result = new ApiResult( 100 );
		$obj = [ 'attr' => '12345' ];
		ApiResult::setContentValue( $obj, 'content', '1234567890' );
		$this->assertTrue( $result->addValue( null, 'foo', $obj ) );
		$this->assertSame( 15, $result->getSize() );

		$result = new ApiResult( 10 );
		$formatter = new ApiErrorFormatter( $result,
			$this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' ),
			'none', false );
		$result->setErrorFormatter( $formatter );
		$this->assertFalse( $result->addValue( null, 'foo', '12345678901' ) );
		$this->assertTrue( $result->addValue( null, 'foo', '12345678901', ApiResult::NO_SIZE_CHECK ) );
		$this->assertSame( 0, $result->getSize() );
		$result->reset();
		$this->assertTrue( $result->addValue( null, 'foo', '1234567890' ) );
		$this->assertFalse( $result->addValue( null, 'foo', '1' ) );
		$result->removeValue( null, 'foo' );
		$this->assertTrue( $result->addValue( null, 'foo', '1' ) );

		$result = new ApiResult( 10 );
		$obj = new ApiResultTestSerializableObject( 'ok' );
		$obj->foobar = 'foobaz';
		$this->assertTrue( $result->addValue( null, 'foo', $obj ) );
		$this->assertSame( 2, $result->getSize() );

		$result = new ApiResult( 8_388_608 );
		$result2 = new ApiResult( 8_388_608 );
		$result2->addValue( null, 'foo', 'bar' );
		$result->addValue( null, 'baz', $result2 );
		$this->assertSame( [
			'baz' => [
				'foo' => 'bar',
				ApiResult::META_TYPE => 'assoc',
			],
			ApiResult::META_TYPE => 'assoc',
		], $result->getResultData() );

		$result = new ApiResult( 8_388_608 );
		$result->addValue( null, 'foo', "foo\x80bar" );
		$result->addValue( null, 'bar', "a\xcc\x81" );
		$result->addValue( null, 'baz', 74 );
		$result->addValue( null, null, "foo\x80bar" );
		$result->addValue( null, null, "a\xcc\x81" );
		$this->assertSame( [
			'foo' => "foo\xef\xbf\xbdbar",
			'bar' => "\xc3\xa1",
			'baz' => 74,
			0 => "foo\xef\xbf\xbdbar",
			1 => "\xc3\xa1",
			ApiResult::META_TYPE => 'assoc',
		], $result->getResultData() );

		$result = new ApiResult( 8_388_608 );
		$obj = (object)[ 1 => 'one' ];
		$arr = [];
		$result->addValue( $arr, 'foo', $obj );
		$this->assertSame( [
			'foo' => [
				1 => 'one',
				ApiResult::META_TYPE => 'assoc',
			],
			ApiResult::META_TYPE => 'assoc',
		], $result->getResultData() );
	}

	/**
	 * @covers \MediaWiki\Api\ApiResult
	 */
	public function testMetadata() {
		$arr = [ 'foo' => [ 'bar' => [] ] ];
		$result = new ApiResult( 8_388_608 );
		$result->addValue( null, 'foo', [ 'bar' => [] ] );

		$expect = [
			'foo' => [
				'bar' => [
					ApiResult::META_INDEXED_TAG_NAME => 'ritn',
					ApiResult::META_TYPE => 'default',
				],
				ApiResult::META_INDEXED_TAG_NAME => 'ritn',
				ApiResult::META_TYPE => 'default',
			],
			ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
			ApiResult::META_INDEXED_TAG_NAME => 'itn',
			ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar' ],
			ApiResult::META_TYPE => 'array',
		];

		ApiResult::setSubelementsList( $arr, 'foo' );
		ApiResult::setSubelementsList( $arr, [ 'bar', 'baz' ] );
		ApiResult::unsetSubelementsList( $arr, 'baz' );
		ApiResult::setIndexedTagNameRecursive( $arr, 'ritn' );
		ApiResult::setIndexedTagName( $arr, 'itn' );
		ApiResult::setPreserveKeysList( $arr, 'foo' );
		ApiResult::setPreserveKeysList( $arr, [ 'bar', 'baz' ] );
		ApiResult::unsetPreserveKeysList( $arr, 'baz' );
		ApiResult::setArrayTypeRecursive( $arr, 'default' );
		ApiResult::setArrayType( $arr, 'array' );
		$this->assertSame( $expect, $arr );

		$result->addSubelementsList( null, 'foo' );
		$result->addSubelementsList( null, [ 'bar', 'baz' ] );
		$result->removeSubelementsList( null, 'baz' );
		$result->addIndexedTagNameRecursive( null, 'ritn' );
		$result->addIndexedTagName( null, 'itn' );
		$result->addPreserveKeysList( null, 'foo' );
		$result->addPreserveKeysList( null, [ 'bar', 'baz' ] );
		$result->removePreserveKeysList( null, 'baz' );
		$result->addArrayTypeRecursive( null, 'default' );
		$result->addArrayType( null, 'array' );
		$this->assertEquals( $expect, $result->getResultData() );

		$arr = [ 'foo' => [ 'bar' => [] ] ];
		$expect = [
			'foo' => [
				'bar' => [
					ApiResult::META_TYPE => 'kvp',
					ApiResult::META_KVP_KEY_NAME => 'key',
				],
				ApiResult::META_TYPE => 'kvp',
				ApiResult::META_KVP_KEY_NAME => 'key',
			],
			ApiResult::META_TYPE => 'BCkvp',
			ApiResult::META_KVP_KEY_NAME => 'bc',
		];
		ApiResult::setArrayTypeRecursive( $arr, 'kvp', 'key' );
		ApiResult::setArrayType( $arr, 'BCkvp', 'bc' );
		$this->assertSame( $expect, $arr );
	}

	/**
	 * @covers \MediaWiki\Api\ApiResult
	 */
	public function testUtilityFunctions() {
		$arr = [
			'foo' => [
				'bar' => [ '_dummy' => 'foobaz' ],
				'bar2' => (object)[ '_dummy' => 'foobaz' ],
				'x' => 'ok',
				'_dummy' => 'foobaz',
			],
			'foo2' => (object)[
				'bar' => [ '_dummy' => 'foobaz' ],
				'bar2' => (object)[ '_dummy' => 'foobaz' ],
				'x' => 'ok',
				'_dummy' => 'foobaz',
			],
			ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
			ApiResult::META_INDEXED_TAG_NAME => 'itn',
			ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
			ApiResult::META_TYPE => 'array',
			'_dummy' => 'foobaz',
			'_dummy2' => 'foobaz!',
		];
		$this->assertEquals( [
			'foo' => [
				'bar' => [],
				'bar2' => (object)[],
				'x' => 'ok',
			],
			'foo2' => (object)[
				'bar' => [],
				'bar2' => (object)[],
				'x' => 'ok',
			],
			'_dummy2' => 'foobaz!',
		], ApiResult::stripMetadata( $arr ), 'ApiResult::stripMetadata' );

		$metadata = [];
		$data = ApiResult::stripMetadataNonRecursive( $arr, $metadata );
		$this->assertEquals( [
			'foo' => [
				'bar' => [ '_dummy' => 'foobaz' ],
				'bar2' => (object)[ '_dummy' => 'foobaz' ],
				'x' => 'ok',
				'_dummy' => 'foobaz',
			],
			'foo2' => (object)[
				'bar' => [ '_dummy' => 'foobaz' ],
				'bar2' => (object)[ '_dummy' => 'foobaz' ],
				'x' => 'ok',
				'_dummy' => 'foobaz',
			],
			'_dummy2' => 'foobaz!',
		], $data, 'ApiResult::stripMetadataNonRecursive ($data)' );
		$this->assertEquals( [
			ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
			ApiResult::META_INDEXED_TAG_NAME => 'itn',
			ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
			ApiResult::META_TYPE => 'array',
			'_dummy' => 'foobaz',
		], $metadata, 'ApiResult::stripMetadataNonRecursive ($metadata)' );

		$metadata = null;
		$data = ApiResult::stripMetadataNonRecursive( (object)$arr, $metadata );
		$this->assertEquals( (object)[
			'foo' => [
				'bar' => [ '_dummy' => 'foobaz' ],
				'bar2' => (object)[ '_dummy' => 'foobaz' ],
				'x' => 'ok',
				'_dummy' => 'foobaz',
			],
			'foo2' => (object)[
				'bar' => [ '_dummy' => 'foobaz' ],
				'bar2' => (object)[ '_dummy' => 'foobaz' ],
				'x' => 'ok',
				'_dummy' => 'foobaz',
			],
			'_dummy2' => 'foobaz!',
		], $data, 'ApiResult::stripMetadataNonRecursive on object ($data)' );
		$this->assertEquals( [
			ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
			ApiResult::META_INDEXED_TAG_NAME => 'itn',
			ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
			ApiResult::META_TYPE => 'array',
			'_dummy' => 'foobaz',
		], $metadata, 'ApiResult::stripMetadataNonRecursive on object ($metadata)' );
	}

	/**
	 * @covers \MediaWiki\Api\ApiResult
	 * @dataProvider provideTransformations
	 * @param string $label
	 * @param array $input
	 * @param array $transforms
	 * @param array|Exception $expect
	 */
	public function testTransformations( $label, $input, $transforms, $expect ) {
		$result = new ApiResult( false );
		$result->addValue( null, 'test', $input );

		if ( $expect instanceof Exception ) {
			try {
				$output = $result->getResultData( 'test', $transforms );
				$this->fail( 'Expected exception not thrown', $label );
			} catch ( Exception $ex ) {
				$this->assertEquals( $ex, $expect, $label );
			}
		} else {
			$output = $result->getResultData( 'test', $transforms );
			$this->assertEquals( $expect, $output, $label );
		}
	}

	public function provideTransformations() {
		$kvp = static function ( $keyKey, $key, $valKey, $value ) {
			return [
				$keyKey => $key,
				$valKey => $value,
				ApiResult::META_PRESERVE_KEYS => [ $keyKey ],
				ApiResult::META_CONTENT => $valKey,
				ApiResult::META_TYPE => 'assoc',
			];
		};
		$typeArr = [
			'defaultArray' => [ 2 => 'a', 0 => 'b', 1 => 'c' ],
			'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c' ],
			'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c' ],
			'array' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'array' ],
			'BCarray' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'BCarray' ],
			'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'BCassoc' ],
			'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
			'kvp' => [ 'x' => 'a', 'y' => 'b', 'z' => [ 'c' ], ApiResult::META_TYPE => 'kvp' ],
			'BCkvp' => [ 'x' => 'a', 'y' => 'b',
				ApiResult::META_TYPE => 'BCkvp',
				ApiResult::META_KVP_KEY_NAME => 'key',
			],
			'kvpmerge' => [ 'x' => 'a', 'y' => [ 'b' ], 'z' => [ 'c' => 'd' ],
				ApiResult::META_TYPE => 'kvp',
				ApiResult::META_KVP_MERGE => true,
			],
			'emptyDefault' => [ '_dummy' => 1 ],
			'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
			'_dummy' => 1,
			ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
		];
		$stripArr = [
			'foo' => [
				'bar' => [ '_dummy' => 'foobaz' ],
				'baz' => [
					ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
					ApiResult::META_INDEXED_TAG_NAME => 'itn',
					ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
					ApiResult::META_TYPE => 'array',
				],
				'x' => 'ok',
				'_dummy' => 'foobaz',
			],
			ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
			ApiResult::META_INDEXED_TAG_NAME => 'itn',
			ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
			ApiResult::META_TYPE => 'array',
			'_dummy' => 'foobaz',
			'_dummy2' => 'foobaz!',
		];

		return [
			[
				'BC: META_BC_BOOLS',
				[
					'BCtrue' => true,
					'BCfalse' => false,
					'true' => true,
					'false' => false,
					ApiResult::META_BC_BOOLS => [ 0, 'true', 'false' ],
				],
				[ 'BC' => [] ],
				[
					'BCtrue' => '',
					'true' => true,
					'false' => false,
					ApiResult::META_BC_BOOLS => [ 0, 'true', 'false' ],
				]
			],
			[
				'BC: META_BC_SUBELEMENTS',
				[
					'bc' => 'foo',
					'nobc' => 'bar',
					ApiResult::META_BC_SUBELEMENTS => [ 'bc' ],
				],
				[ 'BC' => [] ],
				[
					'bc' => [
						'*' => 'foo',
						ApiResult::META_CONTENT => '*',
						ApiResult::META_TYPE => 'assoc',
					],
					'nobc' => 'bar',
					ApiResult::META_BC_SUBELEMENTS => [ 'bc' ],
				],
			],
			[
				'BC: META_CONTENT',
				[
					'content' => '!!!',
					ApiResult::META_CONTENT => 'content',
				],
				[ 'BC' => [] ],
				[
					'*' => '!!!',
					ApiResult::META_CONTENT => '*',
				],
			],
			[
				'BC: BCkvp type',
				[
					'foo' => 'foo value',
					'bar' => 'bar value',
					'_baz' => 'baz value',
					ApiResult::META_TYPE => 'BCkvp',
					ApiResult::META_KVP_KEY_NAME => 'key',
					ApiResult::META_PRESERVE_KEYS => [ '_baz' ],
				],
				[ 'BC' => [] ],
				[
					$kvp( 'key', 'foo', '*', 'foo value' ),
					$kvp( 'key', 'bar', '*', 'bar value' ),
					$kvp( 'key', '_baz', '*', 'baz value' ),
					ApiResult::META_TYPE => 'array',
					ApiResult::META_KVP_KEY_NAME => 'key',
					ApiResult::META_PRESERVE_KEYS => [ '_baz' ],
				],
			],
			[
				'BC: BCarray type',
				[
					ApiResult::META_TYPE => 'BCarray',
				],
				[ 'BC' => [] ],
				[
					ApiResult::META_TYPE => 'default',
				],
			],
			[
				'BC: BCassoc type',
				[
					ApiResult::META_TYPE => 'BCassoc',
				],
				[ 'BC' => [] ],
				[
					ApiResult::META_TYPE => 'default',
				],
			],
			[
				'BC: BCkvp exception',
				[
					ApiResult::META_TYPE => 'BCkvp',
				],
				[ 'BC' => [] ],
				new UnexpectedValueException(
					'Type "BCkvp" used without setting ApiResult::META_KVP_KEY_NAME metadata item'
				),
			],
			[
				'BC: nobool, no*, nosub',
				[
					'true' => true,
					'false' => false,
					'content' => 'content',
					ApiResult::META_CONTENT => 'content',
					'bc' => 'foo',
					ApiResult::META_BC_SUBELEMENTS => [ 'bc' ],
					'BCarray' => [ ApiResult::META_TYPE => 'BCarray' ],
					'BCassoc' => [ ApiResult::META_TYPE => 'BCassoc' ],
					'BCkvp' => [
						'foo' => 'foo value',
						'bar' => 'bar value',
						'_baz' => 'baz value',
						ApiResult::META_TYPE => 'BCkvp',
						ApiResult::META_KVP_KEY_NAME => 'key',
						ApiResult::META_PRESERVE_KEYS => [ '_baz' ],
					],
				],
				[ 'BC' => [ 'nobool', 'no*', 'nosub' ] ],
				[
					'true' => true,
					'false' => false,
					'content' => 'content',
					'bc' => 'foo',
					'BCarray' => [ ApiResult::META_TYPE => 'default' ],
					'BCassoc' => [ ApiResult::META_TYPE => 'default' ],
					'BCkvp' => [
						$kvp( 'key', 'foo', '*', 'foo value' ),
						$kvp( 'key', 'bar', '*', 'bar value' ),
						$kvp( 'key', '_baz', '*', 'baz value' ),
						ApiResult::META_TYPE => 'array',
						ApiResult::META_KVP_KEY_NAME => 'key',
						ApiResult::META_PRESERVE_KEYS => [ '_baz' ],
					],
					ApiResult::META_CONTENT => 'content',
					ApiResult::META_BC_SUBELEMENTS => [ 'bc' ],
				],
			],

			[
				'Types: Normal transform',
				$typeArr,
				[ 'Types' => [] ],
				[
					'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
					'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
					'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
					'array' => [ 'c', 'b', 'a', ApiResult::META_TYPE => 'array' ],
					'BCarray' => [ 'c', 'b', 'a', ApiResult::META_TYPE => 'array' ],
					'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ],
					'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
					'kvp' => [ 'x' => 'a', 'y' => 'b',
						'z' => [ 'c', ApiResult::META_TYPE => 'array' ],
						ApiResult::META_TYPE => 'assoc'
					],
					'BCkvp' => [ 'x' => 'a', 'y' => 'b',
						ApiResult::META_TYPE => 'assoc',
						ApiResult::META_KVP_KEY_NAME => 'key',
					],
					'kvpmerge' => [
						'x' => 'a',
						'y' => [ 'b', ApiResult::META_TYPE => 'array' ],
						'z' => [ 'c' => 'd', ApiResult::META_TYPE => 'assoc' ],
						ApiResult::META_TYPE => 'assoc',
						ApiResult::META_KVP_MERGE => true,
					],
					'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
					'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
					'_dummy' => 1,
					ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
					ApiResult::META_TYPE => 'assoc',
				],
			],
			[
				'Types: AssocAsObject',
				$typeArr,
				[ 'Types' => [ 'AssocAsObject' => true ] ],
				(object)[
					'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
					'defaultAssoc' => (object)[ 'x' => 'a',
						1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc'
					],
					'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b',
						0 => 'c', ApiResult::META_TYPE => 'assoc'
					],
					'array' => [ 'c', 'b', 'a', ApiResult::META_TYPE => 'array' ],
					'BCarray' => [ 'c', 'b', 'a', ApiResult::META_TYPE => 'array' ],
					'BCassoc' => (object)[ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ],
					'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
					'kvp' => (object)[ 'x' => 'a', 'y' => 'b',
						'z' => [ 'c', ApiResult::META_TYPE => 'array' ],
						ApiResult::META_TYPE => 'assoc'
					],
					'BCkvp' => (object)[ 'x' => 'a', 'y' => 'b',
						ApiResult::META_TYPE => 'assoc',
						ApiResult::META_KVP_KEY_NAME => 'key',
					],
					'kvpmerge' => (object)[
						'x' => 'a',
						'y' => [ 'b', ApiResult::META_TYPE => 'array' ],
						'z' => (object)[ 'c' => 'd', ApiResult::META_TYPE => 'assoc' ],
						ApiResult::META_TYPE => 'assoc',
						ApiResult::META_KVP_MERGE => true,
					],
					'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
					'emptyAssoc' => (object)[ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
					'_dummy' => 1,
					ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
					ApiResult::META_TYPE => 'assoc',
				],
			],
			[
				'Types: ArmorKVP',
				$typeArr,
				[ 'Types' => [ 'ArmorKVP' => 'name' ] ],
				[
					'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
					'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
					'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
					'array' => [ 'c', 'b', 'a', ApiResult::META_TYPE => 'array' ],
					'BCarray' => [ 'c', 'b', 'a', ApiResult::META_TYPE => 'array' ],
					'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ],
					'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
					'kvp' => [
						$kvp( 'name', 'x', 'value', 'a' ),
						$kvp( 'name', 'y', 'value', 'b' ),
						$kvp( 'name', 'z', 'value', [ 'c', ApiResult::META_TYPE => 'array' ] ),
						ApiResult::META_TYPE => 'array'
					],
					'BCkvp' => [
						$kvp( 'key', 'x', 'value', 'a' ),
						$kvp( 'key', 'y', 'value', 'b' ),
						ApiResult::META_TYPE => 'array',
						ApiResult::META_KVP_KEY_NAME => 'key',
					],
					'kvpmerge' => [
						$kvp( 'name', 'x', 'value', 'a' ),
						$kvp( 'name', 'y', 'value', [ 'b', ApiResult::META_TYPE => 'array' ] ),
						[
							'name' => 'z',
							'c' => 'd',
							ApiResult::META_TYPE => 'assoc',
							ApiResult::META_PRESERVE_KEYS => [ 'name' ]
						],
						ApiResult::META_TYPE => 'array',
						ApiResult::META_KVP_MERGE => true,
					],
					'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
					'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
					'_dummy' => 1,
					ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
					ApiResult::META_TYPE => 'assoc',
				],
			],
			[
				'Types: ArmorKVP + BC',
				$typeArr,
				[ 'BC' => [], 'Types' => [ 'ArmorKVP' => 'name' ] ],
				[
					'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
					'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
					'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
					'array' => [ 'c', 'b', 'a', ApiResult::META_TYPE => 'array' ],
					'BCarray' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
					'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'array' ],
					'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
					'kvp' => [
						$kvp( 'name', 'x', '*', 'a' ),
						$kvp( 'name', 'y', '*', 'b' ),
						$kvp( 'name', 'z', '*', [ 'c', ApiResult::META_TYPE => 'array' ] ),
						ApiResult::META_TYPE => 'array'
					],
					'BCkvp' => [
						$kvp( 'key', 'x', '*', 'a' ),
						$kvp( 'key', 'y', '*', 'b' ),
						ApiResult::META_TYPE => 'array',
						ApiResult::META_KVP_KEY_NAME => 'key',
					],
					'kvpmerge' => [
						$kvp( 'name', 'x', '*', 'a' ),
						$kvp( 'name', 'y', '*', [ 'b', ApiResult::META_TYPE => 'array' ] ),
						[
							'name' => 'z',
							'c' => 'd',
							ApiResult::META_TYPE => 'assoc',
							ApiResult::META_PRESERVE_KEYS => [ 'name' ] ],
						ApiResult::META_TYPE => 'array',
						ApiResult::META_KVP_MERGE => true,
					],
					'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
					'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
					'_dummy' => 1,
					ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
					ApiResult::META_TYPE => 'assoc',
				],
			],
			[
				'Types: ArmorKVP + AssocAsObject',
				$typeArr,
				[ 'Types' => [ 'ArmorKVP' => 'name', 'AssocAsObject' => true ] ],
				(object)[
					'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
					'defaultAssoc' => (object)[ 'x' => 'a', 1 => 'b',
						0 => 'c', ApiResult::META_TYPE => 'assoc'
					],
					'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b',
						0 => 'c', ApiResult::META_TYPE => 'assoc'
					],
					'array' => [ 'c', 'b', 'a', ApiResult::META_TYPE => 'array' ],
					'BCarray' => [ 'c', 'b', 'a', ApiResult::META_TYPE => 'array' ],
					'BCassoc' => (object)[ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ],
					'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
					'kvp' => [
						(object)$kvp( 'name', 'x', 'value', 'a' ),
						(object)$kvp( 'name', 'y', 'value', 'b' ),
						(object)$kvp( 'name', 'z', 'value', [ 'c', ApiResult::META_TYPE => 'array' ] ),
						ApiResult::META_TYPE => 'array'
					],
					'BCkvp' => [
						(object)$kvp( 'key', 'x', 'value', 'a' ),
						(object)$kvp( 'key', 'y', 'value', 'b' ),
						ApiResult::META_TYPE => 'array',
						ApiResult::META_KVP_KEY_NAME => 'key',
					],
					'kvpmerge' => [
						(object)$kvp( 'name', 'x', 'value', 'a' ),
						(object)$kvp( 'name', 'y', 'value', [ 'b', ApiResult::META_TYPE => 'array' ] ),
						(object)[
							'name' => 'z',
							'c' => 'd',
							ApiResult::META_TYPE => 'assoc',
							ApiResult::META_PRESERVE_KEYS => [ 'name' ]
						],
						ApiResult::META_TYPE => 'array',
						ApiResult::META_KVP_MERGE => true,
					],
					'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
					'emptyAssoc' => (object)[ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
					'_dummy' => 1,
					ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
					ApiResult::META_TYPE => 'assoc',
				],
			],
			[
				'Types: BCkvp exception',
				[
					ApiResult::META_TYPE => 'BCkvp',
				],
				[ 'Types' => [] ],
				new UnexpectedValueException(
					'Type "BCkvp" used without setting ApiResult::META_KVP_KEY_NAME metadata item'
				),
			],

			[
				'Strip: With ArmorKVP + AssocAsObject transforms',
				$typeArr,
				[ 'Types' => [ 'ArmorKVP' => 'name', 'AssocAsObject' => true ], 'Strip' => 'all' ],
				(object)[
					'defaultArray' => [ 'b', 'c', 'a' ],
					'defaultAssoc' => (object)[ 'x' => 'a', 1 => 'b', 0 => 'c' ],
					'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b', 0 => 'c' ],
					'array' => [ 'c', 'b', 'a' ],
					'BCarray' => [ 'c', 'b', 'a' ],
					'BCassoc' => (object)[ 'a', 'b', 'c' ],
					'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c' ],
					'kvp' => [
						(object)[ 'name' => 'x', 'value' => 'a' ],
						(object)[ 'name' => 'y', 'value' => 'b' ],
						(object)[ 'name' => 'z', 'value' => [ 'c' ] ],
					],
					'BCkvp' => [
						(object)[ 'key' => 'x', 'value' => 'a' ],
						(object)[ 'key' => 'y', 'value' => 'b' ],
					],
					'kvpmerge' => [
						(object)[ 'name' => 'x', 'value' => 'a' ],
						(object)[ 'name' => 'y', 'value' => [ 'b' ] ],
						(object)[ 'name' => 'z', 'c' => 'd' ],
					],
					'emptyDefault' => [],
					'emptyAssoc' => (object)[],
					'_dummy' => 1,
				],
			],

			[
				'Strip: all',
				$stripArr,
				[ 'Strip' => 'all' ],
				[
					'foo' => [
						'bar' => [],
						'baz' => [],
						'x' => 'ok',
					],
					'_dummy2' => 'foobaz!',
				],
			],
			[
				'Strip: base',
				$stripArr,
				[ 'Strip' => 'base' ],
				[
					'foo' => [
						'bar' => [ '_dummy' => 'foobaz' ],
						'baz' => [
							ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
							ApiResult::META_INDEXED_TAG_NAME => 'itn',
							ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
							ApiResult::META_TYPE => 'array',
						],
						'x' => 'ok',
						'_dummy' => 'foobaz',
					],
					'_dummy2' => 'foobaz!',
				],
			],
			[
				'Strip: bc',
				$stripArr,
				[ 'Strip' => 'bc' ],
				[
					'foo' => [
						'bar' => [],
						'baz' => [
							ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
							ApiResult::META_INDEXED_TAG_NAME => 'itn',
						],
						'x' => 'ok',
					],
					'_dummy2' => 'foobaz!',
					ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
					ApiResult::META_INDEXED_TAG_NAME => 'itn',
				],
			],

			[
				'Custom transform',
				[
					'foo' => '?',
					'bar' => '?',
					'_dummy' => '?',
					'_dummy2' => '?',
					'_dummy3' => '?',
					ApiResult::META_CONTENT => 'foo',
					ApiResult::META_PRESERVE_KEYS => [ '_dummy2', '_dummy3' ],
				],
				[
					'Custom' => [ $this, 'customTransform' ],
					'BC' => [],
					'Types' => [],
					'Strip' => 'all'
				],
				[
					'*' => 'FOO',
					'bar' => 'BAR',
					'baz' => [ 'a', 'b' ],
					'_dummy2' => '_DUMMY2',
					'_dummy3' => '_DUMMY3',
					ApiResult::META_CONTENT => 'bar',
				],
			],

			[
				'Types: Numeric keys in array and BCarray',
				[
					'array' => [
						0 => 'd',
						'x' => 'a',
						1 => 'b',
						'1.5' => 'c',
						'0.5  ' => 'e',
						ApiResult::META_TYPE => 'array'
					],
					'BCarray' => [
						0 => 'd',
						'x' => 'a',
						1 => 'b',
						'1.5' => 'c',
						'0.5  ' => 'e',
						ApiResult::META_TYPE => 'BCarray'
					],
				],
				[ 'Types' => [] ],
				[
					'array' => [ 'd', 'e', 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
					'BCarray' => [ 'd', 'e', 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
					ApiResult::META_TYPE => 'assoc',
				],
			],
		];
	}

	/**
	 * Custom transformer for testTransformations
	 * @param array &$data
	 * @param array &$metadata
	 */
	public function customTransform( &$data, &$metadata ) {
		// Prevent recursion
		if ( isset( $metadata['_added'] ) ) {
			$metadata[ApiResult::META_TYPE] = 'array';
			return;
		}

		foreach ( $data as $k => $v ) {
			$data[$k] = strtoupper( $k );
		}
		$data['baz'] = [ '_added' => 1, 'z' => 'b', 'y' => 'a' ];
		$metadata[ApiResult::META_PRESERVE_KEYS][0] = '_dummy';
		$data[ApiResult::META_CONTENT] = 'bar';
	}

	/**
	 * @covers \MediaWiki\Api\ApiResult
	 */
	public function testAddMetadataToResultVars() {
		$arr = [
			'a' => "foo",
			'b' => false,
			'c' => 10,
			'sequential_numeric_keys' => [ 'a', 'b', 'c' ],
			'non_sequential_numeric_keys' => [ 'a', 'b', 4 => 'c' ],
			'string_keys' => [
				'one' => 1,
				'two' => 2
			],
			'object_sequential_keys' => (object)[ 'a', 'b', 'c' ],
			'_type' => "should be overwritten in result",
		];
		$this->assertSame( [
			ApiResult::META_TYPE => 'kvp',
			ApiResult::META_KVP_KEY_NAME => 'key',
			ApiResult::META_PRESERVE_KEYS => [
				'a', 'b', 'c',
				'sequential_numeric_keys', 'non_sequential_numeric_keys',
				'string_keys', 'object_sequential_keys'
			],
			ApiResult::META_BC_BOOLS => [ 'b' ],
			ApiResult::META_INDEXED_TAG_NAME => 'var',
			'a' => "foo",
			'b' => false,
			'c' => 10,
			'sequential_numeric_keys' => [
				ApiResult::META_TYPE => 'array',
				ApiResult::META_BC_BOOLS => [],
				ApiResult::META_INDEXED_TAG_NAME => 'value',
				0 => 'a',
				1 => 'b',
				2 => 'c',
			],
			'non_sequential_numeric_keys' => [
				ApiResult::META_TYPE => 'kvp',
				ApiResult::META_KVP_KEY_NAME => 'key',
				ApiResult::META_PRESERVE_KEYS => [ 0, 1, 4 ],
				ApiResult::META_BC_BOOLS => [],
				ApiResult::META_INDEXED_TAG_NAME => 'var',
				0 => 'a',
				1 => 'b',
				4 => 'c',
			],
			'string_keys' => [
				ApiResult::META_TYPE => 'kvp',
				ApiResult::META_KVP_KEY_NAME => 'key',
				ApiResult::META_PRESERVE_KEYS => [ 'one', 'two' ],
				ApiResult::META_BC_BOOLS => [],
				ApiResult::META_INDEXED_TAG_NAME => 'var',
				'one' => 1,
				'two' => 2,
			],
			'object_sequential_keys' => [
				ApiResult::META_TYPE => 'kvp',
				ApiResult::META_KVP_KEY_NAME => 'key',
				ApiResult::META_PRESERVE_KEYS => [ 0, 1, 2 ],
				ApiResult::META_BC_BOOLS => [],
				ApiResult::META_INDEXED_TAG_NAME => 'var',
				0 => 'a',
				1 => 'b',
				2 => 'c',
			],
		], ApiResult::addMetadataToResultVars( $arr ) );
	}

	public function testObjectSerialization() {
		$arr = [];
		ApiResult::setValue( $arr, 'foo', (object)[ 'a' => 1, 'b' => 2 ] );
		$this->assertSame( [
			'a' => 1,
			'b' => 2,
			ApiResult::META_TYPE => 'assoc',
		], $arr['foo'] );

		$arr = [];
		ApiResult::setValue( $arr, 'foo', new ApiResultTestStringifiableObject() );
		$this->assertSame( 'Ok', $arr['foo'] );

		$arr = [];
		ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject( 'Ok' ) );
		$this->assertSame( 'Ok', $arr['foo'] );

		try {
			$arr = [];
			ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject(
				new ApiResultTestStringifiableObject()
			) );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame(
				'MediaWiki\Tests\Api\ApiResultTestSerializableObject::serializeForApiResult() ' .
					'returned an object of class MediaWiki\Tests\Api\ApiResultTestStringifiableObject',
				$ex->getMessage(),
				'Expected exception'
			);
		}

		try {
			$arr = [];
			ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject( NAN ) );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame(
				'MediaWiki\Tests\Api\ApiResultTestSerializableObject::serializeForApiResult() ' .
					'returned an invalid value: Cannot add non-finite floats to ApiResult',
				$ex->getMessage(),
				'Expected exception'
			);
		}

		$arr = [];
		ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject(
			[
				'one' => new ApiResultTestStringifiableObject( '1' ),
				'two' => new ApiResultTestSerializableObject( 2 ),
			]
		) );
		$this->assertSame( [
			'one' => '1',
			'two' => 2,
		], $arr['foo'] );
	}
}

class ApiResultTestStringifiableObject implements Stringable {
	/** @var string */
	private $ret;

	public function __construct( $ret = 'Ok' ) {
		$this->ret = $ret;
	}

	public function __toString() {
		return $this->ret;
	}
}

#[AllowDynamicProperties]
class ApiResultTestSerializableObject implements Stringable {
	/** @var string */
	private $ret;

	public function __construct( $ret ) {
		$this->ret = $ret;
	}

	public function __toString() {
		return "Fail";
	}

	public function serializeForApiResult() {
		return $this->ret;
	}
}
PK       !  H      api/ApiMainTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use Generator;
use InvalidArgumentException;
use LogicException;
use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiContinuationManager;
use MediaWiki\Api\ApiErrorFormatter;
use MediaWiki\Api\ApiErrorFormatter_BackCompat;
use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiRawMessage;
use MediaWiki\Api\ApiUsageException;
use MediaWiki\Config\Config;
use MediaWiki\Config\HashConfig;
use MediaWiki\Config\MultiConfig;
use MediaWiki\Context\RequestContext;
use MediaWiki\Json\FormatJson;
use MediaWiki\Language\RawMessage;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\Authority;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\FauxResponse;
use MediaWiki\Request\WebRequest;
use MediaWiki\ShellDisabledError;
use MediaWiki\StubObject\StubGlobalUser;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\User\User;
use MWExceptionHandler;
use StatusValue;
use UnexpectedValueException;
use Wikimedia\Rdbms\DBQueryError;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers \MediaWiki\Api\ApiMain
 */
class ApiMainTest extends ApiTestCase {
	use MockAuthorityTrait;

	protected function setUp(): void {
		parent::setUp();
		$this->setGroupPermissions( [
			'*' => [
				'read' => true,
				'edit' => true,
				'apihighlimits' => false,
			],
		] );
	}

	/**
	 * Test that the API will accept a MediaWiki\Request\FauxRequest and execute.
	 */
	public function testApi() {
		$fauxRequest = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] );
		$fauxRequest->setRequestURL( 'https://' );
		$api = new ApiMain(
			$fauxRequest
		);
		$api->execute();
		$data = $api->getResult()->getResultData();
		$this->assertIsArray( $data );
		$this->assertArrayHasKey( 'query', $data );
	}

	public function testApiNoParam() {
		$api = new ApiMain();
		$api->execute();
		$data = $api->getResult()->getResultData();
		$this->assertIsArray( $data );
	}

	/**
	 * ApiMain behaves differently if passed a MediaWiki\Request\FauxRequest (mInternalMode set
	 * to true) or a proper WebRequest (mInternalMode false).  For most tests
	 * we can just set mInternalMode to false using TestingAccessWrapper, but
	 * this doesn't work for the constructor.  This method returns an ApiMain
	 * that's been set up in non-internal mode.
	 *
	 * Note that calling execute() will print to the console.  Wrap it in
	 * ob_start()/ob_end_clean() to prevent this.
	 *
	 * @param array $requestData Query parameters for the WebRequest
	 * @param array $headers Headers for the WebRequest
	 * @return ApiMain
	 */
	private function getNonInternalApiMain( array $requestData, array $headers = [] ) {
		$req = $this->getMockBuilder( WebRequest::class )
			->onlyMethods( [ 'response', 'getRawIP' ] )
			->getMock();
		$response = new FauxResponse();
		$req->method( 'response' )->willReturn( $response );
		$req->method( 'getRawIP' )->willReturn( '127.0.0.1' );

		$wrapper = TestingAccessWrapper::newFromObject( $req );
		$wrapper->data = $requestData;
		if ( $headers ) {
			$wrapper->headers = $headers;
		}

		return new ApiMain( $req );
	}

	public function testUselang() {
		global $wgLang;

		$api = $this->getNonInternalApiMain( [
			'action' => 'query',
			'meta' => 'siteinfo',
			'uselang' => 'fr',
		] );

		ob_start();
		$api->execute();
		ob_end_clean();

		$this->assertSame( 'fr', $wgLang->getCode() );
	}

	public function testSuppressedLogin() {
		// Testing some logic that changes the global $wgUser
		// ApiMain will be setting it to a MediaWiki\StubObject\StubGlobalUser object, it should already
		// be one but in case its a full User object we will wrap the comparisons
		// in MediaWiki\StubObject\StubGlobalUser::getRealUser() which will return the inner User object
		// for a MediaWiki\StubObject\StubGlobalUser, or the actual User object if given a user.

		// phpcs:ignore MediaWiki.Usage.DeprecatedGlobalVariables.Deprecated$wgUser
		global $wgUser;
		$origUser = StubGlobalUser::getRealUser( $wgUser );

		$api = $this->getNonInternalApiMain( [
			'action' => 'query',
			'meta' => 'siteinfo',
			'origin' => '*',
		] );

		ob_start();
		$api->execute();
		ob_end_clean();

		$this->assertNotSame( $origUser, StubGlobalUser::getRealUser( $wgUser ) );
		$this->assertSame( 'true', $api->getContext()->getRequest()->response()
			->getHeader( 'MediaWiki-Login-Suppressed' ) );
	}

	public function testSetContinuationManager() {
		$api = new ApiMain();
		$manager = $this->createMock( ApiContinuationManager::class );
		$api->setContinuationManager( $manager );
		$this->assertTrue( true, 'No exception' );
	}

	public function testSetContinuationManagerTwice() {
		$this->expectException( UnexpectedValueException::class );
		$this->expectExceptionMessage(
			'ApiMain::setContinuationManager: tried to set manager from  ' .
				'when a manager is already set from '
		);

		$api = new ApiMain();
		$manager = $this->createMock( ApiContinuationManager::class );
		$api->setContinuationManager( $manager );
		$api->setContinuationManager( $manager );
	}

	public function testSetCacheModeUnrecognized() {
		$api = new ApiMain();
		$api->setCacheMode( 'unrecognized' );
		$this->assertSame(
			'private',
			TestingAccessWrapper::newFromObject( $api )->mCacheMode,
			'Unrecognized params must be silently ignored'
		);
	}

	public function testSetCacheModePrivateWiki() {
		$this->setGroupPermissions( '*', 'read', false );
		$wrappedApi = TestingAccessWrapper::newFromObject( new ApiMain() );
		$wrappedApi->setCacheMode( 'public' );
		$this->assertSame( 'private', $wrappedApi->mCacheMode );
		$wrappedApi->setCacheMode( 'anon-public-user-private' );
		$this->assertSame( 'private', $wrappedApi->mCacheMode );
	}

	public function testAddRequestedFieldsRequestId() {
		$req = new FauxRequest( [
			'action' => 'query',
			'meta' => 'siteinfo',
			'requestid' => '123456',
		] );
		$api = new ApiMain( $req );
		$api->execute();
		$this->assertSame( '123456', $api->getResult()->getResultData()['requestid'] );
	}

	public function testAddRequestedFieldsCurTimestamp() {
		// Fake timestamp for better testability, CI can sometimes take
		// unreasonably long to run the simple test request here.
		ConvertibleTimestamp::setFakeTime( '20190102030405' );

		$req = new FauxRequest( [
			'action' => 'query',
			'meta' => 'siteinfo',
			'curtimestamp' => '',
		] );
		$api = new ApiMain( $req );
		$api->execute();
		$timestamp = $api->getResult()->getResultData()['curtimestamp'];
		$this->assertSame( '2019-01-02T03:04:05Z', $timestamp );
	}

	public function testAddRequestedFieldsResponseLangInfo() {
		$req = new FauxRequest( [
			'action' => 'query',
			'meta' => 'siteinfo',
			// errorlang is ignored if errorformat is not specified
			'errorformat' => 'plaintext',
			'uselang' => 'FR',
			'errorlang' => 'ja',
			'responselanginfo' => '',
		] );
		$api = new ApiMain( $req );
		$api->execute();
		$data = $api->getResult()->getResultData();
		$this->assertSame( 'fr', $data['uselang'] );
		$this->assertSame( 'ja', $data['errorlang'] );
	}

	public function testSetupModuleUnknown() {
		$this->expectApiErrorCode( 'badvalue' );

		$req = new FauxRequest( [ 'action' => 'unknownaction' ] );
		$api = new ApiMain( $req );
		$api->execute();
	}

	public function testSetupModuleNoTokenProvided() {
		$this->expectApiErrorCode( 'missingparam' );

		$req = new FauxRequest( [
			'action' => 'edit',
			'title' => 'New page',
			'text' => 'Some text',
		] );
		$api = new ApiMain( $req );
		$api->execute();
	}

	public function testSetupModuleInvalidTokenProvided() {
		$this->expectApiErrorCode( 'badtoken' );

		$req = new FauxRequest( [
			'action' => 'edit',
			'title' => 'New page',
			'text' => 'Some text',
			'token' => "This isn't a real token!",
		] );
		$api = new ApiMain( $req );
		$api->execute();
	}

	public function testSetupModuleNeedsTokenTrue() {
		$this->expectException( LogicException::class );
		$this->expectExceptionMessage(
			"Module 'testmodule' must be updated for the new token handling. " .
				"See documentation for ApiBase::needsToken for details."
		);

		$mock = $this->createMock( ApiBase::class );
		$mock->method( 'getModuleName' )->willReturn( 'testmodule' );
		$mock->method( 'needsToken' )->willReturn( true );

		$api = new ApiMain( new FauxRequest( [ 'action' => 'testmodule' ] ) );
		$api->getModuleManager()->addModule( 'testmodule', 'action', [
			'class' => get_class( $mock ),
			'factory' => static function () use ( $mock ) {
				return $mock;
			}
		] );
		$api->execute();
	}

	public function testSetupModuleNeedsTokenNeedntBePosted() {
		$this->expectException( LogicException::class );
		$this->expectExceptionMessage( "Module 'testmodule' must require POST to use tokens." );

		$mock = $this->createMock( ApiBase::class );
		$mock->method( 'getModuleName' )->willReturn( 'testmodule' );
		$mock->method( 'needsToken' )->willReturn( 'csrf' );
		$mock->method( 'mustBePosted' )->willReturn( false );

		$api = new ApiMain( new FauxRequest( [ 'action' => 'testmodule' ] ) );
		$api->getModuleManager()->addModule( 'testmodule', 'action', [
			'class' => get_class( $mock ),
			'factory' => static function () use ( $mock ) {
				return $mock;
			}
		] );
		$api->execute();
	}

	public function testCheckMaxLagFailed() {
		// It's hard to mock the LoadBalancer properly, so instead we'll mock
		// checkMaxLag (which is tested directly in other tests below).
		$req = new FauxRequest( [
			'action' => 'query',
			'meta' => 'siteinfo',
		] );

		$mock = $this->getMockBuilder( ApiMain::class )
			->setConstructorArgs( [ $req ] )
			->onlyMethods( [ 'checkMaxLag' ] )
			->getMock();
		$mock->method( 'checkMaxLag' )->willReturn( false );

		$mock->execute();

		$this->assertArrayNotHasKey( 'query', $mock->getResult()->getResultData() );
	}

	public function testCheckConditionalRequestHeadersFailed() {
		// The detailed checking of all cases of checkConditionalRequestHeaders
		// is below in testCheckConditionalRequestHeaders(), which calls the
		// method directly.  Here we just check that it will stop execution if
		// it does fail.
		$now = time();

		$this->overrideConfigValue( MainConfigNames::CacheEpoch, '20030516000000' );

		$mock = $this->createMock( ApiBase::class );
		$mock->method( 'getModuleName' )->willReturn( 'testmodule' );
		$mock->method( 'getConditionalRequestData' )
			->willReturn( wfTimestamp( TS_MW, $now - 3600 ) );
		$mock->expects( $this->never() )->method( 'execute' );

		$req = new FauxRequest( [
			'action' => 'testmodule',
		] );
		$req->setHeader( 'If-Modified-Since', wfTimestamp( TS_RFC2822, $now - 3600 ) );
		$req->setRequestURL( "http://localhost" );

		$api = new ApiMain( $req );
		$api->getModuleManager()->addModule( 'testmodule', 'action', [
			'class' => get_class( $mock ),
			'factory' => static function () use ( $mock ) {
				return $mock;
			}
		] );

		$wrapper = TestingAccessWrapper::newFromObject( $api );
		$wrapper->mInternalMode = false;

		ob_start();
		$api->execute();
		ob_end_clean();
	}

	private function doTestCheckMaxLag( $lag ) {
		$mockLB = $this->createMock( ILoadBalancer::class );
		$mockLB->method( 'getMaxLag' )->willReturn( [ 'somehost', $lag ] );
		$mockLB->method( 'getConnection' )->willReturn( $this->createMock( IDatabase::class ) );
		$this->setService( 'DBLoadBalancer', $mockLB );

		$req = new FauxRequest();

		$api = new ApiMain( $req );
		$wrapper = TestingAccessWrapper::newFromObject( $api );

		$mockModule = $this->createMock( ApiBase::class );
		$mockModule->method( 'shouldCheckMaxLag' )->willReturn( true );

		try {
			$wrapper->checkMaxLag( $mockModule, [ 'maxlag' => 3 ] );
		} finally {
			if ( $lag > 3 ) {
				$this->assertSame( '5', $req->response()->getHeader( 'Retry-After' ) );
				$this->assertSame( (string)$lag, $req->response()->getHeader( 'X-Database-Lag' ) );
			}
		}
	}

	public function testCheckMaxLagOkay() {
		$this->doTestCheckMaxLag( 3 );

		// No exception, we're happy
		$this->assertTrue( true );
	}

	public function testCheckMaxLagExceeded() {
		$this->expectApiErrorCode( 'maxlag' );

		$this->overrideConfigValue( MainConfigNames::ShowHostnames, false );

		$this->doTestCheckMaxLag( 4 );
	}

	public function testCheckMaxLagExceededWithHostNames() {
		$this->expectApiErrorCode( 'maxlag' );

		$this->overrideConfigValue( MainConfigNames::ShowHostnames, true );

		$this->doTestCheckMaxLag( 4 );
	}

	public function provideAssert() {
		return [
			[ $this->mockAnonNullAuthority(), 'user', 'assertuserfailed' ],
			[ $this->mockRegisteredNullAuthority(), 'user', false ],
			[ $this->mockAnonNullAuthority(), 'anon', false ],
			[ $this->mockRegisteredNullAuthority(), 'anon', 'assertanonfailed' ],
			[ $this->mockRegisteredNullAuthority(), 'bot', 'assertbotfailed' ],
			[ $this->mockRegisteredAuthorityWithPermissions( [ 'bot' ] ), 'user', false ],
			[ $this->mockRegisteredAuthorityWithPermissions( [ 'bot' ] ), 'bot', false ],
		];
	}

	/**
	 * Tests the assert={user|bot} functionality
	 *
	 * @dataProvider provideAssert
	 */
	public function testAssert( Authority $performer, $assert, $error ) {
		try {
			$this->doApiRequest( [
				'action' => 'query',
				'assert' => $assert,
			], null, null, $performer );
			$this->assertFalse( $error ); // That no error was expected
		} catch ( ApiUsageException $e ) {
			$this->assertApiErrorCode( $error, $e,
				"Error '{$e->getMessage()}' matched expected '$error'" );
		}
	}

	/**
	 * Tests the assertuser= functionality
	 */
	public function testAssertUser() {
		$user = $this->getTestUser()->getUser();
		$this->doApiRequest( [
			'action' => 'query',
			'assertuser' => $user->getName(),
		], null, null, $user );

		try {
			$this->doApiRequest( [
				'action' => 'query',
				'assertuser' => $user->getName() . 'X',
			], null, null, $user );
			$this->fail( 'Expected exception not thrown' );
		} catch ( ApiUsageException $e ) {
			$this->assertApiErrorCode( 'assertnameduserfailed', $e );
		}
	}

	/**
	 * Test that 'assert' is processed before module errors
	 */
	public function testAssertBeforeModule() {
		// Check that the query without assert throws too-many-titles
		try {
			$this->doApiRequest( [
				'action' => 'query',
				'titles' => implode( '|', range( 1, ApiBase::LIMIT_SML1 + 1 ) ),
			], null, null, new User );
			$this->fail( 'Expected exception not thrown' );
		} catch ( ApiUsageException $e ) {
			$this->assertApiErrorCode( 'toomanyvalues', $e );
		}

		// Now test that the assert happens first
		try {
			$this->doApiRequest( [
				'action' => 'query',
				'titles' => implode( '|', range( 1, ApiBase::LIMIT_SML1 + 1 ) ),
				'assert' => 'user',
			], null, null, new User );
			$this->fail( 'Expected exception not thrown' );
		} catch ( ApiUsageException $e ) {
			$this->assertApiErrorCode( 'assertuserfailed', $e,
				"Error '{$e->getMessage()}' matched expected 'assertuserfailed'" );
		}
	}

	/**
	 * Test if all classes in the main module manager exists
	 */
	public function testClassNamesInModuleManager() {
		$api = new ApiMain(
			new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] )
		);
		$modules = $api->getModuleManager()->getNamesWithClasses();

		foreach ( $modules as $name => $class ) {
			$this->assertTrue(
				class_exists( $class ),
				'Class ' . $class . ' for api module ' . $name . ' does not exist (with exact case)'
			);
		}
	}

	/**
	 * Test HTTP precondition headers
	 *
	 * @dataProvider provideCheckConditionalRequestHeaders
	 * @param array $headers HTTP headers
	 * @param array $conditions Return data for ApiBase::getConditionalRequestData
	 * @param int $status Expected response status
	 * @param array $options Array of options:
	 *   post => true Request is a POST
	 *   cdn => true CDN is enabled ($wgUseCdn)
	 */
	public function testCheckConditionalRequestHeaders(
		$headers, $conditions, $status, $options = []
	) {
		$request = new FauxRequest(
			[ 'action' => 'query', 'meta' => 'siteinfo' ],
			!empty( $options['post'] )
		);
		$request->setHeaders( $headers );
		$request->response()->statusHeader( 200 ); // Why doesn't it default?

		$context = $this->apiContext->newTestContext( $request, null );
		$api = new ApiMain( $context );
		$priv = TestingAccessWrapper::newFromObject( $api );
		$priv->mInternalMode = false;

		if ( !empty( $options['cdn'] ) ) {
			$this->overrideConfigValue( MainConfigNames::UseCdn, true );
		}

		// Can't do this in TestSetup.php because Setup.php will override it
		$this->overrideConfigValue( MainConfigNames::CacheEpoch, '20030516000000' );

		$module = $this->getMockBuilder( ApiBase::class )
			->setConstructorArgs( [ $api, 'mock' ] )
			->onlyMethods( [ 'getConditionalRequestData' ] )
			->getMockForAbstractClass();
		$module->method( 'getConditionalRequestData' )
			->willReturnCallback( static function ( $condition ) use ( $conditions ) {
				return $conditions[$condition] ?? null;
			} );

		$ret = $priv->checkConditionalRequestHeaders( $module );

		$this->assertSame( $status, $request->response()->getStatusCode() );
		$this->assertSame( $status === 200, $ret );
	}

	public static function provideCheckConditionalRequestHeaders() {
		global $wgCdnMaxAge;
		$now = time();

		return [
			// Non-existing from module is ignored
			'If-None-Match' => [ [ 'If-None-Match' => '"foo", "bar"' ], [], 200 ],
			'If-Modified-Since' =>
				[ [ 'If-Modified-Since' => 'Tue, 18 Aug 2015 00:00:00 GMT' ], [], 200 ],

			// No headers
			'No headers' => [ [], [ 'etag' => '""', 'last-modified' => '20150815000000', ], 200 ],

			// Basic If-None-Match
			'If-None-Match with matching etag' =>
				[ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"bar"' ], 304 ],
			'If-None-Match with non-matching etag' =>
				[ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"baz"' ], 200 ],
			'Strong If-None-Match with weak matching etag' =>
				[ [ 'If-None-Match' => '"foo"' ], [ 'etag' => 'W/"foo"' ], 304 ],
			'Weak If-None-Match with strong matching etag' =>
				[ [ 'If-None-Match' => 'W/"foo"' ], [ 'etag' => '"foo"' ], 304 ],
			'Weak If-None-Match with weak matching etag' =>
				[ [ 'If-None-Match' => 'W/"foo"' ], [ 'etag' => 'W/"foo"' ], 304 ],

			// Pointless for GET, but supported
			'If-None-Match: *' => [ [ 'If-None-Match' => '*' ], [], 304 ],

			// Basic If-Modified-Since
			'If-Modified-Since, modified one second earlier' =>
				[ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
					[ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
			'If-Modified-Since, modified now' =>
				[ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
					[ 'last-modified' => wfTimestamp( TS_MW, $now ) ], 304 ],
			'If-Modified-Since, modified one second later' =>
				[ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
					[ 'last-modified' => wfTimestamp( TS_MW, $now + 1 ) ], 200 ],

			// If-Modified-Since ignored when If-None-Match is given too
			'Non-matching If-None-Match and matching If-Modified-Since' =>
				[ [ 'If-None-Match' => '""',
					'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
					[ 'etag' => '"x"', 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ],
			'Non-matching If-None-Match and matching If-Modified-Since with no ETag' =>
				[
					[
						'If-None-Match' => '""',
						'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now )
					],
					[ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ],
					304
				],

			// Ignored for POST
			'Matching If-None-Match with POST' =>
				[ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"bar"' ], 200,
					[ 'post' => true ] ],
			'Matching If-Modified-Since with POST' =>
				[ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
					[ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200,
					[ 'post' => true ] ],

			// Other date formats allowed by the RFC
			'If-Modified-Since with alternate date format 1' =>
				[ [ 'If-Modified-Since' => gmdate( 'l, d-M-y H:i:s', $now ) . ' GMT' ],
					[ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
			'If-Modified-Since with alternate date format 2' =>
				[ [ 'If-Modified-Since' => gmdate( 'D M j H:i:s Y', $now ) ],
					[ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],

			// Old browser extension to HTTP/1.0
			'If-Modified-Since with length' =>
				[ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) . '; length=123' ],
					[ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],

			// Invalid date formats should be ignored
			'If-Modified-Since with invalid date format' =>
				[ [ 'If-Modified-Since' => gmdate( 'Y-m-d H:i:s', $now ) . ' GMT' ],
					[ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ],
			'If-Modified-Since with entirely unparseable date' =>
				[ [ 'If-Modified-Since' => 'a potato' ],
					[ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ],

			// Anything before $wgCdnMaxAge seconds ago should be considered
			// expired.
			'If-Modified-Since with CDN post-expiry' =>
				[ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now - $wgCdnMaxAge * 2 ) ],
					[ 'last-modified' => wfTimestamp( TS_MW, $now - $wgCdnMaxAge * 3 ) ],
					200, [ 'cdn' => true ] ],
			'If-Modified-Since with CDN pre-expiry' =>
				[ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now - $wgCdnMaxAge / 2 ) ],
					[ 'last-modified' => wfTimestamp( TS_MW, $now - $wgCdnMaxAge * 3 ) ],
					304, [ 'cdn' => true ] ],
		];
	}

	/**
	 * Test conditional headers output
	 * @dataProvider provideConditionalRequestHeadersOutput
	 * @param array $conditions Return data for ApiBase::getConditionalRequestData
	 * @param array $headers Expected output headers
	 * @param bool $isError $isError flag
	 * @param bool $post Request is a POST
	 */
	public function testConditionalRequestHeadersOutput(
		$conditions, $headers, $isError = false, $post = false
	) {
		$request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ], $post );
		$response = $request->response();

		$api = new ApiMain( $request );
		$priv = TestingAccessWrapper::newFromObject( $api );
		$priv->mInternalMode = false;

		$module = $this->getMockBuilder( ApiBase::class )
			->setConstructorArgs( [ $api, 'mock' ] )
			->onlyMethods( [ 'getConditionalRequestData' ] )
			->getMockForAbstractClass();
		$module->method( 'getConditionalRequestData' )
			->willReturnCallback( static function ( $condition ) use ( $conditions ) {
				return $conditions[$condition] ?? null;
			} );
		$priv->mModule = $module;

		$priv->sendCacheHeaders( $isError );

		foreach ( [ 'Last-Modified', 'ETag' ] as $header ) {
			$this->assertEquals(
				$headers[$header] ?? null,
				$response->getHeader( $header ),
				$header
			);
		}
	}

	public static function provideConditionalRequestHeadersOutput() {
		return [
			[
				[],
				[]
			],
			[
				[ 'etag' => '"foo"' ],
				[ 'ETag' => '"foo"' ]
			],
			[
				[ 'last-modified' => '20150818000102' ],
				[ 'Last-Modified' => 'Tue, 18 Aug 2015 00:01:02 GMT' ]
			],
			[
				[ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
				[ 'ETag' => '"foo"', 'Last-Modified' => 'Tue, 18 Aug 2015 00:01:02 GMT' ]
			],
			[
				[ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
				[],
				true,
			],
			[
				[ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
				[],
				false,
				true,
			],
		];
	}

	public function testCheckExecutePermissionsReadProhibited() {
		$this->expectApiErrorCode( 'readapidenied' );

		$this->setGroupPermissions( '*', 'read', false );

		$main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
		$main->execute();
	}

	public function testCheckExecutePermissionWriteDisabled() {
		$this->expectApiErrorCode( 'noapiwrite' );
		$main = new ApiMain( new FauxRequest( [
			'action' => 'edit',
			'title' => 'Some page',
			'text' => 'Some text',
			'token' => '+\\',
		] ) );
		$main->execute();
	}

	public function testCheckExecutePermissionPromiseNonWrite() {
		$this->expectApiErrorCode( 'promised-nonwrite-api' );

		$req = new FauxRequest( [
			'action' => 'edit',
			'title' => 'Some page',
			'text' => 'Some text',
			'token' => '+\\',
		] );
		$req->setHeaders( [ 'Promise-Non-Write-API-Action' => '1' ] );
		$main = new ApiMain( $req, /* enableWrite = */ true );
		$main->execute();
	}

	public function testCheckExecutePermissionHookAbort() {
		$this->expectApiErrorCode( 'mainpage' );

		$this->setTemporaryHook( 'ApiCheckCanExecute', static function ( $unused1, $unused2, &$message ) {
			$message = 'mainpage';
			return false;
		} );

		$main = new ApiMain( new FauxRequest( [
			'action' => 'edit',
			'title' => 'Some page',
			'text' => 'Some text',
			'token' => '+\\',
		] ), /* enableWrite = */ true );
		$main->execute();
	}

	public function testGetValUnsupportedArray() {
		$main = new ApiMain( new FauxRequest( [
			'action' => 'query',
			'meta' => 'siteinfo',
			'siprop' => [ 'general', 'namespaces' ],
		] ) );
		$this->assertSame( 'myDefault', $main->getVal( 'siprop', 'myDefault' ) );
		$main->execute();
		$this->assertSame( 'Parameter "siprop" uses unsupported PHP array syntax.',
			$main->getResult()->getResultData()['warnings']['main']['warnings'] );
	}

	public function testReportUnusedParams() {
		$main = new ApiMain( new FauxRequest( [
			'action' => 'query',
			'meta' => 'siteinfo',
			'unusedparam' => 'unusedval',
			'anotherunusedparam' => 'anotherval',
		] ) );
		$main->execute();
		$this->assertSame( 'Unrecognized parameters: unusedparam, anotherunusedparam.',
			$main->getResult()->getResultData()['warnings']['main']['warnings'] );
	}

	public function testLacksSameOriginSecurity() {
		// Basic test
		$main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
		$this->assertFalse( $main->lacksSameOriginSecurity(), 'Basic test, should have security' );

		// JSONp
		$main = new ApiMain(
			new FauxRequest( [ 'action' => 'query', 'format' => 'xml', 'callback' => 'foo' ] )
		);
		$this->assertTrue( $main->lacksSameOriginSecurity(), 'JSONp, should lack security' );

		// Header
		$request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] );
		$request->setHeader( 'TrEaT-As-UnTrUsTeD', '' ); // With falsey value!
		$main = new ApiMain( $request );
		$this->assertTrue( $main->lacksSameOriginSecurity(), 'Header supplied, should lack security' );

		// Hook
		$this->mergeMwGlobalArrayValue( 'wgHooks', [
			'RequestHasSameOriginSecurity' => [ static function () {
				return false;
			} ]
		] );
		$main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
		$this->assertTrue( $main->lacksSameOriginSecurity(), 'Hook, should lack security' );
	}

	/**
	 * Test proper creation of the ApiErrorFormatter
	 *
	 * @dataProvider provideApiErrorFormatterCreation
	 * @param array $request Request parameters
	 * @param array $expect Expected data
	 *  - uselang: ApiMain language
	 *  - class: ApiErrorFormatter class
	 *  - lang: ApiErrorFormatter language
	 *  - format: ApiErrorFormatter format
	 *  - usedb: ApiErrorFormatter use-database flag
	 */
	public function testApiErrorFormatterCreation( array $request, array $expect ) {
		$context = new RequestContext();
		$context->setRequest( new FauxRequest( $request ) );
		$context->setLanguage( 'ru' );

		$main = new ApiMain( $context );
		$formatter = $main->getErrorFormatter();
		$wrappedFormatter = TestingAccessWrapper::newFromObject( $formatter );

		$this->assertSame( $expect['uselang'], $main->getLanguage()->getCode() );
		$this->assertInstanceOf( $expect['class'], $formatter );
		$this->assertSame( $expect['lang'], $formatter->getLanguage()->getCode() );
		$this->assertSame( $expect['format'], $wrappedFormatter->format );
		$this->assertSame( $expect['usedb'], $wrappedFormatter->useDB );
	}

	public static function provideApiErrorFormatterCreation() {
		return [
			'Default (BC)' => [ [], [
				'uselang' => 'ru',
				'class' => ApiErrorFormatter_BackCompat::class,
				'lang' => 'en',
				'format' => 'none',
				'usedb' => false,
			] ],
			'BC ignores fields' => [ [ 'errorlang' => 'de', 'errorsuselocal' => 1 ], [
				'uselang' => 'ru',
				'class' => ApiErrorFormatter_BackCompat::class,
				'lang' => 'en',
				'format' => 'none',
				'usedb' => false,
			] ],
			'Explicit BC' => [ [ 'errorformat' => 'bc' ], [
				'uselang' => 'ru',
				'class' => ApiErrorFormatter_BackCompat::class,
				'lang' => 'en',
				'format' => 'none',
				'usedb' => false,
			] ],
			'Basic' => [ [ 'errorformat' => 'wikitext' ], [
				'uselang' => 'ru',
				'class' => ApiErrorFormatter::class,
				'lang' => 'ru',
				'format' => 'wikitext',
				'usedb' => false,
			] ],
			'Follows uselang' => [ [ 'uselang' => 'fr', 'errorformat' => 'plaintext' ], [
				'uselang' => 'fr',
				'class' => ApiErrorFormatter::class,
				'lang' => 'fr',
				'format' => 'plaintext',
				'usedb' => false,
			] ],
			'Explicitly follows uselang' => [
				[ 'uselang' => 'fr', 'errorlang' => 'uselang', 'errorformat' => 'plaintext' ],
				[
					'uselang' => 'fr',
					'class' => ApiErrorFormatter::class,
					'lang' => 'fr',
					'format' => 'plaintext',
					'usedb' => false,
				]
			],
			'uselang=content' => [
				[ 'uselang' => 'content', 'errorformat' => 'plaintext' ],
				[
					'uselang' => 'en',
					'class' => ApiErrorFormatter::class,
					'lang' => 'en',
					'format' => 'plaintext',
					'usedb' => false,
				]
			],
			'errorlang=content' => [
				[ 'errorlang' => 'content', 'errorformat' => 'plaintext' ],
				[
					'uselang' => 'ru',
					'class' => ApiErrorFormatter::class,
					'lang' => 'en',
					'format' => 'plaintext',
					'usedb' => false,
				]
			],
			'Explicit parameters' => [
				[ 'errorlang' => 'de', 'errorformat' => 'html', 'errorsuselocal' => 1 ],
				[
					'uselang' => 'ru',
					'class' => ApiErrorFormatter::class,
					'lang' => 'de',
					'format' => 'html',
					'usedb' => true,
				]
			],
			'Explicit parameters override uselang' => [
				[ 'errorlang' => 'de', 'uselang' => 'fr', 'errorformat' => 'raw' ],
				[
					'uselang' => 'fr',
					'class' => ApiErrorFormatter::class,
					'lang' => 'de',
					'format' => 'raw',
					'usedb' => false,
				]
			],
			'Bogus language doesn\'t explode' => [
				[ 'errorlang' => '<bogus1>', 'uselang' => '<bogus2>', 'errorformat' => 'none' ],
				[
					'uselang' => 'en',
					'class' => ApiErrorFormatter::class,
					'lang' => 'en',
					'format' => 'none',
					'usedb' => false,
				]
			],
			'Bogus format doesn\'t explode' => [ [ 'errorformat' => 'bogus' ], [
				'uselang' => 'ru',
				'class' => ApiErrorFormatter_BackCompat::class,
				'lang' => 'en',
				'format' => 'none',
				'usedb' => false,
			] ],
		];
	}

	/**
	 * @dataProvider provideExceptionErrors
	 */
	public function testExceptionErrors( $error, $expectReturn, $expectResult ) {
		$this->overrideConfigValues( [
			MainConfigNames::Server => 'https://local.example',
			MainConfigNames::ScriptPath => '/w',
		] );
		$context = new RequestContext();
		$context->setRequest( new FauxRequest( [ 'errorformat' => 'plaintext' ] ) );
		$context->setLanguage( 'en' );
		$context->setConfig( new MultiConfig( [
			new HashConfig( [
				MainConfigNames::ShowHostnames => true, MainConfigNames::ShowExceptionDetails => true,
			] ),
			$context->getConfig()
		] ) );

		$main = new ApiMain( $context );
		$main->addWarning( new RawMessage( 'existing warning' ), 'existing-warning' );
		$main->addError( new RawMessage( 'existing error' ), 'existing-error' );

		$ret = TestingAccessWrapper::newFromObject( $main )->substituteResultWithError( $error );
		$this->assertSame( $expectReturn, $ret );

		// PHPUnit sometimes adds some SplObjectStorage garbage to the arrays,
		// so let's try ->assertEquals().
		$this->assertEquals(
			$expectResult,
			$main->getResult()->getResultData( [], [ 'Strip' => 'all' ] )
		);
	}

	// Not static so $this can be used
	public function provideExceptionErrors() {
		$reqId = WebRequest::getRequestId();
		$doclink = 'https://local.example/w/api.php';

		$ex = new InvalidArgumentException( 'Random exception' );
		$trace = wfMessage( 'api-exception-trace',
			get_class( $ex ),
			$ex->getFile(),
			$ex->getLine(),
			MWExceptionHandler::getRedactedTraceAsString( $ex )
		)->inLanguage( 'en' )->useDatabase( false )->text();

		$dbex = new DBQueryError(
			$this->createMock( IDatabase::class ),
			'error', 1234, 'SELECT 1', __METHOD__ );
		$dbtrace = wfMessage( 'api-exception-trace',
			get_class( $dbex ),
			$dbex->getFile(),
			$dbex->getLine(),
			MWExceptionHandler::getRedactedTraceAsString( $dbex )
		)->inLanguage( 'en' )->useDatabase( false )->text();

		// The specific exception doesn't matter, as long as it's namespaced.
		$nsex = new ShellDisabledError();
		$nstrace = wfMessage( 'api-exception-trace',
			get_class( $nsex ),
			$nsex->getFile(),
			$nsex->getLine(),
			MWExceptionHandler::getRedactedTraceAsString( $nsex )
		)->inLanguage( 'en' )->useDatabase( false )->text();

		$apiEx1 = new ApiUsageException( null,
			StatusValue::newFatal( new ApiRawMessage( 'An error', 'sv-error1' ) ) );
		TestingAccessWrapper::newFromObject( $apiEx1 )->modulePath = 'foo+bar';
		$apiEx1->getStatusValue()->warning( new ApiRawMessage( 'A warning', 'sv-warn1' ) );
		$apiEx1->getStatusValue()->warning( new ApiRawMessage( 'Another warning', 'sv-warn2' ) );
		$apiEx1->getStatusValue()->fatal( new ApiRawMessage( 'Another error', 'sv-error2' ) );

		$badMsg = $this->getMockBuilder( ApiRawMessage::class )
			->setConstructorArgs( [ 'An error', 'ignored' ] )
			->onlyMethods( [ 'getApiCode' ] )
			->getMock();
		$badMsg->method( 'getApiCode' )->willReturn( "bad\nvalue" );
		$apiEx2 = new ApiUsageException( null, StatusValue::newFatal( $badMsg ) );

		return [
			[
				$ex,
				[ 'existing-error', 'internal_api_error_InvalidArgumentException' ],
				[
					'warnings' => [
						[ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
					],
					'errors' => [
						[ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
						[
							'code' => 'internal_api_error_InvalidArgumentException',
							'text' => "[$reqId] Exception caught: Random exception",
							'data' => [
								'errorclass' => InvalidArgumentException::class,
							],
						]
					],
					'trace' => $trace,
					'servedby' => wfHostname(),
				]
			],
			[
				$dbex,
				[ 'existing-error', 'internal_api_error_DBQueryError' ],
				[
					'warnings' => [
						[ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
					],
					'errors' => [
						[ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
						[
							'code' => 'internal_api_error_DBQueryError',
							'text' => "[$reqId] Exception caught: A database query error has occurred. " .
								"This may indicate a bug in the software.",
							'data' => [
								'errorclass' => DBQueryError::class,
							],
						]
					],
					'trace' => $dbtrace,
					'servedby' => wfHostname(),
				]
			],
			[
				$nsex,
				[ 'existing-error', 'internal_api_error_MediaWiki\ShellDisabledError' ],
				[
					'warnings' => [
						[ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
					],
					'errors' => [
						[ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
						[
							'code' => 'internal_api_error_MediaWiki\ShellDisabledError',
							'text' => "[$reqId] Exception caught: " . $nsex->getMessage(),
							'data' => [
								'errorclass' => ShellDisabledError::class,
							],
						]
					],
					'trace' => $nstrace,
					'servedby' => wfHostname(),
				]
			],
			[
				$apiEx1,
				[ 'existing-error', 'sv-error1', 'sv-error2' ],
				[
					'warnings' => [
						[ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
						[ 'code' => 'sv-warn1', 'text' => 'A warning', 'module' => 'foo+bar' ],
						[ 'code' => 'sv-warn2', 'text' => 'Another warning', 'module' => 'foo+bar' ],
					],
					'errors' => [
						[ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
						[ 'code' => 'sv-error1', 'text' => 'An error', 'module' => 'foo+bar' ],
						[ 'code' => 'sv-error2', 'text' => 'Another error', 'module' => 'foo+bar' ],
					],
					'docref' => "See $doclink for API usage. Subscribe to the mediawiki-api-announce mailing " .
						"list at &lt;https://lists.wikimedia.org/postorius/lists/mediawiki-api-announce.lists.wikimedia.org/&gt; " .
						"for notice of API deprecations and breaking changes.",
					'servedby' => wfHostname(),
				]
			],
			[
				$apiEx2,
				[ 'existing-error', '<invalid-code>' ],
				[
					'warnings' => [
						[ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
					],
					'errors' => [
						[ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
						[ 'code' => "bad\nvalue", 'text' => 'An error' ],
					],
					'docref' => "See $doclink for API usage. Subscribe to the mediawiki-api-announce mailing " .
						"list at &lt;https://lists.wikimedia.org/postorius/lists/mediawiki-api-announce.lists.wikimedia.org/&gt; " .
						"for notice of API deprecations and breaking changes.",
					'servedby' => wfHostname(),
				]
			]
		];
	}

	public function testPrinterParameterValidationError() {
		$api = $this->getNonInternalApiMain( [
			'action' => 'query', 'meta' => 'siteinfo', 'format' => 'json', 'formatversion' => 'bogus',
		] );

		ob_start();
		$api->execute();
		$txt = ob_get_clean();

		// Test that the actual output is valid JSON, not just the format of the ApiResult.
		$data = FormatJson::decode( $txt, true );
		$this->assertIsArray( $data );
		$this->assertArrayHasKey( 'error', $data );
		$this->assertArrayHasKey( 'code', $data['error'] );
		$this->assertSame( 'badvalue', $data['error']['code'] );
	}

	public function testMatchRequestedHeaders() {
		$api = TestingAccessWrapper::newFromClass( ApiMain::class );
		$allowedHeaders = [ 'Accept', 'Origin', 'User-Agent' ];

		$this->assertTrue( $api->matchRequestedHeaders( 'Accept', $allowedHeaders ) );
		$this->assertTrue( $api->matchRequestedHeaders( 'Accept,Origin', $allowedHeaders ) );
		$this->assertTrue( $api->matchRequestedHeaders( 'accEpt, oRIGIN', $allowedHeaders ) );
		$this->assertFalse( $api->matchRequestedHeaders( 'Accept,Foo', $allowedHeaders ) );
		$this->assertFalse( $api->matchRequestedHeaders( 'Accept, fOO', $allowedHeaders ) );
	}

	/**
	 * Common test code for tests that cover ApiMain::sendCacheHeaders.
	 *
	 * @param TestingAccessWrapper $api An ApiMain instance, wrapped in a TestingAccessWrapper.
	 * @param FauxRequest $req
	 * @param bool $isError
	 * @param string $cacheMode
	 * @param string|null $expectedVary
	 * @param string $expectedCacheControl
	 */
	private function commonTestCacheHeaders(
		TestingAccessWrapper $api,
		FauxRequest $req,
		bool $isError,
		string $cacheMode,
		?string $expectedVary,
		string $expectedCacheControl
	) {
		$api->setCacheMode( $cacheMode );
		$this->assertSame( $cacheMode, $api->mCacheMode, 'Cache mode precondition' );
		$api->sendCacheHeaders( $isError );

		$this->assertSame( $expectedVary, $req->response()->getHeader( 'Vary' ), 'Vary' );
		$this->assertSame( $expectedCacheControl, $req->response()->getHeader( 'Cache-Control' ), 'Cache-Control' );
	}

	/**
	 * @param string $cacheMode
	 * @param string|null $expectedVary
	 * @param string $expectedCacheControl
	 * @param array $requestData
	 * @param Config|null $config
	 * @dataProvider provideCacheHeaders
	 */
	public function testCacheHeaders(
		string $cacheMode,
		?string $expectedVary,
		string $expectedCacheControl,
		array $requestData = [],
		?Config $config = null
	) {
		$req = new FauxRequest( $requestData );
		$ctx = new RequestContext();
		$ctx->setRequest( $req );
		if ( $config ) {
			$ctx->setConfig( $config );
		}
		/** @var ApiMain|TestingAccessWrapper $api */
		$api = TestingAccessWrapper::newFromObject( new ApiMain( $ctx ) );

		$this->commonTestCacheHeaders( $api, $req, false, $cacheMode, $expectedVary, $expectedCacheControl );
	}

	public static function provideCacheHeaders(): Generator {
		yield 'Private' => [ 'private', null, 'private, must-revalidate, max-age=0' ];
		yield 'Public' => [
			'public',
			'Accept-Encoding, Treat-as-Untrusted, Cookie',
			'private, must-revalidate, max-age=0',
			[ 'uselang' => 'en' ]
		];
		yield 'Anon public, user private' => [
			'anon-public-user-private',
			'Accept-Encoding, Treat-as-Untrusted, Cookie',
			'private, must-revalidate, max-age=0'
		];
	}

	/** @dataProvider provideCacheHeaders */
	public function testCacheHeadersOnIsErrorAsTrue(
		string $cacheMode,
		?string $expectedVary,
		string $expectedCacheControl,
		array $requestData = []
	) {
		$req = new FauxRequest( $requestData );
		$ctx = new RequestContext();
		$ctx->setRequest( $req );
		/** @var ApiMain|TestingAccessWrapper $api */
		$api = TestingAccessWrapper::newFromObject( new ApiMain( $ctx ) );

		// Create a mock ApiBase object that throws an ApiUsageException from ::isWriteMode. This will be used as the
		// mModule property of $api, and will test that ::isWriteMode is either never called or properly wrapped in
		// a try block in the method we are testing (T363133).
		$module = $this->getMockBuilder( ApiBase::class )
			->setConstructorArgs( [ $api->object, 'mock' ] )
			->onlyMethods( [ 'isWriteMode' ] )
			->getMockForAbstractClass();
		$module->method( 'isWriteMode' )
			->willThrowException( new ApiUsageException( $module, StatusValue::newFatal( 'test' ) ) );
		$api->mModule = $module;

		// This will test that ::isWriteMode will not be called if $isError is true.
		$this->commonTestCacheHeaders( $api, $req, true, $cacheMode, $expectedVary, $expectedCacheControl );
	}
}
PK       ! E      api/ApiDeleteTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * Tests for MediaWiki api.php?action=delete.
 *
 * @author Yifei He
 *
 * @group API
 * @group Database
 * @group medium
 *
 * @covers \MediaWiki\Api\ApiDelete
 */
class ApiDeleteTest extends ApiTestCase {

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

		$this->overrideConfigValue( MainConfigNames::WatchlistExpiry, true );
	}

	public function testDelete() {
		$title = Title::makeTitle( NS_HELP, 'TestDelete' );

		// create new page
		$this->editPage( $title, 'Some text' );

		// test deletion
		$apiResult = $this->doApiRequestWithToken( [
			'action' => 'delete',
			'title' => $title->getPrefixedText(),
		] )[0];

		$this->assertArrayHasKey( 'delete', $apiResult );
		$this->assertArrayHasKey( 'title', $apiResult['delete'] );
		$this->assertSame( $title->getPrefixedText(), $apiResult['delete']['title'] );
		$this->assertArrayHasKey( 'logid', $apiResult['delete'] );

		$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );
	}

	public function testBatchedDelete() {
		$this->overrideConfigValue(
			MainConfigNames::DeleteRevisionsBatchSize, 1
		);

		$title = Title::makeTitle( NS_HELP, 'TestBatchedDelete' );
		for ( $i = 1; $i <= 3; $i++ ) {
			$this->editPage( $title, "Revision $i" );
		}

		$apiResult = $this->doApiRequestWithToken( [
			'action' => 'delete',
			'title' => $title->getPrefixedText(),
		] )[0];

		$this->assertArrayHasKey( 'delete', $apiResult );
		$this->assertArrayHasKey( 'title', $apiResult['delete'] );
		$this->assertSame( $title->getPrefixedText(), $apiResult['delete']['title'] );
		$this->assertArrayHasKey( 'scheduled', $apiResult['delete'] );
		$this->assertTrue( $apiResult['delete']['scheduled'] );
		$this->assertArrayNotHasKey( 'logid', $apiResult['delete'] );

		// Run the jobs
		$this->runJobs();

		$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );
	}

	public function testDeleteNonexistent() {
		$this->expectApiErrorCode( 'missingtitle' );

		$this->doApiRequestWithToken( [
			'action' => 'delete',
			'title' => 'This page deliberately left nonexistent',
		] );
	}

	public function testDeleteAssociatedTalkPage() {
		$title = Title::makeTitle( NS_HELP, 'TestDeleteAssociatedTalkPage' );
		$talkPage = $title->getTalkPageIfDefined();
		$this->editPage( $title, 'Some text' );
		$this->editPage( $talkPage, 'Some text' );
		$apiResult = $this->doApiRequestWithToken( [
			'action' => 'delete',
			'title' => $title->getPrefixedText(),
			'deletetalk' => true,
		] )[0];

		$this->assertSame( $title->getPrefixedText(), $apiResult['delete']['title'] );
		$this->assertFalse( $talkPage->exists( IDBAccessObject::READ_LATEST ) );
	}

	public function testDeleteAssociatedTalkPageNonexistent() {
		$title = Title::makeTitle( NS_HELP, 'TestDeleteAssociatedTalkPageNonexistent' );
		$this->editPage( $title, 'Some text' );
		$apiResult = $this->doApiRequestWithToken( [
			'action' => 'delete',
			'title' => $title->getPrefixedText(),
			'deletetalk' => true,
		] )[0];

		$this->assertSame( $title->getPrefixedText(), $apiResult['delete']['title'] );
		$this->assertArrayHasKey( 'warnings', $apiResult );
	}

	public function testDeletionWithoutPermission() {
		$this->expectApiErrorCode( 'permissiondenied' );

		$title = Title::makeTitle( NS_HELP, 'TestDeletionWithoutPermission' );

		// create new page
		$this->editPage( $title, 'Some text' );

		// test deletion without permission
		try {
			$user = new User();
			$this->doApiRequest( [
				'action' => 'delete',
				'title' => $title->getPrefixedText(),
				'token' => $user->getEditToken(),
			], null, null, $user );
		} finally {
			$this->assertTrue( $title->exists( IDBAccessObject::READ_LATEST ) );
		}
	}

	public function testDeleteWithTag() {
		$title = Title::makeTitle( NS_HELP, 'TestDeleteWithTag' );

		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'custom tag' );

		$this->editPage( $title, 'Some text' );

		$this->doApiRequestWithToken( [
			'action' => 'delete',
			'title' => $title->getPrefixedText(),
			'tags' => 'custom tag',
		] );

		$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );

		$this->assertSame( 'custom tag', $this->getDb()->newSelectQueryBuilder()
			->select( 'ctd_name' )
			->from( 'logging' )
			->join( 'change_tag', null, 'ct_log_id = log_id' )
			->join( 'change_tag_def', null, 'ctd_id = ct_tag_id' )
			->where( [ 'log_namespace' => $title->getNamespace(), 'log_title' => $title->getDBkey(), ] )
			->caller( __METHOD__ )->fetchField() );
	}

	public function testDeleteWithoutTagPermission() {
		$this->expectApiErrorCode( 'tags-apply-no-permission' );

		$title = Title::makeTitle( NS_HELP, 'TestDeleteWithoutTagPermission' );

		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'custom tag' );
		$this->overrideConfigValue(
			MainConfigNames::RevokePermissions,
			[ 'user' => [ 'applychangetags' => true ] ]
		);

		$this->editPage( $title, 'Some text' );

		try {
			$this->doApiRequestWithToken( [
				'action' => 'delete',
				'title' => $title->getPrefixedText(),
				'tags' => 'custom tag',
			] );
		} finally {
			$this->assertTrue( $title->exists( IDBAccessObject::READ_LATEST ) );
		}
	}

	public function testDeleteAbortedByHook() {
		$this->expectApiErrorCode( 'delete-hook-aborted' );

		$title = Title::makeTitle( NS_HELP, 'TestDeleteAbortedByHook' );

		$this->editPage( $title, 'Some text' );

		$this->setTemporaryHook( 'ArticleDelete',
			static function () {
				return false;
			}
		);

		try {
			$this->doApiRequestWithToken( [ 'action' => 'delete', 'title' => $title->getPrefixedText() ] );
		} finally {
			$this->assertTrue( $title->exists( IDBAccessObject::READ_LATEST ) );
		}
	}

	public function testDeleteWatch() {
		$title = Title::makeTitle( NS_HELP, 'TestDeleteWatch' );
		$page = $this->getExistingTestPage( $title );
		$performer = $this->getTestSysop()->getUser();
		$watchlistManager = $this->getServiceContainer()->getWatchlistManager();

		$this->assertFalse( $watchlistManager->isWatched( $performer, $page ) );

		$res = $this->doApiRequestWithToken(
			[
				'action' => 'delete',
				'title' => $title->getPrefixedText(),
				'watch' => '',
				'watchlistexpiry' => '99990123000000',
			],
			null,
			$performer
		);
		$this->assertArrayHasKey( 'delete', $res[0] );
		$page->clear();

		$this->assertFalse( $page->exists() );
		$this->assertTrue( $watchlistManager->isWatched( $performer, $page ) );
		$this->assertTrue( $watchlistManager->isTempWatched( $performer, $page ) );
	}

	public function testDeleteUnwatch() {
		$title = Title::makeTitle( NS_HELP, 'TestDeleteUnwatch' );
		$user = $this->getTestSysop()->getUser();

		$this->editPage( $title, 'Some text' );
		$this->assertTrue( $title->exists( IDBAccessObject::READ_LATEST ) );
		$watchlistManager = $this->getServiceContainer()->getWatchlistManager();
		$watchlistManager->addWatch( $user, $title );
		$this->assertTrue( $watchlistManager->isWatched( $user, $title ) );

		$this->doApiRequestWithToken( [
			'action' => 'delete',
			'title' => $title->getPrefixedText(),
			'watchlist' => 'unwatch',
		] );

		$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );
		$this->assertFalse( $watchlistManager->isWatched( $user, $title ) );
	}
}
PK       ! \;NF]  F]    api/ApiErrorFormatterTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use Exception;
use LocalizedException;
use MediaWiki\Api\ApiErrorFormatter;
use MediaWiki\Api\ApiErrorFormatter_BackCompat;
use MediaWiki\Api\ApiMessage;
use MediaWiki\Api\ApiResult;
use MediaWiki\Api\IApiMessage;
use MediaWiki\Language\RawMessage;
use MediaWiki\Message\Message;
use MediaWiki\Status\Status;
use MediaWikiLangTestCase;
use RuntimeException;
use Wikimedia\TestingAccessWrapper;

/**
 * @group API
 */
class ApiErrorFormatterTest extends MediaWikiLangTestCase {

	/**
	 * @covers \MediaWiki\Api\ApiErrorFormatter
	 */
	public function testErrorFormatterBasics() {
		$result = new ApiResult( 8_388_608 );
		$formatter = new ApiErrorFormatter( $result,
			$this->getServiceContainer()->getLanguageFactory()->getLanguage( 'de' ), 'wikitext',
			false );
		$this->assertSame( 'de', $formatter->getLanguage()->getCode() );
		$this->assertSame( 'wikitext', $formatter->getFormat() );

		$formatter->addMessagesFromStatus( null, Status::newGood() );
		$this->assertSame(
			[ ApiResult::META_TYPE => 'assoc' ],
			$result->getResultData()
		);

		$this->assertSame( [], $formatter->arrayFromStatus( Status::newGood() ) );

		$wrappedFormatter = TestingAccessWrapper::newFromObject( $formatter );
		$this->assertSame(
			'Blah "kbd" <X> 😊',
			$wrappedFormatter->stripMarkup( 'Blah <kbd>kbd</kbd> <b>&lt;X&gt;</b> &#x1f60a;' ),
			'stripMarkup'
		);
	}

	/**
	 * @covers \MediaWiki\Api\ApiErrorFormatter
	 * @covers \MediaWiki\Api\ApiErrorFormatter_BackCompat
	 */
	public function testNewWithFormat() {
		$result = new ApiResult( 8_388_608 );
		$formatter = new ApiErrorFormatter( $result,
			$this->getServiceContainer()->getLanguageFactory()->getLanguage( 'de' ), 'wikitext',
			false );
		$formatter2 = $formatter->newWithFormat( 'html' );

		$this->assertSame( $formatter->getLanguage(), $formatter2->getLanguage() );
		$this->assertSame( 'html', $formatter2->getFormat() );

		$formatter3 = new ApiErrorFormatter_BackCompat( $result );
		$formatter4 = $formatter3->newWithFormat( 'html' );
		$this->assertNotInstanceOf( ApiErrorFormatter_BackCompat::class, $formatter4 );
		$this->assertSame( $formatter3->getLanguage(), $formatter4->getLanguage() );
		$this->assertSame( 'html', $formatter4->getFormat() );
	}

	/**
	 * @covers \MediaWiki\Api\ApiErrorFormatter
	 * @dataProvider provideErrorFormatter
	 */
	public function testErrorFormatter( $format, $lang, $useDB,
		$expect1, $expect2, $expect3
	) {
		$result = new ApiResult( 8_388_608 );
		$formatter = new ApiErrorFormatter( $result,
			$this->getServiceContainer()->getLanguageFactory()->getLanguage( $lang ), $format,
			$useDB );

		// Add default type
		$expect1[ApiResult::META_TYPE] = 'assoc';
		$expect2[ApiResult::META_TYPE] = 'assoc';
		$expect3[ApiResult::META_TYPE] = 'assoc';

		$formatter->addWarning( 'string', 'mainpage' );
		$formatter->addError( 'err', 'aboutpage' );
		$this->assertEquals( $expect1, $result->getResultData(), 'Simple test' );

		$result->reset();
		$formatter->addWarning( 'foo', 'mainpage' );
		$formatter->addWarning( 'foo', 'mainpage' );
		$formatter->addWarning( 'foo', [ 'parentheses', 'foobar' ] );
		$msg1 = wfMessage( 'copyright' );
		$formatter->addWarning( 'message', $msg1 );
		$msg2 = new ApiMessage( 'disclaimers', 'overriddenCode', [ 'overriddenData' => true ] );
		$formatter->addWarning( 'messageWithData', $msg2 );
		$msg3 = new ApiMessage( 'edithelp', 'overriddenCode', [ 'overriddenData' => true ] );
		$formatter->addError( 'errWithData', $msg3 );
		$this->assertSame( $expect2, $result->getResultData(), 'Complex test' );

		$this->assertEquals(
			$this->removeModuleTag( $expect2['warnings'][2] ),
			$formatter->formatMessage( $msg1 ),
			'formatMessage test 1'
		);
		$this->assertEquals(
			$this->removeModuleTag( $expect2['warnings'][3] ),
			$formatter->formatMessage( $msg2 ),
			'formatMessage test 2'
		);

		$result->reset();
		$status = Status::newGood();
		$status->warning( 'mainpage' );
		$status->warning( 'parentheses', 'foobar' );
		$status->warning( $msg1 );
		$status->warning( $msg2 );
		$status->error( 'aboutpage' );
		$status->error( 'brackets', 'foobar' );
		$formatter->addMessagesFromStatus( 'status', $status );
		$this->assertSame( $expect3, $result->getResultData(), 'Status test' );

		$this->assertSame(
			array_map( [ $this, 'removeModuleTag' ], $expect3['errors'] ),
			$formatter->arrayFromStatus( $status, 'error' ),
			'arrayFromStatus test for error'
		);
		$this->assertSame(
			array_map( [ $this, 'removeModuleTag' ], $expect3['warnings'] ),
			$formatter->arrayFromStatus( $status, 'warning' ),
			'arrayFromStatus test for warning'
		);
	}

	private function removeModuleTag( $s ) {
		if ( is_array( $s ) ) {
			unset( $s['module'] );
		}
		return $s;
	}

	private static function text( $msg ) {
		return $msg->inLanguage( 'de' )->useDatabase( false )->text();
	}

	private static function html( $msg ) {
		return $msg->inLanguage( 'en' )->parse();
	}

	public static function provideErrorFormatter() {
		$aboutpage = wfMessage( 'aboutpage' );
		$mainpage = wfMessage( 'mainpage' );
		$parens = wfMessage( 'parentheses', 'foobar' );
		$brackets = wfMessage( 'brackets', 'foobar' );
		$copyright = wfMessage( 'copyright' );
		$disclaimers = wfMessage( 'disclaimers' );
		$edithelp = wfMessage( 'edithelp' );

		$C = ApiResult::META_CONTENT;
		$I = ApiResult::META_INDEXED_TAG_NAME;
		$overriddenData = [ 'overriddenData' => true, ApiResult::META_TYPE => 'assoc' ];

		return [
			'zero' => $tmp = [ 'wikitext', 'de', false,
				[
					'errors' => [
						[ 'code' => 'aboutpage', 'text' => self::text( $aboutpage ), 'module' => 'err', $C => 'text' ],
						$I => 'error',
					],
					'warnings' => [
						[ 'code' => 'mainpage', 'text' => self::text( $mainpage ), 'module' => 'string', $C => 'text' ],
						$I => 'warning',
					],
				],
				[
					'errors' => [
						[ 'code' => 'overriddenCode', 'text' => self::text( $edithelp ),
							'data' => $overriddenData, 'module' => 'errWithData', $C => 'text' ],
						$I => 'error',
					],
					'warnings' => [
						[ 'code' => 'mainpage', 'text' => self::text( $mainpage ), 'module' => 'foo', $C => 'text' ],
						[ 'code' => 'parentheses', 'text' => self::text( $parens ), 'module' => 'foo', $C => 'text' ],
						[ 'code' => 'copyright', 'text' => self::text( $copyright ),
							'module' => 'message', $C => 'text' ],
						[ 'code' => 'overriddenCode', 'text' => self::text( $disclaimers ),
							'data' => $overriddenData, 'module' => 'messageWithData', $C => 'text' ],
						$I => 'warning',
					],
				],
				[
					'errors' => [
						[ 'code' => 'aboutpage', 'text' => self::text( $aboutpage ),
							'module' => 'status', $C => 'text' ],
						[ 'code' => 'brackets', 'text' => self::text( $brackets ), 'module' => 'status', $C => 'text' ],
						$I => 'error',
					],
					'warnings' => [
						[ 'code' => 'mainpage', 'text' => self::text( $mainpage ), 'module' => 'status', $C => 'text' ],
						[ 'code' => 'parentheses', 'text' => self::text( $parens ),
							'module' => 'status', $C => 'text' ],
						[ 'code' => 'copyright', 'text' => self::text( $copyright ),
							'module' => 'status', $C => 'text' ],
						[ 'code' => 'overriddenCode', 'text' => self::text( $disclaimers ),
							'data' => $overriddenData, 'module' => 'status', $C => 'text' ],
						$I => 'warning',
					],
				],
			],
			'one' => [ 'plaintext' ] + $tmp, // For these messages, plaintext and wikitext are the same
			'two' => [ 'html', 'en', true,
				[
					'errors' => [
						[ 'code' => 'aboutpage', 'html' => self::html( $aboutpage ), 'module' => 'err', $C => 'html' ],
						$I => 'error',
					],
					'warnings' => [
						[ 'code' => 'mainpage', 'html' => self::html( $mainpage ), 'module' => 'string', $C => 'html' ],
						$I => 'warning',
					],
				],
				[
					'errors' => [
						[ 'code' => 'overriddenCode', 'html' => self::html( $edithelp ),
							'data' => $overriddenData, 'module' => 'errWithData', $C => 'html' ],
						$I => 'error',
					],
					'warnings' => [
						[ 'code' => 'mainpage', 'html' => self::html( $mainpage ), 'module' => 'foo', $C => 'html' ],
						[ 'code' => 'parentheses', 'html' => self::html( $parens ), 'module' => 'foo', $C => 'html' ],
						[ 'code' => 'copyright', 'html' => self::html( $copyright ),
							'module' => 'message', $C => 'html' ],
						[ 'code' => 'overriddenCode', 'html' => self::html( $disclaimers ),
							'data' => $overriddenData, 'module' => 'messageWithData', $C => 'html' ],
						$I => 'warning',
					],
				],
				[
					'errors' => [
						[ 'code' => 'aboutpage', 'html' => self::html( $aboutpage ),
							'module' => 'status', $C => 'html' ],
						[ 'code' => 'brackets', 'html' => self::html( $brackets ), 'module' => 'status', $C => 'html' ],
						$I => 'error',
					],
					'warnings' => [
						[ 'code' => 'mainpage', 'html' => self::html( $mainpage ), 'module' => 'status', $C => 'html' ],
						[ 'code' => 'parentheses', 'html' => self::html( $parens ),
							'module' => 'status', $C => 'html' ],
						[ 'code' => 'copyright', 'html' => self::html( $copyright ),
							'module' => 'status', $C => 'html' ],
						[ 'code' => 'overriddenCode', 'html' => self::html( $disclaimers ),
							'data' => $overriddenData, 'module' => 'status', $C => 'html' ],
						$I => 'warning',
					],
				],
			],
			'three' => [ 'raw', 'fr', true,
				[
					'errors' => [
						[
							'code' => 'aboutpage',
							'key' => 'aboutpage',
							'params' => [ $I => 'param' ],
							'module' => 'err',
						],
						$I => 'error',
					],
					'warnings' => [
						[
							'code' => 'mainpage',
							'key' => 'mainpage',
							'params' => [ $I => 'param' ],
							'module' => 'string',
						],
						$I => 'warning',
					],
				],
				[
					'errors' => [
						[
							'code' => 'overriddenCode',
							'key' => 'edithelp',
							'params' => [ $I => 'param' ],
							'data' => $overriddenData,
							'module' => 'errWithData',
						],
						$I => 'error',
					],
					'warnings' => [
						[
							'code' => 'mainpage',
							'key' => 'mainpage',
							'params' => [ $I => 'param' ],
							'module' => 'foo',
						],
						[
							'code' => 'parentheses',
							'key' => 'parentheses',
							'params' => [ 'foobar', $I => 'param' ],
							'module' => 'foo',
						],
						[
							'code' => 'copyright',
							'key' => 'copyright',
							'params' => [ $I => 'param' ],
							'module' => 'message',
						],
						[
							'code' => 'overriddenCode',
							'key' => 'disclaimers',
							'params' => [ $I => 'param' ],
							'data' => $overriddenData,
							'module' => 'messageWithData',
						],
						$I => 'warning',
					],
				],
				[
					'errors' => [
						[
							'code' => 'aboutpage',
							'key' => 'aboutpage',
							'params' => [ $I => 'param' ],
							'module' => 'status',
						],
						[
							'code' => 'brackets',
							'key' => 'brackets',
							'params' => [ 'foobar', $I => 'param' ],
							'module' => 'status',
						],
						$I => 'error',
					],
					'warnings' => [
						[
							'code' => 'mainpage',
							'key' => 'mainpage',
							'params' => [ $I => 'param' ],
							'module' => 'status',
						],
						[
							'code' => 'parentheses',
							'key' => 'parentheses',
							'params' => [ 'foobar', $I => 'param' ],
							'module' => 'status',
						],
						[
							'code' => 'copyright',
							'key' => 'copyright',
							'params' => [ $I => 'param' ],
							'module' => 'status',
						],
						[
							'code' => 'overriddenCode',
							'key' => 'disclaimers',
							'params' => [ $I => 'param' ],
							'data' => $overriddenData,
							'module' => 'status',
						],
						$I => 'warning',
					],
				],
			],
			'four' => [ 'none', 'fr', true,
				[
					'errors' => [
						[ 'code' => 'aboutpage', 'module' => 'err' ],
						$I => 'error',
					],
					'warnings' => [
						[ 'code' => 'mainpage', 'module' => 'string' ],
						$I => 'warning',
					],
				],
				[
					'errors' => [
						[ 'code' => 'overriddenCode', 'data' => $overriddenData,
							'module' => 'errWithData' ],
						$I => 'error',
					],
					'warnings' => [
						[ 'code' => 'mainpage', 'module' => 'foo' ],
						[ 'code' => 'parentheses', 'module' => 'foo' ],
						[ 'code' => 'copyright', 'module' => 'message' ],
						[ 'code' => 'overriddenCode', 'data' => $overriddenData,
							'module' => 'messageWithData' ],
						$I => 'warning',
					],
				],
				[
					'errors' => [
						[ 'code' => 'aboutpage', 'module' => 'status' ],
						[ 'code' => 'brackets', 'module' => 'status' ],
						$I => 'error',
					],
					'warnings' => [
						[ 'code' => 'mainpage', 'module' => 'status' ],
						[ 'code' => 'parentheses', 'module' => 'status' ],
						[ 'code' => 'copyright', 'module' => 'status' ],
						[ 'code' => 'overriddenCode', 'data' => $overriddenData, 'module' => 'status' ],
						$I => 'warning',
					],
				],
			],
		];
	}

	/**
	 * @covers \MediaWiki\Api\ApiErrorFormatter_BackCompat
	 */
	public function testErrorFormatterBC() {
		$aboutpage = wfMessage( 'aboutpage' );
		$mainpage = wfMessage( 'mainpage' );
		$parens = wfMessage( 'parentheses', 'foobar' );
		$copyright = wfMessage( 'copyright' );
		$disclaimers = wfMessage( 'disclaimers' );
		$edithelp = wfMessage( 'edithelp' );

		$result = new ApiResult( 8_388_608 );
		$formatter = new ApiErrorFormatter_BackCompat( $result );

		$this->assertSame( 'en', $formatter->getLanguage()->getCode() );
		$this->assertSame( 'bc', $formatter->getFormat() );

		$this->assertSame( [], $formatter->arrayFromStatus( Status::newGood() ) );

		$formatter->addWarning( 'string', 'mainpage' );
		$formatter->addWarning( 'raw',
			new RawMessage( 'Blah <kbd>kbd</kbd> <b>&lt;X&gt;</b> &#x1f61e;' )
		);
		$formatter->addError( 'err', 'aboutpage' );
		$this->assertSame( [
			'error' => [
				'code' => 'aboutpage',
				'info' => $aboutpage->useDatabase( false )->plain(),
			],
			'warnings' => [
				'raw' => [
					'warnings' => 'Blah "kbd" <X> 😞',
					ApiResult::META_CONTENT => 'warnings',
				],
				'string' => [
					'warnings' => $mainpage->useDatabase( false )->plain(),
					ApiResult::META_CONTENT => 'warnings',
				],
			],
			ApiResult::META_TYPE => 'assoc',
		], $result->getResultData(), 'Simple test' );

		$result->reset();
		$formatter->addWarning( 'foo', 'mainpage' );
		$formatter->addWarning( 'foo', 'mainpage' );
		$formatter->addWarning( 'xxx+foo', [ 'parentheses', 'foobar' ] );
		$msg1 = wfMessage( 'copyright' );
		$formatter->addWarning( 'message', $msg1 );
		$msg2 = new ApiMessage( 'disclaimers', 'overriddenCode', [ 'overriddenData' => true ] );
		$formatter->addWarning( 'messageWithData', $msg2 );
		$msg3 = new ApiMessage( 'edithelp', 'overriddenCode', [ 'overriddenData' => true ] );
		$formatter->addError( 'errWithData', $msg3 );
		$formatter->addWarning( null, 'mainpage' );
		$this->assertSame( [
			'error' => [
				'code' => 'overriddenCode',
				'info' => $edithelp->useDatabase( false )->plain(),
				'overriddenData' => true,
			],
			'warnings' => [
				'unknown' => [
					'warnings' => $mainpage->useDatabase( false )->plain(),
					ApiResult::META_CONTENT => 'warnings',
				],
				'messageWithData' => [
					'warnings' => $disclaimers->useDatabase( false )->plain(),
					ApiResult::META_CONTENT => 'warnings',
				],
				'message' => [
					'warnings' => $copyright->useDatabase( false )->plain(),
					ApiResult::META_CONTENT => 'warnings',
				],
				'foo' => [
					'warnings' => $mainpage->useDatabase( false )->plain()
						. "\n" . $parens->useDatabase( false )->plain(),
					ApiResult::META_CONTENT => 'warnings',
				],
			],
			ApiResult::META_TYPE => 'assoc',
		], $result->getResultData(), 'Complex test' );

		$this->assertSame(
			[
				'code' => 'copyright',
				'info' => $copyright->useDatabase( false )->plain(),
			],
			$formatter->formatMessage( $msg1 )
		);
		$this->assertSame(
			[
				'code' => 'overriddenCode',
				'info' => $disclaimers->useDatabase( false )->plain(),
				'overriddenData' => true,
			],
			$formatter->formatMessage( $msg2 )
		);
		$this->assertSame(
			[
				'code' => 'overriddenCode',
				'info' => $edithelp->useDatabase( false )->plain(),
				'overriddenData' => true,
			],
			$formatter->formatMessage( $msg3 )
		);

		$result->reset();
		$status = Status::newGood();
		$status->warning( 'mainpage' );
		$status->warning( 'parentheses', 'foobar' );
		$status->warning( $msg1 );
		$status->warning( $msg2 );
		$status->error( 'aboutpage' );
		$status->error( 'brackets', 'foobar' );
		$formatter->addMessagesFromStatus( 'status', $status );
		$this->assertSame( [
			'error' => [
				'code' => 'aboutpage',
				'info' => $aboutpage->useDatabase( false )->plain(),
			],
			'warnings' => [
				'status' => [
					'warnings' => $mainpage->useDatabase( false )->plain()
						. "\n" . $parens->useDatabase( false )->plain()
						. "\n" . $copyright->useDatabase( false )->plain()
						. "\n" . $disclaimers->useDatabase( false )->plain(),
					ApiResult::META_CONTENT => 'warnings',
				],
			],
			ApiResult::META_TYPE => 'assoc',
		], $result->getResultData(), 'Status test' );

		$I = ApiResult::META_INDEXED_TAG_NAME;
		$this->assertSame(
			[
				[
					'message' => 'aboutpage',
					'params' => [ $I => 'param' ],
					'code' => 'aboutpage',
					'type' => 'error',
				],
				[
					'message' => 'brackets',
					'params' => [ 'foobar', $I => 'param' ],
					'code' => 'brackets',
					'type' => 'error',
				],
				$I => 'error',
			],
			$formatter->arrayFromStatus( $status, 'error' ),
			'arrayFromStatus test for error'
		);
		$this->assertSame(
			[
				[
					'message' => 'mainpage',
					'params' => [ $I => 'param' ],
					'code' => 'mainpage',
					'type' => 'warning',
				],
				[
					'message' => 'parentheses',
					'params' => [ 'foobar', $I => 'param' ],
					'code' => 'parentheses',
					'type' => 'warning',
				],
				[
					'message' => 'copyright',
					'params' => [ $I => 'param' ],
					'code' => 'copyright',
					'type' => 'warning',
				],
				[
					'message' => 'disclaimers',
					'params' => [ $I => 'param' ],
					'code' => 'overriddenCode',
					'type' => 'warning',
				],
				$I => 'warning',
			],
			$formatter->arrayFromStatus( $status, 'warning' ),
			'arrayFromStatus test for warning'
		);

		$result->reset();
		$result->addValue( null, 'error', [ 'bogus' ] );
		$formatter->addError( 'err', 'aboutpage' );
		$this->assertSame( [
			'error' => [
				'code' => 'aboutpage',
				'info' => $aboutpage->useDatabase( false )->plain(),
			],
			ApiResult::META_TYPE => 'assoc',
		], $result->getResultData(), 'Overwrites bogus "error" value with real error' );
	}

	/**
	 * @dataProvider provideGetMessageFromException
	 * @covers \MediaWiki\Api\ApiErrorFormatter::getMessageFromException
	 * @covers \MediaWiki\Api\ApiErrorFormatter::formatException
	 * @param Exception $exception
	 * @param array $options
	 * @param array $expect
	 */
	public function testGetMessageFromException( $exception, $options, $expect ) {
		$result = new ApiResult( 8_388_608 );
		$formatter = new ApiErrorFormatter( $result,
			$this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' ), 'html',
			false );

		$msg = $formatter->getMessageFromException( $exception, $options );
		$this->assertInstanceOf( Message::class, $msg );
		$this->assertInstanceOf( IApiMessage::class, $msg );
		$this->assertSame( $expect, [
			'text' => $msg->parse(),
			'code' => $msg->getApiCode(),
			'data' => $msg->getApiData(),
		] );

		$expectFormatted = $formatter->formatMessage( $msg );
		$formatted = $formatter->formatException( $exception, $options );
		$this->assertSame( $expectFormatted, $formatted );
	}

	/**
	 * @dataProvider provideGetMessageFromException
	 * @covers \MediaWiki\Api\ApiErrorFormatter_BackCompat::formatException
	 * @param Exception $exception
	 * @param array $options
	 * @param array $expect
	 */
	public function testGetMessageFromException_BC( $exception, $options, $expect ) {
		$result = new ApiResult( 8_388_608 );
		$formatter = new ApiErrorFormatter_BackCompat( $result );

		$msg = $formatter->getMessageFromException( $exception, $options );
		$this->assertInstanceOf( Message::class, $msg );
		$this->assertInstanceOf( IApiMessage::class, $msg );
		$this->assertSame( $expect, [
			'text' => $msg->parse(),
			'code' => $msg->getApiCode(),
			'data' => $msg->getApiData(),
		] );

		$expectFormatted = $formatter->formatMessage( $msg );
		$formatted = $formatter->formatException( $exception, $options );
		$this->assertSame( $expectFormatted, $formatted );
		$formatted = $formatter->formatException( $exception, $options + [ 'bc' => true ] );
		$this->assertSame( $expectFormatted['info'], $formatted );
	}

	public static function provideGetMessageFromException() {
		return [
			'Normal exception' => [
				new RuntimeException( '<b>Something broke!</b>' ),
				[],
				[
					'text' => '&#60;b&#62;Something broke!&#60;/b&#62;',
					'code' => 'internal_api_error_RuntimeException',
					'data' => [
						'errorclass' => 'RuntimeException',
					],
				]
			],
			'Normal exception, wrapped' => [
				new RuntimeException( '<b>Something broke!</b>' ),
				[ 'wrap' => 'parentheses', 'code' => 'some-code', 'data' => [ 'foo' => 'bar', 'baz' => 42 ] ],
				[
					'text' => '(&#60;b&#62;Something broke!&#60;/b&#62;)',
					'code' => 'some-code',
					'data' => [ 'foo' => 'bar', 'baz' => 42 ],
				]
			],
			'LocalizedException' => [
				new LocalizedException( [ 'returnto', '<b>FooBar</b>' ] ),
				[],
				[
					'text' => 'Return to <b>FooBar</b>.',
					'code' => 'returnto',
					'data' => [],
				]
			],
			'LocalizedException, wrapped' => [
				new LocalizedException( [ 'returnto', '<b>FooBar</b>' ] ),
				[ 'wrap' => 'parentheses', 'code' => 'some-code', 'data' => [ 'foo' => 'bar', 'baz' => 42 ] ],
				[
					'text' => 'Return to <b>FooBar</b>.',
					'code' => 'some-code',
					'data' => [ 'foo' => 'bar', 'baz' => 42 ],
				]
			],
		];
	}

	/**
	 * @covers \MediaWiki\Api\ApiErrorFormatter::addMessagesFromStatus
	 * @covers \MediaWiki\Api\ApiErrorFormatter::addWarningOrError
	 * @covers \MediaWiki\Api\ApiErrorFormatter::formatMessageInternal
	 */
	public function testAddMessagesFromStatus_filter() {
		$result = new ApiResult( 8_388_608 );
		$formatter = new ApiErrorFormatter( $result,
			$this->getServiceContainer()->getLanguageFactory()->getLanguage( 'qqx' ),
			'plaintext', false );

		$status = Status::newGood();
		$status->warning( 'mainpage' );
		$status->warning( 'parentheses', 'foobar' );
		$status->warning( wfMessage( 'mainpage' ) );
		$status->error( 'mainpage' );
		$status->error( 'parentheses', 'foobaz' );
		$formatter->addMessagesFromStatus( 'status', $status, [ 'warning', 'error' ], [ 'mainpage' ] );
		$this->assertSame( [
			'errors' => [
				[
					'code' => 'parentheses',
					'text' => '(parentheses: foobaz)',
					'module' => 'status',
					ApiResult::META_CONTENT => 'text',
				],
				ApiResult::META_INDEXED_TAG_NAME => 'error',
			],
			'warnings' => [
				[
					'code' => 'parentheses',
					'text' => '(parentheses: foobar)',
					'module' => 'status',
					ApiResult::META_CONTENT => 'text',
				],
				ApiResult::META_INDEXED_TAG_NAME => 'warning',
			],
			ApiResult::META_TYPE => 'assoc',
		], $result->getResultData() );
	}

	/**
	 * @dataProvider provideIsValidApiCode
	 * @covers \MediaWiki\Api\ApiErrorFormatter::isValidApiCode
	 * @param string $code
	 * @param bool $expect
	 */
	public function testIsValidApiCode( $code, $expect ) {
		$this->assertSame( $expect, ApiErrorFormatter::isValidApiCode( $code ) );
	}

	public static function provideIsValidApiCode() {
		return [
			[ 'foo-bar_Baz123', true ],
			[ 'foo bar', false ],
			[ 'foo\\bar', false ],
			[ 'internal_api_error_foo\\bar baz', true ],
		];
	}

}
PK       ! [mæ  æ    api/ApiComparePagesTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiUsageException;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use RevisionDeleter;

/**
 * @group API
 * @group Database
 * @group medium
 * @covers \MediaWiki\Api\ApiComparePages
 */
class ApiComparePagesTest extends ApiTestCase {

	use TempUserTestTrait;

	/** @var array */
	protected static $repl = [];

	protected function addPage( $page, $text, $model = CONTENT_MODEL_WIKITEXT ) {
		$page = $this->getServiceContainer()->getWikiPageFactory()
			->newFromLinkTarget( new TitleValue( NS_MAIN, 'ApiComparePagesTest ' . $page ) );
		$content = $this->getServiceContainer()->getContentHandlerFactory()
			->getContentHandler( $model )
			->unserializeContent( $text );
		$performer = static::getTestSysop()->getAuthority();
		$status = $this->editPage(
			$page,
			$content,
			'Test for ApiComparePagesTest: ' . $text,
			NS_MAIN,
			$performer
		);
		if ( !$status->isOK() ) {
			$this->fail( 'Failed to create ' . $page->getTitle()->getPrefixedText() . ': ' . $status->getWikiText( false, false, 'en' ) );
		}
		return $status->getNewRevision()->getId();
	}

	public function addDBDataOnce() {
		$this->disableAutoCreateTempUser();
		$user = static::getTestSysop()->getUser();
		self::$repl['creator'] = $user->getName();
		self::$repl['creatorid'] = $user->getId();

		self::$repl['revA1'] = $this->addPage( 'A', 'A 1' );
		self::$repl['revA2'] = $this->addPage( 'A', 'A 2' );
		self::$repl['revA3'] = $this->addPage( 'A', 'A 3' );
		self::$repl['revA4'] = $this->addPage( 'A', 'A 4' );
		self::$repl['pageA'] = Title::makeTitle( NS_MAIN, 'ApiComparePagesTest A' )->getArticleID();

		self::$repl['revB1'] = $this->addPage( 'B', 'B 1' );
		self::$repl['revB2'] = $this->addPage( 'B', 'B 2' );
		self::$repl['revB3'] = $this->addPage( 'B', 'B 3' );
		self::$repl['revB4'] = $this->addPage( 'B', 'B 4' );
		self::$repl['pageB'] = Title::makeTitle( NS_MAIN, 'ApiComparePagesTest B' )->getArticleID();
		$updateTimestamps = [
			self::$repl['revB1'] => '20010101011101',
			self::$repl['revB2'] => '20020202022202',
			self::$repl['revB3'] => '20030303033303',
			self::$repl['revB4'] => '20040404044404',
		];
		foreach ( $updateTimestamps as $id => $ts ) {
			$this->getDb()->newUpdateQueryBuilder()
				->update( 'revision' )
				->set( [ 'rev_timestamp' => $this->getDb()->timestamp( $ts ) ] )
				->where( [ 'rev_id' => $id ] )
				->caller( __METHOD__ )
				->execute();
		}

		self::$repl['revC1'] = $this->addPage( 'C', 'C 1' );
		self::$repl['revC2'] = $this->addPage( 'C', 'C 2' );
		self::$repl['revC3'] = $this->addPage( 'C', 'C 3' );
		self::$repl['pageC'] = Title::makeTitle( NS_MAIN, 'ApiComparePagesTest C' )->getArticleID();

		$id = $this->addPage( 'D', 'D 1' );
		self::$repl['pageD'] = Title::makeTitle( NS_MAIN, 'ApiComparePagesTest D' )->getArticleID();
		$this->getDb()->newDeleteQueryBuilder()
			->deleteFrom( 'revision' )
			->where( [ 'rev_id' => $id ] )
			->caller( __METHOD__ )
			->execute();

		self::$repl['revE1'] = $this->addPage( 'E', 'E 1' );
		self::$repl['revE2'] = $this->addPage( 'E', 'E 2' );
		self::$repl['revE3'] = $this->addPage( 'E', 'E 3' );
		self::$repl['revE4'] = $this->addPage( 'E', 'E 4' );
		self::$repl['pageE'] = Title::makeTitle( NS_MAIN, 'ApiComparePagesTest E' )->getArticleID();
		$this->getDb()->newUpdateQueryBuilder()
			->update( 'page' )
			->set( [ 'page_latest' => 0 ] )
			->where( [ 'page_id' => self::$repl['pageE'] ] )
			->caller( __METHOD__ )
			->execute();

		self::$repl['revF1'] = $this->addPage( 'F', "== Section 1 ==\nF 1.1\n\n== Section 2 ==\nF 1.2" );
		self::$repl['pageF'] = Title::makeTitle( NS_MAIN, 'ApiComparePagesTest F' )->getArticleID();

		self::$repl['revG1'] = $this->addPage( 'G', "== Section 1 ==\nG 1.1", CONTENT_MODEL_TEXT );
		self::$repl['pageG'] = Title::makeTitle( NS_MAIN, 'ApiComparePagesTest G' )->getArticleID();

		$page = $this->getServiceContainer()->getWikiPageFactory()
			->newFromTitle( Title::makeTitle( NS_MAIN, 'ApiComparePagesTest C' ) );
		$this->deletePage( $page, 'Test for ApiComparePagesTest', $user );

		RevisionDeleter::createList(
			'revision',
			RequestContext::getMain(),
			Title::makeTitle( NS_MAIN, 'ApiComparePagesTest B' ),
			[ self::$repl['revB2'] ]
		)->setVisibility( [
			'value' => [
				RevisionRecord::DELETED_TEXT => 1,
				RevisionRecord::DELETED_USER => 1,
				RevisionRecord::DELETED_COMMENT => 1,
			],
			'comment' => 'Test for ApiComparePages',
		] );

		RevisionDeleter::createList(
			'revision',
			RequestContext::getMain(),
			Title::makeTitle( NS_MAIN, 'ApiComparePagesTest B' ),
			[ self::$repl['revB3'] ]
		)->setVisibility( [
			'value' => [
				RevisionRecord::DELETED_USER => 1,
				RevisionRecord::DELETED_COMMENT => 1,
				RevisionRecord::DELETED_RESTRICTED => 1,
			],
			'comment' => 'Test for ApiComparePages',
		] );

		Title::clearCaches(); // Otherwise it has the wrong latest revision for some reason
	}

	protected function doReplacements( &$value ) {
		if ( is_string( $value ) ) {
			if ( preg_match( '/^{{REPL:(.+?)}}$/', $value, $m ) ) {
				$value = self::$repl[$m[1]];
			} else {
				$value = preg_replace_callback( '/{{REPL:(.+?)}}/', static function ( $m ) {
					return self::$repl[$m[1]] ?? $m[0];
				}, $value );
			}
		} elseif ( is_array( $value ) || is_object( $value ) ) {
			foreach ( $value as &$v ) {
				$this->doReplacements( $v );
			}
			unset( $v );
		}
	}

	/**
	 * @dataProvider provideDiff
	 */
	public function testDiff( $params, $expect, $exceptionCode = false, $sysop = false ) {
		$this->overrideConfigValue( MainConfigNames::DiffEngine, 'php' );

		$this->doReplacements( $params );

		$params += [
			'action' => 'compare',
			'errorformat' => 'none',
		];

		$performer = $sysop
			? static::getTestSysop()->getAuthority()
			: static::getTestUser()->getAuthority();
		if ( $exceptionCode ) {
			try {
				$this->doApiRequest( $params, null, false, $performer );
				$this->fail( 'Expected exception not thrown' );
			} catch ( ApiUsageException $ex ) {
				$this->assertApiErrorCode( $exceptionCode, $ex,
					"Exception with code $exceptionCode" );
			}
		} else {
			$apiResult = $this->doApiRequest( $params, null, false, $performer );
			$apiResult = $apiResult[0];
			$this->doReplacements( $expect );
			$this->assertEquals( $expect, $apiResult );
		}
	}

	private static function makeDeprecationWarnings( ...$params ) {
		$warn = [];
		foreach ( $params as $p ) {
			$warn[] = [
				'code' => 'deprecation',
				'data' => [ 'feature' => "action=compare&{$p}" ],
				'module' => 'compare',
			];
			if ( count( $warn ) === 1 ) {
				$warn[] = [
					'code' => 'deprecation-help',
					'module' => 'main',
				];
			}
		}

		return $warn;
	}

	public static function provideDiff() {
		return [
			'Basic diff, titles' => [
				[
					'fromtitle' => 'ApiComparePagesTest A',
					'totitle' => 'ApiComparePagesTest B',
				],
				[
					'compare' => [
						'fromid' => '{{REPL:pageA}}',
						'fromrevid' => '{{REPL:revA4}}',
						'fromns' => 0,
						'fromtitle' => 'ApiComparePagesTest A',
						'toid' => '{{REPL:pageB}}',
						'torevid' => '{{REPL:revB4}}',
						'tons' => 0,
						'totitle' => 'ApiComparePagesTest B',
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">A </del>4</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">B </ins>4</div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, page IDs' => [
				[
					'fromid' => '{{REPL:pageA}}',
					'toid' => '{{REPL:pageB}}',
				],
				[
					'compare' => [
						'fromid' => '{{REPL:pageA}}',
						'fromrevid' => '{{REPL:revA4}}',
						'fromns' => 0,
						'fromtitle' => 'ApiComparePagesTest A',
						'toid' => '{{REPL:pageB}}',
						'torevid' => '{{REPL:revB4}}',
						'tons' => 0,
						'totitle' => 'ApiComparePagesTest B',
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">A </del>4</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">B </ins>4</div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, revision IDs' => [
				[
					'fromrev' => '{{REPL:revA2}}',
					'torev' => '{{REPL:revA3}}',
				],
				[
					'compare' => [
						'fromid' => '{{REPL:pageA}}',
						'fromrevid' => '{{REPL:revA2}}',
						'fromns' => 0,
						'fromtitle' => 'ApiComparePagesTest A',
						'toid' => '{{REPL:pageA}}',
						'torevid' => '{{REPL:revA3}}',
						'tons' => 0,
						'totitle' => 'ApiComparePagesTest A',
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div>A <del class="diffchange diffchange-inline">2</del></div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div>A <ins class="diffchange diffchange-inline">3</ins></div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, deleted revision ID as sysop' => [
				[
					'fromrev' => '{{REPL:revA2}}',
					'torev' => '{{REPL:revC2}}',
				],
				[
					'compare' => [
						'fromid' => '{{REPL:pageA}}',
						'fromrevid' => '{{REPL:revA2}}',
						'fromns' => 0,
						'fromtitle' => 'ApiComparePagesTest A',
						'toid' => 0,
						'torevid' => '{{REPL:revC2}}',
						'tons' => 0,
						'totitle' => 'ApiComparePagesTest C',
						'toarchive' => true,
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">A </del>2</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">C </ins>2</div></td></tr>' . "\n",
					]
				],
				false, true
			],
			'Basic diff, revdel as sysop' => [
				[
					'fromrev' => '{{REPL:revA2}}',
					'torev' => '{{REPL:revB2}}',
				],
				[
					'compare' => [
						'fromid' => '{{REPL:pageA}}',
						'fromrevid' => '{{REPL:revA2}}',
						'fromns' => 0,
						'fromtitle' => 'ApiComparePagesTest A',
						'toid' => '{{REPL:pageB}}',
						'torevid' => '{{REPL:revB2}}',
						'tons' => 0,
						'totitle' => 'ApiComparePagesTest B',
						'totexthidden' => true,
						'touserhidden' => true,
						'tocommenthidden' => true,
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">A </del>2</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">B </ins>2</div></td></tr>' . "\n",
					]
				],
				false, true
			],
			'Basic diff, text' => [
				[
					'fromslots' => 'main',
					'fromtext-main' => 'From text',
					'fromcontentmodel-main' => 'wikitext',
					'toslots' => 'main',
					'totext-main' => 'To text {{subst:PAGENAME}}',
					'tocontentmodel-main' => 'wikitext',
				],
				[
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">{{subst:PAGENAME}}</ins></div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, text 2' => [
				[
					'fromslots' => 'main',
					'fromtext-main' => 'From text',
					'toslots' => 'main',
					'totext-main' => 'To text {{subst:PAGENAME}}',
					'tocontentmodel-main' => 'wikitext',
				],
				[
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">{{subst:PAGENAME}}</ins></div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, guessed model' => [
				[
					'fromslots' => 'main',
					'fromtext-main' => 'From text',
					'toslots' => 'main',
					'totext-main' => 'To text',
				],
				[
					'warnings' => [ [ 'code' => 'compare-nocontentmodel', 'module' => 'compare' ] ],
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To </ins>text</div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, text with title and PST' => [
				[
					'fromslots' => 'main',
					'fromtext-main' => 'From text',
					'totitle' => 'Test',
					'toslots' => 'main',
					'totext-main' => 'To text {{subst:PAGENAME}}',
					'topst' => true,
				],
				[
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">Test</ins></div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, text with page ID and PST' => [
				[
					'fromslots' => 'main',
					'fromtext-main' => 'From text',
					'toid' => '{{REPL:pageB}}',
					'toslots' => 'main',
					'totext-main' => 'To text {{subst:PAGENAME}}',
					'topst' => true,
				],
				[
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest B</ins></div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, text with revision and PST' => [
				[
					'fromslots' => 'main',
					'fromtext-main' => 'From text',
					'torev' => '{{REPL:revB2}}',
					'toslots' => 'main',
					'totext-main' => 'To text {{subst:PAGENAME}}',
					'topst' => true,
				],
				[
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest B</ins></div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, text with deleted revision and PST' => [
				[
					'fromslots' => 'main',
					'fromtext-main' => 'From text',
					'torev' => '{{REPL:revC2}}',
					'toslots' => 'main',
					'totext-main' => 'To text {{subst:PAGENAME}}',
					'topst' => true,
				],
				[
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest C</ins></div></td></tr>' . "\n",
					]
				],
				false, true
			],
			'Basic diff, test with sections' => [
				[
					'fromtitle' => 'ApiComparePagesTest F',
					'fromslots' => 'main',
					'fromtext-main' => "== Section 2 ==\nFrom text?",
					'fromsection-main' => 2,
					'totitle' => 'ApiComparePagesTest F',
					'toslots' => 'main',
					'totext-main' => "== Section 1 ==\nTo text?",
					'tosection-main' => 1,
				],
				[
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker"></td><td class="diff-context diff-side-deleted"><div>== Section 1 ==</div></td><td class="diff-marker"></td><td class="diff-context diff-side-added"><div>== Section 1 ==</div></td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">F 1.1</del></div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To text?</ins></div></td></tr>' . "\n"
							. '<tr><td class="diff-marker"></td><td class="diff-context diff-side-deleted"><br></td><td class="diff-marker"></td><td class="diff-context diff-side-added"><br></td></tr>' . "\n"
							. '<tr><td class="diff-marker"></td><td class="diff-context diff-side-deleted"><div>== Section 2 ==</div></td><td class="diff-marker"></td><td class="diff-context diff-side-added"><div>== Section 2 ==</div></td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From text?</del></div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">F 1.2</ins></div></td></tr>' . "\n",
					]
				],
			],
			'Diff with all props' => [
				[
					'fromrev' => '{{REPL:revB1}}',
					'torev' => '{{REPL:revB3}}',
					'totitle' => 'ApiComparePagesTest B',
					'prop' => 'diff|diffsize|rel|ids|title|user|comment|parsedcomment|size|timestamp'
				],
				[
					'compare' => [
						'fromid' => '{{REPL:pageB}}',
						'fromrevid' => '{{REPL:revB1}}',
						'fromns' => 0,
						'fromtitle' => 'ApiComparePagesTest B',
						'fromsize' => 3,
						'fromuser' => '{{REPL:creator}}',
						'fromuserid' => '{{REPL:creatorid}}',
						'fromcomment' => 'Test for ApiComparePagesTest: B 1',
						'fromparsedcomment' => 'Test for ApiComparePagesTest: B 1',
						'fromtimestamp' => '2001-01-01T01:11:01Z',
						'toid' => '{{REPL:pageB}}',
						'torevid' => '{{REPL:revB3}}',
						'tons' => 0,
						'totitle' => 'ApiComparePagesTest B',
						'tosize' => 3,
						'touserhidden' => true,
						'tocommenthidden' => true,
						'tosuppressed' => true,
						'totimestamp' => '2003-03-03T03:33:03Z',
						'next' => '{{REPL:revB4}}',
						'diffsize' => 454,
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div>B <del class="diffchange diffchange-inline">1</del></div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div>B <ins class="diffchange diffchange-inline">3</ins></div></td></tr>' . "\n",
					]
				],
			],
			'Diff with all props as sysop' => [
				[
					'fromrev' => '{{REPL:revB2}}',
					'torev' => '{{REPL:revB3}}',
					'totitle' => 'ApiComparePagesTest B',
					'prop' => 'diff|diffsize|rel|ids|title|user|comment|parsedcomment|size|timestamp'
				],
				[
					'compare' => [
						'fromid' => '{{REPL:pageB}}',
						'fromrevid' => '{{REPL:revB2}}',
						'fromns' => 0,
						'fromtitle' => 'ApiComparePagesTest B',
						'fromsize' => 3,
						'fromtexthidden' => true,
						'fromuserhidden' => true,
						'fromuser' => '{{REPL:creator}}',
						'fromuserid' => '{{REPL:creatorid}}',
						'fromcommenthidden' => true,
						'fromcomment' => 'Test for ApiComparePagesTest: B 2',
						'fromparsedcomment' => 'Test for ApiComparePagesTest: B 2',
						'fromtimestamp' => '2002-02-02T02:22:02Z',
						'toid' => '{{REPL:pageB}}',
						'torevid' => '{{REPL:revB3}}',
						'tons' => 0,
						'totitle' => 'ApiComparePagesTest B',
						'tosize' => 3,
						'touserhidden' => true,
						'tocommenthidden' => true,
						'tosuppressed' => true,
						'totimestamp' => '2003-03-03T03:33:03Z',
						'prev' => '{{REPL:revB1}}',
						'next' => '{{REPL:revB4}}',
						'diffsize' => 454,
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div>B <del class="diffchange diffchange-inline">2</del></div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div>B <ins class="diffchange diffchange-inline">3</ins></div></td></tr>' . "\n",
					]
				],
				false, true
			],
			'Text diff with all props' => [
				[
					'fromrev' => '{{REPL:revB1}}',
					'toslots' => 'main',
					'totext-main' => 'To text {{subst:PAGENAME}}',
					'tocontentmodel-main' => 'wikitext',
					'prop' => 'diff|diffsize|rel|ids|title|user|comment|parsedcomment|size|timestamp'
				],
				[
					'compare' => [
						'fromid' => '{{REPL:pageB}}',
						'fromrevid' => '{{REPL:revB1}}',
						'fromns' => 0,
						'fromtitle' => 'ApiComparePagesTest B',
						'fromsize' => 3,
						'fromuser' => '{{REPL:creator}}',
						'fromuserid' => '{{REPL:creatorid}}',
						'fromcomment' => 'Test for ApiComparePagesTest: B 1',
						'fromparsedcomment' => 'Test for ApiComparePagesTest: B 1',
						'fromtimestamp' => '2001-01-01T01:11:01Z',
						'diffsize' => 477,
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">B 1</del></div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To text {{subst:PAGENAME}}</ins></div></td></tr>' . "\n",
					]
				],
			],
			'Relative diff, cur' => [
				[
					'fromrev' => '{{REPL:revA2}}',
					'torelative' => 'cur',
					'prop' => 'ids',
				],
				[
					'compare' => [
						'fromid' => '{{REPL:pageA}}',
						'fromrevid' => '{{REPL:revA2}}',
						'toid' => '{{REPL:pageA}}',
						'torevid' => '{{REPL:revA4}}',
					]
				],
			],
			'Relative diff, next' => [
				[
					'fromrev' => '{{REPL:revE2}}',
					'torelative' => 'next',
					'prop' => 'ids|rel',
				],
				[
					'compare' => [
						'fromid' => '{{REPL:pageE}}',
						'fromrevid' => '{{REPL:revE2}}',
						'toid' => '{{REPL:pageE}}',
						'torevid' => '{{REPL:revE3}}',
						'prev' => '{{REPL:revE1}}',
						'next' => '{{REPL:revE4}}',
					]
				],
			],
			'Relative diff, prev' => [
				[
					'fromrev' => '{{REPL:revE3}}',
					'torelative' => 'prev',
					'prop' => 'ids|rel',
				],
				[
					'compare' => [
						'fromid' => '{{REPL:pageE}}',
						'fromrevid' => '{{REPL:revE2}}',
						'toid' => '{{REPL:pageE}}',
						'torevid' => '{{REPL:revE3}}',
						'prev' => '{{REPL:revE1}}',
						'next' => '{{REPL:revE4}}',
					]
				],
			],
			'Relative diff, no prev' => [
				[
					'fromrev' => '{{REPL:revA1}}',
					'torelative' => 'prev',
					'prop' => 'ids|rel|diff|title|user|comment',
				],
				[
					'warnings' => [
						[
							'code' => 'compare-no-prev',
							'module' => 'compare',
						],
					],
					'compare' => [
						'toid' => '{{REPL:pageA}}',
						'torevid' => '{{REPL:revA1}}',
						'tons' => 0,
						'totitle' => 'ApiComparePagesTest A',
						'touser' => '{{REPL:creator}}',
						'touserid' => '{{REPL:creatorid}}',
						'tocomment' => 'Test for ApiComparePagesTest: A 1',
						'toparsedcomment' => 'Test for ApiComparePagesTest: A 1',
						'next' => '{{REPL:revA2}}',
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div> </div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">A 1</ins></div></td></tr>' . "\n",
					],
				],
			],
			'Relative diff, no next' => [
				[
					'fromrev' => '{{REPL:revA4}}',
					'torelative' => 'next',
					'prop' => 'ids|rel|diff|title|user|comment',
				],
				[
					'warnings' => [
						[
							'code' => 'compare-no-next',
							'module' => 'compare',
						],
					],
					'compare' => [
						'fromid' => '{{REPL:pageA}}',
						'fromrevid' => '{{REPL:revA4}}',
						'fromns' => 0,
						'fromtitle' => 'ApiComparePagesTest A',
						'fromuser' => '{{REPL:creator}}',
						'fromuserid' => '{{REPL:creatorid}}',
						'fromcomment' => 'Test for ApiComparePagesTest: A 4',
						'fromparsedcomment' => 'Test for ApiComparePagesTest: A 4',
						'prev' => '{{REPL:revA3}}',
						'body' => '',
					],
				],
			],
			'Diff for specific slots' => [
				// @todo Use a page with multiple slots here
				[
					'fromrev' => '{{REPL:revA1}}',
					'torev' => '{{REPL:revA3}}',
					'prop' => 'diff',
					'slots' => 'main',
				],
				[
					'compare' => [
						'bodies' => [
							'main' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
								. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
								. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div>A <del class="diffchange diffchange-inline">1</del></div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div>A <ins class="diffchange diffchange-inline">3</ins></div></td></tr>' . "\n",
						],
					],
				],
			],
			// @todo Add a test for diffing with a deleted slot. Deleting 'main' doesn't work.

			'Basic diff, deprecated text' => [
				[
					'fromtext' => 'From text',
					'fromcontentmodel' => 'wikitext',
					'totext' => 'To text {{subst:PAGENAME}}',
					'tocontentmodel' => 'wikitext',
				],
				[
					'warnings' => self::makeDeprecationWarnings( 'fromtext', 'fromcontentmodel', 'totext', 'tocontentmodel' ),
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">{{subst:PAGENAME}}</ins></div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, deprecated text 2' => [
				[
					'fromtext' => 'From text',
					'totext' => 'To text {{subst:PAGENAME}}',
					'tocontentmodel' => 'wikitext',
				],
				[
					'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext', 'tocontentmodel' ),
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">{{subst:PAGENAME}}</ins></div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, deprecated text, guessed model' => [
				[
					'fromtext' => 'From text',
					'totext' => 'To text',
				],
				[
					'warnings' => array_merge( self::makeDeprecationWarnings( 'fromtext', 'totext' ), [
						[ 'code' => 'compare-nocontentmodel', 'module' => 'compare' ],
					] ),
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To </ins>text</div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, deprecated text with title and PST' => [
				[
					'fromtext' => 'From text',
					'totitle' => 'Test',
					'totext' => 'To text {{subst:PAGENAME}}',
					'topst' => true,
				],
				[
					'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext' ),
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">Test</ins></div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, deprecated text with page ID and PST' => [
				[
					'fromtext' => 'From text',
					'toid' => '{{REPL:pageB}}',
					'totext' => 'To text {{subst:PAGENAME}}',
					'topst' => true,
				],
				[
					'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext' ),
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest B</ins></div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, deprecated text with revision and PST' => [
				[
					'fromtext' => 'From text',
					'torev' => '{{REPL:revB2}}',
					'totext' => 'To text {{subst:PAGENAME}}',
					'topst' => true,
				],
				[
					'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext' ),
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest B</ins></div></td></tr>' . "\n",
					]
				],
			],
			'Basic diff, deprecated text with deleted revision and PST' => [
				[
					'fromtext' => 'From text',
					'torev' => '{{REPL:revC2}}',
					'totext' => 'To text {{subst:PAGENAME}}',
					'topst' => true,
				],
				[
					'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext' ),
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest C</ins></div></td></tr>' . "\n",
					]
				],
				false, true
			],
			'Basic diff, test with deprecated sections' => [
				[
					'fromtitle' => 'ApiComparePagesTest F',
					'fromsection' => 1,
					'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?",
					'tosection' => 2,
				],
				[
					'warnings' => self::makeDeprecationWarnings( 'fromsection', 'totext', 'tosection' ),
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div>== Section <del class="diffchange diffchange-inline">1 </del>==</div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div>== Section <ins class="diffchange diffchange-inline">2 </ins>==</div></td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div><del class="diffchange diffchange-inline">F 1.1</del></div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div><ins class="diffchange diffchange-inline">To text?</ins></div></td></tr>' . "\n",
						'fromid' => '{{REPL:pageF}}',
						'fromrevid' => '{{REPL:revF1}}',
						'fromns' => '0',
						'fromtitle' => 'ApiComparePagesTest F',
					]
				],
			],
			'Basic diff, test with deprecated sections and revdel, non-sysop' => [
				[
					'fromrev' => '{{REPL:revB2}}',
					'fromsection' => 0,
					'torev' => '{{REPL:revB4}}',
					'tosection' => 0,
				],
				[],
				'missingcontent'
			],
			'Basic diff, test with deprecated sections and revdel, sysop' => [
				[
					'fromrev' => '{{REPL:revB2}}',
					'fromsection' => 0,
					'torev' => '{{REPL:revB4}}',
					'tosection' => 0,
				],
				[
					'warnings' => self::makeDeprecationWarnings( 'fromsection', 'tosection' ),
					'compare' => [
						'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1">Line 1:</td>' . "\n"
							. '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
							. '<tr><td class="diff-marker" data-marker="−"></td><td class="diff-deletedline diff-side-deleted"><div>B <del class="diffchange diffchange-inline">2</del></div></td><td class="diff-marker" data-marker="+"></td><td class="diff-addedline diff-side-added"><div>B <ins class="diffchange diffchange-inline">4</ins></div></td></tr>' . "\n",
						'fromid' => '{{REPL:pageB}}',
						'fromrevid' => '{{REPL:revB2}}',
						'fromns' => 0,
						'fromtitle' => 'ApiComparePagesTest B',
						'fromtexthidden' => true,
						'fromuserhidden' => true,
						'fromcommenthidden' => true,
						'toid' => '{{REPL:pageB}}',
						'torevid' => '{{REPL:revB4}}',
						'tons' => 0,
						'totitle' => 'ApiComparePagesTest B',
					]
				],
				false, true,
			],

			'Error, missing title' => [
				[
					'fromtitle' => 'ApiComparePagesTest X',
					'totitle' => 'ApiComparePagesTest B',
				],
				[],
				'missingtitle',
			],
			'Error, invalid title' => [
				[
					'fromtitle' => '<bad>',
					'totitle' => 'ApiComparePagesTest B',
				],
				[],
				'invalidtitle',
			],
			'Error, missing page ID' => [
				[
					'fromid' => 8817900,
					'totitle' => 'ApiComparePagesTest B',
				],
				[],
				'nosuchpageid',
			],
			'Error, page with missing revision' => [
				[
					'fromtitle' => 'ApiComparePagesTest D',
					'totitle' => 'ApiComparePagesTest B',
				],
				[],
				'nosuchrevid',
			],
			'Error, page with no revision' => [
				[
					'fromtitle' => 'ApiComparePagesTest E',
					'totitle' => 'ApiComparePagesTest B',
				],
				[],
				'nosuchrevid',
			],
			'Error, bad rev ID' => [
				[
					'fromrev' => 8817900,
					'totitle' => 'ApiComparePagesTest B',
				],
				[],
				'nosuchrevid',
			],
			'Error, deleted revision ID, non-sysop' => [
				[
					'fromrev' => '{{REPL:revA2}}',
					'torev' => '{{REPL:revC2}}',
				],
				[],
				'nosuchrevid',
			],
			'Error, deleted revision ID and torelative=prev' => [
				[
					'fromrev' => '{{REPL:revC2}}',
					'torelative' => 'prev',
				],
				[],
				'compare-relative-to-deleted', true
			],
			'Error, deleted revision ID and torelative=next' => [
				[
					'fromrev' => '{{REPL:revC2}}',
					'torelative' => 'next',
				],
				[],
				'compare-relative-to-deleted', true
			],
			'Deleted revision ID and torelative=cur' => [
				[
					'fromrev' => '{{REPL:revC2}}',
					'torelative' => 'cur',
				],
				[],
				'nosuchrevid', true
			],
			'Error, revision-deleted content' => [
				[
					'fromrev' => '{{REPL:revA2}}',
					'torev' => '{{REPL:revB2}}',
				],
				[],
				'missingcontent',
			],
			'Error, text with no title and PST' => [
				[
					'fromtext' => 'From text',
					'totext' => 'To text {{subst:PAGENAME}}',
					'topst' => true,
				],
				[],
				'compare-no-title',
			],
			'Error, test with invalid from section ID' => [
				[
					'fromtitle' => 'ApiComparePagesTest F',
					'fromsection' => 5,
					'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?",
					'tosection' => 2,
				],
				[],
				'nosuchfromsection',
			],
			'Error, test with invalid to section ID' => [
				[
					'fromtitle' => 'ApiComparePagesTest F',
					'fromsection' => 1,
					'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?",
					'tosection' => 5,
				],
				[],
				'nosuchtosection',
			],
			'Error, Relative diff, no from revision' => [
				[
					'fromtext' => 'Foo',
					'torelative' => 'cur',
					'prop' => 'ids',
				],
				[],
				'compare-relative-to-nothing'
			],
			'Error, Relative diff, cur with no current revision' => [
				[
					'fromrev' => '{{REPL:revE2}}',
					'torelative' => 'cur',
					'prop' => 'ids',
				],
				[],
				'nosuchrevid'
			],
			'Error, Relative diff, next revdeleted' => [
				[
					'fromrev' => '{{REPL:revB1}}',
					'torelative' => 'next',
					'prop' => 'ids',
				],
				[],
				'missingcontent'
			],
			'Error, Relative diff, prev revdeleted' => [
				[
					'fromrev' => '{{REPL:revB3}}',
					'torelative' => 'prev',
					'prop' => 'ids',
				],
				[],
				'missingcontent'
			],
			'Error, section diff with no revision' => [
				[
					'fromtitle' => 'ApiComparePagesTest F',
					'toslots' => 'main',
					'totext-main' => "== Section 1 ==\nTo text?",
					'tosection-main' => 1,
				],
				[],
				'compare-notorevision',
			],
			'Error, section diff with revdeleted revision' => [
				[
					'fromtitle' => 'ApiComparePagesTest F',
					'torev' => '{{REPL:revB2}}',
					'toslots' => 'main',
					'totext-main' => "== Section 1 ==\nTo text?",
					'tosection-main' => 1,
				],
				[],
				'missingcontent',
			],
			'Error, section diff with a content model not supporting sections' => [
				[
					'fromtitle' => 'ApiComparePagesTest G',
					'torev' => '{{REPL:revG1}}',
					'toslots' => 'main',
					'totext-main' => "== Section 1 ==\nTo text?",
					'tosection-main' => 1,
				],
				[],
				'sectionsnotsupported',
			],
			'Error, section diff with bad content model' => [
				[
					'fromtitle' => 'ApiComparePagesTest F',
					'torev' => '{{REPL:revF1}}',
					'toslots' => 'main',
					'totext-main' => "== Section 1 ==\nTo text?",
					'tosection-main' => 1,
					'tocontentmodel-main' => CONTENT_MODEL_TEXT,
				],
				[],
				'sectionreplacefailed',
			],
			'Error, deleting the main slot' => [
				[
					'fromtitle' => 'ApiComparePagesTest A',
					'totitle' => 'ApiComparePagesTest A',
					'toslots' => 'main',
				],
				[],
				'compare-maintextrequired',
			],
			// @todo Add a test for using 'tosection-foo' without 'totext-foo' (can't do it with main)
		];
		// phpcs:enable
	}
}
PK       ! Qwf      api/ApiWatchTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @group API
 * @group Database
 * @group medium
 * @covers MediaWiki\Api\ApiWatch
 */
class ApiWatchTest extends ApiTestCase {
	protected function setUp(): void {
		parent::setUp();

		// Fake current time to be 2019-06-05T19:50:42Z
		ConvertibleTimestamp::setFakeTime( 1559764242 );

		$this->overrideConfigValues( [
			MainConfigNames::WatchlistExpiry => true,
			MainConfigNames::WatchlistExpiryMaxDuration => '6 months',
		] );
	}

	public function testWatch() {
		// Watch for a duration greater than the max ($wgWatchlistExpiryMaxDuration),
		// which should get changed to the max
		$data = $this->doApiRequestWithToken( [
			'action' => 'watch',
			'titles' => 'Talk:Test page',
			'expiry' => '99990101000000',
			'formatversion' => 2
		] );

		$res = $data[0]['watch'][0];
		$this->assertSame( 'Talk:Test page', $res['title'] );
		$this->assertSame( 1, $res['ns'] );
		$this->assertTrue( $res['watched'] );
		$this->assertSame( '2019-12-05T19:50:42Z', $res['expiry'] );

		// Re-watch, changing the expiry to indefinite.
		$data = $this->doApiRequestWithToken( [
			'action' => 'watch',
			'titles' => 'Talk:Test page',
			'expiry' => 'indefinite',
			'formatversion' => 2
		] );
		$res = $data[0]['watch'][0];
		$this->assertSame( 'infinity', $res['expiry'] );
	}

	public function testWatchWithExpiry() {
		$store = $this->getServiceContainer()->getWatchedItemStore();
		$user = $this->getTestUser()->getUser();
		$pageTitle = 'TestWatchWithExpiry';

		// First watch without expiry (indefinite).
		$this->doApiRequestWithToken( [
			'action' => 'watch',
			'titles' => $pageTitle,
		], null, $user );

		// Ensure page was added to the user's watchlist, and expiry is null (not set).
		[ $item ] = $store->getWatchedItemsForUser( $user );
		$this->assertSame( $pageTitle, $item->getTarget()->getDBkey() );
		$this->assertNull( $item->getExpiry() );

		// Re-watch, setting an expiry.
		$expiry = '2 weeks';
		$this->doApiRequestWithToken( [
			'action' => 'watch',
			'titles' => $pageTitle,
			'expiry' => $expiry,
		], null, $user );
		[ $item ] = $store->getWatchedItemsForUser( $user );
		$this->assertSame( '20190619195042', $item->getExpiry() );

		// Re-watch again, providing no expiry parameter, so expiry should remain unchanged.
		$oldExpiry = $item->getExpiry();
		$this->doApiRequestWithToken( [
			'action' => 'watch',
			'titles' => $pageTitle,
		], null, $user );
		[ $item ] = $store->getWatchedItemsForUser( $user );
		$this->assertSame( $oldExpiry, $item->getExpiry() );
	}

	public function testWatchInvalidExpiry() {
		$this->expectApiErrorCode( 'badexpiry' );

		$this->doApiRequestWithToken( [
			'action' => 'watch',
			'titles' => 'Talk:Test page',
			'expiry' => 'invalid expiry',
			'formatversion' => 2
		] );
	}

	public function testWatchExpiryInPast() {
		$this->expectApiErrorCode( 'badexpiry-past' );

		$this->doApiRequestWithToken( [
			'action' => 'watch',
			'titles' => 'Talk:Test page',
			'expiry' => '20010101000000',
			'formatversion' => 2
		] );
	}

	public function testWatchEdit() {
		$data = $this->doApiRequestWithToken( [
			'action' => 'edit',
			'title' => 'Help:TestWatchEdit', // Help namespace is hopefully wikitext
			'text' => 'new text',
			'watchlist' => 'watch'
		] );

		$this->assertArrayHasKey( 'edit', $data[0] );
		$this->assertArrayHasKey( 'result', $data[0]['edit'] );
		$this->assertEquals( 'Success', $data[0]['edit']['result'] );

		return $data;
	}

	/**
	 * @depends testWatchEdit
	 */
	public function testWatchClear() {
		$data = $this->doApiRequest( [
			'action' => 'query',
			'wllimit' => 'max',
			'list' => 'watchlist' ] );

		if ( isset( $data[0]['query']['watchlist'] ) ) {
			$wl = $data[0]['query']['watchlist'];

			foreach ( $wl as $page ) {
				$data = $this->doApiRequestWithToken( [
					'action' => 'watch',
					'title' => $page['title'],
					'unwatch' => true,
				] );
			}
		}
		$data = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'watchlist' ], $data );
		$this->assertArrayHasKey( 'query', $data[0] );
		$this->assertArrayHasKey( 'watchlist', $data[0]['query'] );
		foreach ( $data[0]['query']['watchlist'] as $index => $item ) {
			// Previous tests may insert an invalid title
			// like ":ApiEditPageTest testNonTextEdit", which
			// can't be cleared.
			if ( str_starts_with( $item['title'], ':' ) ) {
				unset( $data[0]['query']['watchlist'][$index] );
			}
		}
		$this->assertSame( [], $data[0]['query']['watchlist'] );

		return $data;
	}

	public function testWatchProtect() {
		$pageTitle = 'Help:TestWatchProtect';
		$this->getExistingTestPage( $pageTitle );
		$data = $this->doApiRequestWithToken( [
			'action' => 'protect',
			'title' => $pageTitle,
			'protections' => 'edit=sysop',
			'watchlist' => 'unwatch'
		] );

		$this->assertArrayHasKey( 'protect', $data[0] );
		$this->assertArrayHasKey( 'protections', $data[0]['protect'] );
		$this->assertCount( 1, $data[0]['protect']['protections'] );
		$this->assertArrayHasKey( 'edit', $data[0]['protect']['protections'][0] );
	}

	public function testWatchRollback() {
		$titleText = 'Help:TestWatchRollback';
		$title = Title::makeTitle( NS_HELP, 'TestWatchRollback' );
		$revertingUser = $this->getTestSysop()->getUser();
		$revertedUser = $this->getTestUser()->getUser();
		$this->editPage( $title, 'Edit 1', '', NS_MAIN, $revertingUser );
		$this->editPage( $title, 'Edit 2', '', NS_MAIN, $revertedUser );

		$watchlistManager = $this->getServiceContainer()->getWatchlistManager();

		// This (and assertTrue below) are mostly for completeness.
		$this->assertFalse( $watchlistManager->isWatched( $revertingUser, $title ) );

		$data = $this->doApiRequestWithToken( [
			'action' => 'rollback',
			'title' => $titleText,
			'user' => $revertedUser,
			'watchlist' => 'watch'
		] );

		$this->assertArrayHasKey( 'rollback', $data[0] );
		$this->assertArrayHasKey( 'title', $data[0]['rollback'] );
		$this->assertTrue( $watchlistManager->isWatched( $revertingUser, $title ) );
	}
}
PK       ! M*"  "  #  api/query/ApiQueryImageInfoTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use File;
use MediaWiki\Api\ApiQueryImageInfo;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\Utils\MWTimestamp;

/**
 * @covers \MediaWiki\Api\ApiQueryImageInfo
 * @group API
 * @group medium
 * @group Database
 */
class ApiQueryImageInfoTest extends ApiTestCase {
	use MockAuthorityTrait;
	use TempUserTestTrait;

	private const IMAGE_NAME = 'Random-11m.png';

	private const OLD_IMAGE_TIMESTAMP = '20201105235241';

	private const NEW_IMAGE_TIMESTAMP = '20201105235242';

	private const OLD_IMAGE_SIZE = 12345;

	private const NEW_IMAGE_SIZE = 54321;

	private const NO_COMMENT_TIMESTAMP = '20201105235239';

	private const IMAGE_2_NAME = 'Random-2.png';
	private const IMAGE_2_TIMESTAMP = '20230101000000';
	private const IMAGE_2_SIZE = 12345;

	/** @var UserIdentity */
	private $testUser = null;
	/** @var User */
	private $tempUser = null;

	public function addDBData() {
		parent::addDBData();
		$this->testUser = new UserIdentityValue( 12364321, 'Dummy User' );

		$actorId = $this->getServiceContainer()
			->getActorStore()
			->acquireActorId( $this->testUser, $this->getDb() );
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'image' )
			->row( [
				'img_name' => 'Random-11m.png',
				'img_size' => self::NEW_IMAGE_SIZE,
				'img_width' => 1000,
				'img_height' => 1800,
				'img_metadata' => '',
				'img_bits' => 16,
				'img_media_type' => 'BITMAP',
				'img_major_mime' => 'image',
				'img_minor_mime' => 'png',
				'img_description_id' => $this->getServiceContainer()
					->getCommentStore()
					->createComment( $this->getDb(), "'''comment'''" )->id,
				'img_actor' => $actorId,
				'img_timestamp' => $this->getDb()->timestamp( self::NEW_IMAGE_TIMESTAMP ),
				'img_sha1' => 'sy02psim0bgdh0jt4vdltuzoh7j80ru',
			] )
			->caller( __METHOD__ )
			->execute();
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'oldimage' )
			->row( [
				'oi_name' => 'Random-11m.png',
				'oi_archive_name' => self::OLD_IMAGE_TIMESTAMP . 'Random-11m.png',
				'oi_size' => self::OLD_IMAGE_SIZE,
				'oi_width' => 1000,
				'oi_height' => 1800,
				'oi_metadata' => '',
				'oi_bits' => 16,
				'oi_media_type' => 'BITMAP',
				'oi_major_mime' => 'image',
				'oi_minor_mime' => 'png',
				'oi_description_id' => $this->getServiceContainer()
					->getCommentStore()
					->createComment( $this->getDb(), 'deleted comment' )->id,
				'oi_actor' => $actorId,
				'oi_timestamp' => $this->getDb()->timestamp( self::OLD_IMAGE_TIMESTAMP ),
				'oi_sha1' => 'sy02psim0bgdh0jt4vdltuzoh7j80ru',
				'oi_deleted' => File::DELETED_FILE | File::DELETED_COMMENT | File::DELETED_USER,
			] )
			->row( [
				'oi_name' => 'Random-11m.png',
				'oi_archive_name' => self::NO_COMMENT_TIMESTAMP . 'Random-11m.png',
				'oi_size' => self::OLD_IMAGE_SIZE,
				'oi_width' => 1000,
				'oi_height' => 1800,
				'oi_metadata' => '',
				'oi_bits' => 16,
				'oi_media_type' => 'BITMAP',
				'oi_major_mime' => 'image',
				'oi_minor_mime' => 'png',
				'oi_description_id' => $this->getServiceContainer()
					->getCommentStore()
					->createComment( $this->getDb(), '' )->id,
				'oi_actor' => $actorId,
				'oi_timestamp' => $this->getDb()->timestamp( self::NO_COMMENT_TIMESTAMP ),
				'oi_sha1' => 'sy02psim0bgdh0jt4vdltuzoh7j80ru',
				'oi_deleted' => 0,
			] )
			->caller( __METHOD__ )
			->execute();

		// Set up temp user config
		$this->enableAutoCreateTempUser();
		$this->tempUser = $this->getServiceContainer()
			->getTempUserCreator()
			->create( null, new FauxRequest() )->getUser();
		$tempActorId = $this->getServiceContainer()
			->getActorStore()
			->acquireActorId( $this->tempUser, $this->getDb() );
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'image' )
			->row( [
				'img_name' => self::IMAGE_2_NAME,
				'img_size' => self::IMAGE_2_SIZE,
				'img_width' => 1000,
				'img_height' => 1800,
				'img_metadata' => '',
				'img_bits' => 16,
				'img_media_type' => 'BITMAP',
				'img_major_mime' => 'image',
				'img_minor_mime' => 'png',
				'img_description_id' => $this->getServiceContainer()
					->getCommentStore()
					->createComment( $this->getDb(), "'''comment'''" )->id,
				'img_actor' => $tempActorId,
				'img_timestamp' => $this->getDb()->timestamp( self::IMAGE_2_TIMESTAMP ),
				'img_sha1' => 'aaaaasim0bgdh0jt4vdltuzoh7',
			] )
			->caller( __METHOD__ )
			->execute();
	}

	private function getImageInfoFromResult( array $result ) {
		$this->assertArrayHasKey( 'query', $result );
		$this->assertArrayHasKey( 'pages', $result['query'] );
		$this->assertArrayHasKey( '-1', $result['query']['pages'] );
		$info = $result['query']['pages']['-1'];
		$this->assertSame( NS_FILE, $info['ns'] );
		$this->assertSame( 'File:' . self::IMAGE_NAME, $info['title'] );
		$this->assertTrue( $info['missing'] );
		$this->assertTrue( $info['known'] );
		$this->assertSame( 'local', $info['imagerepository'] );
		$this->assertFalse( $info['badfile'] );
		$this->assertIsArray( $info['imageinfo'] );
		return $info['imageinfo'][0];
	}

	public function testGetImageInfoLatestImage() {
		[ $result, ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'imageinfo',
			'titles' => 'File:' . self::IMAGE_NAME,
			'iiprop' => implode( '|', ApiQueryImageInfo::getPropertyNames() ),
			'iistart' => self::NEW_IMAGE_TIMESTAMP,
			'iiend' => self::NEW_IMAGE_TIMESTAMP,
		] );
		$image = $this->getImageInfoFromResult( $result );
		$this->assertSame( MWTimestamp::convert( TS_ISO_8601, self::NEW_IMAGE_TIMESTAMP ), $image['timestamp'] );
		$this->assertSame( "'''comment'''", $image['comment'] );
		$this->assertSame( $this->testUser->getName(), $image['user'] );
		$this->assertSame( $this->testUser->getId(), $image['userid'] );
		$this->assertSame( self::NEW_IMAGE_SIZE, $image['size'] );
	}

	public function testGetImageCreatedByTempUser() {
		[ $result, ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'imageinfo',
			'titles' => 'File:' . self::IMAGE_2_NAME
		] );
		$image = $result['query']['pages']['-1']['imageinfo'][0];
		$this->assertArrayHasKey( 'temp', $image );
		$this->assertTrue( $image['temp'] );
	}

	public function testGetImageEmptyComment() {
		[ $result, ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'imageinfo',
			'titles' => 'File:' . self::IMAGE_NAME,
			'iiprop' => implode( '|', ApiQueryImageInfo::getPropertyNames() ),
			'iistart' => self::NO_COMMENT_TIMESTAMP,
			'iiend' => self::NO_COMMENT_TIMESTAMP,
		] );
		$image = $this->getImageInfoFromResult( $result );
		$this->assertSame( MWTimestamp::convert( TS_ISO_8601, self::NO_COMMENT_TIMESTAMP ), $image['timestamp'] );
		$this->assertSame( '', $image['comment'] );
		$this->assertArrayNotHasKey( 'commenthidden', $image );
	}

	public function testGetImageInfoOldRestrictedImage() {
		[ $result, ] = $this->doApiRequest( [
				'action' => 'query',
				'prop' => 'imageinfo',
				'titles' => 'File:' . self::IMAGE_NAME,
				'iiprop' => implode( '|', ApiQueryImageInfo::getPropertyNames() ),
				'iistart' => self::OLD_IMAGE_TIMESTAMP,
				'iiend' => self::OLD_IMAGE_TIMESTAMP,
			],
			null,
			false,
			$this->getTestUser()->getAuthority()
		);
		$image = $this->getImageInfoFromResult( $result );
		$this->assertSame( MWTimestamp::convert( TS_ISO_8601, self::OLD_IMAGE_TIMESTAMP ), $image['timestamp'] );
		$this->assertTrue( $image['commenthidden'] );
		$this->assertArrayNotHasKey( "comment", $image );
		$this->assertTrue( $image['userhidden'] );
		$this->assertArrayNotHasKey( 'user', $image );
		$this->assertArrayNotHasKey( 'userid', $image );
		$this->assertTrue( $image['filehidden'] );
		$this->assertSame( self::OLD_IMAGE_SIZE, $image['size'] );
	}

	public function testGetImageInfoOldRestrictedImage_sysop() {
		[ $result, ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'imageinfo',
			'titles' => 'File:' . self::IMAGE_NAME,
			'iiprop' => implode( '|', ApiQueryImageInfo::getPropertyNames() ),
			'iistart' => self::OLD_IMAGE_TIMESTAMP,
			'iiend' => self::OLD_IMAGE_TIMESTAMP,
		],
			null,
			false,
			$this->mockRegisteredUltimateAuthority()
		);
		$image = $this->getImageInfoFromResult( $result );
		$this->assertSame( MWTimestamp::convert( TS_ISO_8601, self::OLD_IMAGE_TIMESTAMP ), $image['timestamp'] );
		$this->assertTrue( $image['commenthidden'] );
		$this->assertSame( 'deleted comment', $image['comment'] );
		$this->assertTrue( $image['userhidden'] );
		$this->assertSame( $this->testUser->getName(), $image['user'] );
		$this->assertSame( $this->testUser->getId(), $image['userid'] );
		$this->assertTrue( $image['filehidden'] );
		$this->assertSame( self::OLD_IMAGE_SIZE, $image['size'] );
	}
}
PK       ! D}&b  b     api/query/ApiQuerySearchTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use ISearchResultSet;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Title\Title;
use MockSearchEngine;
use MockSearchResult;
use MockSearchResultSet;

/**
 * @group medium
 * @covers MediaWiki\Api\ApiQuerySearch
 */
class ApiQuerySearchTest extends ApiTestCase {

	protected function setUp(): void {
		parent::setUp();
		MockSearchEngine::clearMockResults();
		$this->registerMockSearchEngine();
		$this->setService( 'RevisionLookup', $this->createMock( RevisionLookup::class ) );
	}

	private function registerMockSearchEngine() {
		$this->overrideConfigValue( MainConfigNames::SearchType, MockSearchEngine::class );
	}

	public function provideSearchResults() {
		return [
			'empty search result' => [ [], [] ],
			'has search results' => [
				[ 'Zomg' ],
				[ $this->mockResultClosure( 'Zomg' ) ],
			],
			'filters broken search results' => [
				[ 'A', 'B' ],
				[
					$this->mockResultClosure( 'a' ),
					$this->mockResultClosure( 'Zomg', [ 'setBrokenTitle' => true ] ),
					$this->mockResultClosure( 'b' ),
				],
			],
			'filters results with missing revision' => [
				[ 'B', 'A' ],
				[
					$this->mockResultClosure( 'Zomg', [ 'setMissingRevision' => true ] ),
					$this->mockResultClosure( 'b' ),
					$this->mockResultClosure( 'a' ),
				],
			],
		];
	}

	/**
	 * @dataProvider provideSearchResults
	 */
	public function testSearchResults( $expect, $hits, array $params = [] ) {
		MockSearchEngine::addMockResults( 'my query', $hits );
		[ $response, $request ] = $this->doApiRequest( $params + [
			'action' => 'query',
			'list' => 'search',
			'srsearch' => 'my query',
		] );
		$titles = array_column( $response['query']['search'], 'title' );
		$this->assertEquals( $expect, $titles );
	}

	public function provideInterwikiResults() {
		return [
			'empty' => [ [], [] ],
			'one wiki response' => [
				[ 'utwiki' => [ 'Qwerty' ] ],
				[
					ISearchResultSet::SECONDARY_RESULTS => [
						'utwiki' => new MockSearchResultSet( [
							$this->mockResultClosure(
								'Qwerty',
								[ 'setInterwikiPrefix' => 'utwiki' ]
							),
						] ),
					],
				]
			],
		];
	}

	/**
	 * @dataProvider provideInterwikiResults
	 */
	public function testInterwikiResults( $expect, $hits, array $params = [] ) {
		MockSearchEngine::setMockInterwikiResults( $hits );
		[ $response, $request ] = $this->doApiRequest( $params + [
			'action' => 'query',
			'list' => 'search',
			'srsearch' => 'my query',
			'srinterwiki' => true,
		] );
		if ( !$expect ) {
			$this->assertArrayNotHasKey( 'interwikisearch', $response['query'] );
			return;
		}
		$results = [];
		$this->assertArrayHasKey( 'interwikisearchinfo', $response['query'] );
		foreach ( $response['query']['interwikisearch'] as $wiki => $wikiResults ) {
			$results[$wiki] = [];
			foreach ( $wikiResults as $wikiResult ) {
				$results[$wiki][] = $wikiResult['title'];
			}
		}
		$this->assertEquals( $expect, $results );
	}

	/**
	 * Returns a closure that evaluates to a MockSearchResult, to be resolved by
	 * MockSearchEngine::addMockResults() or MockresultSet::extractResults().
	 *
	 * This is needed because MockSearchResults cannot be instantiated in a data provider,
	 * since they load revisions. This would hit the "real" database instead of the mock
	 * database, which in turn may cause cache pollution and other inconsistencies, see T202641.
	 *
	 * @param string $titleText
	 * @param array $setters
	 * @return callable function(): MockSearchResult
	 */
	private function mockResultClosure( $titleText, $setters = [] ) {
		return static function () use ( $titleText, $setters ) {
			$title = Title::newFromText( $titleText );
			$title->resetArticleID( 0 );
			$result = new MockSearchResult( $title );

			foreach ( $setters as $method => $param ) {
				$result->$method( $param );
			}

			return $result;
		};
	}

}
PK       ! 8    .  api/query/ApiQueryWatchlistIntegrationTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\Permissions\Authority;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\User;
use MediaWiki\Watchlist\WatchedItemQueryService;
use RecentChange;

/**
 * @group medium
 * @group API
 * @group Database
 *
 * @covers MediaWiki\Api\ApiQueryWatchlist
 * @covers \MediaWiki\Watchlist\WatchedItemQueryService
 */
class ApiQueryWatchlistIntegrationTest extends ApiTestCase {
	use TempUserTestTrait;

	// TODO: This test should use Authority, but can't due to User::saveSettings

	/** @var User */
	private $loggedInUser;
	/** @var User */
	private $notLoggedInUser;

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

		$this->loggedInUser = $this->getMutableTestUser()->getUser();
		$this->notLoggedInUser = $this->getMutableTestUser()->getUser();
	}

	private function getLoggedInTestUser(): User {
		return $this->loggedInUser;
	}

	private function getNonLoggedInTestUser(): User {
		return $this->notLoggedInUser;
	}

	private function doPageEdit( Authority $performer, $target, $content, $summary ) {
		$this->editPage(
			$target,
			$content,
			$summary,
			NS_MAIN,
			$performer
		);
	}

	private function doMinorPageEdit( User $user, LinkTarget $target, $content, $summary ) {
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromLinkTarget( $target );
		$page->doUserEditContent(
			$page->getContentHandler()->unserializeContent( $content ),
			$user,
			$summary,
			EDIT_MINOR
		);
	}

	private function doBotPageEdit( User $user, LinkTarget $target, $content, $summary ) {
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromLinkTarget( $target );
		$page->doUserEditContent(
			$page->getContentHandler()->unserializeContent( $content ),
			$user,
			$summary,
			EDIT_FORCE_BOT
		);
	}

	private function doAnonPageEdit( LinkTarget $target, $content, $summary ) {
		$this->disableAutoCreateTempUser();
		$this->editPage(
			$target,
			$content,
			$summary,
			NS_MAIN,
			$this->getServiceContainer()->getUserFactory()->newAnonymous()
		);
	}

	private function doTempPageEdit( LinkTarget $target, $content, $summary ) {
		$this->enableAutoCreateTempUser();
		$this->editPage(
			$target,
			$content,
			$summary,
			NS_MAIN,
			$this->getServiceContainer()->getTempUserCreator()->create( null, new FauxRequest() )->getUser()
		);
	}

	private function doPatrolledPageEdit(
		User $user,
		LinkTarget $target,
		$content,
		$summary,
		User $patrollingUser
	) {
		$summary = CommentStoreComment::newUnsavedComment( trim( $summary ) );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromLinkTarget( $target );

		$updater = $page->newPageUpdater( $user );
		$updater->setContent( SlotRecord::MAIN, $page->getContentHandler()->unserializeContent( $content ) );
		$rev = $updater->saveRevision( $summary );

		$rc = $this->getServiceContainer()->getRevisionStore()->getRecentChange( $rev );
		$rc->markPatrolled( $patrollingUser, [] );
	}

	/**
	 * Performs a batch of page edits as a specified user
	 * @param User $user
	 * @param array $editData associative array, keys:
	 *                        - target    => LinkTarget page to edit
	 *                        - content   => string new content
	 *                        - summary   => string edit summary
	 *                        - minorEdit => bool mark as minor edit if true (defaults to false)
	 *                        - botEdit   => bool mark as bot edit if true (defaults to false)
	 */
	private function doPageEdits( User $user, array $editData ) {
		foreach ( $editData as $singleEditData ) {
			if ( array_key_exists( 'minorEdit', $singleEditData ) && $singleEditData['minorEdit'] ) {
				$this->doMinorPageEdit(
					$user,
					$singleEditData['target'],
					$singleEditData['content'],
					$singleEditData['summary']
				);
				continue;
			}
			if ( array_key_exists( 'botEdit', $singleEditData ) && $singleEditData['botEdit'] ) {
				$this->doBotPageEdit(
					$user,
					$singleEditData['target'],
					$singleEditData['content'],
					$singleEditData['summary']
				);
				continue;
			}
			$this->doPageEdit(
				$user,
				$singleEditData['target'],
				$singleEditData['content'],
				$singleEditData['summary']
			);
		}
	}

	private function getWatchedItemStore() {
		return $this->getServiceContainer()->getWatchedItemStore();
	}

	/**
	 * @param User $user
	 * @param LinkTarget[] $targets
	 */
	private function watchPages( User $user, array $targets ) {
		$store = $this->getWatchedItemStore();
		$store->addWatchBatchForUser( $user, $targets );
	}

	private function doListWatchlistRequest( array $params = [], $user = null ) {
		$user ??= $this->getLoggedInTestUser();
		return $this->doApiRequest(
			array_merge(
				[ 'action' => 'query', 'list' => 'watchlist' ],
				$params
			), null, false, $user
		);
	}

	private function doGeneratorWatchlistRequest( array $params = [] ) {
		return $this->doApiRequest(
			array_merge(
				[ 'action' => 'query', 'generator' => 'watchlist' ],
				$params
			), null, false, $this->getLoggedInTestUser()
		);
	}

	private function getItemsFromApiResponse( array $response ) {
		return $response[0]['query']['watchlist'];
	}

	/**
	 * Convenience method to assert that actual items array fetched from API is equal to the expected
	 * array, Unlike assertEquals this only checks if values of specified keys are equal in both
	 * arrays. This could be used e.g. not to compare IDs that could change between test run
	 * but only stable keys.
	 * Optionally this also checks that specified keys are present in the actual item without
	 * performing any checks on the related values.
	 *
	 * @param array $actualItems array of actual items (associative arrays)
	 * @param array $expectedItems array of expected items (associative arrays),
	 *                             those items have less keys than actual items
	 * @param array $keysUsedInValueComparison list of keys of the actual item that will be used
	 *                                         in the comparison of values
	 * @param array $requiredKeys optional, list of keys that must be present in the
	 *                            actual items. Values of those keys are not checked.
	 */
	private function assertArraySubsetsEqual(
		array $actualItems,
		array $expectedItems,
		array $keysUsedInValueComparison,
		array $requiredKeys = []
	) {
		$this->assertSameSize( $expectedItems, $actualItems );

		// not checking values of all keys of the actual item, so removing unwanted keys from comparison
		$actualItemsOnlyComparedValues = array_map(
			static function ( array $item ) use ( $keysUsedInValueComparison ) {
				return array_intersect_key( $item,
					array_fill_keys( $keysUsedInValueComparison, true ) );
			},
			$actualItems
		);

		$this->assertEquals(
			$expectedItems,
			$actualItemsOnlyComparedValues
		);

		// Check that each item in $actualItems contains all of keys specified in $requiredKeys
		$actualItemsKeysOnly = array_map( 'array_keys', $actualItems );
		foreach ( $actualItemsKeysOnly as $keysOfTheItem ) {
			$this->assertSame( [], array_diff( $requiredKeys, $keysOfTheItem ) );
		}
	}

	private function getPrefixedText( LinkTarget $target ) {
		return $this->getServiceContainer()->getTitleFormatter()->getPrefixedText( $target );
	}

	private function cleanTestUsersWatchlist() {
		$user = $this->getLoggedInTestUser();
		$store = $this->getWatchedItemStore();
		$items = $store->getWatchedItemsForUser( $user );
		foreach ( $items as $item ) {
			$store->removeWatch( $user, $item->getTarget() );
		}
	}

	public function testListWatchlist_returnsWatchedItemsWithRCInfo() {
		// Clean up after previous tests that might have added something to the watchlist of
		// the user with the same user ID as user used here as the test user
		$this->cleanTestUsersWatchlist();

		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$user,
			$target,
			'Some Content',
			'Create the page'
		);
		$this->watchPages( $user, [ $target ] );

		$result = $this->doListWatchlistRequest();

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'watchlist', $result[0]['query'] );

		$this->assertArraySubsetsEqual(
			$this->getItemsFromApiResponse( $result ),
			[
				[
					'type' => 'new',
					'ns' => $target->getNamespace(),
					'title' => $this->getPrefixedText( $target ),
					'bot' => false,
					'new' => true,
					'minor' => false,
				]
			],
			[ 'type', 'ns', 'title', 'bot', 'new', 'minor' ],
			[ 'pageid', 'revid', 'old_revid' ]
		);
	}

	public function testIdsPropParameter() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$user,
			$target,
			'Some Content',
			'Create the page'
		);
		$this->watchPages( $user, [ $target ] );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'ids', ] );
		$items = $this->getItemsFromApiResponse( $result );

		$this->assertCount( 1, $items );
		$this->assertArrayHasKey( 'pageid', $items[0] );
		$this->assertArrayHasKey( 'revid', $items[0] );
		$this->assertArrayHasKey( 'old_revid', $items[0] );
		$this->assertEquals( 'new', $items[0]['type'] );
	}

	public function testTitlePropParameter() {
		$user = $this->getLoggedInTestUser();
		$subjectTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$talkTarget = new TitleValue( NS_TALK, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdits(
			$user,
			[
				[
					'target' => $subjectTarget,
					'content' => 'Some Content',
					'summary' => 'Create the page',
				],
				[
					'target' => $talkTarget,
					'content' => 'Some Talk Page Content',
					'summary' => 'Create Talk page',
				],
			]
		);
		$this->watchPages( $user, [ $subjectTarget, $talkTarget ] );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => $talkTarget->getNamespace(),
					'title' => $this->getPrefixedText( $talkTarget ),
				],
				[
					'type' => 'new',
					'ns' => $subjectTarget->getNamespace(),
					'title' => $this->getPrefixedText( $subjectTarget ),
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testFlagsPropParameter() {
		$user = $this->getLoggedInTestUser();
		$normalEditTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$minorEditTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPageM' );
		$botEditTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPageB' );
		$this->doPageEdits(
			$user,
			[
				[
					'target' => $normalEditTarget,
					'content' => 'Some Content',
					'summary' => 'Create the page',
				],
				[
					'target' => $minorEditTarget,
					'content' => 'Some Content',
					'summary' => 'Create the page',
				],
				[
					'target' => $minorEditTarget,
					'content' => 'Slightly Better Content',
					'summary' => 'Change content',
					'minorEdit' => true,
				],
				[
					'target' => $botEditTarget,
					'content' => 'Some Content',
					'summary' => 'Create the page with a bot',
					'botEdit' => true,
				],
			]
		);
		$this->watchPages( $user, [ $normalEditTarget, $minorEditTarget, $botEditTarget ] );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'flags', ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'new' => true,
					'minor' => false,
					'bot' => true,
				],
				[
					'type' => 'edit',
					'new' => false,
					'minor' => true,
					'bot' => false,
				],
				[
					'type' => 'new',
					'new' => true,
					'minor' => false,
					'bot' => false,
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testUserPropParameter() {
		$user = $this->getLoggedInTestUser();
		$userEditTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$anonEditTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPageA' );
		$this->doPageEdit(
			$user,
			$userEditTarget,
			'Some Content',
			'Create the page'
		);
		$this->doAnonPageEdit(
			$anonEditTarget,
			'Some Content',
			'Create the page'
		);
		$this->watchPages( $user, [ $userEditTarget, $anonEditTarget ] );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'user', ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'anon' => true,
					'temp' => false,
					'user' => $this->getServiceContainer()->getUserFactory()->newAnonymous()->getName(),
				],
				[
					'type' => 'new',
					'anon' => false,
					'temp' => false,
					'user' => $user->getName(),
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testUserIdPropParameter() {
		$user = $this->getLoggedInTestUser();
		$userEditTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$anonEditTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPageA' );
		$this->doPageEdit(
			$user,
			$userEditTarget,
			'Some Content',
			'Create the page'
		);
		$this->doAnonPageEdit(
			$anonEditTarget,
			'Some Content',
			'Create the page'
		);
		$this->watchPages( $user, [ $userEditTarget, $anonEditTarget ] );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'userid', ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'anon' => true,
					'user' => 0,
					'userid' => 0,
				],
				[
					'type' => 'new',
					'anon' => false,
					'user' => $user->getId(),
					'userid' => $user->getId(),
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testCommentPropParameter() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$user,
			$target,
			'Some Content',
			'Create the <b>page</b>'
		);
		$this->watchPages( $user, [ $target ] );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'comment', ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'comment' => 'Create the <b>page</b>',
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testParsedCommentPropParameter() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$user,
			$target,
			'Some Content',
			'Create the <b>page</b>'
		);
		$this->watchPages( $user, [ $target ] );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'parsedcomment', ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'parsedcomment' => 'Create the &lt;b&gt;page&lt;/b&gt;',
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testTimestampPropParameter() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$user,
			$target,
			'Some Content',
			'Create the page'
		);
		$this->watchPages( $user, [ $target ] );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'timestamp', ] );
		$items = $this->getItemsFromApiResponse( $result );

		$this->assertCount( 1, $items );
		$this->assertArrayHasKey( 'timestamp', $items[0] );
		$this->assertIsString( $items[0]['timestamp'] );
	}

	public function testSizesPropParameter() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$user,
			$target,
			'Some Content',
			'Create the page'
		);
		$this->watchPages( $user, [ $target ] );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'sizes', ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'oldlen' => 0,
					'newlen' => 12,
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testNotificationTimestampPropParameter() {
		$otherUser = $this->getNonLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$otherUser,
			$target,
			'Some Content',
			'Create the page'
		);
		$store = $this->getWatchedItemStore();
		$store->addWatch( $this->getLoggedInTestUser(), $target );
		$store->updateNotificationTimestamp(
			$otherUser,
			$target,
			'20151212010101'
		);

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'notificationtimestamp', ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'notificationtimestamp' => '2015-12-12T01:01:01Z',
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	private function setupPatrolledSpecificFixtures( User $user ) {
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );

		$this->doPatrolledPageEdit(
			$user,
			$target,
			'Some Content',
			'Create the page (this gets patrolled)',
			$user
		);

		$this->watchPages( $user, [ $target ] );
	}

	public function testPatrolPropParameter() {
		$testUser = static::getTestSysop();
		$user = $testUser->getUser();
		$this->setupPatrolledSpecificFixtures( $user );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'patrol', ], $user );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'patrolled' => true,
					'unpatrolled' => false,
					'autopatrolled' => false,
				]
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	private function createPageAndDeleteIt( LinkTarget $target ) {
		$wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromLinkTarget( $target );
		$this->doPageEdit(
			$this->getLoggedInTestUser(),
			$wikiPage,
			'Some Content',
			'Create the page that will be deleted'
		);
		$this->deletePage( $wikiPage, 'Important Reason' );
	}

	public function testLoginfoPropParameter() {
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->createPageAndDeleteIt( $target );

		$this->watchPages( $this->getLoggedInTestUser(), [ $target ] );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'loginfo', ] );

		$this->assertArraySubsetsEqual(
			$this->getItemsFromApiResponse( $result ),
			[
				[
					'type' => 'log',
					'logtype' => 'delete',
					'logaction' => 'delete',
					'logparams' => [],
				],
			],
			[ 'type', 'logtype', 'logaction', 'logparams' ],
			[ 'logid' ]
		);
	}

	public function testEmptyPropParameter() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$user,
			$target,
			'Some Content',
			'Create the page'
		);
		$this->watchPages( $user, [ $target ] );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => '', ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
				]
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testNamespaceParam() {
		$user = $this->getLoggedInTestUser();
		$subjectTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$talkTarget = new TitleValue( NS_TALK, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdits(
			$user,
			[
				[
					'target' => $subjectTarget,
					'content' => 'Some Content',
					'summary' => 'Create the page',
				],
				[
					'target' => $talkTarget,
					'content' => 'Some Content',
					'summary' => 'Create the talk page',
				],
			]
		);
		$this->watchPages( $user, [ $subjectTarget, $talkTarget ] );

		$result = $this->doListWatchlistRequest( [ 'wlnamespace' => NS_MAIN, ] );

		$this->assertArraySubsetsEqual(
			$this->getItemsFromApiResponse( $result ),
			[
				[
					'ns' => $subjectTarget->getNamespace(),
					'title' => $this->getPrefixedText( $subjectTarget ),
				],
			],
			[ 'ns', 'title' ]
		);
	}

	public function testUserParam() {
		$user = $this->getLoggedInTestUser();
		$otherUser = $this->getNonLoggedInTestUser();
		$subjectTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$talkTarget = new TitleValue( NS_TALK, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$user,
			$subjectTarget,
			'Some Content',
			'Create the page'
		);
		$this->doPageEdit(
			$otherUser,
			$talkTarget,
			'What is this page about?',
			'Create the talk page'
		);
		$this->watchPages( $user, [ $subjectTarget, $talkTarget ] );

		$result = $this->doListWatchlistRequest( [
			'wlprop' => 'user|title',
			'wluser' => $otherUser->getName(),
		] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => $talkTarget->getNamespace(),
					'title' => $this->getPrefixedText( $talkTarget ),
					'user' => $otherUser->getName(),
					'anon' => false,
					'temp' => false,
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testExcludeUserParam() {
		$user = $this->getLoggedInTestUser();
		$otherUser = $this->getNonLoggedInTestUser();
		$subjectTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$talkTarget = new TitleValue( NS_TALK, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$user,
			$subjectTarget,
			'Some Content',
			'Create the page'
		);
		$this->doPageEdit(
			$otherUser,
			$talkTarget,
			'What is this page about?',
			'Create the talk page'
		);
		$this->watchPages( $user, [ $subjectTarget, $talkTarget ] );

		$result = $this->doListWatchlistRequest( [
			'wlprop' => 'user|title',
			'wlexcludeuser' => $otherUser->getName(),
		] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => $subjectTarget->getNamespace(),
					'title' => $this->getPrefixedText( $subjectTarget ),
					'user' => $user->getName(),
					'anon' => false,
					'temp' => false,
				]
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testShowMinorParams() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdits(
			$user,
			[
				[
					'target' => $target,
					'content' => 'Some Content',
					'summary' => 'Create the page',
				],
				[
					'target' => $target,
					'content' => 'Slightly Better Content',
					'summary' => 'Change content',
					'minorEdit' => true,
				],
			]
		);
		$this->watchPages( $user, [ $target ] );

		$resultMinor = $this->doListWatchlistRequest( [
			'wlshow' => WatchedItemQueryService::FILTER_MINOR,
			'wlprop' => 'flags'
		] );
		$resultNotMinor = $this->doListWatchlistRequest( [
			'wlshow' => WatchedItemQueryService::FILTER_NOT_MINOR, 'wlprop' => 'flags'
		] );

		$this->assertArraySubsetsEqual(
			$this->getItemsFromApiResponse( $resultMinor ),
			[
				[ 'minor' => true, ]
			],
			[ 'minor' ]
		);
		$this->assertSame( [], $this->getItemsFromApiResponse( $resultNotMinor ) );
	}

	public function testShowBotParams() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doBotPageEdit(
			$user,
			$target,
			'Some Content',
			'Create the page'
		);
		$this->watchPages( $user, [ $target ] );

		$resultBot = $this->doListWatchlistRequest( [
			'wlshow' => WatchedItemQueryService::FILTER_BOT
		] );
		$resultNotBot = $this->doListWatchlistRequest( [
			'wlshow' => WatchedItemQueryService::FILTER_NOT_BOT
		] );

		$this->assertArraySubsetsEqual(
			$this->getItemsFromApiResponse( $resultBot ),
			[
				[ 'bot' => true ],
			],
			[ 'bot' ]
		);
		$this->assertSame( [], $this->getItemsFromApiResponse( $resultNotBot ) );
	}

	public function testShowAnonParams() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doAnonPageEdit(
			$target,
			'Some Content',
			'Create the page'
		);
		$this->watchPages( $user, [ $target ] );

		$resultAnon = $this->doListWatchlistRequest( [
			'wlprop' => 'user',
			'wlshow' => WatchedItemQueryService::FILTER_ANON
		] );
		$resultNotAnon = $this->doListWatchlistRequest( [
			'wlprop' => 'user',
			'wlshow' => WatchedItemQueryService::FILTER_NOT_ANON
		] );

		$this->assertArraySubsetsEqual(
			$this->getItemsFromApiResponse( $resultAnon ),
			[
				[
					'anon' => true,
					'temp' => false,
				],
			],
			[ 'anon', 'temp' ]
		);
		$this->assertSame( [], $this->getItemsFromApiResponse( $resultNotAnon ) );
	}

	public function testShowAnonParamsTemp() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doTempPageEdit(
			$target,
			'Some more content',
			'Add more content'
		);
		$this->watchPages( $user, [ $target ] );

		$resultAnon = $this->doListWatchlistRequest( [
			'wlprop' => 'user',
			'wlshow' => WatchedItemQueryService::FILTER_ANON
		] );
		$resultNotAnon = $this->doListWatchlistRequest( [
			'wlprop' => 'user',
			'wlshow' => WatchedItemQueryService::FILTER_NOT_ANON
		] );

		$this->assertArraySubsetsEqual(
			$this->getItemsFromApiResponse( $resultAnon ),
			[
				[
					'anon' => false,
					'temp' => true
				],
			],
			[ 'anon', 'temp' ]
		);
		$this->assertSame( [], $this->getItemsFromApiResponse( $resultNotAnon ) );
	}

	public function testShowUnreadParams() {
		$user = $this->getLoggedInTestUser();
		$otherUser = $this->getNonLoggedInTestUser();
		$subjectTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$talkTarget = new TitleValue( NS_TALK, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$user,
			$subjectTarget,
			'Some Content',
			'Create the page'
		);
		$this->doPageEdit(
			$otherUser,
			$talkTarget,
			'Some Content',
			'Create the talk page'
		);
		$store = $this->getWatchedItemStore();
		$store->addWatchBatchForUser( $user, [ $subjectTarget, $talkTarget ] );
		$store->updateNotificationTimestamp(
			$otherUser,
			$talkTarget,
			'20151212010101'
		);

		$resultUnread = $this->doListWatchlistRequest( [
			'wlprop' => 'notificationtimestamp|title',
			'wlshow' => WatchedItemQueryService::FILTER_UNREAD
		] );
		$resultNotUnread = $this->doListWatchlistRequest( [
			'wlprop' => 'notificationtimestamp|title',
			'wlshow' => WatchedItemQueryService::FILTER_NOT_UNREAD
		] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'notificationtimestamp' => '2015-12-12T01:01:01Z',
					'ns' => $talkTarget->getNamespace(),
					'title' => $this->getPrefixedText( $talkTarget )
				]
			],
			$this->getItemsFromApiResponse( $resultUnread )
		);
		$this->assertEquals(
			[
				[
					'type' => 'new',
					'notificationtimestamp' => '',
					'ns' => $subjectTarget->getNamespace(),
					'title' => $this->getPrefixedText( $subjectTarget )
				]
			],
			$this->getItemsFromApiResponse( $resultNotUnread )
		);
	}

	public function testShowPatrolledParams() {
		$user = static::getTestSysop()->getUser();
		$this->setupPatrolledSpecificFixtures( $user );

		$resultPatrolled = $this->doListWatchlistRequest( [
			'wlprop' => 'patrol',
			'wlshow' => WatchedItemQueryService::FILTER_PATROLLED
		], $user );
		$resultNotPatrolled = $this->doListWatchlistRequest( [
			'wlprop' => 'patrol',
			'wlshow' => WatchedItemQueryService::FILTER_NOT_PATROLLED
		], $user );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'patrolled' => true,
					'unpatrolled' => false,
					'autopatrolled' => false,
				]
			],
			$this->getItemsFromApiResponse( $resultPatrolled )
		);
		$this->assertSame( [], $this->getItemsFromApiResponse( $resultNotPatrolled ) );
	}

	public function testNewAndEditTypeParameters() {
		$user = $this->getLoggedInTestUser();
		$subjectTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$talkTarget = new TitleValue( NS_TALK, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdits(
			$user,
			[
				[
					'target' => $subjectTarget,
					'content' => 'Some Content',
					'summary' => 'Create the page',
				],
				[
					'target' => $subjectTarget,
					'content' => 'Some Other Content',
					'summary' => 'Change the content',
				],
				[
					'target' => $talkTarget,
					'content' => 'Some Talk Page Content',
					'summary' => 'Create Talk page',
				],
			]
		);
		$this->watchPages( $user, [ $subjectTarget, $talkTarget ] );

		$resultNew = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'new' ] );
		$resultEdit = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'edit' ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => $talkTarget->getNamespace(),
					'title' => $this->getPrefixedText( $talkTarget ),
				],
			],
			$this->getItemsFromApiResponse( $resultNew )
		);
		$this->assertEquals(
			[
				[
					'type' => 'edit',
					'ns' => $subjectTarget->getNamespace(),
					'title' => $this->getPrefixedText( $subjectTarget ),
				],
			],
			$this->getItemsFromApiResponse( $resultEdit )
		);
	}

	public function testLogTypeParameters() {
		$user = $this->getLoggedInTestUser();
		$subjectTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$talkTarget = new TitleValue( NS_TALK, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->createPageAndDeleteIt( $subjectTarget );
		$this->doPageEdit(
			$user,
			$talkTarget,
			'Some Talk Page Content',
			'Create Talk page'
		);
		$this->watchPages( $user, [ $subjectTarget, $talkTarget ] );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'log' ] );

		$this->assertEquals(
			[
				[
					'type' => 'log',
					'ns' => $subjectTarget->getNamespace(),
					'title' => $this->getPrefixedText( $subjectTarget ),
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	private function getExternalRC( LinkTarget $target ) {
		$title = $this->getServiceContainer()->getTitleFactory()->newFromLinkTarget( $target );

		$rc = new RecentChange;
		$rc->mAttribs = [
			'rc_timestamp' => wfTimestamp( TS_MW ),
			'rc_namespace' => $title->getNamespace(),
			'rc_title' => $title->getDBkey(),
			'rc_type' => RC_EXTERNAL,
			'rc_source' => 'foo',
			'rc_minor' => 0,
			'rc_cur_id' => $title->getArticleID(),
			'rc_user' => 0,
			'rc_user_text' => 'ext>External User',
			'rc_comment' => '',
			'rc_comment_text' => '',
			'rc_comment_data' => null,
			'rc_this_oldid' => $title->getLatestRevID(),
			'rc_last_oldid' => $title->getLatestRevID(),
			'rc_bot' => 0,
			'rc_ip' => '',
			'rc_patrolled' => 0,
			'rc_new' => 0,
			'rc_old_len' => $title->getLength(),
			'rc_new_len' => $title->getLength(),
			'rc_deleted' => 0,
			'rc_logid' => 0,
			'rc_log_type' => null,
			'rc_log_action' => '',
			'rc_params' => '',
		];
		$rc->mExtra = [
			'prefixedDBkey' => $title->getPrefixedDBkey(),
			'lastTimestamp' => 0,
			'oldSize' => $title->getLength(),
			'newSize' => $title->getLength(),
			'pageStatus' => 'changed'
		];

		return $rc;
	}

	public function testExternalTypeParameters() {
		$user = $this->getLoggedInTestUser();
		$subjectTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$talkTarget = new TitleValue( NS_TALK, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$user,
			$subjectTarget,
			'Some Content',
			'Create the page'
		);
		$this->doPageEdit(
			$user,
			$talkTarget,
			'Some Talk Page Content',
			'Create Talk page'
		);

		$rc = $this->getExternalRC( $subjectTarget );
		$rc->save();

		$this->watchPages( $user, [ $subjectTarget, $talkTarget ] );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'external' ] );

		$this->assertEquals(
			[
				[
					'type' => 'external',
					'ns' => $subjectTarget->getNamespace(),
					'title' => $this->getPrefixedText( $subjectTarget ),
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testCategorizeTypeParameter() {
		$user = $this->getLoggedInTestUser();
		$subjectTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$categoryTarget = new TitleValue( NS_CATEGORY, 'ApiQueryWatchlistIntegrationTestCategory' );
		$this->doPageEdits(
			$user,
			[
				[
					'target' => $categoryTarget,
					'content' => 'Some Content',
					'summary' => 'Create the category',
				],
				[
					'target' => $subjectTarget,
					'content' => 'Some Content [[Category:ApiQueryWatchlistIntegrationTestCategory]]t',
					'summary' => 'Create the page and add it to the category',
				],
			]
		);
		$title = $this->getServiceContainer()->getTitleFactory()->newFromLinkTarget( $subjectTarget );
		$revision = $this->getServiceContainer()
			->getRevisionLookup()
			->getRevisionByTitle( $title );

		$comment = $revision->getComment();
		$rc = RecentChange::newForCategorization(
			$revision->getTimestamp(),
			$this->getServiceContainer()->getTitleFactory()->newFromLinkTarget( $categoryTarget ),
			$user,
			$comment ? $comment->text : '',
			$title,
			0,
			$revision->getId(),
			null,
			false
		);
		$rc->save();

		$this->watchPages( $user, [ $subjectTarget, $categoryTarget ] );

		$result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'categorize' ] );

		$this->assertEquals(
			[
				[
					'type' => 'categorize',
					'ns' => $categoryTarget->getNamespace(),
					'title' => $this->getPrefixedText( $categoryTarget ),
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testLimitParam() {
		$user = $this->getLoggedInTestUser();
		$target1 = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$target2 = new TitleValue( NS_TALK, 'ApiQueryWatchlistIntegrationTestPage' );
		$target3 = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage2' );
		$this->doPageEdits(
			$user,
			[
				[
					'target' => $target1,
					'content' => 'Some Content',
					'summary' => 'Create the page',
				],
				[
					'target' => $target2,
					'content' => 'Some Talk Page Content',
					'summary' => 'Create Talk page',
				],
				[
					'target' => $target3,
					'content' => 'Some Other Content',
					'summary' => 'Create the page',
				],
			]
		);
		$this->watchPages( $user, [ $target1, $target2, $target3 ] );

		$resultWithoutLimit = $this->doListWatchlistRequest( [ 'wlprop' => 'title' ] );
		$resultWithLimit = $this->doListWatchlistRequest( [ 'wllimit' => 2, 'wlprop' => 'title' ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => $target3->getNamespace(),
					'title' => $this->getPrefixedText( $target3 )
				],
				[
					'type' => 'new',
					'ns' => $target2->getNamespace(),
					'title' => $this->getPrefixedText( $target2 )
				],
				[
					'type' => 'new',
					'ns' => $target1->getNamespace(),
					'title' => $this->getPrefixedText( $target1 )
				],
			],
			$this->getItemsFromApiResponse( $resultWithoutLimit )
		);
		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => $target3->getNamespace(),
					'title' => $this->getPrefixedText( $target3 )
				],
				[
					'type' => 'new',
					'ns' => $target2->getNamespace(),
					'title' => $this->getPrefixedText( $target2 )
				],
			],
			$this->getItemsFromApiResponse( $resultWithLimit )
		);
		$this->assertArrayHasKey( 'continue', $resultWithLimit[0] );
		$this->assertArrayHasKey( 'wlcontinue', $resultWithLimit[0]['continue'] );
	}

	public function testAllRevParam() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdits(
			$user,
			[
				[
					'target' => $target,
					'content' => 'Some Content',
					'summary' => 'Create the page',
				],
				[
					'target' => $target,
					'content' => 'Some Other Content',
					'summary' => 'Change the content',
				],
			]
		);
		$this->watchPages( $user, [ $target ] );

		$resultAllRev = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wlallrev' => '', ] );
		$resultNoAllRev = $this->doListWatchlistRequest( [ 'wlprop' => 'title' ] );

		$this->assertEquals(
			[
				[
					'type' => 'edit',
					'ns' => $target->getNamespace(),
					'title' => $this->getPrefixedText( $target ),
				],
			],
			$this->getItemsFromApiResponse( $resultNoAllRev )
		);
		$this->assertEquals(
			[
				[
					'type' => 'edit',
					'ns' => $target->getNamespace(),
					'title' => $this->getPrefixedText( $target ),
				],
				[
					'type' => 'new',
					'ns' => $target->getNamespace(),
					'title' => $this->getPrefixedText( $target ),
				],
			],
			$this->getItemsFromApiResponse( $resultAllRev )
		);
	}

	public function testDirParams() {
		$user = $this->getLoggedInTestUser();
		$subjectTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$talkTarget = new TitleValue( NS_TALK, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdits(
			$user,
			[
				[
					'target' => $subjectTarget,
					'content' => 'Some Content',
					'summary' => 'Create the page',
				],
				[
					'target' => $talkTarget,
					'content' => 'Some Talk Page Content',
					'summary' => 'Create Talk page',
				],
			]
		);
		$this->watchPages( $user, [ $subjectTarget, $talkTarget ] );

		$resultDirOlder = $this->doListWatchlistRequest( [ 'wldir' => 'older', 'wlprop' => 'title' ] );
		$resultDirNewer = $this->doListWatchlistRequest( [ 'wldir' => 'newer', 'wlprop' => 'title' ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => $talkTarget->getNamespace(),
					'title' => $this->getPrefixedText( $talkTarget )
				],
				[
					'type' => 'new',
					'ns' => $subjectTarget->getNamespace(),
					'title' => $this->getPrefixedText( $subjectTarget )
				],
			],
			$this->getItemsFromApiResponse( $resultDirOlder )
		);
		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => $subjectTarget->getNamespace(),
					'title' => $this->getPrefixedText( $subjectTarget )
				],
				[
					'type' => 'new',
					'ns' => $talkTarget->getNamespace(),
					'title' => $this->getPrefixedText( $talkTarget )
				],
			],
			$this->getItemsFromApiResponse( $resultDirNewer )
		);
	}

	public function testStartEndParams() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$user,
			$target,
			'Some Content',
			'Create the page'
		);
		$this->watchPages( $user, [ $target ] );

		$resultStart = $this->doListWatchlistRequest( [
			'wlstart' => '20010115000000',
			'wldir' => 'newer',
			'wlprop' => 'title',
		] );
		$resultEnd = $this->doListWatchlistRequest( [
			'wlend' => '20010115000000',
			'wldir' => 'newer',
			'wlprop' => 'title',
		] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => $target->getNamespace(),
					'title' => $this->getPrefixedText( $target ),
				]
			],
			$this->getItemsFromApiResponse( $resultStart )
		);
		$this->assertSame( [], $this->getItemsFromApiResponse( $resultEnd ) );
	}

	public function testContinueParam() {
		$user = $this->getLoggedInTestUser();
		$target1 = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$target2 = new TitleValue( NS_TALK, 'ApiQueryWatchlistIntegrationTestPage' );
		$target3 = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage2' );
		$this->doPageEdits(
			$user,
			[
				[
					'target' => $target1,
					'content' => 'Some Content',
					'summary' => 'Create the page',
				],
				[
					'target' => $target2,
					'content' => 'Some Talk Page Content',
					'summary' => 'Create Talk page',
				],
				[
					'target' => $target3,
					'content' => 'Some Other Content',
					'summary' => 'Create the page',
				],
			]
		);
		$this->watchPages( $user, [ $target1, $target2, $target3 ] );

		$firstResult = $this->doListWatchlistRequest( [ 'wllimit' => 2, 'wlprop' => 'title' ] );
		$this->assertArrayHasKey( 'continue', $firstResult[0] );
		$this->assertArrayHasKey( 'wlcontinue', $firstResult[0]['continue'] );

		$continuationParam = $firstResult[0]['continue']['wlcontinue'];

		$continuedResult = $this->doListWatchlistRequest(
			[ 'wlcontinue' => $continuationParam, 'wlprop' => 'title' ]
		);

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => $target3->getNamespace(),
					'title' => $this->getPrefixedText( $target3 ),
				],
				[
					'type' => 'new',
					'ns' => $target2->getNamespace(),
					'title' => $this->getPrefixedText( $target2 ),
				],
			],
			$this->getItemsFromApiResponse( $firstResult )
		);
		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => $target1->getNamespace(),
					'title' => $this->getPrefixedText( $target1 )
				]
			],
			$this->getItemsFromApiResponse( $continuedResult )
		);
	}

	public function testOwnerAndTokenParams() {
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$this->getLoggedInTestUser(),
			$target,
			'Some Content',
			'Create the page'
		);

		$userOptionsManager = $this->getServiceContainer()->getUserOptionsManager();

		$otherUser = $this->getNonLoggedInTestUser();
		$userOptionsManager->setOption( $otherUser, 'watchlisttoken', '1234567890' );
		$otherUser->saveSettings();

		$this->watchPages( $otherUser, [ $target ] );

		$reloadedUser = $this->getServiceContainer()->getUserFactory()->newFromName( $otherUser->getName() );
		$option = $userOptionsManager->getOption( $reloadedUser, 'watchlisttoken' );
		$this->assertSame( '1234567890', $option );

		$result = $this->doListWatchlistRequest( [
			'wlowner' => $otherUser->getName(),
			'wltoken' => '1234567890',
			'wlprop' => 'title',
		] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => $target->getNamespace(),
					'title' => $this->getPrefixedText( $target )
				]
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testOwnerAndTokenParams_wrongToken() {
		$userOptionsManager = $this->getServiceContainer()->getUserOptionsManager();

		$otherUser = $this->getNonLoggedInTestUser();
		$userOptionsManager->setOption( $otherUser, 'watchlisttoken', '1234567890' );
		$otherUser->saveSettings();

		$this->expectApiErrorCode( 'bad_wltoken' );

		$this->doListWatchlistRequest( [
			'wlowner' => $otherUser->getName(),
			'wltoken' => 'wrong-token',
		] );
	}

	public function testOwnerAndTokenParams_noWatchlistTokenSet() {
		$this->expectApiErrorCode( 'bad_wltoken' );

		$this->doListWatchlistRequest( [
			'wlowner' => $this->getNonLoggedInTestUser()->getName(),
			'wltoken' => 'some-token',
		] );
	}

	public function testGeneratorWatchlistPropInfo_returnsWatchedPages() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdit(
			$user,
			$target,
			'Some Content',
			'Create the page'
		);
		$this->watchPages( $user, [ $target ] );

		$result = $this->doGeneratorWatchlistRequest( [ 'prop' => 'info' ] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'pages', $result[0]['query'] );

		// $result[0]['query']['pages'] uses page ids as keys. Page ids don't matter here, so drop them
		$pages = array_values( $result[0]['query']['pages'] );

		$this->assertArraySubsetsEqual(
			$pages,
			[
				[
					'ns' => $target->getNamespace(),
					'title' => $this->getPrefixedText( $target ),
					'new' => true,
				]
			],
			[ 'ns', 'title', 'new' ]
		);
	}

	public function testGeneratorWatchlistPropRevisions_returnsWatchedItemsRevisions() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistIntegrationTestPage' );
		$this->doPageEdits(
			$user,
			[
				[
					'target' => $target,
					'content' => 'Some Content',
					'summary' => 'Create the page',
				],
				[
					'target' => $target,
					'content' => 'Some Other Content',
					'summary' => 'Change the content',
				],
			]
		);
		$this->watchPages( $user, [ $target ] );

		$result = $this->doGeneratorWatchlistRequest( [ 'prop' => 'revisions', 'gwlallrev' => '' ] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'pages', $result[0]['query'] );

		// $result[0]['query']['pages'] uses page ids as keys. Page ids don't matter here, so drop them
		$pages = array_values( $result[0]['query']['pages'] );

		$this->assertCount( 1, $pages );
		$this->assertSame( $target->getNamespace(), $pages[0]['ns'] );
		$this->assertEquals( $this->getPrefixedText( $target ), $pages[0]['title'] );
		$this->assertArraySubsetsEqual(
			$pages[0]['revisions'],
			[
				[
					'comment' => 'Create the page',
					'user' => $user->getName(),
					'minor' => false,
				],
				[
					'comment' => 'Change the content',
					'user' => $user->getName(),
					'minor' => false,
				],
			],
			[ 'comment', 'user', 'minor' ]
		);
	}

}
PK       ! P!"  !"    api/query/ApiQueryTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiModuleManager;
use MediaWiki\Api\ApiQuery;
use MediaWiki\Api\ApiUsageException;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Tests\Api\MockApiQueryBase;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\Title;
use Wikimedia\TestingAccessWrapper;

/**
 * @group API
 * @group Database
 * @group medium
 * @covers \MediaWiki\Api\ApiQuery
 */
class ApiQueryTest extends ApiTestCase {
	use DummyServicesTrait;

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

		// Setup apiquerytestiw: as interwiki prefix
		$interwikiLookup = $this->getDummyInterwikiLookup( [
			[ 'iw_prefix' => 'apiquerytestiw', 'iw_url' => 'wikipedia' ],
		] );
		$this->setService( 'InterwikiLookup', $interwikiLookup );
	}

	public function testTitlesGetNormalized() {
		$this->overrideConfigValues( [
			MainConfigNames::CapitalLinks => true,
			MainConfigNames::MetaNamespace => 'TestWiki',
		] );

		$data = $this->doApiRequest( [
			'action' => 'query',
			'titles' => 'Project:articleA|article_B' ] );

		$this->assertArrayHasKey( 'query', $data[0] );
		$this->assertArrayHasKey( 'normalized', $data[0]['query'] );

		$this->assertEquals(
			[
				'fromencoded' => false,
				'from' => 'Project:articleA',
				'to' => 'TestWiki:ArticleA',
			],
			$data[0]['query']['normalized'][0]
		);

		$this->assertEquals(
			[
				'fromencoded' => false,
				'from' => 'article_B',
				'to' => 'Article B'
			],
			$data[0]['query']['normalized'][1]
		);
	}

	public function testTitlesAreRejectedIfInvalid() {
		$title = false;
		while ( !$title || Title::newFromText( $title )->exists() ) {
			$title = md5( mt_rand( 0, 100_000 ) );
		}

		$data = $this->doApiRequest( [
			'action' => 'query',
			'titles' => $title . '|Talk:' ] );

		$this->assertArrayHasKey( 'query', $data[0] );
		$this->assertArrayHasKey( 'pages', $data[0]['query'] );
		$this->assertCount( 2, $data[0]['query']['pages'] );

		$this->assertArrayHasKey( -2, $data[0]['query']['pages'] );
		$this->assertArrayHasKey( -1, $data[0]['query']['pages'] );

		$this->assertArrayHasKey( 'missing', $data[0]['query']['pages'][-2] );
		$this->assertArrayHasKey( 'invalid', $data[0]['query']['pages'][-1] );
	}

	public function testTitlesWithWhitespaces() {
		$data = $this->doApiRequest( [
			'action' => 'query',
			'titles' => ' '
		] );

		$this->assertArrayHasKey( 'query', $data[0] );
		$this->assertArrayHasKey( 'pages', $data[0]['query'] );
		$this->assertCount( 1, $data[0]['query']['pages'] );
		$this->assertArrayHasKey( -1, $data[0]['query']['pages'] );
		$this->assertArrayHasKey( 'invalid', $data[0]['query']['pages'][-1] );
	}

	/**
	 * Test the ApiBase::titlePartToKey function
	 *
	 * @param string $titlePart
	 * @param int $namespace
	 * @param string $expected
	 * @param string $expectException
	 * @dataProvider provideTestTitlePartToKey
	 */
	public function testTitlePartToKey( $titlePart, $namespace, $expected, $expectException ) {
		$this->overrideConfigValue( MainConfigNames::CapitalLinks, true );

		$api = new MockApiQueryBase();
		$exceptionCaught = false;
		try {
			$this->assertEquals( $expected, $api->titlePartToKey( $titlePart, $namespace ) );
		} catch ( ApiUsageException $e ) {
			$exceptionCaught = true;
		}
		$this->assertEquals( $expectException, $exceptionCaught,
			'ApiUsageException thrown by titlePartToKey' );
	}

	public static function provideTestTitlePartToKey() {
		return [
			[ 'a  b  c', NS_MAIN, 'A_b_c', false ],
			[ 'x', NS_MAIN, 'X', false ],
			[ 'y ', NS_MAIN, 'Y_', false ],
			[ 'template:foo', NS_CATEGORY, 'Template:foo', false ],
			[ 'apiquerytestiw:foo', NS_CATEGORY, 'Apiquerytestiw:foo', false ],
			[ "\xF7", NS_MAIN, null, true ],
			[ 'template:foo', NS_MAIN, null, true ],
			[ 'apiquerytestiw:foo', NS_MAIN, null, true ],
		];
	}

	/**
	 * Test if all classes in the query module manager exists
	 */
	public function testClassNamesInModuleManager() {
		$api = new ApiMain(
			new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] )
		);
		$queryApi = $api->getModuleManager()->getModule( 'query' );
		$modules = $queryApi->getModuleManager()->getNamesWithClasses();

		foreach ( $modules as $name => $class ) {
			$this->assertTrue(
				class_exists( $class ),
				'Class ' . $class . ' for api module ' . $name . ' does not exist (with exact case)'
			);
		}
	}

	public function testShouldNotExportPagesThatUserCanNotRead() {
		$title = Title::makeTitle( NS_MAIN, 'Test article' );
		$this->insertPage( $title );

		$this->setTemporaryHook( 'getUserPermissionsErrors',
			static function ( Title $page, &$user, $action, &$result ) use ( $title ) {
				if ( $page->equals( $title ) && $action === 'read' ) {
					$result = false;
					return false;
				}
			} );

		$data = $this->doApiRequest( [
			'action' => 'query',
			'titles' => $title->getPrefixedText(),
			'export' => 1,
		] );

		$this->assertArrayHasKey( 'query', $data[0] );
		$this->assertArrayHasKey( 'export', $data[0]['query'] );
		// This response field contains an XML document even if no pages were exported
		$this->assertStringNotContainsString( $title->getPrefixedText(), $data[0]['query']['export'] );
	}

	public function testIsReadMode() {
		$api = new ApiMain(
			new FauxRequest( [ 'action' => 'query', 'meta' => 'tokens', 'type' => 'login' ] )
		);
		$queryApi = $api->getModuleManager()->getModule( 'query' );
		$this->assertFalse( $queryApi->isReadMode(),
			'isReadMode() => false when meta=tokens is the only module' );

		$api = new ApiMain( new FauxRequest( [
			'action' => 'query', 'meta' => 'tokens', 'type' => 'login', 'rawcontinue' => 1,
			'indexpageids' => 1
		] )
		);
		$queryApi = $api->getModuleManager()->getModule( 'query' );
		$this->assertFalse( $queryApi->isReadMode(),
			'rawcontinue and indexpageids are also allowed' );

		$api = new ApiMain(
			new FauxRequest( [ 'action' => 'query', 'meta' => 'tokens|siteinfo', 'type' => 'login' ] )
		);
		$queryApi = $api->getModuleManager()->getModule( 'query' );
		$this->assertTrue( $queryApi->isReadMode(),
			'isReadMode() => true when other meta modules are present' );

		$api = new ApiMain( new FauxRequest( [
			'action' => 'query', 'meta' => 'tokens', 'type' => 'login', 'list' => 'allpages'
		] ) );
		$queryApi = $api->getModuleManager()->getModule( 'query' );
		$this->assertTrue( $queryApi->isReadMode(),
			'isReadMode() => true when other modules are present' );

		$api = new ApiMain( new FauxRequest( [
			'action' => 'query', 'meta' => 'tokens', 'type' => 'login', 'titles' => 'Foo'
		] ) );
		$queryApi = $api->getModuleManager()->getModule( 'query' );
		$this->assertTrue( $queryApi->isReadMode(),
			'isReadMode() => true when other ApiQuery parameters are present' );

		$api = new ApiMain( new FauxRequest( [ 'action' => 'query' ] ) );
		$queryApi = $api->getModuleManager()->getModule( 'query' );
		$this->assertTrue( $queryApi->isReadMode(),
			'isReadMode() => true when no modules are requested' );
	}

	/** @dataProvider provideIsWriteMode */
	public function testIsWriteMode( $queryParams, $expected ) {
		$api = new ApiMain( new FauxRequest( array_merge( [ 'action' => 'query' ], $queryParams ) ) );
		$queryApi = $api->getModuleManager()->getModule( 'query' );
		$this->assertSame(
			$expected,
			$queryApi->isWriteMode(),
			'::isWriteMode did not return the expected value.'
		);
	}

	public static function provideIsWriteMode() {
		return [
			'No modules specified' => [ [], false ],
			'Only meta=tokens' => [ [ 'meta' => 'tokens', 'type' => 'login' ], false ],
		];
	}

	public function testIsWriteModeForMockedModule() {
		$queryApi = $this->getMockBuilder( ApiQuery::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'extractRequestParams' ] )
			->getMock();
		// We need to mock ::extractRequestParams because we mock the module manager
		// and using the original implementation results in a test failure.
		$queryApi->method( 'extractRequestParams' )->willReturn( [
			'list' => [ 'mocked-module' ]
		] );
		// Mock $queryApi->mModuleMgr to return always return a mock module that returns true from ::isWriteMode
		$mockModuleManager = $this->createMock( ApiModuleManager::class );
		$mockModuleManager->method( 'getModule' )->willReturnCallback( function ( $name ) {
			$module = $this->createMock( MockApiQueryBase::class );
			$module->method( 'isWriteMode' )
				->willReturn( true );
			return $module;
		} );
		$queryApi = TestingAccessWrapper::newFromObject( $queryApi );
		$queryApi->mModuleMgr = $mockModuleManager;
		$this->assertTrue( $queryApi->isWriteMode(), '::isWriteMode did not return the expected value.' );
	}
}
PK       ! :  :  &  api/query/ApiQueryPrefixSearchTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\MainConfigNames;
use MediaWiki\Tests\Api\ApiTestCase;
use MockCompletionSearchEngine;

/**
 * @group API
 * @group medium
 * @group Database
 *
 * @covers MediaWiki\Api\ApiQueryPrefixSearch
 */
class ApiQueryPrefixSearchTest extends ApiTestCase {
	private const TEST_QUERY = 'unittest';

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValue( MainConfigNames::SearchType, MockCompletionSearchEngine::class );
		MockCompletionSearchEngine::clearMockResults();
		$results = [];
		foreach ( range( 0, 10 ) as $i ) {
			$title = "Search_Result_$i";
			$results[] = $title;
			$this->editPage( $title, 'hi there' );
		}
		MockCompletionSearchEngine::addMockResults( self::TEST_QUERY, $results );
	}

	public static function offsetContinueProvider() {
		return [
			'no offset' => [ 2, 2, 0, 2 ],
			'with offset' => [ 7, 2, 5, 2 ],
			'past end, no offset' => [ null, 11, 0, 20 ],
			'past end, with offset' => [ null, 5, 6, 10 ],
		];
	}

	/**
	 * @dataProvider offsetContinueProvider
	 */
	public function testOffsetContinue( $expectedOffset, $expectedResults, $offset, $limit ) {
		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, false );
		$response = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'prefixsearch',
			'pssearch' => self::TEST_QUERY,
			'psoffset' => $offset,
			'pslimit' => $limit,
		] );
		$result = $response[0];
		$this->assertArrayNotHasKey( 'warnings', $result );
		$suggestions = $result['query']['prefixsearch'];
		$this->assertCount( $expectedResults, $suggestions );
		if ( $expectedOffset == null ) {
			$this->assertArrayNotHasKey( 'continue', $result );
		} else {
			$this->assertArrayHasKey( 'continue', $result );
			$this->assertEquals( $expectedOffset, $result['continue']['psoffset'] );
		}
	}
}
PK       ! }&    &  api/query/ApiQueryContinueTestBase.phpnu Iw        <?php

/**
 * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
 *
 * 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
 */

namespace MediaWiki\Tests\Api\Query;

use Exception;

abstract class ApiQueryContinueTestBase extends ApiQueryTestBase {

	/**
	 * @var bool Enable to print in-depth debugging info during the test run
	 */
	protected $mVerbose = false;

	/**
	 * Run query() and compare against expected values
	 * @param array $expected
	 * @param array $params Api parameters
	 * @param int $expectedCount Max number of iterations
	 * @param string $id Unit test id
	 * @param bool $continue True to use smart continue
	 */
	protected function checkC( $expected, $params, $expectedCount, $id, $continue = true ) {
		$result = $this->query( $params, $expectedCount, $id, $continue );
		$this->assertResult( $expected, $result, $id );
	}

	/**
	 * Run query in a loop until no more values are available
	 * @param array $params Api parameters
	 * @param int $expectedCount Max number of iterations
	 * @param string $id Unit test id
	 * @param bool $useContinue True to use smart continue
	 * @return array Merged results data array
	 * @throws Exception
	 */
	protected function query( $params, $expectedCount, $id, $useContinue = true ) {
		if ( isset( $params['action'] ) ) {
			$this->assertEquals( 'query', $params['action'], 'Invalid query action' );
		} else {
			$params['action'] = 'query';
		}
		$count = 0;
		$result = [];
		$continue = [];
		do {
			$request = array_merge( $params, $continue );
			uksort( $request, static function ( $a, $b ) {
				// put 'continue' params at the end - lazy method
				$a = str_contains( $a, 'continue' ) ? 'zzz ' . $a : $a;
				$b = str_contains( $b, 'continue' ) ? 'zzz ' . $b : $b;

				return strcmp( $a, $b );
			} );
			$reqStr = http_build_query( $request );
			// $reqStr = str_replace( '&', ' & ', $reqStr );
			$this->assertLessThan( $expectedCount, $count, "$id more data: $reqStr" );
			if ( $this->mVerbose ) {
				print "$id (#$count): $reqStr\n";
			}
			try {
				$data = $this->doApiRequest( $request );
			} catch ( Exception $e ) {
				throw new Exception( "$id on $count", 0, $e );
			}
			$data = $data[0];
			if ( isset( $data['warnings'] ) ) {
				$warnings = json_encode( $data['warnings'] );
				$this->fail( "$id Warnings on #$count in $reqStr\n$warnings" );
			}
			$this->assertArrayHasKey( 'query', $data, "$id no 'query' on #$count in $reqStr" );
			if ( isset( $data['continue'] ) ) {
				$continue = $data['continue'];
				unset( $data['continue'] );
			} else {
				$continue = [];
			}
			if ( $this->mVerbose ) {
				$this->printResult( $data );
			}
			$this->mergeResult( $result, $data );
			$count++;
			if ( empty( $continue ) ) {
				$this->assertEquals( $expectedCount, $count, "$id finished early" );

				return $result;
			} elseif ( !$useContinue ) {
				$this->assertFalse( 'Non-smart query must be requested all at once' );
			}
		} while ( true );
	}

	/**
	 * @param array $data
	 */
	private function printResult( $data ) {
		$q = $data['query'];
		$print = [];
		if ( isset( $q['pages'] ) ) {
			foreach ( $q['pages'] as $p ) {
				$m = $p['title'];
				if ( isset( $p['links'] ) ) {
					$m .= '/[' . implode( ',', array_column( $p['links'], 'title' ) ) . ']';
				}
				if ( isset( $p['categories'] ) ) {
					$m .= '/(' . implode( ',', array_map(
						static function ( $v ) {
							return str_replace( 'Category:', '', $v['title'] );
						},
						$p['categories'] ) ) . ')';
				}
				$print[] = $m;
			}
		}
		if ( isset( $q['allcategories'] ) ) {
			$print[] = '*Cats/(' . implode( ',', array_column( $q['allcategories'], '*' ) ) . ')';
		}
		self::getItems( $q, 'allpages', 'Pages', $print );
		self::getItems( $q, 'alllinks', 'Links', $print );
		self::getItems( $q, 'alltransclusions', 'Trnscl', $print );
		print ' ' . implode( '  ', $print ) . "\n";
	}

	private static function getItems( $q, $moduleName, $name, &$print ) {
		if ( isset( $q[$moduleName] ) ) {
			$print[] = "*$name/[" . implode( ',', array_column( $q[$moduleName], 'title' ) ) . ']';
		}
	}

	/**
	 * Recursively merge the new result returned from the query to the previous results.
	 * @param mixed &$results
	 * @param mixed $newResult
	 * @param bool $numericIds If true, treat keys as ids to be merged instead of appending
	 */
	protected function mergeResult( &$results, $newResult, $numericIds = false ) {
		$this->assertEquals(
			is_array( $results ),
			is_array( $newResult ),
			'Type of result and data do not match'
		);
		if ( !is_array( $results ) ) {
			$this->assertEquals( $results, $newResult, 'Repeated result must be the same as before' );
		} else {
			$sort = null;
			foreach ( $newResult as $key => $value ) {
				if ( !$numericIds && $sort === null ) {
					if ( !is_array( $value ) ) {
						$sort = false;
					} elseif ( array_key_exists( 'title', $value ) ) {
						$sort = static function ( $a, $b ) {
							return strcmp( $a['title'], $b['title'] );
						};
					} else {
						$sort = false;
					}
				}
				$keyExists = array_key_exists( $key, $results );
				if ( is_numeric( $key ) ) {
					if ( $numericIds ) {
						if ( !$keyExists ) {
							$results[$key] = $value;
						} else {
							$this->mergeResult( $results[$key], $value );
						}
					} else {
						$results[] = $value;
					}
				} elseif ( !$keyExists ) {
					$results[$key] = $value;
				} else {
					$this->mergeResult( $results[$key], $value, $key === 'pages' );
				}
			}
			if ( $numericIds ) {
				ksort( $results, SORT_NUMERIC );
			} elseif ( $sort !== null && $sort !== false ) {
				usort( $results, $sort );
			}
		}
	}
}
PK       ! lG(  (    api/query/ApiQueryInfoTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @group API
 * @group medium
 * @group Database
 *
 * @covers MediaWiki\Api\ApiQueryInfo
 */
class ApiQueryInfoTest extends ApiTestCase {
	use TempUserTestTrait;

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

		$this->overrideConfigValues( [
			MainConfigNames::WatchlistExpiry => true,
			MainConfigNames::WatchlistExpiryMaxDuration => '6 months',
		] );
	}

	public function testExecute() {
		// Mock time for a deterministic result, without cut off from dynamic "max duration"
		ConvertibleTimestamp::setFakeTime( '2011-01-01T00:00:00Z' );

		$page = $this->getExistingTestPage( 'Pluto' );
		$title = $page->getTitle();
		$user = $this->getTestUser()->getUser();
		RequestContext::getMain()->setUser( $user );
		$this->getServiceContainer()->getWatchlistManager()->addWatch(
			$user,
			$title,
			// 3 months later
			'2011-04-01T00:00:00Z'
		);

		[ $data ] = $this->doApiRequest( [
				'action' => 'query',
				'prop' => 'info',
				'inprop' => 'watched|notificationtimestamp',
				'titles' => $title->getText() . '|' . 'NonExistingPage_lkasdoiewlmasdoiwem7483',
		], null, false, $user );

		$this->assertArrayHasKey( 'query', $data );
		$this->assertArrayHasKey( 'pages', $data['query'] );
		$this->assertArrayHasKey( $page->getId(), $data['query']['pages'] );

		$info = $data['query']['pages'][$page->getId()];
		$this->assertSame( $page->getId(), $info['pageid'] );
		$this->assertSame( NS_MAIN, $info['ns'] );
		$this->assertSame( 'Pluto', $info['title'] );
		$this->assertSame( 'wikitext', $info['contentmodel'] );
		$this->assertSame( 'en', $info['pagelanguage'] );
		$this->assertSame( 'en', $info['pagelanguagehtmlcode'] );
		$this->assertSame( 'ltr', $info['pagelanguagedir'] );
		$this->assertSame( '2011-01-01T00:00:00Z', $info['touched'] );
		$this->assertSame( $title->getLatestRevID(), $info['lastrevid'] );
		$this->assertSame( $title->getLength(), $info['length'] );
		$this->assertSame( true, $info['new'] );
		$this->assertSame( true, $info['watched'] );
		$this->assertSame( '2011-04-01T00:00:00Z', $info['watchlistexpiry'] );
		$this->assertArrayNotHasKey( 'actions', $info );
		$this->assertArrayNotHasKey( 'linkclasses', $info );
	}

	public function testExecuteLinkClasses() {
		$page = $this->getExistingTestPage( 'Pluto' );
		$title = $page->getTitle();

		[ $data ] = $this->doApiRequest( [
				'action' => 'query',
				'prop' => 'info',
				'titles' => $title->getText(),
				'inprop' => 'linkclasses',
				'inlinkcontext' => $title->getText(),
		] );

		$this->assertArrayHasKey( 'query', $data );
		$this->assertArrayHasKey( 'pages', $data['query'] );
		$this->assertArrayHasKey( $page->getId(), $data['query']['pages'] );

		$info = $data['query']['pages'][$page->getId()];
		$this->assertArrayHasKey( 'linkclasses', $info );
		$this->assertEquals( [], $info['linkclasses'] );
	}

	public function testExecuteEditActions() {
		$page = $this->getExistingTestPage( 'Pluto' );
		$title = $page->getTitle();

		[ $data ] = $this->doApiRequest( [
				'action' => 'query',
				'prop' => 'info',
				'titles' => $title->getText(),
				'intestactions' => 'edit'
		] );

		$this->assertArrayHasKey( 'query', $data );
		$this->assertArrayHasKey( 'pages', $data['query'] );
		$this->assertArrayHasKey( $page->getId(), $data['query']['pages'] );

		$info = $data['query']['pages'][$page->getId()];
		$this->assertArrayHasKey( 'actions', $info );
		$this->assertArrayHasKey( 'edit', $info['actions'] );
		$this->assertTrue( $info['actions']['edit'] );
	}

	public function testExecuteEditActionsAutoCreate() {
		$page = $this->getExistingTestPage( 'Pluto' );
		$title = $page->getTitle();

		// Disabled
		$this->disableAutoCreateTempUser();
		[ $data ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'info',
			'titles' => $title->getText(),
			'intestactions' => 'edit',
			'intestactionsautocreate' => true,
		], null, false, new User() );
		$result = $data['query']['pages'][$page->getId()]['wouldautocreate']['edit'];
		$this->assertFalse( $result );

		// Enabled
		$this->setGroupPermissions( '*', 'createaccount', true );
		$this->enableAutoCreateTempUser();
		[ $data ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'info',
			'titles' => $title->getText(),
			'intestactions' => 'edit',
			'intestactionsautocreate' => true,
		], null, false, new User() );
		$result = $data['query']['pages'][$page->getId()]['wouldautocreate']['edit'];
		$this->assertTrue( $result );

		[ $data ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'info',
			'titles' => $title->getText(),
			'intestactions' => 'create',
			'intestactionsautocreate' => true,
		], null, false, new User() );
		$result = $data['query']['pages'][$page->getId()]['wouldautocreate']['create'];
		$this->assertTrue( $result );

		// Enabled - 'read' is not an autocreate action
		[ $data ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'info',
			'titles' => $title->getText(),
			'intestactions' => 'read',
			'intestactionsautocreate' => true,
		], null, false, new User() );
		$result = $data['query']['pages'][$page->getId()]['wouldautocreate']['read'];
		$this->assertFalse( $result );

		// Enabled - but the user is logged in
		[ $data ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'info',
			'titles' => $title->getText(),
			'intestactions' => 'edit',
			'intestactionsautocreate' => true,
		], null, false, static::getTestSysop()->getAuthority() );
		$result = $data['query']['pages'][$page->getId()]['wouldautocreate']['edit'];
		$this->assertFalse( $result );

		// Enabled - but the user isn't allowed to create accounts
		$this->setGroupPermissions( '*', 'createaccount', false );
		[ $data ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'info',
			'titles' => $title->getText(),
			'intestactions' => 'edit',
			'intestactionsautocreate' => true,
		], null, false, new User() );
		$result = $data['query']['pages'][$page->getId()]['wouldautocreate']['edit'];
		$this->assertFalse( $result );
	}

	public function testExecuteEditActionsFull() {
		$page = $this->getExistingTestPage( 'Pluto' );
		$title = $page->getTitle();

		[ $data ] = $this->doApiRequest( [
				'action' => 'query',
				'prop' => 'info',
				'titles' => $title->getText(),
				'intestactions' => 'edit',
				'intestactionsdetail' => 'full',
		] );

		$this->assertArrayHasKey( 'query', $data );
		$this->assertArrayHasKey( 'pages', $data['query'] );
		$this->assertArrayHasKey( $page->getId(), $data['query']['pages'] );

		$info = $data['query']['pages'][$page->getId()];
		$this->assertArrayHasKey( 'actions', $info );
		$this->assertArrayHasKey( 'edit', $info['actions'] );
		$this->assertIsArray( $info['actions']['edit'] );
		$this->assertSame( [], $info['actions']['edit'] );
	}

	public function testExecuteEditActionsFullBlock() {
		$badActor = $this->getTestUser()->getUser();
		$sysop = $this->getTestSysop()->getUser();

		$block = new DatabaseBlock( [
			'address' => $badActor,
			'by' => $sysop,
			'expiry' => 'infinity',
			'sitewide' => 1,
			'enableAutoblock' => true,
		] );

		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockStore->insertBlock( $block );

		$page = $this->getExistingTestPage( 'Pluto' );
		$title = $page->getTitle();

		[ $data ] = $this->doApiRequest( [
				'action' => 'query',
				'prop' => 'info',
				'titles' => $title->getText(),
				'intestactions' => 'edit',
				'intestactionsdetail' => 'full',
		], null, false, $badActor );

		$this->assertArrayHasKey( 'query', $data );
		$this->assertArrayHasKey( 'pages', $data['query'] );
		$this->assertArrayHasKey( $page->getId(), $data['query']['pages'] );

		$info = $data['query']['pages'][$page->getId()];
		$this->assertArrayHasKey( 'actions', $info );
		$this->assertArrayHasKey( 'edit', $info['actions'] );
		$this->assertIsArray( $info['actions']['edit'] );
		$this->assertArrayHasKey( 0, $info['actions']['edit'] );
		$this->assertArrayHasKey( 'code', $info['actions']['edit'][0] );
		$this->assertSame( 'blocked', $info['actions']['edit'][0]['code'] );
		$this->assertArrayHasKey( 'data', $info['actions']['edit'][0] );
		$this->assertArrayHasKey( 'blockinfo', $info['actions']['edit'][0]['data'] );
		$this->assertArrayHasKey( 'blockid', $info['actions']['edit'][0]['data']['blockinfo'] );
		$this->assertSame( $block->getId(), $info['actions']['edit'][0]['data']['blockinfo']['blockid'] );
	}

	public function testAssociatedPage() {
		$page = $this->getExistingTestPage( 'Demo' );
		$title = $page->getTitle();

		$title2 = Title::makeTitle( NS_TALK, 'Page does not exist' );
		// Make sure it doesn't exist
		$this->getNonexistingTestPage( $title2 );

		[ $data ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'info',
			'titles' => $title->getPrefixedText() . '|' . $title2->getPrefixedText(),
			'inprop' => 'associatedpage',
		] );

		$this->assertArrayHasKey( 'query', $data );
		$this->assertArrayHasKey( 'pages', $data['query'] );
		$this->assertArrayHasKey( $page->getId(), $data['query']['pages'] );

		$info = $data['query']['pages'][$page->getId()];
		$this->assertArrayHasKey( 'associatedpage', $info );
		$this->assertEquals(
			'Talk:Demo',
			$info['associatedpage']
		);

		// For the non-existing page
		$this->assertArrayHasKey( -1, $data['query']['pages'] );

		$info = $data['query']['pages'][ -1 ];
		$this->assertArrayHasKey( 'associatedpage', $info );
		$this->assertEquals(
			'Page does not exist',
			$info['associatedpage']
		);
	}

	public function testDisplayTitle() {
		[ $data ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'info',
			'inprop' => 'displaytitle',
			'titles' => 'Art&copy',
		] );

		$this->assertArrayHasKey( 'query', $data );
		$this->assertArrayHasKey( 'pages', $data['query'] );

		// For the non-existing page
		$this->assertArrayHasKey( -1, $data['query']['pages'] );

		$info = $data['query']['pages'][ -1 ];
		$this->assertArrayHasKey( 'displaytitle', $info );
		$this->assertEquals(
			'Art&amp;copy',
			$info['displaytitle']
		);
	}

}
PK       ! W
  
  (  api/query/ApiQueryBlockInfoTraitTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Api\ApiQueryBase;
use MediaWiki\Api\ApiQueryBlockInfoTrait;
use MediaWiki\Tests\MockDatabase;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWikiIntegrationTestCase;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @covers \MediaWiki\Api\ApiQueryBlockInfoTrait
 */
class ApiQueryBlockInfoTraitTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;

	public function testUsesApiBlockInfoTrait() {
		$this->assertTrue( method_exists( ApiQueryBlockInfoTrait::class, 'getBlockDetails' ),
			'ApiQueryBlockInfoTrait::getBlockDetails exists' );
	}

	/**
	 * @dataProvider provideAddDeletedUserFilter
	 */
	public function testAddDeletedUserFilter( $isAllowed, $expect ) {
		// Fake timestamp to show up in the queries
		ConvertibleTimestamp::setFakeTime( '20190101000000' );

		$authority = $this->mockRegisteredAuthorityWithPermissions(
			$isAllowed ? [ 'hideuser' ] : [] );
		$db = new MockDatabase;
		$queryBuilder = $db->newSelectQueryBuilder()
			->from( 'table' );

		$mock = $this->getMockBuilder( ApiQueryBase::class )
			->disableOriginalConstructor()
			->onlyMethods( [
				'getQueryBuilder',
				'getDB',
				'getAuthority'
			] )
			->getMockForAbstractClass();

		$mock->method( 'getQueryBuilder' )->willReturn( $queryBuilder );
		$mock->method( 'getDB' )->willReturn( new MockDatabase );
		$mock->method( 'getAuthority' )->willReturn( $authority );

		TestingAccessWrapper::newFromObject( $mock )->addDeletedUserFilter();
		$data = $queryBuilder->getQueryInfo();
		$this->assertSame( $expect, $data );
	}

	public static function provideAddDeletedUserFilter() {
		return [
			'unauthorized' => [
				false,
				[
					'tables' => [ 'table' ],
					'fields' => [ 'hu_deleted' => '1=0' ],
					'conds' => [ '(SELECT  1  FROM "block_target" "hu_block_target" ' .
						'JOIN "block" "hu_block" ON ((hu_block.bl_target=hu_block_target.bt_id))   ' .
						'WHERE (hu_block_target.bt_user=user_id) AND hu_block.bl_deleted = 1  ) IS NULL' ],
					'options' => [],
					'join_conds' => [],
				],
			],
			'authorized' => [
				true,
				[
					'tables' => [ 'table' ],
					'fields' => [ 'hu_deleted' => '(SELECT  1  FROM "block_target" "hu_block_target" ' .
						'JOIN "block" "hu_block" ON ((hu_block.bl_target=hu_block_target.bt_id))   ' .
						'WHERE (hu_block_target.bt_user=user_id) AND hu_block.bl_deleted = 1  ) IS NOT NULL' ],
					'conds' => [],
					'options' => [],
					'join_conds' => []
				],
			],
		];
	}

}
PK       ! Si  i    api/query/ApiQueryTestBase.phpnu Iw        <?php

/**
 * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
 *
 * 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 3 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
 */

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\User\User;
use PHPUnit\Framework\ExpectationFailedException;
use SebastianBergmann\Comparator\ComparisonFailure;

/**
 * This class has some common functionality for testing query module
 */
abstract class ApiQueryTestBase extends ApiTestCase {

	private const PARAM_ASSERT = <<<STR
Each parameter must be an array of two elements,
first - an array of params to the API call,
and the second array - expected results as returned by the API
STR;

	/**
	 * Merges all requests parameter + expected values into one
	 * @param array ...$arrays List of arrays, each of which contains exactly two elements
	 * @return array
	 */
	protected function merge( ...$arrays ) {
		$request = [];
		$expected = [];
		foreach ( $arrays as $array ) {
			[ $req, $exp ] = $this->validateRequestExpectedPair( $array );
			$request = array_merge_recursive( $request, $req );
			$this->mergeExpected( $expected, $exp );
		}

		return [ $request, $expected ];
	}

	/**
	 * Check that the parameter is a valid two element array,
	 * with the first element being API request and the second - expected result
	 * @param array $v
	 * @return array
	 */
	private function validateRequestExpectedPair( $v ) {
		$this->assertIsArray( $v, self::PARAM_ASSERT );
		$this->assertCount( 2, $v, self::PARAM_ASSERT );
		$this->assertArrayHasKey( 0, $v, self::PARAM_ASSERT );
		$this->assertArrayHasKey( 1, $v, self::PARAM_ASSERT );
		$this->assertIsArray( $v[0], self::PARAM_ASSERT );
		$this->assertIsArray( $v[1], self::PARAM_ASSERT );

		return $v;
	}

	/**
	 * Recursively merges the expected values in the $item into the $all
	 * @param array &$all
	 * @param array $item
	 */
	private function mergeExpected( &$all, $item ) {
		foreach ( $item as $k => $v ) {
			if ( array_key_exists( $k, $all ) ) {
				if ( is_array( $all[$k] ) ) {
					$this->mergeExpected( $all[$k], $v );
				} else {
					$this->assertEquals( $all[$k], $v );
				}
			} else {
				$all[$k] = $v;
			}
		}
	}

	/**
	 * Checks that the request's result matches the expected results.
	 * Assumes no rawcontinue and a complete batch.
	 * @param array $values Array containing two elements: [ request, expected_results ]
	 * @param array|null $session
	 * @param bool $appendModule
	 * @param User|null $user
	 */
	protected function check( $values, ?array $session = null,
		$appendModule = false, ?User $user = null
	) {
		[ $req, $exp ] = $this->validateRequestExpectedPair( $values );
		$req['action'] ??= 'query';
		foreach ( $req as &$val ) {
			if ( is_array( $val ) ) {
				$val = implode( '|', array_unique( $val ) );
			}
		}
		$result = $this->doApiRequest( $req, $session, $appendModule, $user );
		$this->assertResult( [ 'batchcomplete' => true, 'query' => $exp ], $result[0], $req );
	}

	protected function assertResult( $exp, $result, $message = '' ) {
		try {
			$exp = self::sanitizeResultArray( $exp );
			$result = self::sanitizeResultArray( $result );
			$this->assertEquals( $exp, $result );
		} catch ( ExpectationFailedException $e ) {
			if ( is_array( $message ) ) {
				$message = http_build_query( $message );
			}

			throw new ExpectationFailedException(
				$e->getMessage() . "\nRequest: $message",
				new ComparisonFailure(
					$exp,
					$result,
					print_r( $exp, true ),
					print_r( $result, true ),
					false,
					$e->getComparisonFailure()->getMessage() . "\nRequest: $message"
				)
			);
		}
	}

	/**
	 * Recursively ksorts a result array and removes any 'pageid' keys.
	 * @param array $result
	 * @return array
	 */
	private static function sanitizeResultArray( $result ) {
		unset( $result['pageid'] );
		foreach ( $result as $key => $value ) {
			if ( is_array( $value ) ) {
				$result[$key] = self::sanitizeResultArray( $value );
			}
		}

		// Sort the result by keys, then take advantage of how array_merge will
		// renumber numeric keys while leaving others alone.
		ksort( $result );
		return array_merge( $result );
	}
}

/** @deprecated class alias since 1.42 */
class_alias( ApiQueryTestBase::class, 'ApiQueryTestBase' );
PK       ! ˴a$  a$    api/query/ApiQueryBasicTest.phpnu Iw        <?php
/**
 * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
 *
 * 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
 */

namespace MediaWiki\Tests\Api\Query;

use Exception;
use MediaWiki\Title\Title;

/**
 * These tests validate basic functionality of the api query module
 *
 * @group API
 * @group Database
 * @group medium
 * @covers MediaWiki\Api\ApiQuery
 */
class ApiQueryBasicTest extends ApiQueryTestBase {
	/** @var Exception|null */
	protected $exceptionFromAddDBData;

	/**
	 * Create a set of pages. These must not change, otherwise the tests might give wrong results.
	 *
	 * @see MediaWikiIntegrationTestCase::addDBDataOnce()
	 */
	public function addDBDataOnce() {
		try {
			if ( Title::makeTitle( NS_MAIN, 'AQBT-All' )->exists() ) {
				return;
			}

			// Ordering is important, as it will be returned in the same order as stored in the index
			$this->editPage( 'AQBT-All', '[[Category:AQBT-Cat]] [[AQBT-Links]] {{AQBT-T}}' );
			$this->editPage( 'AQBT-Categories', '[[Category:AQBT-Cat]]' );
			$this->editPage( 'AQBT-Links', '[[AQBT-All]] [[AQBT-Categories]] [[AQBT-Templates]]' );
			$this->editPage( 'AQBT-Templates', '{{AQBT-T}}' );
			$this->editPage( 'AQBT-T', 'Content', '', NS_TEMPLATE );

			// Refresh due to the bug with listing transclusions as links if they don't exist
			$this->editPage( 'AQBT-All', '[[Category:AQBT-Cat]] [[AQBT-Links]] {{AQBT-T}}' );
			$this->editPage( 'AQBT-Templates', '{{AQBT-T}}' );
		} catch ( Exception $e ) {
			$this->exceptionFromAddDBData = $e;
		}
	}

	private const LINKS = [
		[ 'prop' => 'links', 'titles' => 'AQBT-All' ],
		[ 'pages' => [
			'1' => [
				'pageid' => 1,
				'ns' => NS_MAIN,
				'title' => 'AQBT-All',
				'links' => [
					[ 'ns' => NS_MAIN, 'title' => 'AQBT-Links' ],
				]
			]
		] ]
	];

	private const TEMPLATES = [
		[ 'prop' => 'templates', 'titles' => 'AQBT-All' ],
		[ 'pages' => [
			'1' => [
				'pageid' => 1,
				'ns' => NS_MAIN,
				'title' => 'AQBT-All',
				'templates' => [
					[ 'ns' => NS_TEMPLATE, 'title' => 'Template:AQBT-T' ],
				]
			]
		] ]
	];

	private const CATEGORIES = [
		[ 'prop' => 'categories', 'titles' => 'AQBT-All' ],
		[ 'pages' => [
			'1' => [
				'pageid' => 1,
				'ns' => NS_MAIN,
				'title' => 'AQBT-All',
				'categories' => [
					[ 'ns' => NS_CATEGORY, 'title' => 'Category:AQBT-Cat' ],
				]
			]
		] ]
	];

	private const ALLPAGES = [
		[ 'list' => 'allpages', 'apprefix' => 'AQBT-' ],
		[ 'allpages' => [
			[ 'pageid' => 1, 'ns' => NS_MAIN, 'title' => 'AQBT-All' ],
			[ 'pageid' => 2, 'ns' => NS_MAIN, 'title' => 'AQBT-Categories' ],
			[ 'pageid' => 3, 'ns' => NS_MAIN, 'title' => 'AQBT-Links' ],
			[ 'pageid' => 4, 'ns' => NS_MAIN, 'title' => 'AQBT-Templates' ],
		] ]
	];

	private const ALLLINKS = [
		[ 'list' => 'alllinks', 'alprefix' => 'AQBT-' ],
		[ 'alllinks' => [
			[ 'ns' => NS_MAIN, 'title' => 'AQBT-Links' ],
			[ 'ns' => NS_MAIN, 'title' => 'AQBT-All' ],
			[ 'ns' => NS_MAIN, 'title' => 'AQBT-Categories' ],
			[ 'ns' => NS_MAIN, 'title' => 'AQBT-Templates' ],
		] ]
	];

	private const ALLTRANSCLUSIONS = [
		[ 'list' => 'alltransclusions', 'atprefix' => 'AQBT-' ],
		[ 'alltransclusions' => [
			[ 'ns' => NS_TEMPLATE, 'title' => 'Template:AQBT-T' ],
			[ 'ns' => NS_TEMPLATE, 'title' => 'Template:AQBT-T' ],
		] ]
	];

	/** Although this appears to have no use it is used by testLists() */
	private const ALLCATEGORIES = [
		[ 'list' => 'allcategories', 'acprefix' => 'AQBT-' ],
		[ 'allcategories' => [
			[ 'category' => 'AQBT-Cat' ],
		] ]
	];

	private const BACKLINKS = [
		[ 'list' => 'backlinks', 'bltitle' => 'AQBT-Links' ],
		[ 'backlinks' => [
			[ 'pageid' => 1, 'ns' => NS_MAIN, 'title' => 'AQBT-All' ],
		] ]
	];

	private const EMBEDDEDIN = [
		[ 'list' => 'embeddedin', 'eititle' => 'Template:AQBT-T' ],
		[ 'embeddedin' => [
			[ 'pageid' => 1, 'ns' => NS_MAIN, 'title' => 'AQBT-All' ],
			[ 'pageid' => 4, 'ns' => NS_MAIN, 'title' => 'AQBT-Templates' ],
		] ]
	];

	private const CATEGORYMEMBERS = [
		[ 'list' => 'categorymembers', 'cmtitle' => 'Category:AQBT-Cat' ],
		[ 'categorymembers' => [
			[ 'pageid' => 1, 'ns' => NS_MAIN, 'title' => 'AQBT-All' ],
			[ 'pageid' => 2, 'ns' => NS_MAIN, 'title' => 'AQBT-Categories' ],
		] ]
	];

	private const GENERATOR_ALLPAGES = [
		[ 'generator' => 'allpages', 'gapprefix' => 'AQBT-' ],
		[ 'pages' => [
			'1' => [
				'pageid' => 1,
				'ns' => NS_MAIN,
				'title' => 'AQBT-All' ],
			'2' => [
				'pageid' => 2,
				'ns' => NS_MAIN,
				'title' => 'AQBT-Categories' ],
			'3' => [
				'pageid' => 3,
				'ns' => NS_MAIN,
				'title' => 'AQBT-Links' ],
			'4' => [
				'pageid' => 4,
				'ns' => NS_MAIN,
				'title' => 'AQBT-Templates' ],
		] ]
	];

	private const GENERATOR_LINKS = [
		[ 'generator' => 'links', 'titles' => 'AQBT-Links' ],
		[ 'pages' => [
			'1' => [
				'pageid' => 1,
				'ns' => NS_MAIN,
				'title' => 'AQBT-All' ],
			'2' => [
				'pageid' => 2,
				'ns' => NS_MAIN,
				'title' => 'AQBT-Categories' ],
			'4' => [
				'pageid' => 4,
				'ns' => NS_MAIN,
				'title' => 'AQBT-Templates' ],
		] ]
	];

	private const GENERATOR_LINKS_PROP_LINKS = [
		[ 'prop' => 'links' ],
		[ 'pages' => [
			'1' => [ 'links' => [
				[ 'ns' => NS_MAIN, 'title' => 'AQBT-Links' ],
			] ]
		] ]
	];

	private const GENERATOR_LINKS_PROP_TEMPLATES = [
		[ 'prop' => 'templates' ],
		[ 'pages' => [
			'1' => [ 'templates' => [
				[ 'ns' => NS_TEMPLATE, 'title' => 'Template:AQBT-T' ] ] ],
			'4' => [ 'templates' => [
				[ 'ns' => NS_TEMPLATE, 'title' => 'Template:AQBT-T' ] ] ],
		] ]
	];

	/**
	 * Test basic props
	 */
	public function testProps() {
		$this->check( self::LINKS );
		$this->check( self::TEMPLATES );
		$this->check( self::CATEGORIES );
	}

	/**
	 * Test basic lists
	 */
	public function testLists() {
		$this->check( self::ALLPAGES );
		$this->check( self::ALLLINKS );
		$this->check( self::ALLTRANSCLUSIONS );
		$this->check( self::ALLCATEGORIES );
		$this->check( self::BACKLINKS );
		$this->check( self::EMBEDDEDIN );
		$this->check( self::CATEGORYMEMBERS );
	}

	/**
	 * Test basic lists
	 */
	public function testAllTogether() {
		// All props together
		$this->check( $this->merge(
			self::LINKS,
			self::TEMPLATES,
			self::CATEGORIES
		) );

		// All lists together
		$this->check( $this->merge(
			self::ALLPAGES,
			self::ALLLINKS,
			self::ALLTRANSCLUSIONS,
			// This test is temporarily disabled until a sqlite bug is fixed
			// self::ALLCATEGORIES,
			self::BACKLINKS,
			self::EMBEDDEDIN,
			self::CATEGORYMEMBERS
		) );

		// All props+lists together
		$this->check( $this->merge(
			self::LINKS,
			self::TEMPLATES,
			self::CATEGORIES,
			self::ALLPAGES,
			self::ALLLINKS,
			self::ALLTRANSCLUSIONS,
			// This test is temporarily disabled until a sqlite bug is fixed
			// self::ALLCATEGORIES,
			self::BACKLINKS,
			self::EMBEDDEDIN,
			self::CATEGORYMEMBERS
		) );
	}

	/**
	 * Test basic lists
	 */
	public function testGenerator() {
		// generator=allpages
		$this->check( self::GENERATOR_ALLPAGES );
		// generator=allpages & list=allpages
		$this->check( $this->merge(
			self::GENERATOR_ALLPAGES,
			self::ALLPAGES ) );
		// generator=links
		$this->check( self::GENERATOR_LINKS );
		// generator=links & prop=links
		$this->check( $this->merge(
			self::GENERATOR_LINKS,
			self::GENERATOR_LINKS_PROP_LINKS ) );
		// generator=links & prop=templates
		$this->check( $this->merge(
			self::GENERATOR_LINKS,
			self::GENERATOR_LINKS_PROP_TEMPLATES ) );
		// generator=links & prop=links|templates
		$this->check( $this->merge(
			self::GENERATOR_LINKS,
			self::GENERATOR_LINKS_PROP_LINKS,
			self::GENERATOR_LINKS_PROP_TEMPLATES ) );
		// generator=links & prop=links|templates & list=allpages|...
		$this->check( $this->merge(
			self::GENERATOR_LINKS,
			self::GENERATOR_LINKS_PROP_LINKS,
			self::GENERATOR_LINKS_PROP_TEMPLATES,
			self::ALLPAGES,
			self::ALLLINKS,
			self::ALLTRANSCLUSIONS,
			// This test is temporarily disabled until a sqlite bug is fixed
			// self::ALLCATEGORIES,
			self::BACKLINKS,
			self::EMBEDDEDIN,
			self::CATEGORYMEMBERS ) );
	}

	/**
	 * Test T53821
	 */
	public function testGeneratorRedirects() {
		$this->editPage( 'AQBT-Target', 'test' );
		$this->editPage( 'AQBT-Redir', '#REDIRECT [[AQBT-Target]]' );
		$this->check( [
			[ 'generator' => 'backlinks', 'gbltitle' => 'AQBT-Target', 'redirects' => '1' ],
			[
				'redirects' => [
					[
						'from' => 'AQBT-Redir',
						'to' => 'AQBT-Target',
					]
				],
				'pages' => [
					'6' => [
						'pageid' => 6,
						'ns' => NS_MAIN,
						'title' => 'AQBT-Target',
					]
				],
			]
		] );
	}
}
PK       ! Vq5  5  -  api/query/ApiQueryAllDeletedRevisionsTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\MainConfigNames;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Title\Title;

/**
 * @group API
 * @group Database
 * @covers MediaWiki\Api\ApiQueryAllDeletedRevisions
 */
class ApiQueryAllDeletedRevisionsTest extends ApiTestCase {

	public function testFromToPrefixParameter() {
		$this->overrideConfigValues( [
			MainConfigNames::CapitalLinks => false,
		] );
		$performer = $this->getTestSysop()->getAuthority();

		$title = Title::makeTitle( NS_MAIN, 'pageM' );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$this->editPage( $page, 'Some text', 'Create', NS_MAIN, $performer );
		$this->deletePage( $page, 'Delete', $performer );

		$userTitle = Title::makeTitle( NS_USER, 'PageU' );
		$userPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $userTitle );
		$this->editPage( $userPage, 'Some text', 'Create', NS_MAIN, $performer );
		$this->deletePage( $userPage, 'Delete', $performer );

		$expectedResult0 = [ 'ns' => $title->getNamespace(), 'title' => $title->getPrefixedDbKey() ];
		$expectedResult1 = [ 'ns' => $userTitle->getNamespace(), 'title' => $userTitle->getPrefixedDbKey() ];

		// Search the page with prefix
		[ $result ] = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'alldeletedrevisions',
			'adrdir' => 'newer',
			'adrnamespace' => '0|2',
			'adrprefix' => 'page',
		], null, false, $performer );

		$this->assertArrayHasKey( 'query', $result );
		$this->assertArrayHasKey( 'alldeletedrevisions', $result['query'] );
		$this->assertArrayContains( $expectedResult0, $result['query']['alldeletedrevisions'][0] );
		$this->assertArrayContains( $expectedResult1, $result['query']['alldeletedrevisions'][1] );

		// Search the page with from
		[ $result ] = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'alldeletedrevisions',
			'adrdir' => 'newer',
			'adrnamespace' => '0|2',
			'adrfrom' => 'pageA',
		], null, false, $performer );

		$this->assertArrayHasKey( 'query', $result );
		$this->assertArrayHasKey( 'alldeletedrevisions', $result['query'] );
		$this->assertArrayContains( $expectedResult0, $result['query']['alldeletedrevisions'][0] );
		$this->assertArrayContains( $expectedResult1, $result['query']['alldeletedrevisions'][1] );

		// Search the page with to
		[ $result ] = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'alldeletedrevisions',
			'adrdir' => 'newer',
			'adrnamespace' => '0|2',
			'adrto' => 'pageZ',
		], null, false, $performer );

		$this->assertArrayHasKey( 'query', $result );
		$this->assertArrayHasKey( 'alldeletedrevisions', $result['query'] );
		$this->assertArrayContains( $expectedResult0, $result['query']['alldeletedrevisions'][0] );
		$this->assertArrayContains( $expectedResult1, $result['query']['alldeletedrevisions'][1] );
	}
}
PK       ! J+  +  "  api/query/ApiQueryAllUsersTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Permissions\UltimateAuthority;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\User\User;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers MediaWiki\Api\ApiQueryAllUsers
 */
class ApiQueryAllUsersTest extends ApiTestCase {
	use MockAuthorityTrait;
	use TempUserTestTrait;

	private const USER_PREFIX = 'ApiQueryAllUsersTest ';

	private const TEMP_USER_PREFIX = '~';

	private const TEMP_USER_CONFIG = [
		'genPattern' => self::TEMP_USER_PREFIX . '$1',
		'matchPattern' => self::TEMP_USER_PREFIX . '$1',
		'reservedPattern' => self::TEMP_USER_PREFIX . '$1',
	];

	/** @var User[] */
	private static $usersAdded = [];

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

		$this->enableAutoCreateTempUser( self::TEMP_USER_CONFIG );
	}

	public function addDBDataOnce() {
		$groupManager = $this->getServiceContainer()->getUserGroupManager();
		$userA = $this->getMutableTestUser( [], self::USER_PREFIX . 'A' )->getUser();
		$userB = $this->getMutableTestUser( [], self::USER_PREFIX . 'B' )->getUser();
		$userC = $this->getMutableTestUser( [], self::USER_PREFIX . 'C' )->getUser();
		$userD = $this->getMutableTestUser( [], self::TEMP_USER_PREFIX . 'D' )->getUser();
		$groupManager->addUserToGroup( $userB, 'bot' );
		$groupManager->addUserToGroup( $userC, 'bot' );
		self::$usersAdded = [ $userA, $userB, $userC, $userD ];
	}

	public function testPrefix() {
		$result = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'allusers',
			'auprefix' => self::USER_PREFIX
		] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'allusers', $result[0]['query'] );
		$this->assertApiResultsHasUser( self::$usersAdded[0]->getName(), $result );
		$this->assertApiResultsHasUser( self::$usersAdded[1]->getName(), $result );
		$this->assertApiResultsHasUser( self::$usersAdded[2]->getName(), $result );
	}

	public function testImplicitRights() {
		$result = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'allusers',
			'auprefix' => self::USER_PREFIX,
			'aurights' => 'stashedit',
		] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'allusers', $result[0]['query'] );
		$this->assertApiResultsHasUser( self::$usersAdded[0]->getName(), $result );
		$this->assertApiResultsHasUser( self::$usersAdded[1]->getName(), $result );
		$this->assertApiResultsHasUser( self::$usersAdded[2]->getName(), $result );
	}

	public function testTempUserImplicitRights() {
		$result = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'allusers',
			'auprefix' => self::TEMP_USER_PREFIX,
			'aurights' => 'stashedit',
		] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'allusers', $result[0]['query'] );
		$this->assertApiResultsHasUser( self::$usersAdded[3]->getName(), $result );
	}

	public function testPermissions() {
		$result = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'allusers',
			'auprefix' => self::USER_PREFIX,
			'aurights' => 'bot'
		] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'allusers', $result[0]['query'] );
		$this->assertApiResultsHasUser( self::$usersAdded[1]->getName(), $result );
		$this->assertApiResultsHasUser( self::$usersAdded[2]->getName(), $result );
	}

	public function testHiddenUser() {
		$a = self::$usersAdded[0];
		$b = self::$usersAdded[1];
		$blockStatus = $this->getServiceContainer()->getBlockUserFactory()
			->newBlockUser(
				$b,
				new UltimateAuthority( $a ),
				'infinity',
				'',
				[ 'isHideUser' => true ],
			)
			->placeBlock();
		$this->assertStatusGood( $blockStatus );

		$apiParams = [
			'action' => 'query',
			'list' => 'allusers',
			'auprefix' => self::USER_PREFIX . 'B',
		];

		$result = $this->doApiRequest( $apiParams, null, null,
			$this->mockRegisteredAuthorityWithPermissions( [] ) );
		$this->assertSame( [], $result[0]['query']['allusers'] );

		$result = $this->doApiRequest( $apiParams, null, null,
			$this->mockRegisteredAuthorityWithPermissions( [ 'hideuser' ] ) );

		$this->assertSame(
			[ [
				'userid' => $b->getId(),
				'name' => $b->getName(),
				'hidden' => true,
			] ],
			$result[0]['query']['allusers']
		);

		$apiParams['auprop'] = 'blockinfo';
		$result = $this->doApiRequest( $apiParams, null, null,
			$this->mockRegisteredAuthorityWithPermissions( [ 'hideuser' ] ) );
		$this->assertArraySubmapSame(
			[
				'userid' => $b->getId(),
				'name' => $b->getName(),
				'hidden' => true,
				'blockedby' => $a->getName(),
				'blockreason' => '',
				'blockexpiry' => 'infinite',
				'blockpartial' => false,
			],
			$result[0]['query']['allusers'][0]
		);
	}

	public function testBlockInfo() {
		$a = self::$usersAdded[0];
		$b = self::$usersAdded[1];
		$c = self::$usersAdded[2];

		$blockStatus = $this->getServiceContainer()->getBlockUserFactory()
			->newBlockUser(
				$b,
				new UltimateAuthority( $a ),
				'infinity',
			)
			->placeBlock();
		$this->assertStatusGood( $blockStatus );

		$blockStatus = $this->getServiceContainer()->getBlockUserFactory()
			->newBlockUser(
				$c,
				new UltimateAuthority( $a ),
				'infinity',
				'',
				[ 'isUserTalkEditBlocked' => true ],
			)
			->placeBlock();
		$this->assertStatusGood( $blockStatus );

		$result = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'allusers',
			'auprefix' => self::USER_PREFIX,
			'auprop' => 'blockinfo'
		] );

		$this->assertArraySubmapSame(
			[
				'userid' => $a->getId(),
				'name' => $a->getName(),
			],
			$result[0]['query']['allusers'][0]
		);
		$this->assertArraySubmapSame(
			[
				'userid' => $b->getId(),
				'name' => $b->getName(),
				'blockedby' => $a->getName(),
				'blockreason' => '',
				'blockexpiry' => 'infinite',
				'blockpartial' => false,
				'blockowntalk' => false
			],
			$result[0]['query']['allusers'][1]
		);
		$this->assertArraySubmapSame(
			[
				'userid' => $c->getId(),
				'name' => $c->getName(),
				'blockedby' => $a->getName(),
				'blockreason' => '',
				'blockexpiry' => 'infinite',
				'blockpartial' => false,
				'blockowntalk' => true
			],
			$result[0]['query']['allusers'][2]
		);
	}

	public function testUserRights() {
		$userA = self::$usersAdded[0];
		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		$permissionManager->overrideUserRightsForTesting( $userA, [ 'protect' ] );

		$result = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'allusers',
			'auprefix' => self::USER_PREFIX,
			'auprop' => 'rights',
			'aulimit' => 2,
		] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'allusers', $result[0]['query'] );
		$this->assertArrayHasKey( 'rights', $result[0]['query']['allusers'][0] );
		$this->assertArrayHasKey( 'rights', $result[0]['query']['allusers'][1] );
		$this->assertContains( 'protect', $result[0]['query']['allusers'][0]['rights'] );
		$this->assertNotContains( 'protect', $result[0]['query']['allusers'][1]['rights'] );
	}

	public function testNamedOnly() {
		$result = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'allusers',
			'auexcludetemp' => true,
		] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'allusers', $result[0]['query'] );
		$this->assertApiResultsHasUser( self::$usersAdded[0]->getName(), $result );
		$this->assertApiResultsHasUser( self::$usersAdded[1]->getName(), $result );
		$this->assertApiResultsHasUser( self::$usersAdded[2]->getName(), $result );
		$this->assertApiResultsNotHasUser( self::$usersAdded[3]->getName(), $result );
	}

	public function testNamedOnlyTempDisabled() {
		$this->disableAutoCreateTempUser(
			array_merge( self::TEMP_USER_CONFIG, [ 'known' => false ] )
		);

		$result = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'allusers',
			'auexcludetemp' => true,
		] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'allusers', $result[0]['query'] );
		$this->assertApiResultsHasUser( self::$usersAdded[0]->getName(), $result );
		$this->assertApiResultsHasUser( self::$usersAdded[1]->getName(), $result );
		$this->assertApiResultsHasUser( self::$usersAdded[2]->getName(), $result );
		// The temp user from the setup will return as the filter will be disabled.
		$this->assertApiResultsHasUser( self::$usersAdded[3]->getName(), $result );
	}

	public function testNamedOnlyTempKnown() {
		$this->disableAutoCreateTempUser(
			array_merge( self::TEMP_USER_CONFIG, [ 'enabled' => false, 'known' => true ] )
		);

		$result = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'allusers',
			'auexcludetemp' => true,
		] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'allusers', $result[0]['query'] );
		$this->assertApiResultsHasUser( self::$usersAdded[0]->getName(), $result );
		$this->assertApiResultsHasUser( self::$usersAdded[1]->getName(), $result );
		$this->assertApiResultsHasUser( self::$usersAdded[2]->getName(), $result );
		$this->assertApiResultsNotHasUser( self::$usersAdded[3]->getName(), $result );
	}

	public function testTempOnly() {
		$result = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'allusers',
			'auexcludenamed' => true,
		] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'allusers', $result[0]['query'] );
		$this->assertApiResultsHasUser( self::$usersAdded[3]->getName(), $result );
		$this->assertCount( 1, $result[0]['query']['allusers'] );
	}

	public function testTempOnlyTempDisabled() {
		$this->disableAutoCreateTempUser();

		$result = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'allusers',
			'auexcludenamed' => true,
		] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'allusers', $result[0]['query'] );
		// We'll get some count higher than 1 if the feature is disabled as the
		// filter gets ignored in that case.
		$this->assertNotCount( 1, $result[0]['query']['allusers'] );
	}

	public function testTempOnlyTempKnown() {
		$this->disableAutoCreateTempUser(
			array_merge( self::TEMP_USER_CONFIG, [ 'enabled' => false, 'known' => true ] )
		);

		$result = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'allusers',
			'auexcludenamed' => true,
		] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'allusers', $result[0]['query'] );
		$this->assertApiResultsHasUser( self::$usersAdded[3]->getName(), $result );
		$this->assertCount( 1, $result[0]['query']['allusers'] );
	}

	private function assertApiResultsHasUser( $username, $results ) {
		$this->assertNotFalse(
			array_search(
				$username,
				array_column( $results[0]['query']['allusers'], 'name' )
			),
			"Failed to assert that '{$username}' is in the AllUsers API response"
		);
	}

	private function assertApiResultsNotHasUser( $username, $results ) {
		$this->assertFalse(
			array_search(
				$username,
				array_column( $results[0]['query']['allusers'], 'name' )
			),
			"Failed to assert that '{$username}' is not in the AllUsers API response"
		);
	}
}
PK       ! 0~>  >  1  api/query/ApiQueryWatchlistRawIntegrationTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\User;
use MediaWiki\Watchlist\WatchedItemQueryService;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers MediaWiki\Api\ApiQueryWatchlistRaw
 */
class ApiQueryWatchlistRawIntegrationTest extends ApiTestCase {
	// TODO: This test should use Authority, but can't due to User::saveSettings
	/** @var User */
	private $loggedInUser;
	/** @var User */
	private $notLoggedInUser;

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

		$this->loggedInUser = $this->getMutableTestUser()->getUser();
		$this->notLoggedInUser = $this->getMutableTestUser()->getUser();
	}

	private function getLoggedInTestUser(): User {
		return $this->loggedInUser;
	}

	private function getNotLoggedInTestUser(): User {
		return $this->notLoggedInUser;
	}

	private function getWatchedItemStore() {
		return $this->getServiceContainer()->getWatchedItemStore();
	}

	private function doListWatchlistRawRequest( array $params = [] ) {
		return $this->doApiRequest( array_merge(
			[ 'action' => 'query', 'list' => 'watchlistraw' ],
			$params
		), null, false, $this->getLoggedInTestUser() );
	}

	private function doGeneratorWatchlistRawRequest( array $params = [] ) {
		return $this->doApiRequest( array_merge(
			[ 'action' => 'query', 'generator' => 'watchlistraw' ],
			$params
		), null, false, $this->getLoggedInTestUser() );
	}

	private function getItemsFromApiResponse( array $response ) {
		return $response[0]['watchlistraw'];
	}

	public function testListWatchlistRaw_returnsWatchedItems() {
		$store = $this->getWatchedItemStore();
		$store->addWatch(
			$this->getLoggedInTestUser(),
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage' )
		);

		$result = $this->doListWatchlistRawRequest();

		$this->assertArrayHasKey( 'watchlistraw', $result[0] );

		$this->assertEquals(
			[
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage',
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testPropChanged_addsNotificationTimestamp() {
		$target = new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage' );
		$otherUser = $this->getNotLoggedInTestUser();

		$store = $this->getWatchedItemStore();

		$store->addWatch( $this->getLoggedInTestUser(), $target );
		$store->updateNotificationTimestamp(
			$otherUser,
			$target,
			'20151212010101'
		);

		$result = $this->doListWatchlistRawRequest( [ 'wrprop' => 'changed' ] );

		$this->assertEquals(
			[
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage',
					'changed' => '2015-12-12T01:01:01Z',
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testNamespaceParam() {
		$store = $this->getWatchedItemStore();

		$store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage' ),
			new TitleValue( NS_TALK, 'ApiQueryWatchlistRawIntegrationTestPage' ),
		] );

		$result = $this->doListWatchlistRawRequest( [ 'wrnamespace' => '0' ] );

		$this->assertEquals(
			[
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage',
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testShowChangedParams() {
		$subjectTarget = new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage' );
		$talkTarget = new TitleValue( NS_TALK, 'ApiQueryWatchlistRawIntegrationTestPage' );
		$otherUser = $this->getNotLoggedInTestUser();

		$store = $this->getWatchedItemStore();

		$store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
			$subjectTarget,
			$talkTarget,
		] );
		$store->updateNotificationTimestamp(
			$otherUser,
			$subjectTarget,
			'20151212010101'
		);

		$resultChanged = $this->doListWatchlistRawRequest(
			[ 'wrprop' => 'changed', 'wrshow' => WatchedItemQueryService::FILTER_CHANGED ]
		);
		$resultNotChanged = $this->doListWatchlistRawRequest(
			[ 'wrprop' => 'changed', 'wrshow' => WatchedItemQueryService::FILTER_NOT_CHANGED ]
		);

		$this->assertEquals(
			[
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage',
					'changed' => '2015-12-12T01:01:01Z',
				],
			],
			$this->getItemsFromApiResponse( $resultChanged )
		);

		$this->assertEquals(
			[
				[
					'ns' => NS_TALK,
					'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage',
				],
			],
			$this->getItemsFromApiResponse( $resultNotChanged )
		);
	}

	public function testLimitParam() {
		$store = $this->getWatchedItemStore();

		$store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
			new TitleValue( NS_TALK, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
		] );

		$resultWithoutLimit = $this->doListWatchlistRawRequest();
		$resultWithLimit = $this->doListWatchlistRawRequest( [ 'wrlimit' => 2 ] );

		$this->assertEquals(
			[
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
				],
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
				],
				[
					'ns' => NS_TALK,
					'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1',
				],
			],
			$this->getItemsFromApiResponse( $resultWithoutLimit )
		);
		$this->assertEquals(
			[
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
				],
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
				],
			],
			$this->getItemsFromApiResponse( $resultWithLimit )
		);

		$this->assertArrayNotHasKey( 'continue', $resultWithoutLimit[0] );
		$this->assertArrayHasKey( 'continue', $resultWithLimit[0] );
		$this->assertArrayHasKey( 'wrcontinue', $resultWithLimit[0]['continue'] );
	}

	public function testDirParams() {
		$store = $this->getWatchedItemStore();

		$store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
			new TitleValue( NS_TALK, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
		] );

		$resultDirAsc = $this->doListWatchlistRawRequest( [ 'wrdir' => 'ascending' ] );
		$resultDirDesc = $this->doListWatchlistRawRequest( [ 'wrdir' => 'descending' ] );

		$this->assertEquals(
			[
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
				],
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
				],
				[
					'ns' => NS_TALK,
					'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1',
				],
			],
			$this->getItemsFromApiResponse( $resultDirAsc )
		);

		$this->assertEquals(
			[
				[
					'ns' => NS_TALK,
					'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1',
				],
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
				],
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
				],
			],
			$this->getItemsFromApiResponse( $resultDirDesc )
		);
	}

	public function testAscendingIsDefaultOrder() {
		$store = $this->getWatchedItemStore();

		$store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
			new TitleValue( NS_TALK, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
		] );

		$resultNoDir = $this->doListWatchlistRawRequest();
		$resultAscDir = $this->doListWatchlistRawRequest( [ 'wrdir' => 'ascending' ] );

		$this->assertEquals(
			$this->getItemsFromApiResponse( $resultNoDir ),
			$this->getItemsFromApiResponse( $resultAscDir )
		);
	}

	public function testFromTitleParam() {
		$store = $this->getWatchedItemStore();

		$store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage3' ),
		] );

		$result = $this->doListWatchlistRawRequest( [
			'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
		] );

		$this->assertEquals(
			[
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
				],
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage3',
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testToTitleParam() {
		$store = $this->getWatchedItemStore();

		$store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage3' ),
		] );

		$result = $this->doListWatchlistRawRequest( [
			'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
		] );

		$this->assertEquals(
			[
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
				],
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testContinueParam() {
		$store = $this->getWatchedItemStore();

		$store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage3' ),
		] );

		$firstResult = $this->doListWatchlistRawRequest( [ 'wrlimit' => 2 ] );
		$continuationParam = $firstResult[0]['continue']['wrcontinue'];

		$this->assertSame( '0|ApiQueryWatchlistRawIntegrationTestPage3', $continuationParam );

		$continuedResult = $this->doListWatchlistRawRequest( [ 'wrcontinue' => $continuationParam ] );

		$this->assertEquals(
			[
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage3',
				]
			],
			$this->getItemsFromApiResponse( $continuedResult )
		);
	}

	public static function fromTitleToTitleContinueComboProvider() {
		return [
			[
				[
					'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
					'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
				],
				[
					[ 'ns' => NS_MAIN, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1' ],
					[ 'ns' => NS_MAIN, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2' ],
				],
			],
			[
				[
					'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
					'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage3',
				],
				[
					[ 'ns' => NS_MAIN, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ],
				],
			],
			[
				[
					'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage3',
					'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage2',
				],
				[
					[ 'ns' => NS_MAIN, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2' ],
					[ 'ns' => NS_MAIN, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ],
				],
			],
			[
				[
					'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
					'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage3',
					'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage3',
				],
				[
					[ 'ns' => NS_MAIN, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ],
				],
			],
		];
	}

	/**
	 * @dataProvider fromTitleToTitleContinueComboProvider
	 */
	public function testFromTitleToTitleContinueCombo( array $params, array $expectedItems ) {
		$store = $this->getWatchedItemStore();

		$store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage3' ),
		] );

		$result = $this->doListWatchlistRawRequest( $params );

		$this->assertEquals( $expectedItems, $this->getItemsFromApiResponse( $result ) );
	}

	public static function fromTitleToTitleContinueSelfContradictoryComboProvider() {
		return [
			[
				[
					'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
					'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
				]
			],
			[
				[
					'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
					'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
					'wrdir' => 'descending',
				]
			],
			[
				[
					'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
					'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage2',
				]
			],
		];
	}

	/**
	 * @dataProvider fromTitleToTitleContinueSelfContradictoryComboProvider
	 */
	public function testFromTitleToTitleContinueSelfContradictoryCombo( array $params ) {
		$store = $this->getWatchedItemStore();

		$store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
		] );

		$result = $this->doListWatchlistRawRequest( $params );

		$this->assertSame( [], $this->getItemsFromApiResponse( $result ) );
		$this->assertArrayNotHasKey( 'continue', $result[0] );
	}

	public function testOwnerAndTokenParams() {
		$services = $this->getServiceContainer();
		$userOptionsManager = $services->getUserOptionsManager();

		$otherUser = $this->getNotLoggedInTestUser();
		$userOptionsManager->setOption( $otherUser, 'watchlisttoken', '1234567890' );
		$otherUser->saveSettings();

		$store = $this->getWatchedItemStore();
		$store->addWatchBatchForUser( $otherUser, [
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
			new TitleValue( NS_TALK, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
		] );

		$services->getMainWANObjectCache()->clearProcessCache();
		$result = $this->doListWatchlistRawRequest( [
			'wrowner' => $otherUser->getName(),
			'wrtoken' => '1234567890',
		] );

		$this->assertEquals(
			[
				[
					'ns' => NS_MAIN,
					'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
				],
				[
					'ns' => NS_TALK,
					'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1',
				],
			],
			$this->getItemsFromApiResponse( $result )
		);
	}

	public function testOwnerAndTokenParams_wrongToken() {
		$userOptionsManager = $this->getServiceContainer()->getUserOptionsManager();

		$otherUser = $this->getNotLoggedInTestUser();
		$userOptionsManager->setOption( $otherUser, 'watchlisttoken', '1234567890' );
		$otherUser->saveSettings();

		$this->expectApiErrorCode( 'bad_wltoken' );

		$this->doListWatchlistRawRequest( [
			'wrowner' => $otherUser->getName(),
			'wrtoken' => 'wrong-token',
		] );
	}

	public function testOwnerAndTokenParams_userHasNoWatchlistToken() {
		$this->expectApiErrorCode( 'bad_wltoken' );

		$this->doListWatchlistRawRequest( [
			'wrowner' => $this->getNotLoggedInTestUser()->getName(),
			'wrtoken' => 'some-watchlist-token',
		] );
	}

	public function testGeneratorWatchlistRawPropInfo_returnsWatchedItems() {
		$store = $this->getWatchedItemStore();
		$store->addWatch(
			$this->getLoggedInTestUser(),
			new TitleValue( NS_MAIN, 'ApiQueryWatchlistRawIntegrationTestPage' )
		);

		$result = $this->doGeneratorWatchlistRawRequest( [ 'prop' => 'info' ] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'pages', $result[0]['query'] );
		$this->assertCount( 1, $result[0]['query']['pages'] );

		// $result[0]['query']['pages'] uses page ids as keys
		$item = array_values( $result[0]['query']['pages'] )[0];

		$this->assertSame( NS_MAIN, $item['ns'] );
		$this->assertEquals( 'ApiQueryWatchlistRawIntegrationTestPage', $item['title'] );
	}

}
PK       ! b    "  api/query/ApiQueryDisabledTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Tests\Api\ApiTestCase;

/**
 * @group API
 * @group medium
 *
 * @covers MediaWiki\Api\ApiQueryDisabled
 */
class ApiQueryDisabledTest extends ApiTestCase {
	public function testDisabled() {
		$this->mergeMwGlobalArrayValue( 'wgAPIPropModules',
			[ 'categories' => 'ApiQueryDisabled' ] );

		$data = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'categories',
		] );

		$this->assertArrayHasKey( 'warnings', $data[0] );
		$this->assertArrayHasKey( 'categories', $data[0]['warnings'] );
		$this->assertArrayHasKey( 'warnings', $data[0]['warnings']['categories'] );

		$this->assertEquals( 'The "categories" module has been disabled.',
			$data[0]['warnings']['categories']['warnings'] );
	}
}
PK       ! iN       api/query/ApiQueryTokensTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Tests\Api\ApiTestCase;

/**
 * @group API
 * @group medium
 * @covers MediaWiki\Api\ApiQueryTokens
 */
class ApiQueryTokensTest extends ApiTestCase {

	public function testGetCsrfToken() {
		$params = [
			'action' => 'query',
			'meta' => 'tokens',
			'type' => 'csrf',
		];

		$apiResult = $this->doApiRequest( $params );
		$this->assertArrayHasKey( 'query', $apiResult[0] );
		$this->assertArrayHasKey( 'tokens', $apiResult[0]['query'] );
		$this->assertArrayHasKey( 'csrftoken', $apiResult[0]['query']['tokens'] );
		$this->assertStringEndsWith( '+\\', $apiResult[0]['query']['tokens']['csrftoken'] );
	}

	public function testGetAllTokens() {
		$params = [
			'action' => 'query',
			'meta' => 'tokens',
			'type' => '*',
		];

		$apiResult = $this->doApiRequest( $params );
		$this->assertArrayHasKey( 'query', $apiResult[0] );
		$this->assertArrayHasKey( 'tokens', $apiResult[0]['query'] );

		// MW core has 7 token types (createaccount, csrf, login, patrol, rollback, userrights, watch)
		$this->assertGreaterThanOrEqual( 7, count( $apiResult[0]['query']['tokens'] ) );
	}

	public function testContinuation(): void {
		// one token is 42 characters, so 100 is enough for 2 tokens but not 3
		$size = 100;
		$this->overrideConfigValue( MainConfigNames::APIMaxResultSize, $size );

		[ $result ] = $this->doApiRequest( [
			'action' => 'query',
			'meta' => 'tokens',
			'type' => 'csrf|patrol|watch',
		] );

		$this->assertSame(
			$this->apiContext->msg( 'apiwarn-truncatedresult', Message::numParam( $size ) )
				->text(),
			$result['warnings']['result']['warnings']
		);

		$this->assertSame( [ 'csrftoken', 'patroltoken' ], array_keys( $result['query']['tokens'] ) );
		$this->assertTrue( $result['batchcomplete'], 'batchcomplete should be true' );
		$this->assertSame( [ 'type' => 'watch', 'continue' => '-||' ], $result['continue'] );
	}

}
PK       ! r  r  "  api/query/ApiQuerySiteinfoTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Language\LanguageCode;
use MediaWiki\Language\LanguageConverter;
use MediaWiki\MainConfigNames;
use MediaWiki\MainConfigSchema;
use MediaWiki\Message\Message;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\SiteStats\SiteStats;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use Skin;
use Wikimedia\Composer\ComposerInstalled;
use Wikimedia\Rdbms\LoadBalancer;
use Wikimedia\TestingAccessWrapper;

/**
 * @group API
 * @group medium
 * @group Database
 *
 * @covers MediaWiki\Api\ApiQuerySiteinfo
 */
class ApiQuerySiteinfoTest extends ApiTestCase {
	use TempUserTestTrait;

	/** @var array[]|null */
	private $originalRegistryLoaded = null;

	protected function tearDown(): void {
		if ( $this->originalRegistryLoaded !== null ) {
			$reg = TestingAccessWrapper::newFromObject( ExtensionRegistry::getInstance() );
			$reg->loaded = $this->originalRegistryLoaded;
			$this->originalRegistryLoaded = null;
		}
		parent::tearDown();
	}

	// We don't try to test every single thing for every category, just a sample
	protected function doQuery( $siprop = null, $extraParams = [] ) {
		$params = [ 'action' => 'query', 'meta' => 'siteinfo' ];
		if ( $siprop !== null ) {
			$params['siprop'] = $siprop;
		}
		$params = array_merge( $params, $extraParams );

		$res = $this->doApiRequest( $params );

		$this->assertArrayNotHasKey( 'warnings', $res[0] );
		$this->assertCount( 1, $res[0]['query'] );

		return $res[0]['query'][$siprop === null ? 'general' : $siprop];
	}

	public function testGeneral() {
		$this->overrideConfigValues( [
			MainConfigNames::AllowExternalImagesFrom => '//localhost/',
			MainConfigNames::MainPageIsDomainRoot => true,
		] );

		$data = $this->doQuery();

		$this->assertSame( Title::newMainPage()->getPrefixedText(), $data['mainpage'] );
		$this->assertSame( PHP_VERSION, $data['phpversion'] );
		$this->assertSame( [ '//localhost/' ], $data['externalimages'] );
		$this->assertTrue( $data['mainpageisdomainroot'] );
	}

	public function testLinkPrefixCharset() {
		$contLang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'ar' );
		$this->setContentLang( $contLang );
		$this->assertTrue( $contLang->linkPrefixExtension() );

		$data = $this->doQuery();

		$this->assertSame( $contLang->linkPrefixCharset(), $data['linkprefixcharset'] );
	}

	public function testVariants() {
		$contLang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'zh' );
		$converter = $this->getServiceContainer()->getLanguageConverterFactory()->getLanguageConverter( $contLang );
		$this->setContentLang( $contLang );
		$this->assertTrue( $converter->hasVariants() );

		$data = $this->doQuery();

		$expected = array_map(
			static function ( $code ) use ( $contLang ) {
				return [ 'code' => $code, 'name' => $contLang->getVariantname( $code ) ];
			},
			$converter->getVariants()
		);

		$this->assertSame( $expected, $data['variants'] );
	}

	public function testReadOnly() {
		// Create the test user before making the DB readonly
		$this->getTestSysop()->getUser();
		$svc = $this->getServiceContainer()->getReadOnlyMode();
		$svc->setReason( 'Need more donations' );
		try {
			$data = $this->doQuery();
		} finally {
			$svc->setReason( false );
		}

		$this->assertTrue( $data['readonly'] );
		$this->assertSame( 'Need more donations', $data['readonlyreason'] );
	}

	public function testNamespacesBasic() {
		$this->assertSame(
			array_keys( $this->getServiceContainer()->getContentLanguage()->getFormattedNamespaces() ),
			array_keys( $this->doQuery( 'namespaces' ) )
		);
	}

	public function testNamespacesExtraNS() {
		$this->overrideConfigValue( MainConfigNames::ExtraNamespaces, [ '138' => 'Testing' ] );
		$this->assertSame(
			array_keys( $this->getServiceContainer()->getContentLanguage()->getFormattedNamespaces() ),
			array_keys( $this->doQuery( 'namespaces' ) )
		);
	}

	public function testNamespacesProtection() {
		$this->overrideConfigValue(
			MainConfigNames::NamespaceProtection,
			[
				'0' => '',
				'2' => [ '' ],
				'4' => 'editsemiprotected',
				'8' => [
					'editinterface',
					'noratelimit'
				],
				'14' => [
					'move-categorypages',
					''
				]
			]
		);
		$data = $this->doQuery( 'namespaces' );
		$this->assertArrayNotHasKey( 'namespaceprotection', $data['0'] );
		$this->assertArrayNotHasKey( 'namespaceprotection', $data['2'] );
		$this->assertSame( 'editsemiprotected', $data['4']['namespaceprotection'] );
		$this->assertSame( 'editinterface|noratelimit', $data['8']['namespaceprotection'] );
		$this->assertSame( 'move-categorypages', $data['14']['namespaceprotection'] );
	}

	public function testNamespaceAliases() {
		// XXX: why does this fail when the en-x-piglatin variant is enabled?
		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, false );

		$expected = $this->getServiceContainer()->getContentLanguage()->getNamespaceAliases();
		$expected = array_map(
			static function ( $key, $val ) {
				return [ 'id' => $val, 'alias' => strtr( $key, '_', ' ' ) ];
			},
			array_keys( $expected ),
			$expected
		);

		$this->assertSame( $expected, $this->doQuery( 'namespacealiases' ) );
	}

	public function testSpecialPageAliases() {
		$this->assertSameSize(
			$this->getServiceContainer()->getSpecialPageFactory()->getNames(),
			$this->doQuery( 'specialpagealiases' )
		);
	}

	public function testMagicWords() {
		$this->assertSameSize(
			$this->getServiceContainer()->getContentLanguage()->getMagicWords(),
			$this->doQuery( 'magicwords' )
		);
	}

	/**
	 * @dataProvider interwikiMapProvider
	 */
	public function testInterwikiMap( $filter ) {
		$this->overrideConfigValues( [
			MainConfigNames::ExtraInterlanguageLinkPrefixes => [ 'self' ],
			MainConfigNames::ExtraLanguageNames => [ 'self' => 'Recursion' ],
			MainConfigNames::LocalInterwikis => [ 'self' ],
			MainConfigNames::Server => 'https://local.example',
			MainConfigNames::ScriptPath => '/w',
		] );

		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'interwiki' )
			->ignore()
			->row( [
				'iw_prefix' => 'self',
				'iw_url' => 'https://local.example/w/index.php?title=$1',
				'iw_api' => 'https://local.example/w/api.php',
				'iw_wikiid' => 'somedbname',
				'iw_local' => true,
				'iw_trans' => true,
			] )
			->row( [
				'iw_prefix' => 'foreign',
				'iw_url' => '//foreign.example/wiki/$1',
				'iw_api' => '',
				'iw_wikiid' => '',
				'iw_local' => false,
				'iw_trans' => false,
			] )
			->caller( __METHOD__ )
			->execute();

		$this->getServiceContainer()->getMessageCache()->enable();

		$this->editPage( 'MediaWiki:Interlanguage-link-self', 'Self!' );
		$this->editPage( 'MediaWiki:Interlanguage-link-sitename-self', 'Circular logic' );

		$expected = [];

		if ( $filter === null || $filter === '!local' ) {
			$expected[] = [
				'prefix' => 'foreign',
				'url' => 'http://foreign.example/wiki/$1',
				'protorel' => true,
			];
		}
		if ( $filter === null || $filter === 'local' ) {
			$expected[] = [
				'prefix' => 'self',
				'local' => true,
				'trans' => true,
				'language' => 'Recursion',
				'bcp47' => 'self',
				'localinterwiki' => true,
				'extralanglink' => true,
				'code' => 'self',
				'linktext' => 'Self!',
				'sitename' => 'Circular logic',
				'url' => 'https://local.example/w/index.php?title=$1',
				'protorel' => false,
				'wikiid' => 'somedbname',
				'api' => 'https://local.example/w/api.php',
			];
		}

		$data = $this->doQuery( 'interwikimap',
			$filter === null ? [] : [ 'sifilteriw' => $filter ] );

		$this->assertSame( $expected, $data );
	}

	public static function interwikiMapProvider() {
		return [ [ 'local' ], [ '!local' ], [ null ] ];
	}

	/**
	 * @dataProvider dbReplLagProvider
	 */
	public function testDbReplLagInfo( $showHostnames, $includeAll ) {
		if ( !$showHostnames && $includeAll ) {
			$this->expectApiErrorCode( 'includeAllDenied' );
		}

		// Force creation of the test user before mocking the database.
		$this->getTestSysop()->getUser();

		$mockLB = $this->createNoOpMock( LoadBalancer::class, [ 'getMaxLag', 'getLagTimes',
			'getServerName', 'getLocalDomainID' ] );
		$mockLB->method( 'getMaxLag' )->willReturn( [ null, 7, 1 ] );
		$mockLB->method( 'getLagTimes' )->willReturn( [ 5, 7 ] );
		$mockLB->method( 'getServerName' )->willReturnMap( [
			[ 0, 'apple' ],
			[ 1, 'carrot' ]
		] );
		$mockLB->method( 'getLocalDomainID' )->willReturn( 'testdomain' );
		$this->setService( 'DBLoadBalancer', $mockLB );

		$this->overrideConfigValue( MainConfigNames::ShowHostnames, $showHostnames );

		$expected = [];
		if ( $includeAll ) {
			$expected[] = [ 'host' => $showHostnames ? 'apple' : '', 'lag' => 5 ];
		}
		$expected[] = [ 'host' => $showHostnames ? 'carrot' : '', 'lag' => 7 ];

		$data = $this->doQuery( 'dbrepllag', $includeAll ? [ 'sishowalldb' => '' ] : [] );

		$this->assertSame( $expected, $data );
	}

	public static function dbReplLagProvider() {
		return [
			'no hostnames, no showalldb' => [ false, false ],
			'no hostnames, showalldb' => [ false, true ],
			'hostnames, no showalldb' => [ true, false ],
			'hostnames, showalldb' => [ true, true ]
		];
	}

	public function testStatistics() {
		$this->setTemporaryHook( 'APIQuerySiteInfoStatisticsInfo',
			static function ( &$data ) {
				$data['addedstats'] = 42;
			}
		);

		$expected = [
			'pages' => intval( SiteStats::pages() ),
			'articles' => intval( SiteStats::articles() ),
			'edits' => intval( SiteStats::edits() ),
			'images' => intval( SiteStats::images() ),
			'users' => intval( SiteStats::users() ),
			'activeusers' => intval( SiteStats::activeUsers() ),
			'admins' => intval( SiteStats::numberingroup( 'sysop' ) ),
			'jobs' => intval( SiteStats::jobs() ),
			'addedstats' => 42,
		];

		$this->assertSame( $expected, $this->doQuery( 'statistics' ) );
	}

	/**
	 * @dataProvider groupsProvider
	 */
	public function testUserGroups( $numInGroup ) {
		global $wgGroupPermissions, $wgAutopromote;

		$this->setGroupPermissions( 'viscount', 'perambulate', 'yes' );
		$this->setGroupPermissions( 'viscount', 'legislate', '0' );
		$this->overrideConfigValues( [
			MainConfigNames::AddGroups => [ 'viscount' => true, 'bot' => [] ],
			MainConfigNames::RemoveGroups => [ 'viscount' => [ 'sysop' ], 'bot' => [ '*', 'earl' ] ],
			MainConfigNames::GroupsAddToSelf => [ 'bot' => [ 'bureaucrat', 'sysop' ] ],
			MainConfigNames::GroupsRemoveFromSelf => [ 'bot' => [ 'bot' ] ],
		] );

		$data = $this->doQuery( 'usergroups', $numInGroup ? [ 'sinumberingroup' => '' ] : [] );

		$names = array_column( $data, 'name' );

		$this->assertSame( array_keys( $wgGroupPermissions ), $names );
		$userAllGroups = $this->getServiceContainer()->getUserGroupManager()->listAllGroups();

		foreach ( $data as $val ) {
			if ( !$numInGroup ) {
				$expectedSize = null;
			} elseif ( $val['name'] === 'user' ) {
				$expectedSize = SiteStats::users();
			} elseif ( $val['name'] === '*' || isset( $wgAutopromote[$val['name']] ) ) {
				$expectedSize = null;
			} else {
				$expectedSize = SiteStats::numberingroup( $val['name'] );
			}

			if ( $expectedSize === null ) {
				$this->assertArrayNotHasKey( 'number', $val );
			} else {
				$this->assertSame( $expectedSize, $val['number'] );
			}

			if ( $val['name'] === 'viscount' ) {
				$this->assertSame( [ 'perambulate' ], $val['rights'] );
				$this->assertSame( $userAllGroups, $val['add'] );
			} elseif ( $val['name'] === 'bot' ) {
				$this->assertArrayNotHasKey( 'add', $val );
				$this->assertArrayNotHasKey( 'remove', $val );
				$this->assertSame( [ 'bureaucrat', 'sysop' ], $val['add-self'] );
				$this->assertSame( [ 'bot' ], $val['remove-self'] );
			}
		}
	}

	public function testAutoCreateTempUser() {
		$this->disableAutoCreateTempUser( [ 'reservedPattern' => null ] );
		$this->assertSame(
			[ 'enabled' => false ],
			$this->doQuery( 'autocreatetempuser' ),
			'When disabled, no other properties are present'
		);

		$this->enableAutoCreateTempUser( [
			'reservedPattern' => null,
		] );
		$this->assertArrayEquals(
			[
				'enabled' => true,
				'matchPatterns' => [ '~$1' ],
			],
			$this->doQuery( 'autocreatetempuser' ),
			false,
			true,
			'When enabled, some properties are filled in or cleaned up'
		);
	}

	public function testFileExtensions() {
		// Add duplicate
		$this->overrideConfigValue( MainConfigNames::FileExtensions, [ 'png', 'gif', 'jpg', 'png' ] );

		$expected = [ [ 'ext' => 'png' ], [ 'ext' => 'gif' ], [ 'ext' => 'jpg' ] ];

		$this->assertSame( $expected, $this->doQuery( 'fileextensions' ) );
	}

	public static function groupsProvider() {
		return [
			'numingroup' => [ true ],
			'nonumingroup' => [ false ],
		];
	}

	public function testInstalledLibraries() {
		// @todo Test no installed.json?  Moving installed.json to a different name temporarily
		// seems a bit scary, but I don't see any other way to do it.
		//
		// @todo Install extensions/skins somehow so that we can test they're filtered out
		global $IP;

		$path = "$IP/vendor/composer/installed.json";
		if ( !is_file( $path ) ) {
			$this->markTestSkipped( 'No installed libraries' );
		}

		$expected = ( new ComposerInstalled( $path ) )->getInstalledDependencies();

		$expected = array_filter( $expected,
			static function ( $info ) {
				return !str_starts_with( $info['type'], 'mediawiki-' );
			}
		);

		$expected = array_map(
			static function ( $name, $info ) {
				return [ 'name' => $name, 'version' => $info['version'] ];
			},
			array_keys( $expected ),
			array_values( $expected )
		);

		$this->assertSame( $expected, $this->doQuery( 'libraries' ) );
	}

	public function testExtensions() {
		$tmpdir = $this->getNewTempDirectory();
		touch( "$tmpdir/ErsatzExtension.php" );
		touch( "$tmpdir/LICENSE" );
		touch( "$tmpdir/AUTHORS.txt" );

		$val = [
			'path' => "$tmpdir/ErsatzExtension.php",
			'name' => 'Ersatz Extension',
			'namemsg' => 'ersatz-extension-name',
			'author' => 'John Smith',
			'version' => '0.0.2',
			'url' => 'https://www.example.com/software/ersatz-extension',
			'description' => 'An extension that is not what it seems.',
			'descriptionmsg' => 'ersatz-extension-desc',
			'license-name' => 'PD',
		];

		$this->overrideConfigValue( MainConfigNames::ExtensionCredits, [ 'api' => [
			$val,
			[
				'author' => [ 'John Smith', 'John Smith Jr.', '...' ],
				'descriptionmsg' => [ 'another-extension-desc', 'param' ] ],
		] ] );
		// Make the main registry empty
		// TODO: Make ExtensionRegistry an injected service?
		$reg = TestingAccessWrapper::newFromObject( ExtensionRegistry::getInstance() );
		$this->originalRegistryLoaded = $reg->loaded;
		$reg->loaded = [];

		$data = $this->doQuery( 'extensions' );

		$this->assertCount( 2, $data );

		$this->assertSame( 'api', $data[0]['type'] );

		$sharedKeys = [ 'name', 'namemsg', 'description', 'descriptionmsg', 'author', 'url',
			'version', 'license-name' ];
		foreach ( $sharedKeys as $key ) {
			$this->assertSame( $val[$key], $data[0][$key] );
		}

		// @todo Test git info

		$this->assertSame(
			Title::makeTitle( NS_SPECIAL, 'Version/License/Ersatz Extension' )->getLinkURL(),
			$data[0]['license']
		);

		$this->assertSame(
			Title::makeTitle( NS_SPECIAL, 'Version/Credits/Ersatz Extension' )->getLinkURL(),
			$data[0]['credits']
		);

		$this->assertSame( 'another-extension-desc', $data[1]['descriptionmsg'] );
		$this->assertSame( [ 'param' ], $data[1]['descriptionmsgparams'] );
		$this->assertSame( 'John Smith, John Smith Jr., ...', $data[1]['author'] );
	}

	/**
	 * @dataProvider rightsInfoProvider
	 */
	public function testRightsInfo( $page, $url, $text, $expectedUrl, $expectedText ) {
		$this->overrideConfigValues( [
			MainConfigNames::Server => 'https://local.example',
			MainConfigNames::RightsPage => $page,
			MainConfigNames::RightsUrl => $url,
			MainConfigNames::RightsText => $text,
		] );
		$this->assertSame(
			[ 'url' => $expectedUrl, 'text' => $expectedText ],
			$this->doQuery( 'rightsinfo' )
		);

		// The installer sets these options to empty string if not specified otherwise,
		// test that this behaves the same as null.
		$this->overrideConfigValues( [
			MainConfigNames::RightsPage => $page ?? '',
			MainConfigNames::RightsUrl => $url ?? '',
			MainConfigNames::RightsText => $text ?? '',
		] );
		$this->assertSame(
			[ 'url' => $expectedUrl, 'text' => $expectedText ],
			$this->doQuery( 'rightsinfo' ),
			'empty string behaves the same as null'
		);
	}

	public static function rightsInfoProvider() {
		$licenseTitleUrl = 'https://local.example/wiki/License';
		$licenseUrl = 'http://license.example/';

		return [
			'No rights info' => [ null, null, null, '', '' ],
			'Only page' => [ 'License', null, null, $licenseTitleUrl, 'License' ],
			'Only URL' => [ null, $licenseUrl, null, $licenseUrl, '' ],
			'Only text' => [ null, null, '!!!', '', '!!!' ],
			// URL is ignored if page is specified
			'Page and URL' => [ 'License', $licenseUrl, null, $licenseTitleUrl, 'License' ],
			'URL and text' => [ null, $licenseUrl, '!!!', $licenseUrl, '!!!' ],
			'Page and text' => [ 'License', null, '!!!', $licenseTitleUrl, '!!!' ],
			'Page and URL and text' => [ 'License', $licenseUrl, '!!!', $licenseTitleUrl, '!!!' ],
			'Pagename "0"' => [ '0', null, null, 'https://local.example/wiki/0', '0' ],
			'URL "0"' => [ null, '0', null, '0', '' ],
			'Text "0"' => [ null, null, '0', '', '0' ],
		];
	}

	public function testRestrictions() {
		global $wgRestrictionTypes, $wgRestrictionLevels, $wgCascadingRestrictionLevels,
			$wgSemiprotectedRestrictionLevels;

		$this->assertSame( [
			'types' => $wgRestrictionTypes,
			'levels' => $wgRestrictionLevels,
			'cascadinglevels' => $wgCascadingRestrictionLevels,
			'semiprotectedlevels' => $wgSemiprotectedRestrictionLevels,
		], $this->doQuery( 'restrictions' ) );
	}

	/**
	 * @dataProvider languagesProvider
	 */
	public function testLanguages( $langCode ) {
		$expected = $this->getServiceContainer()
			->getLanguageNameUtils()
			->getLanguageNames( (string)$langCode );

		$expected = array_map(
			static function ( $code, $name ) {
				return [
					'code' => $code,
					'bcp47' => LanguageCode::bcp47( $code ),
					'name' => $name
				];
			},
			array_keys( $expected ),
			array_values( $expected )
		);

		$data = $this->doQuery( 'languages',
			$langCode !== null ? [ 'siinlanguagecode' => $langCode ] : [] );

		$this->assertSame( $expected, $data );
	}

	public static function languagesProvider() {
		return [ [ null ], [ 'fr' ] ];
	}

	public function testLanguageVariants() {
		$expectedKeys = array_filter( LanguageConverter::$languagesWithVariants,
			function ( $langCode ) {
				$lang = $this->getServiceContainer()->getLanguageFactory()
					->getLanguage( $langCode );
				$converter = $this->getServiceContainer()->getLanguageConverterFactory()
					->getLanguageConverter( $lang );
					return $converter->hasVariants();
			}
		);
		sort( $expectedKeys );

		$this->assertSame( $expectedKeys, array_keys( $this->doQuery( 'languagevariants' ) ) );
	}

	public function testLanguageVariantsDisabled() {
		$this->overrideConfigValue( MainConfigNames::DisableLangConversion, true );

		$this->assertSame( [], $this->doQuery( 'languagevariants' ) );
	}

	/**
	 * @todo Test a skin with a description that's known to be different in a different language.
	 *   Vector will do, but it's not installed by default.
	 *
	 * @todo Test that an invalid language code doesn't actually try reading any messages
	 *
	 * @dataProvider skinsProvider
	 */
	public function testSkins( $code ) {
		$data = $this->doQuery( 'skins', $code !== null ? [ 'siinlanguagecode' => $code ] : [] );
		$services = $this->getServiceContainer();
		$skinFactory = $services->getSkinFactory();
		$skinNames = $skinFactory->getInstalledSkins();
		$expectedAllowed = $skinFactory->getAllowedSkins();
		$expectedDefault = Skin::normalizeKey( 'default' );
		$languageNameUtils = $services->getLanguageNameUtils();

		$i = 0;
		foreach ( $skinNames as $name => $displayName ) {
			$this->assertSame( $name, $data[$i]['code'] );

			$msg = wfMessage( "skinname-$name" );
			if ( $code && $languageNameUtils->isValidCode( $code ) ) {
				$msg->inLanguage( $code );
			} else {
				$msg->inContentLanguage();
			}
			if ( $msg->exists() ) {
				$displayName = $msg->text();
			}
			$this->assertSame( $displayName, $data[$i]['name'] );

			if ( !isset( $expectedAllowed[$name] ) ) {
				$this->assertTrue( $data[$i]['unusable'], "$name must be unusable" );
			}
			if ( $name === $expectedDefault ) {
				$this->assertTrue( $data[$i]['default'], "$expectedDefault must be default" );
			}
			$i++;
		}
	}

	public function skinsProvider() {
		return [
			'No language specified' => [ null ],
			'Czech' => [ 'cs' ],
			'Invalid language' => [ '/invalid/' ],
		];
	}

	public function testExtensionTags() {
		$expected = array_map(
			static function ( $tag ) {
				return "<$tag>";
			},
			$this->getServiceContainer()->getParser()->getTags()
		);

		$this->assertSame( $expected, $this->doQuery( 'extensiontags' ) );
	}

	public function testFunctionHooks() {
		$this->assertSame( $this->getServiceContainer()->getParser()->getFunctionHooks(),
			$this->doQuery( 'functionhooks' ) );
	}

	public function testVariables() {
		$this->assertSame(
			$this->getServiceContainer()->getMagicWordFactory()->getVariableIDs(),
			$this->doQuery( 'variables' )
		);
	}

	public function testProtocols() {
		$urlProtocol = MainConfigSchema::getDefaultValue(
			MainConfigNames::UrlProtocols
		);
		$this->assertSame( $urlProtocol, $this->doQuery( 'protocols' ) );
	}

	public function testDefaultOptions() {
		$this->assertSame(
			$this->getServiceContainer()->getUserOptionsLookup()->getDefaultOptions(),
			$this->doQuery( 'defaultoptions' )
		);
	}

	public function testUploadDialog() {
		global $wgUploadDialog;

		$this->assertSame( $wgUploadDialog, $this->doQuery( 'uploaddialog' ) );
	}

	public function testGetHooks() {
		// Make sure there's something to report on
		$this->setTemporaryHook( 'somehook',
			static function () {
			}
		);

		$hookContainer = $this->getServiceContainer()->getHookContainer();
		$expectedNames = $hookContainer->getHookNames();
		$actualNames = array_column( $this->doQuery( 'showhooks' ), 'name' );

		$this->assertArrayEquals( $expectedNames, $actualNames );
	}

	public function testContinuation() {
		// Use $wgUrlProtocols as easy example for forging the
		// size of the API response
		$protocol = 'foo://';
		$size = strlen( $protocol );
		$protocols = [ $protocol ];

		$this->overrideConfigValues( [
			MainConfigNames::UrlProtocols => [ $protocol ],
			MainConfigNames::APIMaxResultSize => $size,
		] );

		$res = $this->doApiRequest( [
			'action' => 'query',
			'meta' => 'siteinfo',
			'siprop' => 'protocols|languages',
		] );

		$this->assertSame(
			wfMessage( 'apiwarn-truncatedresult', Message::numParam( $size ) )
				->text(),
			$res[0]['warnings']['result']['warnings']
		);

		$this->assertSame( $protocols, $res[0]['query']['protocols'] );
		$this->assertArrayNotHasKey( 'languages', $res[0] );
		$this->assertTrue( $res[0]['batchcomplete'], 'batchcomplete should be true' );
		$this->assertSame( [ 'siprop' => 'languages', 'continue' => '-||' ], $res[0]['continue'] );
	}

	/**
	 * @dataProvider provideAutopromote
	 */
	public function testAutopromote( $config, $expected ) {
		$this->overrideConfigValues( [
			MainConfigNames::Autopromote => $config,
			MainConfigNames::AutoConfirmCount => 10,
			MainConfigNames::AutoConfirmAge => 345600,
		] );
		$this->assertSame( $expected, $this->doQuery( 'autopromote' ) );
	}

	public static function provideAutopromote() {
		yield 'simple' => [
			[
				// edit count >= 10 and age >= 4 days
				'simple' => [ '&',
					[ APCOND_EDITCOUNT, 10 ],
					[ APCOND_AGE, 345600 ],
				],
			],
			[
				'simple' => [
					'operand' => '&',
					0 => [
						'condname' => 'APCOND_EDITCOUNT',
						'params' => [ 10 ]
					],
					1 => [
						'condname' => 'APCOND_AGE',
						'params' => [ 345600 ]
					]
				]
			]
		];

		// Test case to check default of null is replaced with value of appropriate $wg
		yield 'simple-use-wg' => [
			[
				'simple-use-wg' => [ '&',
					[ APCOND_EDITCOUNT, null ],
					[ APCOND_AGE, null ],
				],
			],
			[
				'simple-use-wg' => [
					'operand' => '&',
					0 => [
						'condname' => 'APCOND_EDITCOUNT',
						'params' => [ 10 ]
					],
					1 => [
						'condname' => 'APCOND_AGE',
						'params' => [ 345600 ]
					]
				]
			]
		];

		yield 'trivial' => [
			[
				'trivial' => APCOND_EMAILCONFIRMED,
			],
			[
				'trivial' => [
					0 => [
						'condname' => 'APCOND_EMAILCONFIRMED',
						'params' => []
					]
				],
			]
		];

		yield 'multiple-copies-of-condition' => [
			[
				// In both groups 'foo' and 'bar', or in group 'baz'
				'multiple-copies-of-condition' => [ '|',
					[ APCOND_INGROUPS, 'foo', 'bar' ],
					[ APCOND_INGROUPS, 'baz' ],
				],
			],
			[
				'multiple-copies-of-condition' => [
					'operand' => '|',
					0 => [
						'condname' => 'APCOND_INGROUPS',
						'params' => [ 'foo', 'bar' ]
					],
					1 => [
						'condname' => 'APCOND_INGROUPS',
						'params' => [ 'baz' ]
					]
				]
			]
		];

		yield 'complicated' => [
			[
				// confirmed email, or their edit count >= 100 and either they
				// created their account a year ago or it has been 10 days since
				// their first edit, or if they're in both groups 'group1' and
				// 'group2'. Except for users in the 'bad' group.
				'complicated' => [ '&',
					[ '|',
						APCOND_EMAILCONFIRMED,
						[ '&',
							[ APCOND_EDITCOUNT, 100 ],
							[ '|',
								[ APCOND_AGE, 525600 * 60 ],
								[ APCOND_AGE_FROM_EDIT, 864000 ],
							],
						],
						[ APCOND_INGROUPS, 'group1', 'group2' ],
					],
					[ '!', [ APCOND_INGROUPS, 'bad' ] ],
				],
			],
			[
				'complicated' => [
					'operand' => '&',
					0 => [
						'operand' => '|',
						0 => [
							'condname' => 'APCOND_EMAILCONFIRMED',
							'params' => []
						],
						1 => [
							'operand' => '&',
							0 => [
								'condname' => 'APCOND_EDITCOUNT',
								'params' => [ 100 ]
							],
							1 => [
								'operand' => '|',
								0 => [
									'condname' => 'APCOND_AGE',
									'params' => [ 31536000 ]
								],
								1 => [
									'condname' => 'APCOND_AGE_FROM_EDIT',
									'params' => [ 864000 ]
								]
							]
						],
						2 => [
							'condname' => 'APCOND_INGROUPS',
							'params' => [ 'group1', 'group2' ]
						]
					],
					1 => [
						'operand' => '!',
						0 => [
							'condname' => 'APCOND_INGROUPS',
							'params' => [ 'bad' ]
						]
					]
				]
			]
		];

		// Find an undefined APCOND integer
		$constants = [];
		foreach ( get_defined_constants() as $k => $v ) {
			if ( strpos( $k, 'APCOND_' ) !== false ) {
				$constants[$v] = $k;
			}
		}
		$bogusCond = 9000;
		while ( isset( $constants[$bogusCond] ) ) {
			$bogusCond++;
		}
		$bogusCond2 = $bogusCond + 1;
		while ( isset( $constants[$bogusCond2] ) ) {
			$bogusCond2++;
		}

		yield 'bad-cond-1' => [
			[
				// unknown APCOND constant. Might be handled by an extension that
				// didn't define a constant with the expected name.
				'bad-cond' => 'bogus',
			],
			[
				'bad-cond' => [
					'bogus'
				],
			]
		];
		yield 'bad-cond-2' => [
			[
				'bad-cond' => $bogusCond,
			],
			[
				'bad-cond' => [
					0 => [
						'condname' => false,
						'params' => []
					]
				],
			]
		];
		yield 'bad-cond-3' => [
			[
				'bad-cond' => [ 'bogus', 'bogus?', APCOND_EMAILCONFIRMED, $bogusCond, $bogusCond2 ],
			],
			[
				'bad-cond' => [
					'bogus',
					'bogus?',
					APCOND_EMAILCONFIRMED,
					$bogusCond,
					$bogusCond2
				],
			]
		];
		yield 'bad-cond-4' => [
			[
				'bad-cond' => [ '&',
					'bogus1',
					'bogus2',
					APCOND_EMAILCONFIRMED,
					$bogusCond,
					$bogusCond2,
				],
			],
			[
				'bad-cond' => [
					'operand' => '&',
					0 => 'bogus1',
					1 => 'bogus2',
					2 => [
						'condname' => 'APCOND_EMAILCONFIRMED',
						'params' => []
					],
					3 => [
						'condname' => false,
						'params' => []
					],
					4 => [
						'condname' => false,
						'params' => []
					]
				]
			]
		];
	}

	public function testAutopromoteOnceDefault() {
		// PHP doesn't like empty nested arrays nested in arrays...
		$value = [
			'onEdit' => [],
			'onView' => [],
		];
		$this->testAutopromoteOnce( $value, $value );
	}

	/**
	 * @dataProvider provideAutopromoteOnce
	 */
	public function testAutopromoteOnce( $config, $expected ) {
		$this->overrideConfigValues( [
			MainConfigNames::AutopromoteOnce => $config,
			MainConfigNames::AutoConfirmCount => 10,
			MainConfigNames::AutoConfirmAge => 345600,
		] );
		$this->assertSame( $expected, $this->doQuery( 'autopromoteonce' ) );
	}

	public static function provideAutopromoteOnce() {
		yield 'simple' => [
			[
				'onEdit' => [
					// edit count >= 10 and age >= 4 days
					'simple' => [ '&',
						[ APCOND_EDITCOUNT, 10 ],
						[ APCOND_AGE, 345600 ],
					],
				],
			],
			[
				'onEdit' => [
					'simple' => [
						'operand' => '&',
						0 => [
							'condname' => 'APCOND_EDITCOUNT',
							'params' => [ 10 ]
						],
						1 => [
							'condname' => 'APCOND_AGE',
							'params' => [ 345600 ]
						]
					]
				]
			]
		];
	}
}
PK       ! \(    &  api/query/ApiQueryLanguageinfoTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Tests\Api\ApiTestCase;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @group API
 * @group medium
 *
 * @covers MediaWiki\Api\ApiQueryLanguageinfo
 */
class ApiQueryLanguageinfoTest extends ApiTestCase {

	protected function setUp(): void {
		parent::setUp();
		// register custom language names so this test is independent of CLDR
		$this->setTemporaryHook(
			'LanguageGetTranslatedLanguageNames',
			static function ( array &$names, $code ) {
				switch ( $code ) {
					case 'en':
						$names['sh'] = 'Serbo-Croatian';
						$names['qtp'] = 'a custom language code MediaWiki knows nothing about';
						break;
					case 'pt':
						$names['de'] = 'alemão';
						break;
				}
			}
		);
	}

	private function doQuery( array $params ): array {
		$params += [
			'action' => 'query',
			'meta' => 'languageinfo',
			'uselang' => 'en',
		];

		$res = $this->doApiRequest( $params );

		$this->assertArrayNotHasKey( 'warnings', $res[0] );

		return [ $res[0]['query']['languageinfo'], $res[0]['continue'] ?? null ];
	}

	public static function provideTestAllPropsForSingleLanguage() {
		yield [
			'sr',
			[
				'code' => 'sr',
				'bcp47' => 'sr',
				'autonym' => 'српски / srpski',
				'name' => 'српски / srpski',
				'fallbacks' => [ 'sr-ec', 'sr-cyrl', 'sr-el', 'sr-latn' ],
				'dir' => 'ltr',
				'variants' => [ 'sr', 'sr-ec', 'sr-el' ],
				'variantnames' => [
					'sr' => 'Ћир./lat.',
					'sr-ec' => 'Ћирилица',
					'sr-el' => 'Latinica',
				],
			]
		];

		yield [
			'qtp', // reserved for local use by ISO 639; registered in setUp()
			[
				'code' => 'qtp',
				'bcp47' => 'qtp',
				'autonym' => '',
				'name' => 'a custom language code MediaWiki knows nothing about',
				'fallbacks' => [],
				'dir' => 'ltr',
				'variants' => [ 'qtp' ],
				'variantnames' => [ 'qtp' => 'qtp' ],
			]
		];
	}

	/**
	 * @dataProvider provideTestAllPropsForSingleLanguage
	 */
	public function testAllPropsForSingleLanguage( string $langCode, array $expected ) {
		[ $response, $continue ] = $this->doQuery( [
			'liprop' => 'code|bcp47|dir|autonym|name|fallbacks|variants|variantnames',
			'licode' => $langCode,
		] );

		$this->assertArrayEquals( [ $langCode => $expected ], $response );
	}

	public function testNameInOtherLanguageForSingleLanguage() {
		[ $response, $continue ] = $this->doQuery( [
			'liprop' => 'name',
			'licode' => 'de',
			'uselang' => 'pt',
		] );

		$this->assertArrayEquals( [ 'de' => [ 'name' => 'alemão' ] ], $response );
	}

	/**
	 * Test ensures continuation is applied if the test runs for longer than allowed
	 *
	 * ApiQueryLanguageinfo::MAX_EXECUTE_SECONDS controls the speed the API has to have before
	 * applying continuation.
	 *
	 * @see T329609#8613954
	 */
	public function testContinuationNecessary() {
		$time = 0;
		ConvertibleTimestamp::setFakeTime( static function () use ( &$time ) {
			return $time += 1;
		} );

		[ $response, $continue ] = $this->doQuery( [] );

		$this->assertCount( 2, $response );
		$this->assertArrayHasKey( 'licontinue', $continue );
	}

	/**
	 * Test ensures continuation is applied if the test runs for longer than allowed
	 *
	 * ApiQueryLanguageinfo::MAX_EXECUTE_SECONDS controls the speed the API has to have before
	 * applying continuation.
	 *
	 * @see T329609#8613954
	 */
	public function testContinuationNotNecessary() {
		$time = 0;
		ConvertibleTimestamp::setFakeTime( static function () use ( &$time ) {
			return $time += 2;
		} );

		[ $response, $continue ] = $this->doQuery( [
			'licode' => 'de',
		] );

		$this->assertNull( $continue );
	}

	public function testContinuationInAlphabeticalOrderNotParameterOrder() {
		$time = 0;
		ConvertibleTimestamp::setFakeTime( static function () use ( &$time ) {
			return $time += 1;
		} );
		$params = [ 'licode' => 'en|ru|zh|de|yue' ];

		[ $response, $continue ] = $this->doQuery( $params );

		$this->assertCount( 2, $response );
		$this->assertArrayHasKey( 'licontinue', $continue );
		$this->assertSame( [ 'de', 'en' ], array_keys( $response ) );

		$time = 0;
		$params = $continue + $params;
		[ $response, $continue ] = $this->doQuery( $params );

		$this->assertCount( 2, $response );
		$this->assertArrayHasKey( 'licontinue', $continue );
		$this->assertSame( [ 'ru', 'yue' ], array_keys( $response ) );

		$time = 0;
		$params = $continue + $params;
		[ $response, $continue ] = $this->doQuery( $params );

		$this->assertCount( 1, $response );
		$this->assertNull( $continue );
		$this->assertSame( [ 'zh' ], array_keys( $response ) );
	}

	public function testResponseHasModulePathEvenIfEmpty() {
		[ $response, $continue ] = $this->doQuery( [ 'licode' => '' ] );
		$this->assertSame( [], $response );
		// the real test is that $res[0]['query']['languageinfo'] in doQuery() didn’t fail
	}

}
PK       ! Z$    #  api/query/ApiQueryLogEventsTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Permissions\UltimateAuthority;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\User\UserIdentityValue;

/**
 * @covers MediaWiki\Api\ApiQueryLogEvents
 * @group API
 * @group Database
 * @group medium
 */
class ApiQueryLogEventsTest extends ApiTestCase {
	use TempUserTestTrait;

	/**
	 * @group Database
	 */
	public function testLogEventByTempUser() {
		$this->enableAutoCreateTempUser();
		$tempUser = new UserIdentityValue( 1236764321, '~1' );
		$title = $this->getNonexistingTestPage( 'TestPage1' )->getTitle();
		$this->editPage(
			$title,
			'Some Content',
			'Create Page',
			NS_MAIN,
			new UltimateAuthority( $tempUser )
		);

		[ $result, ] = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'logevents',
		] );
		$this->assertArrayHasKey( 'temp', $result[ 'query' ][ 'logevents' ][0] );
		$this->assertTrue( $result[ 'query' ][ 'logevents' ][0]['temp'] );
	}
}
PK       ! v.    "  api/query/ApiQueryAllPagesTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Title\Title;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers MediaWiki\Api\ApiQueryAllPages
 */
class ApiQueryAllPagesTest extends ApiTestCase {
	/**
	 * Test T27702
	 * Prefixes of API search requests are not handled with case sensitivity and may result
	 * in wrong search results
	 */
	public function testPrefixNormalizationSearchBug() {
		$title = Title::makeTitle( NS_CATEGORY, 'Template:xyz' );
		$this->editPage(
			$title,
			'Some text',
			'inserting content',
			NS_MAIN,
			$this->getTestSysop()->getAuthority()
		);

		$result = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'allpages',
			'apnamespace' => NS_CATEGORY,
			'apprefix' => 'Template:x' ] );

		$this->assertArrayHasKey( 'query', $result[0] );
		$this->assertArrayHasKey( 'allpages', $result[0]['query'] );
		$this->assertContains( 'Category:Template:xyz', $result[0]['query']['allpages'][0] );
	}
}
PK       ! `E{  {  &  api/query/ApiQueryUserContribsTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Content\WikitextContent;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\User;
use MediaWiki\User\UserRigorOptions;

/**
 * @group API
 * @group Database
 * @group medium
 * @covers MediaWiki\Api\ApiQueryUserContribs
 */
class ApiQueryUserContribsTest extends ApiTestCase {

	use TempUserTestTrait;

	public function addDBDataOnce() {
		$this->disableAutoCreateTempUser();
		$userFactory = $this->getServiceContainer()->getUserFactory();
		$users = [
			$userFactory->newFromName( '192.168.2.2', UserRigorOptions::RIGOR_NONE ),
			$userFactory->newFromName( '192.168.2.1', UserRigorOptions::RIGOR_NONE ),
			$userFactory->newFromName( '192.168.2.3', UserRigorOptions::RIGOR_NONE ),
			User::createNew( __CLASS__ . ' B' ),
			User::createNew( __CLASS__ . ' A' ),
			User::createNew( __CLASS__ . ' C' ),
			$userFactory->newFromName( 'IW>' . __CLASS__, UserRigorOptions::RIGOR_NONE ),
		];

		$page = $this->getServiceContainer()->getWikiPageFactory()
			->newFromLinkTarget( new TitleValue( NS_MAIN, 'ApiQueryUserContribsTest' ) );
		for ( $i = 0; $i < 3; $i++ ) {
			foreach ( array_reverse( $users ) as $user ) {
				$status = $this->editPage(
					$page,
					new WikitextContent( "Test revision $user #$i" ),
					'Test edit',
					NS_MAIN,
					$user
				);
				if ( !$status->isOK() ) {
					$this->fail( 'Failed to edit: ' . $status->getWikiText( false, false, 'en' ) );
				}
			}
		}
	}

	/**
	 * @dataProvider provideSorting
	 * @param array $params Extra parameters for the query
	 * @param bool $reverse Reverse order?
	 * @param int $revs Number of revisions to expect
	 */
	public function testSorting( $params, $reverse, $revs ) {
		if ( isset( $params['ucuserids'] ) ) {
			$userIdentities = $this->getServiceContainer()->getUserIdentityLookup()
				->newSelectQueryBuilder()
				->whereUserNames( $params['ucuserids'] )
				->fetchUserIdentities();
			$userIds = [];
			foreach ( $userIdentities as $userIdentity ) {
				$userIds[] = $userIdentity->getId();
			}
			$params['ucuserids'] = implode( '|', $userIds );
		}
		if ( isset( $params['ucuser'] ) ) {
			$params['ucuser'] = implode( '|', $params['ucuser'] );
		}

		if ( $reverse ) {
			$params['ucdir'] = 'newer';
		}

		$params += [
			'action' => 'query',
			'list' => 'usercontribs',
			'ucprop' => 'ids',
		];

		$apiResult = $this->doApiRequest( $params + [ 'uclimit' => 500 ] );
		$this->assertArrayNotHasKey( 'continue', $apiResult[0] );
		$this->assertArrayHasKey( 'query', $apiResult[0] );
		$this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'] );

		$count = 0;
		$ids = [];
		foreach ( $apiResult[0]['query']['usercontribs'] as $page ) {
			$count++;
			$ids[$page['user']][] = $page['revid'];
		}
		$this->assertSame( $revs, $count, 'Expected number of revisions' );
		foreach ( $ids as $user => $revids ) {
			$sorted = $revids;
			$reverse ? sort( $sorted ) : rsort( $sorted );
			$this->assertSame( $sorted, $revids, "IDs for $user are sorted" );
		}

		for ( $limit = 1; $limit < $revs; $limit++ ) {
			$continue = [];
			$count = 0;
			$batchedIds = [];
			while ( $continue !== null ) {
				$apiResult = $this->doApiRequest( $params + [ 'uclimit' => $limit ] + $continue );
				$this->assertArrayHasKey( 'query', $apiResult[0], "Batching with limit $limit" );
				$this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'],
					"Batching with limit $limit" );
				$continue = $apiResult[0]['continue'] ?? null;
				foreach ( $apiResult[0]['query']['usercontribs'] as $page ) {
					$count++;
					$batchedIds[$page['user']][] = $page['revid'];
				}
				$this->assertLessThanOrEqual( $revs, $count, "Batching with limit $limit" );
			}
			$this->assertSame( $ids, $batchedIds, "Result set is the same when batching with limit $limit" );
		}
	}

	public static function provideSorting() {
		$users = [ __CLASS__ . ' A', __CLASS__ . ' B', __CLASS__ . ' C' ];
		$users2 = [ __CLASS__ . ' A', __CLASS__ . ' B', __CLASS__ . ' D' ];
		$ips = [ '192.168.2.1', '192.168.2.2', '192.168.2.3', '192.168.2.4' ];

		foreach ( [ false, true ] as $reverse ) {
			$name = ( $reverse ? ', reverse' : '' );
			yield "Named users, $name" => [ [ 'ucuser' => $users ], $reverse, 9 ];
			yield "Named users including a no-edit user, $name" => [
				[ 'ucuser' => $users2 ], $reverse, 6
			];
			yield "IP users, $name" => [ [ 'ucuser' => $ips ], $reverse, 9 ];
			yield "All users, $name" => [
				[ 'ucuser' => array_merge( $users, $ips ) ], $reverse, 18
			];
			yield "User IDs, $name" => [ [ 'ucuserids' => $users ], $reverse, 9 ];
			yield "Users by prefix, $name" => [ [ 'ucuserprefix' => __CLASS__ ], $reverse, 9 ];
			yield "IPs by prefix, $name" => [ [ 'ucuserprefix' => '192.168.2.' ], $reverse, 9 ];
			yield "IPs by range, $name" => [ [ 'uciprange' => '192.168.2.0/24' ], $reverse, 9 ];
		}
	}

	public function testInterwikiUser() {
		$params = [
			'action' => 'query',
			'list' => 'usercontribs',
			'ucuser' => 'IW>' . __CLASS__,
			'ucprop' => 'ids',
			'uclimit' => 'max',
		];

		$apiResult = $this->doApiRequest( $params );
		$this->assertArrayNotHasKey( 'continue', $apiResult[0] );
		$this->assertArrayHasKey( 'query', $apiResult[0] );
		$this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'] );

		$count = 0;
		$ids = [];
		foreach ( $apiResult[0]['query']['usercontribs'] as $page ) {
			$count++;
			$this->assertSame( 'IW>' . __CLASS__, $page['user'], 'Correct user returned' );
			$ids[] = $page['revid'];
		}
		$this->assertSame( 3, $count, 'Expected number of revisions' );
		$sorted = $ids;
		rsort( $sorted );
		$this->assertSame( $sorted, $ids, "IDs are sorted" );
	}

}
PK       ! -f    &  api/query/ApiQueryAllRevisionsTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Title\Title;

/**
 * @group API
 * @group Database
 * @group medium
 * @covers MediaWiki\Api\ApiQueryAllRevisions
 */
class ApiQueryAllRevisionsTest extends ApiTestCase {

	/**
	 * @group medium
	 */
	public function testContentComesWithContentModelAndFormat() {
		$title = Title::makeTitle( NS_HELP, 'TestContentComesWithContentModelAndFormat' );
		$this->editPage(
			$title,
			'Some text',
			'inserting content',
			NS_MAIN,
			$this->getTestSysop()->getAuthority()
		);
		$this->editPage(
			$title,
			'Some other text',
			'adding revision',
			NS_MAIN,
			$this->getTestSysop()->getAuthority()
		);

		$apiResult = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'allrevisions',
			'arvprop' => 'content',
			'arvslots' => 'main',
			'arvdir' => 'older',
		] );

		$this->assertArrayHasKey( 'query', $apiResult[0] );
		$this->assertArrayHasKey( 'allrevisions', $apiResult[0]['query'] );
		$this->assertArrayHasKey( 0, $apiResult[0]['query']['allrevisions'] );
		$this->assertArrayHasKey( 'title', $apiResult[0]['query']['allrevisions'][0] );
		$this->assertSame( $title->getPrefixedText(), $apiResult[0]['query']['allrevisions'][0]['title'] );
		$this->assertArrayHasKey( 'revisions', $apiResult[0]['query']['allrevisions'][0] );
		$this->assertCount( 2, $apiResult[0]['query']['allrevisions'][0]['revisions'] );

		foreach ( $apiResult[0]['query']['allrevisions'] as $page ) {
			$this->assertArrayHasKey( 'revisions', $page );
			foreach ( $page['revisions'] as $revision ) {
				$this->assertArrayHasKey( 'slots', $revision );
				$this->assertArrayHasKey( 'main', $revision['slots'] );
				$this->assertArrayHasKey( 'contentformat', $revision['slots']['main'],
					'contentformat should be included when asking content so client knows how to interpret it'
				);
				$this->assertArrayHasKey( 'contentmodel', $revision['slots']['main'],
					'contentmodel should be included when asking content so client knows how to interpret it'
				);
			}
		}
	}
}
PK       ! Iǫ    #  api/query/ApiQueryRevisionsTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Permissions\UltimateAuthority;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\User\UserIdentityValue;

/**
 * @group API
 * @group Database
 * @group medium
 * @covers MediaWiki\Api\ApiQueryRevisions
 */
class ApiQueryRevisionsTest extends ApiTestCase {
	use TempUserTestTrait;

	/**
	 * @group medium
	 */
	public function testContentComesWithContentModelAndFormat() {
		$pageName = 'Help:' . __METHOD__;
		$page = $this->getExistingTestPage( $pageName );
		$user = $this->getTestUser()->getUser();
		$page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new WikitextContent( 'Some text' ) )
			->saveRevision( CommentStoreComment::newUnsavedComment( 'inserting content' ) );

		$apiResult = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'revisions',
			'titles' => $pageName,
			'rvprop' => 'content',
			'rvslots' => 'main',
		] );
		$this->assertArrayHasKey( 'query', $apiResult[0] );
		$this->assertArrayHasKey( 'pages', $apiResult[0]['query'] );
		foreach ( $apiResult[0]['query']['pages'] as $page ) {
			$this->assertArrayHasKey( 'revisions', $page );
			foreach ( $page['revisions'] as $revision ) {
				$this->assertArrayHasKey( 'slots', $revision );
				$this->assertArrayHasKey( 'main', $revision['slots'] );
				$this->assertArrayHasKey( 'contentformat', $revision['slots']['main'],
					'contentformat should be included when asking content so client knows how to interpret it'
				);
				$this->assertArrayHasKey( 'contentmodel', $revision['slots']['main'],
					'contentmodel should be included when asking content so client knows how to interpret it'
				);
			}
		}
	}

	/**
	 * @group Database
	 * @group medium
	 */
	public function testRevisionMadeByTempUser() {
		$this->enableAutoCreateTempUser();
		$tempUser = new UserIdentityValue( 1236764321, '~1' );

		$title = $this->getNonexistingTestPage( 'TestPage1' )->getTitle();
		$this->editPage(
			$title,
			'Some Content',
			'Create Page',
			NS_MAIN,
			new UltimateAuthority( $tempUser )
		);

		$apiResult = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'revisions',
			'titles' => 'TestPage1'
		] );
		$this->assertArrayHasKey( 'query', $apiResult[0] );
		$this->assertArrayHasKey( 'pages', $apiResult[0]['query'] );
		$this->assertArrayHasKey( 'temp', $apiResult[0]['query']['pages'][1]['revisions'][0] );
		$this->assertTrue( $apiResult[0]['query']['pages'][1]['revisions'][0]['temp'] );
	}

	/**
	 * @group medium
	 */
	public function testResolvesPrevNextInDiffto() {
		$pageName = 'Help:' . __METHOD__;
		$page = $this->getExistingTestPage( $pageName );
		$user = $this->getTestUser()->getUser();

		$revRecord = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new WikitextContent( 'Some text' ) )
			->saveRevision( CommentStoreComment::newUnsavedComment( 'inserting more content' ) );

		[ $rvDiffToPrev ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'revisions',
			'titles' => $pageName,
			'rvdiffto' => 'prev',
		] );

		$this->assertSame(
			$revRecord->getId(),
			$rvDiffToPrev['query']['pages'][$page->getId()]['revisions'][0]['revid']
		);
		$this->assertSame(
			$revRecord->getId(),
			$rvDiffToPrev['query']['pages'][$page->getId()]['revisions'][0]['diff']['to']
		);
		$this->assertSame(
			$revRecord->getParentId(),
			$rvDiffToPrev['query']['pages'][$page->getId()]['revisions'][0]['diff']['from']
		);

		[ $rvDiffToNext ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'revisions',
			'titles' => $pageName,
			'rvdiffto' => 'next',
			'rvdir' => 'newer'
		] );

		$this->assertSame(
			$revRecord->getParentId(),
			$rvDiffToNext['query']['pages'][$page->getId()]['revisions'][0]['revid']
		);
		$this->assertSame(
			$revRecord->getId(),
			$rvDiffToNext['query']['pages'][$page->getId()]['revisions'][0]['diff']['to']
		);
		$this->assertSame(
			$revRecord->getParentId(),
			$rvDiffToNext['query']['pages'][$page->getId()]['revisions'][0]['diff']['from']
		);
	}

	/**
	 * @dataProvider provideSectionNewTestCases
	 * @param string $pageContent
	 * @param string $expectedSectionContent
	 * @group medium
	 */
	public function testSectionNewReturnsEmptyContentForPageWithSection(
		$pageContent,
		$expectedSectionContent
	) {
		$pageName = 'Help:' . __METHOD__;
		$page = $this->getExistingTestPage( $pageName );
		$user = $this->getTestUser()->getUser();
		$revRecord = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new WikitextContent( $pageContent ) )
			->saveRevision( CommentStoreComment::newUnsavedComment( 'inserting content' ) );

		[ $response ] = $this->doApiRequest( [
			'action' => 'query',
			'prop' => 'revisions',
			'revids' => $revRecord->getId(),
			'rvprop' => 'content|ids',
			'rvslots' => 'main',
			'rvsection' => 'new'
		] );

		$this->assertSame(
			$expectedSectionContent,
			$response['query']['pages'][$page->getId()]['revisions'][0]['slots']['main']['content']
		);
	}

	public static function provideSectionNewTestCases() {
		yield 'page with existing section' => [
			"==A section==\ntext",
			''
		];
		yield 'page with no sections' => [
			'This page has no sections',
			'This page has no sections'
		];
	}
}
PK       ! }B    "  api/query/ApiQueryUserInfoTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Utils\MWTimestamp;

/**
 * @group API
 * @group Database
 * @group medium
 * @covers MediaWiki\Api\ApiQueryUserInfo
 */
class ApiQueryUserInfoTest extends ApiTestCase {

	use TempUserTestTrait;
	use MockAuthorityTrait;

	/**
	 * @covers MediaWiki\Api\ApiQueryUserInfo::getLatestContributionTime
	 */
	public function testTimestamp() {
		$clock = MWTimestamp::convert( TS_UNIX, '20100101000000' );
		MWTimestamp::setFakeTime( static function () use ( &$clock ) {
			return $clock += 1000;
		} );

		$params = [
			'action' => 'query',
			'meta' => 'userinfo',
			'uiprop' => 'latestcontrib',
		];

		$page = $this->getNonexistingTestPage();
		$performer = $this->getTestUser()->getAuthority();

		$apiResult = $this->doApiRequest( $params, null, false, $performer );
		$this->assertArrayNotHasKey( 'continue', $apiResult[0] );
		$this->assertArrayHasKey( 'query', $apiResult[0] );
		$this->assertArrayHasKey( 'userinfo', $apiResult[0]['query'] );
		$this->assertArrayNotHasKey( 'latestcontrib', $apiResult[0]['query']['userinfo'] );

		$status = $this->editPage( $page, 'one' );
		$this->assertStatusOK( $status );
		$status = $this->editPage( $page, 'two' );
		$this->assertStatusOK( $status );

		$revisionTimestamp = MWTimestamp::convert( TS_ISO_8601, $page->getTimestamp() );

		$apiResult = $this->doApiRequest( $params, null, false, $performer );
		$this->assertArrayNotHasKey( 'continue', $apiResult[0] );
		$this->assertArrayHasKey( 'query', $apiResult[0] );
		$this->assertArrayHasKey( 'userinfo', $apiResult[0]['query'] );
		$this->assertArrayHasKey( 'latestcontrib', $apiResult[0]['query']['userinfo'] );
		$queryTimestamp = $apiResult[0]['query']['userinfo']['latestcontrib'];
		$this->assertSame( $revisionTimestamp, $queryTimestamp );
	}

	public function testCanCreateAccount() {
		$params = [
			'action' => 'query',
			'meta' => 'userinfo',
			'uiprop' => 'cancreateaccount',
		];
		$user = $this->getTestUser()->getUser();
		$apiResult = $this->doApiRequest( $params, null, false, $user );
		$this->assertArrayHasKey( 'query', $apiResult[0] );
		$this->assertArrayHasKey( 'userinfo', $apiResult[0]['query'] );
		$this->assertArrayHasKey( 'cancreateaccount', $apiResult[0]['query']['userinfo'] );
		$this->assertTrue( $apiResult[0]['query']['userinfo']['cancreateaccount'] );
		$this->assertArrayNotHasKey( 'cancreateaccounterror', $apiResult[0]['query']['userinfo'] );

		$user = $this->getMutableTestUser()->getUser();
		$status = $this->getServiceContainer()->getBlockUserFactory()->newBlockUser(
			$user,
			$this->getTestSysop()->getUser(),
			'infinity',
			'',
			[ 'isCreateAccountBlocked' => true ]
		)->placeBlock();
		if ( !$status->isGood() ) {
			$this->fail( $status->getWikiText( false, false, 'en' ) );
		}
		$apiResult = $this->doApiRequest( $params, null, false, $user );
		$this->assertArrayHasKey( 'query', $apiResult[0] );
		$this->assertArrayHasKey( 'userinfo', $apiResult[0]['query'] );
		$this->assertArrayHasKey( 'cancreateaccount', $apiResult[0]['query']['userinfo'] );
		$this->assertFalse( $apiResult[0]['query']['userinfo']['cancreateaccount'] );
		$this->assertArrayHasKey( 'cancreateaccounterror', $apiResult[0]['query']['userinfo'] );
	}

	public function testTempFlag() {
		$this->enableAutoCreateTempUser();
		$params = [
			'action' => 'query',
			'meta' => 'userinfo',
		];
		$user = $this->getServiceContainer()->getTempUserCreator()->create( null, new FauxRequest() )->getUser();
		$apiResult = $this->doApiRequest( $params, null, false, $user );

		// Verify that the temp flag is set.
		$this->assertArrayHasKey( 'query', $apiResult[0] );
		$this->assertArrayHasKey( 'userinfo', $apiResult[0]['query'] );
		$this->assertArrayHasKey( 'temp', $apiResult[0]['query']['userinfo'] );
		$this->assertTrue( $apiResult[0]['query']['userinfo']['temp'] );

		// Verify that the name is correct
		$this->assertArrayHasKey( 'name', $apiResult[0]['query']['userinfo'] );
		$this->assertSame( $user->getName(), $apiResult[0]['query']['userinfo']['name'] );

		// Verify that the user ID is correct
		$this->assertArrayHasKey( 'id', $apiResult[0]['query']['userinfo'] );
		$this->assertSame( $user->getId(), $apiResult[0]['query']['userinfo']['id'] );
	}

	public function testAnonFlag() {
		$this->disableAutoCreateTempUser();
		$params = [
			'action' => 'query',
			'meta' => 'userinfo',
		];
		$user = $this->mockAnonUltimateAuthority();
		$apiResult = $this->doApiRequest( $params, null, false, $user );

		// Verify that the temp flag is not set.
		$this->assertArrayHasKey( 'query', $apiResult[0] );
		$this->assertArrayHasKey( 'userinfo', $apiResult[0]['query'] );
		$this->assertArrayNotHasKey( 'temp', $apiResult[0]['query']['userinfo'] );

		// Verify that the anon flag is set.
		$this->assertArrayHasKey( 'anon', $apiResult[0]['query']['userinfo'] );
		$this->assertTrue( $apiResult[0]['query']['userinfo']['anon'] );
	}
}
PK       ! 0f  0f  2  api/query/ApiQueryRecentChangesIntegrationTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Linker\LinkTarget;
use MediaWiki\Permissions\Authority;
use MediaWiki\Tests\Api\ApiTestCase;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\User;
use MediaWiki\Watchlist\WatchedItemQueryService;
use RecentChange;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers MediaWiki\Api\ApiQueryRecentChanges
 */
class ApiQueryRecentChangesIntegrationTest extends ApiTestCase {
	use MockAuthorityTrait;
	use TempUserTestTrait;

	private function getLoggedInTestUser() {
		return $this->getTestUser()->getUser();
	}

	private function doPageEdit( Authority $performer, $target, $summary ) {
		static $i = 0;

		$this->editPage(
			$target,
			__CLASS__ . $i++,
			$summary,
			NS_MAIN,
			$performer
		);
	}

	private function doMinorPageEdit( User $user, LinkTarget $target, $summary ) {
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromLinkTarget( $target );
		$page->doUserEditContent(
			$page->getContentHandler()->unserializeContent( __CLASS__ ),
			$user,
			$summary,
			EDIT_MINOR
		);
	}

	private function doBotPageEdit( User $user, LinkTarget $target, $summary ) {
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromLinkTarget( $target );
		$page->doUserEditContent(
			$page->getContentHandler()->unserializeContent( __CLASS__ ),
			$user,
			$summary,
			EDIT_FORCE_BOT
		);
	}

	private function doAnonPageEdit( LinkTarget $target, $summary ) {
		$this->disableAutoCreateTempUser();
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromLinkTarget( $target );
		$page->doUserEditContent(
			$page->getContentHandler()->unserializeContent( __CLASS__ ),
			$this->getServiceContainer()->getUserFactory()->newAnonymous(),
			$summary
		);
	}

	private function doTempPageEdit( LinkTarget $target, $summary ) {
		// Set up temp user config
		$this->enableAutoCreateTempUser();
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromLinkTarget( $target );
		$page->doUserEditContent(
			$page->getContentHandler()->unserializeContent( __CLASS__ ),
			$this->mockTempUltimateAuthority(),
			$summary
		);
	}

	/**
	 * Performs a batch of page edits as a specified user
	 * @param User $user
	 * @param array $editData associative array, keys:
	 *                        - target    => LinkTarget page to edit
	 *                        - summary   => string edit summary
	 *                        - minorEdit => bool mark as minor edit if true (defaults to false)
	 *                        - botEdit   => bool mark as bot edit if true (defaults to false)
	 */
	private function doPageEdits( User $user, array $editData ) {
		foreach ( $editData as $singleEditData ) {
			if ( array_key_exists( 'minorEdit', $singleEditData ) && $singleEditData['minorEdit'] ) {
				$this->doMinorPageEdit(
					$user,
					$singleEditData['target'],
					$singleEditData['summary']
				);
				continue;
			}
			if ( array_key_exists( 'botEdit', $singleEditData ) && $singleEditData['botEdit'] ) {
				$this->doBotPageEdit(
					$user,
					$singleEditData['target'],
					$singleEditData['summary']
				);
				continue;
			}
			$this->doPageEdit(
				$user,
				$singleEditData['target'],
				$singleEditData['summary']
			);
		}
	}

	private function doListRecentChangesRequest( array $params = [] ) {
		return $this->doApiRequest(
			array_merge(
				[ 'action' => 'query', 'list' => 'recentchanges' ],
				$params
			),
			null,
			false,
			$this->getLoggedInTestUser()
		);
	}

	private function doGeneratorRecentChangesRequest( array $params = [] ) {
		return $this->doApiRequest(
			array_merge(
				[ 'action' => 'query', 'generator' => 'recentchanges' ],
				$params
			),
			null,
			false,
			$this->getLoggedInTestUser()
		);
	}

	private function getItemsFromRecentChangesResult( array $result ) {
		return $result[0]['query']['recentchanges'];
	}

	public function testListRecentChanges_returnsRCInfo() {
		$target = new TitleValue( NS_MAIN, 'ApiQueryRecentChangesIntegrationTestPage' );
		$this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );

		$result = $this->doListRecentChangesRequest();
		$items = $this->getItemsFromRecentChangesResult( $result );

		// Default contains at least props for 'title', 'timestamp', and 'ids'.
		$this->assertCount( 1, $items );
		$item = $items[0];
		foreach ( [
			'pageid',
			'revid',
			'old_revid',
			'rcid',
			'timestamp',
		] as $key ) {
			// Assert key but ignore value
			$this->assertArrayHasKey( $key, $item );
			unset( $item[ $key ] );
		}

		// The rest must equal exactly, with no additional keys (e.g. 'minor' or 'bot').
		$this->assertEquals(
			[
				'type' => 'new',
				'ns' => NS_MAIN,
				'title' => 'ApiQueryRecentChangesIntegrationTestPage',
			],
			$item
		);
	}

	public function testIdsPropParameter() {
		$target = new TitleValue( NS_MAIN, 'ApiQueryRecentChangesIntegrationTestPage' );
		$this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );
		$result = $this->doListRecentChangesRequest( [ 'rcprop' => 'ids' ] );
		$items = $this->getItemsFromRecentChangesResult( $result );

		$this->assertCount( 1, $items );
		$item = $items[0];
		foreach ( [
			'pageid',
			'revid',
			'old_revid',
			'rcid',
		] as $key ) {
			// Assert key but ignore value
			$this->assertArrayHasKey( $key, $item );
			unset( $item[ $key ] );
		}

		$this->assertEquals(
			[
				'type' => 'new',
			],
			$item
		);
	}

	public function testTitlePropParameter() {
		$this->doPageEdits(
			$this->getLoggedInTestUser(),
			[
				[
					'target' => new TitleValue( NS_MAIN, 'Thing' ),
					'summary' => 'Create the page',
				],
				[
					'target' => new TitleValue( NS_TALK, 'Thing' ),
					'summary' => 'Create Talk page',
				],
			]
		);

		$result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title' ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => NS_TALK,
					'title' => 'Talk:Thing',
				],
				[
					'type' => 'new',
					'ns' => NS_MAIN,
					'title' => 'Thing',
				],
			],
			$this->getItemsFromRecentChangesResult( $result )
		);
	}

	public function testFlagsPropParameter() {
		$this->doPageEdits(
			$this->getLoggedInTestUser(),
			[
				[
					'summary' => 'Create the page',
					'target' => new TitleValue( NS_MAIN, 'Regularpage' ),
				],
				[
					'summary' => 'Create the page for minor change',
					'target' => new TitleValue( NS_MAIN, 'Minorpage' ),
				],
				[
					'summary' => 'Make minor content',
					'target' => new TitleValue( NS_MAIN, 'Minorpage' ),
					'minorEdit' => true,
				],
				[
					'summary' => 'Create the page as a bot',
					'target' => new TitleValue( NS_MAIN, 'Botpage' ),
					'botEdit' => true,
				],
			]
		);

		$result = $this->doListRecentChangesRequest( [ 'rcprop' => 'flags' ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'new' => true,
					'minor' => false,
					'bot' => true,
				],
				[
					'type' => 'edit',
					'new' => false,
					'minor' => true,
					'bot' => false,
				],
				[
					'type' => 'new',
					'new' => true,
					'minor' => false,
					'bot' => false,
				],
				[
					'type' => 'new',
					'new' => true,
					'minor' => false,
					'bot' => false,
				],
			],
			$this->getItemsFromRecentChangesResult( $result )
		);
	}

	public function testUserPropParameter() {
		$userEditTarget = new TitleValue( NS_MAIN, 'Foo' );
		$anonEditTarget = new TitleValue( NS_MAIN, 'Bar' );
		$tempEditTarget = new TitleValue( NS_MAIN, 'Baz' );
		$this->doPageEdit( $this->getLoggedInTestUser(), $userEditTarget, 'Create the page' );
		$this->doAnonPageEdit( $anonEditTarget, 'Create the page' );

		// Test that querying for anonymous edits works even if temporary accounts are disabled
		$this->disableAutoCreateTempUser();
		$result = $this->doListRecentChangesRequest( [
			'rcprop' => 'user',
			'rcshow' => WatchedItemQueryService::FILTER_NOT_ANON,
		] );
		$this->assertEquals(
			[
				[
					'type' => 'new',
					'user' => $this->getLoggedInTestUser()->getName(),
				],
			],
			$this->getItemsFromRecentChangesResult( $result )
		);

		// Test that temporary accounts are treated as anonymous
		$this->doTempPageEdit( $tempEditTarget, 'Create the page' );
		$result = $this->doListRecentChangesRequest( [ 'rcprop' => 'user' ] );
		$this->assertEquals(
			[
				[
					'type' => 'new',
					'temp' => true,
					'user' => '~2024-1',
				],
				[
					'type' => 'new',
					'anon' => true,
					'user' => '127.0.0.1',
				],
				[
					'type' => 'new',
					'user' => $this->getLoggedInTestUser()->getName(),
				],
			],
			$this->getItemsFromRecentChangesResult( $result )
		);
	}

	public function testUserIdPropParameter() {
		$user = $this->getLoggedInTestUser();
		$userEditTarget = new TitleValue( NS_MAIN, 'Foo' );
		$anonEditTarget = new TitleValue( NS_MAIN, 'Bar' );
		$this->doPageEdit( $user, $userEditTarget, 'Create the page' );
		$this->doAnonPageEdit( $anonEditTarget, 'Create the page' );

		$result = $this->doListRecentChangesRequest( [ 'rcprop' => 'userid' ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'anon' => true,
					'userid' => 0,
				],
				[
					'type' => 'new',
					'userid' => $user->getId(),
				],
			],
			$this->getItemsFromRecentChangesResult( $result )
		);
	}

	public function testCommentPropParameter() {
		$target = new TitleValue( NS_MAIN, 'Thing' );
		$this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the <b>page</b>' );

		$result = $this->doListRecentChangesRequest( [ 'rcprop' => 'comment' ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'comment' => 'Create the <b>page</b>',
				],
			],
			$this->getItemsFromRecentChangesResult( $result )
		);
	}

	public function testParsedCommentPropParameter() {
		$target = new TitleValue( NS_MAIN, 'Thing' );
		$this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the <b>page</b>' );

		$result = $this->doListRecentChangesRequest( [ 'rcprop' => 'parsedcomment' ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'parsedcomment' => 'Create the &lt;b&gt;page&lt;/b&gt;',
				],
			],
			$this->getItemsFromRecentChangesResult( $result )
		);
	}

	public function testTimestampPropParameter() {
		$target = new TitleValue( NS_MAIN, 'Thing' );
		$this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );

		$result = $this->doListRecentChangesRequest( [ 'rcprop' => 'timestamp' ] );
		$items = $this->getItemsFromRecentChangesResult( $result );

		$this->assertCount( 1, $items );
		$this->assertIsString( $items[0]['timestamp'] );
	}

	public function testSizesPropParameter() {
		$target = new TitleValue( NS_MAIN, 'Thing' );
		$this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );

		$result = $this->doListRecentChangesRequest( [ 'rcprop' => 'sizes' ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'oldlen' => 0,
					// strlen( __CLASS__ ) - 2 = 64
					'newlen' => 64,
				],
			],
			$this->getItemsFromRecentChangesResult( $result )
		);
	}

	private function createPageAndDeleteIt( LinkTarget $target ) {
		$wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromLinkTarget( $target );
		$this->doPageEdit( $this->getLoggedInTestUser(),
			$wikiPage,
			'Create the page that will be deleted'
		);
		$this->deletePage( $wikiPage, 'Important Reason' );
	}

	public function testLoginfoPropParameter() {
		$target = new TitleValue( NS_MAIN, 'Thing' );
		$this->createPageAndDeleteIt( $target );

		$result = $this->doListRecentChangesRequest( [ 'rcprop' => 'loginfo' ] );
		$items = $this->getItemsFromRecentChangesResult( $result );

		$this->assertCount( 1, $items );
		foreach ( [
			'logid',
		] as $key ) {
			// Assert key but ignore value
			$this->assertArrayHasKey( $key, $items[0] );
			unset( $items[0][ $key ] );
		}
		$this->assertEquals(
			[
				'type' => 'log',
				'logtype' => 'delete',
				'logaction' => 'delete',
				'logparams' => [],
			],
			$items[0]
		);
	}

	public function testEmptyPropParameter() {
		$user = $this->getLoggedInTestUser();
		$target = new TitleValue( NS_MAIN, 'Thing' );
		$this->doPageEdit( $user, $target, 'Create the page' );

		$result = $this->doListRecentChangesRequest( [ 'rcprop' => '' ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
				]
			],
			$this->getItemsFromRecentChangesResult( $result )
		);
	}

	public function testNamespaceParam() {
		$subjectTarget = new TitleValue( NS_MAIN, 'Foo' );
		$talkTarget = new TitleValue( NS_TALK, 'Foo' );
		$this->doPageEdits(
			$this->getLoggedInTestUser(),
			[
				[
					'target' => $subjectTarget,
					'summary' => 'Create the page',
				],
				[
					'target' => $talkTarget,
					'summary' => 'Create the talk page',
				],
			]
		);

		$result = $this->doListRecentChangesRequest( [ 'rcnamespace' => '0', 'rcprop' => 'title' ] );
		$items = $this->getItemsFromRecentChangesResult( $result );

		$this->assertCount( 1, $items );
		$this->assertEquals(
			[
				'type' => 'new',
				'ns' => NS_MAIN,
				'title' => 'Foo',
			],
			$items[0]
		);
	}

	public function testShowAnonParams() {
		$target = new TitleValue( NS_MAIN, 'Thing' );
		$this->doAnonPageEdit( $target, 'Create the page' );

		$tempEditTarget = new TitleValue( NS_MAIN, 'Baz' );
		$this->doTempPageEdit( $tempEditTarget, 'Create the page' );

		$resultAnon = $this->doListRecentChangesRequest( [
			'rcprop' => 'user',
			'rcshow' => WatchedItemQueryService::FILTER_ANON
		] );
		$resultNotAnon = $this->doListRecentChangesRequest( [
			'rcprop' => 'user',
			'rcshow' => WatchedItemQueryService::FILTER_NOT_ANON
		] );

		$items = $this->getItemsFromRecentChangesResult( $resultAnon );
		$this->assertCount( 2, $items );
		$this->assertEquals(
			[
				[
					'type' => 'new',
					'temp' => true,
					'user' => '~2024-1',
				],
				[
					'type' => 'new',
					'anon' => true,
					'user' => '127.0.0.1',
				],
			],
			$items
		);
		$this->assertSame( [], $this->getItemsFromRecentChangesResult( $resultNotAnon ) );
	}

	public function testNewAndEditTypeParameters() {
		$subjectTarget = new TitleValue( NS_MAIN, 'Foo' );
		$talkTarget = new TitleValue( NS_TALK, 'Foo' );
		$this->doPageEdits(
			$this->getLoggedInTestUser(),
			[
				[
					'target' => $subjectTarget,
					'summary' => 'Create the page',
				],
				[
					'target' => $subjectTarget,
					'summary' => 'Change the content',
				],
				[
					'target' => $talkTarget,
					'summary' => 'Create Talk page',
				],
			]
		);

		$resultNew = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'new' ] );
		$resultEdit = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'edit' ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => NS_TALK,
					'title' => 'Talk:Foo',
				],
				[
					'type' => 'new',
					'ns' => NS_MAIN,
					'title' => 'Foo',
				],
			],
			$this->getItemsFromRecentChangesResult( $resultNew )
		);
		$this->assertEquals(
			[
				[
					'type' => 'edit',
					'ns' => NS_MAIN,
					'title' => 'Foo',
				],
			],
			$this->getItemsFromRecentChangesResult( $resultEdit )
		);
	}

	public function testLogTypeParameters() {
		$subjectTarget = new TitleValue( NS_MAIN, 'Foo' );
		$talkTarget = new TitleValue( NS_TALK, 'Foo' );
		$this->createPageAndDeleteIt( $subjectTarget );
		$this->doPageEdit( $this->getLoggedInTestUser(), $talkTarget, 'Create Talk page' );

		$result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'log' ] );

		$this->assertEquals(
			[
				[
					'type' => 'log',
					'ns' => NS_MAIN,
					'title' => 'Foo',
				],
			],
			$this->getItemsFromRecentChangesResult( $result )
		);
	}

	private function getExternalRC( LinkTarget $target ) {
		$title = $this->getServiceContainer()->getTitleFactory()->newFromLinkTarget( $target );

		$rc = new RecentChange;
		$rc->mAttribs = [
			'rc_timestamp' => wfTimestamp( TS_MW ),
			'rc_namespace' => $title->getNamespace(),
			'rc_title' => $title->getDBkey(),
			'rc_type' => RC_EXTERNAL,
			'rc_source' => 'foo',
			'rc_minor' => 0,
			'rc_cur_id' => $title->getArticleID(),
			'rc_user' => 0,
			'rc_user_text' => 'm>External User',
			'rc_comment' => '',
			'rc_comment_text' => '',
			'rc_comment_data' => null,
			'rc_this_oldid' => $title->getLatestRevID(),
			'rc_last_oldid' => $title->getLatestRevID(),
			'rc_bot' => 0,
			'rc_ip' => '',
			'rc_patrolled' => 0,
			'rc_new' => 0,
			'rc_old_len' => $title->getLength(),
			'rc_new_len' => $title->getLength(),
			'rc_deleted' => 0,
			'rc_logid' => 0,
			'rc_log_type' => null,
			'rc_log_action' => '',
			'rc_params' => '',
		];
		$rc->mExtra = [
			'prefixedDBkey' => $title->getPrefixedDBkey(),
			'lastTimestamp' => 0,
			'oldSize' => $title->getLength(),
			'newSize' => $title->getLength(),
			'pageStatus' => 'changed'
		];

		return $rc;
	}

	public function testExternalTypeParameters() {
		$user = $this->getLoggedInTestUser();
		$subjectTarget = new TitleValue( NS_MAIN, 'Foo' );
		$talkTarget = new TitleValue( NS_TALK, 'Foo' );
		$this->doPageEdit( $user, $subjectTarget, 'Create the page' );
		$this->doPageEdit( $user, $talkTarget, 'Create Talk page' );
		$rc = $this->getExternalRC( $subjectTarget );
		$rc->save();

		$result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'external' ] );

		$this->assertEquals(
			[
				[
					'type' => 'external',
					'ns' => NS_MAIN,
					'title' => 'Foo',
				],
			],
			$this->getItemsFromRecentChangesResult( $result )
		);
	}

	public function testCategorizeTypeParameter() {
		$user = $this->getLoggedInTestUser();
		$subjectTarget = new TitleValue( NS_MAIN, 'Foo' );
		$categoryTarget = new TitleValue( NS_CATEGORY, 'Bar' );
		$this->doPageEdits(
			$user,
			[
				[
					'target' => $categoryTarget,
					'summary' => 'Create the category',
				],
				[
					'target' => $subjectTarget,
					'summary' => 'Create the page and add it to the category',
				],
			]
		);
		$titleFactory = $this->getServiceContainer()->getTitleFactory();
		$title = $titleFactory->newFromLinkTarget( $subjectTarget );
		$revision = $this->getServiceContainer()
			->getRevisionLookup()
			->getRevisionByTitle( $title );

		$comment = $revision->getComment();
		$rc = RecentChange::newForCategorization(
			$revision->getTimestamp(),
			$titleFactory->newFromLinkTarget( $categoryTarget ),
			$user,
			$comment ? $comment->text : '',
			$title,
			0,
			$revision->getId(),
			null,
			false
		);
		$rc->save();

		$result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'categorize' ] );

		$this->assertEquals(
			[
				[
					'type' => 'categorize',
					'ns' => NS_CATEGORY,
					'title' => 'Category:Bar',
				],
			],
			$this->getItemsFromRecentChangesResult( $result )
		);
	}

	public function testLimitParam() {
		$this->doPageEdits(
			$this->getLoggedInTestUser(),
			[
				[
					'target' => new TitleValue( NS_MAIN, 'Foo' ),
					'summary' => 'Create the page',
				],
				[
					'target' => new TitleValue( NS_TALK, 'Foo' ),
					'summary' => 'Create Talk page',
				],
				[
					'target' => new TitleValue( NS_MAIN, 'Bar' ),
					'summary' => 'Create another page',
				],
			]
		);

		$resultWithoutLimit = $this->doListRecentChangesRequest( [ 'rcprop' => 'title' ] );
		$resultWithLimit = $this->doListRecentChangesRequest( [ 'rclimit' => 2, 'rcprop' => 'title' ] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => NS_MAIN,
					'title' => 'Bar'
				],
				[
					'type' => 'new',
					'ns' => NS_TALK,
					'title' => 'Talk:Foo'
				],
				[
					'type' => 'new',
					'ns' => NS_MAIN,
					'title' => 'Foo'
				],
			],
			$this->getItemsFromRecentChangesResult( $resultWithoutLimit )
		);
		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => NS_MAIN,
					'title' => 'Bar'
				],
				[
					'type' => 'new',
					'ns' => NS_TALK,
					'title' => 'Talk:Foo'
				],
			],
			$this->getItemsFromRecentChangesResult( $resultWithLimit )
		);
		$this->assertArrayHasKey( 'rccontinue', $resultWithLimit[0]['continue'] );
	}

	public function testAllRevParam() {
		$target = new TitleValue( NS_MAIN, 'Thing' );
		$this->doPageEdits(
			$this->getLoggedInTestUser(),
			[
				[
					'target' => $target,
					'summary' => 'Create the page',
				],
				[
					'target' => $target,
					'summary' => 'Change the content',
				],
			]
		);

		$resultAllRev = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rcallrev' => '' ] );
		$resultNoAllRev = $this->doListRecentChangesRequest( [ 'rcprop' => 'title' ] );

		$this->assertEquals(
			[
				[
					'type' => 'edit',
					'ns' => NS_MAIN,
					'title' => 'Thing',
				],
				[
					'type' => 'new',
					'ns' => NS_MAIN,
					'title' => 'Thing',
				],
			],
			$this->getItemsFromRecentChangesResult( $resultNoAllRev )
		);
		$this->assertEquals(
			[
				[
					'type' => 'edit',
					'ns' => NS_MAIN,
					'title' => 'Thing',
				],
				[
					'type' => 'new',
					'ns' => NS_MAIN,
					'title' => 'Thing',
				],
			],
			$this->getItemsFromRecentChangesResult( $resultAllRev )
		);
	}

	public function testDirParams() {
		$subjectTarget = new TitleValue( NS_MAIN, 'Foo' );
		$talkTarget = new TitleValue( NS_TALK, 'Foo' );
		$this->doPageEdits(
			$this->getLoggedInTestUser(),
			[
				[
					'target' => $subjectTarget,
					'summary' => 'Create the page',
				],
				[
					'target' => $talkTarget,
					'summary' => 'Create Talk page',
				],
			]
		);

		$resultDirOlder = $this->doListRecentChangesRequest(
			[ 'rcdir' => 'older', 'rcprop' => 'title' ]
		);
		$resultDirNewer = $this->doListRecentChangesRequest(
			[ 'rcdir' => 'newer', 'rcprop' => 'title' ]
		);

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => NS_TALK,
					'title' => 'Talk:Foo'
				],
				[
					'type' => 'new',
					'ns' => NS_MAIN,
					'title' => 'Foo'
				],
			],
			$this->getItemsFromRecentChangesResult( $resultDirOlder )
		);
		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => NS_MAIN,
					'title' => 'Foo'
				],
				[
					'type' => 'new',
					'ns' => NS_TALK,
					'title' => 'Talk:Foo'
				],
			],
			$this->getItemsFromRecentChangesResult( $resultDirNewer )
		);
	}

	public function testTitleParams() {
		$this->doPageEdits(
			$this->getLoggedInTestUser(),
			[
				[
					'target' => new TitleValue( NS_MAIN, 'Foo' ),
					'summary' => 'Create the page',
				],
				[
					'target' => new TitleValue( NS_TALK, 'Bar' ),
					'summary' => 'Create the page',
				],
				[
					'target' => new TitleValue( NS_MAIN, 'Quux' ),
					'summary' => 'Create the page',
				],
			]
		);

		$result1 = $this->doListRecentChangesRequest(
			[
				'rctitle' => 'Foo',
				'rcprop' => 'title'
			]
		);
		$result2 = $this->doListRecentChangesRequest(
			[
				'rctitle' => 'Talk:Bar',
				'rcprop' => 'title'
			]
		);

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => NS_MAIN,
					'title' => 'Foo'
				],
			],
			$this->getItemsFromRecentChangesResult( $result1 )
		);
		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => NS_TALK,
					'title' => 'Talk:Bar'
				],
			],
			$this->getItemsFromRecentChangesResult( $result2 )
		);
	}

	public function testStartEndParams() {
		$target = new TitleValue( NS_MAIN, 'Thing' );
		$this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );

		$resultStart = $this->doListRecentChangesRequest( [
			'rcstart' => '20010115000000',
			'rcdir' => 'newer',
			'rcprop' => 'title',
		] );
		$resultEnd = $this->doListRecentChangesRequest( [
			'rcend' => '20010115000000',
			'rcdir' => 'newer',
			'rcprop' => 'title',
		] );

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => NS_MAIN,
					'title' => 'Thing',
				]
			],
			$this->getItemsFromRecentChangesResult( $resultStart )
		);
		$this->assertSame( [], $this->getItemsFromRecentChangesResult( $resultEnd ) );
	}

	public function testContinueParam() {
		$this->doPageEdits(
			$this->getLoggedInTestUser(),
			[
				[
					'target' => new TitleValue( NS_MAIN, 'Foo' ),
					'summary' => 'Create the page',
				],
				[
					'target' => new TitleValue( NS_TALK, 'Foo' ),
					'summary' => 'Create Talk page',
				],
				[
					'target' => new TitleValue( NS_MAIN, 'Bar' ),
					'summary' => 'Create the page',
				],
			]
		);

		$firstResult = $this->doListRecentChangesRequest( [ 'rclimit' => 2, 'rcprop' => 'title' ] );

		$continuationParam = $firstResult[0]['continue']['rccontinue'];

		$continuedResult = $this->doListRecentChangesRequest(
			[ 'rccontinue' => $continuationParam, 'rcprop' => 'title' ]
		);

		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => NS_MAIN,
					'title' => 'Bar',
				],
				[
					'type' => 'new',
					'ns' => NS_TALK,
					'title' => 'Talk:Foo',
				],
			],
			$this->getItemsFromRecentChangesResult( $firstResult )
		);
		$this->assertEquals(
			[
				[
					'type' => 'new',
					'ns' => NS_MAIN,
					'title' => 'Foo',
				]
			],
			$this->getItemsFromRecentChangesResult( $continuedResult )
		);
	}

	public function testGeneratorRecentChangesPropInfo_returnsRCPages() {
		$target = new TitleValue( NS_MAIN, 'Thing' );
		$this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );

		$result = $this->doGeneratorRecentChangesRequest( [ 'prop' => 'info' ] );

		// $result[0]['query']['pages'] uses page ids as keys. Page ids don't matter here, so drop them
		$pages = array_values( $result[0]['query']['pages'] );
		$this->assertCount( 1, $pages );

		$page = $pages[0];
		foreach ( [
			'pageid',
			'touched',
			'lastrevid',
			'length',
		] as $key ) {
			// Assert key but ignore value
			$this->assertArrayHasKey( $key, $page );
			unset( $page[ $key ] );
		}

		$this->assertEquals(
			[
				'ns' => NS_MAIN,
				'title' => 'Thing',
				'new' => true,
				'contentmodel' => 'wikitext',
				'pagelanguage' => 'en',
				'pagelanguagehtmlcode' => 'en',
				'pagelanguagedir' => 'ltr',
			],
			$page
		);
	}

}
PK       !        api/query/ApiQueryBlocksTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api\Query;

use MediaWiki\Block\BlockActionInfo;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\Restriction\ActionRestriction;
use MediaWiki\Block\Restriction\NamespaceRestriction;
use MediaWiki\Block\Restriction\PageRestriction;
use MediaWiki\MainConfigNames;
use MediaWiki\Tests\Api\ApiTestCase;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers MediaWiki\Api\ApiQueryBlocks
 */
class ApiQueryBlocksTest extends ApiTestCase {

	public function testExecute() {
		[ $data ] = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'blocks',
		] );
		$this->assertEquals( [ 'batchcomplete' => true, 'query' => [ 'blocks' => [] ] ], $data );
	}

	public function testExecuteBlock() {
		$badActor = $this->getTestUser()->getUser();
		$sysop = $this->getTestSysop()->getUser();

		$block = new DatabaseBlock( [
			'address' => $badActor,
			'by' => $sysop,
			'expiry' => 'infinity',
		] );

		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );

		[ $data ] = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'blocks',
		] );
		$this->assertArrayHasKey( 'query', $data );
		$this->assertArrayHasKey( 'blocks', $data['query'] );
		$this->assertCount( 1, $data['query']['blocks'] );
		$subset = [
			'id' => $block->getId(),
			'user' => $badActor->getName(),
			'expiry' => $block->getExpiry(),
		];
		$this->assertArraySubmapSame( $subset, $data['query']['blocks'][0] );
	}

	public function testExecuteSitewide() {
		$badActor = $this->getTestUser()->getUser();
		$sysop = $this->getTestSysop()->getUser();

		$block = new DatabaseBlock( [
			'address' => $badActor,
			'by' => $sysop,
		] );

		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );

		[ $data ] = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'blocks',
		] );
		$this->assertArrayHasKey( 'query', $data );
		$this->assertArrayHasKey( 'blocks', $data['query'] );
		$this->assertCount( 1, $data['query']['blocks'] );
		$subset = [
			'id' => $block->getId(),
			'user' => $badActor->getName(),
			'expiry' => $block->getExpiry(),
			'partial' => !$block->isSitewide(),
		];
		$this->assertArraySubmapSame( $subset, $data['query']['blocks'][0] );
	}

	public function testExecuteRestrictions() {
		$this->overrideConfigValue( MainConfigNames::EnablePartialActionBlocks, true );
		$badActor = $this->getTestUser()->getUser();
		$sysop = $this->getTestSysop()->getUser();

		$block = new DatabaseBlock( [
			'address' => $badActor,
			'by' => $sysop,
			'expiry' => 'infinity',
			'sitewide' => 0,
		] );

		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );

		$subset = [
			'id' => $block->getId(),
			'user' => $badActor->getName(),
			'expiry' => $block->getExpiry(),
		];

		$title = 'Lady Macbeth';
		$pageData = $this->insertPage( $title );
		$pageId = $pageData['id'];

		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'ipblocks_restrictions' )
			->row( [
				'ir_ipb_id' => $block->getId(),
				'ir_type' => PageRestriction::TYPE_ID,
				'ir_value' => $pageId,
			] )
			// Page that has been deleted.
			->row( [
				'ir_ipb_id' => $block->getId(),
				'ir_type' => PageRestriction::TYPE_ID,
				'ir_value' => 999999,
			] )
			->row( [
				'ir_ipb_id' => $block->getId(),
				'ir_type' => NamespaceRestriction::TYPE_ID,
				'ir_value' => NS_USER_TALK,
			] )
			// Invalid type
			->row( [
				'ir_ipb_id' => $block->getId(),
				'ir_type' => 127,
				'ir_value' => 4,
			] )
			// Action (upload)
			->row( [
				'ir_ipb_id' => $block->getId(),
				'ir_type' => ActionRestriction::TYPE_ID,
				'ir_value' => BlockActionInfo::ACTION_UPLOAD,
			] )
			->caller( __METHOD__ )
			->execute();

		// Test without requesting restrictions.
		[ $data ] = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'blocks',
		] );
		$this->assertArrayHasKey( 'query', $data );
		$this->assertArrayHasKey( 'blocks', $data['query'] );
		$this->assertCount( 1, $data['query']['blocks'] );
		$flagSubset = array_merge( $subset, [
			'partial' => !$block->isSitewide(),
		] );
		$this->assertArraySubmapSame( $flagSubset, $data['query']['blocks'][0] );
		$this->assertArrayNotHasKey( 'restrictions', $data['query']['blocks'][0] );

		// Test requesting the restrictions.
		[ $data ] = $this->doApiRequest( [
			'action' => 'query',
			'list' => 'blocks',
			'bkprop' => 'id|user|expiry|restrictions'
		] );
		$this->assertArrayHasKey( 'query', $data );
		$this->assertArrayHasKey( 'blocks', $data['query'] );
		$this->assertCount( 1, $data['query']['blocks'] );
		$restrictionsSubset = array_merge( $subset, [
			'restrictions' => [
				'pages' => [
					[
						'id' => $pageId,
						'ns' => NS_MAIN,
						'title' => $title,
					],
				],
				'namespaces' => [
					NS_USER_TALK,
				],
				'actions' => [
					'upload'
				]
			],
		] );
		$this->assertArraySubmapSame( $restrictionsSubset, $data['query']['blocks'][0] );
		$this->assertArrayNotHasKey( 'partial', $data['query']['blocks'][0] );
	}
}
PK       ! HV
  V
  #  api/query/ApiQueryContinue2Test.phpnu Iw        <?php

/**
 * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
 *
 * 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 3 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
 */

namespace MediaWiki\Tests\Api\Query;

use Exception;

/**
 * @group API
 * @group Database
 * @group medium
 * @covers MediaWiki\Api\ApiQuery
 */
class ApiQueryContinue2Test extends ApiQueryContinueTestBase {
	/** @var Exception|null */
	protected $exceptionFromAddDBData;

	/**
	 * Create a set of pages. These must not change, otherwise the tests might give wrong results.
	 *
	 * @see MediaWikiIntegrationTestCase::addDBDataOnce()
	 */
	public function addDBDataOnce() {
		try {
			$this->editPage( 'AQCT73462-A', '**AQCT73462-A**  [[AQCT73462-B]] [[AQCT73462-C]]' );
			$this->editPage( 'AQCT73462-B', '[[AQCT73462-A]]  **AQCT73462-B** [[AQCT73462-C]]' );
			$this->editPage( 'AQCT73462-C', '[[AQCT73462-A]]  [[AQCT73462-B]] **AQCT73462-C**' );
			$this->editPage( 'AQCT73462-A', '**AQCT73462-A**  [[AQCT73462-B]] [[AQCT73462-C]]' );
			$this->editPage( 'AQCT73462-B', '[[AQCT73462-A]]  **AQCT73462-B** [[AQCT73462-C]]' );
			$this->editPage( 'AQCT73462-C', '[[AQCT73462-A]]  [[AQCT73462-B]] **AQCT73462-C**' );
		} catch ( Exception $e ) {
			$this->exceptionFromAddDBData = $e;
		}
	}

	/**
	 * @group medium
	 */
	public function testA() {
		$this->mVerbose = false;
		$mk = static function ( $g, $p, $gDir ) {
			return [
				'generator' => 'allpages',
				'gapprefix' => 'AQCT73462-',
				'prop' => 'links',
				'gaplimit' => "$g",
				'pllimit' => "$p",
				'gapdir' => $gDir ? "ascending" : "descending",
			];
		};
		// generator + 1 prop + 1 list
		$data = $this->query( $mk( 99, 99, true ), 1, 'g1p', false ) +
			[ 'batchcomplete' => true ];
		$this->checkC( $data, $mk( 1, 1, true ), 6, 'g1p-11t' );
		$this->checkC( $data, $mk( 2, 2, true ), 3, 'g1p-22t' );
		$this->checkC( $data, $mk( 1, 1, false ), 6, 'g1p-11f' );
		$this->checkC( $data, $mk( 2, 2, false ), 3, 'g1p-22f' );
	}
}
PK       ! 2ղ.  .  "  api/query/ApiQueryContinueTest.phpnu Iw        <?php

/**
 * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
 *
 * 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
 */

namespace MediaWiki\Tests\Api\Query;

use Exception;

/**
 * These tests validate the new continue functionality of the api query module by
 * doing multiple requests with varying parameters, merging the results, and checking
 * that the result matches the full data received in one no-limits call.
 *
 * @group API
 * @group Database
 * @group medium
 * @covers MediaWiki\Api\ApiQuery
 */
class ApiQueryContinueTest extends ApiQueryContinueTestBase {
	/** @var Exception|null */
	protected $exceptionFromAddDBData;

	/**
	 * Create a set of pages. These must not change, otherwise the tests might give wrong results.
	 *
	 * @see MediaWikiIntegrationTestCase::addDBDataOnce()
	 */
	public function addDBDataOnce() {
		try {
			$this->editPage( 'Template:AQCT-T1', '**Template:AQCT-T1**' );
			$this->editPage( 'Template:AQCT-T2', '**Template:AQCT-T2**' );
			$this->editPage( 'Template:AQCT-T3', '**Template:AQCT-T3**' );
			$this->editPage( 'Template:AQCT-T4', '**Template:AQCT-T4**' );
			$this->editPage( 'Template:AQCT-T5', '**Template:AQCT-T5**' );

			$this->editPage( 'AQCT-1', '**AQCT-1** {{AQCT-T2}} {{AQCT-T3}} {{AQCT-T4}} {{AQCT-T5}}' );
			$this->editPage( 'AQCT-2', '[[AQCT-1]] **AQCT-2** {{AQCT-T3}} {{AQCT-T4}} {{AQCT-T5}}' );
			$this->editPage( 'AQCT-3', '[[AQCT-1]] [[AQCT-2]] **AQCT-3** {{AQCT-T4}} {{AQCT-T5}}' );
			$this->editPage( 'AQCT-4', '[[AQCT-1]] [[AQCT-2]] [[AQCT-3]] **AQCT-4** {{AQCT-T5}}' );
			$this->editPage( 'AQCT-5', '[[AQCT-1]] [[AQCT-2]] [[AQCT-3]] [[AQCT-4]] **AQCT-5**' );
		} catch ( Exception $e ) {
			$this->exceptionFromAddDBData = $e;
		}
	}

	/**
	 * Test smart continue - list=allpages
	 * @group medium
	 */
	public function test1List() {
		$this->mVerbose = false;
		$mk = static function ( $l ) {
			return [
				'list' => 'allpages',
				'apprefix' => 'AQCT-',
				'aplimit' => "$l",
			];
		};
		$data = $this->query( $mk( 99 ), 1, '1L', false ) +
			[ 'batchcomplete' => true ];

		// 1 list
		$this->checkC( $data, $mk( 1 ), 5, '1L-1' );
		$this->checkC( $data, $mk( 2 ), 3, '1L-2' );
		$this->checkC( $data, $mk( 3 ), 2, '1L-3' );
		$this->checkC( $data, $mk( 4 ), 2, '1L-4' );
		$this->checkC( $data, $mk( 5 ), 1, '1L-5' );
	}

	/**
	 * Test smart continue - list=allpages|alltransclusions
	 * @group medium
	 */
	public function test2Lists() {
		$this->mVerbose = false;
		$mk = static function ( $l1, $l2 ) {
			return [
				'list' => 'allpages|alltransclusions',
				'apprefix' => 'AQCT-',
				'atprefix' => 'AQCT-',
				'atunique' => '',
				'aplimit' => "$l1",
				'atlimit' => "$l2",
			];
		};
		// 2 lists
		$data = $this->query( $mk( 99, 99 ), 1, '2L', false ) +
			[ 'batchcomplete' => true ];
		$this->checkC( $data, $mk( 1, 1 ), 5, '2L-11' );
		$this->checkC( $data, $mk( 2, 2 ), 3, '2L-22' );
		$this->checkC( $data, $mk( 3, 3 ), 2, '2L-33' );
		$this->checkC( $data, $mk( 4, 4 ), 2, '2L-44' );
		$this->checkC( $data, $mk( 5, 5 ), 1, '2L-55' );
	}

	/**
	 * Test smart continue - generator=allpages, prop=links
	 * @group medium
	 */
	public function testGen1Prop() {
		$this->mVerbose = false;
		$mk = static function ( $g, $p ) {
			return [
				'generator' => 'allpages',
				'gapprefix' => 'AQCT-',
				'gaplimit' => "$g",
				'prop' => 'links',
				'pllimit' => "$p",
			];
		};
		// generator + 1 prop
		$data = $this->query( $mk( 99, 99 ), 1, 'G1P', false ) +
			[ 'batchcomplete' => true ];
		$this->checkC( $data, $mk( 1, 1 ), 11, 'G1P-11' );
		$this->checkC( $data, $mk( 2, 2 ), 6, 'G1P-22' );
		$this->checkC( $data, $mk( 3, 3 ), 4, 'G1P-33' );
		$this->checkC( $data, $mk( 4, 4 ), 3, 'G1P-44' );
		$this->checkC( $data, $mk( 5, 5 ), 2, 'G1P-55' );
	}

	/**
	 * Test smart continue - generator=allpages, prop=links|templates
	 * @group medium
	 */
	public function testGen2Prop() {
		$this->mVerbose = false;
		$mk = static function ( $g, $p1, $p2 ) {
			return [
				'generator' => 'allpages',
				'gapprefix' => 'AQCT-',
				'gaplimit' => "$g",
				'prop' => 'links|templates',
				'pllimit' => "$p1",
				'tllimit' => "$p2",
			];
		};
		// generator + 2 props
		$data = $this->query( $mk( 99, 99, 99 ), 1, 'G2P', false ) +
			[ 'batchcomplete' => true ];
		$this->checkC( $data, $mk( 1, 1, 1 ), 16, 'G2P-111' );
		$this->checkC( $data, $mk( 2, 2, 2 ), 9, 'G2P-222' );
		$this->checkC( $data, $mk( 3, 3, 3 ), 6, 'G2P-333' );
		$this->checkC( $data, $mk( 4, 4, 4 ), 4, 'G2P-444' );
		$this->checkC( $data, $mk( 5, 5, 5 ), 2, 'G2P-555' );
		$this->checkC( $data, $mk( 5, 1, 1 ), 10, 'G2P-511' );
		$this->checkC( $data, $mk( 4, 2, 2 ), 7, 'G2P-422' );
		$this->checkC( $data, $mk( 2, 3, 3 ), 7, 'G2P-233' );
		$this->checkC( $data, $mk( 2, 4, 4 ), 5, 'G2P-244' );
		$this->checkC( $data, $mk( 1, 5, 5 ), 5, 'G2P-155' );
	}

	/**
	 * Test smart continue - generator=allpages, prop=links, list=alltransclusions
	 * @group medium
	 */
	public function testGen1Prop1List() {
		$this->mVerbose = false;
		$mk = static function ( $g, $p, $l ) {
			return [
				'generator' => 'allpages',
				'gapprefix' => 'AQCT-',
				'gaplimit' => "$g",
				'prop' => 'links',
				'pllimit' => "$p",
				'list' => 'alltransclusions',
				'atprefix' => 'AQCT-',
				'atunique' => '',
				'atlimit' => "$l",
			];
		};
		// generator + 1 prop + 1 list
		$data = $this->query( $mk( 99, 99, 99 ), 1, 'G1P1L', false ) +
			[ 'batchcomplete' => true ];
		$this->checkC( $data, $mk( 1, 1, 1 ), 11, 'G1P1L-111' );
		$this->checkC( $data, $mk( 2, 2, 2 ), 6, 'G1P1L-222' );
		$this->checkC( $data, $mk( 3, 3, 3 ), 4, 'G1P1L-333' );
		$this->checkC( $data, $mk( 4, 4, 4 ), 3, 'G1P1L-444' );
		$this->checkC( $data, $mk( 5, 5, 5 ), 2, 'G1P1L-555' );
		$this->checkC( $data, $mk( 5, 5, 1 ), 4, 'G1P1L-551' );
		$this->checkC( $data, $mk( 5, 5, 2 ), 2, 'G1P1L-552' );
	}

	/**
	 * Test smart continue - generator=allpages, prop=links|templates,
	 *                       list=alllinks|alltransclusions, meta=siteinfo
	 * @group medium
	 */
	public function testGen2Prop2List1Meta() {
		$this->mVerbose = false;
		$mk = static function ( $g, $p1, $p2, $l1, $l2 ) {
			return [
				'generator' => 'allpages',
				'gapprefix' => 'AQCT-',
				'gaplimit' => "$g",
				'prop' => 'links|templates',
				'pllimit' => "$p1",
				'tllimit' => "$p2",
				'list' => 'alllinks|alltransclusions',
				'alprefix' => 'AQCT-',
				'alunique' => '',
				'allimit' => "$l1",
				'atprefix' => 'AQCT-',
				'atunique' => '',
				'atlimit' => "$l2",
				'meta' => 'siteinfo',
				'siprop' => 'namespaces',
			];
		};
		// generator + 1 prop + 1 list
		$data = $this->query( $mk( 99, 99, 99, 99, 99 ), 1, 'G2P2L1M', false ) +
			[ 'batchcomplete' => true ];
		$this->checkC( $data, $mk( 1, 1, 1, 1, 1 ), 16, 'G2P2L1M-11111' );
		$this->checkC( $data, $mk( 2, 2, 2, 2, 2 ), 9, 'G2P2L1M-22222' );
		$this->checkC( $data, $mk( 3, 3, 3, 3, 3 ), 6, 'G2P2L1M-33333' );
		$this->checkC( $data, $mk( 4, 4, 4, 4, 4 ), 4, 'G2P2L1M-44444' );
		$this->checkC( $data, $mk( 5, 5, 5, 5, 5 ), 2, 'G2P2L1M-55555' );
		$this->checkC( $data, $mk( 5, 5, 5, 1, 1 ), 4, 'G2P2L1M-55511' );
		$this->checkC( $data, $mk( 5, 5, 5, 2, 2 ), 2, 'G2P2L1M-55522' );
		$this->checkC( $data, $mk( 5, 1, 1, 5, 5 ), 10, 'G2P2L1M-51155' );
		$this->checkC( $data, $mk( 5, 2, 2, 5, 5 ), 5, 'G2P2L1M-52255' );
	}

	/**
	 * Test smart continue - generator=templates, prop=templates
	 * @group medium
	 */
	public function testSameGenAndProp() {
		$this->mVerbose = false;
		$mk = static function ( $g, $gDir, $p, $pDir ) {
			return [
				'titles' => 'AQCT-1',
				'generator' => 'templates',
				'gtllimit' => "$g",
				'gtldir' => $gDir ? 'ascending' : 'descending',
				'prop' => 'templates',
				'tllimit' => "$p",
				'tldir' => $pDir ? 'ascending' : 'descending',
			];
		};
		// generator + 1 prop
		$data = $this->query( $mk( 99, true, 99, true ), 1, 'G=P', false ) +
			[ 'batchcomplete' => true ];

		$this->checkC( $data, $mk( 1, true, 1, true ), 4, 'G=P-1t1t' );
		$this->checkC( $data, $mk( 2, true, 2, true ), 2, 'G=P-2t2t' );
		$this->checkC( $data, $mk( 3, true, 3, true ), 2, 'G=P-3t3t' );
		$this->checkC( $data, $mk( 1, true, 3, true ), 4, 'G=P-1t3t' );
		$this->checkC( $data, $mk( 3, true, 1, true ), 2, 'G=P-3t1t' );

		$this->checkC( $data, $mk( 1, true, 1, false ), 4, 'G=P-1t1f' );
		$this->checkC( $data, $mk( 2, true, 2, false ), 2, 'G=P-2t2f' );
		$this->checkC( $data, $mk( 3, true, 3, false ), 2, 'G=P-3t3f' );
		$this->checkC( $data, $mk( 1, true, 3, false ), 4, 'G=P-1t3f' );
		$this->checkC( $data, $mk( 3, true, 1, false ), 2, 'G=P-3t1f' );

		$this->checkC( $data, $mk( 1, false, 1, true ), 4, 'G=P-1f1t' );
		$this->checkC( $data, $mk( 2, false, 2, true ), 2, 'G=P-2f2t' );
		$this->checkC( $data, $mk( 3, false, 3, true ), 2, 'G=P-3f3t' );
		$this->checkC( $data, $mk( 1, false, 3, true ), 4, 'G=P-1f3t' );
		$this->checkC( $data, $mk( 3, false, 1, true ), 2, 'G=P-3f1t' );

		$this->checkC( $data, $mk( 1, false, 1, false ), 4, 'G=P-1f1f' );
		$this->checkC( $data, $mk( 2, false, 2, false ), 2, 'G=P-2f2f' );
		$this->checkC( $data, $mk( 3, false, 3, false ), 2, 'G=P-3f3f' );
		$this->checkC( $data, $mk( 1, false, 3, false ), 4, 'G=P-1f3f' );
		$this->checkC( $data, $mk( 3, false, 1, false ), 2, 'G=P-3f1f' );
	}

	/**
	 * Test smart continue - generator=allpages, list=allpages
	 * @group medium
	 */
	public function testSameGenList() {
		$this->mVerbose = false;
		$mk = static function ( $g, $gDir, $l, $pDir ) {
			return [
				'generator' => 'allpages',
				'gapprefix' => 'AQCT-',
				'gaplimit' => "$g",
				'gapdir' => $gDir ? 'ascending' : 'descending',
				'list' => 'allpages',
				'apprefix' => 'AQCT-',
				'aplimit' => "$l",
				'apdir' => $pDir ? 'ascending' : 'descending',
			];
		};
		// generator + 1 list
		$data = $this->query( $mk( 99, true, 99, true ), 1, 'G=L', false ) +
			[ 'batchcomplete' => true ];

		$this->checkC( $data, $mk( 1, true, 1, true ), 5, 'G=L-1t1t' );
		$this->checkC( $data, $mk( 2, true, 2, true ), 3, 'G=L-2t2t' );
		$this->checkC( $data, $mk( 3, true, 3, true ), 2, 'G=L-3t3t' );
		$this->checkC( $data, $mk( 1, true, 3, true ), 5, 'G=L-1t3t' );
		$this->checkC( $data, $mk( 3, true, 1, true ), 5, 'G=L-3t1t' );
		$this->checkC( $data, $mk( 1, true, 1, false ), 5, 'G=L-1t1f' );
		$this->checkC( $data, $mk( 2, true, 2, false ), 3, 'G=L-2t2f' );
		$this->checkC( $data, $mk( 3, true, 3, false ), 2, 'G=L-3t3f' );
		$this->checkC( $data, $mk( 1, true, 3, false ), 5, 'G=L-1t3f' );
		$this->checkC( $data, $mk( 3, true, 1, false ), 5, 'G=L-3t1f' );
		$this->checkC( $data, $mk( 1, false, 1, true ), 5, 'G=L-1f1t' );
		$this->checkC( $data, $mk( 2, false, 2, true ), 3, 'G=L-2f2t' );
		$this->checkC( $data, $mk( 3, false, 3, true ), 2, 'G=L-3f3t' );
		$this->checkC( $data, $mk( 1, false, 3, true ), 5, 'G=L-1f3t' );
		$this->checkC( $data, $mk( 3, false, 1, true ), 5, 'G=L-3f1t' );
		$this->checkC( $data, $mk( 1, false, 1, false ), 5, 'G=L-1f1f' );
		$this->checkC( $data, $mk( 2, false, 2, false ), 3, 'G=L-2f2f' );
		$this->checkC( $data, $mk( 3, false, 3, false ), 2, 'G=L-3f3f' );
		$this->checkC( $data, $mk( 1, false, 3, false ), 5, 'G=L-1f3f' );
		$this->checkC( $data, $mk( 3, false, 1, false ), 5, 'G=L-3f1f' );
	}
}
PK       ! v=      api/MockApi.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiBase;

class MockApi extends ApiBase {
	/** @var array */
	public $warnings = [];

	public function execute() {
	}

	public function __construct() {
	}

	public function getModulePath() {
		return $this->getModuleName();
	}

	public function addWarning( $warning, $code = null, $data = null ) {
		$this->warnings[] = $warning;
	}

	public function getAllowedParams() {
		return [
			'filename' => null,
			'enablechunks' => false,
			'sessionkey' => null,
		];
	}
}
PK       ! km2  2    api/ApiRevisionDeleteTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\Restriction\PageRestriction;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Title\Title;
use MWCryptRand;

/**
 * Tests for action=revisiondelete
 * @covers MediaWiki\Api\ApiRevisionDelete
 * @group API
 * @group medium
 * @group Database
 */
class ApiRevisionDeleteTest extends ApiTestCase {
	use MockAuthorityTrait;

	/** @var int[] */
	public $revs = [];

	protected function setUp(): void {
		parent::setUp();
		// Make a few edits for us to play with
		$title = Title::makeTitle( NS_HELP, 'ApiRevDel_test' );
		for ( $i = 1; $i <= 5; $i++ ) {
			$status = $this->editPage( $title, MWCryptRand::generateHex( 10 ), 'summary' );
			$this->revs[] = $status->getNewRevision()->getId();
		}
	}

	public function testHidingRevisions() {
		$performer = $this->mockRegisteredAuthorityWithPermissions( [ 'deleterevision' ] );
		$revid = array_shift( $this->revs );
		$out = $this->doApiRequestWithToken( [
			'action' => 'revisiondelete',
			'reason' => __METHOD__,
			'type' => 'revision',
			'target' => 'Help:ApiRevDel_test',
			'ids' => $revid,
			'hide' => 'content|user|comment',
		], null, $performer );
		// Check the output
		$out = $out[0]['revisiondelete'];
		$this->assertEquals( 'Success', $out['status'] );
		$this->assertArrayHasKey( 'items', $out );
		$item = $out['items'][0];
		$this->assertTrue( $item['userhidden'], 'userhidden' );
		$this->assertTrue( $item['commenthidden'], 'commenthidden' );
		$this->assertTrue( $item['texthidden'], 'texthidden' );
		$this->assertEquals( $revid, $item['id'] );

		// Now check that that revision was actually hidden
		$revRecord = $this->getServiceContainer()
			->getRevisionLookup()
			->getRevisionById( $revid );
		$this->assertNull( $revRecord->getContent( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ) );
		$this->assertNull( $revRecord->getComment( RevisionRecord::FOR_PUBLIC ) );
		$this->assertNull( $revRecord->getUser( RevisionRecord::FOR_PUBLIC ) );

		// Now test unhiding!
		$out2 = $this->doApiRequestWithToken( [
			'action' => 'revisiondelete',
			'reason' => __METHOD__,
			'type' => 'revision',
			'target' => 'Help:ApiRevDel_test',
			'ids' => $revid,
			'show' => 'content|user|comment',
		], null, $performer );

		// Check the output
		$out2 = $out2[0]['revisiondelete'];
		$this->assertEquals( 'Success', $out2['status'] );
		$this->assertArrayHasKey( 'items', $out2 );
		$item = $out2['items'][0];

		$this->assertFalse( $item['userhidden'], 'userhidden' );
		$this->assertFalse( $item['commenthidden'], 'commenthidden' );
		$this->assertFalse( $item['texthidden'], 'texthidden' );

		$this->assertEquals( $revid, $item['id'] );

		// Now check that that revision was actually unhidden
		$revRecord = $this->getServiceContainer()
			->getRevisionLookup()
			->getRevisionById( $revid );
		$this->assertNotNull( $revRecord->getContent( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ) );
		$this->assertNotNull( $revRecord->getComment( RevisionRecord::FOR_PUBLIC ) );
		$this->assertNotNull( $revRecord->getUser( RevisionRecord::FOR_PUBLIC ) );
	}

	public function testUnhidingOutput() {
		$performer = $this->mockRegisteredAuthorityWithPermissions( [ 'deleterevision' ] );
		$revid = array_shift( $this->revs );
		// Hide revisions
		$this->doApiRequestWithToken( [
			'action' => 'revisiondelete',
			'reason' => __METHOD__,
			'type' => 'revision',
			'target' => 'Help:ApiRevDel_test',
			'ids' => $revid,
			'hide' => 'content|user|comment',
		], null, $performer );

		$out = $this->doApiRequestWithToken( [
			'action' => 'revisiondelete',
			'reason' => __METHOD__,
			'type' => 'revision',
			'target' => 'Help:ApiRevDel_test',
			'ids' => $revid,
			'show' => 'comment',
		], null, $performer );
		$out = $out[0]['revisiondelete'];
		$this->assertEquals( 'Success', $out['status'] );
		$this->assertArrayHasKey( 'items', $out );
		$item = $out['items'][0];
		// Check it has userhidden & texthidden
		// but not commenthidden
		$this->assertTrue( $item['userhidden'], 'userhidden' );
		$this->assertFalse( $item['commenthidden'], 'commenthidden' );
		$this->assertTrue( $item['texthidden'], 'texthidden' );
		$this->assertEquals( $revid, $item['id'] );
	}

	public function testPartiallyBlockedPage() {
		$this->expectApiErrorCode( 'blocked' );
		$performer = $this->mockAnonAuthorityWithPermissions( [ 'deleterevision' ] );

		$block = new DatabaseBlock( [
			'address' => $performer->getUser(),
			'by' => static::getTestSysop()->getUser(),
			'sitewide' => false,
		] );

		$title = Title::makeTitle( NS_HELP, 'ApiRevDel_test' );
		$block->setRestrictions( [
			new PageRestriction( 0, $title->getArticleID() )
		] );
		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );

		$revid = array_shift( $this->revs );

		$this->doApiRequestWithToken( [
			'action' => 'revisiondelete',
			'reason' => __METHOD__,
			'type' => 'revision',
			'target' => $title->getPrefixedText(),
			'ids' => $revid,
			'hide' => 'content|user|comment',
		], null, $performer );
	}
}
PK       ! &,      api/ApiUndeleteTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * Tests for Undelete API.
 *
 * @group API
 * @group Database
 * @group medium
 *
 * @covers MediaWiki\Api\ApiUndelete
 */
class ApiUndeleteTest extends ApiTestCase {

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

		$this->overrideConfigValue( MainConfigNames::WatchlistExpiry, true );
	}

	/**
	 * @covers MediaWiki\Api\ApiUndelete::execute()
	 */
	public function testUndeleteWithWatch(): void {
		$title = Title::makeTitle( NS_MAIN, 'TestUndeleteWithWatch' );
		$sysop = $this->getTestSysop()->getUser();
		$watchlistManager = $this->getServiceContainer()->getWatchlistManager();

		// Create page.
		$this->editPage( $title, 'Test', '', NS_MAIN, $sysop );

		// Delete page.
		$this->doApiRequestWithToken( [
			'action' => 'delete',
			'title' => $title->getPrefixedText(),
		] );

		// For good measure.
		$this->assertFalse( $title->exists( IDBAccessObject::READ_LATEST ) );
		$this->assertFalse( $watchlistManager->isWatched( $sysop, $title ) );

		// Restore page, and watch with expiry.
		$this->doApiRequestWithToken( [
			'action' => 'undelete',
			'title' => $title->getPrefixedText(),
			'watchlist' => 'watch',
			'watchlistexpiry' => '99990123000000',
		] );

		$this->assertTrue( $title->exists( IDBAccessObject::READ_LATEST ) );
		$this->assertTrue( $watchlistManager->isTempWatched( $sysop, $title ) );
	}
}
PK       ! >'  '    api/ApiUserrightsTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Block\DatabaseBlock;
use MediaWiki\MainConfigNames;
use MediaWiki\MainConfigSchema;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use TestUserRegistry;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers MediaWiki\Api\ApiUserrights
 */
class ApiUserrightsTest extends ApiTestCase {

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

		$this->overrideConfigValues( [
			MainConfigNames::AddGroups => [],
			MainConfigNames::RemoveGroups => [],
		] );
	}

	/**
	 * Unsets $wgGroupPermissions['bureaucrat']['userrights'], and sets
	 * $wgAddGroups['bureaucrat'] and $wgRemoveGroups['bureaucrat'] to the
	 * specified values.
	 *
	 * @param array|bool $add Groups bureaucrats should be allowed to add, true for all
	 * @param array|bool $remove Groups bureaucrats should be allowed to remove, true for all
	 */
	protected function setPermissions( $add = [], $remove = [] ) {
		$this->setGroupPermissions( 'bureaucrat', 'userrights', false );

		if ( $add ) {
			$this->overrideConfigValue(
				MainConfigNames::AddGroups,
				[ 'bureaucrat' => $add ] + MainConfigSchema::getDefaultValue( MainConfigNames::AddGroups )
			);
		}
		if ( $remove ) {
			$this->overrideConfigValue(
				MainConfigNames::RemoveGroups,
				[ 'bureaucrat' => $remove ] + MainConfigSchema::getDefaultValue( MainConfigNames::RemoveGroups )
			);
		}
	}

	/**
	 * Perform an API userrights request that's expected to be successful.
	 *
	 * @param array|string $expectedGroups Group(s) that the user is expected
	 *   to have after the API request
	 * @param array $params Array to pass to doApiRequestWithToken().  'action'
	 *   => 'userrights' is implicit.  If no 'user' or 'userid' is specified,
	 *   we add a 'user' parameter.  If no 'add' or 'remove' is specified, we
	 *   add 'add' => 'sysop'.
	 * @param User|null $user The user that we're modifying.  The user must be
	 *   mutable, because we're going to change its groups!  null means that
	 *   we'll make up our own user to modify, and doesn't make sense if 'user'
	 *   or 'userid' is specified in $params.
	 */
	protected function doSuccessfulRightsChange(
		$expectedGroups = 'sysop', array $params = [], ?User $user = null
	) {
		$expectedGroups = (array)$expectedGroups;
		$params['action'] = 'userrights';

		if ( !$user ) {
			$user = $this->getMutableTestUser()->getUser();
		}

		$this->assertTrue( TestUserRegistry::isMutable( $user ),
			'Immutable user passed to doSuccessfulRightsChange!' );

		if ( !isset( $params['user'] ) && !isset( $params['userid'] ) ) {
			$params['user'] = $user->getName();
		}
		if ( !isset( $params['add'] ) && !isset( $params['remove'] ) ) {
			$params['add'] = 'sysop';
		}

		$res = $this->doApiRequestWithToken( $params );

		$user->clearInstanceCache();
		$this->getServiceContainer()->getPermissionManager()->invalidateUsersRightsCache();
		$this->assertSame(
			$expectedGroups, $this->getServiceContainer()->getUserGroupManager()->getUserGroups( $user )
		);

		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	/**
	 * Perform an API userrights request that's expected to fail.
	 *
	 * @param string $expectedCode Expected API error code
	 * @param array $params As for doSuccessfulRightsChange()
	 * @param User|null $user As for doSuccessfulRightsChange().  If there's no
	 *   user who will possibly be affected (such as if an invalid username is
	 *   provided in $params), pass null.
	 */
	private function doFailedRightsChange(
		$expectedCode, array $params = [], ?User $user = null
	) {
		$params['action'] = 'userrights';
		$userGroupManager = $this->getServiceContainer()->getUserGroupManager();

		$this->expectApiErrorCode( $expectedCode );

		if ( !$user ) {
			// If 'user' or 'userid' is specified and $user was not specified,
			// the user we're creating now will have nothing to do with the API
			// request, but that's okay, since we're just testing that it has
			// no groups.
			$user = $this->getMutableTestUser()->getUser();
		}

		$this->assertTrue( TestUserRegistry::isMutable( $user ),
			'Immutable user passed to doFailedRightsChange!' );

		if ( !isset( $params['user'] ) && !isset( $params['userid'] ) ) {
			$params['user'] = $user->getName();
		}
		if ( !isset( $params['add'] ) && !isset( $params['remove'] ) ) {
			$params['add'] = 'sysop';
		}
		$expectedGroups = $userGroupManager->getUserGroups( $user );

		try {
			$this->doApiRequestWithToken( $params );
		} finally {
			$user->clearInstanceCache();
			$this->assertSame( $expectedGroups, $userGroupManager->getUserGroups( $user ) );
		}
	}

	public function testAdd() {
		$this->doSuccessfulRightsChange();
	}

	public function testBlockedWithUserrights() {
		$user = $this->getTestSysop()->getUser();

		$block = new DatabaseBlock( [ 'address' => $user, 'by' => $user, ] );
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockStore->insertBlock( $block );

		$this->doSuccessfulRightsChange();
	}

	public function testBlockedWithoutUserrights() {
		$user = $this->getTestSysop()->getUser();

		$this->setPermissions( true, true );

		$block = new DatabaseBlock( [ 'address' => $user, 'by' => $user ] );
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockStore->insertBlock( $block );

		$this->doFailedRightsChange( 'blocked' );
	}

	public function testAddMultiple() {
		$this->doSuccessfulRightsChange(
			[ 'bureaucrat', 'sysop' ],
			[ 'add' => 'bureaucrat|sysop' ]
		);
	}

	public function testTooFewExpiries() {
		$this->doFailedRightsChange(
			'toofewexpiries',
			[ 'add' => 'sysop|bureaucrat|bot', 'expiry' => 'infinity|tomorrow' ]
		);
	}

	public function testTooManyExpiries() {
		$this->doFailedRightsChange(
			'toofewexpiries',
			[ 'add' => 'sysop|bureaucrat', 'expiry' => 'infinity|tomorrow|never' ]
		);
	}

	public function testInvalidExpiry() {
		$this->doFailedRightsChange( 'invalidexpiry', [ 'expiry' => 'yummy lollipops!' ] );
	}

	public function testMultipleInvalidExpiries() {
		$this->doFailedRightsChange(
			'invalidexpiry',
			[ 'add' => 'sysop|bureaucrat', 'expiry' => 'foo|bar' ]
		);
	}

	public function testWithTag() {
		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'custom tag' );

		$user = $this->getMutableTestUser()->getUser();

		$this->doSuccessfulRightsChange( 'sysop', [ 'tags' => 'custom tag' ], $user );

		$this->assertSame(
			'custom tag',
			$this->getDb()->newSelectQueryBuilder()
				->select( 'ctd_name' )
				->from( 'logging' )
				->join( 'change_tag', null, 'ct_log_id = log_id' )
				->join( 'change_tag_def', null, 'ctd_id = ct_tag_id' )
				->where( [ 'log_namespace' => NS_USER, 'log_title' => strtr( $user->getName(), ' ', '_' ) ] )
				->caller( __METHOD__ )->fetchField() );
	}

	public function testWithoutTagPermission() {
		$this->getServiceContainer()->getChangeTagsStore()->defineTag( 'custom tag' );

		$this->setGroupPermissions( 'user', 'applychangetags', false );

		$this->doFailedRightsChange(
			'tags-apply-no-permission',
			[ 'tags' => 'custom tag' ]
		);
	}

	public function testNonexistentUser() {
		$this->doFailedRightsChange(
			'nosuchuser',
			[ 'user' => 'Nonexistent user' ]
		);
	}

	public function testWebToken() {
		$sysop = $this->getTestSysop()->getUser();
		$user = $this->getMutableTestUser()->getUser();

		$token = $sysop->getEditToken( $user->getName() );

		$res = $this->doApiRequest( [
			'action' => 'userrights',
			'user' => $user->getName(),
			'add' => 'sysop',
			'token' => $token,
		] );

		$user->clearInstanceCache();
		$this->assertSame( [ 'sysop' ], $this->getServiceContainer()->getUserGroupManager()->getUserGroups( $user ) );

		$this->assertArrayNotHasKey( 'warnings', $res[0] );
	}

	/**
	 * Tests adding and removing various groups with various permissions.
	 *
	 * @dataProvider addAndRemoveGroupsProvider
	 * @param array|null $permissions [ [ $wgAddGroups, $wgRemoveGroups ] ] or null for 'userrights'
	 *   to be set in $wgGroupPermissions
	 * @param array $groupsToChange [ [ groups to add ], [ groups to remove ] ]
	 * @param array $expectedGroups Array of expected groups
	 */
	public function testAddAndRemoveGroups(
		?array $permissions, array $groupsToChange, array $expectedGroups
	) {
		if ( $permissions !== null ) {
			$this->setPermissions( $permissions[0], $permissions[1] );
		}

		$params = [
			'add' => implode( '|', $groupsToChange[0] ),
			'remove' => implode( '|', $groupsToChange[1] ),
		];

		// We'll take a bot so we have a group to remove
		$user = $this->getMutableTestUser( [ 'bot' ] )->getUser();

		$this->doSuccessfulRightsChange( $expectedGroups, $params, $user );
	}

	public static function addAndRemoveGroupsProvider() {
		return [
			'Simple add' => [
				[ [ 'sysop' ], [] ],
				[ [ 'sysop' ], [] ],
				[ 'bot', 'sysop' ]
			], 'Add with only remove permission' => [
				[ [], [ 'sysop' ] ],
				[ [ 'sysop' ], [] ],
				[ 'bot' ],
			], 'Add with global remove permission' => [
				[ [], true ],
				[ [ 'sysop' ], [] ],
				[ 'bot' ],
			], 'Simple remove' => [
				[ [], [ 'bot' ] ],
				[ [], [ 'bot' ] ],
				[],
			], 'Remove with only add permission' => [
				[ [ 'bot' ], [] ],
				[ [], [ 'bot' ] ],
				[ 'bot' ],
			], 'Remove with global add permission' => [
				[ true, [] ],
				[ [], [ 'bot' ] ],
				[ 'bot' ],
			], 'Add and remove same new group' => [
				null,
				[ [ 'sysop' ], [ 'sysop' ] ],
				// The userrights code does removals before adds, so it doesn't remove the sysop
				// group here and only adds it.
				[ 'bot', 'sysop' ],
			], 'Add and remove same existing group' => [
				null,
				[ [ 'bot' ], [ 'bot' ] ],
				// But here it first removes the existing group and then re-adds it.
				[ 'bot' ],
			],
		];
	}

	public function testWatched() {
		$user = $this->getMutableTestUser()->getUser();
		$userPage = Title::makeTitle( NS_USER, $user->getName() );
		$this->doSuccessfulRightsChange( 'sysop', [ 'watchuser' => true ], $user );
		$this->assertTrue( $this->getServiceContainer()->getWatchlistManager()
			->isWatched( $this->getTestSysop()->getUser(), $userPage ) );
	}
}
PK       ! 1ڇ      api/MockApiQueryBase.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiQueryBase;

class MockApiQueryBase extends ApiQueryBase {
	/** @var string */
	private $name;

	public function execute() {
	}

	public function __construct( $name = 'mock' ) {
		$this->name = $name;
	}

	public function getModuleName() {
		return $this->name;
	}

	public function getModulePath() {
		return 'query+' . $this->getModuleName();
	}
}
PK       ! ?      api/ApiLogoutTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\User\User;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers \MediaWiki\Api\ApiLogout
 */
class ApiLogoutTest extends ApiTestCase {

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

		parent::setUp();

		$user = $this->getTestSysop()->getUser();
		$wgRequest->getSession()->setUser( $user );
		$this->apiContext->setUser( $user );
	}

	public function testUserLogoutBadToken() {
		$user = $this->getTestSysop()->getUser();

		$this->expectApiErrorCode( 'badtoken' );
		try {
			$token = 'invalid token';
			$this->doUserLogout( $token, $user );
		} finally {
			$this->assertTrue( $user->isRegistered(), 'not logged out' );
		}
	}

	public function testUserLogoutAlreadyLoggedOut() {
		$user = $this->getServiceContainer()->getUserFactory()->newAnonymous( '1.2.3.4' );

		$this->assertFalse( $user->isRegistered() );
		$token = $this->getUserCsrfTokenFromApi( $user );
		$response = $this->doUserLogout( $token, $user )[0];
		$this->assertFalse( $user->isRegistered() );

		$this->assertArrayEquals(
			[ 'warnings' => [ 'logout' => [ 'warnings' => 'You must be logged in.' ] ] ],
			$response
		);
	}

	public function testUserLogout() {
		$user = $this->getTestSysop()->getUser();

		$this->assertTrue( $user->isRegistered() );
		$token = $this->getUserCsrfTokenFromApi( $user );
		$this->doUserLogout( $token, $user );
		$this->assertFalse( $user->isRegistered() );
	}

	public function testUserLogoutWithWebToken() {
		global $wgRequest;

		$user = $this->getTestSysop()->getUser();
		$this->assertTrue( $user->isRegistered() );

		// Logic copied from SkinTemplate.
		$token = $user->getEditToken( 'logoutToken', $wgRequest );

		$this->doUserLogout( $token, $user );
		$this->assertFalse( $user->isRegistered() );
	}

	private function getUserCsrfTokenFromApi( User $user ) {
		$retToken = $this->doApiRequest( [
			'action' => 'query',
			'meta' => 'tokens',
			'type' => 'csrf'
		], null, false, $user );

		$this->assertArrayNotHasKey( 'warnings', $retToken );

		return $retToken[0]['query']['tokens']['csrftoken'];
	}

	private function doUserLogout( $logoutToken, User $user ) {
		return $this->doApiRequest( [
			'action' => 'logout',
			'token' => $logoutToken
		], null, false, $user );
	}
}
PK       ! G.p  p     api/ApiFeedRecentChangesTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiFeedRecentChanges;
use MediaWiki\Api\ApiMain;
use MediaWiki\Context\RequestContext;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWikiIntegrationTestCase;

/**
 * @group API
 * @covers \MediaWiki\Api\ApiFeedRecentChanges
 */
class ApiFeedRecentChangesTest extends MediaWikiIntegrationTestCase {

	use TempUserTestTrait;

	private function commonTestGetAllowedParamsForHideAnons( $expectedMessageKey ) {
		$ctx = new RequestContext();
		$apiMain = new ApiMain( $ctx );
		$api = new ApiFeedRecentChanges(
			$apiMain,
			'feedrecentchanges',
			$this->getServiceContainer()->getSpecialPageFactory(),
			$this->getServiceContainer()->getTempUserConfig()
		);
		$params = $api->getAllowedParams();
		$this->assertArrayHasKey( 'hideanons', $params );
		$this->assertSame(
			$expectedMessageKey,
			$params['hideanons'][ApiBase::PARAM_HELP_MSG]
		);
	}

	public function testGetAllowedParamsWhenTemporaryAccountsAreEnabled() {
		$this->enableAutoCreateTempUser();
		$this->commonTestGetAllowedParamsForHideAnons(
			'apihelp-feedrecentchanges-param-hideanons-temp'
		);
	}

	public function testGetAllowedParamsWhenTemporaryAccountsAreNotEnabled() {
		$this->disableAutoCreateTempUser();
		$this->commonTestGetAllowedParamsForHideAnons(
			'apihelp-feedrecentchanges-param-hideanons'
		);
	}
}
PK       ! \+  +    api/ApiLoginTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Api;

use MediaWiki\Api\ApiErrorFormatter;
use MediaWiki\Auth\AbstractSecondaryAuthenticationProvider;
use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\UsernameAuthenticationRequest;
use MediaWiki\MainConfigNames;
use MediaWiki\Session\BotPasswordSessionProvider;
use MediaWiki\Session\SessionManager;
use MediaWiki\Session\Token;
use MediaWiki\User\BotPassword;
use MediaWiki\User\User;
use MWRestrictions;
use Wikimedia\TestingAccessWrapper;

/**
 * @group API
 * @group Database
 * @group medium
 *
 * @covers \MediaWiki\Api\ApiLogin
 */
class ApiLoginTest extends ApiTestCase {

	public static function provideEnableBotPasswords() {
		return [
			'Bot passwords enabled' => [ true ],
			'Bot passwords disabled' => [ false ],
		];
	}

	/**
	 * @dataProvider provideEnableBotPasswords
	 */
	public function testExtendedDescription( $enableBotPasswords ) {
		$this->overrideConfigValue(
			MainConfigNames::EnableBotPasswords,
			$enableBotPasswords
		);
		$ret = $this->doApiRequest( [
			'action' => 'paraminfo',
			'modules' => 'login',
			'helpformat' => 'raw',
		] );
		$this->assertSame(
			'apihelp-login-extended-description' . ( $enableBotPasswords ? '' : '-nobotpasswords' ),
			$ret[0]['paraminfo']['modules'][0]['description'][1]['key']
		);
	}

	/**
	 * Test result of attempted login with an empty username
	 */
	public function testNoName() {
		$session = [
			'wsTokenSecrets' => [ 'login' => 'foobar' ],
		];
		$ret = $this->doApiRequest( [
			'action' => 'login',
			'lgname' => '',
			'lgpassword' => $this->getTestSysop()->getPassword(),
			'lgtoken' => (string)( new Token( 'foobar', '' ) ),
		], $session );
		$this->assertSame( 'Failed', $ret[0]['login']['result'] );
	}

	/**
	 * @dataProvider provideEnableBotPasswords
	 */
	public function testDeprecatedUserLogin( $enableBotPasswords ) {
		$this->overrideConfigValue(
			MainConfigNames::EnableBotPasswords,
			$enableBotPasswords
		);

		$user = $this->getTestUser();

		$ret = $this->doApiRequest( [
			'action' => 'login',
			'lgname' => $user->getUser()->getName(),
		] );

		$this->assertSame(
			[ 'warnings' => ApiErrorFormatter::stripMarkup( wfMessage(
				'apiwarn-deprecation-login-token' )->text() ) ],
			$ret[0]['warnings']['login']
		);
		$this->assertSame( 'NeedToken', $ret[0]['login']['result'] );

		$ret = $this->doApiRequest( [
			'action' => 'login',
			'lgtoken' => $ret[0]['login']['token'],
			'lgname' => $user->getUser()->getName(),
			'lgpassword' => $user->getPassword(),
		], $ret[2] );

		$this->assertSame(
			[ 'warnings' => ApiErrorFormatter::stripMarkup( wfMessage(
				'apiwarn-deprecation-login-' . ( $enableBotPasswords ? '' : 'no' ) . 'botpw' )
				->text() ) ],
			$ret[0]['warnings']['login']
		);
		$this->assertSame(
			[
				'result' => 'Success',
				'lguserid' => $user->getUser()->getId(),
				'lgusername' => $user->getUser()->getName(),
			],
			$ret[0]['login']
		);
	}

	/**
	 * Attempts to log in with the given name and password, retrieves the returned token, and makes
	 * a second API request to actually log in with the token.
	 *
	 * @param string $name
	 * @param string $password
	 * @param array $params To pass to second request
	 * @return array Result of second doApiRequest
	 */
	private function doUserLogin( $name, $password, array $params = [] ) {
		$ret = $this->doApiRequest( [
			'action' => 'query',
			'meta' => 'tokens',
			'type' => 'login',
		] );

		$this->assertArrayNotHasKey( 'warnings', $ret );

		return $this->doApiRequest( array_merge(
			[
				'action' => 'login',
				'lgtoken' => $ret[0]['query']['tokens']['logintoken'],
				'lgname' => $name,
				'lgpassword' => $password,
			], $params
		), $ret[2] );
	}

	public function testBadToken() {
		$testUser = $this->getTestSysop();
		$userName = $testUser->getUser()->getName();
		$password = $testUser->getPassword();
		$testUser->getUser()->logout();

		$ret = $this->doUserLogin( $userName, $password, [ 'lgtoken' => 'invalid token' ] );

		$this->assertSame( 'WrongToken', $ret[0]['login']['result'] );
	}

	public function testLostSession() {
		$testUser = $this->getTestSysop();
		$userName = $testUser->getUser()->getName();
		$password = $testUser->getPassword();
		$testUser->getUser()->logout();

		$ret = $this->doApiRequest( [
			'action' => 'query',
			'meta' => 'tokens',
			'type' => 'login',
		] );

		$this->assertArrayNotHasKey( 'warnings', $ret );

		// Lose the session
		SessionManager::getGlobalSession()->clear();
		$ret[2] = [];

		$ret = $this->doApiRequest( [
			'action' => 'login',
			'lgtoken' => $ret[0]['query']['tokens']['logintoken'],
			'lgname' => $userName,
			'lgpassword' => $password,
			'errorformat' => 'raw',
		], $ret[2] );

		$this->assertSame( [
			'result' => 'Failed',
			'reason' => [
				'code' => 'sessionlost',
				'key' => 'authpage-cannot-login-continue',
				'params' => [],
			],
		], $ret[0]['login'] );
	}

	public function testBadPass() {
		$user = $this->getTestSysop()->getUser();
		$userName = $user->getName();
		$user->logout();

		$ret = $this->doUserLogin( $userName, 'bad', [ 'errorformat' => 'raw' ] );

		$this->assertSame( [
			'result' => 'Failed',
			'reason' => [
				'code' => 'wrongpassword',
				'key' => 'wrongpassword',
				'params' => [],
			],
		], $ret[0]['login'] );
	}

	/**
	 * @dataProvider provideEnableBotPasswords
	 */
	public function testGoodPass( $enableBotPasswords ) {
		$this->overrideConfigValue(
			MainConfigNames::EnableBotPasswords,
			$enableBotPasswords
		);

		$testUser = $this->getTestSysop();
		$userName = $testUser->getUser()->getName();
		$password = $testUser->getPassword();
		$testUser->getUser()->logout();

		$ret = $this->doUserLogin( $userName, $password );

		$this->assertSame( 'Success', $ret[0]['login']['result'] );
		$this->assertSame(
			[ 'warnings' => ApiErrorFormatter::stripMarkup( wfMessage(
				'apiwarn-deprecation-login-' . ( $enableBotPasswords ? '' : 'no' ) . 'botpw' )->
				text() ) ],
			$ret[0]['warnings']['login']
		);
	}

	/**
	 * @dataProvider provideEnableBotPasswords
	 */
	public function testUnsupportedAuthResponseType( $enableBotPasswords ) {
		$this->overrideConfigValue(
			MainConfigNames::EnableBotPasswords,
			$enableBotPasswords
		);

		$mockProvider = $this->createMock(
			AbstractSecondaryAuthenticationProvider::class );
		$mockProvider->method( 'beginSecondaryAuthentication' )->willReturn(
			AuthenticationResponse::newUI(
				[ new UsernameAuthenticationRequest ],
				// Slightly silly message here
				wfMessage( 'mainpage' )
			)
		);
		$mockProvider->method( 'getAuthenticationRequests' )
			->willReturn( [] );

		$this->mergeMwGlobalArrayValue( 'wgAuthManagerConfig', [
			'secondaryauth' => [ [
				'factory' => static function () use ( $mockProvider ) {
					return $mockProvider;
				},
			] ],
		] );

		$testUser = $this->getTestSysop();
		$userName = $testUser->getUser()->getName();
		$password = $testUser->getPassword();
		$testUser->getUser()->logout();

		$ret = $this->doUserLogin( $userName, $password );

		$this->assertSame( [ 'login' => [
			'result' => 'Aborted',
			'reason' => ApiErrorFormatter::stripMarkup( wfMessage(
				'api-login-fail-aborted' . ( $enableBotPasswords ? '' : '-nobotpw' ) )->text() ),
		] ], $ret[0] );
	}

	/**
	 * @return array [ $username, $password ] suitable for passing to an API request for successful login
	 */
	private function setUpForBotPassword() {
		global $wgSessionProviders;

		$this->overrideConfigValues( [
			// We can't use mergeMwGlobalArrayValue because it will overwrite the existing entry
			// with index 0
			MainConfigNames::SessionProviders => array_merge( $wgSessionProviders, [
				[
					'class' => BotPasswordSessionProvider::class,
					'args' => [ [ 'priority' => 40 ] ],
					'services' => [ 'GrantsInfo' ],
				],
			] ),
			MainConfigNames::EnableBotPasswords => true,
			MainConfigNames::CentralIdLookupProvider => 'local',
			MainConfigNames::GrantPermissions => [
				'test' => [ 'read' => true ],
			],
		] );

		// Make sure our session provider is present
		$manager = TestingAccessWrapper::newFromObject( SessionManager::singleton() );
		if ( !isset( $manager->sessionProviders[BotPasswordSessionProvider::class] ) ) {
			$tmp = $manager->sessionProviders;
			$manager->sessionProviders = null;
			$manager->sessionProviders = $tmp + $manager->getProviders();
		}
		$this->assertNotNull(
			SessionManager::singleton()->getProvider( BotPasswordSessionProvider::class )
		);

		$user = $this->getTestSysop()->getUser();
		$centralId = $this->getServiceContainer()
			->getCentralIdLookup()
			->centralIdFromLocalUser( $user );
		$this->assertNotSame( 0, $centralId );

		$password = 'ngfhmjm64hv0854493hsj5nncjud2clk';
		$passwordFactory = $this->getServiceContainer()->getPasswordFactory();
		// A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
		$passwordHash = $passwordFactory->newFromPlaintext( $password );

		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'bot_passwords' )
			->row( [
				'bp_user' => $centralId,
				'bp_app_id' => 'foo',
				'bp_password' => $passwordHash->toString(),
				'bp_token' => '',
				'bp_restrictions' => MWRestrictions::newDefault()->toJson(),
				'bp_grants' => '["test"]',
			] )
			->caller( __METHOD__ )
			->execute();

		$lgName = $user->getName() . BotPassword::getSeparator() . 'foo';

		return [ $lgName, $password ];
	}

	public function testBotPassword() {
		$ret = $this->doUserLogin( ...$this->setUpForBotPassword() );

		$this->assertSame( 'Success', $ret[0]['login']['result'] );
	}

	public function testBotPasswordThrottled() {
		// Undo high count from DevelopmentSettings.php
		$throttle = [
			[ 'count' => 5, 'seconds' => 30 ],
			[ 'count' => 100, 'seconds' => 60 * 60 * 48 ],
		];

		$this->setGroupPermissions( 'sysop', 'noratelimit', false );
		$this->overrideConfigValue(
			MainConfigNames::PasswordAttemptThrottle,
			$throttle
		);

		[ $name, $password ] = $this->setUpForBotPassword();

		for ( $i = 0; $i < $throttle[0]['count']; $i++ ) {
			$this->doUserLogin( $name, 'incorrectpasswordincorrectpassword' );
		}

		$ret = $this->doUserLogin( $name, $password );

		$this->assertSame( [
			'result' => 'Failed',
			'reason' => ApiErrorFormatter::stripMarkup( wfMessage( 'login-throttled' )->
				durationParams( $throttle[0]['seconds'] )->text() ),
		], $ret[0]['login'] );
	}

	public function testBotPasswordLocked() {
		$this->setTemporaryHook( 'UserIsLocked', static function ( User $unused, &$isLocked ) {
			$isLocked = true;
			return true;
		} );

		$ret = $this->doUserLogin( ...$this->setUpForBotPassword() );

		$this->assertSame( [
			'result' => 'Failed',
			'reason' => wfMessage( 'botpasswords-locked' )->text(),
		], $ret[0]['login'] );
	}

	public function testNoSameOriginSecurity() {
		$this->setTemporaryHook( 'RequestHasSameOriginSecurity',
			static function () {
				return false;
			}
		);

		$ret = $this->doApiRequest( [
			'action' => 'login',
			'errorformat' => 'plaintext',
		] )[0]['login'];

		$this->assertSame( [
			'result' => 'Aborted',
			'reason' => [
				'code' => 'api-login-fail-sameorigin',
				'text' => 'Cannot log in when the same-origin policy is not applied.',
			],
		], $ret );
	}
}
PK       ! jU,  ,    skins/SideBarTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;

/**
 * @covers \Skin
 * @covers \SkinTemplate
 * @group Skin
 * @group Database
 */
class SideBarTest extends MediaWikiLangTestCase {
	/** @var SkinTemplate */
	private $skin;
	/** @var string[][] Local cache for sidebar messages */
	private $messages;

	private function initMessagesHref() {
		# List of default messages for the sidebar. The sidebar doesn't care at
		# all whether they are full URLs, interwiki links or local titles.
		$URL_messages = [
			'mainpage',
			'portal-url',
			'currentevents-url',
			'recentchanges-url',
			'randompage-url',
			'helppage',
		];

		$messageCache = MediaWikiServices::getInstance()->getMessageCache();
		# We're assuming that isValidURI works as advertised: it's also
		# tested separately, in tests/phpunit/includes/HttpTest.php.
		foreach ( $URL_messages as $m ) {
			$titleName = $messageCache->get( $m );
			if ( MWHttpRequest::isValidURI( $titleName ) ) {
				$this->messages[$m]['href'] = $titleName;
			} else {
				$title = Title::newFromText( $titleName );
				$this->messages[$m]['href'] = $title->getLocalURL();
			}
		}
	}

	protected function setUp(): void {
		parent::setUp();
		$this->skin = new SkinTemplate();
		$this->skin->getContext()->setLanguage( 'en' );
	}

	/** @return array */
	public function provideSidebars() {
		$this->initMessagesHref();
		return [
			// sidebar with only two titles
			[
				[
					'Title1' => [],
					'Title2' => [],
				],
				'* Title1
* Title2
'
			],
			// expand messages
			[
				[ 'Title' => [
					[
						'text' => 'Help',
						'href' => $this->messages['helppage']['href'],
						'id' => 'n-help',
						'icon' => 'help',
						'active' => null
					]
				] ],
				'* Title
** helppage|help
'
			],
			// test tricky pipe - T35321 - Make sure there's a | after transforming.
			[
				[ 'Title' => [
					# The first 2 are skipped
					# Doesn't really test the url properly
					# because it will vary with $wgArticlePath et al.
					# ** Baz|Fred
					[
						'text' => 'Fred',
						'href' => Title::makeTitle( NS_MAIN, 'Baz' )->getLocalURL(),
						'id' => 'n-Fred',
						'active' => null,
						'icon' => null,
					],
					[
						'text' => 'title-to-display',
						'href' => Title::makeTitle( NS_MAIN, 'Page-to-go-to' )->getLocalURL(),
						'id' => 'n-title-to-display',
						'active' => null,
						'icon' => null,
					],
				] ],
				'* Title
** {{PAGENAME|Foo}}
** Bar
** Baz|Fred
** {{PLURAL:1|page-to-go-to{{int:pipe-separator/en}}title-to-display|branch not taken}}
'
			],

		];
	}

	/**
	 * @dataProvider provideSidebars
	 */
	public function testAddToSidebarPlain( $expected, $text, $message = '' ) {
		$bar = [];
		$this->skin->addToSidebarPlain( $bar, $text );
		$this->assertEquals( $expected, $bar, $message );
	}

	public function testExternalUrlsRequireADescription() {
		$this->overrideConfigValues( [
			MainConfigNames::NoFollowLinks => true,
			MainConfigNames::NoFollowDomainExceptions => [],
			MainConfigNames::NoFollowNsExceptions => [],
		] );

		$bar = [];
		$text = '* Title
** https://www.mediawiki.org/| Home
** http://valid.no.desc.org/
';
		$this->skin->addToSidebarPlain( $bar, $text );
		$this->assertEquals(
			[ 'Title' => [
				# ** https://www.mediawiki.org/| Home
				[
					'text' => 'Home',
					'href' => 'https://www.mediawiki.org/',
					'id' => 'n-Home',
					'active' => null,
					'icon' => null,
					'rel' => 'nofollow',
				],
				# ** http://valid.no.desc.org/
				# ... skipped since it is missing a pipe with a description
			] ],
			$bar
		);
	}

	public function testProtocolRelativeExternalUrl() {
		$this->overrideConfigValues( [
			MainConfigNames::NoFollowLinks => true,
			MainConfigNames::NoFollowDomainExceptions => [],
			MainConfigNames::NoFollowNsExceptions => [],
		] );

		$bar = [];
		$text = '* Title
** //www.mediawiki.org/| Home
';
		$this->skin->addToSidebarPlain( $bar, $text );
		$this->assertEquals(
			[ 'Title' => [
				# ** //www.mediawiki.org/| Home
				[
					'text' => 'Home',
					'href' => '//www.mediawiki.org/', // not /wiki///www.mediawiki.org/ (T364539)
					'id' => 'n-Home',
					'active' => null,
					'icon' => null,
					'rel' => 'nofollow',
				],
			] ],
			$bar
		);
	}

	private function getAttribs() {
		# Sidebar text we will use everytime
		$text = '* Title
** https://www.mediawiki.org/| Home';

		$bar = [];
		$this->skin->addToSidebarPlain( $bar, $text );

		return $bar['Title'][0];
	}

	/**
	 * Test our assertAttribs() helper function
	 * @coversNothing
	 */
	public function testTestAttributesAssertionHelper() {
		$this->overrideConfigValues( [
			MainConfigNames::NoFollowLinks => true,
			MainConfigNames::NoFollowDomainExceptions => [],
			MainConfigNames::NoFollowNsExceptions => [],
			MainConfigNames::ExternalLinkTarget => false,
		] );
		$attribs = $this->getAttribs();

		$this->assertArrayHasKey( 'rel', $attribs );
		$this->assertEquals( 'nofollow', $attribs['rel'] );

		$this->assertArrayNotHasKey( 'target', $attribs );
	}

	/**
	 * Test $wgNoFollowLinks in sidebar
	 */
	public function testRespectWgnofollowlinks() {
		$this->overrideConfigValue( MainConfigNames::NoFollowLinks, false );

		$attribs = $this->getAttribs();
		$this->assertArrayNotHasKey( 'rel', $attribs,
			'External URL in sidebar do not have rel=nofollow when $wgNoFollowLinks = false'
		);
	}

	/**
	 * Test $wgExternaLinkTarget in sidebar
	 * @dataProvider dataRespectExternallinktarget
	 */
	public function testRespectExternallinktarget( $externalLinkTarget ) {
		$this->overrideConfigValue( MainConfigNames::ExternalLinkTarget, $externalLinkTarget );

		$attribs = $this->getAttribs();
		$this->assertArrayHasKey( 'target', $attribs );
		$this->assertEquals( $attribs['target'], $externalLinkTarget );
	}

	public static function dataRespectExternallinktarget() {
		return [
			[ '_blank' ],
			[ '_self' ],
		];
	}
}
PK       ! ܛJ      skins/SkinMustacheTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Output\OutputPage;
use MediaWiki\Request\ContentSecurityPolicy;
use MediaWiki\Title\Title;
use PHPUnit\Framework\MockObject\MockObject;

/**
 * @covers \SkinMustache
 * @group Skin
 * @group Database
 */
class SkinMustacheTest extends MediaWikiIntegrationTestCase {

	/**
	 * @param string $html
	 * @param Title $title
	 * @return MockObject|OutputPage
	 */
	private function getMockOutputPage( $html, $title ) {
		$mockContentSecurityPolicy = $this->createMock( ContentSecurityPolicy::class );

		$mock = $this->createMock( OutputPage::class );
		$mock->method( 'getHTML' )
			->willReturn( $html );
		$mock->method( 'getCategoryLinks' )
			->willReturn( [] );
		$mock->method( 'getIndicators' )
			->willReturn( [
				'id' => '<a>indicator</a>'
			] );
		$mock->method( 'getTitle' )
			->willReturn( $title );
		$mock->method( 'getIndicators' )
			->willReturn( [ '' ] );
		$mock->method( 'getLanguageLinks' )
			->willReturn( [] );
		$mock->method( 'isTOCEnabled' )
			->willReturn( true );
		$mock->method( 'getTOCData' )
			->willReturn( null );
		return $mock;
	}

	private function validateTemplateData( $data, $key ) {
		$value = $data[$key];
		if ( $value === null ) {
			// Cannot validate a null value
			return;
		} elseif ( is_array( $value ) ) {
			$this->assertTrue(
				str_starts_with( $key, 'data-' ) || str_starts_with( $key, 'array-' ),
				"Template data that is an object should be associated with a key" .
				" prefixed with `data-` or `array-` ($key)"
			);

			// Validate the children
			foreach ( $value as $childKey => $childValue ) {
				if ( is_string( $childKey ) ) {
					$this->validateTemplateData( $value, $childKey );
				} else {
					$this->assertStringStartsWith(
						'array-',
						$key,
						"Template data that is a flat array should be associated with a key prefixed `array-` ($key)"
					);
				}
			}
		} elseif ( is_string( $value ) ) {
			if ( str_contains( $value, '<' ) ) {
				$this->assertTrue(
					str_starts_with( $key, 'html-' ) || $key === 'html',
					"Template data containing HTML must be prefixed with `html-` ($key)"
				);
			}
		} elseif ( is_bool( $value ) ) {
			$this->assertTrue(
				str_starts_with( $key, 'is-' ) || str_starts_with( $key, 'has-' ),
				"Template data containing booleans must be prefixed with `is-` or `has-` ($key)"
			);
		} elseif ( is_numeric( $value ) ) {
			$this->assertTrue(
				str_starts_with( $key, 'number-' ),
				"Template data containing numbers must be prefixed with `number-` ($key)"
			);
		} else {
			$this->fail(
				"Keys must be primitives e.g. arrays OR strings OR bools OR null ($key)."
			);
		}
	}

	/**
	 * @covers \Skin
	 * @covers \MediaWiki\Skin\SkinComponentLogo
	 * @covers \MediaWiki\Skin\SkinComponentSearch
	 * @covers \MediaWiki\Skin\SkinComponentTableOfContents
	 * @covers \MediaWiki\Skin\SkinComponentFooter
	 */
	public function testGetTemplateData() {
		$config = $this->getServiceContainer()->getMainConfig();
		$bodytext = '<p>hello</p>';
		$context = new RequestContext();
		$title = Title::makeTitle( NS_MAIN, 'Mustache skin' );
		$context->setTitle( $title );
		$out = $this->getMockOutputPage( $bodytext, $title );
		$context->setOutput( $out );
		$this->overrideConfigValue( MainConfigNames::Logos, [] );
		$skin = new SkinMustache( [
			'name' => 'test',
			'templateDirectory' => __DIR__,
		] );
		$context->setConfig( $config );
		$skin->setContext( $context );
		$data = $skin->getTemplateData();

		// Validate the default template data respects the naming rules
		foreach ( $data as $key => $_ ) {
			$this->validateTemplateData( $data, $key );
		}

		// Validate search data
		$searchData = $data['data-search-box'];
		foreach ( $searchData as $key => $_ ) {
			$this->validateTemplateData( $searchData, $key );
		}
	}
}
PK       ! ik        skins/test.mustachenu Iw        HELLO WORLD
PK       ! "CQ  CQ    skins/SkinTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Interwiki\InterwikiLookup;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MainConfigNames;
use MediaWiki\Output\OutputPage;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Permissions\Authority;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\Unit\MockBlockTrait;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityLookup;
use MediaWiki\User\UserIdentityValue;

/**
 * @covers \Skin
 * @group Database
 */
class SkinTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;
	use MockBlockTrait;
	use TempUserTestTrait;

	/**
	 * @covers \Skin
	 */
	public function testGetSkinName() {
		$skin = new SkinFallback();
		$this->assertEquals( 'fallback', $skin->getSkinName(), 'Default' );
		$skin = new SkinFallback( 'testname' );
		$this->assertEquals( 'testname', $skin->getSkinName(), 'Constructor argument' );
	}

	public function testGetDefaultModules() {
		$skin = $this->getMockBuilder( Skin::class )
			->onlyMethods( [ 'outputPage' ] )
			->getMock();

		$modules = $skin->getDefaultModules();
		$this->assertTrue( isset( $modules['core'] ), 'core key is set by default' );
		$this->assertTrue( isset( $modules['styles'] ), 'style key is set by default' );
	}

	/**
	 * @param bool $isSyndicated
	 * @param string $html
	 * @return OutputPage
	 */
	private function getMockOutputPage( $isSyndicated, $html ) {
		$mock = $this->createMock( OutputPage::class );
		$mock->expects( $this->once() )
			->method( 'isSyndicated' )
			->willReturn( $isSyndicated );
		$mock->method( 'getHTML' )
			->willReturn( $html );
		return $mock;
	}

	public static function provideGetDefaultModulesForOutput() {
		return [
			[
				false,
				'',
				[]
			],
			[
				true,
				'',
				[ 'mediawiki.feedlink' ]
			],
			[
				false,
				'FOO mw-ui-button BAR',
				[ 'mediawiki.ui.button' ]
			],
			[
				true,
				'FOO mw-ui-button BAR',
				[ 'mediawiki.ui.button', 'mediawiki.feedlink' ]
			],
		];
	}

	/**
	 * @dataProvider provideGetDefaultModulesForOutput
	 */
	public function testGetDefaultModulesForContent( $isSyndicated, $html, array $expectedModuleStyles ) {
		$skin = new class extends Skin {
			public function outputPage() {
			}
		};
		$fakeContext = new RequestContext();
		$fakeContext->setTitle( Title::makeTitle( NS_MAIN, 'Test' ) );
		$fakeContext->setOutput( $this->getMockOutputPage( $isSyndicated, $html ) );
		$skin->setContext( $fakeContext );

		$modules = $skin->getDefaultModules();

		$actualStylesModule = array_merge( ...array_values( $modules['styles'] ) );
		foreach ( $expectedModuleStyles as $expected ) {
			$this->assertContains( $expected, $actualStylesModule );
		}
	}

	public function provideGetDefaultModulesForRights() {
		yield 'no rights' => [
			$this->mockRegisteredNullAuthority(), // $authority
			false, // $hasModule
		];
		yield 'has all rights' => [
			$this->mockRegisteredUltimateAuthority(), // $authority
			true, // $hasModule
		];
	}

	/**
	 * @dataProvider provideGetDefaultModulesForRights
	 */
	public function testGetDefaultModulesForRights( Authority $authority, bool $hasModule ) {
		$skin = new class extends Skin {
			public function outputPage() {
			}
		};
		$fakeContext = new RequestContext();
		$fakeContext->setAuthority( $authority );
		$fakeContext->setTitle( Title::makeTitle( NS_MAIN, 'Test' ) );
		$skin->setContext( $fakeContext );

		$defaultModules = $skin->getDefaultModules();
		$this->assertArrayHasKey( 'watch', $defaultModules );
		if ( $hasModule ) {
			$this->assertContains( 'mediawiki.page.watch.ajax', $defaultModules['watch'] );
		} else {
			$this->assertNotContains( 'mediawiki.page.watch.ajax', $defaultModules['watch'] );
		}
	}

	public function providGetPageClasses() {
		yield 'normal page has namespace' => [
			new TitleValue( NS_MAIN, 'Test' ), // $title
			$this->mockRegisteredUltimateAuthority(), // $authority
			[ 'ns-0' ], // $expectedClasses
		];
		yield 'valid special page' => [
			new TitleValue( NS_SPECIAL, 'Userlogin' ), // $title
			$this->mockRegisteredUltimateAuthority(), // $authority
			[ 'mw-special-Userlogin' ], // $expectedClasses
		];
		yield 'invalid special page' => [
			new TitleValue( NS_SPECIAL, 'BLABLABLABLA_I_AM_INVALID' ), // $title
			$this->mockRegisteredUltimateAuthority(), // $authority
			[ 'mw-invalidspecialpage' ], // $expectedClasses
		];
		yield 'talk page' => [
			new TitleValue( NS_TALK, 'Test' ), // $title
			$this->mockRegisteredUltimateAuthority(), // $authority
			[ 'ns-talk' ], // $expectedClasses
		];
		yield 'subject' => [
			new TitleValue( NS_MAIN, 'Test' ), // $title
			$this->mockRegisteredUltimateAuthority(), // $authority
			[ 'ns-subject' ], // $expectedClasses
		];
		yield 'editable' => [
			new TitleValue( NS_MAIN, 'Test' ), // $title
			$this->mockRegisteredAuthorityWithPermissions( [ 'edit' ] ), // $authority
			[ 'mw-editable' ], // $expectedClasses
		];
		yield 'not editable' => [
			new TitleValue( NS_MAIN, 'Test' ), // $title
			$this->mockRegisteredNullAuthority(), // $authority
			[], // $expectedClasses
			[ 'mw-editable' ], // $unexpectedClasses
		];
	}

	/**
	 * @dataProvider providGetPageClasses
	 */
	public function testGetPageClasses(
		LinkTarget $title,
		Authority $authority,
		array $expectedClasses,
		array $unexpectedClasses = []
	) {
		$skin = new class extends Skin {
			public function outputPage() {
			}
		};
		$fakeContext = new RequestContext();
		$fakeContext->setAuthority( $authority );
		$skin->setContext( $fakeContext );
		$classes = $skin->getPageClasses( Title::newFromLinkTarget( $title ) );
		foreach ( $expectedClasses as $class ) {
			$this->assertStringContainsString( $class, $classes );
		}
		foreach ( $unexpectedClasses as $class ) {
			$this->assertStringNotContainsString( $class, $classes );
		}
	}

	/**
	 * @dataProvider provideSkinResponsiveOptions
	 */
	public function testIsResponsive( array $options, bool $expected ) {
		$skin = new class( $options ) extends Skin {

			/**
			 * @inheritDoc
			 */
			public function outputPage() {
			}

			/**
			 * @inheritDoc
			 */
			public function getUser() {
				$user = TestUserRegistry::getImmutableTestUser( [] )->getUser();
				\MediaWiki\MediaWikiServices::getInstance()->getUserOptionsManager()->setOption(
					$user,
					'skin-responsive',
					$this->options['userPreference']
				);
				return $user;
			}
		};

		$this->assertSame( $expected, $skin->isResponsive() );
	}

	public static function provideSkinResponsiveOptions() {
		yield 'responsive not set' => [
			[ 'name' => 'test', 'userPreference' => true ],
			false
		];
		yield 'responsive false' => [
			[ 'name' => 'test', 'responsive' => false, 'userPreference' => true ],
			false
		];
		yield 'responsive true' => [
			[ 'name' => 'test', 'responsive' => true, 'userPreference' => true ],
			true
		];
		yield 'responsive true, user preference false' => [
			[ 'name' => 'test', 'responsive' => true, 'userPreference' => false ],
			false
		];
		yield 'responsive false, user preference false' => [
			[ 'name' => 'test', 'responsive' => false, 'userPreference' => false ],
			false
		];
	}

	public static function provideMakeLink() {
		return [
			'Empty href with link class' => [
				[
					'text' => 'Test',
					'href' => '',
					'class' => [
						'class1',
						'class2'
					]
				],
				[ 'link-class' => 'link-class' ],
				'<a href="" class="class1 class2 link-class">Test</a>',
			],
			'link with link-html' => [
				[
					'text' => '',
					'href' => '#go',
					'link-html' => '<i>label</i>'
				],
				[ 'text-wrapper' => [ 'tag' => 'span' ] ],
				'<a href="#go"><i>label</i> </a>',
			],
			'Basic text wrapper' => [
				[
					'text' => 'Test',
				],
				[ 'text-wrapper' => [ 'tag' => 'span' ] ],
				'<span>Test</span>'
			],
			'Text wrapper with tooltip ID in id attribute' => [
				[
					'text' => 'Test',
					'id' => 'ii'
				],
				[ 'text-wrapper' => [ 'tag' => 'span' ] ],
				'<span title="(tooltip-ii)">Test</span>'
			],
			'Text wrapper with tooltip ID in single-id' => [
				[
					'text' => 'Test',
					'id' => 'foo',
					'single-id' => 'ii'
				],
				[ 'text-wrapper' => [ 'tag' => 'span' ] ],
				'<span title="(tooltip-ii)">Test</span>'
			],
			'Multi-level text wrapper with tooltip' => [
				[
					'text' => 'Test',
					'id' => 'ii'
				],
				[ 'text-wrapper' => [
					[ 'tag' => 'b' ],
					[ 'tag' => 'i' ]
				] ],
				'<b title="(tooltip-ii)"><i>Test</i></b>'
			],
			'Multi-level text wrapper with link' => [
				[
					'text' => 'Test',
					'id' => 'ii',
					'href' => '#',
				],
				[ 'text-wrapper' => [
					[ 'tag' => 'b' ],
					[ 'tag' => 'i' ]
				] ],
				'<a id="ii" href="#" title="(tooltip-ii)(word-separator)(brackets: (accesskey-ii))" ' .
				'accesskey="(accesskey-ii)"><b><i>Test</i></b></a>'
			],
			'Specified HTML' => [
				[
					'html' => '<b>1</b>',
				],
				[],
				'<b>1</b>'
			],
			'Data attribute' => [
				[
					'text' => 'Test',
					'href' => '#',
					'data' => [ 'foo' => 'bar' ]
				],
				[],
				'<a href="#" data-foo="bar">Test</a>'
			],
			'tooltip only' => [
				[
					'text' => 'Save',
					'id' => 'save',
					'href' => '#',
					'tooltiponly' => true,
				],
				[],
				'<a id="save" href="#" title="(tooltip-save)">Save</a>'
			]
		];
	}

	/**
	 * @dataProvider provideMakeLink
	 * @param array $data
	 * @param array $options
	 * @param string $expected
	 */
	public function testMakeLinkLink( array $data, array $options, string $expected ) {
		$this->setUserLang( 'qqx' );
		$skin = new class extends Skin {
			public function outputPage() {
			}
		};

		$link = $skin->makeLink(
			'test',
			$data,
			$options
		);

		$this->assertHTMLEquals(
			$expected,
			$link
		);
	}

	public static function provideGetPersonalToolsForMakeListItem() {
		return [
			[
				[
					'foo' => [
						'class' => 'foo',
						'link-html' => '<i>text</i>',
						'text' => 'Hello',
					],
				],
				false,
				[
					'foo' => [
						'links' => [
							[
								'single-id' => 'pt-foo',
								'text' => 'Hello',
								'link-html' => '<i>text</i>',
								'class' => 'foo',
							]
						],
						'id' => 'pt-foo',
					]
				],
			],
			[
				[
					'foo' => [
						'class' => 'foo',
						'link-html' => '<i>text</i>',
						'text' => 'Hello',
					],
				],
				true,
				[
					'foo' => [
						'links' => [
							[
								'single-id' => 'pt-foo',
								'text' => 'Hello',
								'link-html' => '<i>text</i>',
							]
						],
						'id' => 'pt-foo',
						'class' => 'foo',
					]
				],
			]
		];
	}

	/**
	 * @dataProvider provideGetPersonalToolsForMakeListItem
	 * @param array $urls
	 * @param bool $applyClassesToListItems
	 * @param array $expected
	 */
	public function testGetPersonalToolsForMakeListItem( array $urls, bool $applyClassesToListItems, array $expected ) {
		$skin = new class extends Skin {
			public function outputPage() {
			}
		};

		$this->assertSame(
			$expected,
			$skin->getPersonalToolsForMakeListItem(
				$urls,
				$applyClassesToListItems
			)
		);
	}

	public function testGetRelevantUser_get_set() {
		$skin = new class extends Skin {
			public function outputPage() {
			}
		};
		$relevantUser = UserIdentityValue::newRegistered( 1, 'SomeUser' );
		$skin->setRelevantUser( $relevantUser );
		$this->assertSame( $relevantUser, $skin->getRelevantUser() );

		$this->installMockBlockManager(
			[
				'target' => $relevantUser,
				'hideName' => true,
			]
		);

		$ctx = RequestContext::getMain();
		$ctx->setAuthority( $this->mockAnonNullAuthority() );
		$skin->setContext( $ctx );
		$this->assertNull( $skin->getRelevantUser() );

		$ctx->setAuthority( $this->mockAnonUltimateAuthority() );
		$skin->setContext( $ctx );
		$skin->setRelevantUser( $relevantUser );
		$skin->setRelevantUser( $relevantUser );
		$this->assertSame( $relevantUser, $skin->getRelevantUser() );
	}

	public static function provideGetRelevantUser_load_from_title() {
		yield 'Not user namespace' => [
			'relevantPage' => PageReferenceValue::localReference( NS_MAIN, '123.123.123.123' ),
			'expectedUser' => null
		];
		yield 'User namespace' => [
			'relevantPage' => PageReferenceValue::localReference( NS_USER, '123.123.123.123' ),
			'expectedUser' => UserIdentityValue::newAnonymous( '123.123.123.123' )
		];
		yield 'User talk namespace' => [
			'relevantPage' => PageReferenceValue::localReference( NS_USER_TALK, '123.123.123.123' ),
			'expectedUser' => UserIdentityValue::newAnonymous( '123.123.123.123' )
		];
		yield 'User page subpage' => [
			'relevantPage' => PageReferenceValue::localReference( NS_USER, '123.123.123.123/bla' ),
			'expectedUser' => UserIdentityValue::newAnonymous( '123.123.123.123' )
		];
		yield 'Non-registered user with name' => [
			'relevantPage' => PageReferenceValue::localReference( NS_USER, 'I_DO_NOT_EXIST' ),
			'expectedUser' => null
		];
	}

	/**
	 * @dataProvider provideGetRelevantUser_load_from_title
	 */
	public function testGetRelevantUser_load_from_title(
		PageReferenceValue $relevantPage,
		?UserIdentity $expectedUser
	) {
		$skin = new class extends Skin {
			public function outputPage() {
			}
		};
		$skin->setRelevantTitle( Title::newFromPageReference( $relevantPage ) );
		$relevantUser = $skin->getRelevantUser();
		if ( $expectedUser ) {
			$this->assertTrue( $expectedUser->equals( $relevantUser ) );
		} else {
			$this->assertNull( $relevantUser );
		}
	}

	public function testGetRelevantUser_load_existing() {
		$skin = new class extends Skin {
			public function outputPage() {
			}
		};
		$user = new UserIdentityValue( 42, 'foo' );
		$userIdentityLookup = $this->createMock( UserIdentityLookup::class );
		$userIdentityLookup->method( 'getUserIdentityByName' )
			->willReturnCallback( function ( $name ) use ( $user ) {
				if ( $name === $user->getName() ) {
					return $user;
				}
				return $this->createMock( UserIdentity::class );
			} );
		$this->setService( 'UserIdentityLookup', $userIdentityLookup );
		$skin->setRelevantTitle(
			Title::makeTitle( NS_USER, $user->getName() )
		);
		$this->assertTrue( $user->equals( $skin->getRelevantUser() ) );
		$this->assertSame( $user->getId(), $skin->getRelevantUser()->getId() );
	}

	public function testBuildSidebarCache() {
		// T303007: Skin subclasses and Skin hooks may vary their sidebar contents.
		$this->overrideConfigValues( [
			MainConfigNames::UseDatabaseMessages => true,
			MainConfigNames::EnableSidebarCache => true,
			MainConfigNames::SidebarCacheExpiry => 3600,
		] );
		// Mock time (T344191)
		$clock = 1301644800.0;
		$this->getServiceContainer()->getMainWANObjectCache()->setMockTime( $clock );
		$id = 0;
		$this->setTemporaryHook( 'SkinBuildSidebar',
			static function ( Skin $skin, array &$bar ) use ( &$id, &$clock ) {
				$id++;
				$clock += 1.0;
				if ( $skin->getSkinName() === 'foo' ) {
					$bar['myhook'] = "foo $id";
				}
				if ( $skin->getSkinName() === 'bar' ) {
					$bar['myhook'] = "bar $id";
				}
			}
		);
		$context = RequestContext::newExtraneousContext( Title::makeTitle( NS_SPECIAL, 'Blankpage' ) );
		$foo1 = new class( 'foo' ) extends Skin {
			public function outputPage() {
			}
		};
		$foo2 = new class( 'foo' ) extends Skin {
			public function outputPage() {
			}
		};
		$bar = new class( 'bar' ) extends Skin {
			public function outputPage() {
			}
		};
		$foo1->setContext( $context );
		$foo2->setContext( $context );
		$bar->setContext( $context );
		$this->assertArrayContains( [ 'myhook' => 'foo 1' ], $foo1->buildSidebar(), 'fresh' );
		$clock += 0.01;
		$this->assertArrayContains( [ 'myhook' => 'foo 1' ], $foo2->buildSidebar(), 'cache hit' );
		$this->assertArrayContains( [ 'myhook' => 'bar 2' ], $bar->buildSidebar(), 'cache miss' );
	}

	public function testBuildSidebarWithUserAddedContent() {
		$this->overrideConfigValues( [
			MainConfigNames::UseDatabaseMessages => true,
			MainConfigNames::EnableSidebarCache => false
		] );
		$foo1 = new class( 'foo' ) extends Skin {
			public function outputPage() {
			}
		};
		$this->editPage( 'MediaWiki:Sidebar', <<<EOS
		* navigation
		** mainpage|mainpage-description
		** recentchanges-url|recentchanges
		** randompage-url|randompage
		** helppage|help-mediawiki
		* SEARCH
		* TOOLBOX
		** A|B
		* LANGUAGES
		** C|D
		EOS );

		$context = RequestContext::newExtraneousContext( Title::makeTitle( NS_MAIN, 'Main Page' ) );
		$foo1->setContext( $context );

		$this->assertArrayContains( [ [ 'id' => 'n-B', 'text' => 'B' ] ], $foo1->buildSidebar()['TOOLBOX'], 'Toolbox has user defined links' );

		$hasUserDefinedLinks = false;
		$languageLinks = $foo1->buildSidebar()['LANGUAGES'];
		foreach ( $languageLinks as $languageLink ) {
			if ( $languageLink['id'] === 'n-D' ) {
				$hasUserDefinedLinks = true;
				break;
			}
		}

		$this->assertSame( false, $hasUserDefinedLinks, 'Languages does not support user defined links' );
	}

	public function testBuildSidebarForContributionsPageOfTemporaryAccount() {
		// Don't allow extensions to modify the TOOLBOX array as we assert pretty strictly against it.
		$this->clearHook( 'SidebarBeforeOutput' );

		$this->overrideConfigValues( [
			MainConfigNames::UploadNavigationUrl => false,
			MainConfigNames::EnableUploads => false,
			MainConfigNames::EnableSpecialMute => true,
		] );
		$foo1 = new class( 'foo' ) extends Skin {
			public function outputPage() {
			}
		};

		// Simulate the settings and context for Special:Contributions for a temporary account
		// (no article related and relevant user set).
		$this->enableAutoCreateTempUser();
		$tempUser = $this->getServiceContainer()->getTempUserCreator()
			->create( null, new FauxRequest() )->getUser();
		$context = RequestContext::newExtraneousContext(
			Title::makeTitle( NS_SPECIAL, 'Contributions/' . $tempUser->getName() )
		);
		$context->setUser( $this->getTestSysop()->getUser() );
		$foo1->setContext( $context );
		$foo1->setRelevantUser( $tempUser );
		$foo1->getOutput()->setArticleRelated( false );

		// Verify that the "userrights" key is not present, by checking that the list of keys is as expected.
		$this->assertArrayEquals(
			[ 'contributions', 'log', 'blockip', 'mute', 'print', 'specialpages' ],
			array_keys( $foo1->buildSidebar()['TOOLBOX'] )
		);
	}

	public function testGetLanguagesHidden() {
		$this->overrideConfigValues( [
			MainConfigNames::HideInterlanguageLinks => true,
		] );
		$skin = new class extends Skin {
			public function outputPage() {
			}
		};
		$this->assertSame( [], $skin->getLanguages() );
	}

	public function testGetLanguages() {
		$this->overrideConfigValues( [
			MainConfigNames::HideInterlanguageLinks => false,
			MainConfigNames::InterlanguageLinkCodeMap => [ 'talk' => 'fr' ],
			MainConfigNames::LanguageCode => 'qqx',
		] );

		$mockOutputPage = $this->createMock( OutputPage::class );
		$mockOutputPage->method( 'getLanguageLinks' )
			// The 'talk' interwiki is a deliberate conflict with the
			// Talk namespace (T363538)
			->willReturn( [ 'en:Foo', 'talk:Page' ] );

		$fakeContext = new RequestContext();
		$fakeContext->setTitle( Title::makeTitle( NS_MAIN, 'Test' ) );
		$fakeContext->setOutput( $mockOutputPage );
		$fakeContext->setLanguage( 'en' );

		$hookContainer = $this->createMock( HookContainer::class );
		$this->setService( 'HookContainer', $hookContainer );

		$mockIwLookup = $this->createMock( InterwikiLookup::class );
		$mockIwLookup->method( 'isValidInterwiki' )->willReturn( true );
		$mockIwLookup->method( 'fetch' )->willReturnCallback( static function ( string $prefix ) {
			return new Interwiki(
				$prefix,
				"https://$prefix.example.com/$1"
			);
		} );
		$this->setService( 'InterwikiLookup', $mockIwLookup );

		$skin = new class extends Skin {
			public function outputPage() {
			}
		};
		$skin->setContext( $fakeContext );

		$this->assertSame( [
			[
				'href' => 'https://en.example.com/Foo',
				'text' => 'English',
				'title' => 'Foo – English',
				'class' => 'interlanguage-link interwiki-en',
				'link-class' => 'interlanguage-link-target',
				'lang' => 'en',
				'hreflang' => 'en',
				'data-title' => 'Foo',
				'data-language-autonym' => 'English',
				'data-language-local-name' => 'English',
			],
			[
				'href' => 'https://talk.example.com/Page',
				'text' => 'Français',
				'title' => 'Page – français',
				'class' => 'interlanguage-link interwiki-talk',
				'link-class' => 'interlanguage-link-target',
				'lang' => 'fr',
				'hreflang' => 'fr',
				'data-title' => 'Page',
				'data-language-autonym' => 'Français',
				'data-language-local-name' => 'français',
			],
		], $skin->getLanguages() );
	}
}
PK       ! '      skins/SkinTemplateTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;
use Wikimedia\TestingAccessWrapper;

// phpcs:ignore MediaWiki.Files.ClassMatchesFilename.NotMatch
class SkinQuickTemplateTest extends QuickTemplate {
	public function execute() {
	}
}

/**
 * @covers \SkinTemplate
 * @covers \Skin
 * @group Skin
 * @group Database
 * @author Bene* < benestar.wikimedia@gmail.com >
 */
class SkinTemplateTest extends MediaWikiIntegrationTestCase {
	/**
	 * @dataProvider makeListItemProvider
	 */
	public function testMakeListItem( $expected, $key, array $item, array $options, $message ) {
		$template = $this->getMockForAbstractClass( BaseTemplate::class );
		$template->set( 'skin', new SkinFallback( [
			'name' => 'fallback',
			'templateDirectory' => __DIR__,
		] ) );

		$this->assertEquals(
			$expected,
			$template->makeListItem( $key, $item, $options ),
			$message
		);
	}

	public static function makeListItemProvider() {
		return [
			[
				'<li class="class mw-list-item" title="itemtitle"><a href="url" title="title">text</a></li>',
				'',
				[
					'class' => 'class',
					'itemtitle' => 'itemtitle',
					'href' => 'url',
					'title' => 'title',
					'text' => 'text'
				],
				[],
				'Test makeListItem with normal values'
			]
		];
	}

	public static function provideGetFooterIcons() {
		return [
			// Test case 1
			[
				[
					MainConfigNames::FooterIcons => [],
				],
				[],
				'Empty list'
			],
			// Test case 2
			[
				[
					MainConfigNames::FooterIcons => [
						'poweredby' => [
							'mediawiki' => [
								'src' => '/w/resources/assets/poweredby_mediawiki_88x31.png',
								'url' => 'https://www.mediawiki.org/',
								'alt' => 'Powered by MediaWiki',
								'srcset' => '/w/resources/assets/poweredby_mediawiki_132x47.png 1.5x,' .
									' /w/resources/assets/poweredby_mediawiki_176x62.png 2x',
							]
						]
					],
				],
				[
					'poweredby' => [
						[
							'src' => '/w/resources/assets/poweredby_mediawiki_88x31.png',
							'url' => 'https://www.mediawiki.org/',
							'alt' => 'Powered by MediaWiki',
							'srcset' => '/w/resources/assets/poweredby_mediawiki_132x47.png 1.5x,' .
								' /w/resources/assets/poweredby_mediawiki_176x62.png 2x',
							'width' => 88,
							'height' => 31,
						]
					]
				],
				'Width and height are hardcoded if not provided'
			],
			// Test case 3
			[
				[
					MainConfigNames::FooterIcons => [
						'copyright' => [
							'copyright' => [],
						],
					],
				],
				[],
				'Empty arrays are filtered out'
			],
			// Test case 4
			[
				[
					MainConfigNames::FooterIcons => [
						'copyright' => [
							'copyright' => [
								'alt' => 'Wikimedia Foundation',
								'url' => 'https://wikimediafoundation.org'
							],
						],
					],
				],
				[],
				'Icons with no icon are filtered out'
			]
		];
	}

	/**
	 * @dataProvider provideGetFooterIcons
	 */
	public function testGetFooterIcons( $globals, $expected, $msg ) {
		$this->overrideConfigValues( $globals );
		$wrapper = TestingAccessWrapper::newFromObject( new SkinTemplate() );
		$icons = $wrapper->getFooterIcons();

		$this->assertEquals( $expected, $icons, $msg );
	}

	/**
	 * @dataProvider provideContentNavigation
	 * @param array $contentNavigation
	 * @param array $expected
	 */
	public function testInjectLegacyMenusIntoPersonalTools(
		array $contentNavigation,
		array $expected
	) {
		$wrapper = TestingAccessWrapper::newFromObject( new SkinTemplate() );

		$this->assertEquals(
			$expected,
			$wrapper->injectLegacyMenusIntoPersonalTools( $contentNavigation )
		);
	}

	public static function provideContentNavigation(): array {
		return [
			'No userpage set' => [
				'contentNavigation' => [
					'notifications' => [
						'notification 1' => []
					],
					'user-menu' => [
						'item 1' => [],
						'item 2' => [],
						'item 3' => []
					]
				],
				'expected' => [
					'item 1' => [],
					'item 2' => [],
					'item 3' => []
				]
			],
			'userpage set, no notifications' => [
				'contentNavigation' => [
					'notifications' => [],
					'user-menu' => [
						'item 1' => [],
						'userpage' => [],
						'item 2' => [],
						'item 3' => []
					]
				],
				'expected' => [
					'item 1' => [],
					'userpage' => [],
					'item 2' => [],
					'item 3' => []
				]
			],
			'userpage set, notification defined' => [
				'contentNavigation' => [
					'notifications' => [
						'notification 1' => []
					],
					'user-menu' => [
						'item 1' => [],
						'userpage' => [],
						'item 2' => [],
						'item 3' => []
					]
				],
				'expected' => [
					'item 1' => [],
					'userpage' => [],
					'notification 1' => [],
					'item 2' => [],
					'item 3' => []
				]
			],
			'userpage set, notification defined, user interface preferences set' => [
				'contentNavigation' => [
					'notifications' => [
						'notification 1' => []
					],
					'user-menu' => [
						'item 1' => [],
						'userpage' => [],
						'item 2' => [],
						'item 3' => []
					],
					'user-interface-preferences' => [
						'uls' => [],
					],
				],
				'expected' => [
					'uls' => [],
					'item 1' => [],
					'userpage' => [],
					'notification 1' => [],
					'item 2' => [],
					'item 3' => []
				]
			],
			'no userpage, no notifications, no user-interface-preferences' => [
				'contentNavigation' => [
					'user-menu' => [
						'item 1' => [],
						'item 2' => [],
						'item 3' => []
					],
				],
				'expected' => [
					'item 1' => [],
					'item 2' => [],
					'item 3' => []
				]
			]
		];
	}

	public function testGenerateHTML() {
		$wrapper = TestingAccessWrapper::newFromObject(
			new SkinTemplate( [ 'template' => 'SkinQuickTemplateTest', 'name' => 'test' ] )
		);

		$wrapper->getContext()->setTitle( Title::makeTitle( NS_MAIN, 'PrepareQuickTemplateTest' ) );
		$tpl = $wrapper->prepareQuickTemplate();
		$contentNav = $tpl->get( 'content_navigation' );

		$this->assertEquals( [ 'namespaces', 'views', 'actions', 'variants' ], array_keys( $contentNav ) );
	}
}
PK       ! +ʀo      interwiki/InterwikiTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use Wikimedia\Rdbms\Platform\ISQLPlatform;

/**
 * @covers \Interwiki
 * @group Database
 */
class InterwikiTest extends MediaWikiIntegrationTestCase {

	public function testConstructor() {
		$interwiki = new Interwiki(
			'xyz',
			'http://xyz.acme.test/wiki/$1',
			'http://xyz.acme.test/w/api.php',
			'xyzwiki',
			1,
			0
		);

		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'qqx' );

		$this->assertSame( '(interwiki-name-xyz)', $interwiki->getName() );
		$this->assertSame( '(interwiki-desc-xyz)', $interwiki->getDescription() );
		$this->assertSame( 'http://xyz.acme.test/w/api.php', $interwiki->getAPI() );
		$this->assertSame( 'http://xyz.acme.test/wiki/$1', $interwiki->getURL() );
		$this->assertSame( 'xyzwiki', $interwiki->getWikiID() );
		$this->assertTrue( $interwiki->isLocal() );
		$this->assertFalse( $interwiki->isTranscludable() );
	}

	public function testGetUrl() {
		$interwiki = new Interwiki(
			'xyz',
			'http://xyz.acme.test/wiki/$1'
		);

		$this->assertSame( 'http://xyz.acme.test/wiki/$1', $interwiki->getURL() );
		$this->assertSame( 'http://xyz.acme.test/wiki/Foo%26Bar', $interwiki->getURL( 'Foo&Bar' ) );
	}

	//// tests for static data access methods below ///////////////////////////////////////////////

	private function populateDB( $iwrows ) {
		$dbw = $this->getDb();
		$dbw->newDeleteQueryBuilder()
			->deleteFrom( 'interwiki' )
			->where( ISQLPlatform::ALL_ROWS )
			->caller( __METHOD__ )->execute();
		$dbw->newInsertQueryBuilder()
			->insertInto( 'interwiki' )
			->rows( $iwrows )
			->caller( __METHOD__ )
			->execute();
	}

	public function testDatabaseStorage() {
		// NOTE: database setup is expensive, so we only do
		//  it once and run all the tests in one go.
		$dewiki = [
			'iw_prefix' => 'de',
			'iw_url' => 'http://de.wikipedia.org/wiki/',
			'iw_api' => 'http://de.wikipedia.org/w/api.php',
			'iw_wikiid' => 'dewiki',
			'iw_local' => 1,
			'iw_trans' => 0
		];

		$zzwiki = [
			'iw_prefix' => 'zz',
			'iw_url' => 'http://zzwiki.org/wiki/',
			'iw_api' => 'http://zzwiki.org/w/api.php',
			'iw_wikiid' => 'zzwiki',
			'iw_local' => 0,
			'iw_trans' => 0
		];

		$this->populateDB( [ $dewiki, $zzwiki ] );

		$this->overrideConfigValue( MainConfigNames::InterwikiCache, false );

		$interwikiLookup = $this->getServiceContainer()->getInterwikiLookup();
		$this->assertEquals(
			[ $dewiki, $zzwiki ],
			$interwikiLookup->getAllPrefixes(),
			'getAllPrefixes()'
		);
		$this->assertEquals(
			[ $dewiki ],
			$interwikiLookup->getAllPrefixes( true ),
			'getAllPrefixes()'
		);
		$this->assertEquals(
			[ $zzwiki ],
			$interwikiLookup->getAllPrefixes( false ),
			'getAllPrefixes()'
		);

		$this->assertTrue( $interwikiLookup->isValidInterwiki( 'de' ), 'known prefix is valid' );
		$this->assertFalse( $interwikiLookup->isValidInterwiki( 'xyz' ), 'unknown prefix is valid' );

		$this->assertNull( $interwikiLookup->fetch( null ), 'no prefix' );
		$this->assertFalse( $interwikiLookup->fetch( 'xyz' ), 'unknown prefix' );

		$interwiki = $interwikiLookup->fetch( 'de' );
		$this->assertInstanceOf( Interwiki::class, $interwiki );
		$this->assertSame( $interwiki, $interwikiLookup->fetch( 'de' ), 'in-process caching' );

		$this->assertSame( 'http://de.wikipedia.org/wiki/', $interwiki->getURL(), 'getURL' );
		$this->assertSame( 'http://de.wikipedia.org/w/api.php', $interwiki->getAPI(), 'getAPI' );
		$this->assertSame( 'dewiki', $interwiki->getWikiID(), 'getWikiID' );
		$this->assertSame( true, $interwiki->isLocal(), 'isLocal' );
		$this->assertSame( false, $interwiki->isTranscludable(), 'isTranscludable' );

		$interwikiLookup->invalidateCache( 'de' );
		$this->assertNotSame( $interwiki, $interwikiLookup->fetch( 'de' ), 'invalidate cache' );
	}

}
PK       ! 2N    (  interwiki/ClassicInterwikiLookupTest.phpnu Iw        <?php

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Interwiki\ClassicInterwikiLookup;
use MediaWiki\MainConfigNames;
use MediaWiki\WikiMap\WikiMap;
use Wikimedia\ObjectCache\WANObjectCache;

/**
 * @covers \MediaWiki\Interwiki\ClassicInterwikiLookup
 * @group Database
 */
class ClassicInterwikiLookupTest extends MediaWikiIntegrationTestCase {

	private function populateDB( $iwrows ) {
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'interwiki' )
			->rows( $iwrows )
			->caller( __METHOD__ )
			->execute();
	}

	/**
	 * @param string[]|false $interwikiData
	 * @return ClassicInterwikiLookup
	 */
	private function getClassicInterwikiLookup( $interwikiData ): ClassicInterwikiLookup {
		$services = $this->getServiceContainer();
		$lang = $services->getLanguageFactory()->getLanguage( 'en' );
		$config = [
				MainConfigNames::InterwikiExpiry => 60 * 60,
				MainConfigNames::InterwikiCache => $interwikiData,
				MainConfigNames::InterwikiFallbackSite => 'en',
				MainConfigNames::InterwikiScopes => 3,
				'wikiId' => WikiMap::getCurrentWikiId(),
			];

		return new ClassicInterwikiLookup(
			new ServiceOptions(
				ClassicInterwikiLookup::CONSTRUCTOR_OPTIONS,
				$config
			),
			$lang,
			WANObjectCache::newEmpty(),
			$services->getHookContainer(),
			$services->getConnectionProvider()
		);
	}

	public function testDatabaseStorage() {
		// NOTE: database setup is expensive, so we only do
		//  it once and run all the tests in one go.
		$dewiki = [
			'iw_prefix' => 'de',
			'iw_url' => 'http://de.wikipedia.org/wiki/',
			'iw_api' => 'http://de.wikipedia.org/w/api.php',
			'iw_wikiid' => 'dewiki',
			'iw_local' => 1,
			'iw_trans' => 0
		];

		$zzwiki = [
			'iw_prefix' => 'zz',
			'iw_url' => 'http://zzwiki.org/wiki/',
			'iw_api' => 'http://zzwiki.org/w/api.php',
			'iw_wikiid' => 'zzwiki',
			'iw_local' => 0,
			'iw_trans' => 0
		];

		$this->populateDB( [ $dewiki, $zzwiki ] );
		$lookup = $this->getClassicInterwikiLookup( false );

		$this->assertEquals(
			[ $dewiki, $zzwiki ],
			$lookup->getAllPrefixes(),
			'getAllPrefixes()'
		);
		$this->assertEquals(
			[ $dewiki ],
			$lookup->getAllPrefixes( true ),
			'getAllPrefixes()'
		);
		$this->assertEquals(
			[ $zzwiki ],
			$lookup->getAllPrefixes( false ),
			'getAllPrefixes()'
		);

		$this->assertTrue( $lookup->isValidInterwiki( 'de' ), 'known prefix is valid' );
		$this->assertFalse( $lookup->isValidInterwiki( 'xyz' ), 'unknown prefix is valid' );

		$this->assertNull( $lookup->fetch( null ), 'no prefix' );
		$this->assertFalse( $lookup->fetch( 'xyz' ), 'unknown prefix' );

		$interwiki = $lookup->fetch( 'de' );
		$this->assertInstanceOf( Interwiki::class, $interwiki );
		$this->assertSame( $interwiki, $lookup->fetch( 'de' ), 'in-process caching' );

		$this->assertSame( 'http://de.wikipedia.org/wiki/', $interwiki->getURL(), 'getURL' );
		$this->assertSame( 'http://de.wikipedia.org/w/api.php', $interwiki->getAPI(), 'getAPI' );
		$this->assertSame( 'dewiki', $interwiki->getWikiID(), 'getWikiID' );
		$this->assertSame( true, $interwiki->isLocal(), 'isLocal' );
		$this->assertSame( false, $interwiki->isTranscludable(), 'isTranscludable' );

		$lookup->invalidateCache( 'de' );
		$this->assertNotSame( $interwiki, $lookup->fetch( 'de' ), 'invalidate cache' );
	}

	/**
	 * @param string $thisSite
	 * @param string[][] $local
	 * @param string[][] $global
	 *
	 * @return string[]
	 */
	private function populateHash( $thisSite, $local, $global ) {
		$hash = [];
		$hash[ '__sites:' . WikiMap::getCurrentWikiId() ] = $thisSite;

		$globals = [];
		$locals = [];

		foreach ( $local as $row ) {
			$prefix = $row['iw_prefix'];
			$data = $row['iw_local'] . ' ' . $row['iw_url'];
			$locals[] = $prefix;
			$hash[ "_{$thisSite}:{$prefix}" ] = $data;
		}

		foreach ( $global as $row ) {
			$prefix = $row['iw_prefix'];
			$data = $row['iw_local'] . ' ' . $row['iw_url'];
			$globals[] = $prefix;
			$hash[ "__global:{$prefix}" ] = $data;
		}

		$hash[ '__list:__global' ] = implode( ' ', $globals );
		$hash[ '__list:_' . $thisSite ] = implode( ' ', $locals );

		return $hash;
	}

	public function testArrayStorage() {
		$zzwiki = [
			'iw_prefix' => 'zz',
			'iw_url' => 'http://zzwiki.org/wiki/',
			'iw_local' => 0
		];
		$dewiki = [
			'iw_prefix' => 'de',
			'iw_url' => 'http://de.wikipedia.org/wiki/',
			'iw_local' => 1
		];

		$hash = $this->populateHash(
			'en',
			[ $dewiki ],
			[ $zzwiki ]
		);
		$lookup = $this->getClassicInterwikiLookup( $hash );

		$this->assertEquals(
			[ $zzwiki, $dewiki ],
			$lookup->getAllPrefixes(),
			'getAllPrefixes()'
		);

		$this->assertTrue( $lookup->isValidInterwiki( 'de' ), 'known prefix is valid' );
		$this->assertTrue( $lookup->isValidInterwiki( 'zz' ), 'known prefix is valid' );

		$interwiki = $lookup->fetch( 'de' );
		$this->assertInstanceOf( Interwiki::class, $interwiki );

		$this->assertSame( 'http://de.wikipedia.org/wiki/', $interwiki->getURL(), 'getURL' );
		$this->assertSame( true, $interwiki->isLocal(), 'isLocal' );

		$interwiki = $lookup->fetch( 'zz' );
		$this->assertInstanceOf( Interwiki::class, $interwiki );

		$this->assertSame( 'http://zzwiki.org/wiki/', $interwiki->getURL(), 'getURL' );
		$this->assertSame( false, $interwiki->isLocal(), 'isLocal' );
	}

	public function testGetAllPrefixes() {
		$zz = [
			'iw_prefix' => 'zz',
			'iw_url' => 'https://azz.example.org/',
			'iw_local' => 1
		];
		$de = [
			'iw_prefix' => 'de',
			'iw_url' => 'https://de.example.org/',
			'iw_local' => 1
		];
		$azz = [
			'iw_prefix' => 'azz',
			'iw_url' => 'https://azz.example.org/',
			'iw_local' => 1
		];

		$hash = $this->populateHash(
			'en',
			[],
			[ $zz, $de, $azz ]
		);
		$lookup = $this->getClassicInterwikiLookup( $hash );

		$this->assertEquals(
			[ $zz, $de, $azz ],
			$lookup->getAllPrefixes(),
			'getAllPrefixes() - preserves order'
		);
	}

}
PK       !     '  poolcounter/PoolWorkArticleViewTest.phpnu Iw        <?php

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Logger\Spi as LoggerSpi;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\PoolCounter\PoolWorkArticleView;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Status\Status;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

/**
 * @covers \MediaWiki\PoolCounter\PoolWorkArticleView
 * @group Database
 */
class PoolWorkArticleViewTest extends MediaWikiIntegrationTestCase {

	/**
	 * @param LoggerInterface|null $logger
	 *
	 * @return LoggerSpi
	 */
	protected function getLoggerSpi( $logger = null ) {
		$spi = $this->createNoOpMock( LoggerSpi::class, [ 'getLogger' ] );
		$spi->method( 'getLogger' )->willReturn( $logger ?? new NullLogger() );
		return $spi;
	}

	/**
	 * @param WikiPage $page
	 * @param RevisionRecord|null $rev
	 * @param ParserOptions|null $options
	 *
	 * @return PoolWorkArticleView
	 */
	protected function newPoolWorkArticleView(
		WikiPage $page,
		?RevisionRecord $rev = null,
		$options = null
	) {
		if ( !$options ) {
			$options = ParserOptions::newFromAnon();
		}

		if ( !$rev ) {
			$rev = $page->getRevisionRecord();
		}

		$revisionRenderer = $this->getServiceContainer()->getRevisionRenderer();

		return new PoolWorkArticleView(
			'test:' . $rev->getId(),
			$rev,
			$options,
			$revisionRenderer,
			$this->getLoggerSpi()
		);
	}

	private function makeRevision( WikiPage $page, $text ) {
		$user = $this->getTestUser()->getUser();
		$revision = $page->newPageUpdater( $user )
			->setContent( SlotRecord::MAIN, new WikitextContent( $text ) )
			->saveRevision( CommentStoreComment::newUnsavedComment( 'testing' ) );

		return $revision;
	}

	public function testDoWorkLoadRevision() {
		$options = ParserOptions::newFromAnon();
		$page = $this->getExistingTestPage( __METHOD__ );
		$rev1 = $this->makeRevision( $page, 'First!' );
		$rev2 = $this->makeRevision( $page, 'Second!' );

		$work = $this->newPoolWorkArticleView( $page, $rev1, $options );
		/** @var Status $status */
		$status = $work->execute();
		$this->assertStringContainsString( 'First', $status->getValue()->getText() );

		$work = $this->newPoolWorkArticleView( $page, $rev2, $options );
		/** @var Status $status */
		$status = $work->execute();
		$this->assertStringContainsString( 'Second', $status->getValue()->getText() );
	}

	public function testDoWorkParserCache() {
		$options = ParserOptions::newFromAnon();
		$page = $this->getExistingTestPage( __METHOD__ );
		$rev1 = $this->makeRevision( $page, 'First!' );

		$work = $this->newPoolWorkArticleView( $page, $rev1, $options );
		$work->execute();

		$cache = $this->getServiceContainer()->getParserCache();
		$out = $cache->get( $page, $options );

		$this->assertNotNull( $out );
		$this->assertNotFalse( $out );
		$this->assertStringContainsString( 'First', $out->getRawText() );
	}

	public function testDoWorkWithFakeRevision() {
		$options = ParserOptions::newFromAnon();
		$page = $this->getExistingTestPage( __METHOD__ );
		$rev = $this->makeRevision( $page, 'NOPE' );

		// Make a fake revision with different content and no revision ID or page ID,
		// and make sure the fake content is used.
		$fakeRev = new MutableRevisionRecord( $page->getTitle() );
		$fakeRev->setContent( SlotRecord::MAIN, new WikitextContent( 'YES!' ) );

		$work = $this->newPoolWorkArticleView( $page, $fakeRev, $options );
		/** @var Status $status */
		$status = $work->execute();

		$text = $status->getValue()->getText();
		$this->assertStringContainsString( 'YES!', $text );
		$this->assertStringNotContainsString( 'NOPE', $text );
	}

	public static function provideMagicWords() {
		yield 'PAGEID' => [
			'Test {{PAGEID}} Test',
			static function ( RevisionRecord $rev ) {
				return $rev->getPageId();
			}
		];
		yield 'REVISIONID' => [
			'Test {{REVISIONID}} Test',
			static function ( RevisionRecord $rev ) {
				return $rev->getId();
			}
		];
		yield 'REVISIONUSER' => [
			'Test {{REVISIONUSER}} Test',
			static function ( RevisionRecord $rev ) {
				return $rev->getUser()->getName();
			}
		];
		yield 'REVISIONTIMESTAMP' => [
			'Test {{REVISIONTIMESTAMP}} Test',
			static function ( RevisionRecord $rev ) {
				return $rev->getTimestamp();
			}
		];
	}

	/**
	 * @dataProvider provideMagicWords
	 */
	public function testMagicWords( $wikitext, $callback ) {
		static $counter = 1;

		$options = ParserOptions::newFromAnon();
		$page = $this->getNonexistingTestPage( __METHOD__ . $counter++ );
		$this->editPage( $page, $wikitext );
		$rev = $page->getRevisionRecord();

		// NOTE: provide the input as a string and let the PoolWorkArticleView create a fake
		// revision internally, to see if the magic words work with that fake. They should
		// work if the Parser causes the actual revision to be loaded when needed.
		$work = $this->newPoolWorkArticleView(
			$page,
			$page->getRevisionRecord(),
			$options,
			false
		);
		/** @var Status $status */
		$status = $work->execute();

		$expected = strval( $callback( $rev ) );
		$output = $status->getValue();

		$this->assertStringContainsString( $expected, $output->getText() );
	}

	public function testDoWorkDeletedContent() {
		$options = ParserOptions::newFromAnon();
		$page = $this->getExistingTestPage( __METHOD__ );
		$rev1 = $page->getRevisionRecord();

		// make another revision, since the latest revision cannot be deleted.
		$rev2 = $this->makeRevision( $page, 'Next' );

		// make a fake revision with deleted different content
		$fakeRev = new MutableRevisionRecord( $page->getTitle() );
		$fakeRev->setId( $rev1->getId() );
		$fakeRev->setPageId( $page->getId() );
		$fakeRev->setContent( SlotRecord::MAIN, new WikitextContent( 'SECRET' ) );
		$fakeRev->setVisibility( RevisionRecord::DELETED_TEXT );

		// rendering of a deleted revision should work, audience checks are bypassed
		$work = $this->newPoolWorkArticleView( $page, $fakeRev, $options );
		/** @var Status $status */
		$status = $work->execute();
		$this->assertStatusGood( $status );
	}

}
PK       ! l    *  poolcounter/PoolWorkArticleViewOldTest.phpnu Iw        <?php

use MediaWiki\Json\JsonCodec;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\RevisionOutputCache;
use MediaWiki\PoolCounter\PoolWorkArticleViewOld;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Status\Status;
use Psr\Log\NullLogger;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Stats\StatsFactory;
use Wikimedia\UUID\GlobalIdGenerator;

/**
 * @covers \MediaWiki\PoolCounter\PoolWorkArticleViewOld
 * @group Database
 */
class PoolWorkArticleViewOldTest extends PoolWorkArticleViewTest {

	/** @var RevisionOutputCache */
	private $cache = null;

	/**
	 * @param WikiPage $page
	 * @param RevisionRecord|null $rev
	 * @param ParserOptions|null $options
	 *
	 * @return PoolWorkArticleViewOld
	 */
	protected function newPoolWorkArticleView(
		WikiPage $page,
		?RevisionRecord $rev = null,
		$options = null
	) {
		if ( !$options ) {
			$options = ParserOptions::newFromAnon();
		}

		if ( !$rev ) {
			$rev = $page->getRevisionRecord();
		}

		if ( !$this->cache ) {
			$this->installRevisionOutputCache();
		}

		$renderer = $this->getServiceContainer()->getRevisionRenderer();

		return new PoolWorkArticleViewOld(
			'test:' . $rev->getId(),
			$this->cache,
			$rev,
			$options,
			$renderer,
			$this->getLoggerSpi()
		);
	}

	/**
	 * @param BagOStuff|null $bag
	 *
	 * @return RevisionOutputCache
	 */
	private function installRevisionOutputCache( $bag = null ) {
		$globalIdGenerator = $this->createMock( GlobalIdGenerator::class );
		$globalIdGenerator->method( 'newUUIDv1' )->willReturn( 'uuid-uuid' );
		$this->cache = new RevisionOutputCache(
			'test',
			new WANObjectCache( [ 'cache' => $bag ?: new HashBagOStuff() ] ),
			60 * 60,
			'20200101223344',
			new JsonCodec(),
			StatsFactory::newNull(),
			new NullLogger(),
			$globalIdGenerator
		);

		return $this->cache;
	}

	public function testUpdateCachedOutput() {
		$options = ParserOptions::newFromAnon();
		$page = $this->getExistingTestPage( __METHOD__ );

		$cache = $this->installRevisionOutputCache();

		$work = $this->newPoolWorkArticleView( $page, null, $options );
		/** @var Status $status */
		$status = $work->execute();
		$this->assertStatusGood( $status );

		$cachedOutput = $cache->get( $page->getRevisionRecord(), $options );
		$this->assertNotEmpty( $cachedOutput );
		$this->assertSame( $status->getValue()->getRawText(),
			$cachedOutput->getRawText() );
	}

	public function testDoesNotCacheNotSafe() {
		$page = $this->getExistingTestPage( __METHOD__ );

		$cache = $this->installRevisionOutputCache();

		$parserOptions = ParserOptions::newFromAnon();
		$parserOptions->setWrapOutputClass( 'wrapwrap' ); // Not safe to cache!

		$work = $this->newPoolWorkArticleView( $page, null, $parserOptions );
		/** @var Status $status */
		$status = $work->execute();
		$this->assertStatusGood( $status );

		$this->assertFalse( $cache->get( $page->getRevisionRecord(), $parserOptions ) );
	}

	public function testDoWorkWithFakeRevision() {
		// PoolWorkArticleViewOld caches the results, but things with null revid should
		// not be cached.
		$this->expectException( InvalidArgumentException::class );
		parent::testDoWorkWithFakeRevision();
	}
}
PK       ! f    .  poolcounter/PoolWorkArticleViewCurrentTest.phpnu Iw        <?php

use MediaWiki\Json\JsonCodec;
use MediaWiki\Parser\ParserCache;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\PoolCounter\PoolWorkArticleViewCurrent;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Status\Status;
use Psr\Log\NullLogger;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\Rdbms\ChronologyProtector;
use Wikimedia\Stats\StatsFactory;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\PoolCounter\PoolWorkArticleViewCurrent
 * @group Database
 */
class PoolWorkArticleViewCurrentTest extends PoolWorkArticleViewTest {

	/** @var ParserCache */
	private $parserCache = null;

	/**
	 * @param WikiPage $page
	 * @param RevisionRecord|null $rev
	 * @param ParserOptions|null $options
	 *
	 * @return PoolWorkArticleViewCurrent
	 */
	protected function newPoolWorkArticleView(
		WikiPage $page,
		?RevisionRecord $rev = null,
		$options = null
	) {
		if ( !$options ) {
			$options = ParserOptions::newFromAnon();
		}

		if ( !$rev ) {
			$rev = $page->getRevisionRecord();
		}

		$parserCache = $this->parserCache ?: $this->installParserCache();

		return new PoolWorkArticleViewCurrent(
			'test:' . $rev->getId(),
			$page,
			$rev,
			$options,
			$this->getServiceContainer()->getRevisionRenderer(),
			$parserCache,
			$this->getServiceContainer()->getConnectionProvider(),
			$this->getServiceContainer()->getChronologyProtector(),
			$this->getLoggerSpi(),
			$this->getServiceContainer()->getWikiPageFactory()
		);
	}

	private function installParserCache( $bag = null ) {
		$this->parserCache = new ParserCache(
			'test',
			$bag ?: new HashBagOStuff(),
			'',
			$this->getServiceContainer()->getHookContainer(),
			new JsonCodec(),
			StatsFactory::newNull(),
			new NullLogger(),
			$this->getServiceContainer()->getTitleFactory(),
			$this->getServiceContainer()->getWikiPageFactory(),
			$this->getServiceContainer()->getGlobalIdGenerator()
		);

		return $this->parserCache;
	}

	public function testUpdateCachedOutput() {
		$options = ParserOptions::newFromAnon();
		$page = $this->getExistingTestPage( __METHOD__ );

		$parserCache = $this->installParserCache();

		// rendering of a deleted revision should work, audience checks are bypassed
		$work = $this->newPoolWorkArticleView( $page, null, $options );
		/** @var Status $status */
		$status = $work->execute();
		$this->assertStatusGood( $status );

		$cachedOutput = $parserCache->get( $page, $options );
		$this->assertNotEmpty( $cachedOutput );
		$this->assertSame( $status->getValue()->getRawText(), $cachedOutput->getRawText() );
	}

	/**
	 * Test that cache miss is not cached in-process, so pool work can fetch
	 * a parse cached by other pool work after waiting for a lock. See T277829
	 */
	public function testFetchAfterMissWithLock() {
		$bag = new HashBagOStuff();
		$options = ParserOptions::newFromAnon();
		$page = $this->getExistingTestPage( __METHOD__ );

		$this->installParserCache( $bag );
		$work1 = $this->newPoolWorkArticleView( $page, null, $options );
		$this->assertFalse( $work1->getCachedWork() );

		// Pretend we're in another process with another ParserCache,
		// but share the backend store
		$this->installParserCache( $bag );
		$work2 = $this->newPoolWorkArticleView( $page, null, $options );
		/** @var Status $status2 */
		$status2 = $work2->execute();
		$this->assertStatusGood( $status2 );

		// The parser output cached but $work2 should now be also visible to $work1
		$status1 = $work1->getCachedWork();
		$this->assertInstanceOf( ParserOutput::class, $status1->getValue() );
		$this->assertSame( $status2->getValue()->getText(), $status1->getValue()->getText() );
	}

	public function testFallbackFromOutdatedParserCache() {
		// Fake Unix timestamps
		$lastWrite = 10;
		$outdated = $lastWrite;

		$chronologyProtector = $this->createNoOpMock( ChronologyProtector::class, [ 'getTouched' ] );
		$chronologyProtector->method( 'getTouched' )->willReturn( $lastWrite );

		$output = $this->createNoOpMock( ParserOutput::class, [ 'getCacheTime' ] );
		$output->method( 'getCacheTime' )->willReturn( $outdated );
		$this->parserCache = $this->createNoOpMock( ParserCache::class, [ 'getDirty' ] );
		$this->parserCache->method( 'getDirty' )->willReturn( $output );

		$work = $this->newPoolWorkArticleView(
			$this->createMock( WikiPage::class ),
			$this->createMock( RevisionRecord::class )
		);
		TestingAccessWrapper::newFromObject( $work )->chronologyProtector = $chronologyProtector;

		$this->assertFalse( $work->fallback( true ) );

		$status = $work->fallback( false );
		$this->assertInstanceOf( ParserOutput::class, $status->getValue() );
		$this->assertStatusWarning( 'view-pool-overload', $status );
	}

	public function testFallbackFromMoreRecentParserCache() {
		// Fake Unix timestamps
		$lastWrite = 10;
		$moreRecent = $lastWrite + 1;

		$chronologyProtector = $this->createNoOpMock( ChronologyProtector::class, [ 'getTouched' ] );
		$chronologyProtector->method( 'getTouched' )->willReturn( $lastWrite );

		$output = $this->createNoOpMock( ParserOutput::class, [ 'getCacheTime' ] );
		$output->method( 'getCacheTime' )->willReturn( $moreRecent );
		$this->parserCache = $this->createNoOpMock( ParserCache::class, [ 'getDirty' ] );
		$this->parserCache->method( 'getDirty' )->willReturn( $output );

		$work = $this->newPoolWorkArticleView(
			$this->createMock( WikiPage::class ),
			$this->createMock( RevisionRecord::class )
		);
		TestingAccessWrapper::newFromObject( $work )->chronologyProtector = $chronologyProtector;

		$status = $work->fallback( true );
		$this->assertInstanceOf( ParserOutput::class, $status->getValue() );
		$this->assertStatusWarning( 'view-pool-contention', $status );

		$status = $work->fallback( false );
		$this->assertInstanceOf( ParserOutput::class, $status->getValue() );
		$this->assertStatusWarning( 'view-pool-overload', $status );
	}

}
PK       ! ?u##  #    sparql/SparqlClientTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Sparql;

use MediaWiki\Http\HttpRequestFactory;
use MediaWiki\Sparql\SparqlClient;
use MWHttpRequest;

/**
 * @covers \MediaWiki\Sparql\SparqlClient
 */
class SparqlClientTest extends \PHPUnit\Framework\TestCase {

	private function getRequestFactory( $request ) {
		$requestFactory = $this->createMock( HttpRequestFactory::class );
		$requestFactory->method( 'create' )->willReturn( $request );
		return $requestFactory;
	}

	private function getRequestMock( $content ) {
		$request = $this->createMock( MWHttpRequest::class );
		$request->method( 'execute' )->willReturn( \MediaWiki\Status\Status::newGood( 200 ) );
		$request->method( 'getContent' )->willReturn( $content );
		return $request;
	}

	public function testQuery() {
		$json = <<<JSON
{
  "head" : {
    "vars" : [ "x", "y", "z" ]
  },
  "results" : {
    "bindings" : [ {
      "x" : {
        "type" : "uri",
        "value" : "http://wikiba.se/ontology#Dump"
      },
      "y" : {
        "type" : "uri",
        "value" : "http://creativecommons.org/ns#license"
      },
      "z" : {
        "type" : "uri",
        "value" : "http://creativecommons.org/publicdomain/zero/1.0/"
      }
    }, {
      "x" : {
        "type" : "uri",
        "value" : "http://wikiba.se/ontology#Dump"
      },
      "z" : {
        "type" : "literal",
        "value" : "0.1.0"
      }
    } ]
  }
}
JSON;

		$request = $this->getRequestMock( $json );
		$client = new SparqlClient( 'http://acme.test/', $this->getRequestFactory( $request ) );

		// values only
		$result = $client->query( "TEST SPARQL" );
		$this->assertCount( 2, $result );
		$this->assertEquals( 'http://wikiba.se/ontology#Dump', $result[0]['x'] );
		$this->assertEquals( 'http://creativecommons.org/ns#license', $result[0]['y'] );
		$this->assertSame( '0.1.0', $result[1]['z'] );
		$this->assertNull( $result[1]['y'] );
		// raw data format
		$result = $client->query( "TEST SPARQL 2", true );
		$this->assertCount( 2, $result );
		$this->assertEquals( 'uri', $result[0]['x']['type'] );
		$this->assertEquals( 'http://wikiba.se/ontology#Dump', $result[0]['x']['value'] );
		$this->assertEquals( 'literal', $result[1]['z']['type'] );
		$this->assertSame( '0.1.0', $result[1]['z']['value'] );
		$this->assertNull( $result[1]['y'] );
	}

	public function testBadQuery() {
		$request = $this->createMock( MWHttpRequest::class );
		$client = new SparqlClient( 'http://acme.test/', $this->getRequestFactory( $request ) );

		$request->method( 'execute' )->willReturn( \MediaWiki\Status\Status::newFatal( "Bad query" ) );
		$this->expectException( \MediaWiki\Sparql\SparqlException::class );
		$result = $client->query( "TEST SPARQL 3" );
	}

	public static function optionsProvider() {
		return [
			'defaults' => [
				'TEST тест SPARQL 4 ',
				null,
				null,
				[
					'http://acme.test/',
					'query=TEST+%D1%82%D0%B5%D1%81%D1%82+SPARQL+4+',
					'format=json',
					'maxQueryTimeMillis=30000',
				],
				[
					'method' => 'GET',
					'userAgent' => 'testOptions SparqlClient',
					'timeout' => 30
				]
			],
			'big query' => [
				str_repeat( 'ZZ', SparqlClient::MAX_GET_SIZE ),
				null,
				null,
				[
					'format=json',
					'maxQueryTimeMillis=30000',
				],
				[
					'method' => 'POST',
					'postData' => 'query=' . str_repeat( 'ZZ', SparqlClient::MAX_GET_SIZE ),
				]
			],
			'timeout 1s' => [
				'TEST SPARQL 4',
				null,
				1,
				[
					'maxQueryTimeMillis=1000',
				],
				[
					'timeout' => 1
				]
			],
			'more options' => [
				'TEST SPARQL 5',
				[
					'userAgent' => 'My Test',
					'randomOption' => 'duck',
				],
				null,
				[],
				[
					'userAgent' => 'My Test',
					'randomOption' => 'duck',
				]
			],

		];
	}

	/**
	 * @dataProvider  optionsProvider
	 * @param string $sparql
	 * @param array|null $options
	 * @param int|null $timeout
	 * @param array $expectedUrl
	 * @param array $expectedOptions
	 */
	public function testOptions( $sparql, $options, $timeout, $expectedUrl, $expectedOptions ) {
		$requestFactory = $this->createMock( HttpRequestFactory::class );
		$requestFactory->method( 'getUserAgent' )->willReturn( 'testOptions' );
		$client = new SparqlClient( 'http://acme.test/', $requestFactory );

		$request = $this->getRequestMock( '{}' );

		$requestFactory->method( 'create' )->willReturnCallback(
			function ( $url, $options ) use ( $request, $expectedUrl, $expectedOptions ) {
				foreach ( $expectedUrl as $eurl ) {
					$this->assertStringContainsString( $eurl, $url );
				}
				foreach ( $expectedOptions as $ekey => $evalue ) {
					$this->assertArrayHasKey( $ekey, $options );
					$this->assertEquals( $options[$ekey], $evalue );
				}
				return $request;
			}
		);

		if ( $options !== null ) {
			$client->setClientOptions( $options );
		}
		if ( $timeout !== null ) {
			$client->setTimeout( $timeout );
		}

		$result = $client->query( $sparql );
	}

}
PK       ! 87  7  !  profiler/ProfilingContextTest.phpnu Iw        <?php

use MediaWiki\Profiler\ProfilingContext;

/**
 * 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
 */

/**
 * @covers \MediaWiki\Profiler\ProfilingContext
 */
class ProfilingContextTest extends PHPUnit\Framework\TestCase {

	use MediaWikiCoversValidator;

	public static function provideEntryPointNames() {
		return [
			[ 'index', 'edit', 'index_edit' ],
			[ 'index', 'Recentchanges', 'index_Recentchanges' ],
			[ 'api', 'upload', 'api_upload' ],
			[ 'rest', '/wikibase/v1/something/{complex}/id', 'rest__wikibase_v1_something_complex_id' ]
		];
	}

	/**
	 * @dataProvider provideEntryPointNames
	 */
	public function testSetEntryPointHandler( $entryPoint, $handler, $metricName ) {
		$profilerContext = new ProfilingContext();

		$this->assertFalse( $profilerContext->isInitialized() );
		$profilerContext->init( $entryPoint, $handler );

		$this->assertTrue( $profilerContext->isInitialized() );
		$this->assertSame( $entryPoint, $profilerContext->getEntryPoint() );
		$this->assertSame( $handler, $profilerContext->getHandler() );
		$this->assertSame( $metricName, $profilerContext->getHandlerMetricPrefix() );
	}
}
PK       ! F    $  block/AutoblockExemptionListTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Block;

use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;

/**
 * @group Database
 * @group Blocking
 * @covers \MediaWiki\Block\AutoblockExemptionList
 */
class AutoblockExemptionListTest extends MediaWikiIntegrationTestCase {

	protected function setUp(): void {
		// Allow the MediaWiki override to take effect
		$this->getServiceContainer()->getMessageCache()->enable();
	}

	/** @dataProvider provideIsExempt */
	public function testIsExempt( $ip, $expectedReturnValue ) {
		$this->assertSame(
			$expectedReturnValue,
			$this->getServiceContainer()->getAutoblockExemptionList()->isExempt( $ip )
		);
	}

	public static function provideIsExempt() {
		return [
			'IP is exempt from autoblocks' => [ '1.2.3.4', true ],
			'IP is not exempt from autoblocks' => [ '1.2.3.5', false ],
			'IP is exempt from autoblocks based on IP range exemption' => [ '7.8.9.40', true ],
		];
	}

	public function addDBDataOnce() {
		$this->editPage(
			Title::newFromText( 'block-autoblock-exemptionlist', NS_MEDIAWIKI ),
			'[[Test]]. This is a autoblocking exemption list description.' .
			"\n\n* 1.2.3.4\n** 1.2.3.6\n* 7.8.9.0/24"
		);
	}
}
PK       ! te  e    block/BlockManagerTest.phpnu Iw        <?php

use MediaWiki\Block\BlockManager;
use MediaWiki\Block\CompositeBlock;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\SystemBlock;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\FauxResponse;
use MediaWiki\User\User;
use Psr\Log\NullLogger;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Blocking
 * @group Database
 * @covers \MediaWiki\Block\BlockManager
 */
class BlockManagerTest extends MediaWikiIntegrationTestCase {
	use TestAllServiceOptionsUsed;

	protected User $user;
	protected User $sysopUser;
	private array $blockManagerConfig;

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

		$this->user = $this->getTestUser()->getUser();
		$this->sysopUser = $this->getTestSysop()->getUser();
		$this->blockManagerConfig = [
			MainConfigNames::ApplyIpBlocksToXff => true,
			MainConfigNames::CookieSetOnAutoblock => true,
			MainConfigNames::CookieSetOnIpBlock => true,
			MainConfigNames::DnsBlacklistUrls => [],
			MainConfigNames::EnableDnsBlacklist => true,
			MainConfigNames::ProxyList => [],
			MainConfigNames::ProxyWhitelist => [],
			MainConfigNames::SecretKey => false,
			MainConfigNames::SoftBlockRanges => [],
		];
	}

	private function getBlockManager( $overrideConfig ) {
		return new BlockManager(
			...$this->getBlockManagerConstructorArgs( $overrideConfig )
		);
	}

	private function getBlockManagerConstructorArgs( $overrideConfig ) {
		$blockManagerConfig = array_merge( $this->blockManagerConfig, $overrideConfig );
		$this->overrideConfigValues( $blockManagerConfig );
		$services = $this->getServiceContainer();
		return [
			new LoggedServiceOptions(
				self::$serviceOptionsAccessLog,
				BlockManager::CONSTRUCTOR_OPTIONS,
				$services->getMainConfig()
			),
			$services->getUserFactory(),
			$services->getUserIdentityUtils(),
			new NullLogger(),
			$services->getHookContainer(),
			$services->getDatabaseBlockStore(),
			$services->getProxyLookup()
		];
	}

	public function testGetBlock() {
		// Reset so that hooks are called
		$permissionManager = $this->getServiceContainer()->getPermissionManager();
		$permissionManager->invalidateUsersRightsCache();

		$onGetUserBlockCalled = false;
		$onGetUserBlockIP = false;
		$this->setTemporaryHook(
			'GetUserBlock',
			static function ( $user, $ip, &$block ) use ( &$onGetUserBlockCalled, &$onGetUserBlockIP ) {
				$onGetUserBlockCalled = true;
				$onGetUserBlockIP = $ip;
				return true;
			}
		);

		$blockManager = $this->getBlockManager( [] );
		$block = $blockManager->getBlock(
			$this->user,
			null,
			false
		);

		// We don't actually care about the block, just whether or not the right hooks were called
		$this->assertTrue(
			$onGetUserBlockCalled,
			'Check that HookRunner::onGetUserBlock was called'
		);
		$this->assertNull(
			$onGetUserBlockIP,
			'The `GetUserBlock` hook should have been called with null since we ' .
			'didn\'t pass a request'
		);
	}

	/**
	 * @dataProvider provideBlocksForShouldApplyCookieBlock
	 */
	public function testGetBlockFromCookieValue( $options, $expected ) {
		/** @var BlockManager $blockManager */
		$blockManager = TestingAccessWrapper::newFromObject(
			$this->getBlockManager( [
				MainConfigNames::CookieSetOnAutoblock => true,
				MainConfigNames::CookieSetOnIpBlock => true,
			] )
		);

		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$block = new DatabaseBlock( array_merge( [
			'address' => $options['target'] ?: $this->user,
			'by' => $this->sysopUser,
		], $options['blockOptions'] ) );
		$blockStore->insertBlock( $block );

		$user = $options['registered'] ? $this->user : new User();
		$user->getRequest()->setCookie( 'BlockID', $blockManager->getCookieValue( $block ) );

		$this->assertSame( $expected, (bool)$blockManager->getBlockFromCookieValue(
			$user,
			$user->getRequest()
		) );
	}

	/**
	 * @dataProvider provideBlocksForShouldApplyCookieBlock
	 */
	public function testTrackBlockWithCookieRemovesBlocks( $options, $expectKeepCookie ) {
		/** @var BlockManager $blockManager */
		$blockManager = TestingAccessWrapper::newFromObject(
			$this->getBlockManager( [
				MainConfigNames::CookieSetOnAutoblock => true,
				MainConfigNames::CookieSetOnIpBlock => true,
			] )
		);

		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$block = new DatabaseBlock( array_merge( [
			'address' => $options['target'] ?: $this->user,
			'by' => $this->sysopUser,
		], $options['blockOptions'] ) );
		$blockStore->insertBlock( $block );

		$user = $options['registered'] ? $this->user : new User();
		$user->getRequest()->setCookie( 'BlockID', $blockManager->getCookieValue( $block ) );

		$response = new FauxResponse;

		$blockManager->trackBlockWithCookie(
			$user,
			$response
		);

		$this->assertCount(
			$expectKeepCookie ? 0 : 1,
			$response->getCookies()
		);
	}

	public static function provideBlocksForShouldApplyCookieBlock() {
		return [
			'Autoblocking user block' => [
				[
					'target' => '',
					'registered' => true,
					'blockOptions' => [
						'enableAutoblock' => true
					],
				],
				true,
			],
			'Autoblocking user block for anonymous user' => [
				[
					'target' => '',
					'registered' => false,
					'blockOptions' => [
						'enableAutoblock' => true
					],
				],
				true,
			],
			'Non-autoblocking user block' => [
				[
					'target' => '',
					'registered' => true,
					'blockOptions' => [],
				],
				false,
			],
			'IP block for anonymous user' => [
				[
					'target' => '127.0.0.1',
					'registered' => false,
					'blockOptions' => [],
				],
				true,
			],
			'IP block for logged in user' => [
				[
					'target' => '127.0.0.1',
					'registered' => true,
					'blockOptions' => [],
				],
				false,
			],
			'IP range block for anonymous user' => [
				[
					'target' => '127.0.0.0/8',
					'registered' => false,
					'blockOptions' => [],
				],
				true,
			],
		];
	}

	/**
	 * @dataProvider provideIsLocallyBlockedProxy
	 */
	public function testIsLocallyBlockedProxy( $proxyList, $expected ) {
		/** @var BlockManager $blockManager */
		$blockManager = TestingAccessWrapper::newFromObject(
			$this->getBlockManager( [
				MainConfigNames::ProxyList => $proxyList
			] )
		);

		$ip = '1.2.3.4';
		$this->assertSame( $expected, $blockManager->isLocallyBlockedProxy( $ip ) );
	}

	public static function provideIsLocallyBlockedProxy() {
		return [
			'Proxy list is empty' => [ [], false ],
			'Proxy list contains IP' => [ [ '1.2.3.4' ], true ],
			'Proxy list contains IP as value' => [ [ 'test' => '1.2.3.4' ], true ],
			'Proxy list contains range that covers IP' => [ [ '1.2.3.0/16' ], true ],
		];
	}

	/**
	 * @dataProvider provideIsDnsBlacklisted
	 */
	public function testIsDnsBlacklisted( $options, $expected ) {
		$blockManagerConfig = [
			MainConfigNames::EnableDnsBlacklist => true,
			MainConfigNames::DnsBlacklistUrls => $options['blacklist'],
			MainConfigNames::ProxyWhitelist => $options['whitelist'],
		];

		$blockManager = $this->getMockBuilder( BlockManager::class )
			->setConstructorArgs( $this->getBlockManagerConstructorArgs( $blockManagerConfig ) )
			->onlyMethods( [ 'checkHost' ] )
			->getMock();
		$blockManager->method( 'checkHost' )
			->willReturnMap( [ [
				$options['dnsblQuery'],
				$options['dnsblResponse'],
			] ] );

		$this->assertSame(
			$expected,
			$blockManager->isDnsBlacklisted( $options['ip'], $options['checkWhitelist'] )
		);
	}

	public static function provideIsDnsBlacklisted() {
		$dnsblFound = [ '127.0.0.2' ];
		$dnsblNotFound = false;
		return [
			'IP is blacklisted' => [
				[
					'blacklist' => [ 'dnsbl.test' ],
					'ip' => '127.0.0.1',
					'dnsblQuery' => '1.0.0.127.dnsbl.test',
					'dnsblResponse' => $dnsblFound,
					'whitelist' => [],
					'checkWhitelist' => false,
				],
				true,
			],
			'IP is blacklisted; blacklist has key' => [
				[
					'blacklist' => [ [ 'dnsbl.test', 'key' ] ],
					'ip' => '127.0.0.1',
					'dnsblQuery' => 'key.1.0.0.127.dnsbl.test',
					'dnsblResponse' => $dnsblFound,
					'whitelist' => [],
					'checkWhitelist' => false,
				],
				true,
			],
			'IP is blacklisted; blacklist is array' => [
				[
					'blacklist' => [ [ 'dnsbl.test' ] ],
					'ip' => '127.0.0.1',
					'dnsblQuery' => '1.0.0.127.dnsbl.test',
					'dnsblResponse' => $dnsblFound,
					'whitelist' => [],
					'checkWhitelist' => false,
				],
				true,
			],
			'IP is not blacklisted' => [
				[
					'blacklist' => [ 'dnsbl.test' ],
					'ip' => '1.2.3.4',
					'dnsblQuery' => '4.3.2.1.dnsbl.test',
					'dnsblResponse' => $dnsblNotFound,
					'whitelist' => [],
					'checkWhitelist' => false,
				],
				false,
			],
			'Blacklist is empty' => [
				[
					'blacklist' => [],
					'ip' => '127.0.0.1',
					'dnsblQuery' => '1.0.0.127.dnsbl.test',
					'dnsblResponse' => $dnsblFound,
					'whitelist' => [],
					'checkWhitelist' => false,
				],
				false,
			],
			'IP is blacklisted and whitelisted; whitelist is not checked' => [
				[
					'blacklist' => [ 'dnsbl.test' ],
					'ip' => '127.0.0.1',
					'dnsblQuery' => '1.0.0.127.dnsbl.test',
					'dnsblResponse' => $dnsblFound,
					'whitelist' => [ '127.0.0.1' ],
					'checkWhitelist' => false,
				],
				true,
			],
			'IP is blacklisted and whitelisted; whitelist is checked' => [
				[
					'blacklist' => [ 'dnsbl.test' ],
					'ip' => '127.0.0.1',
					'dnsblQuery' => '1.0.0.127.dnsbl.test',
					'dnsblResponse' => $dnsblFound,
					'whitelist' => [ '127.0.0.1' ],
					'checkWhitelist' => true,
				],
				false,
			],
		];
	}

	public function testGetUniqueBlocks() {
		$blockId = 100;

		/** @var BlockManager $blockManager */
		$blockManager = TestingAccessWrapper::newFromObject( $this->getBlockManager( [] ) );

		$block = $this->getMockBuilder( DatabaseBlock::class )
			->onlyMethods( [ 'getId' ] )
			->getMock();
		$block->method( 'getId' )
			->willReturn( $blockId );

		$autoblock = $this->getMockBuilder( DatabaseBlock::class )
			->onlyMethods( [ 'getParentBlockId', 'getType' ] )
			->getMock();
		$autoblock->method( 'getParentBlockId' )
			->willReturn( $blockId );
		$autoblock->method( 'getType' )
			->willReturn( DatabaseBlock::TYPE_AUTO );

		$blocks = [ $block, $block, $autoblock, new SystemBlock() ];

		$this->assertCount( 2, $blockManager->getUniqueBlocks( $blocks ) );
	}

	/**
	 * @dataProvider provideTrackBlockWithCookie
	 */
	public function testTrackBlockWithCookie( $options, $expected ) {
		$this->overrideConfigValue( MainConfigNames::CookiePrefix, '' );

		$request = new FauxRequest();
		if ( $options['cookieSet'] ) {
			$request->setCookie( 'BlockID', 'the value does not matter' );
		}
		/** @var FauxResponse $response */
		$response = $request->response();

		$user = $this->getMockBuilder( User::class )
			->onlyMethods( [ 'getBlock', 'getRequest' ] )
			->getMock();
		$user->method( 'getBlock' )
			->willReturn( $options['block'] );
		$user->method( 'getRequest' )
			->willReturn( $request );

		// Although the block cookie is set via DeferredUpdates, in command line mode updates are
		// processed immediately
		$blockManager = $this->getBlockManager( [
			MainConfigNames::SecretKey => '',
			MainConfigNames::CookieSetOnIpBlock => true,
		] );
		$blockManager->trackBlockWithCookie( $user, $response );

		$this->assertCount( $expected['count'], $response->getCookies() );
		$this->assertEquals( $expected['value'], $response->getCookie( 'BlockID' ) );
	}

	public function provideTrackBlockWithCookie() {
		$blockId = 123;
		return [
			'Block cookie is already set; there is a trackable block' => [
				[
					'cookieSet' => true,
					'block' => $this->getTrackableBlock( $blockId ),
				],
				[
					'count' => 1,
					'value' => $blockId,
				]
			],
			'Block cookie is already set; there is no block' => [
				[
					'cookieSet' => true,
					'block' => null,
				],
				[
					// Cookie is cleared by setting it to empty value
					'count' => 1,
					'value' => '',
				]
			],
			'Block cookie is not yet set; there is no block' => [
				[
					'cookieSet' => false,
					'block' => null,
				],
				[
					'count' => 0,
					'value' => null,
				]
			],
			'Block cookie is not yet set; there is a trackable block' => [
				[
					'cookieSet' => false,
					'block' => $this->getTrackableBlock( $blockId ),
				],
				[
					'count' => 1,
					'value' => $blockId,
				]
			],
			'Block cookie is not yet set; there is a composite block with a trackable block' => [
				[
					'cookieSet' => false,
					'block' => new CompositeBlock( [
						'originalBlocks' => [
							new SystemBlock(),
							$this->getTrackableBlock( $blockId ),
						]
					] ),
				],
				[
					'count' => 1,
					'value' => $blockId,
				]
			],
			'Block cookie is not yet set; there is a composite block but no trackable block' => [
				[
					'cookieSet' => false,
					'block' => new CompositeBlock( [
						'originalBlocks' => [
							new SystemBlock(),
							new SystemBlock(),
						]
					] ),
				],
				[
					'count' => 0,
					'value' => null,
				]
			],
		];
	}

	private function getTrackableBlock( $blockId ) {
		$block = $this->getMockBuilder( DatabaseBlock::class )
			->onlyMethods( [ 'getType', 'getId' ] )
			->getMock();
		$block->method( 'getType' )
			->willReturn( DatabaseBlock::TYPE_IP );
		$block->method( 'getId' )
			->willReturn( $blockId );
		return $block;
	}

	/**
	 * @dataProvider provideSetBlockCookie
	 */
	public function testSetBlockCookie( $expiryDelta, $expectedExpiryDelta ) {
		$this->overrideConfigValue( MainConfigNames::CookiePrefix, '' );

		$request = new FauxRequest();
		$response = $request->response();

		/** @var BlockManager $blockManager */
		$blockManager = TestingAccessWrapper::newFromObject(
			$this->getBlockManager( [
				MainConfigNames::SecretKey => '',
				MainConfigNames::CookieSetOnIpBlock => true,
			] )
		);

		$now = wfTimestamp();

		$block = new DatabaseBlock( [
			'expiry' => $expiryDelta === '' ? '' : $now + $expiryDelta
		] );
		$blockManager->setBlockCookie( $block, $response );
		$cookies = $response->getCookies();

		$this->assertEqualsWithDelta(
			$now + $expectedExpiryDelta,
			$cookies['BlockID']['expire'],
			60 // Allow actual to be up to 60 seconds later than expected
		);
	}

	public static function provideSetBlockCookie() {
		// Maximum length of a block cookie, defined in BlockManager::setBlockCookie
		$maxExpiryDelta = ( 24 * 60 * 60 );

		$longExpiryDelta = ( 48 * 60 * 60 );
		$shortExpiryDelta = ( 12 * 60 * 60 );

		return [
			'Block has indefinite expiry' => [
				'',
				$maxExpiryDelta,
			],
			'Block expiry is later than maximum cookie block expiry' => [
				$longExpiryDelta,
				$maxExpiryDelta,
			],
			'Block expiry is sooner than maximum cookie block expiry' => [
				$shortExpiryDelta,
				$shortExpiryDelta,
			],
		];
	}

	/**
	 * @dataProvider provideShouldTrackBlockWithCookie
	 */
	public function testShouldTrackBlockWithCookie( $options, $expected ) {
		$block = $this->getMockBuilder( DatabaseBlock::class )
			->onlyMethods( [ 'getType', 'isAutoblocking' ] )
			->getMock();
		$block->method( 'getType' )
			->willReturn( $options['type'] );
		if ( isset( $options['autoblocking'] ) ) {
			$block->method( 'isAutoblocking' )
				->willReturn( $options['autoblocking'] );
		}

		/** @var BlockManager $blockManager */
		$blockManager = TestingAccessWrapper::newFromObject(
			$this->getBlockManager( $options['blockManagerConfig'] )
		);

		$this->assertSame(
			$expected,
			$blockManager->shouldTrackBlockWithCookie( $block, $options['isAnon'] )
		);
	}

	public static function provideShouldTrackBlockWithCookie() {
		return [
			'IP block, anonymous user, IP block cookies enabled' => [
				[
					'type' => DatabaseBlock::TYPE_IP,
					'isAnon' => true,
					'blockManagerConfig' => [ MainConfigNames::CookieSetOnIpBlock => true ],
				],
				true
			],
			'IP range block, anonymous user, IP block cookies enabled' => [
				[
					'type' => DatabaseBlock::TYPE_RANGE,
					'isAnon' => true,
					'blockManagerConfig' => [ MainConfigNames::CookieSetOnIpBlock => true ],
				],
				true
			],
			'IP block, anonymous user, IP block cookies disabled' => [
				[
					'type' => DatabaseBlock::TYPE_IP,
					'isAnon' => true,
					'blockManagerConfig' => [ MainConfigNames::CookieSetOnIpBlock => false ],
				],
				false
			],
			'IP block, logged in user, IP block cookies enabled' => [
				[
					'type' => DatabaseBlock::TYPE_IP,
					'isAnon' => false,
					'blockManagerConfig' => [ MainConfigNames::CookieSetOnIpBlock => true ],
				],
				false
			],
			'User block, anonymous, autoblock cookies enabled, block is autoblocking' => [
				[
					'type' => DatabaseBlock::TYPE_USER,
					'isAnon' => true,
					'blockManagerConfig' => [ MainConfigNames::CookieSetOnAutoblock => true ],
					'autoblocking' => true,
				],
				false
			],
			'User block, logged in, autoblock cookies enabled, block is autoblocking' => [
				[
					'type' => DatabaseBlock::TYPE_USER,
					'isAnon' => false,
					'blockManagerConfig' => [ MainConfigNames::CookieSetOnAutoblock => true ],
					'autoblocking' => true,
				],
				true
			],
			'User block, logged in, autoblock cookies disabled, block is autoblocking' => [
				[
					'type' => DatabaseBlock::TYPE_USER,
					'isAnon' => false,
					'blockManagerConfig' => [ MainConfigNames::CookieSetOnAutoblock => false ],
					'autoblocking' => true,
				],
				false
			],
			'User block, logged in, autoblock cookies enabled, block is not autoblocking' => [
				[
					'type' => DatabaseBlock::TYPE_USER,
					'isAnon' => false,
					'blockManagerConfig' => [ MainConfigNames::CookieSetOnAutoblock => true ],
					'autoblocking' => false,
				],
				false
			],
			'Block type is autoblock' => [
				[
					'type' => DatabaseBlock::TYPE_AUTO,
					'isAnon' => true,
					'blockManagerConfig' => [],
				],
				false
			]
		];
	}

	public function testClearBlockCookie() {
		$this->overrideConfigValue( MainConfigNames::CookiePrefix, '' );

		$request = new FauxRequest();
		$response = $request->response();
		$response->setCookie( 'BlockID', '100' );
		$this->assertSame( '100', $response->getCookie( 'BlockID' ) );

		BlockManager::clearBlockCookie( $response );
		$this->assertSame( '', $response->getCookie( 'BlockID' ) );
	}

	/**
	 * @dataProvider provideGetIdFromCookieValue
	 */
	public function testGetIdFromCookieValue( $options, $expected ) {
		/** @var BlockManager $blockManager */
		$blockManager = TestingAccessWrapper::newFromObject(
			$this->getBlockManager( [ MainConfigNames::SecretKey => $options['secretKey'] ] ) );
		$this->assertEquals(
			$expected,
			$blockManager->getIdFromCookieValue( $options['cookieValue'] )
		);
	}

	public static function provideGetIdFromCookieValue() {
		$blockId = 100;
		$secretKey = '123';
		$hmac = MWCryptHash::hmac( $blockId, $secretKey, false );
		return [
			'No secret key is set' => [
				[
					'secretKey' => '',
					'cookieValue' => $blockId,
					'calculatedHmac' => MWCryptHash::hmac( $blockId, '', false ),
				],
				$blockId,
			],
			'Secret key is set and stored hmac is correct' => [
				[
					'secretKey' => $secretKey,
					'cookieValue' => $blockId . '!' . $hmac,
					'calculatedHmac' => $hmac,
				],
				$blockId,
			],
			'Secret key is set and stored hmac is incorrect' => [
				[
					'secretKey' => $secretKey,
					'cookieValue' => $blockId . '!xyz',
					'calculatedHmac' => $hmac,
				],
				null,
			],
		];
	}

	/**
	 * @dataProvider provideGetCookieValue
	 */
	public function testGetCookieValue( $options, $expected ) {
		/** @var BlockManager $blockManager */
		$blockManager = TestingAccessWrapper::newFromObject( $this->getBlockManager( [
			MainConfigNames::SecretKey => $options['secretKey']
		] ) );

		$block = $this->getMockBuilder( DatabaseBlock::class )
			->onlyMethods( [ 'getId' ] )
			->getMock();
		$block->method( 'getId' )
			->willReturn( $options['blockId'] );

		$this->assertEquals(
			$expected,
			$blockManager->getCookieValue( $block )
		);
	}

	public static function provideGetCookieValue() {
		$blockId = 100;
		return [
			'Secret key not set' => [
				[
					'secretKey' => '',
					'blockId' => $blockId,
					'hmac' => MWCryptHash::hmac( $blockId, '', false ),
				],
				$blockId,
			],
			'Secret key set' => [
				[
					'secretKey' => '123',
					'blockId' => $blockId,
					'hmac' => MWCryptHash::hmac( $blockId, '123', false ),
				],
				$blockId . '!' . MWCryptHash::hmac( $blockId, '123', false ) ],
		];
	}

	/**
	 * @dataProvider provideGetXffBlocks
	 */
	public function testGetXffBlocks(
		$applyIpBlocksToXff,
		$proxyWhiteList,
		$isAnon,
		$expected
	) {
		$xff = '1.2.3.4, 5.6.7.8, 9.10.11.12';
		$ip = '1.2.3.4';

		$blockManagerConfig = [
			MainConfigNames::ApplyIpBlocksToXff => $applyIpBlocksToXff,
			MainConfigNames::ProxyWhitelist => $proxyWhiteList,
		];

		$blockManagerMock = $this->getMockBuilder( BlockManager::class )
			->setConstructorArgs( $this->getBlockManagerConstructorArgs( $blockManagerConfig ) )
			->onlyMethods( [ 'getBlocksForIPList' ] )
			->getMock();
		$blockManagerMock->method( 'getBlocksForIPList' )
			->willReturnCallback( function () use ( $isAnon ) {
				if ( $isAnon ) {
					return [ $this->createMock( DatabaseBlock::class ) ];
				} else {
					return [];
				}
			} );

		/** @var BlockManager $blockManager */
		$blockManager = TestingAccessWrapper::newFromObject( $blockManagerMock );

		$this->assertSame(
			$expected,
			(bool)$blockManager->getXffBlocks( $ip, $xff, $isAnon, false )
		);
	}

	public static function provideGetXffBlocks() {
		return [
			'ApplyIpBlocksToXff config is false' => [
				'applyIpBlocksToXff' => false,
				'proxyWhiteList' => [],
				'isAnon' => true,
				false,
			],
			'IP is in ProxyWhiteList' => [
				'applyIpBlocksToXff' => true,
				'proxyWhiteList' => [ '1.2.3.4' ],
				'isAnon' => true,
				false,
			],
			'User is logged in' => [
				'applyIpBlocksToXff' => true,
				'proxyWhiteList' => [],
				'isAnon' => false,
				false,
			],
			'IP is in XFF list but not in ProxyWhiteList' => [
				'applyIpBlocksToXff' => true,
				'proxyWhiteList' => [],
				'isAnon' => true,
				true,
			],
		];
	}

	/**
	 * @dataProvider provideGetSystemIpBlocks
	 */
	public function testGetSystemIpBlocks(
		$proxyWhitelist,
		$softBlockRanges,
		$isLocallyBlockedProxy,
		$isDnsBlacklisted,
		$isAnon,
		$expected
	) {
		$ip = '1.2.3.4';

		$blockManagerConfig = [
			MainConfigNames::ProxyWhitelist => $proxyWhitelist,
			MainConfigNames::SoftBlockRanges => $softBlockRanges,
			MainConfigNames::ProxyList => ( $isLocallyBlockedProxy ? [ $ip ] : [] ),
		];

		$blockManagerMock = $this->getMockBuilder( BlockManager::class )
			->setConstructorArgs( $this->getBlockManagerConstructorArgs( $blockManagerConfig ) )
			->onlyMethods( [ 'isDnsBlacklisted' ] )
			->getMock();
		$blockManagerMock->method( 'isDnsBlacklisted' )
			->willReturn( $isDnsBlacklisted );

		/** @var BlockManager $blockManager */
		$blockManager = TestingAccessWrapper::newFromObject( $blockManagerMock );

		$this->assertSame(
			$expected,
			(bool)$blockManager->getSystemIpBlocks( $ip, $isAnon )
		);
	}

	public static function provideGetSystemIpBlocks() {
		return [
			'IP is in ProxyWhiteList' => [
				'proxyWhitelist' => [ '1.2.3.4' ],
				'softBlockRanges' => [],
				'isLocallyBlockedProxy' => true,
				'isDnsBlacklisted' => true,
				'isAnon' => true,
				false,
			],
			'IP is locally blocked proxy only' => [
				'proxyWhitelist' => [],
				'softBlockRanges' => [],
				'isLocallyBlockedProxy' => true,
				'isDnsBlacklisted' => false,
				'isAnon' => false,
				true,
			],
			'IP is DNS blacklisted only, anon' => [
				'proxyWhitelist' => [],
				'softBlockRanges' => [],
				'isLocallyBlockedProxy' => false,
				'isDnsBlacklisted' => true,
				'isAnon' => true,
				true,
			],
			'IP is DNS blacklisted only, logged in' => [
				'proxyWhitelist' => [],
				'softBlockRanges' => [],
				'isLocallyBlockedProxy' => false,
				'isDnsBlacklisted' => true,
				'isAnon' => false,
				false,
			],
			'IP is in SoftBlockRanges and ProxyWhiteList, anon' => [
				'proxyWhitelist' => [ '1.2.3.4' ],
				'softBlockRanges' => [ '1.2.3.4' ],
				'isLocallyBlockedProxy' => false,
				'isDnsBlacklisted' => false,
				'isAnon' => true,
				true,
			],
			'IP is in SoftBlockRanges and ProxyWhiteList, logged in' => [
				'proxyWhitelist' => [ '1.2.3.4' ],
				'softBlockRanges' => [ '1.2.3.4' ],
				'isLocallyBlockedProxy' => false,
				'isDnsBlacklisted' => false,
				'isAnon' => false,
				false,
			],
		];
	}

	public function testGetBlocksForIPList() {
		$blockManager = $this->getBlockManager( [] );
		$block = new DatabaseBlock( [
			'address' => '1.2.3.4',
			'by' => $this->getTestSysop()->getUser(),
		] );
		$inserted = $this->getServiceContainer()
			->getDatabaseBlockStore()
			->insertBlock( $block );
		$this->assertTrue(
			(bool)$inserted['id'],
			'Check that the block was inserted correctly'
		);

		// Early return of empty array if no ips in the list
		$list = $blockManager->getBlocksForIPList( [], true, false );
		$this->assertCount(
			0,
			$list,
			'No blocks retrieved if no ips listed'
		);

		// Early return of empty array if all ips are either invalid or trusted proxies,
		// '192.168.1.1' is set to trusted in setUp();
		$list = $blockManager->getBlocksForIPList(
			[ '300.300.300.300', '192.168.1.1' ],
			true,
			false
		);
		$this->assertCount(
			0,
			$list,
			'No blocks retrieved if all ips are invalid or trusted proxies'
		);

		// Actually fetching, block was inserted above
		$list = $blockManager->getBlocksForIPList( [ '1.2.3.4' ], true, false );
		$this->assertCount(
			1,
			$list,
			'Block retrieved for the blocked ip'
		);
		$this->assertInstanceOf(
			DatabaseBlock::class,
			$list[0],
			'DatabaseBlock returned'
		);
		$this->assertSame(
			$inserted['id'],
			$list[0]->getId(),
			'Block returned is the correct one'
		);
	}

	/**
	 * @coversNothing
	 */
	public function testAllServiceOptionsUsed() {
		$this->assertAllServiceOptionsUsed();
	}
}
PK       !     !  block/BlockErrorFormatterTest.phpnu Iw        <?php

use MediaWiki\Block\BlockErrorFormatter;
use MediaWiki\Block\CompositeBlock;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\SystemBlock;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\Message\Message;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\LBFactory;
use Wikimedia\Rdbms\LoadBalancer;

/**
 * @todo Can this be converted to unit tests?
 *
 * @group Blocking
 * @covers \MediaWiki\Block\BlockErrorFormatter
 */
class BlockErrorFormatterTest extends MediaWikiIntegrationTestCase {

	/**
	 * @return DerivativeContext
	 */
	private function getContext(): DerivativeContext {
		$context = new DerivativeContext( RequestContext::getMain() );

		$context->setLanguage(
			$this->getServiceContainer()
				->getLanguageFactory()->getLanguage( 'qqx' )
		);

		return $context;
	}

	private function getBlockErrorFormatter( IContextSource $context ): BlockErrorFormatter {
		return $this->getServiceContainer()
			->getFormatterFactory()->getBlockErrorFormatter( $context );
	}

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

		$db = $this->createMock( IDatabase::class );
		$db->method( 'getInfinity' )->willReturn( 'infinity' );
		$db->method( 'decodeExpiry' )->willReturnArgument( 0 );

		$lb = $this->createNoOpMock(
			LoadBalancer::class,
			[ 'getConnection' ]
		);
		$lb->method( 'getConnection' )->willReturn( $db );

		$lbFactory = $this->createNoOpMock(
			LBFactory::class,
			[ 'getReplicaDatabase', 'getPrimaryDatabase', 'getMainLB', ]
		);
		$lbFactory->method( 'getReplicaDatabase' )->willReturn( $db );
		$lbFactory->method( 'getPrimaryDatabase' )->willReturn( $db );
		$lbFactory->method( 'getMainLB' )->willReturn( $lb );
		$this->setService( 'DBLoadBalancerFactory', $lbFactory );
	}

	/**
	 * @dataProvider provideTestGetMessage
	 */
	public function testGetMessage( $blockClass, $blockData, $expectedKey, $expectedParams ) {
		$block = $this->makeBlock(
			$blockClass,
			$blockData
		);
		$context = $this->getContext();

		$formatter = $this->getBlockErrorFormatter( $context );
		$message = $formatter->getMessage(
			$block,
			$context->getUser(),
			$context->getLanguage(),
			'1.2.3.4'
		);

		$this->assertSame( $expectedKey, $message->getKey() );
		$this->assertSame( $expectedParams, $message->getParams() );
	}

	public static function provideTestGetMessage() {
		$timestamp = '20000101000000';
		$expiry = '20010101000000';

		$databaseBlock = [
			'timestamp' => $timestamp,
			'expiry' => $expiry,
			'reason' => 'Test reason.',
		];

		$systemBlock = [
			'timestamp' => $timestamp,
			'systemBlock' => 'test',
			'reason' => new Message( 'proxyblockreason' ),
		];

		$compositeBlock = [
			'timestamp' => $timestamp,
			'originalBlocks' => [
				[ DatabaseBlock::class, $databaseBlock ],
				[ SystemBlock::class, $systemBlock ]
			]
		];

		return [
			'Database block' => [
				DatabaseBlock::class,
				$databaseBlock,
				'blockedtext',
				[
					'',
					'Test reason.',
					'1.2.3.4',
					'',
					null, // Block not inserted
					'00:00, 1 (january) 2001',
					'',
					'00:00, 1 (january) 2000',
				],
			],
			'Database block (autoblock)' => [
				DatabaseBlock::class,
				[
					'timestamp' => $timestamp,
					'expiry' => $expiry,
					'auto' => true,
				],
				'autoblockedtext',
				[
					'',
					'(blockednoreason)',
					'1.2.3.4',
					'',
					null, // Block not inserted
					'00:00, 1 (january) 2001',
					'',
					'00:00, 1 (january) 2000',
				],
			],
			'Database block (partial block)' => [
				DatabaseBlock::class,
				[
					'timestamp' => $timestamp,
					'expiry' => $expiry,
					'sitewide' => false,
				],
				'blockedtext-partial',
				[
					'',
					'(blockednoreason)',
					'1.2.3.4',
					'',
					null, // Block not inserted
					'00:00, 1 (january) 2001',
					'',
					'00:00, 1 (january) 2000',
				],
			],
			'System block (type \'test\')' => [
				SystemBlock::class,
				$systemBlock,
				'systemblockedtext',
				[
					'',
					'(proxyblockreason)',
					'1.2.3.4',
					'',
					'test',
					'(infiniteblock)',
					'',
					'00:00, 1 (january) 2000',
				],
			],
			'System block (type \'test\') with reason parameters' => [
				SystemBlock::class,
				[
					'timestamp' => $timestamp,
					'systemBlock' => 'test',
					'reason' => new Message( 'softblockrangesreason', [ '1.2.3.4' ] ),
				],
				'systemblockedtext',
				[
					'',
					'(softblockrangesreason: 1.2.3.4)',
					'1.2.3.4',
					'',
					'test',
					'(infiniteblock)',
					'',
					'00:00, 1 (january) 2000',
				],
			],
			'Composite block (original blocks not inserted)' => [
				CompositeBlock::class,
				$compositeBlock,
				'blockedtext-composite',
				[
					'',
					'(blockednoreason)',
					'1.2.3.4',
					'',
					'(blockedtext-composite-no-ids)',
					'(infiniteblock)',
					'',
					'00:00, 1 (january) 2000',
				],
			],
		];
	}

	/**
	 * @dataProvider provideTestGetMessageCompositeBlocks
	 */
	public function testGetMessageCompositeBlocks( $ids, $expected ) {
		$block = $this->getMockBuilder( CompositeBlock::class )
			->onlyMethods( [ 'getIdentifier' ] )
			->getMock();
		$block->method( 'getIdentifier' )
			->willReturn( $ids );

		$context = RequestContext::getMain();

		$formatter = $this->getBlockErrorFormatter( $context );
		$this->assertContains(
			$expected,
			$formatter->getMessage(
				$block,
				$context->getUser(),
				$context->getLanguage(),
				$context->getRequest()->getIP()
			)->getParams()
		);
	}

	public static function provideTestGetMessageCompositeBlocks() {
		return [
			'All original blocks are system blocks' => [
				[ 'test', 'test' ],
				'Your IP address appears in multiple blocklists',
			],
			'One original block is a database block' => [
				[ 100, 'test' ],
				'Relevant block IDs: #100 (your IP address may also appear in a blocklist)',
			],
			'Several original blocks are database blocks' => [
				[ 100, 101, 102 ],
				'Relevant block IDs: #100, #101, #102 (your IP address may also appear in a blocklist)',
			],
		];
	}

	/**
	 * @dataProvider provideTestGetMessages
	 */
	public function testGetMessages( $blockClass, $blockData, $expectedKeys ) {
		$block = $this->makeBlock(
			$blockClass,
			$blockData
		);

		$context = $this->getContext();

		$formatter = $this->getBlockErrorFormatter( $context );
		$messages = $formatter->getMessages(
			$block,
			$context->getUser(),
			'1.2.3.4'
		);

		$this->assertSame( $expectedKeys, array_map( static function ( $message ) {
			return $message->getKey();
		}, $messages ) );
	}

	public static function provideTestGetMessages() {
		$timestamp = '20000101000000';
		$expiry = '20010101000000';

		$databaseBlock = [
			'timestamp' => $timestamp,
			'expiry' => $expiry,
			'reason' => 'Test reason.',
		];

		$systemBlock = [
			'timestamp' => $timestamp,
			'systemBlock' => 'test',
			'reason' => new Message( 'proxyblockreason' ),
		];

		$compositeBlock = [
			'timestamp' => $timestamp,
			'originalBlocks' => [
				[ DatabaseBlock::class, $databaseBlock ],
				[ SystemBlock::class, $systemBlock ]
			]
		];

		return [
			'Database block' => [
				DatabaseBlock::class,
				$databaseBlock,
				[ 'blockedtext' ],
			],

			'System block (type \'test\')' => [
				SystemBlock::class,
				$systemBlock,
				[ 'systemblockedtext' ],
			],
			'Composite block (original blocks not inserted)' => [
				CompositeBlock::class,
				$compositeBlock,
				[ 'blockedtext', 'systemblockedtext' ],
			],
		];
	}

	/**
	 * @param string $blockClass
	 * @param array $blockData
	 *
	 * @return mixed
	 */
	private function makeBlock( $blockClass, $blockData ) {
		foreach ( $blockData['originalBlocks'] ?? [] as $key => $originalBlock ) {
			$blockData['originalBlocks'][$key] = $this->makeBlock( ...$originalBlock );
		}

		return new $blockClass( $blockData );
	}
}
PK       ! K]  K]    block/DatabaseBlockTest.phpnu Iw        <?php

use MediaWiki\Block\BlockRestrictionStore;
use MediaWiki\Block\BlockUtils;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\Restriction\NamespaceRestriction;
use MediaWiki\Block\Restriction\PageRestriction;
use MediaWiki\DAO\WikiAwareEntity;
use MediaWiki\MainConfigNames;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\User\UserNameUtils;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Rdbms\LBFactory;

/**
 * @group Database
 * @group Blocking
 * @coversDefaultClass \MediaWiki\Block\DatabaseBlock
 */
class DatabaseBlockTest extends MediaWikiLangTestCase {
	use TempUserTestTrait;

	public function addDBData() {
		$blockList = [
			[ 'target' => '70.2.0.0/16',
				'type' => DatabaseBlock::TYPE_RANGE,
				'desc' => 'Range Hardblock',
				'ACDisable' => false,
				'isHardblock' => true,
				'isAutoBlocking' => false,
			],
			[ 'target' => '2001:4860:4001:0:0:0:0:0/48',
				'type' => DatabaseBlock::TYPE_RANGE,
				'desc' => 'Range6 Hardblock',
				'ACDisable' => false,
				'isHardblock' => true,
				'isAutoBlocking' => false,
			],
			[ 'target' => '60.2.0.0/16',
				'type' => DatabaseBlock::TYPE_RANGE,
				'desc' => 'Range Softblock with AC Disabled',
				'ACDisable' => true,
				'isHardblock' => false,
				'isAutoBlocking' => false,
			],
			[ 'target' => '50.2.0.0/16',
				'type' => DatabaseBlock::TYPE_RANGE,
				'desc' => 'Range Softblock',
				'ACDisable' => false,
				'isHardblock' => false,
				'isAutoBlocking' => false,
			],
			[ 'target' => '50.1.1.1',
				'type' => DatabaseBlock::TYPE_IP,
				'desc' => 'Exact Softblock',
				'ACDisable' => false,
				'isHardblock' => false,
				'isAutoBlocking' => false,
			],
		];

		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blocker = $this->getTestUser()->getUser();
		foreach ( $blockList as $insBlock ) {
			$block = new DatabaseBlock();
			$block->setTarget( $insBlock['target'] );
			$block->setBlocker( $blocker );
			$block->setReason( $insBlock['desc'] );
			$block->setExpiry( 'infinity' );
			$block->isCreateAccountBlocked( $insBlock['ACDisable'] );
			$block->isHardblock( $insBlock['isHardblock'] );
			$block->isAutoblocking( $insBlock['isAutoBlocking'] );
			$blockStore->insertBlock( $block );
		}
	}

	/**
	 * @return UserIdentity
	 */
	private function getUserForBlocking() {
		return $this->getTestUser()->getUserIdentity();
	}

	/**
	 * @param UserIdentity $user
	 *
	 * @return DatabaseBlock
	 */
	private function addBlockForUser( UserIdentity $user ) {
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();

		$blockOptions = [
			'address' => $user,
			'by' => $this->getTestSysop()->getUser(),
			'reason' => 'Parce que',
			'expiry' => time() + 100500,
		];
		$block = new DatabaseBlock( $blockOptions );

		$blockStore->insertBlock( $block );
		// save up ID for use in assertion. Since ID is an autoincrement,
		// its value might change depending on the order the tests are run.
		// ApiBlockTest insert its own blocks!
		if ( !$block->getId() ) {
			throw new RuntimeException( "Failed to insert block for BlockTest; old leftover block remaining?" );
		}

		return $block;
	}

	/**
	 * @covers ::newFromTarget
	 */
	public function testHardBlocks() {
		// Set up temp user config
		$this->enableAutoCreateTempUser();

		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blocker = $this->getTestUser()->getUser();

		$block = new DatabaseBlock();
		$block->setTarget( '1.2.3.4' );
		$block->setBlocker( $blocker );
		$block->setReason( 'test' );
		$block->setExpiry( 'infinity' );
		$block->isHardblock( false );
		$blockStore->insertBlock( $block );

		$this->assertFalse(
			(bool)DatabaseBlock::newFromTarget( '~1' ),
			'Temporary user is not blocked directly'
		);
		$this->assertTrue(
			(bool)DatabaseBlock::newFromTarget( '~1', '1.2.3.4' ),
			'Temporary user is blocked by soft block'
		);
		$this->assertFalse(
			(bool)DatabaseBlock::newFromTarget( $blocker, '1.2.3.4' ),
			'Logged-in user is not blocked by soft block'
		);
	}

	/**
	 * @covers ::newFromTarget
	 */
	public function testINewFromTargetReturnsCorrectBlock() {
		$user = $this->getUserForBlocking();
		$block = $this->addBlockForUser( $user );

		$this->assertTrue(
			$block->equals( DatabaseBlock::newFromTarget( $user->getName() ) ),
			"newFromTarget() returns the same block as the one that was made"
		);
	}

	/**
	 * @covers ::newFromID
	 */
	public function testINewFromIDReturnsCorrectBlock() {
		$this->hideDeprecated( DatabaseBlock::class . '::newFromID' );
		$user = $this->getUserForBlocking();
		$block = $this->addBlockForUser( $user );

		$this->assertTrue(
			$block->equals( DatabaseBlock::newFromID( $block->getId() ) ),
			"newFromID() returns the same block as the one that was made"
		);
	}

	/**
	 * per T28425
	 * @covers ::__construct
	 */
	public function testT28425BlockTimestampDefaultsToTime() {
		$user = $this->getUserForBlocking();
		$block = $this->addBlockForUser( $user );
		$madeAt = wfTimestamp( TS_MW );

		// delta to stop one-off errors when things happen to go over a second mark.
		$delta = abs( $madeAt - $block->getTimestamp() );
		$this->assertLessThan(
			2,
			$delta,
			"If no timestamp is specified, the block is recorded as time()"
		);
	}

	/**
	 * CheckUser since being changed to use DatabaseBlock::newFromTarget started failing
	 * because the new function didn't accept empty strings like DatabaseBlock::load()
	 * had. Regression T31116.
	 *
	 * @dataProvider provideT31116Data
	 * @covers ::newFromTarget
	 */
	public function testT31116NewFromTargetWithEmptyIp( $vagueTarget ) {
		$user = $this->getUserForBlocking();
		$initialBlock = $this->addBlockForUser( $user );
		$block = DatabaseBlock::newFromTarget( $user->getName(), $vagueTarget );

		$this->assertTrue(
			$initialBlock->equals( $block ),
			"newFromTarget() returns the same block as the one that was made when "
				. "given empty vagueTarget param " . var_export( $vagueTarget, true )
		);
	}

	public static function provideT31116Data() {
		return [
			[ null ],
			[ '' ],
			[ false ]
		];
	}

	/**
	 * @dataProvider provideNewFromTargetRangeBlocks
	 * @covers ::newFromTarget
	 */
	public function testNewFromTargetRangeBlocks( $targets, $ip, $expectedTarget ) {
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blocker = $this->getTestSysop()->getUser();

		foreach ( $targets as $target ) {
			$block = new DatabaseBlock();
			$block->setTarget( $target );
			$block->setBlocker( $blocker );
			$blockStore->insertBlock( $block );
		}

		// Should find the block with the narrowest range
		$block = DatabaseBlock::newFromTarget( $this->getTestUser()->getUserIdentity(), $ip );
		$this->assertSame(
			$expectedTarget,
			$block->getTargetName()
		);
	}

	public static function provideNewFromTargetRangeBlocks() {
		return [
			'Blocks to IPv4 ranges' => [
				[ '0.0.0.0/20', '0.0.0.0/30', '0.0.0.0/25' ],
				'0.0.0.0',
				'0.0.0.0/30'
			],
			'Blocks to IPv6 ranges' => [
				[ '0:0:0:0:0:0:0:0/20', '0:0:0:0:0:0:0:0/30', '0:0:0:0:0:0:0:0/25' ],
				'0:0:0:0:0:0:0:0',
				'0:0:0:0:0:0:0:0/30'
			],
			'Blocks to wide IPv4 range and IP' => [
				[ '0.0.0.0/16', '0.0.0.0' ],
				'0.0.0.0',
				'0.0.0.0'
			],
			'Blocks to narrow IPv4 range and IP' => [
				[ '0.0.0.0/31', '0.0.0.0' ],
				'0.0.0.0',
				'0.0.0.0'
			],
			'Blocks to wide IPv6 range and IP' => [
				[ '0:0:0:0:0:0:0:0/19', '0:0:0:0:0:0:0:0' ],
				'0:0:0:0:0:0:0:0',
				'0:0:0:0:0:0:0:0'
			],
			'Blocks to narrow IPv6 range and IP' => [
				[ '0:0:0:0:0:0:0:0/127', '0:0:0:0:0:0:0:0' ],
				'0:0:0:0:0:0:0:0',
				'0:0:0:0:0:0:0:0'
			],
			'Blocks to wide IPv6 range and IP, large numbers' => [
				[ '2000:DEAD:BEEF:A:0:0:0:0/19', '2000:DEAD:BEEF:A:0:0:0:0' ],
				'2000:DEAD:BEEF:A:0:0:0:0',
				'2000:DEAD:BEEF:A:0:0:0:0'
			],
			'Blocks to narrow IPv6 range and IP, large numbers' => [
				[ '2000:DEAD:BEEF:A:0:0:0:0/127', '2000:DEAD:BEEF:A:0:0:0:0' ],
				'2000:DEAD:BEEF:A:0:0:0:0',
				'2000:DEAD:BEEF:A:0:0:0:0'
			],
		];
	}

	/**
	 * @covers ::appliesToRight
	 */
	public function testBlockedUserCanNotCreateAccount() {
		$username = 'BlockedUserToCreateAccountWith';
		$u = User::newFromName( $username );
		$u->addToDatabase();
		$userId = $u->getId();
		$this->assertNotEquals( 0, $userId, 'Check user id is not 0' );
		TestUser::setPasswordForUser( $u, 'NotRandomPass' );
		unset( $u );

		$this->assertNull(
			DatabaseBlock::newFromTarget( $username ),
			"$username should not be blocked"
		);

		// Reload user
		$u = User::newFromName( $username );
		$this->assertTrue(
			$u->isDefinitelyAllowed( 'createaccount' ),
			"Our sandbox user should be able to create account before being blocked"
		);

		// Foreign perspective (blockee not on current wiki)...
		$blockOptions = [
			'address' => $username,
			'reason' => 'crosswiki block...',
			'timestamp' => wfTimestampNow(),
			'expiry' => $this->getDb()->getInfinity(),
			'createAccount' => true,
			'enableAutoblock' => true,
			'hideName' => true,
			'blockEmail' => true,
			'by' => UserIdentityValue::newExternal( 'm', 'MetaWikiUser' ),
		];
		$block = new DatabaseBlock( $blockOptions );
		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );

		// Reload block from DB
		$userBlock = DatabaseBlock::newFromTarget( $username );
		$this->assertTrue(
			(bool)$block->appliesToRight( 'createaccount' ),
			"Block object in DB should block right 'createaccount'"
		);

		$this->assertInstanceOf(
			DatabaseBlock::class,
			$userBlock,
			"'$username' block block object should be existent"
		);

		// Reload user
		$u = User::newFromName( $username );
		$this->assertFalse(
			$u->isDefinitelyAllowed( 'createaccount' ),
			"Our sandbox user '$username' should NOT be able to create account"
		);
	}

	public static function providerXff() {
		return [
			[ 'xff' => '1.2.3.4, 70.2.1.1, 60.2.1.1, 2.3.4.5',
				'count' => 2,
				'result' => 'Range Hardblock'
			],
			[ 'xff' => '1.2.3.4, 50.2.1.1, 60.2.1.1, 2.3.4.5',
				'count' => 2,
				'result' => 'Range Softblock with AC Disabled'
			],
			[ 'xff' => '1.2.3.4, 70.2.1.1, 50.1.1.1, 2.3.4.5',
				'count' => 2,
				'result' => 'Exact Softblock'
			],
			[ 'xff' => '1.2.3.4, 70.2.1.1, 50.2.1.1, 50.1.1.1, 2.3.4.5',
				'count' => 3,
				'result' => 'Exact Softblock'
			],
			[ 'xff' => '1.2.3.4, 70.2.1.1, 50.2.1.1, 2.3.4.5',
				'count' => 2,
				'result' => 'Range Hardblock'
			],
			[ 'xff' => '1.2.3.4, 70.2.1.1, 60.2.1.1, 2.3.4.5',
				'count' => 2,
				'result' => 'Range Hardblock'
			],
			[ 'xff' => '50.2.1.1, 60.2.1.1, 2.3.4.5',
				'count' => 2,
				'result' => 'Range Softblock with AC Disabled'
			],
			[ 'xff' => '1.2.3.4, 50.1.1.1, 60.2.1.1, 2.3.4.5',
				'count' => 2,
				'result' => 'Exact Softblock'
			],
			[ 'xff' => '1.2.3.4, <$A_BUNCH-OF{INVALID}TEXT\>, 60.2.1.1, 2.3.4.5',
				'count' => 1,
				'result' => 'Range Softblock with AC Disabled'
			],
			[ 'xff' => '1.2.3.4, 50.2.1.1, 2001:4860:4001:802::1003, 2.3.4.5',
				'count' => 2,
				'result' => 'Range6 Hardblock'
			],
		];
	}

	/**
	 * @dataProvider providerXff
	 * @covers ::getBlocksForIPList
	 */
	public function testBlocksOnXff( $xff, $exCount, $exResult ) {
		$user = $this->getUserForBlocking();
		$this->addBlockForUser( $user );

		$list = array_map( 'trim', explode( ',', $xff ) );
		$xffblocks = DatabaseBlock::getBlocksForIPList( $list, true );
		$this->assertCount( $exCount, $xffblocks, 'Number of blocks for ' . $xff );
	}

	/**
	 * @covers ::newFromRow
	 */
	public function testNewFromRow() {
		$badActor = $this->getTestUser()->getUser();
		$sysop = $this->getTestSysop()->getUser();

		$block = new DatabaseBlock( [
			'address' => $badActor,
			'by' => $sysop,
			'expiry' => 'infinity',
		] );
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockStore->insertBlock( $block );

		$blockQuery = $blockStore->getQueryInfo();
		$row = $this->getDb()->newSelectQueryBuilder()
			->queryInfo( $blockQuery )
			->where( [
				'bl_id' => $block->getId(),
			] )
			->caller( __METHOD__ )
			->fetchRow();

		$block = DatabaseBlock::newFromRow( $row );
		$this->assertInstanceOf( DatabaseBlock::class, $block );
		$this->assertEquals( $block->getBy(), $sysop->getId() );
		$this->assertEquals( $block->getTargetName(), $badActor->getName() );
		$this->assertEquals( $block->getTargetName(), $badActor->getName() );
		$this->assertTrue( $block->isBlocking( $badActor ), 'Is blocking expected user' );
		$this->assertEquals( $block->getTargetUserIdentity()->getId(), $badActor->getId() );
	}

	/**
	 * @covers ::getTargetName()
	 * @covers ::getTargetUserIdentity()
	 * @covers ::isBlocking()
	 * @covers ::getBlocker()
	 * @covers ::getByName()
	 */
	public function testCrossWikiBlocking() {
		$this->overrideConfigValue( MainConfigNames::LocalDatabases, [ 'm' ] );
		$dbMock = $this->createMock( IDatabase::class );
		$dbMock->method( 'decodeExpiry' )->willReturn( 'infinity' );
		$lbMock = $this->createMock( ILoadBalancer::class );
		$lbMock->method( 'getConnection' )
			->with( DB_REPLICA, [], 'm' )
			->willReturn( $dbMock );
		$lbFactoryMock = $this->createMock( LBFactory::class );
		$lbFactoryMock
			->method( 'getMainLB' )
			->with( 'm' )
			->willReturn( $lbMock );
		$this->setService( 'DBLoadBalancerFactory', $lbFactoryMock );

		$target = UserIdentityValue::newExternal( 'm', 'UserOnForeignWiki', 'm' );

		$blockUtilsMock = $this->createMock( BlockUtils::class );
		$blockUtilsMock
			->method( 'parseBlockTarget' )
			->with( $target )
			->willReturn( [ $target, DatabaseBlock::TYPE_USER ] );
		$this->setService( 'BlockUtils', $blockUtilsMock );

		$blocker = UserIdentityValue::newExternal( 'm', 'MetaWikiUser', 'm' );

		$userNameUtilsMock = $this->createMock( UserNameUtils::class );
		$userNameUtilsMock
			->method( 'isUsable' )
			->with( $blocker->getName() )
			->willReturn( false );
		$this->setService( 'UserNameUtils', $userNameUtilsMock );

		$blockOptions = [
			'address' => $target,
			'wiki' => 'm',
			'reason' => 'testing crosswiki blocking',
			'timestamp' => wfTimestampNow(),
			'createAccount' => true,
			'enableAutoblock' => true,
			'hideName' => true,
			'blockEmail' => true,
			'by' => $blocker,
		];
		$block = new DatabaseBlock( $blockOptions );

		$this->assertEquals(
			'm>UserOnForeignWiki',
			$block->getTargetName(),
			'Correct blockee name'
		);
		$this->assertEquals(
			'm>UserOnForeignWiki',
			$block->getTargetUserIdentity()->getName(),
			'Correct blockee name'
		);
		$this->assertTrue( $block->isBlocking( 'm>UserOnForeignWiki' ), 'Is blocking blockee' );
		$this->assertEquals(
			'm>MetaWikiUser',
			$block->getBlocker()->getName(),
			'Correct blocker name'
		);
		$this->assertEquals( 'm>MetaWikiUser', $block->getByName(), 'Correct blocker name' );
	}

	/**
	 * @covers ::equals
	 */
	public function testEquals() {
		$block = new DatabaseBlock();

		$this->assertTrue( $block->equals( $block ) );

		$partial = new DatabaseBlock( [
			'sitewide' => false,
		] );
		$this->assertFalse( $block->equals( $partial ) );
	}

	/**
	 * @covers ::getWikiId
	 */
	public function testGetWikiId() {
		$this->overrideConfigValue( MainConfigNames::LocalDatabases, [ 'foo' ] );
		$dbMock = $this->createMock( IDatabase::class );
		$dbMock->method( 'decodeExpiry' )->willReturn( 'infinity' );
		$lbMock = $this->createMock( ILoadBalancer::class );
		$lbMock->method( 'getConnection' )->willReturn( $dbMock );
		$lbFactoryMock = $this->createMock( LBFactory::class );
		$lbFactoryMock->method( 'getMainLB' )->willReturn( $lbMock );
		$this->setService( 'DBLoadBalancerFactory', $lbFactoryMock );

		$block = new DatabaseBlock( [ 'wiki' => 'foo' ] );
		$this->assertSame( 'foo', $block->getWikiId() );

		$this->resetServices();

		$localBlock = new DatabaseBlock();
		$this->assertSame( WikiAwareEntity::LOCAL, $localBlock->getWikiId() );
	}

	/**
	 * @covers ::isSitewide
	 */
	public function testIsSitewide() {
		$block = new DatabaseBlock();
		$this->assertTrue( $block->isSitewide() );

		$block = new DatabaseBlock( [
			'sitewide' => true,
		] );
		$this->assertTrue( $block->isSitewide() );

		$block = new DatabaseBlock( [
			'sitewide' => false,
		] );
		$this->assertFalse( $block->isSitewide() );

		$block = new DatabaseBlock( [
			'sitewide' => false,
		] );
		$block->isSitewide( true );
		$this->assertTrue( $block->isSitewide() );
	}

	/**
	 * @covers ::getRestrictions
	 * @covers ::setRestrictions
	 */
	public function testRestrictions() {
		$block = new DatabaseBlock();
		$restrictions = [
			new PageRestriction( 0, 1 )
		];
		$block->setRestrictions( $restrictions );

		$this->assertSame( $restrictions, $block->getRestrictions() );
	}

	/**
	 * @covers ::getRestrictions
	 * @covers ::insert
	 */
	public function testRestrictionsFromDatabase() {
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$badActor = $this->getTestUser()->getUser();
		$sysop = $this->getTestSysop()->getUser();

		$block = new DatabaseBlock( [
			'address' => $badActor,
			'by' => $sysop,
			'expiry' => 'infinity',
		] );
		$page = $this->getExistingTestPage( 'Foo' );
		$restriction = new PageRestriction( 0, $page->getId() );
		$block->setRestrictions( [ $restriction ] );
		$blockStore->insertBlock( $block );

		// Refresh the block from the database.
		$block = $blockStore->newFromID( $block->getId() );
		$restrictions = $block->getRestrictions();
		$this->assertCount( 1, $restrictions );
		$this->assertTrue( $restriction->equals( $restrictions[0] ) );
	}

	/**
	 * TODO: Move to DatabaseBlockStoreTest
	 *
	 * @covers ::insert
	 */
	public function testInsertExistingBlock() {
		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$badActor = $this->getTestUser()->getUser();
		$sysop = $this->getTestSysop()->getUser();

		$block = new DatabaseBlock( [
			'address' => $badActor,
			'by' => $sysop,
			'expiry' => 'infinity',
		] );
		$page = $this->getExistingTestPage( 'Foo' );
		$restriction = new PageRestriction( 0, $page->getId() );
		$block->setRestrictions( [ $restriction ] );
		$blockStore->insertBlock( $block );

		// Insert the block again, which should result in a failure
		$result = $block->insert();

		$this->assertFalse( $result );

		// Ensure that there are no restrictions where the blockId is 0.
		$count = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'ipblocks_restrictions' )
			->where( [ 'ir_ipb_id' => 0 ] )
			->caller( __METHOD__ )->fetchRowCount();
		$this->assertSame( 0, $count );
	}

	/**
	 * @covers ::appliesToTitle
	 */
	public function testAppliesToTitleReturnsTrueOnSitewideBlock() {
		$this->overrideConfigValue( MainConfigNames::BlockDisablesLogin, false );
		$user = $this->getTestUser()->getUser();
		$block = new DatabaseBlock( [
			'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ),
			'allowUsertalk' => true,
			'sitewide' => true
		] );

		$block->setTarget( new UserIdentityValue( $user->getId(), $user->getName() ) );
		$block->setBlocker( $this->getTestSysop()->getUser() );

		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockStore->insertBlock( $block );

		$title = $this->getExistingTestPage( 'Foo' )->getTitle();

		$this->assertTrue( $block->appliesToTitle( $title ) );

		// appliesToTitle() ignores allowUsertalk
		$title = $user->getTalkPage();
		$this->assertTrue( $block->appliesToTitle( $title ) );
	}

	/**
	 * @covers ::appliesToTitle
	 */
	public function testAppliesToTitleOnPartialBlock() {
		$this->overrideConfigValue( MainConfigNames::BlockDisablesLogin, false );
		$user = $this->getTestUser()->getUser();
		$block = new DatabaseBlock( [
			'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ),
			'allowUsertalk' => true,
			'sitewide' => false
		] );

		$block->setTarget( $user );
		$block->setBlocker( $this->getTestSysop()->getUser() );

		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockStore->insertBlock( $block );

		$pageFoo = $this->getExistingTestPage( 'Foo' );
		$pageBar = $this->getExistingTestPage( 'Bar' );
		$pageJohn = $this->getExistingTestPage( 'User:John' );

		$pageRestriction = new PageRestriction( $block->getId(), $pageFoo->getId() );
		$namespaceRestriction = new NamespaceRestriction( $block->getId(), NS_USER );
		$this->getBlockRestrictionStore()->insert( [ $pageRestriction, $namespaceRestriction ] );

		$this->assertTrue( $block->appliesToTitle( $pageFoo->getTitle() ) );
		$this->assertFalse( $block->appliesToTitle( $pageBar->getTitle() ) );
		$this->assertTrue( $block->appliesToTitle( $pageJohn->getTitle() ) );
	}

	/**
	 * @covers ::appliesToNamespace
	 * @covers ::appliesToPage
	 */
	public function testAppliesToReturnsTrueOnSitewideBlock() {
		$this->overrideConfigValue( MainConfigNames::BlockDisablesLogin, false );
		$user = $this->getTestUser()->getUser();
		$block = new DatabaseBlock( [
			'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ),
			'allowUsertalk' => true,
			'sitewide' => true
		] );

		$block->setTarget( $user );
		$block->setBlocker( $this->getTestSysop()->getUser() );

		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockStore->insertBlock( $block );

		$title = $this->getExistingTestPage()->getTitle();

		$this->assertTrue( $block->appliesToPage( $title->getArticleID() ) );
		$this->assertTrue( $block->appliesToNamespace( NS_MAIN ) );
		$this->assertTrue( $block->appliesToNamespace( NS_USER_TALK ) );
	}

	/**
	 * @covers ::appliesToPage
	 */
	public function testAppliesToPageOnPartialPageBlock() {
		$this->overrideConfigValue( MainConfigNames::BlockDisablesLogin, false );
		$user = $this->getTestUser()->getUser();
		$block = new DatabaseBlock( [
			'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ),
			'allowUsertalk' => true,
			'sitewide' => false
		] );

		$block->setTarget( $user );
		$block->setBlocker( $this->getTestSysop()->getUser() );

		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockStore->insertBlock( $block );

		$title = $this->getExistingTestPage()->getTitle();

		$pageRestriction = new PageRestriction(
			$block->getId(),
			$title->getArticleID()
		);
		$this->getBlockRestrictionStore()->insert( [ $pageRestriction ] );

		$this->assertTrue( $block->appliesToPage( $title->getArticleID() ) );
	}

	/**
	 * @covers ::appliesToNamespace
	 */
	public function testAppliesToNamespaceOnPartialNamespaceBlock() {
		$this->overrideConfigValue( MainConfigNames::BlockDisablesLogin, false );
		$user = $this->getTestUser()->getUser();
		$block = new DatabaseBlock( [
			'expiry' => wfTimestamp( TS_MW, wfTimestamp() + ( 40 * 60 * 60 ) ),
			'allowUsertalk' => true,
			'sitewide' => false
		] );

		$block->setTarget( $user );
		$block->setBlocker( $this->getTestSysop()->getUser() );

		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockStore->insertBlock( $block );

		$namespaceRestriction = new NamespaceRestriction( $block->getId(), NS_MAIN );
		$this->getBlockRestrictionStore()->insert( [ $namespaceRestriction ] );

		$this->assertTrue( $block->appliesToNamespace( NS_MAIN ) );
		$this->assertFalse( $block->appliesToNamespace( NS_USER ) );
	}

	/**
	 * @covers ::appliesToRight
	 */
	public function testBlockAllowsRead() {
		$this->overrideConfigValue( MainConfigNames::BlockDisablesLogin, false );
		$block = new DatabaseBlock();
		$this->assertFalse( $block->appliesToRight( 'read' ) );
	}

	/**
	 * Get an instance of BlockRestrictionStore
	 *
	 * @return BlockRestrictionStore
	 */
	protected function getBlockRestrictionStore(): BlockRestrictionStore {
		return $this->getServiceContainer()->getBlockRestrictionStore();
	}
}
PK       ! ޫ!  !    block/CompositeBlockTest.phpnu Iw        <?php

use MediaWiki\Block\BlockRestrictionStore;
use MediaWiki\Block\CompositeBlock;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\Restriction\NamespaceRestriction;
use MediaWiki\Block\Restriction\PageRestriction;
use MediaWiki\Block\SystemBlock;
use MediaWiki\MainConfigNames;

/**
 * @group Database
 * @group Blocking
 * @covers \MediaWiki\Block\CompositeBlock
 */
class CompositeBlockTest extends MediaWikiLangTestCase {
	private function getPartialBlocks() {
		$sysopUser = $this->getTestSysop()->getUser();

		$userBlock = new DatabaseBlock( [
			'address' => $this->getTestUser()->getUser(),
			'by' => $sysopUser,
			'sitewide' => false,
		] );
		$ipBlock = new DatabaseBlock( [
			'address' => '127.0.0.1',
			'by' => $sysopUser,
			'sitewide' => false,
		] );

		$blockStore = $this->getServiceContainer()->getDatabaseBlockStore();
		$blockStore->insertBlock( $userBlock );
		$blockStore->insertBlock( $ipBlock );

		return [
			'user' => $userBlock,
			'ip' => $ipBlock,
		];
	}

	/**
	 * @dataProvider provideTestStrictestParametersApplied
	 */
	public function testStrictestParametersApplied( $blocks, $expected ) {
		$this->overrideConfigValues( [
			MainConfigNames::BlockDisablesLogin => false,
			MainConfigNames::BlockAllowsUTEdit => true,
		] );

		$block = new CompositeBlock( [
			'originalBlocks' => $blocks,
		] );

		$this->assertSame( $expected[ 'hideName' ], $block->getHideName() );
		$this->assertSame( $expected[ 'sitewide' ], $block->isSitewide() );
		$this->assertSame( $expected[ 'blockEmail' ], $block->isEmailBlocked() );
		$this->assertSame( $expected[ 'allowUsertalk' ], $block->isUsertalkEditAllowed() );
	}

	public static function provideTestStrictestParametersApplied() {
		return [
			'Sitewide block and partial block' => [
				[
					new DatabaseBlock( [
						'sitewide' => false,
						'blockEmail' => true,
						'allowUsertalk' => true,
					] ),
					new DatabaseBlock( [
						'sitewide' => true,
						'blockEmail' => false,
						'allowUsertalk' => false,
					] ),
				],
				[
					'hideName' => false,
					'sitewide' => true,
					'blockEmail' => true,
					'allowUsertalk' => false,
				],
			],
			'Partial block and system block' => [
				[
					new DatabaseBlock( [
						'sitewide' => false,
						'blockEmail' => true,
						'allowUsertalk' => false,
					] ),
					new SystemBlock( [
						'systemBlock' => 'proxy',
					] ),
				],
				[
					'hideName' => false,
					'sitewide' => true,
					'blockEmail' => true,
					'allowUsertalk' => false,
				],
			],
			'System block and user name hiding block' => [
				[
					new DatabaseBlock( [
						'hideName' => true,
						'sitewide' => true,
						'blockEmail' => true,
						'allowUsertalk' => false,
					] ),
					new SystemBlock( [
						'systemBlock' => 'proxy',
					] ),
				],
				[
					'hideName' => true,
					'sitewide' => true,
					'blockEmail' => true,
					'allowUsertalk' => false,
				],
			],
			'Two lenient partial blocks' => [
				[
					new DatabaseBlock( [
						'sitewide' => false,
						'blockEmail' => false,
						'allowUsertalk' => true,
					] ),
					new DatabaseBlock( [
						'sitewide' => false,
						'blockEmail' => false,
						'allowUsertalk' => true,
					] ),
				],
				[
					'hideName' => false,
					'sitewide' => false,
					'blockEmail' => false,
					'allowUsertalk' => true,
				],
			],
		];
	}

	public function testBlockAppliesToTitle() {
		$this->overrideConfigValue( MainConfigNames::BlockDisablesLogin, false );

		$blocks = $this->getPartialBlocks();

		$block = new CompositeBlock( [
			'originalBlocks' => $blocks,
		] );

		$pageFoo = $this->getExistingTestPage( 'Foo' );
		$pageBar = $this->getExistingTestPage( 'User:Bar' );

		$this->getBlockRestrictionStore()->insert( [
			new PageRestriction( $blocks[ 'user' ]->getId(), $pageFoo->getId() ),
			new NamespaceRestriction( $blocks[ 'ip' ]->getId(), NS_USER ),
		] );

		$this->assertTrue( $block->appliesToTitle( $pageFoo->getTitle() ) );
		$this->assertTrue( $block->appliesToTitle( $pageBar->getTitle() ) );
	}

	public function testBlockAppliesToUsertalk() {
		$this->overrideConfigValues( [
			MainConfigNames::BlockAllowsUTEdit => true,
			MainConfigNames::BlockDisablesLogin => false,
		] );

		$blocks = $this->getPartialBlocks();

		$block = new CompositeBlock( [
			'originalBlocks' => $blocks,
		] );

		$userFactory = $this->getServiceContainer()->getUserFactory();
		$targetIdentity = $userFactory->newFromUserIdentity( $blocks[ 'user' ]->getTargetUserIdentity() );
		$title = $targetIdentity->getTalkPage();
		$page = $this->getExistingTestPage( 'User talk:' . $title->getText() );

		$this->getBlockRestrictionStore()->insert( [
			new PageRestriction( $blocks[ 'user' ]->getId(), $page->getId() ),
			new NamespaceRestriction( $blocks[ 'ip' ]->getId(), NS_USER ),
		] );

		$this->assertTrue( $block->appliesToUsertalk( $title ) );
	}

	/**
	 * @dataProvider provideTestBlockAppliesToRight
	 */
	public function testBlockAppliesToRight( $applies, $expected ) {
		$this->overrideConfigValue( MainConfigNames::BlockDisablesLogin, false );

		$block = new CompositeBlock( [
			'originalBlocks' => [
				$this->getMockBlockForTestAppliesToRight( $applies[ 0 ] ),
				$this->getMockBlockForTestAppliesToRight( $applies[ 1 ] ),
			],
		] );

		$this->assertSame( $expected, $block->appliesToRight( 'right' ) );
	}

	private function getMockBlockForTestAppliesToRight( $applies ) {
		$mockBlock = $this->getMockBuilder( DatabaseBlock::class )
			->onlyMethods( [ 'appliesToRight' ] )
			->getMock();
		$mockBlock->method( 'appliesToRight' )
			->willReturn( $applies );
		return $mockBlock;
	}

	public static function provideTestBlockAppliesToRight() {
		return [
			'Block does not apply if no original blocks apply' => [
				[ false, false ],
				false,
			],
			'Block applies if any original block applies (second block doesn\'t apply)' => [
				[ true, false ],
				true,
			],
			'Block applies if any original block applies (second block unsure)' => [
				[ true, null ],
				true,
			],
			'Block is unsure if all original blocks are unsure' => [
				[ null, null ],
				null,
			],
			'Block is unsure if any original block is unsure, and no others apply' => [
				[ null, false ],
				null,
			],
		];
	}

	public function testTimestamp() {
		$timestamp = 20000101000000;

		$firstBlock = $this->createMock( DatabaseBlock::class );
		$firstBlock->method( 'getTimestamp' )
			->willReturn( (string)$timestamp );

		$secondBlock = $this->createMock( DatabaseBlock::class );
		$secondBlock->method( 'getTimestamp' )
			->willReturn( (string)( $timestamp + 10 ) );

		$thirdBlock = $this->createMock( DatabaseBlock::class );
		$thirdBlock->method( 'getTimestamp' )
			->willReturn( (string)( $timestamp + 100 ) );

		$block = new CompositeBlock( [
			'originalBlocks' => [ $thirdBlock, $firstBlock, $secondBlock ],
		] );
		$this->assertSame( (string)$timestamp, $block->getTimestamp() );
	}

	public function testCreateFromBlocks() {
		$block1 = new SystemBlock( [
			'address' => '127.0.0.1',
			'systemBlock' => 'test1',
		] );
		$block2 = new SystemBlock( [
			'address' => '127.0.0.1',
			'systemBlock' => 'test2',
		] );
		$block3 = new SystemBlock( [
			'address' => '127.0.0.1',
			'systemBlock' => 'test3',
		] );

		$compositeBlock = CompositeBlock::createFromBlocks( $block1, $block2 );
		$this->assertInstanceOf( CompositeBlock::class, $compositeBlock );
		$this->assertCount( 2, $compositeBlock->getOriginalBlocks() );
		[ $actualBlock1, $actualBlock2 ] = $compositeBlock->getOriginalBlocks();
		$this->assertSame( $block1->getSystemBlockType(), $actualBlock1->getSystemBlockType() );
		$this->assertSame( $block2->getSystemBlockType(), $actualBlock2->getSystemBlockType() );
		$this->assertSame( 'blockedtext-composite-reason',
			$compositeBlock->getReasonComment()->message->getKey() );
		$this->assertSame( '127.0.0.1', $compositeBlock->getTargetName() );

		$compositeBlock2 = CompositeBlock::createFromBlocks( $compositeBlock, $block3 );
		$this->assertCount( 3, $compositeBlock2->getOriginalBlocks() );
		[ $actualBlock1, $actualBlock2, $actualBlock3 ] = $compositeBlock2->getOriginalBlocks();
		$this->assertSame( $block1->getSystemBlockType(), $actualBlock1->getSystemBlockType() );
		$this->assertSame( $block2->getSystemBlockType(), $actualBlock2->getSystemBlockType() );
		$this->assertSame( $block3->getSystemBlockType(), $actualBlock3->getSystemBlockType() );
	}

	/**
	 * Get an instance of BlockRestrictionStore
	 *
	 * @return BlockRestrictionStore
	 */
	protected function getBlockRestrictionStore(): BlockRestrictionStore {
		return $this->getServiceContainer()->getBlockRestrictionStore();
	}
}
PK       ! D    )  block/Restriction/PageRestrictionTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Block\Restriction;

use MediaWiki\Block\Restriction\PageRestriction;
use MediaWiki\Title\Title;

/**
 * @group Database
 * @group Blocking
 * @covers \MediaWiki\Block\Restriction\AbstractRestriction
 * @covers \MediaWiki\Block\Restriction\PageRestriction
 */
class PageRestrictionTest extends RestrictionTestCase {

	public function testMatches() {
		$class = $this->getClass();
		$page = $this->getExistingTestPage( 'Saturn' );
		$restriction = new $class( 1, $page->getId() );
		$this->assertTrue( $restriction->matches( $page->getTitle() ) );

		$page = $this->getExistingTestPage( 'Mars' );
		$this->assertFalse( $restriction->matches( $page->getTitle() ) );

		// Deleted page.
		$restriction = new $class( 2, 99999 );
		$page = $this->getExistingTestPage( 'Saturn' );
		$this->assertFalse( $restriction->matches( $page->getTitle() ) );
	}

	public function testGetType() {
		$class = $this->getClass();
		$restriction = new $class( 1, 2 );
		$this->assertEquals( 'page', $restriction->getType() );
	}

	public function testGetTitle() {
		$class = $this->getClass();
		$restriction = new $class( 1, 2 );
		$title = Title::makeTitle( NS_MAIN, 'Pluto' );
		$title->mArticleID = 2;
		$restriction->setTitle( $title );
		$this->assertSame( $title, $restriction->getTitle() );
	}

	public function testNewFromRow() {
		$class = $this->getClass();
		$restriction = $class::newFromRow( (object)[
			'ir_ipb_id' => 1,
			'ir_value' => 2,
			'page_namespace' => 0,
			'page_title' => 'Saturn',
		] );

		$this->assertSame( 1, $restriction->getBlockId() );
		$this->assertSame( 2, $restriction->getValue() );
		$this->assertSame( 'Saturn', $restriction->getTitle()->getText() );
	}

	public function testNewFromTitle() {
		$class = $this->getClass();
		$title = Title::makeTitle( NS_MAIN, 'Pluto' );
		$restriction = $class::newFromTitle( 'Mars' );
		$restriction2 = $class::newFromTitle( $title );

		$this->assertSame( 0, $restriction->getBlockId() );
		$this->assertSame( 'Mars', $restriction->getTitle()->getText() );
		$this->assertSame( $title->getArticleID(), $restriction2->getValue() );
	}

	/**
	 * @inheritDoc
	 */
	protected function getClass() {
		return PageRestriction::class;
	}
}
PK       ! J    +  block/Restriction/ActionRestrictionTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Block\Restriction;

use MediaWiki\Block\Restriction\ActionRestriction;
use MediaWiki\Title\Title;

/**
 * @group Blocking
 * @covers \MediaWiki\Block\Restriction\AbstractRestriction
 * @covers \MediaWiki\Block\Restriction\ActionRestriction
 */
class ActionRestrictionTest extends RestrictionTestCase {

	public function testMatches() {
		$class = $this->getClass();
		$restriction = new $class( 1, 2 );
		$this->assertFalse( $restriction->matches(
			$this->createMock( Title::class )
		) );
	}

	public function testGetType() {
		$class = $this->getClass();
		$restriction = new $class( 1, 2 );
		$this->assertEquals( 'action', $restriction->getType() );
	}

	/**
	 * @inheritDoc
	 */
	protected function getClass() {
		return ActionRestriction::class;
	}
}
PK       ! z/  /  )  block/Restriction/RestrictionTestCase.phpnu Iw        <?php

namespace MediaWiki\Tests\Block\Restriction;

/**
 * @group Blocking
 */
abstract class RestrictionTestCase extends \MediaWikiIntegrationTestCase {
	public function testConstruct() {
		$class = $this->getClass();
		$restriction = new $class( 1, 2 );

		$this->assertSame( 1, $restriction->getBlockId() );
		$this->assertSame( 2, $restriction->getValue() );
	}

	public function testSetBlockId() {
		$class = $this->getClass();
		$restriction = new $class( 1, 2 );

		$restriction->setBlockId( 10 );
		$this->assertSame( 10, $restriction->getBlockId() );
	}

	public function testEquals() {
		$class = $this->getClass();

		// Test two restrictions with the same data.
		$restriction = new $class( 1, 2 );
		$second = new $class( 1, 2 );
		$this->assertTrue( $restriction->equals( $second ) );

		// Test two restrictions that implement different classes.
		$second = $this->createMock( $this->getClass() );
		$this->assertFalse( $restriction->equals( $second ) );

		// Not the same block id.
		$second = new $class( 2, 2 );
		$this->assertTrue( $restriction->equals( $second ) );

		// Not the same value.
		$second = new $class( 1, 3 );
		$this->assertFalse( $restriction->equals( $second ) );
	}

	public function testNewFromRow() {
		$class = $this->getClass();

		$restriction = $class::newFromRow( (object)[
			'ir_ipb_id' => 1,
			'ir_value' => 2,
		] );

		$this->assertSame( 1, $restriction->getBlockId() );
		$this->assertSame( 2, $restriction->getValue() );
	}

	public function testToRow() {
		$class = $this->getClass();

		$restriction = new $class( 1, 2 );
		$row = $restriction->toRow();

		$this->assertSame( 1, $row['ir_ipb_id'] );
		$this->assertSame( 2, $row['ir_value'] );
	}

	/**
	 * Get the class name of the class that is being tested.
	 *
	 * @return string
	 */
	abstract protected function getClass();
}
PK       ! esyW    .  block/Restriction/NamespaceRestrictionTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Block\Restriction;

use MediaWiki\Block\Restriction\NamespaceRestriction;

/**
 * @group Database
 * @group Blocking
 * @covers \MediaWiki\Block\Restriction\AbstractRestriction
 * @covers \MediaWiki\Block\Restriction\NamespaceRestriction
 */
class NamespaceRestrictionTest extends RestrictionTestCase {

	public function testMatches() {
		$class = $this->getClass();
		$page = $this->getExistingTestPage( 'Saturn' );
		$restriction = new $class( 1, NS_MAIN );
		$this->assertTrue( $restriction->matches( $page->getTitle() ) );

		$page = $this->getExistingTestPage( 'Talk:Saturn' );
		$this->assertFalse( $restriction->matches( $page->getTitle() ) );
	}

	public function testGetType() {
		$class = $this->getClass();
		$restriction = new $class( 1, 2 );
		$this->assertEquals( 'ns', $restriction->getType() );
	}

	/**
	 * @inheritDoc
	 */
	protected function getClass() {
		return NamespaceRestriction::class;
	}
}
PK       ! ?	X@  X@  #  block/BlockRestrictionStoreTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Block;

use MediaWiki\Block\BlockRestrictionStore;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\Restriction\NamespaceRestriction;
use MediaWiki\Block\Restriction\PageRestriction;
use MediaWiki\Block\Restriction\Restriction;
use MediaWiki\MainConfigNames;

/**
 * @group Database
 * @group Blocking
 * @coversDefaultClass \MediaWiki\Block\BlockRestrictionStore
 */
class BlockRestrictionStoreTest extends \MediaWikiLangTestCase {

	protected BlockRestrictionStore $blockRestrictionStore;

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

		$this->blockRestrictionStore = $this->getServiceContainer()->getBlockRestrictionStore();
	}

	/**
	 * @covers ::loadByBlockId
	 * @covers ::resultToRestrictions
	 * @covers ::rowToRestriction
	 */
	public function testLoadMultipleRestrictions() {
		$this->overrideConfigValue( MainConfigNames::BlockDisablesLogin, false );
		$block = $this->insertBlock();

		$pageFoo = $this->getExistingTestPage( 'Foo' );
		$pageBar = $this->getExistingTestPage( 'Bar' );

		$this->blockRestrictionStore->insert( [
			new PageRestriction( $block->getId(), $pageFoo->getId() ),
			new PageRestriction( $block->getId(), $pageBar->getId() ),
			new NamespaceRestriction( $block->getId(), NS_USER ),
		] );

		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );

		$this->assertCount( 3, $restrictions );
	}

	/**
	 * @covers ::loadByBlockId
	 * @covers ::resultToRestrictions
	 * @covers ::rowToRestriction
	 */
	public function testWithNoRestrictions() {
		$block = $this->insertBlock();
		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );
		$this->assertSame( [], $restrictions );
	}

	/**
	 * @covers ::loadByBlockId
	 * @covers ::resultToRestrictions
	 * @covers ::rowToRestriction
	 */
	public function testWithEmptyParam() {
		$restrictions = $this->blockRestrictionStore->loadByBlockId( [] );
		$this->assertSame( [], $restrictions );
	}

	/**
	 * @covers ::loadByBlockId
	 * @covers ::resultToRestrictions
	 * @covers ::rowToRestriction
	 */
	public function testIgnoreNotSupportedTypes() {
		$block = $this->insertBlock();

		$pageFoo = $this->getExistingTestPage( 'Foo' );
		$pageBar = $this->getExistingTestPage( 'Bar' );

		// valid type
		$this->insertRestriction( $block->getId(), PageRestriction::TYPE_ID, $pageFoo->getId() );
		$this->insertRestriction( $block->getId(), NamespaceRestriction::TYPE_ID, NS_USER );

		// invalid type
		$this->insertRestriction( $block->getId(), 9, $pageBar->getId() );
		$this->insertRestriction( $block->getId(), 10, NS_FILE );

		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );
		$this->assertCount( 2, $restrictions );
	}

	/**
	 * @covers ::loadByBlockId
	 * @covers ::resultToRestrictions
	 * @covers ::rowToRestriction
	 */
	public function testMappingPageRestrictionObject() {
		$block = $this->insertBlock();
		$title = 'Lady Macbeth';
		$page = $this->getExistingTestPage( $title );

		// Test Page Restrictions.
		$this->blockRestrictionStore->insert( [
			new PageRestriction( $block->getId(), $page->getId() ),
		] );

		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );

		[ $pageRestriction ] = $restrictions;
		$this->assertInstanceOf( PageRestriction::class, $pageRestriction );
		$this->assertEquals( $block->getId(), $pageRestriction->getBlockId() );
		$this->assertEquals( $page->getId(), $pageRestriction->getValue() );
		$this->assertEquals( PageRestriction::TYPE, $pageRestriction->getType() );
		$this->assertEquals( $pageRestriction->getTitle()->getText(), $title );
	}

	/**
	 * @covers ::loadByBlockId
	 * @covers ::resultToRestrictions
	 * @covers ::rowToRestriction
	 */
	public function testMappingNamespaceRestrictionObject() {
		$block = $this->insertBlock();

		$this->blockRestrictionStore->insert( [
			new NamespaceRestriction( $block->getId(), NS_USER ),
		] );

		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );

		[ $namespaceRestriction ] = $restrictions;
		$this->assertInstanceOf( NamespaceRestriction::class, $namespaceRestriction );
		$this->assertEquals( $block->getId(), $namespaceRestriction->getBlockId() );
		$this->assertSame( NS_USER, $namespaceRestriction->getValue() );
		$this->assertEquals( NamespaceRestriction::TYPE, $namespaceRestriction->getType() );
	}

	/**
	 * @covers ::insert
	 */
	public function testInsert() {
		$block = $this->insertBlock();

		$pageFoo = $this->getExistingTestPage( 'Foo' );
		$pageBar = $this->getExistingTestPage( 'Bar' );

		$restrictions = [
			new PageRestriction( $block->getId(), $pageFoo->getId() ),
			new PageRestriction( $block->getId(), $pageBar->getId() ),
			new NamespaceRestriction( $block->getId(), NS_USER )
		];

		$result = $this->blockRestrictionStore->insert( $restrictions );
		$this->assertTrue( $result );

		$result = $this->blockRestrictionStore->insert( [] );
		$this->assertFalse( $result );
	}

	/**
	 * @covers ::insert
	 */
	public function testInsertTypes() {
		$block = $this->insertBlock();

		$pageFoo = $this->getExistingTestPage( 'Foo' );
		$pageBar = $this->getExistingTestPage( 'Bar' );

		$invalid = $this->createMock( Restriction::class );
		$invalid->method( 'toRow' )
			->willReturn( [
				'ir_ipb_id' => $block->getId(),
				'ir_type' => 9,
				'ir_value' => 42,
			] );

		$restrictions = [
			new PageRestriction( $block->getId(), $pageFoo->getId() ),
			new PageRestriction( $block->getId(), $pageBar->getId() ),
			new NamespaceRestriction( $block->getId(), NS_USER ),
			$invalid,
		];

		$result = $this->blockRestrictionStore->insert( $restrictions );
		$this->assertTrue( $result );

		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );
		$this->assertCount( 3, $restrictions );
	}

	/**
	 * @covers ::update
	 * @covers ::restrictionsByBlockId
	 * @covers ::restrictionsToRemove
	 */
	public function testUpdateInsert() {
		$block = $this->insertBlock();
		$pageFoo = $this->getExistingTestPage( 'Foo' );
		$pageBar = $this->getExistingTestPage( 'Bar' );
		$this->blockRestrictionStore->insert( [
				new PageRestriction( $block->getId(), $pageFoo->getId() ),
		] );

		$this->blockRestrictionStore->update( [
			new PageRestriction( $block->getId(), $pageBar->getId() ),
			new NamespaceRestriction( $block->getId(), NS_USER ),
		] );

		$result = $this->getDb()->newSelectQueryBuilder()
			->select( [ '*' ] )
			->from( 'ipblocks_restrictions' )
			->where( [ 'ir_ipb_id' => $block->getId() ] )
			->fetchResultSet();

		$this->assertEquals( 2, $result->numRows() );
		$row = $result->fetchObject();
		$this->assertEquals( $block->getId(), $row->ir_ipb_id );
		$this->assertEquals( $pageBar->getId(), $row->ir_value );
	}

	/**
	 * @covers ::update
	 * @covers ::restrictionsByBlockId
	 * @covers ::restrictionsToRemove
	 */
	public function testUpdateChange() {
		$block = $this->insertBlock();
		$page = $this->getExistingTestPage( 'Foo' );

		$this->blockRestrictionStore->update( [
			new PageRestriction( $block->getId(), $page->getId() ),
		] );

		$result = $this->getDb()->newSelectQueryBuilder()
			->select( [ '*' ] )
			->from( 'ipblocks_restrictions' )
			->where( [ 'ir_ipb_id' => $block->getId() ] )
			->fetchResultSet();

		$this->assertSame( 1, $result->numRows() );
		$row = $result->fetchObject();
		$this->assertEquals( $block->getId(), $row->ir_ipb_id );
		$this->assertEquals( $page->getId(), $row->ir_value );
	}

	/**
	 * @covers ::update
	 * @covers ::restrictionsByBlockId
	 * @covers ::restrictionsToRemove
	 */
	public function testUpdateNoRestrictions() {
		$block = $this->insertBlock();

		$this->blockRestrictionStore->update( [] );

		$result = $this->getDb()->newSelectQueryBuilder()
			->select( [ '*' ] )
			->from( 'ipblocks_restrictions' )
			->where( [ 'ir_ipb_id' => $block->getId() ] )
			->fetchResultSet();

		$this->assertSame( 0, $result->numRows() );
	}

	/**
	 * @covers ::update
	 * @covers ::restrictionsByBlockId
	 * @covers ::restrictionsToRemove
	 */
	public function testUpdateSame() {
		$block = $this->insertBlock();
		$page = $this->getExistingTestPage( 'Foo' );
		$this->blockRestrictionStore->insert( [
			new PageRestriction( $block->getId(), $page->getId() ),
		] );

		$this->blockRestrictionStore->update( [
			new PageRestriction( $block->getId(), $page->getId() ),
		] );

		$result = $this->getDb()->newSelectQueryBuilder()
			->select( [ '*' ] )
			->from( 'ipblocks_restrictions' )
			->where( [ 'ir_ipb_id' => $block->getId() ] )
			->fetchResultSet();

		$this->assertSame( 1, $result->numRows() );
		$row = $result->fetchObject();
		$this->assertEquals( $block->getId(), $row->ir_ipb_id );
		$this->assertEquals( $page->getId(), $row->ir_value );
	}

	/**
	 * @covers ::updateByParentBlockId
	 */
	public function testDeleteAllUpdateByParentBlockId() {
		// Create a block and an autoblock (a child block)
		$block = $this->insertBlock();
		$pageFoo = $this->getExistingTestPage( 'Foo' );
		$pageBar = $this->getExistingTestPage( 'Bar' );
		$this->blockRestrictionStore->insert( [
			new PageRestriction( $block->getId(), $pageFoo->getId() ),
		] );
		$autoblockId = $this->getServiceContainer()->getDatabaseBlockStore()
			->doAutoblock( $block, '127.0.0.1' );

		// Ensure that the restrictions on the block have not changed.
		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );
		$this->assertCount( 1, $restrictions );
		$this->assertEquals( $pageFoo->getId(), $restrictions[0]->getValue() );

		// Ensure that the restrictions on the autoblock are the same as the block.
		$restrictions = $this->blockRestrictionStore->loadByBlockId( $autoblockId );
		$this->assertCount( 1, $restrictions );
		$this->assertEquals( $pageFoo->getId(), $restrictions[0]->getValue() );

		// Update the restrictions on the autoblock (but leave the block unchanged)
		$this->blockRestrictionStore->updateByParentBlockId( $block->getId(), [
			new PageRestriction( $block->getId(), $pageBar->getId() ),
		] );

		// Ensure that the restrictions on the block have not changed.
		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );
		$this->assertCount( 1, $restrictions );
		$this->assertEquals( $pageFoo->getId(), $restrictions[0]->getValue() );

		// Ensure that the restrictions on the autoblock have been updated.
		$restrictions = $this->blockRestrictionStore->loadByBlockId( $autoblockId );
		$this->assertCount( 1, $restrictions );
		$this->assertEquals( $pageBar->getId(), $restrictions[0]->getValue() );
	}

	/**
	 * @covers ::updateByParentBlockId
	 */
	public function testUpdateByParentBlockId() {
		// Create a block and an autoblock (a child block)
		$block = $this->insertBlock();
		$page = $this->getExistingTestPage( 'Foo' );
		$this->blockRestrictionStore->insert( [
			new PageRestriction( $block->getId(), $page->getId() ),
		] );
		$autoblockId = $this->getServiceContainer()->getDatabaseBlockStore()
			->doAutoblock( $block, '127.0.0.1' );

		// Ensure that the restrictions on the block have not changed.
		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );
		$this->assertCount( 1, $restrictions );

		// Ensure that the restrictions on the autoblock have not changed.
		$restrictions = $this->blockRestrictionStore->loadByBlockId( $autoblockId );
		$this->assertCount( 1, $restrictions );

		// Remove the restrictions on the autoblock (but leave the block unchanged)
		$this->blockRestrictionStore->updateByParentBlockId( $block->getId(), [] );

		// Ensure that the restrictions on the block have not changed.
		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );
		$this->assertCount( 1, $restrictions );

		// Ensure that the restrictions on the autoblock have been updated.
		$restrictions = $this->blockRestrictionStore->loadByBlockId( $autoblockId );
		$this->assertSame( [], $restrictions );
	}

	/**
	 * @covers ::updateByParentBlockId
	 */
	public function testNoAutoblocksUpdateByParentBlockId() {
		// Create a block with no autoblock.
		$block = $this->insertBlock();
		$page = $this->getExistingTestPage( 'Foo' );
		$this->blockRestrictionStore->insert( [
			new PageRestriction( $block->getId(), $page->getId() ),
		] );

		// Ensure that the restrictions on the block have not changed.
		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );
		$this->assertCount( 1, $restrictions );

		// Update the restrictions on any autoblocks (there are none).
		$this->blockRestrictionStore->updateByParentBlockId( $block->getId(), $restrictions );

		// Ensure that the restrictions on the block have not changed.
		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );
		$this->assertCount( 1, $restrictions );
	}

	/**
	 * @covers ::delete
	 */
	public function testDelete() {
		$block = $this->insertBlock();
		$page = $this->getExistingTestPage( 'Foo' );
		$this->blockRestrictionStore->insert( [
			new PageRestriction( $block->getId(), $page->getId() ),
		] );

		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );
		$this->assertCount( 1, $restrictions );

		$result = $this->blockRestrictionStore->delete( $restrictions );
		$this->assertTrue( $result );

		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );
		$this->assertSame( [], $restrictions );
	}

	/**
	 * @covers ::deleteByBlockId
	 */
	public function testDeleteByBlockId() {
		$block = $this->insertBlock();
		$page = $this->getExistingTestPage( 'Foo' );
		$this->blockRestrictionStore->insert( [
			new PageRestriction( $block->getId(), $page->getId() ),
		] );

		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );
		$this->assertCount( 1, $restrictions );

		$result = $this->blockRestrictionStore->deleteByBlockId( $block->getId() );
		$this->assertNotFalse( $result );

		$restrictions = $this->blockRestrictionStore->loadByBlockId( $block->getId() );
		$this->assertSame( [], $restrictions );
	}

	/**
	 * @covers ::equals
	 * @dataProvider equalsDataProvider
	 *
	 * @param array $a
	 * @param array $b
	 * @param bool $expected
	 */
	public function testEquals( array $a, array $b, $expected ) {
		$this->assertSame( $expected, $this->blockRestrictionStore->equals( $a, $b ) );
	}

	public function equalsDataProvider() {
		return [
			[
				[
					new PageRestriction( 1, 1 ),
				],
				[
					new PageRestriction( 1, 2 )
				],
				false,
			],
			[
				[
					new PageRestriction( 1, 1 ),
				],
				[
					new PageRestriction( 1, 1 ),
					new PageRestriction( 1, 2 )
				],
				false,
			],
			[
				[],
				[],
				true,
			],
			[
				[
					new PageRestriction( 1, 1 ),
					new PageRestriction( 1, 2 ),
					new PageRestriction( 2, 3 ),
				],
				[
					new PageRestriction( 2, 3 ),
					new PageRestriction( 1, 2 ),
					new PageRestriction( 1, 1 ),
				],
				true
			],
			[
				[
					new NamespaceRestriction( 1, NS_USER ),
				],
				[
					new NamespaceRestriction( 1, NS_USER ),
				],
				true
			],
			[
				[
					new NamespaceRestriction( 1, NS_USER ),
				],
				[
					new NamespaceRestriction( 1, NS_TALK ),
				],
				false
			],
		];
	}

	/**
	 * @covers ::setBlockId
	 */
	public function testSetBlockId() {
		$restrictions = [
			new PageRestriction( 1, 1 ),
			new PageRestriction( 1, 2 ),
			new NamespaceRestriction( 1, NS_USER ),
		];

		$this->assertSame( 1, $restrictions[0]->getBlockId() );
		$this->assertSame( 1, $restrictions[1]->getBlockId() );
		$this->assertSame( 1, $restrictions[2]->getBlockId() );

		$result = $this->blockRestrictionStore->setBlockId( 2, $restrictions );

		foreach ( $result as $restriction ) {
			$this->assertSame( 2, $restriction->getBlockId() );
		}
	}

	protected function insertBlock() {
		$badActor = $this->getTestUser()->getUser();
		$sysop = $this->getTestSysop()->getUser();

		$block = new DatabaseBlock( [
			'address' => $badActor,
			'by' => $sysop,
			'expiry' => 'infinity',
			'sitewide' => 0,
			'enableAutoblock' => true,
		] );

		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );

		return $block;
	}

	protected function insertRestriction( $blockId, $type, $value ) {
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'ipblocks_restrictions' )
			->row( [
				'ir_ipb_id' => $blockId,
				'ir_type' => $type,
				'ir_value' => $value,
			] )
			->caller( __METHOD__ )
			->execute();
	}
}
PK       ! ber  r    Status/StatusTest.phpnu Iw        <?php

use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\Language\RawMessage;
use MediaWiki\Message\Message;
use MediaWiki\Status\Status;
use Wikimedia\Message\MessageSpecifier;
use Wikimedia\Message\MessageValue;

/**
 * @covers \MediaWiki\Status\Status
 * @covers \StatusValue
 * @author Addshore
 */
class StatusTest extends MediaWikiLangTestCase {

	/**
	 * @dataProvider provideValues
	 */
	public function testNewGood( $value = null ) {
		$status = Status::newGood( $value );
		$this->assertTrue( $status->isGood() );
		$this->assertTrue( $status->isOK() );
		$this->assertEquals( $value, $status->getValue() );
	}

	public static function provideValues() {
		return [
			[],
			[ 'foo' ],
			[ [ 'foo' => 'bar' ] ],
			[ new Exception() ],
			[ 1234 ],
		];
	}

	public function testNewFatalWithMessage() {
		$message = $this->getMockMessage();
		$status = Status::newFatal( $message );
		$this->assertFalse( $status->isGood() );
		$this->assertFalse( $status->isOK() );
		$this->assertEquals( $message, $status->getMessage() );
	}

	public function testNewFatalWithString() {
		$message = 'foo';
		$status = Status::newFatal( $message );
		$this->assertFalse( $status->isGood() );
		$this->assertFalse( $status->isOK() );
		$this->assertEquals( $message, $status->getMessage()->getKey() );
	}

	public function testOkAndErrorsGetters() {
		$status = Status::newGood( 'foo' );
		$this->assertTrue( $status->ok );
		$status = Status::newFatal( 'foo', 1, 2 );
		$this->assertFalse( $status->ok );
		$this->assertArrayEquals(
			[
				[
					'type' => 'error',
					'message' => 'foo',
					'params' => [ 1, 2 ]
				]
			],
			$status->errors
		);
	}

	public function testOkSetter() {
		$status = new Status();
		$status->ok = false;
		$this->assertFalse( $status->isOK() );
		$status->ok = true;
		$this->assertTrue( $status->isOK() );
	}

	/**
	 * @dataProvider provideSetResult
	 */
	public function testSetResult( $ok, $value = null ) {
		$status = new Status();
		$status->setResult( $ok, $value );
		$this->assertEquals( $ok, $status->isOK() );
		$this->assertEquals( $value, $status->getValue() );
	}

	public static function provideSetResult() {
		return [
			[ true ],
			[ false ],
			[ true, 'value' ],
			[ false, 'value' ],
		];
	}

	/**
	 * @dataProvider provideIsOk
	 */
	public function testIsOk( $ok ) {
		$status = new Status();
		$status->setOK( $ok );
		$this->assertEquals( $ok, $status->isOK() );
	}

	public static function provideIsOk() {
		return [
			[ true ],
			[ false ],
		];
	}

	public function testGetValue() {
		$status = new Status();
		$status->value = 'foobar';
		$this->assertEquals( 'foobar', $status->getValue() );
	}

	/**
	 * @dataProvider provideIsGood
	 */
	public function testIsGood( $ok, $errors, $expected ) {
		$status = new Status();
		$status->setOK( $ok );
		foreach ( $errors as $error ) {
			$status->warning( $error );
		}
		$this->assertEquals( $expected, $status->isGood() );
	}

	public static function provideIsGood() {
		return [
			[ true, [], true ],
			[ true, [ 'foo' ], false ],
			[ false, [], false ],
			[ false, [ 'foo' ], false ],
		];
	}

	/**
	 * @dataProvider provideMockMessageDetails
	 */
	public function testWarningWithMessage( $mockDetails ) {
		$status = new Status();
		$messages = $this->getMockMessages( $mockDetails );

		foreach ( $messages as $message ) {
			$status->warning( $message );
		}
		$warnings = $status->getWarningsArray();

		$this->assertSameSize( $messages, $warnings );
		foreach ( $messages as $key => $message ) {
			$expectedArray = [ $message->getKey(), ...$message->getParams() ];
			$this->assertEquals( $expectedArray, $warnings[$key] );
		}
	}

	/**
	 * @dataProvider provideMockMessageDetails
	 */
	public function testErrorWithMessage( $mockDetails ) {
		$status = new Status();
		$messages = $this->getMockMessages( $mockDetails );

		foreach ( $messages as $message ) {
			$status->error( $message );
		}
		$errors = $status->getErrorsArray();

		$this->assertSameSize( $messages, $errors );
		foreach ( $messages as $key => $message ) {
			$expectedArray = [ $message->getKey(), ...$message->getParams() ];
			$this->assertEquals( $expectedArray, $errors[$key] );
		}
	}

	/**
	 * @dataProvider provideMockMessageDetails
	 */
	public function testFatalWithMessage( $mockDetails ) {
		$status = new Status();
		$messages = $this->getMockMessages( $mockDetails );

		foreach ( $messages as $message ) {
			$status->fatal( $message );
		}
		$errors = $status->getErrorsArray();

		$this->assertSameSize( $messages, $errors );
		foreach ( $messages as $key => $message ) {
			$expectedArray = [ $message->getKey(), ...$message->getParams() ];
			$this->assertEquals( $expectedArray, $errors[$key] );
		}
		$this->assertStatusNotOK( $status );
	}

	public function testRawMessage() {
		$status = new Status();
		$msg = new RawMessage( 'Foo Bar' );
		$status->fatal( $msg );

		$this->assertEquals( 'rawmessage', $status->getErrorsArray()[0][0] );
		$this->assertEquals( $msg, $status->getMessages()[0] );
	}

	/**
	 * @param array $messageDetails E.g. [ 'KEY' => [ /PARAMS/ ] ]
	 * @return Message[]
	 */
	protected function getMockMessages( $messageDetails ) {
		$messages = [];
		foreach ( $messageDetails as $key => $paramsArray ) {
			$messages[] = $this->getMockMessage( $key, $paramsArray );
		}
		return $messages;
	}

	public static function provideMockMessageDetails() {
		return [
			[ [ 'key1' => [ 'bar' ] ] ],
			[ [ 'key1' => [ 'bar' ], 'key2' => [ 'bar2' ] ] ],
		];
	}

	public function testMerge() {
		$status1 = new Status();
		$status2 = new Status();
		$message1 = $this->getMockMessage( 'warn1' );
		$message2 = $this->getMockMessage( 'error2' );
		$status1->warning( $message1 );
		$status2->error( $message2 );

		$status1->merge( $status2 );
		$this->assertCount( 2, $status1->getMessages() );
	}

	public function testMergeWithOverwriteValue() {
		$status1 = new Status();
		$status2 = new Status();
		$message1 = $this->getMockMessage( 'warn1' );
		$message2 = $this->getMockMessage( 'error2' );
		$status1->warning( $message1 );
		$status2->error( $message2 );
		$status2->value = 'FooValue';

		$status1->merge( $status2, true );
		$this->assertCount( 2, $status1->getMessages() );
		$this->assertEquals( 'FooValue', $status1->getValue() );
	}

	public function testHasMessage() {
		$status = new Status();
		$status->fatal( 'bad' );
		$status->fatal( wfMessage( 'bad-msg' ) );
		$status->fatal( new MessageValue( 'bad-msg-value' ) );
		$this->assertTrue( $status->hasMessage( 'bad' ) );
		$this->assertTrue( $status->hasMessage( 'bad-msg' ) );
		$this->expectDeprecationAndContinue( '/Passing MessageSpecifier/' );
		$this->assertTrue( $status->hasMessage( wfMessage( 'bad-msg' ) ) );
		$this->assertTrue( $status->hasMessage( wfMessage( 'bad-msg-value' ) ) );
		$this->assertTrue( $status->hasMessage( new MessageValue( 'bad-msg' ) ) );
		$this->assertTrue( $status->hasMessage( new MessageValue( 'bad-msg-value' ) ) );
		$this->assertFalse( $status->hasMessage( 'good' ) );
	}

	public function testHasMessagesExcept() {
		$status = new Status();
		$status->fatal( 'bad' );
		$status->fatal( wfMessage( 'bad-msg' ) );
		$status->fatal( new MessageValue( 'bad-msg-value' ) );
		$this->assertTrue( $status->hasMessagesExcept( 'good' ) );
		$this->assertTrue( $status->hasMessagesExcept( 'bad' ) );
		$this->assertFalse( $status->hasMessagesExcept(
			'bad', 'bad-msg', 'bad-msg-value' ) );
		$this->assertFalse( $status->hasMessagesExcept(
			'good', 'bad', 'bad-msg', 'bad-msg-value' ) );
		$this->expectDeprecationAndContinue( '/Passing MessageSpecifier/' );
		$this->assertFalse( $status->hasMessagesExcept(
			wfMessage( 'bad' ), new MessageValue( 'bad-msg' ), 'bad-msg-value' ) );
	}

	/**
	 * @dataProvider provideCleanParams
	 */
	public function testCleanParams( $cleanCallback, $params, $expected, $unexpected ) {
		$this->setUserLang( 'qqx' );

		$status = new Status();
		$status->cleanCallback = $cleanCallback;
		$status->warning( 'ok', ...$params );

		$wikitext = $status->getWikiText();
		$this->assertStringContainsString( $expected, $wikitext );
		$this->assertStringNotContainsString( $unexpected, $wikitext );

		$html = $status->getHTML();
		$this->assertStringContainsString( $expected, $html );
		$this->assertStringNotContainsString( $unexpected, $html );
	}

	public static function provideCleanParams() {
		$cleanCallback = static function ( $value ) {
			return 'xxx';
		};

		return [
			[ false, [ 'secret' ], 'secret', 'xxx' ],
			[ $cleanCallback, [ 'secret' ], 'xxx', 'secret' ],
		];
	}

	/**
	 * @dataProvider provideGetWikiTextAndHtml
	 */
	public function testGetWikiText(
		Status $status, $wikitext, $wrappedWikitext, $html, $wrappedHtml
	) {
		$this->assertEquals( $wikitext, $status->getWikiText() );

		$this->assertEquals( $wrappedWikitext, $status->getWikiText( 'wrap-short', 'wrap-long', 'qqx' ) );
	}

	/**
	 * @dataProvider provideGetWikiTextAndHtml
	 */
	public function testGetHtml(
		Status $status, $wikitext, $wrappedWikitext, $html, $wrappedHtml
	) {
		$this->assertEquals( $html, $status->getHTML() );

		$this->assertEquals( $wrappedHtml, $status->getHTML( 'wrap-short', 'wrap-long', 'qqx' ) );
	}

	/**
	 * @return array Array of arrays with values;
	 *    0 => status object
	 *    1 => expected string (with no context)
	 */
	public static function provideGetWikiTextAndHtml() {
		$testCases = [];

		$testCases['GoodStatus'] = [
			new Status(),
			"Internal error: MediaWiki\Status\StatusFormatter::getWikiText called for a good result, this is incorrect&#10;",
			"(wrap-short: (internalerror_info: MediaWiki\Status\StatusFormatter::getWikiText called for a good result, " .
				"this is incorrect&#10;))",
			"<p>Internal error: MediaWiki\Status\StatusFormatter::getWikiText called for a good result, this is incorrect&#10;\n</p>",
			"<p>(wrap-short: (internalerror_info: MediaWiki\Status\StatusFormatter::getWikiText called for a good result, " .
				"this is incorrect&#10;))\n</p>",
		];

		$status = new Status();
		$status->setOK( false );
		$testCases['GoodButNoError'] = [
			$status,
			"Internal error: MediaWiki\Status\StatusFormatter::getWikiText: Invalid result object: no error text but not OK&#10;",
			"(wrap-short: (internalerror_info: MediaWiki\Status\StatusFormatter::getWikiText: Invalid result object: " .
				"no error text but not OK&#10;))",
			"<p>Internal error: MediaWiki\Status\StatusFormatter::getWikiText: Invalid result object: no error text but not OK&#10;\n</p>",
			"<p>(wrap-short: (internalerror_info: MediaWiki\Status\StatusFormatter::getWikiText: Invalid result object: " .
				"no error text but not OK&#10;))\n</p>",
		];

		$status = new Status();
		$status->warning( 'fooBar!' );
		$testCases['1StringWarning'] = [
			$status,
			"⧼fooBar!⧽",
			"(wrap-short: (fooBar!))",
			"<p>⧼fooBar!⧽\n</p>",
			"<p>(wrap-short: (fooBar!))\n</p>",
		];

		$status = new Status();
		$status->warning( 'fooBar!' );
		$status->warning( 'fooBar2!' );
		$testCases['2StringWarnings'] = [
			$status,
			"<ul>\n<li>\n⧼fooBar!⧽\n</li>\n<li>\n⧼fooBar2!⧽\n</li>\n</ul>\n",
			"(wrap-long: <ul>\n<li>\n(fooBar!)\n</li>\n<li>\n(fooBar2!)\n</li>\n</ul>\n)",
			"<ul>\n<li>\n⧼fooBar!⧽\n</li>\n<li>\n⧼fooBar2!⧽\n</li>\n</ul>\n",
			"<p>(wrap-long: </p><ul>\n<li>\n(fooBar!)\n</li>\n<li>\n(fooBar2!)\n</li>\n</ul>\n<p>)\n</p>",
		];

		$status = new Status();
		$status->warning( new Message( 'fooBar!', [ 'foo', 'bar' ] ) );
		$testCases['1MessageWarning'] = [
			$status,
			"⧼fooBar!⧽",
			"(wrap-short: (fooBar!: foo, bar))",
			"<p>⧼fooBar!⧽\n</p>",
			"<p>(wrap-short: (fooBar!: foo, bar))\n</p>",
		];

		$status = new Status();
		$status->warning( new Message( 'fooBar!', [ 'foo', 'bar' ] ) );
		$status->warning( new Message( 'fooBar2!' ) );
		$testCases['2MessageWarnings'] = [
			$status,
			"<ul>\n<li>\n⧼fooBar!⧽\n</li>\n<li>\n⧼fooBar2!⧽\n</li>\n</ul>\n",
			"(wrap-long: <ul>\n<li>\n(fooBar!: foo, bar)\n</li>\n<li>\n(fooBar2!)\n</li>\n</ul>\n)",
			"<ul>\n<li>\n⧼fooBar!⧽\n</li>\n<li>\n⧼fooBar2!⧽\n</li>\n</ul>\n",
			"<p>(wrap-long: </p><ul>\n<li>\n(fooBar!: foo, bar)\n</li>\n<li>\n(fooBar2!)\n</li>\n</ul>\n<p>)\n</p>",
		];

		return $testCases;
	}

	private static function sanitizedMessageParams( Message $message ) {
		return array_map( static function ( $p ) {
			return $p instanceof Message
				? [
					'key' => $p->getKey(),
					'params' => self::sanitizedMessageParams( $p ),
					'lang' => $p->getLanguage()->getCode(),
				]
				: $p;
		}, $message instanceof RawMessage ? $message->getParamsOfRawMessage() : $message->getParams() );
	}

	private static function sanitizedMessageKey( Message $message ) {
		return $message instanceof RawMessage ? $message->getTextOfRawMessage() : $message->getKey();
	}

	/**
	 * @dataProvider provideGetMessage
	 */
	public function testGetMessage(
		Status $status, $expectedParams, $expectedKey, $expectedWrapper
	) {
		$message = $status->getMessage( null, null, 'qqx' );
		$this->assertInstanceOf( Message::class, $message );
		$this->assertEquals( $expectedParams, self::sanitizedMessageParams( $message ),
			'Message::getParams' );
		$this->assertEquals( $expectedKey, self::sanitizedMessageKey( $message ), 'Message::getKey' );

		$message = $status->getMessage( 'wrapper-short', 'wrapper-long' );
		$this->assertInstanceOf( Message::class, $message );
		$this->assertEquals( $expectedWrapper, $message->getKey(), 'Message::getKey with wrappers' );
		$this->assertCount( 1, $message->getParams(), 'Message::getParams with wrappers' );

		$message = $status->getMessage( 'wrapper' );
		$this->assertInstanceOf( Message::class, $message );
		$this->assertEquals( 'wrapper', $message->getKey(), 'Message::getKey with wrappers' );
		$this->assertCount( 1, $message->getParams(), 'Message::getParams with wrappers' );

		$message = $status->getMessage( false, 'wrapper' );
		$this->assertInstanceOf( Message::class, $message );
		$this->assertEquals( 'wrapper', $message->getKey(), 'Message::getKey with wrappers' );
		$this->assertCount( 1, $message->getParams(), 'Message::getParams with wrappers' );
	}

	/**
	 * @return array Array of arrays with values;
	 *    0 => status object
	 *    1 => expected Message parameters (with no context)
	 *    2 => expected Message key
	 */
	public static function provideGetMessage() {
		$testCases = [];

		$testCases['GoodStatus'] = [
			new Status(),
			[ "MediaWiki\Status\StatusFormatter::getMessage called for a good result, this is incorrect&#10;" ],
			'internalerror_info',
			'wrapper-short'
		];

		$status = new Status();
		$status->setOK( false );
		$testCases['GoodButNoError'] = [
			$status,
			[ "MediaWiki\Status\StatusFormatter::getMessage: Invalid result object: no error text but not OK&#10;" ],
			'internalerror_info',
			'wrapper-short'
		];

		$status = new Status();
		$status->warning( 'fooBar!' );
		$testCases['1StringWarning'] = [
			$status,
			[],
			'fooBar!',
			'wrapper-short'
		];

		$status = new Status();
		$status->warning( 'fooBar!' );
		$status->warning( 'fooBar2!' );
		$testCases[ '2StringWarnings' ] = [
			$status,
			[
				[ 'key' => 'fooBar!', 'params' => [], 'lang' => 'qqx' ],
				[ 'key' => 'fooBar2!', 'params' => [], 'lang' => 'qqx' ]
			],
			"* \$1\n* \$2",
			'wrapper-long'
		];

		$status = new Status();
		$status->warning( new Message( 'fooBar!', [ 'foo', 'bar' ] ) );
		$testCases['1MessageWarning'] = [
			$status,
			[ 'foo', 'bar' ],
			'fooBar!',
			'wrapper-short'
		];

		$status = new Status();
		$status->warning( new MessageValue( 'fooBar!', [ 'foo', 'bar' ] ) );
		$status->warning( new MessageValue( 'fooBar2!' ) );
		$testCases['2MessageWarnings'] = [
			$status,
			[
				[ 'key' => 'fooBar!', 'params' => [ 'foo', 'bar' ], 'lang' => 'qqx' ],
				[ 'key' => 'fooBar2!', 'params' => [], 'lang' => 'qqx' ]
			],
			"* \$1\n* \$2",
			'wrapper-long'
		];

		return $testCases;
	}

	/**
	 * @dataProvider provideGetPsr3MessageAndContext
	 */
	public function testGetPsr3MessageAndContext(
		array $errors,
		string $expectedMessage,
		array $expectedContext
	) {
		// set up a rawmessage_2 message, which is just like rawmessage but doesn't trigger
		// the special-casing in Status::getPsr3MessageAndContext
		$this->setTemporaryHook( 'MessageCacheFetchOverrides', static function ( &$overrides ) {
			$overrides['rawmessage_2'] = 'rawmessage';
		}, false );

		$status = new Status();
		foreach ( $errors as $error ) {
			$status->error( ...$error );
		}
		[ $actualMessage, $actualContext ] = $status->getPsr3MessageAndContext();
		$this->assertSame( $expectedMessage, $actualMessage );
		$this->assertSame( $expectedContext, $actualContext );
	}

	public static function provideGetPsr3MessageAndContext() {
		return [
			// parameters to Status::error() calls as array of arrays; expected message; expected context
			'no errors' => [
				[],
				"Internal error: MediaWiki\Status\StatusFormatter::getWikiText called for a good result, this is incorrect&#10;",
				[],
			],
			// make sure that the rawmessage_2 hack works as the following tests rely on it
			'rawmessage_2' => [
				[ [ 'rawmessage_2', 'foo' ] ],
				'{parameter1}',
				[ 'parameter1' => 'foo' ],
			],
			'two errors' => [
				[ [ 'rawmessage_2', 'foo' ], [ 'rawmessage_2', 'bar' ] ],
				"<ul>\n<li>\nfoo\n</li>\n<li>\nbar\n</li>\n</ul>\n",
				[],
			],
			'unknown subclass' => [
				// phpcs:ignore Squiz.WhiteSpace.ScopeClosingBrace.ContentBefore
				[ [ new class( 'rawmessage_2', [ 'foo' ] ) extends Message {} ] ],
				'foo',
				[],
			],
			'non-scalar parameter' => [
				[ [ new Message( 'rawmessage_2', [ new Message( 'rawmessage_2', [ 'foo' ] ) ] ) ] ],
				'foo',
				[],
			],
			'one parameter' => [
				[ [ 'apiwarn-invalidtitle', 'foo' ] ],
				'"{parameter1}" is not a valid title.',
				[ 'parameter1' => 'foo' ],
			],
			'multiple parameters' => [
				[ [ 'api-exception-trace', 'foo', 'bar', 'baz', 'boom' ] ],
				"{parameter1} at {parameter2}({parameter3})\n{parameter4}",
				[ 'parameter1' => 'foo', 'parameter2' => 'bar', 'parameter3' => 'baz', 'parameter4' => 'boom' ],
			],
			'formatted parameter' => [
				[ [ 'apiwarn-invalidtitle', Message::numParam( 1000000 ) ] ],
				'"{parameter1}" is not a valid title.',
				[ 'parameter1' => 1000000 ],
			],
			'rawmessage' => [
				[ [ 'rawmessage', 'foo' ] ],
				'foo',
				[],
			],
			'RawMessage' => [
				[ [ new RawMessage( 'foo $1 baz', [ 'bar' ] ) ] ],
				'foo {parameter1} baz',
				[ 'parameter1' => 'bar' ],
			],
		];
	}

	public function testReplaceMessageObj() {
		$this->expectDeprecationAndContinue( '/Passing MessageSpecifier/' );

		$status = new Status();
		$message = new Message( 'key1', [ 'foo1', 'bar1' ] );
		$status->error( $message );
		$newMessage = new Message( 'key2', [ 'foo2', 'bar2' ] );

		// Replacing by searching for the same Message object works
		$status->replaceMessage( $message, $newMessage );
		$this->assertSame( $newMessage, $status->errors[0]['message'] );

		$messageB = new Message( 'key-b', [ 'foo1', 'bar1' ] );
		$status->error( $messageB );

		// Replacing by searching for a different but equivalent Message object DOES NOT WORK
		// (that's why this is deprecated)
		$status->replaceMessage( new Message( 'key-b' ), 'huh' );
		$status->replaceMessage( new Message( 'key-b', [ 'foo1', 'bar1' ] ), 'huh' );
		$this->assertSame( $messageB, $status->errors[1]['message'] );
	}

	public function testReplaceMessageValue() {
		$this->expectDeprecationAndContinue( '/Passing MessageSpecifier/' );

		$status = new Status();
		$messageVal = new MessageValue( 'key1', [ 'foo1', 'bar1' ] );
		$status->error( $messageVal );
		$newMessageVal = new MessageValue( 'key2', [ 'foo2', 'bar2' ] );

		$status->replaceMessage( $messageVal, $newMessageVal );

		$conv = new \MediaWiki\Message\Converter;
		$this->assertEquals( $newMessageVal, $conv->convertMessage( $status->errors[0]['message'] ) );
	}

	public function testReplaceMessageByKey() {
		$status = new Status();

		$status->error( new Message( 'key1', [ 'foo1', 'bar1' ] ) );
		$newMessage = new Message( 'key2', [ 'foo2', 'bar2' ] );

		$status->replaceMessage( 'key1', $newMessage );

		$this->assertEquals( $newMessage, $status->errors[0]['message'] );
	}

	public function testWakeUpSanitizesCallback() {
		$status = new Status();
		$status->cleanCallback = static function ( $value ) {
			return '-' . $value . '-';
		};
		$status->__wakeup();
		$this->assertFalse( $status->cleanCallback );
	}

	/**
	 * @dataProvider provideNonObjectMessages
	 */
	public function testGetStatusArrayWithNonObjectMessages( $nonObjMsg ) {
		$status = new Status();
		if ( !array_key_exists( 1, $nonObjMsg ) ) {
			$status->warning( $nonObjMsg[0] );
		} else {
			$status->warning( $nonObjMsg[0], $nonObjMsg[1] );
		}

		$array = $status->getWarningsArray(); // We use getWarningsArray to access getStatusArray

		$this->assertCount( 1, $array );
		$this->assertEquals( $nonObjMsg, $array[0] );
	}

	public static function provideNonObjectMessages() {
		return [
			[ [ 'ImaString', [ 'param1' => 'value1' ] ] ],
			[ [ 'ImaString' ] ],
		];
	}

	/**
	 * @dataProvider provideErrorsWarningsOnly
	 */
	public function testGetErrorsWarningsOnlyStatus( $errorText, $warningText, $type, $errorResult,
		$warningResult
	) {
		$status = Status::newGood();
		if ( $errorText ) {
			$status->fatal( $errorText );
		}
		if ( $warningText ) {
			$status->warning( $warningText );
		}
		$testStatus = $status->splitByErrorType()[$type];
		$this->assertEquals( $errorResult, $testStatus->getErrorsByType( 'error' ) );
		$this->assertEquals( $warningResult, $testStatus->getErrorsByType( 'warning' ) );
	}

	public static function provideErrorsWarningsOnly() {
		return [
			[
				'Just an error',
				'Just a warning',
				0,
				[
					0 => [
						'type' => 'error',
						'message' => 'Just an error',
						'params' => []
					],
				],
				[],
			], [
				'Just an error',
				'Just a warning',
				1,
				[],
				[
					0 => [
						'type' => 'warning',
						'message' => 'Just a warning',
						'params' => []
					],
				],
			], [
				null,
				null,
				1,
				[],
				[],
			], [
				null,
				null,
				0,
				[],
				[],
			]
		];
	}

	/**
	 * Regression test for interference between cloning and references.
	 * @coversNothing
	 */
	public function testWrapAndSplitByErrorType() {
		$sv = StatusValue::newFatal( 'fatal' );
		$sv->warning( 'warning' );
		$s = Status::wrap( $sv );
		[ $se, $sw ] = $s->splitByErrorType();
		$this->assertTrue( $s->hasMessage( 'fatal' ) );
		$this->assertTrue( $s->hasMessage( 'warning' ) );
		$this->assertFalse( $s->isOK() );
		$this->assertTrue( $se->hasMessage( 'fatal' ) );
		$this->assertFalse( $se->hasMessage( 'warning' ) );
		$this->assertFalse( $s->isOK() );
		$this->assertFalse( $sw->hasMessage( 'fatal' ) );
		$this->assertTrue( $sw->hasMessage( 'warning' ) );
		$this->assertTrue( $sw->isOK() );
	}

	public function testSetContext() {
		$status = Status::newFatal( 'foo' );
		$status->fatal( 'bar' );

		$messageLocalizer = $this->createNoOpMock( IContextSource::class, [ 'msg' ] );
		$messageLocalizer->expects( $this->atLeastOnce() )
			->method( 'msg' )
			->willReturnCallback( static function ( $key ) {
				return new RawMessage( $key );
			} );

		$status->setMessageLocalizer( $messageLocalizer );
		$status->getWikiText();
		$status->getWikiText( false, false, 'en' );
		$status->getWikiText( 'wrap-short', 'wrap-long' );
	}

	public static function provideDuplicates() {
		yield [ [ 'foo', 1, 2 ], [ 'foo', 1, 2 ] ];
		$message = new Message( 'foo', [ 1, 2 ] );
		yield [ $message, $message ];
		yield [ $message, [ 'foo', 1, 2 ] ];
		yield [ [ 'foo', 1, 2 ], $message ];
		$messageWithContext1 = ( new Message( 'foo' ) )->setContext( RequestContext::getMain() );
		$messageWithContext2 = ( new Message( 'foo' ) )->setContext( RequestContext::getMain() );
		yield [ $messageWithContext1, $messageWithContext2 ];
	}

	/**
	 * @dataProvider provideDuplicates
	 */
	public function testDuplicateError( $error1, $error2 ) {
		$status = Status::newGood();
		if ( $error1 instanceof MessageSpecifier ) {
			$status->error( $error1 );
			$expected = [
				'type' => 'error',
				'message' => $error1,
				'params' => []
			];
		} else {
			$status->error( ...$error1 );
			$message1 = $error1[0];
			array_shift( $error1 );
			$expected = [
				'type' => 'error',
				'message' => $message1,
				'params' => $error1
			];
		}
		if ( $error2 instanceof MessageSpecifier ) {
			$status->error( $error2 );
		} else {
			$message2 = $error2[0];
			array_shift( $error2 );
			$status->error( $message2, ...$error2 );
		}
		$this->assertArrayEquals( [ $expected ], $status->errors );
	}

	public function testDuplicateWarning() {
		$status = Status::newGood();
		$status->warning( 'foo', 1, 2 );
		$status->warning( 'foo', 1, 2 );
		$this->assertArrayEquals(
			[
				[
					'type' => 'warning',
					'message' => 'foo',
					'params' => [ 1, 2 ]
				]
			],
			$status->errors
		);
	}

	public function testErrorNotDowngradedToWarning() {
		$status = Status::newGood();
		$status->error( 'foo', 1, 2 );
		$status->warning( 'foo', 1, 2 );
		$this->assertArrayEquals(
			[
				[
					'type' => 'error',
					'message' => 'foo',
					'params' => [ 1, 2 ]
				]
			],
			$status->errors
		);
	}

	public function testErrorNotDowngradedToWarningMessage() {
		$status = Status::newGood();
		$message = new Message( 'foo', [ 1, 2 ] );
		$status->error( $message );
		$status->warning( $message );
		$this->assertArrayEquals(
			[
				[
					'type' => 'error',
					'message' => $message,
					'params' => []
				]
			],
			$status->errors
		);
	}

	public function testWarningUpgradedToError() {
		$status = Status::newGood();
		$status->warning( 'foo', 1, 2 );
		$status->error( 'foo', 1, 2 );
		$this->assertArrayEquals(
			[
				[
					'type' => 'error',
					'message' => 'foo',
					'params' => [ 1, 2 ]
				]
			],
			$status->errors
		);
	}

	public function testWarningUpgradedToErrorMessage() {
		$status = Status::newGood();
		$message = new Message( 'foo', [ 1, 2 ] );
		$status->warning( $message );
		$status->error( $message );
		$this->assertArrayEquals(
			[
				[
					'type' => 'error',
					'message' => $message,
					'params' => []
				]
			],
			$status->errors
		);
	}

	/**
	 * Ensure that two MessageSpecifiers that have the same key and params are considered
	 * identical even if they are different instances.
	 */
	public function testCompareMessages() {
		$status = Status::newGood();
		$message1 = new Message( 'foo', [ 1, 2 ] );
		$status->error( $message1 );
		$message2 = new Message( 'foo', [ 1, 2 ] );
		$status->error( $message2 );
		$this->assertCount( 1, $status->errors );
	}

	public function testDuplicateMerge() {
		$status1 = Status::newGood();
		$status1->error( 'cat', 1, 2 );
		$status1->warning( 'dog', 3, 4 );
		$status2 = Status::newGood();
		$status2->warning( 'cat', 1, 2 );
		$status1->error( 'dog', 3, 4 );
		$status2->warning( 'rabbit', 5, 6 );
		$status1->merge( $status2 );
		$this->assertArrayEquals(
			[
				[
					'type' => 'error',
					'message' => 'cat',
					'params' => [ 1, 2 ]
				],
				[
					'type' => 'error',
					'message' => 'dog',
					'params' => [ 3, 4 ]
				],
				[
					'type' => 'warning',
					'message' => 'rabbit',
					'params' => [ 5, 6 ]
				]
			],
			$status1->errors
		);
	}

	public function testNotDuplicateIfKeyDiffers() {
		$status = Status::newGood();
		$status->error( 'foo', 1, 2 );
		$status->error( 'bar', 1, 2 );
		$this->assertArrayEquals(
			[
				[
					'type' => 'error',
					'message' => 'foo',
					'params' => [ 1, 2 ]
				],
				[
					'type' => 'error',
					'message' => 'bar',
					'params' => [ 1, 2 ]
				]
			],
			$status->errors
		);
	}

	public function testNotDuplicateIfParamsDiffer() {
		$status = Status::newGood();
		$status->error( 'foo', 1, 2 );
		$status->error( 'foo', 3, 4 );
		$this->assertArrayEquals( [
			[
				'type' => 'error',
				'message' => 'foo',
				'params' => [ 1, 2 ]
			],
			[
				'type' => 'error',
				'message' => 'foo',
				'params' => [ 3, 4 ]
			]
		], $status->errors );
	}

	public function testToString() {
		$loremIpsum = 'Lorem ipsum dolor sit amet, consectetur adipisici elit, sed eiusmod tempor' .
			' incidunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud ' .
			'exercitation ullamco laboris nisi ut aliquid ex ea commodi consequat. Quis aute iure ' .
			'reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.';
		$abc = [
			'x' => [ 'a', 'b', 'c' ],
			'z' => 'ABCDEFGHIJKLMNOPQRSTUVWXYZ ABCDEFGHIJKLMNOPQRSTUVWXYZ ' .
				'ABCDEFGHIJKLMNOPQRSTUVWXYZ ABCDEFGHIJKLMNOPQRSTUVWXYZ '
		];

		// This is a debug method, we don't care about the exact output. But it shouldn't cause
		// an error as it's called in various logging code.
		$this->expectNotToPerformAssertions();
		(string)Status::newGood();
		(string)Status::newGood( new MessageValue( 'foo' ) );
		(string)Status::newFatal( 'foo' );
		(string)Status::newFatal( 'foo', $loremIpsum, $abc );
		(string)Status::newFatal( wfMessage( 'foo' ) );
		(string)( Status::newFatal( 'foo' )->fatal( 'bar' ) );

		$status = Status::newGood();
		$status->warning( 'foo', $loremIpsum );
		$status->error( 'bar', $abc );
		(string)$status;
	}
}
PK       ! J=  =    Status/StatusFormatterTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Language\RawMessage;
use MediaWiki\Message\Message;
use MediaWiki\Status\StatusFormatter;
use MediaWiki\User\User;
use Psr\Log\Test\TestLogger;
use Wikimedia\Message\MessageValue;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Status\StatusFormatter
 */
class StatusFormatterTest extends MediaWikiLangTestCase {

	private ?TestLogger $logger;

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

		$this->logger = new TestLogger();
	}

	protected function tearDown(): void {
		parent::tearDown();
		$this->logger = null;
	}

	private function getFormatter( $lang = 'en' ) {
		$localizer = new class() implements MessageLocalizer {
			public $lang;

			public function msg( $key, ...$params ) {
				return wfMessage( $key, ...$params )->inLanguage( $this->lang );
			}
		};

		$cache = $this->createNoOpMock( MessageCache::class, [ 'parse' ] );
		$cache->method( 'parse' )->willReturnCallback(
			static function ( $text ) {
				$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5 );
				return "<p>" . trim( $text ) . "\n</p>";
			}
		);

		$localizer->lang = $lang;

		return new StatusFormatter( $localizer, $cache, $this->logger );
	}

	/**
	 * @dataProvider provideCleanParams
	 */
	public function testCleanParams( $cleanCallback, $params, $expected, $unexpected ) {
		$status = new StatusValue();
		$status->warning( 'ok', ...$params );

		$formatter = $this->getFormatter( 'qqx' );
		$options = [ 'cleanCallback' => $cleanCallback ];

		$wikitext = $formatter->getWikiText( $status, $options );
		$this->assertStringContainsString( $expected, $wikitext );
		$this->assertStringNotContainsString( $unexpected, $wikitext );

		$html = $formatter->getHTML( $status, $options );
		$this->assertStringContainsString( $expected, $html );
		$this->assertStringNotContainsString( $unexpected, $html );
	}

	public static function provideCleanParams() {
		$cleanCallback = static function ( $value ) {
			return 'xxx';
		};

		return [
			[ false, [ 'secret' ], 'secret', 'xxx' ],
			[ $cleanCallback, [ 'secret' ], 'xxx', 'secret' ],
		];
	}

	/**
	 * @dataProvider provideGetWikiTextAndHtml
	 */
	public function testGetWikiText(
		StatusValue $status, $wikitext, $wrappedWikitext, $html, $wrappedHtml
	) {
		$formatter = $this->getFormatter();
		$this->assertEquals( $wikitext, $formatter->getWikiText( $status ) );

		$this->assertEquals(
			$wrappedWikitext,
			$formatter->getWikiText(
				$status,
				[
					'shortContext' => 'wrap-short',
					'longContext' => 'wrap-long',
					'lang' => 'qqx',
				]
			)
		);
	}

	/**
	 * @dataProvider provideGetWikiTextAndHtml
	 */
	public function testGetHtml(
		StatusValue $status,
		$wikitext,
		$wrappedWikitext,
		$html,
		$wrappedHtml,
		?string $expectedWarning = null
	) {
		$formatter = $this->getFormatter();
		$this->assertEquals( $html, $formatter->getHTML( $status ) );

		$this->assertEquals(
			$wrappedHtml,
			$formatter->getHTML(
				$status,
				[
					'shortContext' => 'wrap-short',
					'longContext' => 'wrap-long',
					'lang' => 'qqx',
				]
			)
		);

		if ( $expectedWarning !== null ) {
			$this->assertTrue( $this->logger->hasWarningThatContains( $expectedWarning ) );
		} else {
			$this->assertFalse( $this->logger->hasWarningRecords() );
		}
	}

	/**
	 * @return array Array of arrays with values;
	 *    0 => status object
	 *    1 => expected string (with no context)
	 */
	public static function provideGetWikiTextAndHtml() {
		$testCases = [];

		$testCases['GoodStatus'] = [
			new StatusValue(),
			"Internal error: MediaWiki\Status\StatusFormatter::getWikiText called for a good result, this is incorrect&#10;",
			"(wrap-short: (internalerror_info: MediaWiki\Status\StatusFormatter::getWikiText called for a good result, " .
				"this is incorrect&#10;))",
			"<p>Internal error: MediaWiki\Status\StatusFormatter::getWikiText called for a good result, this is incorrect\n</p>",
			"<p>(wrap-short: (internalerror_info: MediaWiki\Status\StatusFormatter::getWikiText called for a good result, " .
				"this is incorrect\n))\n</p>",
			'MediaWiki\Status\StatusFormatter::getWikiText called for a good result, this is incorrect'
		];

		$status = new StatusValue();
		$status->setOK( false );
		$testCases['GoodButNoError'] = [
			$status,
			"Internal error: MediaWiki\Status\StatusFormatter::getWikiText: Invalid result object: no error text but not OK&#10;",
			"(wrap-short: (internalerror_info: MediaWiki\Status\StatusFormatter::getWikiText: Invalid result object: " .
				"no error text but not OK&#10;))",
			"<p>Internal error: MediaWiki\Status\StatusFormatter::getWikiText: Invalid result object: no error text but not OK\n</p>",
			"<p>(wrap-short: (internalerror_info: MediaWiki\Status\StatusFormatter::getWikiText: Invalid result object: " .
				"no error text but not OK\n))\n</p>",
			'MediaWiki\Status\StatusFormatter::getWikiText: Invalid result object: no error text but not OK'
		];

		$status = new StatusValue();
		$status->warning( 'fooBar!' );
		$testCases['1StringWarning'] = [
			$status,
			"⧼fooBar!⧽",
			"(wrap-short: (fooBar!))",
			"<p>⧼fooBar!⧽\n</p>",
			"<p>(wrap-short: (fooBar!))\n</p>",
		];

		$status = new StatusValue();
		$status->warning( 'fooBar!' );
		$status->warning( 'fooBar2!' );
		$testCases['2StringWarnings'] = [
			$status,
			"<ul>\n<li>\n⧼fooBar!⧽\n</li>\n<li>\n⧼fooBar2!⧽\n</li>\n</ul>\n",
			"(wrap-long: <ul>\n<li>\n(fooBar!)\n</li>\n<li>\n(fooBar2!)\n</li>\n</ul>\n)",
			"<p><ul>\n<li>\n⧼fooBar!⧽\n</li>\n<li>\n⧼fooBar2!⧽\n</li>\n</ul>\n</p>",
			"<p>(wrap-long: <ul>\n<li>\n(fooBar!)\n</li>\n<li>\n(fooBar2!)\n</li>\n</ul>\n)\n</p>",
		];

		$status = new StatusValue();
		$status->warning( new Message( 'fooBar!', [ 'foo', 'bar' ] ) );
		$testCases['1MessageWarning'] = [
			$status,
			"⧼fooBar!⧽",
			"(wrap-short: (fooBar!: foo, bar))",
			"<p>⧼fooBar!⧽\n</p>",
			"<p>(wrap-short: (fooBar!: foo, bar))\n</p>",
		];

		$status = new StatusValue();
		$status->warning( new Message( 'fooBar!', [ 'foo', 'bar' ] ) );
		$status->warning( new Message( 'fooBar2!' ) );
		$testCases['2MessageWarnings'] = [
			$status,
			"<ul>\n<li>\n⧼fooBar!⧽\n</li>\n<li>\n⧼fooBar2!⧽\n</li>\n</ul>\n",
			"(wrap-long: <ul>\n<li>\n(fooBar!: foo, bar)\n</li>\n<li>\n(fooBar2!)\n</li>\n</ul>\n)",
			"<p><ul>\n<li>\n⧼fooBar!⧽\n</li>\n<li>\n⧼fooBar2!⧽\n</li>\n</ul>\n</p>",
			"<p>(wrap-long: <ul>\n<li>\n(fooBar!: foo, bar)\n</li>\n<li>\n(fooBar2!)\n</li>\n</ul>\n)\n</p>",
		];

		return $testCases;
	}

	private static function sanitizedMessageParams( Message $message ) {
		return array_map( static function ( $p ) {
			return $p instanceof Message
				? [
					'key' => $p->getKey(),
					'params' => self::sanitizedMessageParams( $p ),
					'lang' => $p->getLanguage()->getCode(),
				]
				: $p;
		}, $message instanceof RawMessage ? $message->getParamsOfRawMessage() : $message->getParams() );
	}

	private static function sanitizedMessageKey( Message $message ) {
		return $message instanceof RawMessage ? $message->getTextOfRawMessage() : $message->getKey();
	}

	/**
	 * @dataProvider provideGetMessage
	 */
	public function testGetMessage(
		StatusValue $status,
		$expectedParams,
		$expectedKey,
		$expectedWrapper,
		?string $expectedWarning = null
	) {
		$formatter = $this->getFormatter();
		$message = $formatter->getMessage( $status, [ 'lang' => 'qqx' ] );
		$this->assertInstanceOf( Message::class, $message );
		$this->assertEquals( $expectedParams, self::sanitizedMessageParams( $message ),
			'Message::getParams' );
		$this->assertEquals( $expectedKey, self::sanitizedMessageKey( $message ), 'Message::getKey' );

		$message = $formatter->getMessage(
			$status,
			[
				'shortContext' => 'wrapper-short',
				'longContext' => 'wrapper-long',
			]
		);
		$this->assertInstanceOf( Message::class, $message );
		$this->assertEquals( $expectedWrapper, $message->getKey(), 'Message::getKey with wrappers' );
		$this->assertCount( 1, $message->getParams(), 'Message::getParams with wrappers' );

		$message = $formatter->getMessage( $status, [ 'shortContext' => 'wrapper' ] );
		$this->assertInstanceOf( Message::class, $message );
		$this->assertEquals( 'wrapper', $message->getKey(), 'Message::getKey with wrappers' );
		$this->assertCount( 1, $message->getParams(), 'Message::getParams with wrappers' );

		$message = $formatter->getMessage( $status, [ 'longContext' => 'wrapper' ] );
		$this->assertInstanceOf( Message::class, $message );
		$this->assertEquals( 'wrapper', $message->getKey(), 'Message::getKey with wrappers' );
		$this->assertCount( 1, $message->getParams(), 'Message::getParams with wrappers' );

		if ( $expectedWarning !== null ) {
			$this->assertTrue( $this->logger->hasWarningThatContains( $expectedWarning ) );
		} else {
			$this->assertFalse( $this->logger->hasWarningRecords() );
		}
	}

	/**
	 * @return array Array of arrays with values;
	 *    0 => status object
	 *    1 => expected Message parameters (with no context)
	 *    2 => expected Message key
	 */
	public static function provideGetMessage() {
		$testCases = [];

		$testCases['GoodStatus'] = [
			new StatusValue(),
			[ "MediaWiki\Status\StatusFormatter::getMessage called for a good result, this is incorrect&#10;" ],
			'internalerror_info',
			'wrapper-short',
			'MediaWiki\Status\StatusFormatter::getMessage called for a good result, this is incorrect'
		];

		$status = new StatusValue();
		$status->setOK( false );
		$testCases['GoodButNoError'] = [
			$status,
			[ "MediaWiki\Status\StatusFormatter::getMessage: Invalid result object: no error text but not OK&#10;" ],
			'internalerror_info',
			'wrapper-short',
			'MediaWiki\Status\StatusFormatter::getMessage: Invalid result object: no error text but not OK'
		];

		$status = new StatusValue();
		$status->warning( 'fooBar!' );
		$testCases['1StringWarning'] = [
			$status,
			[],
			'fooBar!',
			'wrapper-short'
		];

		$status = new StatusValue();
		$status->warning( 'fooBar!' );
		$status->warning( 'fooBar2!' );
		$testCases[ '2StringWarnings' ] = [
			$status,
			[
				[ 'key' => 'fooBar!', 'params' => [], 'lang' => 'qqx' ],
				[ 'key' => 'fooBar2!', 'params' => [], 'lang' => 'qqx' ]
			],
			"* \$1\n* \$2",
			'wrapper-long'
		];

		$status = new StatusValue();
		$status->warning( new Message( 'fooBar!', [ 'foo', 'bar' ] ) );
		$testCases['1MessageWarning'] = [
			$status,
			[ 'foo', 'bar' ],
			'fooBar!',
			'wrapper-short'
		];

		$status = new StatusValue();
		$status->warning( new MessageValue( 'fooBar!', [ 'foo', 'bar' ] ) );
		$status->warning( new MessageValue( 'fooBar2!' ) );
		$testCases['2MessageWarnings'] = [
			$status,
			[
				[ 'key' => 'fooBar!', 'params' => [ 'foo', 'bar' ], 'lang' => 'qqx' ],
				[ 'key' => 'fooBar2!', 'params' => [], 'lang' => 'qqx' ]
			],
			"* \$1\n* \$2",
			'wrapper-long'
		];

		return $testCases;
	}

	/**
	 * @dataProvider provideGetPsr3MessageAndContext
	 */
	public function testGetPsr3MessageAndContext(
		array $errors,
		string $expectedMessage,
		array $expectedContext
	) {
		// set up a rawmessage_2 message, which is just like rawmessage but doesn't trigger
		// the special-casing in StatusFormatter::getPsr3MessageAndContext
		$this->setTemporaryHook( 'MessageCacheFetchOverrides', static function ( &$overrides ) {
			$overrides['rawmessage_2'] = 'rawmessage';
		}, false );

		$status = new StatusValue();
		foreach ( $errors as $error ) {
			$status->error( ...$error );
		}

		$formatter = $this->getFormatter();

		[ $actualMessage, $actualContext ] = $formatter->getPsr3MessageAndContext( $status );
		$this->assertSame( $expectedMessage, $actualMessage );
		$this->assertSame( $expectedContext, $actualContext );
	}

	public static function provideGetPsr3MessageAndContext() {
		return [
			// parameters to StatusValue::error() calls as array of arrays; expected message; expected context
			'no errors' => [
				[],
				"Internal error: MediaWiki\Status\StatusFormatter::getWikiText called for a good result, this is incorrect&#10;",
				[],
			],
			// make sure that the rawmessage_2 hack works as the following tests rely on it
			'rawmessage_2' => [
				[ [ 'rawmessage_2', 'foo' ] ],
				'{parameter1}',
				[ 'parameter1' => 'foo' ],
			],
			'two errors' => [
				[ [ 'rawmessage_2', 'foo' ], [ 'rawmessage_2', 'bar' ] ],
				"<ul>\n<li>\nfoo\n</li>\n<li>\nbar\n</li>\n</ul>\n",
				[],
			],
			'unknown subclass' => [
				// phpcs:ignore Squiz.WhiteSpace.ScopeClosingBrace.ContentBefore
				[ [ new class( 'rawmessage_2', [ 'foo' ] ) extends Message {} ] ],
				'foo',
				[],
			],
			'non-scalar parameter' => [
				[ [ new Message( 'rawmessage_2', [ new Message( 'rawmessage_2', [ 'foo' ] ) ] ) ] ],
				'foo',
				[],
			],
			'one parameter' => [
				[ [ 'apiwarn-invalidtitle', 'foo' ] ],
				'"{parameter1}" is not a valid title.',
				[ 'parameter1' => 'foo' ],
			],
			'multiple parameters' => [
				[ [ 'api-exception-trace', 'foo', 'bar', 'baz', 'boom' ] ],
				"{parameter1} at {parameter2}({parameter3})\n{parameter4}",
				[ 'parameter1' => 'foo', 'parameter2' => 'bar', 'parameter3' => 'baz', 'parameter4' => 'boom' ],
			],
			'formatted parameter' => [
				[ [ 'apiwarn-invalidtitle', Message::numParam( 1000000 ) ] ],
				'"{parameter1}" is not a valid title.',
				[ 'parameter1' => 1000000 ],
			],
			'rawmessage' => [
				[ [ 'rawmessage', 'foo' ] ],
				'foo',
				[],
			],
			'RawMessage' => [
				[ [ new RawMessage( 'foo $1 baz', [ 'bar' ] ) ] ],
				'foo {parameter1} baz',
				[ 'parameter1' => 'bar' ],
			],
		];
	}

	public function testGetErrorMessage() {
		$formatter = $this->getFormatter();
		/** @var StatusFormatter $formatter */
		$formatter = TestingAccessWrapper::newFromObject( $formatter );
		$key = 'foo';
		$params = [ 'bar' ];

		$message = $formatter->getErrorMessage( [ $key, ...$params ] );
		$this->assertInstanceOf( Message::class, $message );
		$this->assertEquals( $key, $message->getKey() );
		$this->assertEquals( $params, $message->getParams() );
	}

	public function testGetErrorMessageComplexParam() {
		$formatter = $this->getFormatter();
		/** @var StatusFormatter $formatter */
		$formatter = TestingAccessWrapper::newFromObject( $formatter );
		$key = 'foo';
		$params = [ 'bar', Message::numParam( 5 ) ];

		$message = $formatter->getErrorMessage( [ $key, ...$params ] );
		$this->assertInstanceOf( Message::class, $message );
		$this->assertEquals( $key, $message->getKey() );
		$this->assertEquals( $params, $message->getParams() );
	}

	public function testGetErrorMessageArray() {
		$formatter = $this->getFormatter();
		$formatter = TestingAccessWrapper::newFromObject( $formatter );
		$key = 'foo';
		$params = [ 'bar' ];

		/** @var Message[] $messageArray */
		$messageArray = $formatter->getErrorMessageArray(
			[
				[ $key, ...$params ],
				[ $key, ...$params ],
			]
		);

		$this->assertIsArray( $messageArray );
		$this->assertCount( 2, $messageArray );
		foreach ( $messageArray as $message ) {
			$this->assertInstanceOf( Message::class, $message );
			$this->assertEquals( $key, $message->getKey() );
			$this->assertEquals( $params, $message->getParams() );
		}
	}

	public function testUserLanguageNotLoaded() {
		// Confirm that the user language is not loaded from the database when
		// formatting an error in a specific language
		$this->getServiceContainer()->disableService( 'UserOptionsLookup' );
		$context = RequestContext::getMain();
		$user = new User;
		$user->setName( 'Test' );
		$context->setUser( $user );
		$this->getServiceContainer()
			->getFormatterFactory()
			->getStatusFormatter( RequestContext::getMain() )
			->getWikiText(
				StatusValue::newFatal( 'apierror-badquery' ),
				[ 'lang' => 'en' ]
			);
		$this->assertTrue( true );
	}
}
PK       ! QS  S  .  recentchanges/CategoryMembershipChangeTest.phpnu Iw        <?php

use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;

/**
 * @covers \CategoryMembershipChange
 *
 * @group Database
 *
 * @author Addshore
 */
class CategoryMembershipChangeTest extends MediaWikiLangTestCase {

	/**
	 * @var array
	 */
	private static $lastNotifyArgs;

	/**
	 * @var int
	 */
	private static $notifyCallCounter = 0;

	/**
	 * @var RecentChange
	 */
	private static $mockRecentChange;

	/**
	 * @var RevisionRecord
	 */
	private static $pageRev = null;

	/**
	 * @var UserIdentity
	 */
	private static $revUser = null;

	/**
	 * @var string
	 */
	private static $pageName = 'CategoryMembershipChangeTestPage';

	public static function newForCategorizationCallback( ...$args ) {
		self::$lastNotifyArgs = $args;
		self::$notifyCallCounter += 1;
		return self::$mockRecentChange;
	}

	protected function setUp(): void {
		parent::setUp();
		self::$notifyCallCounter = 0;
		self::$mockRecentChange = $this->createMock( RecentChange::class );

		$this->setContentLang( 'qqx' );
	}

	public function addDBDataOnce() {
		$info = $this->insertPage( self::$pageName );
		$title = $info['title'];

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		self::$pageRev = $page->getRevisionRecord();
		self::$revUser = self::$pageRev->getUser( RevisionRecord::RAW );
	}

	private function newChange( ?RevisionRecord $revision = null ) {
		$title = Title::makeTitle( NS_MAIN, self::$pageName );
		$blcFactory = $this->getServiceContainer()->getBacklinkCacheFactory();
		$change = new CategoryMembershipChange(
			$title, $blcFactory->getBacklinkCache( $title ), $revision
		);
		$change->overrideNewForCategorizationCallback(
			'CategoryMembershipChangeTest::newForCategorizationCallback'
		);

		return $change;
	}

	public function testChangeAddedNoRev() {
		$change = $this->newChange();
		$change->triggerCategoryAddedNotification( Title::makeTitle( NS_CATEGORY, 'CategoryName' ) );

		$this->assertSame( 1, self::$notifyCallCounter );

		$this->assertSame( 14, strlen( self::$lastNotifyArgs[0] ) );
		$this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
		$this->assertEquals( '(autochange-username)', self::$lastNotifyArgs[2]->getName() );
		$this->assertEquals( '(recentchanges-page-added-to-category: ' . self::$pageName . ')',
			self::$lastNotifyArgs[3] );
		$this->assertEquals( self::$pageName, self::$lastNotifyArgs[4]->getPrefixedText() );
		$this->assertSame( 0, self::$lastNotifyArgs[5] );
		$this->assertSame( 0, self::$lastNotifyArgs[6] );
		$this->assertNull( self::$lastNotifyArgs[7] );
		$this->assertSame( 1, self::$lastNotifyArgs[8] );
		$this->assertSame( '', self::$lastNotifyArgs[9] );
		$this->assertSame( 0, self::$lastNotifyArgs[10] );
	}

	public function testChangeRemovedNoRev() {
		$change = $this->newChange();
		$change->triggerCategoryRemovedNotification( Title::makeTitle( NS_CATEGORY, 'CategoryName' ) );

		$this->assertSame( 1, self::$notifyCallCounter );

		$this->assertSame( 14, strlen( self::$lastNotifyArgs[0] ) );
		$this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
		$this->assertEquals( '(autochange-username)', self::$lastNotifyArgs[2]->getName() );
		$this->assertEquals( '(recentchanges-page-removed-from-category: ' . self::$pageName . ')',
			self::$lastNotifyArgs[3] );
		$this->assertEquals( self::$pageName, self::$lastNotifyArgs[4]->getPrefixedText() );
		$this->assertSame( 0, self::$lastNotifyArgs[5] );
		$this->assertSame( 0, self::$lastNotifyArgs[6] );
		$this->assertNull( self::$lastNotifyArgs[7] );
		$this->assertSame( 1, self::$lastNotifyArgs[8] );
		$this->assertSame( '', self::$lastNotifyArgs[9] );
		$this->assertSame( 0, self::$lastNotifyArgs[10] );
	}

	public function testChangeAddedWithRev() {
		$revision = $this->getServiceContainer()
			->getRevisionLookup()
			->getRevisionByTitle( Title::makeTitle( NS_MAIN, self::$pageName ) );
		$change = $this->newChange( $revision );
		$change->triggerCategoryAddedNotification( Title::makeTitle( NS_CATEGORY, 'CategoryName' ) );

		$this->assertSame( 1, self::$notifyCallCounter );

		$this->assertSame( 14, strlen( self::$lastNotifyArgs[0] ) );
		$this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
		$this->assertEquals( self::$revUser->getName(), self::$lastNotifyArgs[2]->getName() );
		$this->assertEquals( '(recentchanges-page-added-to-category: ' . self::$pageName . ')',
			self::$lastNotifyArgs[3] );
		$this->assertEquals( self::$pageName, self::$lastNotifyArgs[4]->getPrefixedText() );
		$this->assertEquals( self::$pageRev->getParentId(), self::$lastNotifyArgs[5] );
		$this->assertEquals( $revision->getId(), self::$lastNotifyArgs[6] );
		$this->assertNull( self::$lastNotifyArgs[7] );
		$this->assertSame( 0, self::$lastNotifyArgs[8] );
		$this->assertEquals( '127.0.0.1', self::$lastNotifyArgs[9] );
		$this->assertSame( 0, self::$lastNotifyArgs[10] );
	}

	public function testChangeRemovedWithRev() {
		$revision = $this->getServiceContainer()
			->getRevisionLookup()
			->getRevisionByTitle( Title::makeTitle( NS_MAIN, self::$pageName ) );
		$change = $this->newChange( $revision );
		$change->triggerCategoryRemovedNotification( Title::makeTitle( NS_CATEGORY, 'CategoryName' ) );

		$this->assertSame( 1, self::$notifyCallCounter );

		$this->assertSame( 14, strlen( self::$lastNotifyArgs[0] ) );
		$this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
		$this->assertEquals( self::$revUser->getName(), self::$lastNotifyArgs[2]->getName() );
		$this->assertEquals( '(recentchanges-page-removed-from-category: ' . self::$pageName . ')',
			self::$lastNotifyArgs[3] );
		$this->assertEquals( self::$pageName, self::$lastNotifyArgs[4]->getPrefixedText() );
		$this->assertEquals( self::$pageRev->getParentId(), self::$lastNotifyArgs[5] );
		$this->assertEquals( $revision->getId(), self::$lastNotifyArgs[6] );
		$this->assertNull( self::$lastNotifyArgs[7] );
		$this->assertSame( 0, self::$lastNotifyArgs[8] );
		$this->assertEquals( '127.0.0.1', self::$lastNotifyArgs[9] );
		$this->assertSame( 0, self::$lastNotifyArgs[10] );
	}

}
PK       ! '$    )  recentchanges/TestRecentChangesHelper.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;
use MediaWiki\User\User;

/**
 * Helper for generating test recent changes entries.
 *
 * @author Katie Filbert < aude.wiki@gmail.com >
 */
class TestRecentChangesHelper {

	public function makeEditRecentChange( User $user, $titleText, $curid, $thisid, $lastid,
		$timestamp, $counter, $watchingUsers
	) {
		$attribs = array_merge(
			$this->getDefaultAttributes( $titleText, $timestamp ),
			[
				'rc_user' => $user->getId(),
				'rc_user_text' => $user->getName(),
				'rc_this_oldid' => $thisid,
				'rc_last_oldid' => $lastid,
				'rc_cur_id' => $curid
			]
		);

		return $this->makeRecentChange( $attribs, $counter, $watchingUsers );
	}

	public function makeLogRecentChange(
		$logType, $logAction, User $user, $titleText, $timestamp, $counter, $watchingUsers
	) {
		$attribs = array_merge(
			$this->getDefaultAttributes( $titleText, $timestamp ),
			[
				'rc_cur_id' => 0,
				'rc_user' => $user->getId(),
				'rc_user_text' => $user->getName(),
				'rc_this_oldid' => 0,
				'rc_last_oldid' => 0,
				'rc_old_len' => null,
				'rc_new_len' => null,
				'rc_type' => 3,
				'rc_logid' => 25,
				'rc_log_type' => $logType,
				'rc_log_action' => $logAction,
				'rc_source' => 'mw.log'
			]
		);

		return $this->makeRecentChange( $attribs, $counter, $watchingUsers );
	}

	public function makeDeletedEditRecentChange( User $user, $titleText, $timestamp, $curid,
		$thisid, $lastid, $counter, $watchingUsers
	) {
		$attribs = array_merge(
			$this->getDefaultAttributes( $titleText, $timestamp ),
			[
				'rc_user' => $user->getId(),
				'rc_user_text' => $user->getName(),
				'rc_deleted' => 5,
				'rc_cur_id' => $curid,
				'rc_this_oldid' => $thisid,
				'rc_last_oldid' => $lastid
			]
		);

		return $this->makeRecentChange( $attribs, $counter, $watchingUsers );
	}

	public function makeNewBotEditRecentChange( User $user, $titleText, $curid, $thisid, $lastid,
		$timestamp, $counter, $watchingUsers
	) {
		$attribs = array_merge(
			$this->getDefaultAttributes( $titleText, $timestamp ),
			[
				'rc_user' => $user->getId(),
				'rc_user_text' => $user->getName(),
				'rc_this_oldid' => $thisid,
				'rc_last_oldid' => $lastid,
				'rc_cur_id' => $curid,
				'rc_type' => 1,
				'rc_bot' => 1,
				'rc_source' => 'mw.new'
			]
		);

		return $this->makeRecentChange( $attribs, $counter, $watchingUsers );
	}

	private function makeRecentChange( $attribs, $counter, $watchingUsers ) {
		$change = new RecentChange();
		$change->setAttribs( $attribs );
		$change->counter = $counter;
		$change->numberofWatchingusers = $watchingUsers;

		return $change;
	}

	public function getCacheEntry( $recentChange ) {
		$rcCacheFactory = new RCCacheEntryFactory(
			new RequestContext(),
			[ 'diff' => 'diff', 'cur' => 'cur', 'last' => 'last' ],
			MediaWikiServices::getInstance()->getLinkRenderer()
		);
		return $rcCacheFactory->newFromRecentChange( $recentChange, false );
	}

	public function makeCategorizationRecentChange(
		User $user, $titleText, $curid, $thisid, $lastid, $timestamp
	) {
		$attribs = array_merge(
			$this->getDefaultAttributes( $titleText, $timestamp ),
			[
				'rc_type' => RC_CATEGORIZE,
				'rc_user' => $user->getId(),
				'rc_user_text' => $user->getName(),
				'rc_this_oldid' => $thisid,
				'rc_last_oldid' => $lastid,
				'rc_cur_id' => $curid,
				'rc_comment' => '[[:Testpage]] added to category',
				'rc_comment_text' => '[[:Testpage]] added to category',
				'rc_comment_data' => null,
				'rc_old_len' => 0,
				'rc_new_len' => 0,
			]
		);

		return $this->makeRecentChange( $attribs, 0, 0 );
	}

	private function getDefaultAttributes( $titleText, $timestamp ) {
		return [
			'rc_id' => 545,
			'rc_user' => 0,
			'rc_user_text' => '127.0.0.1',
			'rc_ip' => '127.0.0.1',
			'rc_title' => $titleText,
			'rc_namespace' => 0,
			'rc_timestamp' => $timestamp,
			'rc_old_len' => 212,
			'rc_new_len' => 188,
			'rc_comment' => '',
			'rc_comment_text' => '',
			'rc_comment_data' => null,
			'rc_minor' => 0,
			'rc_bot' => 0,
			'rc_type' => 0,
			'rc_patrolled' => 1,
			'rc_deleted' => 0,
			'rc_logid' => 0,
			'rc_log_type' => null,
			'rc_log_action' => '',
			'rc_params' => '',
			'rc_source' => 'mw.edit'
		];
	}

	public function getTestContext( User $user ) {
		$context = new RequestContext();
		$context->setLanguage( 'en' );

		$context->setUser( $user );

		$title = Title::makeTitle( NS_SPECIAL, 'RecentChanges' );
		$context->setTitle( $title );

		return $context;
	}
}
PK       ! =    )  recentchanges/RCCacheEntryFactoryTest.phpnu Iw        <?php

use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Title\Title;

/**
 * @covers \RCCacheEntryFactory
 * @group Database
 * @author Katie Filbert <aude.wiki@gmail.com>
 */
class RCCacheEntryFactoryTest extends MediaWikiLangTestCase {

	/**
	 * @var TestRecentChangesHelper
	 */
	private $testRecentChangesHelper;

	/**
	 * @var LinkRenderer
	 */
	private $linkRenderer;

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

		$this->linkRenderer = $this->getServiceContainer()->getLinkRenderer();
		$this->testRecentChangesHelper = new TestRecentChangesHelper();
	}

	public function testNewFromRecentChange() {
		$user = $this->getMutableTestUser()->getUser();
		$recentChange = $this->testRecentChangesHelper->makeEditRecentChange(
			$user,
			'Xyz',
			5, // curid
			191, // thisid
			190, // lastid
			'20131103212153',
			0, // counter
			0 // number of watching users
		);
		$cacheEntryFactory = new RCCacheEntryFactory(
			$this->getContext(),
			$this->getMessages(),
			$this->linkRenderer
		);
		$cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, false );

		$this->assertInstanceOf( RCCacheEntry::class, $cacheEntry );

		$this->assertFalse( $cacheEntry->watched, 'watched' );
		$this->assertEquals( '21:21', $cacheEntry->timestamp, 'timestamp' );
		$this->assertSame( 0, $cacheEntry->numberofWatchingusers, 'watching users' );
		$this->assertFalse( $cacheEntry->unpatrolled, 'unpatrolled' );

		$this->assertUserLinks( $user->getName(), $cacheEntry );
		$this->assertTitleLink( 'Xyz', $cacheEntry );

		$diff = [ 'curid' => 5, 'diff' => 191, 'oldid' => 190 ];
		$cur = [ 'curid' => 5, 'diff' => 0, 'oldid' => 191 ];
		$this->assertQueryLink( 'cur', $cur, $cacheEntry->curlink );
		$this->assertQueryLink( 'prev', $diff, $cacheEntry->lastlink );
		$this->assertQueryLink( 'diff', $diff, $cacheEntry->difflink );
	}

	public function testNewForDeleteChange() {
		$user = $this->getMutableTestUser()->getUser();
		$recentChange = $this->testRecentChangesHelper->makeLogRecentChange(
			'delete',
			'delete',
			$user,
			'Abc',
			'20131103212153',
			0, // counter
			0 // number of watching users
		);
		$cacheEntryFactory = new RCCacheEntryFactory(
			$this->getContext(),
			$this->getMessages(),
			$this->linkRenderer
		);
		$cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, false );

		$this->assertInstanceOf( RCCacheEntry::class, $cacheEntry );

		$this->assertFalse( $cacheEntry->watched, 'watched' );
		$this->assertEquals( '21:21', $cacheEntry->timestamp, 'timestamp' );
		$this->assertSame( 0, $cacheEntry->numberofWatchingusers, 'watching users' );
		$this->assertFalse( $cacheEntry->unpatrolled, 'unpatrolled' );

		$this->assertDeleteLogLink( $cacheEntry );
		$this->assertUserLinks( $user->getName(), $cacheEntry );

		$this->assertEquals( 'cur', $cacheEntry->curlink, 'cur link for delete log or rev' );
		$this->assertEquals( 'diff', $cacheEntry->difflink, 'diff link for delete log or rev' );
		$this->assertEquals( 'prev', $cacheEntry->lastlink, 'pref link for delete log or rev' );
	}

	public function testNewForRevUserDeleteChange() {
		$user = $this->getMutableTestUser()->getUser();
		$recentChange = $this->testRecentChangesHelper->makeDeletedEditRecentChange(
			$user,
			'Zzz',
			'20131103212153',
			191, // thisid
			190, // lastid
			'20131103212153',
			0, // counter
			0 // number of watching users
		);
		$cacheEntryFactory = new RCCacheEntryFactory(
			$this->getContext(),
			$this->getMessages(),
			$this->linkRenderer
		);
		$cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, false );

		$this->assertInstanceOf( RCCacheEntry::class, $cacheEntry );

		$this->assertFalse( $cacheEntry->watched, 'watched' );
		$this->assertEquals( '21:21', $cacheEntry->timestamp, 'timestamp' );
		$this->assertSame( 0, $cacheEntry->numberofWatchingusers, 'watching users' );
		$this->assertFalse( $cacheEntry->unpatrolled, 'unpatrolled' );

		$this->assertRevDel( $cacheEntry );
		$this->assertTitleLink( 'Zzz', $cacheEntry );

		$this->assertEquals( 'cur', $cacheEntry->curlink, 'cur link for delete log or rev' );
		$this->assertEquals( 'diff', $cacheEntry->difflink, 'diff link for delete log or rev' );
		$this->assertEquals( 'prev', $cacheEntry->lastlink, 'pref link for delete log or rev' );
	}

	private function assertValidHTML( $actual ) {
		$this->assertNotSame( '', $actual );
		$document = new DOMDocument;

		$oldUseInternalErrors = libxml_use_internal_errors( true );

		try {
			$loaded = $document->loadHTML( $actual );
			$message = '';
			foreach ( libxml_get_errors() as $error ) {
				$message .= "\n" . $error->message;
			}

			$this->assertNotFalse( $loaded, $message ?: 'Invalid for unknown reason' );
		} finally {
			libxml_use_internal_errors( $oldUseInternalErrors );
		}
	}

	private function assertUserLinks( $user, $cacheEntry ) {
		$this->assertValidHTML( $cacheEntry->userlink );
		$this->assertMatchesRegularExpression(
			'#^<a .*class="new mw-userlink".*><bdi>' . $user . '</bdi></a>#',
			$cacheEntry->userlink,
			'verify user link'
		);

		$this->assertValidHTML( $cacheEntry->usertalklink );
		$this->assertMatchesRegularExpression(
			'#^ <span class="mw-usertoollinks mw-changeslist-links">.*<span><a .+>talk</a></span>.*</span>#',
			$cacheEntry->usertalklink,
			'verify user talk link'
		);

		$this->assertValidHTML( $cacheEntry->usertalklink );
		$this->assertMatchesRegularExpression(
			'#^ <span class="mw-usertoollinks mw-changeslist-links">.*<span><a .+>' .
				'contribs</a></span>.*</span>$#',
			$cacheEntry->usertalklink,
			'verify user tool links'
		);
	}

	private function assertDeleteLogLink( $cacheEntry ) {
		$this->assertEquals(
			'(<a href="/wiki/Special:Log/delete" title="Special:Log/delete">Deletion log</a>)',
			$cacheEntry->link,
			'verify deletion log link'
		);

		$this->assertValidHTML( $cacheEntry->link );
	}

	private function assertRevDel( $cacheEntry ) {
		$this->assertEquals(
			' <span class="history-deleted">(username removed)</span>',
			$cacheEntry->userlink,
			'verify user link for change with deleted revision and user'
		);
		$this->assertValidHTML( $cacheEntry->userlink );
	}

	private function assertTitleLink( $title, $cacheEntry ) {
		$this->assertEquals(
			'<a href="/wiki/' . $title . '" title="' . $title . '">' . $title . '</a>',
			$cacheEntry->link,
			'verify title link'
		);
		$this->assertValidHTML( $cacheEntry->link );
	}

	private function assertQueryLink( $content, $params, $link ) {
		$this->assertMatchesRegularExpression(
			"#^<a .+>$content</a>$#",
			$link,
			'verify query link element'
		);
		$this->assertValidHTML( $link );

		foreach ( $params as $key => $value ) {
			$this->assertMatchesRegularExpression( '/' . $key . '=' . $value . '/', $link, "verify $key link params" );
		}
	}

	private function getMessages() {
		return [
			'cur' => 'cur',
			'diff' => 'diff',
			'hist' => 'hist',
			'enhancedrc-history' => 'history',
			'last' => 'prev',
			'blocklink' => 'block',
			'history' => 'Page history',
			'semicolon-separator' => '; ',
			'pipe-separator' => ' | '
		];
	}

	private function getContext() {
		$user = $this->getMutableTestUser()->getUser();
		$context = $this->testRecentChangesHelper->getTestContext( $user );

		$title = Title::makeTitle( NS_SPECIAL, 'RecentChanges' );
		$context->setTitle( $title );

		return $context;
	}
}
PK       ! R    .  recentchanges/rcfeed/RCFeedIntegrationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\RCFeed\FormattedRCFeed;
use MediaWiki\RCFeed\JSONRCFeedFormatter;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;

/**
 * @group medium
 * @group Database
 * @covers \MediaWiki\RCFeed\FormattedRCFeed
 * @covers \RecentChange
 * @covers \MediaWiki\RCFeed\JSONRCFeedFormatter
 * @covers \MediaWiki\RCFeed\MachineReadableRCFeedFormatter
 * @covers \MediaWiki\RCFeed\RCFeed
 */
class RCFeedIntegrationTest extends MediaWikiIntegrationTestCase {
	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValues( [
			MainConfigNames::CanonicalServer => 'https://example.org',
			MainConfigNames::ServerName => 'example.org',
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::DBname => 'example',
			MainConfigNames::DBprefix => self::dbPrefix(),
			MainConfigNames::RCFeeds => [],
			MainConfigNames::RCEngines => [],
		] );
	}

	public function testNotify() {
		$feed = $this->getMockBuilder( FormattedRCFeed::class )
			->setConstructorArgs( [ [ 'formatter' => JSONRCFeedFormatter::class ] ] )
			->onlyMethods( [ 'send' ] )
			->getMock();

		$feed->expects( $this->once() )
			->method( 'send' )
			->willReturn( true )
			->with( $this->anything(), $this->callback( function ( $line ) {
				$this->assertJsonStringEqualsJsonString(
					json_encode( [
						'id' => null,
						'type' => 'log',
						'namespace' => 0,
						'title' => 'Example',
						'title_url' => 'https://example.org/wiki/Example',
						'comment' => '',
						'timestamp' => 1301644800,
						'user' => 'UTSysop',
						'bot' => false,
						'notify_url' => null,
						'log_id' => 0,
						'log_type' => 'move',
						'log_action' => 'move',
						'log_params' => [
							'color' => 'green',
							'nr' => 42,
							'pet' => 'cat',
						],
						'log_action_comment' => '',
						'server_url' => 'https://example.org',
						'server_name' => 'example.org',
						'server_script_path' => '/w',
						'wiki' => 'example-' . self::dbPrefix(),
					] ),
					$line
				);
				return true;
			} ) );

		$this->overrideConfigValue(
			MainConfigNames::RCFeeds,
			[
				'myfeed' => [
					'class' => $feed,
					'uri' => 'test://localhost:1234',
					'formatter' => JSONRCFeedFormatter::class,
				],
			]
		);
		$logpage = SpecialPage::getTitleFor( 'Log', 'move' );
		$user = $this->getTestSysop()->getUser();
		$rc = RecentChange::newLogEntry(
			'20110401080000',
			$logpage, // &$title
			$user, // &$user
			'', // $actionComment
			'127.0.0.1', // $ip
			'move', // $type
			'move', // $action
			Title::makeTitle( 0, 'Example' ), // $target
			'', // $logComment
			LogEntryBase::makeParamBlob( [
				'4::color' => 'green',
				'5:number:nr' => 42,
				'pet' => 'cat',
			] )
		);
		$rc->notifyRCFeeds();
	}
}
PK       ! ˣ<  <  ,  recentchanges/RecentChangesUpdateJobTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @group Database
 * @covers RecentChangesUpdateJob
 * @author Dreamy Jazz
 */
class RecentChangesUpdateJobTest extends MediaWikiIntegrationTestCase {

	private function addTestingExpiredRows() {
		// Make three testing edits, which will trigger a recentchanges insert. Two of the edits will be made
		// over wgRCMaxAge seconds ago while the other will be made a day ago
		$testPage = $this->getExistingTestPage();
		$testUser = $this->getTestUser()->getAuthority();
		// So that only our two testing edits are present, and nothing from creating the test page or test user
		$this->truncateTable( 'recentchanges' );
		// Fix wgRCMaxAge at a high value to ensure that the recentchanges entries we are creating are not purged
		// by later testing edits.
		$this->overrideConfigValue( MainConfigNames::RCMaxAge, 24 * 3600 * 1000 );
		ConvertibleTimestamp::setFakeTime( '20230405060708' );
		$this->editPage( $testPage, 'testing1234', '', NS_MAIN, $testUser );
		ConvertibleTimestamp::setFakeTime( '20230705060708' );
		$this->editPage( $testPage, 'testing12345', '', NS_MAIN, $testUser );
		ConvertibleTimestamp::setFakeTime( '20240405060708' );
		$this->editPage( $testPage, 'testing123456', '', NS_MAIN, $testUser );
		// Verify that the recentchanges table row count is as expected for the test
		$this->newSelectQueryBuilder()
			->field( 'COUNT(*)' )
			->table( 'recentchanges' )
			->assertFieldValue( 3 );
	}

	public function testNewPurgeJob() {
		$this->addTestingExpiredRows();
		// Set the time as one day beyond the last test edit
		ConvertibleTimestamp::setFakeTime( '20240406060708' );
		// Fix wgRCMaxAge for the test, in case the default value changes.
		$this->overrideConfigValue( MainConfigNames::RCMaxAge, 90 * 24 * 3600 );
		$hookRunAtLeastOnce = false;
		$this->setTemporaryHook( 'RecentChangesPurgeRows', function ( $rows ) use ( &$hookRunAtLeastOnce ) {
			// Check that the first row has the expected columns. Checking just the first row should be fine
			// as the value of $rows should come from ::fetchResultSet which returns the same columns for each
			// returned row.
			$rowAsArray = (array)$rows[0];
			// To get the expected fields, use the value of the items in the 'fields' array. The exception to this
			// is where the key is a string, when it should be used instead (as this is an alias).
			$recentChangeQueryFields = RecentChange::getQueryInfo()['fields'];
			$expectedFields = [];
			foreach ( $recentChangeQueryFields as $key => $value ) {
				if ( is_string( $key ) ) {
					$expectedFields[] = $key;
				} else {
					$expectedFields[] = $value;
				}
			}
			$this->assertArrayEquals(
				$expectedFields,
				array_keys( $rowAsArray ),
				false,
				true,
				'Columns in the provided $row are not as expected'
			);
			$hookRunAtLeastOnce = true;
		} );
		// Call the code we are testing
		$objectUnderTest = RecentChangesUpdateJob::newPurgeJob();
		$this->assertInstanceOf( RecentChangesUpdateJob::class, $objectUnderTest );
		$objectUnderTest->run();
		// Verify that only the edit made a day ago is now in the recentchanges table
		$this->newSelectQueryBuilder()
			->field( 'rc_timestamp' )
			->table( 'recentchanges' )
			->assertFieldValue( $this->getDb()->timestamp( '20240405060708' ) );
		// Verify that the lock placed to do the purge is no longer active.
		$this->assertTrue( $this->getDb()->lockIsFree(
			$this->getDb()->getDomainID() . ':recentchanges-prune', __METHOD__
		) );
		// Check that the RecentChangesPurgeRows hook was run at least once
		$this->assertTrue( $hookRunAtLeastOnce, 'RecentChangesPurgeRows hook was not run' );
	}

	/** @dataProvider provideInvalidTypes */
	public function testWhenTypeForInvalidType( $type ) {
		$this->expectException( InvalidArgumentException::class );
		$objectUnderTest = new RecentChangesUpdateJob( $this->getExistingTestPage()->getTitle(), [ 'type' => $type ] );
		$objectUnderTest->run();
	}

	public static function provideInvalidTypes() {
		return [
			'Type is null' => [ null ],
			'Type is a unrecognised string' => [ 'unknown-type' ],
		];
	}
}
PK       ! ?    $  recentchanges/OldChangesListTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Title\Title;

/**
 * @todo add tests to cover article link, timestamp, character difference,
 *       log entry, user tool links, direction marks, tags, rollback,
 *       watching users, and date header.
 *
 * @covers \OldChangesList
 * @group Database
 * @author Katie Filbert <aude.wiki@gmail.com>
 */
class OldChangesListTest extends MediaWikiLangTestCase {

	/**
	 * @var TestRecentChangesHelper
	 */
	private $testRecentChangesHelper;

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

		$this->setUserLang( 'qqx' );
		$this->testRecentChangesHelper = new TestRecentChangesHelper();
	}

	/**
	 * @dataProvider recentChangesLine_CssForLineNumberProvider
	 */
	public function testRecentChangesLine_CssForLineNumber( $expected, $linenumber, $message ) {
		$oldChangesList = $this->getOldChangesList();
		$recentChange = $this->getEditChange();

		$line = $oldChangesList->recentChangesLine( $recentChange, false, $linenumber );

		$this->assertMatchesRegularExpression( $expected, $line, $message );
	}

	public static function recentChangesLine_CssForLineNumberProvider() {
		return [
			[ '/mw-line-odd/', 1, 'odd line number' ],
			[ '/mw-line-even/', 2, 'even line number' ]
		];
	}

	public function testRecentChangesLine_NotWatchedCssClass() {
		$oldChangesList = $this->getOldChangesList();
		$recentChange = $this->getEditChange();

		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );

		$this->assertMatchesRegularExpression( '/mw-changeslist-line-not-watched/', $line );
	}

	public function testRecentChangesLine_WatchedCssClass() {
		$oldChangesList = $this->getOldChangesList();
		$recentChange = $this->getEditChange();

		$line = $oldChangesList->recentChangesLine( $recentChange, true, 1 );

		$this->assertMatchesRegularExpression( '/mw-changeslist-line-watched/', $line );
	}

	public function testRecentChangesLine_LogTitle() {
		$oldChangesList = $this->getOldChangesList();
		$recentChange = $this->getLogChange( 'delete', 'delete' );

		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );

		$this->assertMatchesRegularExpression( '/href="\/wiki\/Special:Log\/delete/', $line, 'link has href attribute' );
		$this->assertMatchesRegularExpression( '/title="Special:Log\/delete/', $line, 'link has title attribute' );
		$this->assertMatchesRegularExpression( "/dellogpage/", $line, 'link text' );
	}

	public function testRecentChangesLine_DiffHistLinks() {
		$oldChangesList = $this->getOldChangesList();
		$recentChange = $this->getEditChange();

		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );

		$this->assertMatchesRegularExpression(
			'/title=Cat&amp;curid=20131103212153&amp;diff=5&amp;oldid=191/',
			$line,
			'assert diff link'
		);

		$this->assertMatchesRegularExpression(
			'/title=Cat&amp;curid=20131103212153&amp;action=history"/',
			$line,
			'assert history link'
		);
	}

	public function testRecentChangesLine_Flags() {
		$oldChangesList = $this->getOldChangesList();
		$recentChange = $this->getNewBotEditChange();

		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );

		$this->assertStringContainsString(
			'<abbr class="newpage" title="(recentchanges-label-newpage)">(newpageletter)</abbr>',
			$line,
			'new page flag'
		);

		$this->assertStringContainsString(
			'<abbr class="botedit" title="(recentchanges-label-bot)">(boteditletter)</abbr>',
			$line,
			'bot flag'
		);
	}

	public function testRecentChangesLine_Attribs() {
		$recentChange = $this->getEditChange();
		$recentChange->mAttribs['ts_tags'] = 'vandalism,newbie';

		$this->setTemporaryHook( 'OldChangesListRecentChangesLine', static function (
			$oldChangesList, &$html, $rc, $classes, $attribs
		) {
			$html = $html . '/<div>Additional change line </div>/';
		} );

		$oldChangesList = $this->getOldChangesList();
		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );

		$this->assertStringContainsString(
			'/<div>Additional change line </div>/',
			$line
		);
		$this->assertMatchesRegularExpression(
			'/<li data-mw-revid="\d+" data-mw-ts="\d+" class="[\w\s-]*mw-tag-vandalism[\w\s-]*">/',
			$line
		);
		$this->assertMatchesRegularExpression(
			'/<li data-mw-revid="\d+" data-mw-ts="\d+" class="[\w\s-]*mw-tag-newbie[\w\s-]*">/',
			$line
		);
	}

	public function testRecentChangesLine_numberOfWatchingUsers() {
		$oldChangesList = $this->getOldChangesList();

		$recentChange = $this->getEditChange();
		$recentChange->numberofWatchingusers = 100;

		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
		$this->assertMatchesRegularExpression( "/(number-of-watching-users-for-recent-changes: 100)/", $line );
	}

	public function testRecentChangesLine_watchlistCssClass() {
		$oldChangesList = $this->getOldChangesList();
		$oldChangesList->setWatchlistDivs( true );

		$recentChange = $this->getEditChange();
		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
		$this->assertMatchesRegularExpression( "/watchlist-0-Cat/", $line );
	}

	public function testRecentChangesLine_dataAttribute() {
		$oldChangesList = $this->getOldChangesList();
		$oldChangesList->setWatchlistDivs( true );

		$recentChange = $this->getEditChange();
		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
		$this->assertMatchesRegularExpression( '/data-target-page=\"Cat\"/', $line );

		$recentChange = $this->getLogChange( 'delete', 'delete' );
		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
		$this->assertMatchesRegularExpression( '/data-target-page="Abc"/', $line );
	}

	public function testRecentChangesLine_prefix() {
		$mockContext = $this->getMockBuilder( RequestContext::class )
			->onlyMethods( [ 'getTitle' ] )
			->getMock();
		$mockContext->method( 'getTitle' )
			->willReturn( Title::makeTitle( NS_MAIN, 'Expected Context Title' ) );

		$oldChangesList = $this->getOldChangesList();
		$oldChangesList->setContext( $mockContext );
		$recentChange = $this->getEditChange();

		$oldChangesList->setChangeLinePrefixer( function ( $rc, $changesList ) {
			// Make sure RecentChange and ChangesList objects are the same
			$this->assertEquals( 'Expected Context Title', $changesList->getContext()->getTitle() );
			$this->assertEquals( 'Cat', $rc->getTitle() );
			return 'I am a prefix';
		} );
		$line = $oldChangesList->recentChangesLine( $recentChange );
		$this->assertMatchesRegularExpression( "/I am a prefix/", $line );
	}

	private function getNewBotEditChange() {
		$user = $this->getMutableTestUser()->getUser();

		$recentChange = $this->testRecentChangesHelper->makeNewBotEditRecentChange(
			$user, 'Abc', '20131103212153', 5, 191, 190, 0, 0
		);

		return $recentChange;
	}

	private function getLogChange( $logType, $logAction ) {
		$user = $this->getMutableTestUser()->getUser();

		$recentChange = $this->testRecentChangesHelper->makeLogRecentChange(
			$logType, $logAction, $user, 'Abc', '20131103212153', 0, 0
		);

		return $recentChange;
	}

	private function getEditChange() {
		$user = $this->getMutableTestUser()->getUser();
		$recentChange = $this->testRecentChangesHelper->makeEditRecentChange(
			$user, 'Cat', '20131103212153', 5, 191, 190, 0, 0
		);

		return $recentChange;
	}

	private function getOldChangesList() {
		$context = $this->getContext();
		return new OldChangesList( $context );
	}

	private function getContext() {
		$user = $this->getMutableTestUser()->getUser();
		$context = $this->testRecentChangesHelper->getTestContext( $user );
		$context->setLanguage( 'qqx' );

		return $context;
	}

}
PK       ! #OO@  @  "  recentchanges/RecentChangeTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageProps;
use MediaWiki\Page\PageReference;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\Utils\MWTimestamp;

/**
 * @group Database
 */
class RecentChangeTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;
	use MockTitleTrait;
	use TempUserTestTrait;

	/** @var PageIdentity */
	protected $title;
	/** @var PageIdentity */
	protected $target;
	/** @var UserIdentity */
	protected $user;
	private const USER_COMMENT = '<User comment about action>';

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

		$this->title = new PageIdentityValue( 17, NS_MAIN, 'SomeTitle', PageIdentity::LOCAL );
		$this->target = new PageIdentityValue( 78, NS_MAIN, 'TestTarget', PageIdentity::LOCAL );

		$user = $this->getTestUser()->getUser();
		$this->user = new UserIdentityValue( $user->getId(), $user->getName() );

		$this->overrideConfigValues( [
			MainConfigNames::CanonicalServer => 'https://example.org',
			MainConfigNames::ServerName => 'example.org',
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::UseRCPatrol => false,
			MainConfigNames::UseNPPatrol => false,
			MainConfigNames::RCFeeds => [],
			MainConfigNames::RCEngines => [],
		] );
	}

	public static function provideAttribs() {
		$attribs = [
			'rc_timestamp' => wfTimestamp( TS_MW ),
			'rc_namespace' => NS_USER,
			'rc_title' => 'Tony',
			'rc_type' => RC_EDIT,
			'rc_source' => RecentChange::SRC_EDIT,
			'rc_minor' => 0,
			'rc_cur_id' => 77,
			'rc_user' => 858173476,
			'rc_user_text' => 'Tony',
			'rc_comment' => '',
			'rc_comment_text' => '',
			'rc_comment_data' => null,
			'rc_this_oldid' => 70,
			'rc_last_oldid' => 71,
			'rc_bot' => 0,
			'rc_ip' => '',
			'rc_patrolled' => 0,
			'rc_new' => 0,
			'rc_old_len' => 80,
			'rc_new_len' => 88,
			'rc_deleted' => 0,
			'rc_logid' => 0,
			'rc_log_type' => null,
			'rc_log_action' => '',
			'rc_params' => '',
		];

		yield 'external user' => [
			[
				'rc_type' => RC_EXTERNAL,
				'rc_source' => 'foo',
				'rc_user' => 0,
				'rc_user_text' => 'm>External User',
			] + $attribs
		];

		yield 'anon user' => [
			[
				'rc_type' => RC_EXTERNAL,
				'rc_source' => 'foo',
				'rc_user' => 0,
				'rc_user_text' => '192.168.0.1',
			] + $attribs
		];

		yield 'special title' => [
			[
				'rc_namespace' => NS_SPECIAL,
				'rc_title' => 'Log',
				'rc_type' => RC_LOG,
				'rc_source' => RecentChange::SRC_LOG,
				'rc_log_type' => 'delete',
				'rc_log_action' => 'delete',
			] + $attribs
		];

		yield 'no title' => [
			[
				'rc_namespace' => NS_MAIN,
				'rc_title' => '',
				'rc_type' => RC_LOG,
				'rc_source' => RecentChange::SRC_LOG,
				'rc_log_type' => 'delete',
				'rc_log_action' => 'delete',
			] + $attribs
		];
	}

	/**
	 * @covers \RecentChange::save
	 * @covers \RecentChange::newFromId
	 * @covers \RecentChange::getTitle
	 * @covers \RecentChange::getPerformerIdentity
	 * @dataProvider provideAttribs
	 */
	public function testDatabaseRoundTrip( $attribs ) {
		$rc_user = $attribs['rc_user'] ?? 0;
		if ( !$rc_user ) {
			$this->disableAutoCreateTempUser();
		}
		$rc = new RecentChange;
		$rc->mAttribs = $attribs;
		$rc->mExtra = [
			'pageStatus' => 'changed'
		];
		$rc->save();
		$id = $rc->getAttribute( 'rc_id' );

		$rc = RecentChange::newFromId( $id );

		$actualAttribs = array_intersect_key( $rc->mAttribs, $attribs );
		$this->assertArrayEquals( $attribs, $actualAttribs, false, true );

		$user = new UserIdentityValue( $rc_user, $attribs['rc_user_text'] );
		$this->assertTrue( $user->equals( $rc->getPerformerIdentity() ) );

		if ( empty( $attribs['rc_title'] ) ) {
			$this->assertNull( $rc->getPage() );
		} else {
			$title = Title::makeTitle( $attribs['rc_namespace'], $attribs['rc_title'] );
			$this->assertTrue( $title->isSamePageAs( $rc->getTitle() ) );
			$this->assertTrue( $title->isSamePageAs( $rc->getPage() ) );
		}
	}

	/**
	 * @covers \RecentChange::newFromRow
	 * @covers \RecentChange::loadFromRow
	 * @covers \RecentChange::getAttributes
	 * @covers \RecentChange::getPerformerIdentity
	 */
	public function testNewFromRow() {
		$user = $this->getTestUser()->getUser();

		$row = (object)[
			'rc_foo' => 'AAA',
			'rc_timestamp' => '20150921134808',
			'rc_deleted' => 'bar',
			'rc_comment_text' => 'comment',
			'rc_comment_data' => null,
			'rc_user' => $user->getId(), // lookup by id
		];

		$rc = RecentChange::newFromRow( $row );

		$expected = [
			'rc_foo' => 'AAA',
			'rc_timestamp' => '20150921134808',
			'rc_deleted' => 'bar',
			'rc_comment' => 'comment',
			'rc_comment_text' => 'comment',
			'rc_comment_data' => null,
			'rc_user' => $user->getId(),
			'rc_user_text' => $user->getName()
		];
		$this->assertEquals( $expected, $rc->getAttributes() );
		$this->assertTrue( $user->equals( $rc->getPerformerIdentity() ) );

		$row = (object)[
			'rc_foo' => 'AAA',
			'rc_timestamp' => '20150921134808',
			'rc_deleted' => 'bar',
			'rc_comment' => 'comment',
			'rc_user_text' => $user->getName(), // lookup by name
		];
		$rc = @RecentChange::newFromRow( $row );

		$expected = [
			'rc_foo' => 'AAA',
			'rc_timestamp' => '20150921134808',
			'rc_deleted' => 'bar',
			'rc_comment' => 'comment',
			'rc_comment_text' => 'comment',
			'rc_comment_data' => null,
			'rc_user' => $user->getId(),
			'rc_user_text' => $user->getName()
		];
		$this->assertEquals( $expected, $rc->getAttributes() );
		$this->assertEquals( $expected, $rc->getAttributes() );
		$this->assertTrue( $user->equals( $rc->getPerformerIdentity() ) );
	}

	/**
	 * @covers \RecentChange::notifyNew
	 * @covers \RecentChange::newFromId
	 * @covers \RecentChange::getAttributes
	 * @covers \RecentChange::getPerformerIdentity
	 */
	public function testNotifyNew() {
		$now = MWTimestamp::now();
		$rc = RecentChange::notifyNew(
			$now,
			$this->title,
			false,
			$this->user,
			self::USER_COMMENT,
			false
		);

		$expected = [
			'rc_timestamp' => $now,
			'rc_deleted' => 0,
			'rc_comment_text' => self::USER_COMMENT,
			'rc_user' => $this->user->getId(),
			'rc_user_text' => $this->user->getName()
		];

		$actual = array_intersect_key( $rc->getAttributes(), $expected );

		$this->assertEquals( $expected, $actual );
		$this->assertTrue( $this->user->equals( $rc->getPerformerIdentity() ) );

		$rc = RecentChange::newFromId( $rc->getAttribute( 'rc_id' ) );

		$actual = array_intersect_key( $rc->getAttributes(), $expected );

		$this->assertEquals( $expected, $actual );
		$this->assertTrue( $this->user->equals( $rc->getPerformerIdentity() ) );
	}

	/**
	 * @covers \RecentChange::notifyNew
	 * @covers \RecentChange::newFromId
	 * @covers \RecentChange::getAttributes
	 * @covers \RecentChange::getPerformerIdentity
	 */
	public function testNotifyEdit() {
		$now = MWTimestamp::now();
		$rc = RecentChange::notifyEdit(
			$now,
			$this->title,
			false,
			$this->user,
			self::USER_COMMENT,
			0,
			$now,
			false
		);

		$expected = [
			'rc_timestamp' => $now,
			'rc_deleted' => 0,
			'rc_comment_text' => self::USER_COMMENT,
			'rc_user' => $this->user->getId(),
			'rc_user_text' => $this->user->getName()
		];

		$actual = array_intersect_key( $rc->getAttributes(), $expected );

		$this->assertEquals( $expected, $actual );
		$this->assertTrue( $this->user->equals( $rc->getPerformerIdentity() ) );

		$rc = RecentChange::newFromId( $rc->getAttribute( 'rc_id' ) );

		$actual = array_intersect_key( $rc->getAttributes(), $expected );

		$this->assertEquals( $expected, $actual );
		$this->assertTrue( $this->user->equals( $rc->getPerformerIdentity() ) );
	}

	/**
	 * @covers \RecentChange::notifyNew
	 * @covers \RecentChange::newFromId
	 * @covers \RecentChange::getAttributes
	 * @covers \RecentChange::getPerformerIdentity
	 */
	public function testNewLogEntry() {
		$now = MWTimestamp::now();
		$logPage = new PageReferenceValue( NS_SPECIAL, 'Log/test', PageReference::LOCAL );

		$rc = RecentChange::newLogEntry(
			$now,
			$logPage,
			$this->user,
			'action comment',
			'192.168.0.2',
			'test',
			'testing',
			$this->title,
			self::USER_COMMENT,
			'a|b|c',
			7,
			'',
			42,
			false,
			true
		);

		$expected = [
			'rc_timestamp' => $now,
			'rc_comment_text' => self::USER_COMMENT,
			'rc_user' => $this->user->getId(),
			'rc_user_text' => $this->user->getName(),
			'rc_title' => $this->title->getDBkey(),
			'rc_logid' => 7,
			'rc_log_type' => 'test',
			'rc_log_action' => 'testing',
			'rc_this_oldid' => 42,
			'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED,
			'rc_bot' => 1,
		];

		$actual = array_intersect_key( $rc->getAttributes(), $expected );

		$this->assertEquals( $expected, $actual );
		$this->assertTrue( $this->user->equals( $rc->getPerformerIdentity() ) );
		$this->assertTrue( $this->title->isSamePageAs( $rc->getPage() ) );
		$this->assertTrue( $this->title->isSamePageAs( $rc->getTitle() ) );
	}

	public static function provideParseParams() {
		// $expected, $raw
		yield 'extracting an array' => [
			[
				'root' => [
					'A' => 1,
					'B' => 'two'
				]
			],
			'a:1:{s:4:"root";a:2:{s:1:"A";i:1;s:1:"B";s:3:"two";}}'
		];

		yield 'null' => [ null, null ];
		yield 'false' => [ null, serialize( false ) ];
		yield 'non-array' => [ null, 'not-an-array' ];
	}

	/**
	 * @covers \RecentChange::parseParams
	 * @dataProvider provideParseParams
	 * @param array $expectedParseParams
	 * @param string|null $rawRcParams
	 */
	public function testParseParams( $expectedParseParams, $rawRcParams ) {
		$rc = new RecentChange;
		$rc->setAttribs( [ 'rc_params' => $rawRcParams ] );

		$actualParseParams = $rc->parseParams();

		$this->assertEquals( $expectedParseParams, $actualParseParams );
	}

	/**
	 * @covers \RecentChange::getNotifyUrl
	 */
	public function testGetNotifyUrlForEdit() {
		$rc = new RecentChange;
		$rc->mAttribs = [
			'rc_id' => 60,
			'rc_timestamp' => '20110401090000',
			'rc_namespace' => NS_MAIN,
			'rc_title' => 'Example',
			'rc_type' => RC_EDIT,
			'rc_cur_id' => 42,
			'rc_this_oldid' => 50,
			'rc_last_oldid' => 30,
			'rc_patrolled' => 0,
		];
		$this->assertSame(
			'https://example.org/w/index.php?diff=50&oldid=30',
			$rc->getNotifyUrl(), 'Notify url'
		);

		$this->overrideConfigValue( MainConfigNames::UseRCPatrol, true );
		$this->assertSame(
			'https://example.org/w/index.php?diff=50&oldid=30&rcid=60',
			$rc->getNotifyUrl(), 'Notify url (RC Patrol)'
		);
	}

	/**
	 * @covers \RecentChange::getNotifyUrl
	 */
	public function testGetNotifyUrlForCreate() {
		$rc = new RecentChange;
		$rc->mAttribs = [
			'rc_id' => 60,
			'rc_timestamp' => '20110401090000',
			'rc_namespace' => NS_MAIN,
			'rc_title' => 'Example',
			'rc_type' => RC_NEW,
			'rc_cur_id' => 42,
			'rc_this_oldid' => 50,
			'rc_last_oldid' => 0,
			'rc_patrolled' => 0,
		];
		$this->assertSame(
			'https://example.org/w/index.php?oldid=50',
			$rc->getNotifyUrl(), 'Notify url'
		);

		$this->overrideConfigValue( MainConfigNames::UseNPPatrol, true );
		$this->assertSame(
			'https://example.org/w/index.php?oldid=50&rcid=60',
			$rc->getNotifyUrl(), 'Notify url (NP Patrol)'
		);
	}

	/**
	 * @covers \RecentChange::getNotifyUrl
	 */
	public function testGetNotifyUrlForLog() {
		$rc = new RecentChange;
		$rc->mAttribs = [
			'rc_id' => 60,
			'rc_timestamp' => '20110401090000',
			'rc_namespace' => NS_MAIN,
			'rc_title' => 'Example',
			'rc_type' => RC_LOG,
			'rc_cur_id' => 42,
			'rc_this_oldid' => 50,
			'rc_last_oldid' => 0,
			'rc_patrolled' => 2,
			'rc_logid' => 160,
			'rc_log_type' => 'delete',
			'rc_log_action' => 'delete',
		];
		$this->assertSame( null, $rc->getNotifyUrl(), 'Notify url' );
	}

	/**
	 * @return array
	 */
	public static function provideIsInRCLifespan() {
		return [
			[ 6000, -3000, 0, true ],
			[ 3000, -6000, 0, false ],
			[ 6000, -3000, 6000, true ],
			[ 3000, -6000, 6000, true ],
		];
	}

	/**
	 * @covers \RecentChange::isInRCLifespan
	 * @dataProvider provideIsInRCLifespan
	 */
	public function testIsInRCLifespan( $maxAge, $offset, $tolerance, $expected ) {
		$this->overrideConfigValue( MainConfigNames::RCMaxAge, $maxAge );
		// Calculate this here instead of the data provider because the provider
		// is expanded early on and the full test suite may take longer than 100 minutes
		// when coverage is enabled.
		$timestamp = time() + $offset;
		$this->assertEquals( $expected, RecentChange::isInRCLifespan( $timestamp, $tolerance ) );
	}

	public static function provideRCTypes() {
		return [
			[ RC_EDIT, 'edit' ],
			[ RC_NEW, 'new' ],
			[ RC_LOG, 'log' ],
			[ RC_EXTERNAL, 'external' ],
			[ RC_CATEGORIZE, 'categorize' ],
		];
	}

	/**
	 * @dataProvider provideRCTypes
	 * @covers \RecentChange::parseFromRCType
	 */
	public function testParseFromRCType( $rcType, $type ) {
		$this->assertEquals( $type, RecentChange::parseFromRCType( $rcType ) );
	}

	/**
	 * @dataProvider provideRCTypes
	 * @covers \RecentChange::parseToRCType
	 */
	public function testParseToRCType( $rcType, $type ) {
		$this->assertEquals( $rcType, RecentChange::parseToRCType( $type ) );
	}

	public static function provideCategoryContent() {
		return [
			[ true ],
			[ false ],
		];
	}

	/**
	 * @dataProvider provideCategoryContent
	 * @covers \RecentChange::newForCategorization
	 */
	public function testHiddenCategoryChange( $isHidden ) {
		$categoryTitle = Title::makeTitle( NS_CATEGORY, 'CategoryPage' );

		$pageProps = $this->createMock( PageProps::class );
		$pageProps->expects( $this->once() )
			->method( 'getProperties' )
			->with( $categoryTitle, 'hiddencat' )
			->willReturn( $isHidden ? [ $categoryTitle->getArticleID() => '' ] : [] );

		$this->setService( 'PageProps', $pageProps );

		$rc = RecentChange::newForCategorization(
			'0',
			$categoryTitle,
			$this->user,
			self::USER_COMMENT,
			$this->title,
			$categoryTitle->getLatestRevID(),
			$categoryTitle->getLatestRevID(),
			'0',
			false
		);

		$this->assertEquals( $isHidden, $rc->getParam( 'hidden-cat' ) );
	}

	private function getDummyEditRecentChange(): RecentChange {
		return RecentChange::notifyEdit(
			MWTimestamp::now(),
			$this->title,
			false,
			$this->user,
			self::USER_COMMENT,
			0,
			MWTimestamp::now(),
			false
		);
	}

	/**
	 * @covers \RecentChange::markPatrolled
	 */
	public function testMarkPatrolledPermissions() {
		$rc = $this->getDummyEditRecentChange();
		$performer = $this->mockRegisteredAuthority( static function (
			string $permission,
			PageIdentity $page,
			PermissionStatus $status
		) {
			if ( $permission === 'patrol' ) {
				$status->fatal( 'missing-patrol' );
				return false;
			}
			return true;
		} );
		$status = $rc->markPatrolled(
			$performer
		);
		$this->assertStatusError( 'missing-patrol', $status );
	}

	/**
	 * @covers \RecentChange::markPatrolled
	 */
	public function testMarkPatrolledPermissions_Hook() {
		$rc = $this->getDummyEditRecentChange();
		$this->setTemporaryHook( 'MarkPatrolled', static function () {
			return false;
		} );
		$status = $rc->markPatrolled( $this->mockRegisteredUltimateAuthority() );
		$this->assertStatusError( 'hookaborted', $status );
	}

	/**
	 * @covers \RecentChange::markPatrolled
	 */
	public function testMarkPatrolledPermissions_Self() {
		$rc = $this->getDummyEditRecentChange();
		$status = $rc->markPatrolled(
			$this->mockUserAuthorityWithoutPermissions( $this->user, [ 'autopatrol' ] )
		);
		$this->assertStatusError( 'markedaspatrollederror-noautopatrol', $status );
	}

	/**
	 * @covers \RecentChange::markPatrolled
	 */
	public function testMarkPatrolledPermissions_NoRcPatrol() {
		$rc = $this->getDummyEditRecentChange();
		$status = $rc->markPatrolled( $this->mockRegisteredUltimateAuthority() );
		$this->assertStatusError( 'rcpatroldisabled', $status );
	}

	/**
	 * @covers \RecentChange::markPatrolled
	 */
	public function testMarkPatrolled() {
		$this->overrideConfigValue( MainConfigNames::UseRCPatrol, true );
		$rc = $this->getDummyEditRecentChange();
		$status = $rc->markPatrolled(
			$this->mockUserAuthorityWithPermissions( $this->user, [ 'patrol', 'autopatrol' ] )
		);
		$this->assertStatusGood( $status );

		$reloadedRC = RecentChange::newFromId( $rc->getAttribute( 'rc_id' ) );
		$this->assertSame( '1', $reloadedRC->getAttribute( 'rc_patrolled' ) );
	}
}
PK       ! E'  '  )  recentchanges/EnhancedChangesListTest.phpnu Iw        <?php

use MediaWiki\Content\WikitextContent;
use MediaWiki\Context\RequestContext;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\Utils\MWTimestamp;

/**
 * @covers \EnhancedChangesList
 *
 * @group Database
 *
 * @author Katie Filbert < aude.wiki@gmail.com >
 */
class EnhancedChangesListTest extends MediaWikiLangTestCase {

	/**
	 * @var TestRecentChangesHelper
	 */
	private $testRecentChangesHelper;

	protected function setUp(): void {
		parent::setUp();
		$this->testRecentChangesHelper = new TestRecentChangesHelper();
	}

	public function testBeginRecentChangesList_styleModules() {
		$enhancedChangesList = $this->newEnhancedChangesList();
		$enhancedChangesList->beginRecentChangesList();

		$styleModules = $enhancedChangesList->getOutput()->getModuleStyles();

		$this->assertContains(
			'mediawiki.special.changeslist',
			$styleModules,
			'has mediawiki.special.changeslist'
		);

		$this->assertContains(
			'mediawiki.special.changeslist.enhanced',
			$styleModules,
			'has mediawiki.special.changeslist.enhanced'
		);
	}

	public function testBeginRecentChangesList_html() {
		$enhancedChangesList = $this->newEnhancedChangesList();
		$html = $enhancedChangesList->beginRecentChangesList();

		$this->assertEquals( '<div class="mw-changeslist" aria-live="polite">', $html );
	}

	/**
	 * @todo more tests
	 */
	public function testRecentChangesLine() {
		$enhancedChangesList = $this->newEnhancedChangesList();
		$enhancedChangesList->beginRecentChangesList();

		$recentChange = $this->getEditChange( '20131103092153' );
		$html = $enhancedChangesList->recentChangesLine( $recentChange, false );

		$this->assertIsString( $html );

		$recentChange2 = $this->getEditChange( '20131103092253' );
		$html = $enhancedChangesList->recentChangesLine( $recentChange2, false );

		$this->assertSame( '', $html );
	}

	public function testRecentChangesPrefix() {
		$mockContext = $this->getMockBuilder( RequestContext::class )
			->onlyMethods( [ 'getTitle' ] )
			->getMock();
		$mockContext->method( 'getTitle' )
			->willReturn( Title::makeTitle( NS_MAIN, 'Expected Context Title' ) );

		// One group of two lines
		$enhancedChangesList = $this->newEnhancedChangesList();
		$enhancedChangesList->setContext( $mockContext );
		$enhancedChangesList->setChangeLinePrefixer( function ( $rc, $changesList ) {
			// Make sure RecentChange and ChangesList objects are the same
			$this->assertEquals( 'Expected Context Title', $changesList->getContext()->getTitle() );
			$this->assertTrue( $rc->getTitle() == 'Cat' || $rc->getTitle() == 'Dog' );
			return 'Hello world prefix';
		} );

		$this->setTemporaryHook( 'EnhancedChangesListModifyLineData', static function (
			$enhancedChangesList, &$data, $block, $rc, &$classes, &$attribs
		) {
			$data['recentChangesFlags']['minor'] = 1;
		} );

		$this->setTemporaryHook( 'EnhancedChangesListModifyBlockLineData', static function (
			$enhancedChangesList, &$data, $rcObj
		) {
			$data['recentChangesFlags']['bot'] = 1;
		} );

		$enhancedChangesList->beginRecentChangesList();

		$recentChange = $this->getEditChange( '20131103092153' );
		$enhancedChangesList->recentChangesLine( $recentChange );
		$recentChange = $this->getEditChange( '20131103092154' );
		$enhancedChangesList->recentChangesLine( $recentChange );

		$html = $enhancedChangesList->endRecentChangesList();

		$this->assertMatchesRegularExpression( '/Hello world prefix/', $html );

		// Test EnhancedChangesListModifyLineData hook was run
		$this->assertMatchesRegularExpression( '/This is a minor edit/', $html );

		// Two separate lines
		$enhancedChangesList->beginRecentChangesList();

		$recentChange = $this->getEditChange( '20131103092153' );
		$enhancedChangesList->recentChangesLine( $recentChange );
		$recentChange = $this->getEditChange( '20131103092154', 'Dog' );
		$enhancedChangesList->recentChangesLine( $recentChange );

		$html = $enhancedChangesList->endRecentChangesList();

		// Test EnhancedChangesListModifyBlockLineData hook was run
		$this->assertMatchesRegularExpression( '/This edit was performed by a bot/', $html );

		preg_match_all( '/Hello world prefix/', $html, $matches );
		$this->assertCount( 2, $matches[0] );
	}

	public function testCategorizationLineFormatting() {
		$html = $this->createCategorizationLine(
			$this->getCategorizationChange( '20150629191735', 0, 0 )
		);
		$this->assertStringNotContainsString( 'diffhist', strip_tags( $html ) );
	}

	public function testCategorizationLineFormattingWithRevision() {
		$html = $this->createCategorizationLine(
			$this->getCategorizationChange( '20150629191735', 1025, 1024 )
		);
		$this->assertStringContainsString( 'diffhist', strip_tags( $html ) );
	}

	/**
	 * @todo more tests for actual formatting, this is more of a smoke test
	 */
	public function testEndRecentChangesList() {
		$enhancedChangesList = $this->newEnhancedChangesList();
		$enhancedChangesList->beginRecentChangesList();

		$recentChange = $this->getEditChange( '20131103092153' );
		$enhancedChangesList->recentChangesLine( $recentChange, false );

		$html = $enhancedChangesList->endRecentChangesList();
		$this->assertMatchesRegularExpression(
			'/data-mw-revid="5" data-mw-ts="20131103092153" class="[^"]*mw-enhanced-rc[^"]*"/',
			$html
		);

		$recentChange2 = $this->getEditChange( '20131103092253' );
		$enhancedChangesList->recentChangesLine( $recentChange2, false );

		$html = $enhancedChangesList->endRecentChangesList();

		preg_match_all( '/td class="mw-enhanced-rc-nested"/', $html, $matches );
		$this->assertCount( 2, $matches[0] );

		preg_match_all( '/data-target-page="Cat"/', $html, $matches );
		$this->assertCount( 2, $matches[0] );

		$recentChange3 = $this->getLogChange();
		$enhancedChangesList->recentChangesLine( $recentChange3, false );

		$html = $enhancedChangesList->endRecentChangesList();
		$this->assertStringContainsString( 'data-mw-logaction="foo/bar"', $html );
		$this->assertStringContainsString( 'data-mw-logid="25"', $html );
		$this->assertStringContainsString( 'data-target-page="Title"', $html );
	}

	/**
	 * @return EnhancedChangesList
	 */
	private function newEnhancedChangesList() {
		$user = User::newFromId( 0 );
		$context = $this->testRecentChangesHelper->getTestContext( $user );

		return new EnhancedChangesList( $context );
	}

	/**
	 * @param string $timestamp
	 * @param string $pageTitle
	 * @return RecentChange
	 */
	private function getEditChange( $timestamp, $pageTitle = 'Cat' ) {
		$user = $this->getMutableTestUser()->getUser();
		$recentChange = $this->testRecentChangesHelper->makeEditRecentChange(
			$user, $pageTitle, 0, 5, 191, $timestamp, 0, 0
		);

		return $recentChange;
	}

	private function getLogChange() {
		$user = $this->getMutableTestUser()->getUser();
		$recentChange = $this->testRecentChangesHelper->makeLogRecentChange( 'foo', 'bar', $user,
			'Title', '20131103092153', 0, 0
		);

		return $recentChange;
	}

	/**
	 * @param string $timestamp
	 * @param int $thisId
	 * @param int $lastId
	 * @return RecentChange
	 */
	private function getCategorizationChange( $timestamp, $thisId, $lastId ) {
		$wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::makeTitle( NS_MAIN, 'Testpage' ) );
		$wikiPage->doUserEditContent(
			new WikitextContent( 'Some random text' ),
			$this->getTestSysop()->getUser(),
			'page created'
		);

		$wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::makeTitle( NS_CATEGORY, 'Foo' ) );
		$wikiPage->doUserEditContent(
			new WikitextContent( 'Some random text' ),
			$this->getTestSysop()->getUser(),
			'category page created'
		);

		$user = $this->getMutableTestUser()->getUser();
		$recentChange = $this->testRecentChangesHelper->makeCategorizationRecentChange(
			$user, 'Category:Foo', $wikiPage->getId(), $thisId, $lastId, $timestamp
		);

		return $recentChange;
	}

	private function createCategorizationLine( $recentChange ) {
		$enhancedChangesList = $this->newEnhancedChangesList();
		$cacheEntry = $this->testRecentChangesHelper->getCacheEntry( $recentChange );

		$reflection = new \ReflectionClass( get_class( $enhancedChangesList ) );
		$method = $reflection->getMethod( 'recentChangesBlockLine' );
		$method->setAccessible( true );

		return $method->invokeArgs( $enhancedChangesList, [ $cacheEntry ] );
	}

	public function testExpiringWatchlistItem(): void {
		// Set current time to 2020-05-05.
		MWTimestamp::setFakeTime( '20200505000000' );
		$enhancedChangesList = $this->newEnhancedChangesList();
		$enhancedChangesList->getOutput()->enableOOUI();
		$enhancedChangesList->setWatchlistDivs( true );

		$row = (object)[
			'rc_namespace' => NS_MAIN,
			'rc_title' => '',
			'rc_timestamp' => '20150921134808',
			'rc_deleted' => '',
			'rc_comment_text' => 'comment',
			'rc_comment_data' => null,
			'rc_user' => $this->getTestUser()->getUser()->getId(),
			'we_expiry' => '20200101000000',
		];
		$rc = RecentChange::newFromRow( $row );

		// Make sure it doesn't output anything for a past expiry.
		$html1 = $enhancedChangesList->getWatchlistExpiry( $rc );
		$this->assertSame( '', $html1 );

		// Check a future expiry for the right tooltip text.
		$rc->watchlistExpiry = '20200512000000';
		$html2 = $enhancedChangesList->getWatchlistExpiry( $rc );
		$this->assertStringContainsString( "title='7 days left in your watchlist'", $html2 );

		// Check that multiple changes on the same day all get the clock icon.
		$enhancedChangesList->beginRecentChangesList();
		// 1. Expire on 2020-06-01 (27 days):
		$rc1 = $this->getEditChange( '20200501000001', __METHOD__ . '1' );
		$rc1->watchlistExpiry = '20200601000000';
		$enhancedChangesList->recentChangesLine( $rc1 );
		// 2. Expire on 2020-06-08 (34 days):
		$rc2 = $this->getEditChange( '20200501000002', __METHOD__ . '2' );
		$rc2->watchlistExpiry = '20200608000000';
		$enhancedChangesList->recentChangesLine( $rc2 );
		// Get and test the HTML.
		$html3 = $enhancedChangesList->endRecentChangesList();
		$this->assertStringContainsString( '27 days left in your watchlist', $html3 );
		$this->assertStringContainsString( '34 days left in your watchlist', $html3 );
	}
}
PK       ! &!5  5    xml/XmlTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Xml\Xml;

/**
 * See also \MediaWiki\Tests\Unit\XmlTest for the pure unit tests
 *
 * @group Xml
 * @covers \MediaWiki\Xml\Xml
 */
class XmlTest extends MediaWikiIntegrationTestCase {

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

		$this->overrideConfigValues( [
			MainConfigNames::LanguageCode => 'en',
		] );

		$langObj = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' );
		$langObj->setNamespaces( [
			-2 => 'Media',
			-1 => 'Special',
			0 => '',
			1 => 'Talk',
			2 => 'User',
			3 => 'User_talk',
			4 => 'MyWiki',
			5 => 'MyWiki_Talk',
			6 => 'File',
			7 => 'File_talk',
			8 => 'MediaWiki',
			9 => 'MediaWiki_talk',
			10 => 'Template',
			11 => 'Template_talk',
			100 => 'Custom',
			101 => 'Custom_talk',
		] );

		$this->setUserLang( $langObj );
	}

	public static function provideElement() {
		// $expect, $element, $attribs, $contents
		yield 'Opening element with no attributes' => [ '<element>', 'element', null, null ];
		yield 'Terminated empty element' => [ '<element />', 'element', null, '' ];
		yield 'Element with no attributes and content that needs escaping' => [
			'<element>"hello &lt;there&gt; your\'s &amp; you"</element>',
			'element',
			null,
			'"hello <there> your\'s & you"'
		];
		yield 'Element attributes, keys are not escaped' => [
			'<element key="value" <>="&lt;&gt;">',
			'element',
			[ 'key' => 'value', '<>' => '<>' ],
			null
		];
	}

	/**
	 * @dataProvider provideElement
	 */
	public function testElement( string $expect, string $element, $attribs, $content ) {
		$this->assertEquals(
			$expect,
			Xml::element( $element, $attribs, $content )
		);
	}

	public function testElementInputCanHaveAValueOfZero() {
		$this->assertEquals(
			'<input name="name" value="0" />',
			Xml::input( 'name', false, 0 ),
			'Input with a value of 0 (T25797)'
		);
	}

	public function testOpenElement() {
		$this->assertEquals(
			'<element k="v">',
			Xml::openElement( 'element', [ 'k' => 'v' ] ),
			'openElement() shortcut'
		);
	}

	public function testCloseElement() {
		$this->assertEquals( '</element>', Xml::closeElement( 'element' ), 'closeElement() shortcut' );
	}

	public static function provideMonthSelector() {
		# providers are run before services are set up
		$lang = new class() {
			public function getMonthName( $i ) {
				$months = [
					'January', 'February', 'March', 'April', 'May', 'June',
					'July', 'August', 'September', 'October', 'November',
					'December',
				];
				return $months[$i - 1] ?? 'unknown';
			}
		};

		$header = '<select name="month" id="month" class="mw-month-selector">';
		$header2 = '<select name="month" id="monthSelector" class="mw-month-selector">';
		$monthsString = '';
		for ( $i = 1; $i < 13; $i++ ) {
			$monthName = $lang->getMonthName( $i );
			$monthsString .= "<option value=\"{$i}\">{$monthName}</option>";
			if ( $i !== 12 ) {
				$monthsString .= "\n";
			}
		}
		$monthsString2 = str_replace(
			'<option value="12">December</option>',
			'<option value="12" selected="">December</option>',
			$monthsString
		);
		$end = '</select>';

		$allMonths = "<option value=\"AllMonths\">all</option>\n";
		return [
			[ $header . $monthsString . $end, '', null, 'month' ],
			[ $header . $monthsString2 . $end, 12, null, 'month' ],
			[ $header2 . $monthsString . $end, '', null, 'monthSelector' ],
			[ $header . $allMonths . $monthsString . $end, '', 'AllMonths', 'month' ],

		];
	}

	/**
	 * @dataProvider provideMonthSelector
	 */
	public function testMonthSelector( $expected, $selected, $allmonths, $id ) {
		$this->hideDeprecated( 'MediaWiki\Xml\Xml::monthSelector' );
		$this->assertEquals(
			$expected,
			Xml::monthSelector( $selected, $allmonths, $id )
		);
	}

	public function testSpan() {
		$this->assertEquals(
			'<span class="foo" id="testSpan">element</span>',
			Xml::span( 'element', 'foo', [ 'id' => 'testSpan' ] )
		);
	}

	public function testDateMenu() {
		$curYear = intval( gmdate( 'Y' ) );
		$prevYear = $curYear - 1;

		$curMonth = intval( gmdate( 'n' ) );

		$nextMonth = $curMonth + 1;
		if ( $nextMonth == 13 ) {
			$nextMonth = 1;
		}

		$this->hideDeprecated( 'MediaWiki\Xml\Xml::dateMenu' );
		$this->hideDeprecated( 'MediaWiki\Xml\Xml::monthSelector' );

		$this->assertEquals(
			'<label for="year">From year (and earlier):</label> ' .
				'<input id="year" maxlength="4" size="7" type="number" value="2011" name="year"> ' .
				'<label for="month">From month (and earlier):</label> ' .
				'<select name="month" id="month" class="mw-month-selector">' .
				'<option value="-1">all</option>' . "\n" .
				'<option value="1">January</option>' . "\n" .
				'<option value="2" selected="">February</option>' . "\n" .
				'<option value="3">March</option>' . "\n" .
				'<option value="4">April</option>' . "\n" .
				'<option value="5">May</option>' . "\n" .
				'<option value="6">June</option>' . "\n" .
				'<option value="7">July</option>' . "\n" .
				'<option value="8">August</option>' . "\n" .
				'<option value="9">September</option>' . "\n" .
				'<option value="10">October</option>' . "\n" .
				'<option value="11">November</option>' . "\n" .
				'<option value="12">December</option></select>',
			Xml::dateMenu( 2011, 02 ),
			"Date menu for february 2011"
		);
		$this->assertEquals(
			'<label for="year">From year (and earlier):</label> ' .
				'<input id="year" maxlength="4" size="7" type="number" value="2011" name="year"> ' .
				'<label for="month">From month (and earlier):</label> ' .
				'<select name="month" id="month" class="mw-month-selector">' .
				'<option value="-1">all</option>' . "\n" .
				'<option value="1">January</option>' . "\n" .
				'<option value="2">February</option>' . "\n" .
				'<option value="3">March</option>' . "\n" .
				'<option value="4">April</option>' . "\n" .
				'<option value="5">May</option>' . "\n" .
				'<option value="6">June</option>' . "\n" .
				'<option value="7">July</option>' . "\n" .
				'<option value="8">August</option>' . "\n" .
				'<option value="9">September</option>' . "\n" .
				'<option value="10">October</option>' . "\n" .
				'<option value="11">November</option>' . "\n" .
				'<option value="12">December</option></select>',
			Xml::dateMenu( 2011, -1 ),
			"Date menu with negative month for 'All'"
		);
		$this->assertEquals(
			Xml::dateMenu( $curYear, $curMonth ),
			Xml::dateMenu( '', $curMonth ),
			"Date menu year is the current one when not specified"
		);

		$wantedYear = $nextMonth == 1 ? $curYear : $prevYear;
		$this->assertEquals(
			Xml::dateMenu( $wantedYear, $nextMonth ),
			Xml::dateMenu( '', $nextMonth ),
			"Date menu next month is 11 months ago"
		);

		$this->assertEquals(
			'<label for="year">From year (and earlier):</label> ' .
				'<input id="year" maxlength="4" size="7" type="number" name="year"> ' .
				'<label for="month">From month (and earlier):</label> ' .
				'<select name="month" id="month" class="mw-month-selector">' .
				'<option value="-1">all</option>' . "\n" .
				'<option value="1">January</option>' . "\n" .
				'<option value="2">February</option>' . "\n" .
				'<option value="3">March</option>' . "\n" .
				'<option value="4">April</option>' . "\n" .
				'<option value="5">May</option>' . "\n" .
				'<option value="6">June</option>' . "\n" .
				'<option value="7">July</option>' . "\n" .
				'<option value="8">August</option>' . "\n" .
				'<option value="9">September</option>' . "\n" .
				'<option value="10">October</option>' . "\n" .
				'<option value="11">November</option>' . "\n" .
				'<option value="12">December</option></select>',
			Xml::dateMenu( '', '' ),
			"Date menu with neither year or month"
		);
	}

	public function testTextareaNoContent() {
		$this->assertEquals(
			'<textarea name="name" id="name" cols="40" rows="5"></textarea>',
			Xml::textarea( 'name', '' ),
			'textarea() with not content'
		);
	}

	public function testTextareaAttribs() {
		$this->assertEquals(
			'<textarea name="name" id="name" cols="20" rows="10">&lt;txt&gt;</textarea>',
			Xml::textarea( 'name', '<txt>', 20, 10 ),
			'textarea() with custom attribs'
		);
	}

	public function testLabelCreation() {
		$this->assertEquals(
			'<label for="id">name</label>',
			Xml::label( 'name', 'id' ),
			'label() with no attribs'
		);
	}

	public function testLabelAttributeCanOnlyBeClassOrTitle() {
		$this->assertEquals(
			'<label for="id">name</label>',
			Xml::label( 'name', 'id', [ 'generated' => true ] ),
			'label() cannot be given a generated attribute'
		);
		$this->assertEquals(
			'<label for="id" class="nice">name</label>',
			Xml::label( 'name', 'id', [ 'class' => 'nice' ] ),
			'label() can get a class attribute'
		);
		$this->assertEquals(
			'<label for="id" title="nice tooltip">name</label>',
			Xml::label( 'name', 'id', [ 'title' => 'nice tooltip' ] ),
			'label() can get a title attribute'
		);
		$this->assertEquals(
			'<label for="id" class="nice" title="nice tooltip">name</label>',
			Xml::label( 'name', 'id', [
					'generated' => true,
					'class' => 'nice',
					'title' => 'nice tooltip',
					'anotherattr' => 'value',
				]
			),
			'label() skip all attributes but "class" and "title"'
		);
	}

	public function testLanguageSelector() {
		$this->hideDeprecated( 'MediaWiki\Xml\Xml::languageSelector' );

		$select = Xml::languageSelector( 'en', true, null,
			[ 'id' => 'testlang' ], wfMessage( 'yourlanguage' ) );
		$this->assertEquals(
			'<label for="testlang">Language:</label>',
			$select[0]
		);
	}

	public function testListDropdown() {
		$this->assertEquals(
			'<select name="test-name" id="test-name" class="test-css" tabindex="2">' .
				'<option value="other">other reasons</option>' . "\n" .
				'<optgroup label="Foo">' .
				'<option value="Foo 1">Foo 1</option>' . "\n" .
				'<option value="Example" selected="">Example</option>' . "\n" .
				'</optgroup>' . "\n" .
				'<optgroup label="Bar">' .
				'<option value="Bar 1">Bar 1</option>' . "\n" .
				'</optgroup>' .
				'</select>',
			Xml::listDropdown(
				// name
				'test-name',
				// source list
				"* Foo\n** Foo 1\n** Example\n* Bar\n** Bar 1",
				// other
				'other reasons',
				// selected
				'Example',
				// class
				'test-css',
				// tabindex
				2
			)
		);
	}

	public function testListDropdownOptions() {
		$this->assertEquals(
			[
				'other reasons' => 'other',
				'Empty group item' => 'Empty group item',
				'Foo' => [
					'Foo 1' => 'Foo 1',
					'Example' => 'Example',
				],
				'Bar' => [
					'Bar 1' => 'Bar 1',
				],
			],
			Xml::listDropdownOptions(
				"*\n** Empty group item\n* Foo\n** Foo 1\n** Example\n* Bar\n** Bar 1",
				[ 'other' => 'other reasons' ]
			)
		);
	}

	public function testListDropdownOptionsOthers() {
		// Do not use the value for 'other' as option group - T251351
		$this->assertEquals(
			[
				'other reasons' => 'other',
				'Foo 1' => 'Foo 1',
				'Example' => 'Example',
				'Bar' => [
					'Bar 1' => 'Bar 1',
				],
			],
			Xml::listDropdownOptions(
				"* other reasons\n** Foo 1\n** Example\n* Bar\n** Bar 1",
				[ 'other' => 'other reasons' ]
			)
		);
	}

	public function testListDropdownOptionsOoui() {
		$this->assertEquals(
			[
				[ 'data' => 'other', 'label' => 'other reasons' ],
				[ 'optgroup' => 'Foo' ],
				[ 'data' => 'Foo 1', 'label' => 'Foo 1' ],
				[ 'data' => 'Example', 'label' => 'Example' ],
				[ 'optgroup' => 'Bar' ],
				[ 'data' => 'Bar 1', 'label' => 'Bar 1' ],
			],
			Xml::listDropdownOptionsOoui( [
				'other reasons' => 'other',
				'Foo' => [
					'Foo 1' => 'Foo 1',
					'Example' => 'Example',
				],
				'Bar' => [
					'Bar 1' => 'Bar 1',
				],
			] )
		);
	}

	public static function provideFieldset() {
		// $expect, [ $arg1, $arg2, ... ]
		yield 'Opening tag' => [ "<fieldset>\n", [] ];
		yield 'Opening tag (false means no legend)' => [ "<fieldset>\n", [ false ] ];
		yield 'Opening tag (empty string means no legend)' => [ "<fieldset>\n", [ '' ] ];
		yield 'Opening tag with legend' => [
			"<fieldset>\n<legend>Foo</legend>\n",
			[ 'Foo' ]
		];
		yield 'Entire element with legend' => [
			"<fieldset>\n<legend>Foo</legend>\nBar\n</fieldset>\n",
			[ 'Foo', 'Bar' ]
		];
		yield 'Opening tag with legend (false means no content and no closing tag)' => [
			"<fieldset>\n<legend>Foo</legend>\n",
			[ 'Foo', false ]
		];
		yield 'Entire element with legend but no content (empty string generates a closing tag)' => [
			"<fieldset>\n<legend>Foo</legend>\n\n</fieldset>\n",
			[ 'Foo', '' ]
		];
		yield 'Opening tag with legend and attributes' => [
			"<fieldset class=\"bar\">\n<legend>Foo</legend>\nBar\n</fieldset>\n",
			[ 'Foo', 'Bar', [ 'class' => 'bar' ] ]
		];
		yield 'Entire element with legend and attributes' => [
			"<fieldset class=\"bar\">\n<legend>Foo</legend>\n",
			[ 'Foo', false, [ 'class' => 'bar' ] ]
		];
	}

	/**
	 * @dataProvider provideFieldset
	 */
	public function testFieldset( string $expect, array $args ) {
		$this->assertEquals(
			$expect,
			Xml::fieldset( ...$args )
		);
	}

	public function testBuildTable() {
		$firstRow = [ 'foo', 'bar' ];
		$secondRow = [ 'Berlin', 'Tehran' ];
		$headers = [ 'header1', 'header2' ];
		$expected = '<table id="testTable"><thead id="testTable"><th>header1</th>' .
			'<th>header2</th></thead><tr><td>foo</td><td>bar</td></tr><tr><td>Berlin</td>' .
			'<td>Tehran</td></tr></table>';
		$this->assertEquals(
			$expected,
			Xml::buildTable(
				[ $firstRow, $secondRow ],
				[ 'id' => 'testTable' ],
				$headers
			)
		);
	}

	public function testBuildTableRow() {
		$this->assertEquals(
			'<tr id="testRow"><td>foo</td><td>bar</td></tr>',
			Xml::buildTableRow( [ 'id' => 'testRow' ], [ 'foo', 'bar' ] )
		);
	}
}
PK       ! f    %  ResourceLoader/ResourceLoaderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use Exception;
use InvalidArgumentException;
use MediaWiki\Config\Config;
use MediaWiki\Config\HashConfig;
use MediaWiki\Html\HtmlJsCode;
use MediaWiki\MainConfigNames;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\Request\FauxRequest;
use MediaWiki\ResourceLoader\Context;
use MediaWiki\ResourceLoader\FileModule;
use MediaWiki\ResourceLoader\ResourceLoader;
use MediaWiki\ResourceLoader\SkinModule;
use MediaWiki\ResourceLoader\StartUpModule;
use MediaWiki\User\Options\StaticUserOptionsLookup;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use RuntimeException;
use UnexpectedValueException;
use Wikimedia\Minify\IdentityMinifierState;
use Wikimedia\Stats\NullStatsdDataFactory;
use Wikimedia\TestingAccessWrapper;

/**
 * @group ResourceLoader
 * @covers \MediaWiki\ResourceLoader\ResourceLoader
 */
class ResourceLoaderTest extends ResourceLoaderTestCase {

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

		$this->overrideConfigValue( MainConfigNames::ShowExceptionDetails, true );
	}

	/**
	 * Ensure the ResourceLoaderRegisterModules hook is called.
	 * @coversNothing
	 */
	public function testServiceWiring() {
		$ranHook = 0;
		$this->setTemporaryHook(
			'ResourceLoaderRegisterModules',
			static function ( &$resourceLoader ) use ( &$ranHook ) {
				$ranHook++;
			}
		);

		$this->getServiceContainer()->getResourceLoader();

		$this->assertSame( 1, $ranHook, 'Hook was called' );
	}

	public static function provideInvalidModuleName() {
		return [
			'name with 300 chars' => [ str_repeat( 'x', 300 ) ],
			'name with bang' => [ 'this!that' ],
			'name with comma' => [ 'this,that' ],
			'name with pipe' => [ 'this|that' ],
		];
	}

	public static function provideValidModuleName() {
		return [
			'empty string' => [ '' ],
			'simple name' => [ 'this.and-that2' ],
			'name with 100 chars' => [ str_repeat( 'x', 100 ) ],
			'name with hash' => [ 'this#that' ],
			'name with slash' => [ 'this/that' ],
			'name with at' => [ 'this@that' ],
		];
	}

	/**
	 * @dataProvider provideInvalidModuleName
	 */
	public function testIsValidModuleName_invalid( $name ) {
		$this->assertFalse( ResourceLoader::isValidModuleName( $name ) );
	}

	/**
	 * @dataProvider provideValidModuleName
	 */
	public function testIsValidModuleName_valid( $name ) {
		$this->assertTrue( ResourceLoader::isValidModuleName( $name ) );
	}

	public function testRegisterValidArray() {
		$resourceLoader = new EmptyResourceLoader();
		// Covers case of register() setting $rl->moduleInfos,
		// but $rl->modules lazy-populated by getModule()
		$resourceLoader->register( 'test', [ 'class' => ResourceLoaderTestModule::class ] );
		$this->assertInstanceOf(
			ResourceLoaderTestModule::class,
			$resourceLoader->getModule( 'test' )
		);
	}

	/**
	 * @group medium
	 */
	public function testRegisterEmptyString() {
		$resourceLoader = new EmptyResourceLoader();
		$resourceLoader->register( '', [ 'class' => ResourceLoaderTestModule::class ] );
		$this->assertInstanceOf(
			ResourceLoaderTestModule::class,
			$resourceLoader->getModule( '' )
		);
	}

	/**
	 * @group medium
	 */
	public function testRegisterInvalidName() {
		$resourceLoader = new EmptyResourceLoader();
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( "name 'test!invalid' is invalid" );
		$resourceLoader->register( 'test!invalid', [] );
	}

	public function testRegisterInvalidType() {
		$resourceLoader = new EmptyResourceLoader();
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( 'Invalid module info' );
		$resourceLoader->register( [ 'test' => (object)[] ] );
	}

	public function testRegisterDuplicate() {
		$logger = $this->createMock( LoggerInterface::class );
		$logger->expects( $this->once() )
			->method( 'warning' );
		$resourceLoader = new EmptyResourceLoader( null, $logger );

		$resourceLoader->register( 'test', [ 'class' => SkinModule::class ] );
		$resourceLoader->register( 'test', [ 'class' => StartUpModule::class ] );
		$this->assertInstanceOf(
			StartUpModule::class,
			$resourceLoader->getModule( 'test' ),
			'last one wins'
		);
	}

	public function testGetModuleNames() {
		// Use an empty one so that core and extension modules don't get in.
		$resourceLoader = new EmptyResourceLoader();
		$resourceLoader->register( 'test.foo', [] );
		$resourceLoader->register( 'test.bar', [] );
		$this->assertEquals(
			[ 'startup', 'test.foo', 'test.bar' ],
			$resourceLoader->getModuleNames()
		);
	}

	public function testIsModuleRegistered() {
		$rl = new EmptyResourceLoader();
		$rl->register( 'test', [] );
		$this->assertTrue( $rl->isModuleRegistered( 'test' ) );
		$this->assertFalse( $rl->isModuleRegistered( 'test.unknown' ) );
	}

	public function testGetModuleUnknown() {
		$rl = new EmptyResourceLoader();
		$this->assertSame( null, $rl->getModule( 'test' ) );
	}

	public function testGetModuleClass() {
		$rl = new EmptyResourceLoader();
		$rl->register( 'test', [ 'class' => ResourceLoaderTestModule::class ] );
		$this->assertInstanceOf(
			ResourceLoaderTestModule::class,
			$rl->getModule( 'test' )
		);
	}

	public function testGetModuleFactory() {
		$factory = function ( array $info ) {
			$this->assertArrayHasKey( 'kitten', $info );
			unset( $info['kitten'] );
			return new ResourceLoaderTestModule( $info );
		};

		$rl = new EmptyResourceLoader();
		$rl->register( 'test', [ 'factory' => $factory, 'kitten' => 'little ball of fur' ] );
		$this->assertInstanceOf(
			ResourceLoaderTestModule::class,
			$rl->getModule( 'test' )
		);
	}

	public function testGetModuleClassDefault() {
		$rl = new EmptyResourceLoader();
		$rl->register( 'test', [] );
		$this->assertInstanceOf(
			FileModule::class,
			$rl->getModule( 'test' ),
			'Array-style module registrations default to FileModule'
		);
	}

	public function testGetVersionHash_length() {
		$hash = ResourceLoader::makeHash(
			'Anything you do could have serious repercussions on future events.'
		);
		$this->assertSame( 'xhh1x', $hash, 'Hash' );
		$this->assertSame( ResourceLoader::HASH_LENGTH, strlen( $hash ), 'Hash length' );
	}

	public function testLessImportDirs() {
		$rl = new EmptyResourceLoader();
		$lc = $rl->getLessCompiler( [ 'foo'  => '2px', 'Foo' => '#eeeeee' ] );
		$basePath = dirname( dirname( __DIR__ ) ) . '/data/less';
		$lc->SetImportDirs( [
			"$basePath/common" => '',
		] );
		$css = $lc->parseFile( "$basePath/module/use-import-dir.less" )->getCss();
		$this->assertStringEqualsFile( "$basePath/module/styles.css", $css );
	}

	public static function provideLessImportRemappingCases() {
		$basePath = dirname( dirname( __DIR__ ) ) . '/data/less';
		return [
			[
				'input' => "$basePath/import-codex-icons.less",
				'expected' => "$basePath/import-codex-icons.css"
			],
			[
				'input' => "$basePath/import-codex-icons.less",
				'expected' => "$basePath/import-codex-icons-devmode.css",
				'exception' => null,
				'devmode' => true
			],
			[
				'input' => "$basePath/import-codex-tokens.less",
				'expected' => "$basePath/import-codex-tokens.css"
			],
			[
				'input' => "$basePath/import-codex-tokens.less",
				'expected' => "$basePath/import-codex-tokens-devmode.css",
				'exception' => null,
				'devmode' => true
			],
			[
				'input' => "$basePath/import-codex-tokens-npm.less",
				'expected' => null,
				'exception' => [
					'class' => Exception::class,
					'message' => 'Importing from @wikimedia/codex-design-tokens is not supported. ' .
						"To use the Codex tokens, use `@import 'mediawiki.skin.variables.less';` instead."
				]
			]
		];
	}

	/**
	 * @dataProvider provideLessImportRemappingCases
	 */
	public function testLessImportRemapping( $input, $expected, $exception = null, $devmode = false ) {
		$configOverrides = [];
		if ( $devmode ) {
			$devDir = MW_INSTALL_PATH . '/tests/phpunit/data/resourceloader/codex-devmode';
			$configOverrides += [
				MainConfigNames::CodexDevelopmentDir => $devDir
			];
		}

		$this->overrideConfigValues( $configOverrides );
		// Unfortunately the EmptyResourceLoader constructor doesn't pick up the overridden config
		// values, we have to do that separately
		$baseConfig = static::getSettings();
		$rl = new EmptyResourceLoader( new HashConfig( $configOverrides + $baseConfig ) );
		$lc = $rl->getLessCompiler();

		if ( $exception !== null ) {
			if ( isset( $exception['class'] ) ) {
				$this->expectException( $exception['class'] );
			}
			if ( isset( $exception['message'] ) ) {
				$this->expectExceptionMessage( $exception['message'] );
			}
		}

		$css = $lc->parseFile( $input )->getCss();
		if ( $expected !== null ) {
			$this->assertStringEqualsFile( $expected, $css );
		}
	}

	public static function provideMediaWikiVariablesCases() {
		$basePath = __DIR__ . '/../../data/less';
		return [
			[
				'config' => [],
				'importPaths' => [],
				'skin' => 'fallback',
				'expected' => "$basePath/use-variables-default.css",
			],
			[
				'config' => [
					MainConfigNames::ValidSkinNames => [
						// Required to make Context::getSkin work
						'example' => 'Example',
					],
				],
				'importPaths' => [
					'example' => "$basePath/testvariables/",
				],
				'skin' => 'example',
				'expected' => "$basePath/use-variables-test.css",
			]
		];
	}

	/**
	 * @dataProvider provideMediaWikiVariablesCases
	 */
	public function testMediaWikiVariablesDefault( array $config, array $importPaths, $skin, $expectedFile ) {
		$this->overrideConfigValues( $config );
		$reset = ExtensionRegistry::getInstance()->setAttributeForTest( 'SkinLessImportPaths', $importPaths );

		$context = $this->getResourceLoaderContext( [ 'skin' => $skin ] );
		$module = new FileModule( [
			'localBasePath' => __DIR__ . '/../../data/less',
			'styles' => [ 'use-variables.less' ],
		] );
		$module->setConfig( $context->getResourceLoader()->getConfig() );
		$module->setName( 'test.less' );
		$styles = $module->getStyles( $context );
		$this->assertStringEqualsFile( $expectedFile, $styles['all'] );
	}

	public static function providePackedModules() {
		return [
			[
				'Example from makePackedModulesString doc comment',
				[ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ],
				'foo.bar,baz|bar.baz,quux',
			],
			[
				'Example from expandModuleNames doc comment',
				[ 'jquery.foo', 'jquery.bar', 'jquery.ui.baz', 'jquery.ui.quux' ],
				'jquery.foo,bar|jquery.ui.baz,quux',
			],
			[
				'Regression fixed in r87497 (7fee86c38e) with dotless names',
				[ 'foo', 'bar', 'baz' ],
				'foo,bar,baz',
			],
			[
				'Prefixless modules after a prefixed module',
				[ 'single.module', 'foobar', 'foobaz' ],
				'single.module|foobar,foobaz',
			],
			[
				'Ordering',
				[ 'foo', 'foo.baz', 'baz.quux', 'foo.bar' ],
				'foo|foo.baz,bar|baz.quux',
				[ 'foo', 'foo.baz', 'foo.bar', 'baz.quux' ],
			]
		];
	}

	/**
	 * @dataProvider providePackedModules
	 */
	public function testMakePackedModulesString( $desc, $modules, $packed ) {
		$this->assertEquals( $packed, ResourceLoader::makePackedModulesString( $modules ), $desc );
	}

	/**
	 * @dataProvider providePackedModules
	 */
	public function testExpandModuleNames( $desc, $modules, $packed, $unpacked = null ) {
		$this->assertEquals(
			$unpacked ?: $modules,
			ResourceLoader::expandModuleNames( $packed ),
			$desc
		);
	}

	public static function provideAddSource() {
		return [
			[ 'foowiki', 'https://example.org/w/load.php', 'foowiki' ],
			[ 'foowiki', [ 'loadScript' => 'https://example.org/w/load.php' ], 'foowiki' ],
			[
				[
					'foowiki' => 'https://example.org/w/load.php',
					'bazwiki' => 'https://example.com/w/load.php',
				],
				null,
				[ 'foowiki', 'bazwiki' ]
			]
		];
	}

	/**
	 * @dataProvider provideAddSource
	 */
	public function testAddSource( $name, $info, $expected ) {
		$rl = new EmptyResourceLoader;
		$rl->addSource( $name, $info );
		if ( is_array( $expected ) ) {
			foreach ( $expected as $source ) {
				$this->assertArrayHasKey( $source, $rl->getSources() );
			}
		} else {
			$this->assertArrayHasKey( $expected, $rl->getSources() );
		}
	}

	public function testAddSourceDupe() {
		$rl = new EmptyResourceLoader;
		$this->expectException( RuntimeException::class );
		$this->expectExceptionMessage( 'Cannot register source' );
		$rl->addSource( 'foo', 'https://example.org/w/load.php' );
		$rl->addSource( 'foo', 'https://example.com/w/load.php' );
	}

	public function testAddSourceInvalid() {
		$rl = new EmptyResourceLoader;
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( 'must have a "loadScript" key' );
		$rl->addSource( 'foo', [ 'x' => 'https://example.org/w/load.php' ] );
	}

	public static function provideLoaderImplement() {
		return [
			'Implement scripts, styles and messages' => [ [
				'name' => 'test.example',
				'scripts' => 'mw.example();',
				'styles' => [ 'css' => [ '.mw-example {}' ] ],
				'messages' => [ 'example' => '' ],
				'templates' => [],

				'expected' => 'mw.loader.impl(function(){return["test.example@1",function($,jQuery,require,module){mw.example();
},{"css":[".mw-example {}"]},{"example":""}];});',
			] ],
			'Implement scripts' => [ [
				'name' => 'test.example',
				'scripts' => 'mw.example();',
				'styles' => [],

				'expected' => 'mw.loader.impl(function(){return["test.example@1",function($,jQuery,require,module){mw.example();
}];});',
			] ],
			'Implement scripts with newline at end' => [ [
				'name' => 'test.example',
				'scripts' => "mw.example();\n",
				'styles' => [],

				'expected' => 'mw.loader.impl(function(){return["test.example@1",function($,jQuery,require,module){mw.example();
}];});',
			] ],
			'Implement scripts with comment at end' => [ [
				'name' => 'test.example',
				'scripts' => "mw.example();//Foo",
				'styles' => [],

				'expected' => 'mw.loader.impl(function(){return["test.example@1",function($,jQuery,require,module){mw.example();//Foo
}];});',
			] ],
			'Implement styles' => [ [
				'name' => 'test.example',
				'scripts' => [],
				'styles' => [ 'css' => [ '.mw-example {}' ] ],

				'expected' => 'mw.loader.impl(function(){return["test.example@1",[],{"css":[".mw-example {}"]}];});',
			] ],
			'Implement scripts and messages' => [ [
				'name' => 'test.example',
				'scripts' => 'mw.example();',
				'messages' => [ 'example' => '' ],

				'expected' => 'mw.loader.impl(function(){return["test.example@1",function($,jQuery,require,module){mw.example();
},{},{"example":""}];});',
			] ],
			'Implement scripts and templates' => [ [
				'name' => 'test.example',
				'scripts' => 'mw.example();',
				'templates' => [ 'example.html' => '' ],

				'expected' => 'mw.loader.impl(function(){return["test.example@1",function($,jQuery,require,module){mw.example();
},{},{},{"example.html":""}];});',
			] ],
			'Implement unwrapped user script' => [ [
				'name' => 'user',
				'scripts' => 'mw.example( 1 );',
				'wrap' => false,

				'expected' => 'mw.loader.impl(function(){return["user@1","mw.example( 1 );"];});',
			] ],
			'Implement multi-file script' => [ [
				'name' => 'test.multifile',
				'scripts' => [
					'files' => [
						'one.js' => [
							'type' => 'script',
							'content' => 'mw.example( 1 );',
						],
						'two.json' => [
							'type' => 'data',
							'content' => [ 'n' => 2 ],
						],
						'three.js' => [
							'type' => 'script',
							'content' => 'mw.example( 3 ); // Comment'
						],
						'four.js' => [
							'type' => 'script',
							'content' => "mw.example( 4 );\n"
						],
						'five.js' => [
							'type' => 'script',
							'content' => 'mw.example( 5 );'
						],
					],
					'main' => 'five.js',
				],

				'expected' => <<<END
mw.loader.impl(function(){return["test.multifile@1",{"main":"five.js","files":{"one.js":function(require,module,exports){mw.example( 1 );
},"two.json":{
    "n": 2
},"three.js":function(require,module,exports){mw.example( 3 ); // Comment
},"four.js":function(require,module,exports){mw.example( 4 );
},"five.js":function(require,module,exports){mw.example( 5 );
}}}];});
END
			] ],
		];
	}

	/**
	 * @dataProvider provideLoaderImplement
	 */
	public function testAddImplementScript( $case ) {
		$case += [
			'version' => '1',
			'wrap' => true,
			'styles' => [],
			'templates' => [],
			'messages' => new HtmlJsCode( '{}' ),
			'packageFiles' => [],
		];
		$rl = TestingAccessWrapper::newFromObject( new EmptyResourceLoader );
		$minifier = new IdentityMinifierState;
		$rl->addImplementScript(
			$minifier,
			$case['name'],
			$case['version'],
			( $case['wrap'] && is_string( $case['scripts'] ) )
				? [ 'plainScripts' => [ [ 'content' => $case['scripts'] ] ] ]
				: $case['scripts'],
			$case['styles'],
			$case['messages'],
			$case['templates'],
			$case['packageFiles']
		);
		$this->assertEquals( $case['expected'], $minifier->getMinifiedOutput() );
	}

	public function testAddImplementScriptInvalid() {
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( 'Script must be a' );
		$minifier = new IdentityMinifierState;
		$rl = TestingAccessWrapper::newFromObject( new EmptyResourceLoader );
		$rl->addImplementScript(
			$minifier,
			'test', // name
			'1', // version
			123, // scripts
			null, // styles
			null, // messages
			null, // templates
			null // package files
		);
	}

	public function testMakeLoaderRegisterScript() {
		$context = new Context( new EmptyResourceLoader(), new FauxRequest( [
			'debug' => 'true',
		] ) );
		$this->assertEquals(
			'mw.loader.register([
    [
        "test.name",
        "1234567"
    ]
]);',
			ResourceLoader::makeLoaderRegisterScript( $context, [
				[ 'test.name', '1234567' ],
			] ),
			'Nested array parameter'
		);

		$this->assertEquals(
			'mw.loader.register([
    [
        "test.foo",
        "100"
    ],
    [
        "test.bar",
        "200",
        [
            "test.unknown"
        ]
    ],
    [
        "test.baz",
        "300",
        [
            3,
            0
        ]
    ],
    [
        "test.quux",
        "400",
        [],
        null,
        null,
        "return true;"
    ]
]);',
			ResourceLoader::makeLoaderRegisterScript( $context, [
				[ 'test.foo', '100', [], null, null ],
				[ 'test.bar', '200', [ 'test.unknown' ], null ],
				[ 'test.baz', '300', [ 'test.quux', 'test.foo' ], null ],
				[ 'test.quux', '400', [], null, null, 'return true;' ],
			] ),
			'Compact dependency indexes'
		);
	}

	public function testMakeLoaderSourcesScript() {
		$context = new Context( new EmptyResourceLoader(), new FauxRequest( [
			'debug' => 'true',
		] ) );
		$this->assertEquals(
			'mw.loader.addSource({
    "local": "/w/load.php"
});',
			ResourceLoader::makeLoaderSourcesScript( $context, [ 'local' => '/w/load.php' ] )
		);
		$this->assertEquals(
			'mw.loader.addSource({
    "local": "/w/load.php",
    "example": "https://example.org/w/load.php"
});',
			ResourceLoader::makeLoaderSourcesScript( $context, [
				'local' => '/w/load.php',
				'example' => 'https://example.org/w/load.php'
			] )
		);
		$this->assertEquals(
			'mw.loader.addSource([]);',
			ResourceLoader::makeLoaderSourcesScript( $context, [] )
		);
	}

	private static function fakeSources() {
		return [
			'examplewiki' => [
				'loadScript' => '//example.org/w/load.php',
				'apiScript' => '//example.org/w/api.php',
			],
			'example2wiki' => [
				'loadScript' => '//example.com/w/load.php',
				'apiScript' => '//example.com/w/api.php',
			],
		];
	}

	public function testGetLoadScript() {
		$rl = new EmptyResourceLoader();
		$sources = self::fakeSources();
		$rl->addSource( $sources );
		foreach ( [ 'examplewiki', 'example2wiki' ] as $name ) {
			$this->assertEquals( $rl->getLoadScript( $name ), $sources[$name]['loadScript'] );
		}

		$this->expectException( UnexpectedValueException::class );
		$rl->getLoadScript( 'thiswasneverregistered' );
	}

	protected function getFailFerryMock( $getter = 'getScript' ) {
		$mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
			->onlyMethods( [ $getter, 'getName' ] )
			->getMock();
		$mock->method( $getter )->willThrowException(
			new Exception( 'Ferry not found' )
		);
		$mock->method( 'getName' )->willReturn( __METHOD__ );
		return $mock;
	}

	protected function getSimpleModuleMock( $script = '' ) {
		$mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
			->onlyMethods( [ 'getScript', 'getName' ] )
			->getMock();
		$mock->method( 'getScript' )->willReturn( $script );
		$mock->method( 'getName' )->willReturn( __METHOD__ );
		return $mock;
	}

	protected function getSimpleStyleModuleMock( $styles = '' ) {
		$mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
			->onlyMethods( [ 'getStyles', 'getName' ] )
			->getMock();
		$mock->method( 'getStyles' )->willReturn( [ '' => $styles ] );
		$mock->method( 'getName' )->willReturn( __METHOD__ );
		return $mock;
	}

	public function testGetCombinedVersion() {
		$rl = $this->getMockBuilder( EmptyResourceLoader::class )
			// Disable log from outputErrorAndLog
			->onlyMethods( [ 'outputErrorAndLog' ] )->getMock();
		$rl->register( [
			'foo' => [ 'class' => ResourceLoaderTestModule::class ],
			'ferry' => [
				'factory' => function () {
					return $this->getFailFerryMock();
				}
			],
			'bar' => [ 'class' => ResourceLoaderTestModule::class ],
		] );
		$context = $this->getResourceLoaderContext( [ 'debug' => 'false' ], $rl );

		$this->assertSame(
			'',
			$rl->getCombinedVersion( $context, [] ),
			'empty list'
		);

		$this->assertEquals(
			self::BLANK_COMBI,
			$rl->getCombinedVersion( $context, [ 'foo' ] ),
			'compute foo'
		);

		// Verify that getCombinedVersion() does not throw when ferry fails.
		// Instead it gracefully continues to combine the remaining modules.
		$this->assertEquals(
			ResourceLoader::makeHash( self::BLANK_VERSION . self::BLANK_VERSION ),
			$rl->getCombinedVersion( $context, [ 'foo', 'ferry', 'bar' ] ),
			'compute foo+ferry+bar (T152266)'
		);
	}

	public static function provideMakeModuleResponseConcat() {
		$testcases = [
			[
				'modules' => [
					'foo' => 'foo()',
				],
				'expected' => "foo()\n" . 'mw.loader.state({
    "foo": "ready"
});',
				'minified' => "foo()\n" . 'mw.loader.state({"foo":"ready"});',
				'message' => 'Script without semi-colon',
			],
			[
				'modules' => [
					'foo' => 'foo()',
					'bar' => 'bar()',
				],
				'expected' => "foo()\nbar()\n" . 'mw.loader.state({
    "foo": "ready",
    "bar": "ready"
});',
				'minified' => "foo()\nbar()\n" . 'mw.loader.state({"foo":"ready","bar":"ready"});',
				'message' => 'Two scripts without semi-colon',
			],
			[
				'modules' => [
					'foo' => "foo()\n// bar();"
				],
				'expected' => "foo()\n// bar();\n" . 'mw.loader.state({
    "foo": "ready"
});',
				'minified' => "foo()\n" . 'mw.loader.state({"foo":"ready"});',
				'message' => 'Script with semi-colon in comment (T162719)',
			],
		];
		$ret = [];
		foreach ( $testcases as $i => $case ) {
			$ret["#$i"] = [
				$case['modules'],
				$case['expected'],
				true, // debug
				$case['message'],
			];
			$ret["#$i (minified)"] = [
				$case['modules'],
				$case['minified'],
				false, // debug
				$case['message'],
			];
		}
		return $ret;
	}

	/**
	 * Verify how multiple scripts and mw.loader.state() calls are concatenated.
	 *
	 * @dataProvider provideMakeModuleResponseConcat
	 */
	public function testMakeModuleResponseConcat( $scripts, $expected, $debug, $message = null ) {
		$rl = new EmptyResourceLoader();
		$modules = array_map( function ( $script ) {
			return $this->getSimpleModuleMock( $script );
		}, $scripts );

		$context = $this->getResourceLoaderContext(
			[
				'modules' => implode( '|', array_keys( $modules ) ),
				'only' => 'scripts',
				'debug' => $debug ? 'true' : 'false',
			],
			$rl
		);

		$response = $rl->makeModuleResponse( $context, $modules );
		$this->assertSame( [], $rl->getErrors(), 'Errors' );
		$this->assertEquals( $expected, $response, $message ?: 'Response' );
	}

	public function testMakeModuleResponseEmpty() {
		$rl = new EmptyResourceLoader();
		$context = $this->getResourceLoaderContext(
			[ 'modules' => '', 'only' => 'scripts' ],
			$rl
		);

		$response = $rl->makeModuleResponse( $context, [] );
		$this->assertSame( [], $rl->getErrors(), 'Errors' );
		$this->assertMatchesRegularExpression( '/^\/\*.+no modules were requested.+\*\/$/ms', $response );
	}

	/**
	 * Verify that when building module content in a load.php response,
	 * an exception from one module will not break script output from
	 * other modules.
	 */
	public function testMakeModuleResponseError() {
		$modules = [
			'foo' => $this->getSimpleModuleMock( 'foo();' ),
			'ferry' => $this->getFailFerryMock(),
			'bar' => $this->getSimpleModuleMock( 'bar();' ),
		];
		$rl = new EmptyResourceLoader();
		$context = $this->getResourceLoaderContext(
			[
				'modules' => 'foo|ferry|bar',
				'only' => 'scripts',
			],
			$rl
		);

		// Disable log from makeModuleResponse via outputErrorAndLog
		$this->setLogger( 'exception', new NullLogger() );

		$response = $rl->makeModuleResponse( $context, $modules );
		$errors = $rl->getErrors();

		$this->assertCount( 1, $errors );
		$this->assertMatchesRegularExpression( '/Ferry not found/', $errors[0] );
		$this->assertEquals(
			"foo();\nbar();\n" . 'mw.loader.state({
    "ferry": "error",
    "foo": "ready",
    "bar": "ready"
});',
			$response
		);
	}

	/**
	 * Verify that exceptions in PHP for one module will not break others
	 * (stylesheet response).
	 */
	public function testMakeModuleResponseErrorCSS() {
		$modules = [
			'foo' => self::getSimpleStyleModuleMock( '.foo{}' ),
			'ferry' => $this->getFailFerryMock( 'getStyles' ),
			'bar' => self::getSimpleStyleModuleMock( '.bar{}' ),
		];
		$rl = new EmptyResourceLoader();
		$context = $this->getResourceLoaderContext(
			[
				'modules' => 'foo|ferry|bar',
				'only' => 'styles',
				'debug' => 'false',
			],
			$rl
		);

		// Disable log from makeModuleResponse via outputErrorAndLog
		$this->setLogger( 'exception', new NullLogger() );

		$response = $rl->makeModuleResponse( $context, $modules );
		$errors = $rl->getErrors();

		$this->assertCount( 2, $errors );
		$this->assertMatchesRegularExpression( '/Ferry not found/', $errors[0] );
		$this->assertMatchesRegularExpression( '/Problem.+"ferry":\s*"error"/ms', $errors[1] );
		$this->assertEquals(
			'.foo{}.bar{}',
			$response
		);
	}

	/**
	 * Verify that when building the startup module response,
	 * an exception from one module class will not break the entire
	 * startup module response. See T152266.
	 */
	public function testMakeModuleResponseStartupError() {
		// This is an integration test that uses a lot of MediaWiki state,
		// provide the full Config object here.
		$rl = new EmptyResourceLoader( $this->getServiceContainer()->getMainConfig() );
		$rl->register( [
			'foo' => [ 'factory' => function () {
				return $this->getSimpleModuleMock( 'foo();' );
			} ],
			'ferry' => [ 'factory' => function () {
				return $this->getFailFerryMock();
			} ],
			'bar' => [ 'factory' => function () {
				return $this->getSimpleModuleMock( 'bar();' );
			} ],
		] );
		$context = $this->getResourceLoaderContext(
			[
				'modules' => 'startup',
				'only' => 'scripts',
				// No module build for version hash in debug mode
				'debug' => 'false',
			],
			$rl
		);

		$this->assertEquals(
			[ 'startup', 'foo', 'ferry', 'bar' ],
			$rl->getModuleNames(),
			'getModuleNames'
		);

		// Disable log from makeModuleResponse via outputErrorAndLog
		$this->setLogger( 'exception', new NullLogger() );

		$modules = [ 'startup' => $rl->getModule( 'startup' ) ];
		$response = $rl->makeModuleResponse( $context, $modules );
		$errors = $rl->getErrors();

		$this->assertMatchesRegularExpression( '/Ferry not found/', $errors[0] ?? '' );
		$this->assertCount( 1, $errors );
		$this->assertMatchesRegularExpression(
			'/isCompatible.*window\.RLQ/s',
			$response,
			'startup response undisrupted (T152266)'
		);
		$this->assertMatchesRegularExpression(
			'/register\([^)]+"ferry",\s*""/',
			$response,
			'startup response registers broken module'
		);
		$this->assertMatchesRegularExpression(
			'/state\([^)]+"ferry":\s*"error"/',
			$response,
			'startup response sets state to error'
		);
	}

	/**
	 * Integration test for modules sending extra HTTP response headers.
	 */
	public function testMakeModuleResponseExtraHeaders() {
		$module = $this->getMockBuilder( ResourceLoaderTestModule::class )
			->onlyMethods( [ 'getPreloadLinks', 'getName' ] )->getMock();
		$module->method( 'getPreloadLinks' )->willReturn( [
			'https://example.org/script.js' => [ 'as' => 'script' ],
		] );
		$module->method( 'getName' )->willReturn( __METHOD__ );

		$rl = new EmptyResourceLoader();
		$context = $this->getResourceLoaderContext(
			[ 'modules' => 'foo', 'only' => 'scripts' ],
			$rl
		);

		$modules = [ 'foo' => $module ];
		$response = $rl->makeModuleResponse( $context, $modules );
		$extraHeaders = TestingAccessWrapper::newFromObject( $rl )->extraHeaders;

		$this->assertEquals(
			[
				'Link: <https://example.org/script.js>;rel=preload;as=script'
			],
			$extraHeaders,
			'Extra headers'
		);
	}

	public function testMakeModuleResponseExtraHeadersMulti() {
		$foo = $this->getMockBuilder( ResourceLoaderTestModule::class )
			->onlyMethods( [ 'getPreloadLinks', 'getName' ] )->getMock();
		$foo->method( 'getPreloadLinks' )->willReturn( [
			'https://example.org/script.js' => [ 'as' => 'script' ],
		] );
		$foo->method( 'getName' )->willReturn( __METHOD__ );

		$bar = $this->getMockBuilder( ResourceLoaderTestModule::class )
			->onlyMethods( [ 'getPreloadLinks', 'getName' ] )->getMock();
		$bar->method( 'getPreloadLinks' )->willReturn( [
			'/example.png' => [ 'as' => 'image' ],
			'/example.jpg' => [ 'as' => 'image' ],
		] );
		$bar->method( 'getName' )->willReturn( __METHOD__ );

		$rl = new EmptyResourceLoader();
		$context = $this->getResourceLoaderContext(
			[ 'modules' => 'foo|bar', 'only' => 'scripts' ],
			$rl
		);

		$modules = [ 'foo' => $foo, 'bar' => $bar ];
		$response = $rl->makeModuleResponse( $context, $modules );
		$extraHeaders = TestingAccessWrapper::newFromObject( $rl )->extraHeaders;
		$this->assertEquals(
			[
				'Link: <https://example.org/script.js>;rel=preload;as=script',
				'Link: </example.png>;rel=preload;as=image,</example.jpg>;rel=preload;as=image'
			],
			$extraHeaders,
			'Extra headers'
		);
	}

	public function testRespondEmpty() {
		$rl = $this->getMockBuilder( EmptyResourceLoader::class )
			->onlyMethods( [
				'tryRespondNotModified',
				'sendResponseHeaders',
				'measureResponseTime',
			] )
			->getMock();
		$context = $this->getResourceLoaderContext( [ 'modules' => '' ], $rl );

		$rl->expects( $this->once() )->method( 'measureResponseTime' );
		$this->expectOutputRegex( '/no modules were requested/' );

		$rl->respond( $context );
	}

	public function testRespondSimple() {
		$module = new ResourceLoaderTestModule( [ 'script' => 'foo();' ] );
		$rl = $this->getMockBuilder( EmptyResourceLoader::class )
			->onlyMethods( [
				'measureResponseTime',
				'tryRespondNotModified',
				'sendResponseHeaders',
				'makeModuleResponse',
			] )
			->getMock();
		$rl->register( 'test', [
			'factory' => static function () use ( $module ) {
				return $module;
			}
		] );
		$context = $this->getResourceLoaderContext(
			[ 'modules' => 'test', 'only' => null ],
			$rl
		);

		$rl->expects( $this->once() )->method( 'makeModuleResponse' )
			->with( $context, [ 'test' => $module ] )
			->willReturn( 'implement_foo;' );
		$this->expectOutputRegex( '/^implement_foo;/' );

		$rl->respond( $context );
	}

	public function testRespondMissingModule() {
		$rl = $this->getMockBuilder( EmptyResourceLoader::class )
			->onlyMethods( [
				'measureResponseTime',
				'tryRespondNotModified',
				'sendResponseHeaders',
			] )
			->getMock();
		$context = $this->getResourceLoaderContext(
			[ 'modules' => 'unknown', 'only' => null ],
			$rl
		);

		$this->expectOutputRegex( '/mw\.loader\.state.*"unknown": "missing"/s' );

		$rl->respond( $context );
	}

	/**
	 * Silently ignore invalid UTF-8 injected into random query parameters.
	 *
	 * @see https://phabricator.wikimedia.org/T331641
	 */
	public function testRespondInvalidMissingModule() {
		$rl = $this->getMockBuilder( EmptyResourceLoader::class )
			->onlyMethods( [
				'measureResponseTime',
				'tryRespondNotModified',
				'sendResponseHeaders',
			] )
			->getMock();

		// Cover the JS-response which formats via mw.loader.state()
		$context = $this->getResourceLoaderContext(
			[ 'modules' => "foo|bar\x80\xf0bara|quux", 'only' => null ],
			$rl
		);
		$this->expectOutputRegex( '/mw\.loader\.state.*"foo": "missing"/s' );
		$rl->respond( $context );

		// Cover the CSS-response which formats via a block comment
		$context = $this->getResourceLoaderContext(
			[ 'modules' => "foo|bar\x80\xf0bara|quux", 'only' => 'styles' ],
			$rl
		);
		$this->expectOutputRegex( '/Problematic modules.*"foo": "missing"/s' );
		$rl->respond( $context );
	}

	/**
	 * Refuse requests for private modules.
	 */
	public function testRespondErrorPrivate() {
		$rl = $this->getMockBuilder( EmptyResourceLoader::class )
			->onlyMethods( [
				'measureResponseTime',
				'tryRespondNotModified',
				'sendResponseHeaders',
			] )
			->getMock();
		$rl->register( [
			'foo' => [ 'class' => ResourceLoaderTestModule::class ],
			'bar' => [ 'class' => ResourceLoaderTestModule::class, 'group' => 'private' ],
		] );
		$context = $this->getResourceLoaderContext(
			[ 'modules' => 'foo|bar', 'only' => null ],
			$rl
		);

		$this->expectOutputRegex( '/\/\*.+Cannot build private module/s' );
		$rl->respond( $context );
	}

	public function testRespondInternalFailures() {
		$module = $this->getMockBuilder( ResourceLoaderTestModule::class )
			->onlyMethods( [ 'getDefinitionSummary', 'enableModuleContentVersion' ] )
			->getMock();
		$module->method( 'enableModuleContentVersion' )
			->willReturn( false );
		$module->method( 'getDefinitionSummary' )
			->willThrowException( new Exception( 'Version error' ) );
		$rl = $this->getMockBuilder( EmptyResourceLoader::class )
			->onlyMethods( [
				'measureResponseTime',
				'preloadModuleInfo',
				'tryRespondNotModified',
				'makeModuleResponse',
				'sendResponseHeaders',
			] )
			->getMock();
		$rl->register( 'test', [
			'factory' => static function () use ( $module ) {
				return $module;
			}
		] );
		$context = $this->getResourceLoaderContext(
			[ 'modules' => 'test', 'debug' => 'false' ],
			$rl
		);
		// Disable logging from outputErrorAndLog
		$this->setLogger( 'exception', new NullLogger() );

		$rl->expects( $this->once() )->method( 'preloadModuleInfo' )
			->willThrowException( new Exception( 'Preload error' ) );
		$rl->expects( $this->once() )->method( 'makeModuleResponse' )
			->with( $context, [ 'test' => $module ] )
			->willReturn( 'foo;' );
		// Internal errors should be caught and logged without affecting module output
		$this->expectOutputRegex( '/foo;.*\/\*.+Preload error.+Version error.+\*\//ms' );

		$rl->respond( $context );
	}

	private function getResourceLoaderWithTestModules( ?Config $config = null ) {
		$localBasePath = __DIR__ . '/../../data/resourceloader';
		$remoteBasePath = '/w';
		$rl = new EmptyResourceLoader( $config );
		$rl->register( 'test1', [
			'localBasePath' => $localBasePath,
			'remoteBasePath' => $remoteBasePath,
			'scripts' => [ 'script-nosemi.js', 'script-comment.js' ],
		] );
		$rl->register( 'test2', [
			'localBasePath' => $localBasePath,
			'remoteBasePath' => $remoteBasePath,
			'scripts' => [ 'script-nosemi-nonl.js' ],
		] );
		return $rl;
	}

	public function testRespondSourceMap() {
		$rl = $this->getResourceLoaderWithTestModules();
		$context = $this->getResourceLoaderContext(
			[ 'modules' => 'test1', 'sourcemap' => '1', 'debug' => '' ],
			$rl
		);
		$this->expectOutputString( <<<JSON
{
"version": 3,
"file": "/load.php?lang=en&modules=test1&only=scripts",
"sources": ["/w/script-nosemi.js","/w/script-comment.js"],
"sourcesContent": ["/* eslint-disable */\\nmw.foo()\\n","/* eslint-disable */\\nmw.foo()\\n// mw.bar();\\n"],
"names": [],
"mappings": "AACA,EAAE,CAAC,GAAG,CAAC;ACAP,EAAE,CAAC,GAAG,CAAC"
}

JSON
		);
		$rl->respond( $context );
	}

	public function testRespondIndexMap() {
		$rl = $this->getResourceLoaderWithTestModules();
		$context = $this->getResourceLoaderContext(
			[ 'modules' => 'test1|test2', 'sourcemap' => '1', 'debug' => '' ],
			$rl
		);
		$this->expectOutputString( <<<JSON
{
"version": 3,
"sections": [
{"offset":{"line":0,"column":0},"map":{
"version": 3,
"file": "/load.php?lang=en&modules=test1%2Ctest2&only=scripts",
"sources": ["/w/script-nosemi.js","/w/script-comment.js"],
"sourcesContent": ["/* eslint-disable */\\nmw.foo()\\n","/* eslint-disable */\\nmw.foo()\\n// mw.bar();\\n"],
"names": [],
"mappings": "AACA,EAAE,CAAC,GAAG,CAAC;ACAP,EAAE,CAAC,GAAG,CAAC"
}
},
{"offset":{"line":2,"column":0},"map":{
"version": 3,
"file": "/load.php?lang=en&modules=test1%2Ctest2&only=scripts",
"sources": ["/w/script-nosemi-nonl.js"],
"sourcesContent": ["/* eslint-disable */\\nmw.foo()"],
"names": [],
"mappings": "AACA,EAAE,CAAC,GAAG,CAAC"
}
}
]
}
JSON
		);
		$rl->respond( $context );
	}

	public function testRespondSourceMapLink() {
		$rl = $this->getResourceLoaderWithTestModules( new HashConfig(
			[
				MainConfigNames::ResourceLoaderEnableSourceMapLinks => true,
			]
		) );
		$context = $this->getResourceLoaderContext(
			[ 'modules' => 'test1|test2', 'debug' => '' ],
			$rl
		);
		$this->expectOutputString( <<<JS
mw.foo()
mw.foo()
mw.foo()
mw.loader.state({"test1":"ready","test2":"ready"});
JS
		);
		$rl->respond( $context );

		$extraHeaders = TestingAccessWrapper::newFromObject( $rl )->extraHeaders;
		$this->assertEquals(
			[
				'SourceMap: /load.php?lang=en&modules=test1%2Ctest2&only=scripts&sourcemap=1&version=pq39u'
			],
			$extraHeaders,
			'Extra headers'
		);
	}

	public function testMeasureResponseTime() {
		$stats = $this->getMockBuilder( NullStatsdDataFactory::class )
			->onlyMethods( [ 'timing' ] )->getMock();
		$this->setService( 'StatsdDataFactory', $stats );

		$stats->expects( $this->once() )->method( 'timing' )
			->with( 'resourceloader.responseTime', $this->anything() );

		$rl = TestingAccessWrapper::newFromObject( new EmptyResourceLoader );
		$rl->measureResponseTime();
	}

	public function testGetUserDefaults() {
		$this->setService( 'UserOptionsLookup', new StaticUserOptionsLookup(
			[],
			[
				'include' => 1,
				'exclude' => 1,
			]
		) );
		$ctx = $this->createStub( Context::class );
		$this->setTemporaryHook( 'ResourceLoaderExcludeUserOptions', function (
			array &$keysToExclude,
			Context $context
		) use ( $ctx ): void {
			$this->assertSame( $ctx, $context );
			$keysToExclude[] = 'exclude';
		}, true );

		$defaults = ResourceLoader::getUserDefaults(
			$ctx,
			$this->getServiceContainer()->getHookContainer(),
			$this->getServiceContainer()->getUserOptionsLookup()
		);
		$this->assertSame( [ 'include' => 1 ], $defaults );
	}
}
PK       ! W      ResourceLoader/ContextTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use Generator;
use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\WebRequest;
use MediaWiki\ResourceLoader\Context;
use MediaWiki\ResourceLoader\ResourceLoader;
use MediaWiki\User\User;
use MediaWikiCoversValidator;
use MediaWikiTestCaseTrait;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;

/**
 * See also:
 * - ImageModuleTest::testContext
 *
 * @group ResourceLoader
 * @covers \MediaWiki\ResourceLoader\Context
 */
class ContextTest extends TestCase {

	use MediaWikiCoversValidator;
	use MediaWikiTestCaseTrait;

	protected static function getResourceLoader() {
		return new EmptyResourceLoader( new HashConfig( [
			MainConfigNames::ResourceLoaderDebug => false,
			MainConfigNames::LoadScript => '/w/load.php',
		] ) );
	}

	public function testEmpty() {
		$ctx = new Context( self::getResourceLoader(), new FauxRequest( [] ) );

		// Request parameters
		$this->assertEquals( [], $ctx->getModules() );
		$this->assertEquals( 'qqx', $ctx->getLanguage() );
		$this->assertSame( 0, $ctx->getDebug() );
		$this->assertNull( $ctx->getOnly() );
		$this->assertEquals( 'fallback', $ctx->getSkin() );
		$this->assertNull( $ctx->getUser() );
		$this->assertNull( $ctx->getContentOverrideCallback() );

		// Misc
		$this->assertEquals( 'ltr', $ctx->getDirection() );
		$this->assertEquals( 'qqx|fallback|0|||||||', $ctx->getHash() );
		$this->assertSame( [], $ctx->getReqBase() );
		$this->assertInstanceOf( User::class, $ctx->getUserObj() );
		$this->assertNull( $ctx->getUserIdentity() );
	}

	public function testDummy() {
		$this->assertInstanceOf(
			Context::class,
			Context::newDummyContext()
		);
	}

	public function testAccessors() {
		$ctx = new Context( self::getResourceLoader(), new FauxRequest( [] ) );
		$this->assertInstanceOf( ResourceLoader::class, $ctx->getResourceLoader() );
		$this->assertInstanceOf( WebRequest::class, $ctx->getRequest() );
		$this->assertInstanceOf( LoggerInterface::class, $ctx->getLogger() );
	}

	public function testTypicalRequest() {
		$ctx = new Context( self::getResourceLoader(), new FauxRequest( [
			'debug' => 'false',
			'lang' => 'zh',
			'modules' => 'foo|foo.quux,baz,bar|baz.quux',
			'only' => 'styles',
			'skin' => 'fallback',
		] ) );

		// Request parameters
		$this->assertEquals(
			[ 'foo', 'foo.quux', 'foo.baz', 'foo.bar', 'baz.quux' ],
			$ctx->getModules()
		);
		$this->assertSame( 0, $ctx->getDebug() );
		$this->assertEquals( 'zh', $ctx->getLanguage() );
		$this->assertEquals( 'styles', $ctx->getOnly() );
		$this->assertEquals( 'fallback', $ctx->getSkin() );
		$this->assertNull( $ctx->getUser() );

		// Misc
		$this->assertEquals( 'ltr', $ctx->getDirection() );
		$this->assertEquals( 'zh|fallback|0||styles|||||', $ctx->getHash() );
		$this->assertSame( [ 'lang' => 'zh' ], $ctx->getReqBase() );
	}

	public static function provideDirection() {
		yield 'LTR language' => [
			[ 'lang' => 'en' ],
			'ltr',
		];
		yield 'RTL language' => [
			[ 'lang' => 'he' ],
			'rtl',
		];
		yield 'explicit LTR' => [
			[ 'lang' => 'he', 'dir' => 'ltr' ],
			'ltr',
		];
		yield 'explicit RTL' => [
			[ 'lang' => 'en', 'dir' => 'rtl' ],
			'rtl',
		];
		// Not supported, but tested to cover the case and detect change
		yield 'invalid dir' => [
			[ 'lang' => 'he', 'dir' => 'xyz' ],
			'rtl',
		];
	}

	/**
	 * @dataProvider provideDirection
	 */
	public function testDirection( array $params, $expected ) {
		$ctx = new Context( self::getResourceLoader(), new FauxRequest( $params ) );
		$this->assertEquals( $expected, $ctx->getDirection() );
	}

	public function testShouldInclude() {
		$ctx = new Context( self::getResourceLoader(), new FauxRequest( [] ) );
		$this->assertTrue( $ctx->shouldIncludeScripts(), 'Scripts in combined' );
		$this->assertTrue( $ctx->shouldIncludeStyles(), 'Styles in combined' );
		$this->assertTrue( $ctx->shouldIncludeMessages(), 'Messages in combined' );

		$ctx = new Context( self::getResourceLoader(), new FauxRequest( [
			'only' => 'styles'
		] ) );
		$this->assertFalse( $ctx->shouldIncludeScripts(), 'Scripts not in styles-only' );
		$this->assertTrue( $ctx->shouldIncludeStyles(), 'Styles in styles-only' );
		$this->assertFalse( $ctx->shouldIncludeMessages(), 'Messages not in styles-only' );

		$ctx = new Context( self::getResourceLoader(), new FauxRequest( [
			'only' => 'scripts'
		] ) );
		$this->assertTrue( $ctx->shouldIncludeScripts(), 'Scripts in scripts-only' );
		$this->assertFalse( $ctx->shouldIncludeStyles(), 'Styles not in scripts-only' );
		$this->assertFalse( $ctx->shouldIncludeMessages(), 'Messages not in scripts-only' );
	}

	public function testGetUser() {
		$ctx = new Context( self::getResourceLoader(), new FauxRequest( [] ) );
		$this->assertSame( null, $ctx->getUser() );
		$this->assertFalse( $ctx->getUserObj()->isRegistered() );
		$this->assertNull( $ctx->getUserIdentity() );

		$ctx = new Context( self::getResourceLoader(), new FauxRequest( [
			'user' => 'Example'
		] ) );
		$this->assertSame( 'Example', $ctx->getUser() );
		$this->assertEquals( 'Example', $ctx->getUserObj()->getName() );
		$this->assertEquals( 'Example', $ctx->getUserIdentity()->getName() );
	}

	public function testMsg() {
		$ctx = new Context( self::getResourceLoader(), new FauxRequest( [
			'lang' => 'en'
		] ) );
		$msg = $ctx->msg( 'mainpage' );
		$this->assertInstanceOf( Message::class, $msg );
		$this->assertSame( 'Main Page', $msg->useDatabase( false )->plain() );
	}

	public function testEncodeJson() {
		$ctx = new Context( self::getResourceLoader(), new FauxRequest( [] ) );

		$json = $ctx->encodeJson( [ 'x' => 'A' ] );
		$this->assertSame( '{"x":"A"}', $json );

		// Regression: https://phabricator.wikimedia.org/T329330
		$json = @$ctx->encodeJson( [
			'x' => 'A',
			'y' => "Foo\x80\xf0Bar",
			'z' => 'C',
		] );
		$this->assertSame( '{"x":"A","y":null,"z":"C"}', $json, 'Ignore invalid UTF-8' );
	}

	public function testEncodeJsonWarning() {
		$ctx = new Context( self::getResourceLoader(), new FauxRequest( [] ) );

		$this->expectPHPError(
			E_USER_WARNING,
			static function () use ( $ctx ) {
				$ctx->encodeJson( [
					'x' => 'A',
					'y' => "Foo\x80\xf0Bar",
					'z' => 'C',
				] );
			},
			'encodeJson partially failed: Malformed UTF-8'
		);
	}

	public static function skinsProvider(): Generator {
		// expected skin, supplied skin, installed skins
		yield 'keep validated' => [
			'example',
			[ 'skin' => 'example' ],
			[ 'example', 'foo', 'bar' ]
		];

		yield 'fallback invalid' => [
			'fallback',
			[ 'skin' => 'not-example' ],
			[ 'example', 'foo', 'bar' ]
		];

		yield 'keep anything without validation' => [
			'not-example',
			[ 'skin' => 'not-example' ],
			null
		];
	}

	/**
	 * @dataProvider skinsProvider
	 */
	public function testContextWithSkinsValidation(
		string $expectedSkin, array $suppliedSkin, ?array $installedSkins
	) {
		$context = new Context(
			self::getResourceLoader(), new FauxRequest( $suppliedSkin ), $installedSkins
		);

		$this->assertSame( $expectedSkin, $context->getSkin() );
	}
}
PK       ! CU7  U7  !  ResourceLoader/ClientHtmlTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use MediaWiki\Config\HashConfig;
use MediaWiki\Request\FauxRequest;
use MediaWiki\ResourceLoader\ClientHtml;
use MediaWiki\ResourceLoader\Context;
use MediaWiki\ResourceLoader\Module;
use MediaWiki\ResourceLoader\ResourceLoader;
use MediaWikiCoversValidator;
use PHPUnit\Framework\TestCase;
use Wikimedia\TestingAccessWrapper;

/**
 * @group ResourceLoader
 * @covers \MediaWiki\ResourceLoader\ClientHtml
 */
class ClientHtmlTest extends TestCase {

	use MediaWikiCoversValidator;

	public function testGetData() {
		$context = self::makeContext();
		$context->getResourceLoader()->register( self::makeSampleModules() );

		$client = new ClientHtml( $context );
		$client->setModules( [
			'test',
			'test.private',
			'test.shouldembed.empty',
			'test.shouldembed',
			'test.user',
			'test.unregistered',
		] );
		$client->setModuleStyles( [
			'test.styles.mixed',
			'test.styles.user.empty',
			'test.styles.private',
			'test.styles.pure',
			'test.styles.shouldembed',
			'test.styles.deprecated',
			'test.unregistered.styles',
		] );

		$expected = [
			'states' => [
				// The below are NOT queued for loading via `mw.loader.load(Array)`.
				// Instead we tell the client to set their state to "loading" so that
				// if they are needed as dependencies, the client will not try to
				// load them on-demand, because the server is taking care of them already.
				// Either:
				// - Embedded as inline scripts in the HTML (e.g. user-private code, and
				//   previews). Once that script tag is reached, the state is "loaded".
				// - Loaded directly from the HTML with a dedicated HTTP request (e.g.
				//   user scripts, which vary by a 'user' and 'version' parameter that
				//   the static user-agnostic startup module won't have).
				'test.private' => 'loading',
				'test.shouldembed' => 'loading',
				'test.user' => 'loading',
				// The below are known to the server to be empty scripts, or to be
				// synchronously loaded stylesheets. These start in the "ready" state.
				'test.shouldembed.empty' => 'ready',
				'test.styles.pure' => 'ready',
				'test.styles.user.empty' => 'ready',
				'test.styles.private' => 'ready',
				'test.styles.shouldembed' => 'ready',
				'test.styles.deprecated' => 'ready',
			],
			'general' => [
				'test',
			],
			'styles' => [
				'test.styles.pure',
				'test.styles.deprecated',
			],
			'embed' => [
				'styles' => [ 'test.styles.private', 'test.styles.shouldembed' ],
				'general' => [
					'test.private',
					'test.shouldembed',
					'test.user',
				],
			],
			'styleDeprecations' => [
				// phpcs:ignore Generic.Files.LineLength.TooLong
				"This page is using the deprecated ResourceLoader module \"test.styles.deprecated\".\nDeprecation message."
			],
		];

		$access = TestingAccessWrapper::newFromObject( $client );
		$this->assertEquals( $expected, $access->getData() );
	}

	public function testGetHeadHtml() {
		$context = self::makeContext();
		$context->getResourceLoader()->register( self::makeSampleModules() );

		$client = new ClientHtml( $context );
		$client->setConfig( [ 'key' => 'value' ] );
		$client->setModules( [
			'test',
			'test.private',
		] );
		$client->setModuleStyles( [
			'test.styles.pure',
			'test.styles.private',
			'test.styles.deprecated',
		] );
		$client->setExemptStates( [
			'test.exempt' => 'ready',
		] );
		$expected = '<script>'
			. 'document.documentElement.className="client-js";'
			. 'RLCONF={"key":"value"};'
			. 'RLSTATE={"test.exempt":"ready","test.private":"loading","test.styles.pure":"ready","test.styles.private":"ready","test.styles.deprecated":"ready"};'
			. 'RLPAGEMODULES=["test"];'
			. '</script>' . "\n"
			. '<script>(RLQ=window.RLQ||[]).push(function(){'
			. 'mw.loader.impl(function(){return["test.private@{blankVer}",null,{"css":[]}];});'
			. '});</script>' . "\n"
			. '<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.styles.deprecated%2Cpure&amp;only=styles">' . "\n"
			. '<style>.private{}</style>' . "\n"
			. '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts&amp;raw=1"></script>';
		// phpcs:enable
		$expected = self::expandVariables( $expected );

		$this->assertSame( $expected, (string)$client->getHeadHtml() );
	}

	/**
	 * Confirm that 'target' is passed down to the startup module's load url.
	 */
	public function testGetHeadHtmlWithTarget() {
		$client = new ClientHtml(
			self::makeContext(),
			[ 'target' => 'example' ]
		);
		$expected = '<script>document.documentElement.className="client-js";</script>' . "\n"
			. '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts&amp;raw=1&amp;target=example"></script>';
		// phpcs:enable

		$this->assertSame( $expected, (string)$client->getHeadHtml() );
	}

	/**
	 * Confirm that 'safemode' is passed down to startup.
	 */
	public function testGetHeadHtmlWithSafemode() {
		$client = new ClientHtml(
			self::makeContext(),
			[ 'safemode' => '1' ]
		);
		$expected = '<script>document.documentElement.className="client-js";</script>' . "\n"
			. '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts&amp;raw=1&amp;safemode=1"></script>';
		// phpcs:enable

		$this->assertSame( $expected, (string)$client->getHeadHtml() );
	}

	/**
	 * Confirm that a null 'target' is the same as no target.
	 */
	public function testGetHeadHtmlWithNullTarget() {
		$client = new ClientHtml(
			self::makeContext(),
			[ 'target' => null ]
		);
		$expected = '<script>document.documentElement.className="client-js";</script>' . "\n"
			. '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts&amp;raw=1"></script>';
		// phpcs:enable

		$this->assertSame( $expected, (string)$client->getHeadHtml() );
	}

	public function testGetBodyHtml() {
		$context = self::makeContext();
		$context->getResourceLoader()->register( self::makeSampleModules() );

		$client = new ClientHtml( $context );
		$client->setConfig( [ 'key' => 'value' ] );
		$client->setModules( [
			'test',
			'test.private.bottom',
		] );
		$client->setModuleStyles( [
			'test.styles.deprecated',
		] );
		$expected = '<script>(RLQ=window.RLQ||[]).push(function(){'
			. 'mw.log.warn("This page is using the deprecated ResourceLoader module \"test.styles.deprecated\".\nDeprecation message.");'
			. '});</script>';
		// phpcs:enable

		$this->assertSame( $expected, (string)$client->getBodyHtml() );
	}

	public static function provideMakeLoad() {
		return [
			[
				'context' => [],
				'modules' => [ 'test.unknown' ],
				'only' => Module::TYPE_STYLES,
				'extra' => [],
				'output' => '',
			],
			[
				'context' => [],
				'modules' => [ 'test.styles.private' ],
				'only' => Module::TYPE_STYLES,
				'extra' => [],
				'output' => '<style>.private{}</style>',
			],
			[
				'context' => [],
				'modules' => [ 'test.private' ],
				'only' => Module::TYPE_COMBINED,
				'extra' => [],
				'output' => '<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.impl(function(){return["test.private@{blankVer}",null,{"css":[]}];});});</script>',
			],
			[
				'context' => [],
				'modules' => [ 'test.scripts' ],
				'only' => Module::TYPE_SCRIPTS,
				// Eg. startup module
				'extra' => [ 'raw' => '1' ],
				'output' => '<script async="" src="/w/load.php?lang=nl&amp;modules=test.scripts&amp;only=scripts&amp;raw=1"></script>',
			],
			[
				'context' => [],
				'modules' => [ 'test.scripts.user' ],
				'only' => Module::TYPE_SCRIPTS,
				'extra' => [],
				'output' => '<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?lang=nl\u0026modules=test.scripts.user\u0026only=scripts\u0026user=Example\u0026version={blankCombi}");});</script>',
			],
			[
				'context' => [],
				'modules' => [ 'test.user' ],
				'only' => Module::TYPE_COMBINED,
				'extra' => [],
				'output' => '<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?lang=nl\u0026modules=test.user\u0026user=Example\u0026version={blankCombi}");});</script>',
			],
			[
				'context' => [ 'debug' => 'true' ],
				'modules' => [ 'test.styles.pure', 'test.styles.mixed' ],
				'only' => Module::TYPE_STYLES,
				'extra' => [],
				'output' => '<link rel="stylesheet" href="/w/load.php?debug=1&amp;lang=nl&amp;modules=test.styles.mixed&amp;only=styles">' . "\n"
					. '<link rel="stylesheet" href="/w/load.php?debug=1&amp;lang=nl&amp;modules=test.styles.pure&amp;only=styles">',
			],
			[
				'context' => [ 'debug' => 'false' ],
				'modules' => [ 'test.styles.pure', 'test.styles.mixed' ],
				'only' => Module::TYPE_STYLES,
				'extra' => [],
				'output' => '<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.styles.mixed%2Cpure&amp;only=styles">',
			],
			[
				'context' => [],
				'modules' => [ 'test.styles.noscript' ],
				'only' => Module::TYPE_STYLES,
				'extra' => [],
				'output' => '<noscript><link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.styles.noscript&amp;only=styles"></noscript>',
			],
			[
				'context' => [],
				'modules' => [ 'test.shouldembed' ],
				'only' => Module::TYPE_COMBINED,
				'extra' => [],
				'output' => '<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.impl(function(){return["test.shouldembed@{blankVer}",null,{"css":[]}];});});</script>',
			],
			[
				'context' => [],
				'modules' => [ 'test.styles.shouldembed' ],
				'only' => Module::TYPE_STYLES,
				'extra' => [],
				'output' => '<style>.shouldembed{}</style>',
			],
			[
				'context' => [],
				'modules' => [ 'test.scripts.shouldembed' ],
				'only' => Module::TYPE_SCRIPTS,
				'extra' => [],
				'output' => '<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.state({"test.scripts.shouldembed":"ready"});});</script>',
			],
			[
				'context' => [],
				'modules' => [ 'test', 'test.shouldembed' ],
				'only' => Module::TYPE_COMBINED,
				'extra' => [],
				'output' => '<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?lang=nl\u0026modules=test");mw.loader.impl(function(){return["test.shouldembed@{blankVer}",null,{"css":[]}];});});</script>',
			],
			[
				'context' => [],
				'modules' => [ 'test.styles.pure', 'test.styles.shouldembed' ],
				'only' => Module::TYPE_STYLES,
				'extra' => [],
				'output' =>
					'<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.styles.pure&amp;only=styles">' . "\n"
					. '<style>.shouldembed{}</style>'
			],
			[
				'context' => [],
				'modules' => [ 'test.ordering.a', 'test.ordering.e', 'test.ordering.b', 'test.ordering.d', 'test.ordering.c' ],
				'only' => Module::TYPE_STYLES,
				'extra' => [],
				'output' =>
					'<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.ordering.a%2Cb&amp;only=styles">' . "\n"
					. '<style>.orderingC{}.orderingD{}</style>' . "\n"
					. '<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.ordering.e&amp;only=styles">'
			],
		];
		// phpcs:enable
	}

	/**
	 * @dataProvider provideMakeLoad
	 * @covers \MediaWiki\ResourceLoader\ClientHtml
	 * @covers \MediaWiki\ResourceLoader\Module
	 * @covers \MediaWiki\ResourceLoader\ResourceLoader
	 */
	public function testMakeLoad(
		array $contextQuery,
		array $modules,
		$type,
		array $extraQuery,
		$expected
	) {
		$context = self::makeContext( $contextQuery );
		$context->getResourceLoader()->register( self::makeSampleModules() );
		$actual = ClientHtml::makeLoad( $context, $modules, $type, $extraQuery, false );
		$expected = self::expandVariables( $expected );
		$this->assertSame( $expected, (string)$actual );
	}

	public function testGetDocumentAttributes() {
		$client = new ClientHtml( self::makeContext() );
		$this->assertIsArray( $client->getDocumentAttributes() );
	}

	private static function expandVariables( $text ) {
		return strtr( $text, [
			'{blankCombi}' => ResourceLoaderTestCase::BLANK_COMBI,
			'{blankVer}' => ResourceLoaderTestCase::BLANK_VERSION
		] );
	}

	private static function makeContext( $extraQuery = [] ) {
		$conf = new HashConfig( [] );
		return new Context(
			new ResourceLoader( $conf, null, null, [
				'loadScript' => '/w/load.php',
			] ),
			new FauxRequest( array_merge( [
				'lang' => 'nl',
				'skin' => 'fallback',
				'user' => 'Example',
				'target' => 'phpunit',
			], $extraQuery ) )
		);
	}

	private static function makeModule( array $options = [] ) {
		return $options + [ 'class' => ResourceLoaderTestModule::class ];
	}

	private static function makeSampleModules() {
		$modules = [
			'test' => [],
			'test.private' => [ 'group' => 'private' ],
			'test.shouldembed.empty' => [ 'shouldEmbed' => true, 'isKnownEmpty' => true ],
			'test.shouldembed' => [ 'shouldEmbed' => true ],
			'test.user' => [ 'group' => 'user' ],

			'test.styles.pure' => [ 'type' => Module::LOAD_STYLES ],
			'test.styles.mixed' => [],
			'test.styles.noscript' => [
				'type' => Module::LOAD_STYLES,
				'group' => 'noscript',
			],
			'test.styles.user' => [
				'type' => Module::LOAD_STYLES,
				'group' => 'user',
			],
			'test.styles.user.empty' => [
				'type' => Module::LOAD_STYLES,
				'group' => 'user',
				'isKnownEmpty' => true,
			],
			'test.styles.private' => [
				'type' => Module::LOAD_STYLES,
				'group' => 'private',
				'styles' => '.private{}',
			],
			'test.styles.shouldembed' => [
				'type' => Module::LOAD_STYLES,
				'shouldEmbed' => true,
				'styles' => '.shouldembed{}',
			],
			'test.styles.deprecated' => [
				'type' => Module::LOAD_STYLES,
				'deprecated' => 'Deprecation message.',
			],

			'test.scripts' => [],
			'test.scripts.user' => [ 'group' => 'user' ],
			'test.scripts.user.empty' => [ 'group' => 'user', 'isKnownEmpty' => true ],
			'test.scripts.shouldembed' => [ 'shouldEmbed' => true ],

			'test.ordering.a' => [ 'shouldEmbed' => false ],
			'test.ordering.b' => [ 'shouldEmbed' => false ],
			'test.ordering.c' => [ 'shouldEmbed' => true, 'styles' => '.orderingC{}' ],
			'test.ordering.d' => [ 'shouldEmbed' => true, 'styles' => '.orderingD{}' ],
			'test.ordering.e' => [ 'shouldEmbed' => false ],
		];
		return array_map( static function ( $options ) {
			return self::makeModule( $options );
		}, $modules );
	}
}
PK       ! a9R    (  ResourceLoader/UserOptionsModuleTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use MediaWiki\ResourceLoader\Context;
use MediaWiki\ResourceLoader\UserOptionsModule;
use MediaWiki\User\Options\StaticUserOptionsLookup;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;

/**
 * @group ResourceLoader
 * @covers \MediaWiki\ResourceLoader\UserOptionsModule
 */
class UserOptionsModuleTest extends MediaWikiIntegrationTestCase {

	public function testGetScript() {
		$module = new UserOptionsModule();
		$hooks = $this->createHookContainer();
		$module->setHookContainer( $hooks );
		$options = new StaticUserOptionsLookup(
			[
				'Example1' => [],
				'Example2' => [ 'y' => '1', 'userjs-extra' => '1' ],
			],
			[
				'x' => '1',
				'y' => '0',
				'foobar' => 'Rhabarberbarbara',
				'ipsum' => 'consectetur adipiscing elit',
			]
		);
		$this->setService( 'UserOptionsLookup', $options );

		$script = $module->getScript( $this->makeContext() );
		$this->assertStringContainsString(
			'"csrfToken":',
			$script,
			'always send csrfToken'
		);
		$this->assertStringNotContainsString(
			'Rhabarberbarbara',
			$script,
			'no default settings sent'
		);
		$this->assertStringNotContainsString(
			'mw.user.options.set',
			$script,
			'no in-page blob for anon default settings'
		);

		$script = $module->getScript( $this->makeContext( 'Example1' ) );
		$this->assertStringNotContainsString(
			'mw.user.options.set',
			$script,
			'no in-page blob for logged-in default settings'
		);

		$script = $module->getScript( $this->makeContext( 'Example2' ) );
		$this->assertStringContainsString(
			'mw.user.options.set',
			$script,
			'send blob for non-default settings'
		);
		$this->assertStringContainsString(
			'"y":"1"',
			$script,
			'send overridden value'
		);
		$this->assertStringContainsString(
			'"userjs-extra":"1"',
			$script,
			'send custom preference keys'
		);
	}

	public function testResourceLoaderExcludeUserOptionsHook() {
		$module = new UserOptionsModule();
		$hooks = $this->createHookContainer( [
			'ResourceLoaderExcludeUserOptions' => static function (
				array &$keysToExclude,
				Context $context
			): void {
				$keysToExclude[] = 'exclude-explicit';
				$keysToExclude[] = 'exclude-default';
			}
		] );
		$module->setHookContainer( $hooks );
		$options = new StaticUserOptionsLookup(
			[
				'User' => [ 'include-explicit' => '1', 'exclude-explicit' => '1' ],
			],
			[
				'exclude-default' => '1',
			]
		);
		$this->setService( 'UserOptionsLookup', $options );

		$script = $module->getScript( $this->makeContext( 'User' ) );
		$this->assertStringContainsString(
			'include-explicit',
			$script,
			'normal behavior'
		);
		$this->assertStringNotContainsString(
			'exclude-explicit',
			$script,
			'$keysToExclude filters'
		);
		// defaults shouldn't show up here anyway but double-check
		$this->assertStringNotContainsString(
			'exclude-default',
			$script,
			'default excluded'
		);
	}

	private function makeContext( ?string $name = null ) {
		$user = $this->createStub( User::class );
		if ( $name ) {
			$user->method( 'isRegistered' )->willReturn( true );
			$user->method( 'getName' )->willReturn( $name );
		}
		$ctx = $this->createStub( Context::class );
		$ctx->method( 'encodeJson' )->willReturnCallback( 'json_encode' );
		$ctx->method( 'getUserObj' )->willReturn( $user );
		return $ctx;
	}
}
PK       ! >4U,  U,    ResourceLoader/ModuleTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use LogicException;
use MediaWiki\MainConfigNames;
use MediaWiki\ResourceLoader\FileModule;
use MediaWiki\ResourceLoader\MessageBlobStore;
use MediaWiki\ResourceLoader\Module;
use MediaWiki\ResourceLoader\ResourceLoader;
use ReflectionMethod;

/**
 * @group ResourceLoader
 * @covers \MediaWiki\ResourceLoader\Module
 */
class ModuleTest extends ResourceLoaderTestCase {

	public function testGetVersionHash() {
		$context = $this->getResourceLoaderContext( [ 'debug' => 'false' ] );
		$msgBlobStore = $this->createMock( MessageBlobStore::class );
		$msgBlobStore->method( 'getBlob' )->willReturn( '{}' );
		$context->getResourceLoader()->setMessageBlobStore( $msgBlobStore );

		$baseParams = [
			'scripts' => [ 'foo.js', 'bar.js' ],
			'dependencies' => [ 'jquery', 'mediawiki' ],
			'messages' => [ 'hello', 'world' ],
		];

		$module = new FileModule( $baseParams );
		$module->setName( 'test' );
		$version = json_encode( $module->getVersionHash( $context ) );

		// Exactly the same
		$module = new FileModule( $baseParams );
		$module->setName( 'test' );
		$this->assertEquals(
			$version,
			json_encode( $module->getVersionHash( $context ) ),
			'Instance is insignificant'
		);

		// Re-order dependencies
		$module = new FileModule( [
			'dependencies' => [ 'mediawiki', 'jquery' ],
		] + $baseParams );
		$module->setName( 'test' );
		$this->assertEquals(
			$version,
			json_encode( $module->getVersionHash( $context ) ),
			'Order of dependencies is insignificant'
		);

		// Re-order messages
		$module = new FileModule( [
			'messages' => [ 'world', 'hello' ],
		] + $baseParams );
		$module->setName( 'test' );
		$this->assertEquals(
			$version,
			json_encode( $module->getVersionHash( $context ) ),
			'Order of messages is insignificant'
		);

		// Re-order scripts
		$module = new FileModule( [
			'scripts' => [ 'bar.js', 'foo.js' ],
		] + $baseParams );
		$module->setName( 'test' );
		$this->assertNotEquals(
			$version,
			json_encode( $module->getVersionHash( $context ) ),
			'Order of scripts is significant'
		);

		// Subclass
		$module = new ResourceLoaderFileModuleTestingSubclass( $baseParams );
		$module->setName( 'test' );
		$this->assertNotEquals(
			$version,
			json_encode( $module->getVersionHash( $context ) ),
			'Class is significant'
		);
	}

	public function testGetVersionHash_debug() {
		$module = new ResourceLoaderTestModule( [ 'script' => 'foo();' ] );
		$module->setName( 'test' );
		$context = $this->getResourceLoaderContext( [ 'debug' => 'true' ] );
		$this->assertSame( '', $module->getVersionHash( $context ) );
	}

	public function testGetVersionHash_length() {
		$context = $this->getResourceLoaderContext( [ 'debug' => 'false' ] );
		$module = new ResourceLoaderTestModule( [
			'script' => 'foo();'
		] );
		$module->setName( 'test' );
		$version = $module->getVersionHash( $context );
		$this->assertSame( ResourceLoader::HASH_LENGTH, strlen( $version ), 'Hash length' );
	}

	public function testGetVersionHash_parentDefinition() {
		$context = $this->getResourceLoaderContext( [ 'debug' => 'false' ] );
		$module = $this->getMockBuilder( Module::class )
			->onlyMethods( [ 'getDefinitionSummary' ] )->getMock();
		$module->method( 'getDefinitionSummary' )->willReturn( [ 'a' => 'summary' ] );
		$module->setName( 'test' );

		$this->expectException( LogicException::class );
		$this->expectExceptionMessage( 'must call parent' );
		$module->getVersionHash( $context );
	}

	/**
	 * @covers \MediaWiki\ResourceLoader\Module
	 * @covers \MediaWiki\ResourceLoader\ResourceLoader
	 */
	public function testGetURLsForDebug() {
		$module = new ResourceLoaderTestModule( [
			'script' => 'foo();',
			'styles' => '.foo { color: blue; }',
		] );
		$context = $this->getResourceLoaderContext( [ 'debug' => 'true' ] );
		$module->setConfig( $context->getResourceLoader()->getConfig() );
		$module->setName( 'test' );

		$this->assertEquals(
			[
				'https://example.org/w/load.php?debug=1&lang=en&modules=test&only=scripts'
			],
			$module->getScriptURLsForDebug( $context ),
			'script urls debug=true'
		);
		$this->assertEquals(
			[ 'all' => [
				'/w/load.php?debug=1&lang=en&modules=test&only=styles'
			] ],
			$module->getStyleURLsForDebug( $context ),
			'style urls debug=true'
		);

		$context = $this->getResourceLoaderContext( [ 'debug' => '2' ] );
		$this->assertEquals(
			[
				'https://example.org/w/load.php?debug=2&lang=en&modules=test&only=scripts'
			],
			$module->getScriptURLsForDebug( $context ),
			'script urls debug=2'
		);
		$this->assertEquals(
			[ 'all' => [
				'/w/load.php?debug=2&lang=en&modules=test&only=styles'
			] ],
			$module->getStyleURLsForDebug( $context ),
			'style urls debug=2'
		);
	}

	public static function provideValidateScripts() {
		yield 'valid ES5' => [ "\n'valid';" ];

		yield 'valid ES6/ES2015 for-of' => [
			"var x = ['a', 'b']; for (var key of x) { console.log(key); }"
		];

		yield 'valid ES2016 exponentiation' => [
			"var x = 2; var y = 3; console.log(x ** y);"
		];

		yield 'valid ES2017 async-await' => [
			"var foo = async function(x) { return await x.fetch(); }",
			'Parse error: Unexpected: function on line 1'
		];

		yield 'valid ES2018 spread in object literal' => [
			"var x = {b: 2, c: 3}; var y = {a: 1, ...x};",
			'Parse error: Unexpected: ... on line 1'
		];

		yield 'SyntaxError' => [
			"var a = 'this is';\n {\ninvalid",
			'Parse error: Unclosed { on line 3'
		];

		// If an implementation matches inputs using a regex with runaway backtracking,
		// then inputs with more than ~3072 repetitions are likely to fail (T299537).
		$input = '"' . str_repeat( 'x', 10000 ) . '";';
		yield 'double quote string 10K' => [ $input, ];
		$input = '\'' . str_repeat( 'x', 10000 ) . '\';';
		yield 'single quote string 10K' => [ $input ];
		$input = '"' . str_repeat( '\u0021', 100 ) . '";';
		yield 'escaping string 100' => [ $input ];
		$input = '"' . str_repeat( '\u0021', 10000 ) . '";';
		yield 'escaping string 10K' => [ $input ];
		$input = '/' . str_repeat( 'x', 1000 ) . '/;';
		yield 'regex 1K' => [ $input ];
		$input = '/' . str_repeat( 'x', 10000 ) . '/;';
		yield 'regex 10K' => [ $input ];
		$input = '/' . str_repeat( '\u0021', 100 ) . '/;';
		yield 'escaping regex 100' => [ $input ];
		$input = '/' . str_repeat( '\u0021', 10000 ) . '/;';
		yield 'escaping regex 10K' => [ $input ];
	}

	/**
	 * @dataProvider provideValidateScripts
	 */
	public function testValidateScriptFile( $input, $error = null ) {
		$this->overrideConfigValue( MainConfigNames::ResourceLoaderValidateJS, true );

		$context = $this->getResourceLoaderContext();

		$module = new ResourceLoaderTestModule( [
			'mayValidateScript' => true,
			'script' => $input
		] );
		$module->setConfig( $context->getResourceLoader()->getConfig() );

		$result = $module->getScript( $context );
		if ( $error ) {
			$this->assertStringContainsString( 'mw.log.error(', $result, 'log error' );
			$this->assertStringContainsString( $error, $result, 'error message' );
		} else {
			$this->assertEquals(
				$input,
				$module->getScript( $context ),
				'Leave valid scripts as-is'
			);
		}
	}

	public static function provideBuildContentScripts() {
		return [
			[
				"mw.foo()",
			],
			[
				"mw.foo();",
			],
			[
				"mw.foo();\n",
			],
			[
				"mw.foo()\n",
			],
			[
				"mw.foo()\n// mw.bar();",
			],
			[
				"mw.foo()\n// mw.bar()",
			],
			[
				"mw.foo()// mw.bar();",
			],
		];
	}

	/**
	 * @dataProvider provideBuildContentScripts
	 */
	public function testBuildContentScripts( $raw, $message = '' ) {
		$context = $this->getResourceLoaderContext();
		$module = new ResourceLoaderTestModule( [
			'script' => $raw
		] );
		$module->setName( 'test' );
		$this->assertEquals( $raw, $module->getScript( $context ), 'Raw script' );
		$this->assertEquals(
			[ 'plainScripts' => [ [ 'content' => $raw ] ] ],
			$module->getModuleContent( $context )[ 'scripts' ],
			$message
		);
	}

	public function testPlaceholderize() {
		$getRelativePaths = new ReflectionMethod( Module::class, 'getRelativePaths' );
		$getRelativePaths->setAccessible( true );
		$expandRelativePaths = new ReflectionMethod( Module::class, 'expandRelativePaths' );
		$expandRelativePaths->setAccessible( true );

		$this->setMwGlobals( [
			'IP' => '/srv/example/mediawiki/core',
		] );
		$raw = [
				'/srv/example/mediawiki/core/resources/foo.js',
				'/srv/example/mediawiki/core/extensions/Example/modules/bar.js',
				'/srv/example/mediawiki/skins/Example/baz.css',
				'/srv/example/mediawiki/skins/Example/images/quux.png',
		];
		$canonical = [
				'resources/foo.js',
				'extensions/Example/modules/bar.js',
				'../skins/Example/baz.css',
				'../skins/Example/images/quux.png',
		];
		$this->assertEquals(
			$canonical,
			$getRelativePaths->invoke( null, $raw ),
			'Insert placeholders'
		);
		$this->assertEquals(
			$raw,
			$expandRelativePaths->invoke( null, $canonical ),
			'Substitute placeholders'
		);
	}

	public function testGetHeaders() {
		$context = $this->getResourceLoaderContext();

		$module = new ResourceLoaderTestModule();
		$module->setName( 'test' );
		$this->assertSame( [], $module->getHeaders( $context ), 'Default' );

		$module = $this->getMockBuilder( ResourceLoaderTestModule::class )
			->onlyMethods( [ 'getPreloadLinks' ] )->getMock();
		$module->method( 'getPreloadLinks' )->willReturn( [
			'https://example.org/script.js' => [ 'as' => 'script' ],
		] );
		$this->assertSame(
			[
				'Link: <https://example.org/script.js>;rel=preload;as=script'
			],
			$module->getHeaders( $context ),
			'Preload one resource'
		);

		$module = $this->getMockBuilder( ResourceLoaderTestModule::class )
			->onlyMethods( [ 'getPreloadLinks' ] )->getMock();
		$module->method( 'getPreloadLinks' )->willReturn( [
			'https://example.org/script.js' => [ 'as' => 'script' ],
			'/example.png' => [ 'as' => 'image' ],
		] );
		$module->setName( 'test' );
		$this->assertSame(
			[
				'Link: <https://example.org/script.js>;rel=preload;as=script,' .
					'</example.png>;rel=preload;as=image'
			],
			$module->getHeaders( $context ),
			'Preload two resources'
		);
	}

	public static function provideGetDeprecationWarning() {
		return [
			[
				null,
				'normalModule',
				null,
			],
			[
				true,
				'deprecatedModule',
				'This page is using the deprecated ResourceLoader module "deprecatedModule".',
			],
			[
				'Will be removed tomorrow.',
				'deprecatedTomorrow',
				"This page is using the deprecated ResourceLoader module \"deprecatedTomorrow\".\n" .
				"Will be removed tomorrow.",
			],
		];
	}

	/**
	 * @dataProvider provideGetDeprecationWarning
	 *
	 * @param string|bool|null $deprecated
	 * @param string $name
	 * @param string $expected
	 */
	public function testGetDeprecationWarning( $deprecated, $name, $expected ) {
		$module = new ResourceLoaderTestModule( [ 'deprecated' => $deprecated ] );
		$module->setName( $name );
		$this->assertSame( $expected, $module->getDeprecationWarning() );

		$this->hideDeprecated( 'MediaWiki\ResourceLoader\Module::getDeprecationInformation' );
		$info = $module->getDeprecationInformation( $this->getResourceLoaderContext() );
		if ( !$expected ) {
			$this->assertSame( '', $info );
		} else {
			$this->assertSame( 'mw.log.warn(' . json_encode( $expected ) . ');', $info );
		}
	}

}
PK       ! %Gm  m  &  ResourceLoader/OOUIImageModuleTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\StaticHookRegistry;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\ResourceLoader\OOUIImageModule;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use SkinFactory;

/**
 * @group ResourceLoader
 * @covers \MediaWiki\ResourceLoader\OOUIImageModule
 */
class OOUIImageModuleTest extends ResourceLoaderTestCase {
	use DummyServicesTrait;

	public function testNonDefaultSkin() {
		$module = new OOUIImageModule( [
			'class' => OOUIImageModule::class,
			'name' => 'icons',
			'rootPath' => 'tests/phpunit/data/resourceloader/oouiimagemodule',
		] );
		$module->setHookContainer( new HookContainer(
			new StaticHookRegistry(),
			$this->getServiceContainer()->getObjectFactory()
		) );

		// Pretend that 'fakemonobook' is a real skin using the Apex theme
		$skinFactory = new SkinFactory( $this->getDummyObjectFactory(), [] );
		$skinFactory->register(
			'fakemonobook',
			'FakeMonoBook',
			[]
		);
		$this->setService( 'SkinFactory', $skinFactory );

		$reset = ExtensionRegistry::getInstance()->setAttributeForTest(
			'SkinOOUIThemes', [ 'fakemonobook' => 'Apex' ]
		);

		$styles = $module->getStyles( $this->getResourceLoaderContext( [ 'skin' => 'fakemonobook' ] ) );
		$this->assertMatchesRegularExpression(
			'/stu-apex/',
			$styles['all'],
			'Generated styles use the non-default image'
		);

		$styles = $module->getStyles( $this->getResourceLoaderContext() );
		$this->assertMatchesRegularExpression(
			'/stu-wikimediaui/',
			$styles['all'],
			'Generated styles use the default image'
		);
	}

}
PK       ! k?  ?  !  ResourceLoader/SkinModuleTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use Generator;
use InvalidArgumentException;
use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\ResourceLoader\Context;
use MediaWiki\ResourceLoader\FilePath;
use MediaWiki\ResourceLoader\SkinModule;
use ReflectionClass;
use Wikimedia\TestingAccessWrapper;

/**
 * @group ResourceLoader
 * @covers \MediaWiki\ResourceLoader\SkinModule
 */
class SkinModuleTest extends ResourceLoaderTestCase {
	public static function provideApplyFeaturesCompatibility() {
		return [
			'Alias for unset target (content-thumbnails)' => [
				[
					'content-thumbnails' => true,
				],
				[
					'content-media' => true,
				],
				true
			],
			'Alias with conflict (content-thumbnails)' => [
				[
					'content-thumbnails' => true,
					'content-media' => false,
				],
				[
					'content-media' => false,
				],
				true
			],
			'Alias that no-ops (legacy)' => [
				[
					'toc' => true,
					'legacy' => true,
				],
				[
					'toc' => true,
				],
				true
			],
			'content-links enables content-links-external if unset' => [
				[
					'content-links' => true,
				],
				[
					'content-links-external' => true,
					'content-links' => true,
				],
				true
			],
			'elements enables content-links if unset' => [
				[
					'elements' => true,
				],
				[
					'elements' => true,
					'content-links' => true,
				],
				true
			],
			'content-links does not change content-links-external if set' => [
				[
					'content-links-external' => false,
					'content-links' => true,
				],
				[
					'content-links-external' => false,
					'content-links' => true,
				],
				true
			],
			'list-form does not add unwanted defaults (aliases)' => [
				[
					'content-links' => true,
					'content-thumbnails' => true,
				],
				[
					'content-links' => true,
					'content-media' => true,
				],
				false
			],
			'list-form does not add unwanted defaults (no aliases)' => [
				[
					'elements' => true,
				],
				[
					'elements' => true,
				],
				false
			],
		];
	}

	/**
	 * @dataProvider provideApplyFeaturesCompatibility
	 */
	public function testApplyFeaturesCompatibility( array $features, array $expected, bool $optInPolicy ) {
		// Test protected method
		$class = TestingAccessWrapper::newFromClass( SkinModule::class );
		$actual = $class->applyFeaturesCompatibility( $features, $optInPolicy );
		$this->assertEquals( $expected, $actual );
	}

	public static function provideGetAvailableLogos() {
		return [
			[
				[
					MainConfigNames::Logos => [],
					MainConfigNames::Logo => '/logo.png',
				],
				[
					'1x' => '/logo.png',
				]
			],
			[
				[
					MainConfigNames::Logos => [
						'svg' => '/logo.svg',
						'2x' => 'logo-2x.png'
					],
					MainConfigNames::Logo => '/logo.png',
				],
				[
					'svg' => '/logo.svg',
					'2x' => 'logo-2x.png',
					'1x' => '/logo.png',
				]
			],
			[
				[
					MainConfigNames::Logos => [
						'wordmark' => [
							'src' => '/logo-wordmark.png',
							'width' => 100,
							'height' => 15,
						],
						'1x' => '/logo.png',
						'svg' => '/logo.svg',
						'2x' => 'logo-2x.png'
					],
				],
				[
					'wordmark' => [
						'src' => '/logo-wordmark.png',
						'width' => 100,
						'height' => 15,
						'style' => 'width: 6.25em; height: 0.9375em;',
					],
					'1x' => '/logo.png',
					'svg' => '/logo.svg',
					'2x' => 'logo-2x.png',
				]
			]
		];
	}

	public static function provideGetLogoStyles() {
		return [
			[
				'features' => [],
				'logo' => '/logo.png',
				'expected' => [
					'all' => [ '.mw-wiki-logo { background-image: url(/logo.png); }' ],
				],
			],
			[
				'features' => [
					'screen' => '.example {}',
				],
				'logo' => '/logo.png',
				'expected' => [
					'screen' => '.example {}',
					'all' => [ '.mw-wiki-logo { background-image: url(/logo.png); }' ],
				],
			],
			[
				'features' => [],
				'logo' => [
					'1x' => '/logo.png',
					'1.5x' => '/logo@1.5x.png',
					'2x' => '/logo@2x.png',
				],
				'expected' => [
					'all' => [
						'.mw-wiki-logo { background-image: url(/logo.png); }',
					],
					'(-webkit-min-device-pixel-ratio: 1.5), (min-resolution: 1.5dppx), (min-resolution: 144dpi)' => [
						'.mw-wiki-logo { background-image: url(/logo@1.5x.png);background-size: 135px auto; }',
					],
					'(-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx), (min-resolution: 192dpi)' => [
						'.mw-wiki-logo { background-image: url(/logo@2x.png);background-size: 135px auto; }',
					],
				],
			],
			[
				'features' => [],
				'logo' => [
					'1x' => '/logo.png',
					'svg' => '/logo.svg',
				],
				'expected' => [
					'all' => [
						'.mw-wiki-logo { background-image: url(/logo.svg); }',
						'.mw-wiki-logo { background-size: 135px auto; }',
					],
				],
			],
		];
		// phpcs:enable
	}

	/**
	 * @dataProvider provideGetLogoStyles
	 */
	public function testGenerateAndAppendLogoStyles( $features, $logo, $expected ) {
		$module = $this->getMockBuilder( SkinModule::class )
			->onlyMethods( [ 'getLogoData' ] )
			->getMock();
		$module->expects( $this->atLeast( 1 ) )->method( 'getLogoData' )
			->willReturn( $logo );
		$module->setConfig( new HashConfig( [
			MainConfigNames::ParserEnableLegacyMediaDOM => false,
		] + self::getSettings() ) );

		$ctx = $this->createMock( Context::class );

		$this->assertEquals(
			$expected,
			$module->generateAndAppendLogoStyles( $features, $ctx )
		);
	}

	/**
	 * @dataProvider provideGetAvailableLogos
	 */
	public function testGetAvailableLogos( $config, $expected ) {
		$logos = SkinModule::getAvailableLogos( new HashConfig( $config ) );
		$this->assertSame( $expected, $logos );
	}

	public function testGetAvailableLogosRuntimeException() {
		$logos = SkinModule::getAvailableLogos( new HashConfig( [
			MainConfigNames::Logo => false,
			MainConfigNames::Logos => false,
		] ) );
		$this->assertSame( [], $logos );
	}

	public function testIsKnownEmpty() {
		$module = new SkinModule();
		$ctx = $this->createMock( Context::class );

		$this->assertFalse( $module->isKnownEmpty( $ctx ) );
	}

	/**
	 * @dataProvider provideGetLogoData
	 */
	public function testGetLogoData( $config, $expected ) {
		// Allow testing of protected method
		$module = TestingAccessWrapper::newFromObject( new SkinModule() );

		$this->assertEquals(
			$expected,
			$module->getLogoData( new HashConfig( $config ) )
		);
	}

	public static function provideGetLogoData() {
		return [
			'wordmark' => [
				'config' => [
					MainConfigNames::BaseDirectory => MW_INSTALL_PATH,
					MainConfigNames::ResourceBasePath => '/w',
					MainConfigNames::Logos => [
						'1x' => '/img/default.png',
						'wordmark' => [
							'src' => '/img/wordmark.png',
							'width' => 120,
							'height' => 20,
						],
					],
				],
				'expected' => '/img/default.png',
			],
			'simple' => [
				'config' => [
					MainConfigNames::BaseDirectory => MW_INSTALL_PATH,
					MainConfigNames::ResourceBasePath => '/w',
					MainConfigNames::Logos => [
						'1x' => '/img/default.png',
					],
				],
				'expected' => '/img/default.png',
			],
			'default and 2x' => [
				'config' => [
					MainConfigNames::BaseDirectory => MW_INSTALL_PATH,
					MainConfigNames::ResourceBasePath => '/w',
					MainConfigNames::Logos => [
						'1x' => '/img/default.png',
						'2x' => '/img/two-x.png',
					],
				],
				'expected' => [
					'1x' => '/img/default.png',
					'2x' => '/img/two-x.png',
				],
			],
			'default and all HiDPIs' => [
				'config' => [
					MainConfigNames::BaseDirectory => MW_INSTALL_PATH,
					MainConfigNames::ResourceBasePath => '/w',
					MainConfigNames::Logos => [
						'1x' => '/img/default.png',
						'1.5x' => '/img/one-point-five.png',
						'2x' => '/img/two-x.png',
					],
				],
				'expected' => [
					'1x' => '/img/default.png',
					'1.5x' => '/img/one-point-five.png',
					'2x' => '/img/two-x.png',
				],
			],
			'default and SVG' => [
				'config' => [
					MainConfigNames::BaseDirectory => MW_INSTALL_PATH,
					MainConfigNames::ResourceBasePath => '/w',
					MainConfigNames::Logos => [
						'1x' => '/img/default.png',
						'svg' => '/img/vector.svg',
					],
				],
				'expected' => [
					'1x' => '/img/default.png',
					'svg' => '/img/vector.svg',
				],
			],
			'everything' => [
				'config' => [
					MainConfigNames::BaseDirectory => MW_INSTALL_PATH,
					MainConfigNames::ResourceBasePath => '/w',
					MainConfigNames::Logos => [
						'1x' => '/img/default.png',
						'1.5x' => '/img/one-point-five.png',
						'2x' => '/img/two-x.png',
						'svg' => '/img/vector.svg',
					],
				],
				'expected' => [
					'1x' => '/img/default.png',
					'svg' => '/img/vector.svg',
				],
			],
			'versioned url' => [
				'config' => [
					MainConfigNames::BaseDirectory => dirname( dirname( __DIR__ ) ) . '/data/media',
					MainConfigNames::ResourceBasePath => '/w',
					MainConfigNames::UploadPath => '/w/images',
					MainConfigNames::Logos => [
						'1x' => '/w/test.jpg',
					],
				],
				'expected' => '/w/test.jpg?edcf2',
			],
		];
	}

	/**
	 * @dataProvider providePreloadLinks
	 */
	public function testPreloadLinkHeaders( $config, $lang, $result ) {
		$ctx = $this->createMock( Context::class );
		$ctx->method( 'getLanguage' )->willReturn( $lang );
		$module = new SkinModule();
		$module->setConfig( new HashConfig( $config + [
			MainConfigNames::BaseDirectory => '/dummy',
			MainConfigNames::ResourceBasePath => '/w',
			MainConfigNames::Logo => false,
		] + self::getSettings() ) );

		$this->assertEquals( [ $result ], $module->getHeaders( $ctx ) );
	}

	public static function providePreloadLinks() {
		return [
			[
				[
					MainConfigNames::Logos => [
						'1x' => '/img/default.png',
						'1.5x' => '/img/one-point-five.png',
						'2x' => '/img/two-x.png',
					],
				],
				'en',
				'Link: </img/default.png>;rel=preload;as=image;media=' .
				'not all and (min-resolution: 1.5dppx),' .
				'</img/one-point-five.png>;rel=preload;as=image;media=' .
				'(min-resolution: 1.5dppx) and (max-resolution: 1.999999dppx),' .
				'</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
			],
			[
				[
					MainConfigNames::Logos => [ '1x' => '/img/default.png' ],
				],
				'en',
				'Link: </img/default.png>;rel=preload;as=image'
			],
			[
				[
					MainConfigNames::Logos => [
						'1x' => '/img/default.png',
						'2x' => '/img/two-x.png',
					],
				],
				'en',
				'Link: </img/default.png>;rel=preload;as=image;media=' .
				'not all and (min-resolution: 2dppx),' .
				'</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
			],
			[
				[
					MainConfigNames::Logos => [
						'1x' => '/img/default.png',
						'svg' => '/img/vector.svg',
					],
				],
				'en',
				'Link: </img/vector.svg>;rel=preload;as=image'

			],
			[
				[
					MainConfigNames::BaseDirectory => dirname( dirname( __DIR__ ) ) . '/data/media',
					MainConfigNames::Logos => [ '1x' => '/w/test.jpg' ],
					MainConfigNames::UploadPath => '/w/images',
				],
				'en',
				'Link: </w/test.jpg?edcf2>;rel=preload;as=image',
			],
			[
				[
					MainConfigNames::Logos => [
						'1x' => '/img/default.png',
						'1.5x' => '/img/one-point-five.png',
						'2x' => '/img/two-x.png',
						'variants' => [
							'zh-hans' => [
								'1x' => '/img/default-zh-hans.png',
								'1.5x' => '/img/one-point-five-zh-hans.png',
							]
						]
					],
				],
				'zh-hans',
				'Link: </img/default-zh-hans.png>;rel=preload;as=image;media=' .
				'not all and (min-resolution: 1.5dppx),' .
				'</img/one-point-five-zh-hans.png>;rel=preload;as=image;media=' .
				'(min-resolution: 1.5dppx) and (max-resolution: 1.999999dppx),' .
				'</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
			],
		];
	}

	public function testNoPreloadLogos() {
		$module = new SkinModule( [ 'features' => [ 'logo' => false ] ] );
		$context =
			$this->createMock( Context::class );
		$preloadLinks = $module->getPreloadLinks( $context );
		$this->assertArrayEquals( [], $preloadLinks );
	}

	public function testPreloadLogos() {
		$module = new SkinModule();
		$module->setConfig( self::getMinimalConfig() );
		$context = $this->createMock( Context::class );

		$preloadLinks = $module->getPreloadLinks( $context );
		$this->assertNotSameSize( [], $preloadLinks );
	}

	/**
	 * Covers SkinModule::FEATURE_FILES, but not annotatable.
	 *
	 * @dataProvider provideFeatureFiles
	 * @param string $file
	 */
	public function testFeatureFilesExist( string $file ): void {
		$this->assertFileExists( $file );
	}

	public static function provideFeatureFiles(): Generator {
		global $IP;

		$featureFiles = ( new ReflectionClass( SkinModule::class ) )
			->getConstant( 'FEATURE_FILES' );

		foreach ( $featureFiles as $feature => $files ) {
			foreach ( $files as $media => $stylesheets ) {
				foreach ( $stylesheets as $stylesheet ) {
					yield "$feature: $media: $stylesheet" => [ "$IP/$stylesheet" ];
				}
			}
		}
	}

	public static function getSkinFeaturePath( $feature, $mediaType ) {
		global $IP;
		$featureFiles = ( new ReflectionClass( SkinModule::class ) )->getConstant( 'FEATURE_FILES' );
		return new FilePath( $featureFiles[ $feature ][ $mediaType ][ 0 ], $IP, '/w' );
	}

	public static function provideGetFeatureFilePathsOrder() {
		return [
			[
				'The "logo" skin-feature is loaded when the "features" key is absent',
				[],
				[
					'all' => [ self::getSkinFeaturePath( 'logo', 'all' ) ],
					'print' => [ self::getSkinFeaturePath( 'logo', 'print' ) ],
				],
			],
			[
				'The "normalize" skin-feature is always output first',
				[
					'features' => [ 'elements', 'normalize' ],
				],
				[
					'all' => [ self::getSkinFeaturePath( 'normalize', 'all' ) ],
					'screen' => [ self::getSkinFeaturePath( 'elements', 'screen' ) ],
					'print' => [ self::getSkinFeaturePath( 'elements', 'print' ) ],
				],
			],
			[
				'Empty media query blocks are not included in output',
				[
					'features' => [
						'accessibility' => false,
						'content-body' => false,
						'interface-core' => false,
						'toc' => false
					],
				],
				[],
			],
			[
				'Empty "features" key outputs default skin-features',
				[
					'features' => [],
				],
				[
					'all' => [
						self::getSkinFeaturePath( 'accessibility', 'all' ),
						self::getSkinFeaturePath( 'toc', 'all' )
					],
					'screen' => [
						self::getSkinFeaturePath( 'content-body', 'screen' ),
						self::getSkinFeaturePath( 'interface-core', 'screen' ),
						self::getSkinFeaturePath( 'toc', 'screen' ),
					],
					'print' => [
						self::getSkinFeaturePath( 'content-body', 'print' ),
						self::getSkinFeaturePath( 'interface-core', 'print' ),
						self::getSkinFeaturePath( 'toc', 'print' )
					]
				],
			],
			[
				'skin-features are output in the order defined in SkinModule.php',
				[
					'features' => [ 'interface-message-box', 'normalize', 'accessibility' ],
				],
				[
					'all' => [
						self::getSkinFeaturePath( 'accessibility', 'all' ),
						self::getSkinFeaturePath( 'normalize', 'all' ),
						self::getSkinFeaturePath( 'interface-message-box', 'all' )
					],
				]
			]
		];
	}

	/**
	 * @dataProvider provideGetFeatureFilePathsOrder
	 * @param string $msg to show for debugging
	 * @param array $skinModuleConfig
	 * @param array $expectedStyleOrder
	 */
	public function testGetFeatureFilePathsOrder(
		$msg, $skinModuleConfig, $expectedStyleOrder
	): void {
		$module = new SkinModule( $skinModuleConfig );
		$module->setConfig( self::getMinimalConfig() );

		$actual = $module->getFeatureFilePaths();

		$this->assertEquals(
			array_values( $expectedStyleOrder ),
			array_values( $actual )
		);
	}

	public static function provideInvalidFeatures() {
		yield 'listed unknown' => [
			[ 'logo', 'unknown' ],
		];

		yield 'enabled unknown' => [
			[
				'logo' => true,
				'toc' => false,
				'unknown' => true,
			],
		];

		yield 'disabled unknown' => [
			[
				'logo' => true,
				'toc' => false,
				'unknown' => false,
			],
		];
	}

	/**
	 * @dataProvider provideInvalidFeatures
	 */
	public function testConstructInvalidFeatures( array $features ) {
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( "Feature 'unknown' is not recognised" );
		$module = new SkinModule( [
			'features' => $features,
		] );
	}
}
PK       ! Z_    (  ResourceLoader/DerivativeContextTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use MediaWiki\Request\FauxRequest;
use MediaWiki\ResourceLoader\Context;
use MediaWiki\ResourceLoader\DerivativeContext;
use MediaWiki\ResourceLoader\ResourceLoader;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use MediaWikiIntegrationTestCase;

/**
 * @group ResourceLoader
 * @covers \MediaWiki\ResourceLoader\DerivativeContext
 */
class DerivativeContextTest extends MediaWikiIntegrationTestCase {

	protected static function makeContext() {
		$request = new FauxRequest( [
				'lang' => 'qqx',
				'modules' => 'test.default',
				'only' => 'scripts',
				'skin' => 'fallback',
				'target' => 'test',
		] );
		return new Context(
			new ResourceLoader( ResourceLoaderTestCase::getMinimalConfig() ),
			$request
		);
	}

	public function testChangeModules() {
		$derived = new DerivativeContext( self::makeContext() );
		$this->assertSame( [ 'test.default' ], $derived->getModules(), 'inherit from parent' );

		$derived->setModules( [ 'test.override' ] );
		$this->assertSame( [ 'test.override' ], $derived->getModules() );
	}

	public function testChangeLanguageAndDirection() {
		$derived = new DerivativeContext( self::makeContext() );
		$this->assertSame( 'qqx', $derived->getLanguage(), 'inherit from parent' );
		$this->assertSame( 'ltr', $derived->getDirection(), 'inherit from parent' );

		$derived->setLanguage( 'nl' );
		$this->assertSame( 'nl', $derived->getLanguage() );
		$this->assertSame( 'ltr', $derived->getDirection() );

		// Changing the language must clear cache of computed direction
		$derived->setLanguage( 'he' );
		$this->assertSame( 'rtl', $derived->getDirection() );
		$this->assertSame( 'he', $derived->getLanguage() );

		// Overriding the direction explicitly is allowed
		$derived->setDirection( 'ltr' );
		$this->assertSame( 'ltr', $derived->getDirection() );
		$this->assertSame( 'he', $derived->getLanguage() );
	}

	public function testChangeSkin() {
		$derived = new DerivativeContext( self::makeContext() );
		$this->assertSame( 'fallback', $derived->getSkin(), 'inherit from parent' );

		$derived->setSkin( 'myskin' );
		$this->assertSame( 'myskin', $derived->getSkin() );
	}

	public function testChangeUser() {
		$derived = new DerivativeContext( self::makeContext() );
		$this->assertNull( $derived->getUser(), 'inherit from parent' );

		$derived->setUser( 'MyUser' );
		$this->assertSame( 'MyUser', $derived->getUser() );
	}

	public function testChangeUserObj() {
		$user = $this->createMock( User::class );
		$userIdentity = $this->createMock( UserIdentity::class );
		$parent = $this->createMock( Context::class );
		$parent
			->expects( $this->once() )
			->method( 'getUserObj' )
			->willReturn( $user );
		$parent
			->expects( $this->once() )
			->method( 'getUserIdentity' )
			->willReturn( $userIdentity );

		$derived = new DerivativeContext( $parent );
		$this->assertSame( $derived->getUserObj(), $user, 'inherit from parent' );
		$this->assertSame( $derived->getUserIdentity(), $userIdentity, 'inherit from parent' );

		$derived->setUser( null );
		$this->assertNotSame( $derived->getUserObj(), $user, 'different' );
		$this->assertNull( $derived->getUserIdentity(), 'no user identity' );
	}

	public function testChangeDebug() {
		$derived = new DerivativeContext( self::makeContext() );
		$this->assertSame( 0, $derived->getDebug(), 'inherit from parent' );

		$derived->setDebug( 1 );
		$this->assertSame( 1, $derived->getDebug() );
	}

	public function testChangeOnly() {
		$derived = new DerivativeContext( self::makeContext() );
		$this->assertSame( 'scripts', $derived->getOnly(), 'inherit from parent' );

		$derived->setOnly( 'styles' );
		$this->assertSame( 'styles', $derived->getOnly() );

		$derived->setOnly( null );
		$this->assertNull( $derived->getOnly() );
	}

	public function testChangeVersion() {
		$derived = new DerivativeContext( self::makeContext() );
		$this->assertNull( $derived->getVersion() );

		$derived->setVersion( 'hw1' );
		$this->assertSame( 'hw1', $derived->getVersion() );
	}

	public function testChangeRaw() {
		$derived = new DerivativeContext( self::makeContext() );
		$this->assertFalse( $derived->getRaw(), 'inherit from parent' );

		$derived->setRaw( true );
		$this->assertTrue( $derived->getRaw() );
	}

	public function testChangeHash() {
		$derived = new DerivativeContext( self::makeContext() );
		$this->assertSame( 'qqx|fallback|0||scripts|||||', $derived->getHash(), 'inherit' );

		$derived->setLanguage( 'nl' );
		$derived->setUser( 'Example' );
		// Assert that subclass is able to clear parent class "hash" member
		$this->assertSame( 'nl|fallback|0|Example|scripts|||||', $derived->getHash() );
	}

	public function testChangeContentOverrides() {
		$derived = new DerivativeContext( self::makeContext() );
		$this->assertNull( $derived->getContentOverrideCallback(), 'default' );

		$override = static function ( Title $t ) {
			return null;
		};
		$derived->setContentOverrideCallback( $override );
		$this->assertSame( $override, $derived->getContentOverrideCallback(), 'changed' );

		$derived2 = new DerivativeContext( $derived );
		$this->assertSame(
			$override,
			$derived2->getContentOverrideCallback(),
			'change via a second derivative layer'
		);
	}

	public function testImmutableAccessors() {
		$context = self::makeContext();
		$derived = new DerivativeContext( $context );
		$this->assertSame( $derived->getRequest(), $context->getRequest() );
		$this->assertSame( $derived->getResourceLoader(), $context->getResourceLoader() );
	}
}
PK       ! (a?  ?  "  ResourceLoader/CodexModuleTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use InvalidArgumentException;
use MediaWiki\MainConfigNames;
use MediaWiki\ResourceLoader\CodexModule;
use RuntimeException;
use Wikimedia\TestingAccessWrapper;

/**
 * @group ResourceLoader
 * @covers \MediaWiki\ResourceLoader\CodexModule
 */
class CodexModuleTest extends ResourceLoaderTestCase {

	public const FIXTURE_PATH = 'tests/phpunit/data/resourceloader/codex';
	public const DEVMODE_FIXTURE_PATH = 'tests/phpunit/data/resourceloader/codex-devmode';

	public static function provideModuleConfig() {
		yield 'Codex subset' => [
				[
					'codexComponents' => [ 'CdxButton', 'CdxMessage', 'useModelWrapper' ],
					'codexStyleOnly' => false,
					'codexScriptOnly' => false
				],
				[
					'packageFiles' => [
						'codex.js',
						'_codex/constants.js',
						'_codex/useSlotContents2.js',
						'_codex/useWarnOnce.js',
						'_codex/useIconOnlyButton.js',
						'_codex/_plugin-vue_export-helper.js',
						'_codex/CdxButton.js',
						'_codex/useComputedDirection.js',
						'_codex/useComputedLanguage.js',
						'_codex/Icon.js',
						'_codex/CdxMessage.js',
						'_codex/useModelWrapper.js'
					],
					'styles' => [ 'modules/CdxButton.css', 'modules/CdxIcon.css', 'modules/CdxMessage.css' ]
				]
		];
		yield 'Codex subset, style only' => [
			[
				'codexComponents' => [ 'CdxButton', 'CdxMessage' ],
				'codexStyleOnly' => true,
				'codexScriptOnly' => false
			],
			[
				'packageFiles' => [],
				'styles' => [ 'modules/CdxButton.css', 'modules/CdxIcon.css', 'modules/CdxMessage.css' ]
			]
		];
		yield 'Codex subset, script only' => [
			[
				'codexComponents' => [ 'CdxButton', 'CdxMessage', 'useModelWrapper' ],
				'codexStyleOnly' => false,
				'codexScriptOnly' => true
			],
			[
				'packageFiles' => [
					'codex.js',
					'_codex/constants.js',
					'_codex/useSlotContents2.js',
					'_codex/useWarnOnce.js',
					'_codex/useIconOnlyButton.js',
					'_codex/_plugin-vue_export-helper.js',
					'_codex/CdxButton.js',
					'_codex/useComputedDirection.js',
					'_codex/useComputedLanguage.js',
					'_codex/Icon.js',
					'_codex/CdxMessage.js',
					'_codex/useModelWrapper.js'
				],
				'styles' => []
			]
		];
		yield 'Exception thrown when a chunk is requested' => [
			[
				'codexComponents' => [ 'CdxButton', 'buttonHelpers' ],
			],
			[
				'exception' => [
					'class' => InvalidArgumentException::class,
					'message' => '"buttonHelpers" is not an export of Codex and cannot be included in the "codexComponents" array.'
				]
			]
		];
		yield 'Exception thrown when a nonexistent file is requested' => [
			[
				'codexComponents' => [ 'CdxButton', 'blahblahidontexistblah' ],
			],
			[
				'exception' => [
					'class' => InvalidArgumentException::class,
					'message' => '"blahblahidontexistblah" is not an export of Codex and cannot be included in the "codexComponents" array.'
				]
			]
		];
		yield 'Exception thrown when codexComponents is empty in the module definition' => [
			[
				'codexComponents' => []
			],
			[
				'exception' => [
					'class' => InvalidArgumentException::class,
					'message' => "All 'codexComponents' properties in your module definition file " .
					'must either be omitted or be an array with at least one component name'
				]
			]
		];
		yield 'Exception thrown when codexComponents is not an array in the module definition' => [
			[
				'codexComponents' => ''
			],
			[
				'exception' => [
					'class' => InvalidArgumentException::class,
					'message' => "All 'codexComponents' properties in your module definition file " .
					'must either be omitted or be an array with at least one component name'
				]
			]
		];

		yield 'Exception thrown when the @wikimedia/codex module is required' => [
			[
				'codexComponents' => [ 'CdxButton', 'buttonHelpers' ],
				'dependencies' => [ '@wikimedia/codex' ]
			],
			[
				'exception' => [
					'class' => InvalidArgumentException::class,
					'message' => 'ResourceLoader modules using the CodexModule class cannot ' .
						"list the '@wikimedia/codex' module as a dependency. " .
						"Instead, use 'codexComponents' to require a subset of components."
				]
			]
		];

		yield 'Full library' => [
			[
				'codexFullLibrary' => true
			],
			[
				'packageFiles' => [
					'codex.js'
				],
				'styles' => [
					'codex.style.css'
				]
			]
		];

		yield 'Full library, script only' => [
			[
				'codexFullLibrary' => true,
				'codexScriptOnly' => true
			],
			[
				'packageFiles' => [
					'codex.js'
				],
				'styles' => []
			]
		];

		yield 'Full library, style only' => [
			[
				'codexFullLibrary' => true,
				'codexStyleOnly' => true
			],
			[
				'packageFiles' => [],
				'styles' => [
					'codex.style.css'
				]
			]
		];
	}

	/**
	 * @dataProvider provideModuleConfig
	 */
	public function testCodexSubset( $moduleDefinition, $expected ) {
		if ( isset( $expected['exception'] ) ) {
			$this->expectException( $expected['exception']['class'] );
			$this->expectExceptionMessage( $expected['exception']['message'] );
		}

		$testModule = new class( $moduleDefinition ) extends CodexModule {
			public const CODEX_DEFAULT_LIBRARY_DIR = CodexModuleTest::FIXTURE_PATH;
		};

		$context = $this->getResourceLoaderContext();
		$config = $context->getResourceLoader()->getConfig();
		$testModule->setConfig( $config );

		$packageFiles = $testModule->getPackageFiles( $context );
		$styleFiles = $testModule->getStyleFiles( $context );

		// Style-only module will not have any packageFiles.
		$packageFilenames = isset( $packageFiles ) ? array_keys( $packageFiles[ 'files' ] ) : [];
		$this->assertEquals( $expected[ 'packageFiles' ] ?? [], $packageFilenames, 'Correct packageFiles added' );

		// Script-only module will not have any styleFiles.
		$styleFilenames = [];
		if ( count( $styleFiles ) > 0 ) {
			$styleFilenames = array_map( static function ( $filepath ) use ( $testModule ) {
				return str_replace( $testModule::CODEX_DEFAULT_LIBRARY_DIR . '/', '', $filepath->getPath() );
			}, $styleFiles[ 'all' ] );
		}
		$this->assertEquals( $expected[ 'styles' ] ?? [], $styleFilenames, 'Correct styleFiles added' );
	}

	public function testMissingCodexComponentsDefinition() {
		$moduleDefinition = [
			'codexComponents' => [ 'CdxButton', 'CdxMessage' ]
		];

		$testModule = new class( $moduleDefinition ) extends CodexModule {
			public const CODEX_DEFAULT_LIBRARY_DIR = CodexModuleTest::FIXTURE_PATH;
		};

		$context = $this->getResourceLoaderContext();
		$config = $context->getResourceLoader()->getConfig();
		$testModule->setConfig( $config );

		$packageFiles = $testModule->getPackageFiles( $context );

		$codexPackageFileContent = $packageFiles[ 'files' ][ 'codex.js' ][ 'content' ];
		$expectedProxiedExports = '{"CdxButton":require( "./_codex/CdxButton.js" ),'
			. '"CdxMessage":require( "./_codex/CdxMessage.js" )}';

		// Components defined in the 'codexComponents' array should be proxied in the codex.js
		// package file so that missing components will throw a custom error when required.
		// By asserting what components are proxied, we are indirectly asserting that missing
		// components would throw an error when required.
		$this->assertStringContainsString( $expectedProxiedExports, $codexPackageFileContent );
	}

	public function testGetManifestFile() {
		$moduleDefinition = [ 'codexComponents' => [ 'CdxButton', 'CdxMessage' ] ];
		$testModule = new class( $moduleDefinition ) extends CodexModule {
			public const CODEX_DEFAULT_LIBRARY_DIR = CodexModuleTest::FIXTURE_PATH;
		};

		$context = $this->getResourceLoaderContext();
		$config = $context->getResourceLoader()->getConfig();
		$testModule->setConfig( $config );
		$testWrapper = TestingAccessWrapper::newFromObject( $testModule );

		// By default, look for a manifest file called "manifest.json"
		$this->assertEquals(
			MW_INSTALL_PATH . '/' . self::FIXTURE_PATH . '/modules/manifest.json',
			$testWrapper->getManifestFilePath( $context )
		);
	}

	public function testGetMessages() {
		$messageKeysFromFile = json_decode( file_get_contents(
			MW_INSTALL_PATH . '/' . self::FIXTURE_PATH . '/messageKeys.json'
		) );

		$moduleDefinition = [ 'codexComponents' => [ 'CdxButton', 'CdxMessage' ] ];
		$testModule = new class( $moduleDefinition ) extends CodexModule {
			public const CODEX_DEFAULT_LIBRARY_DIR = CodexModuleTest::FIXTURE_PATH;
		};
		$context = $this->getResourceLoaderContext();
		$config = $context->getResourceLoader()->getConfig();
		$testModule->setConfig( $config );
		$this->assertEquals(
			$messageKeysFromFile,
			$testModule->getMessages(),
			'i18n messages from messageKeys.json are added'
		);

		$moduleDefinition = [
			'codexComponents' => [ 'CdxButton', 'CdxMessage' ],
			'messages' => [ 'monday', 'tuesday' ]
		];
		$testModule = new class( $moduleDefinition ) extends CodexModule {
			public const CODEX_DEFAULT_LIBRARY_DIR = CodexModuleTest::FIXTURE_PATH;
		};
		$context = $this->getResourceLoaderContext();
		$config = $context->getResourceLoader()->getConfig();
		$testModule->setConfig( $config );
		$this->assertEquals(
			array_merge( [ 'monday', 'tuesday' ], $messageKeysFromFile ),
			$testModule->getMessages(),
			'i18n messages from messageKeys.json are in addition to messages in module definition'
		);

		$moduleDefinition = [
			'codexFullLibrary' => true
		];
		$testModule = new class( $moduleDefinition ) extends CodexModule {
			public const CODEX_DEFAULT_LIBRARY_DIR = CodexModuleTest::FIXTURE_PATH;
		};
		$context = $this->getResourceLoaderContext();
		$config = $context->getResourceLoader()->getConfig();
		$testModule->setConfig( $config );
		$this->assertEquals(
			$messageKeysFromFile,
			$testModule->getMessages(),
			'i18n messages are added for full library modules'
		);

		$moduleDefinition = [
			'codexComponents' => [ 'CdxButton', 'CdxMessage' ],
			'codexStyleOnly' => true
		];
		$testModule = new class( $moduleDefinition ) extends CodexModule {
			public const CODEX_DEFAULT_LIBRARY_DIR = CodexModuleTest::FIXTURE_PATH;
		};
		$context = $this->getResourceLoaderContext();
		$config = $context->getResourceLoader()->getConfig();
		$testModule->setConfig( $config );
		$this->assertEquals(
			[],
			$testModule->getMessages(),
			'i18n messages are not added for style-only modules'
		);
	}

	public function testDevMode() {
		$devDir = MW_INSTALL_PATH . '/' . self::DEVMODE_FIXTURE_PATH;
		$this->overrideConfigValues( [
			MainConfigNames::CodexDevelopmentDir => $devDir
		] );

		$moduleDefinition = [ 'codexComponents' => [ 'CdxButton', 'CdxMessage' ] ];
		$testModule = new class( $moduleDefinition ) extends CodexModule {
			public const CODEX_DEFAULT_LIBRARY_DIR = CodexModuleTest::FIXTURE_PATH;
		};

		$context = $this->getResourceLoaderContext();
		$config = $context->getResourceLoader()->getConfig();
		$testModule->setConfig( $config );
		$testWrapper = TestingAccessWrapper::newFromObject( $testModule );

		$this->assertEquals(
			$devDir . '/packages/codex/dist/modules/manifest.json',
			$testWrapper->getManifestFilePath( $context ),
			'Manifest path is based on dev mode path'
		);

		$packageFiles = $testModule->getPackageFiles( $context );
		$this->assertEquals(
			$devDir . '/packages/codex/dist/modules/CdxButton.js',
			$packageFiles[ 'files' ][ '_codex/CdxButton.js' ][ 'filePath' ]->getLocalPath(),
			'Package file paths are based on dev mode path'
		);

		$styleFiles = $testModule->getStyleFiles( $context );
		$this->assertEquals(
			$devDir . '/packages/codex/dist/modules/CdxButton.css',
			$styleFiles[ 'all' ][ 0 ]->getLocalPath(),
			'Style file paths are based on dev mode path'
		);

		$this->assertEquals(
			[ 'cdx-test-message-1', 'cdx-test-message-2' ],
			$testModule->getMessages(),
			'i18n message keys come from messages file in dev mode path'
		);

		$fullLibraryModuleDefinition = [ 'codexFullLibrary' => true ];
		$fullLibraryModule = new class( $fullLibraryModuleDefinition ) extends CodexModule {
			public const CODEX_DEFAULT_LIBRARY_DIR = CodexModuleTest::FIXTURE_PATH;
		};
		$fullLibraryModule->setConfig( $config );

		$packageFiles = $fullLibraryModule->getPackageFiles( $context );
		$this->assertEquals(
			$devDir . '/packages/codex/dist/codex.umd.cjs',
			$packageFiles[ 'files' ][ 'codex.js' ][ 'versionFilePath' ]->getLocalPath(),
			'Full library module script path is based on dev mode path'
		);

		$styleFiles = $fullLibraryModule->getStyleFiles( $context );
		$this->assertEquals(
			$devDir . '/packages/codex/dist/codex.style.css',
			$styleFiles[ 'all' ][ 0 ]->getLocalPath(),
			'Full library module style path is based on dev mode path'
		);
	}

	public function testDevModeException() {
		$badDir = MW_INSTALL_PATH . '/' . self::DEVMODE_FIXTURE_PATH . '/path/that/does/not/exist';
		$this->overrideConfigValues( [
			MainConfigNames::CodexDevelopmentDir => $badDir
		] );

		$this->expectException( RuntimeException::class );
		$this->expectExceptionMessage( 'Could not find Codex development build' );

		$moduleDefinition = [ 'codexComponents' => [ 'CdxButton', 'CdxMessage' ] ];
		$testModule = new class( $moduleDefinition ) extends CodexModule {
			public const CODEX_DEFAULT_LIBRARY_DIR = CodexModuleTest::FIXTURE_PATH;
		};
		$context = $this->getResourceLoaderContext();
		$config = $context->getResourceLoader()->getConfig();
		$testModule->setConfig( $config );

		$testModule->getPackageFiles( $context );
	}

	/**
	 * Test that the manifest data structure is transformed correctly.
	 * This test relies on the fixture manifest data that lives in
	 * tests/phpunit/data/resourceloader/codexModules
	 */
	public function testGetCodexFiles() {
		$moduleDefinition = [ 'codexComponents' => [ 'CdxButton', 'CdxMessage' ] ];
		$testModule = new class( $moduleDefinition ) extends CodexModule {
			public const CODEX_DEFAULT_LIBRARY_DIR = CodexModuleTest::FIXTURE_PATH;
		};

		$context = $this->getResourceLoaderContext();
		$config = $context->getResourceLoader()->getConfig();
		$testModule->setConfig( $config );
		$testWrapper = TestingAccessWrapper::newFromObject( $testModule );
		$codexFiles = $testWrapper->getCodexFiles( $context );

		// The transformed data structure should have a "files" and a "components" array.
		$this->assertIsArray( $codexFiles );
		$this->assertArrayHasKey( 'files', $codexFiles );
		$this->assertArrayHasKey( 'components', $codexFiles );

		// The "components" array should contain keys like "CdxButton"
		// with values like "CdxButton.js" (matching the names in the manifest)
		$this->assertArrayHasKey( 'CdxButton', $codexFiles[ 'components' ] );
		$this->assertEquals( 'CdxButton.js', $codexFiles[ 'components' ][ 'CdxButton' ] );

		// The "files" array should contains keys like "CdxButton.js"
		// Items in this array are themselves arrays with "styles" and "dependencies" keys.
		$this->assertArrayHasKey( 'CdxButton.js', $codexFiles[ 'files' ] );
		$this->assertArrayHasKey( 'styles', $codexFiles[ 'files' ][ 'CdxButton.js' ] );
		$this->assertArrayHasKey( 'dependencies', $codexFiles[ 'files' ][ 'CdxButton.js' ] );
	}

	public function testGetIcons() {
		$context = $this->getResourceLoaderContext();
		$config = $context->getResourceLoader()->getConfig();

		$icons = CodexModule::getIcons( $context, $config, [ 'cdxIconAdd', 'cdxIconNext' ] );
		$this->assertArrayHasKey( 'cdxIconAdd', $icons );
		$this->assertArrayHasKey( 'cdxIconNext', $icons );
		$this->assertArrayNotHasKey( 'cdxIconPrevious', $icons );
	}

	public function testGetIconsInDevMode() {
		$devDir = MW_INSTALL_PATH . '/' . self::DEVMODE_FIXTURE_PATH;
		$this->overrideConfigValues( [
			MainConfigNames::CodexDevelopmentDir => $devDir
		] );

		$context = $this->getResourceLoaderContext();
		$config = $context->getResourceLoader()->getConfig();
		$icons = CodexModule::getIcons( $context, $config, [ 'cdxIconAdd', 'cdxIconNext' ] );
		$this->assertArrayHasKey( 'cdxIconAdd', $icons );
		$this->assertArrayHasKey( 'cdxIconNext', $icons );
		$this->assertArrayNotHasKey( 'cdxIconPrevious', $icons );

		$this->assertEquals( 'test add icon', $icons['cdxIconAdd'] );
		$this->assertEquals( 'test next icon', $icons['cdxIconNext'] );
	}
}
PK       ! )abBY  BY  $  ResourceLoader/StartUpModuleTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use Exception;
use MediaWiki\ResourceLoader\Module;
use MediaWiki\ResourceLoader\StartUpModule;
use Psr\Log\NullLogger;

/**
 * @group ResourceLoader
 * @covers \MediaWiki\ResourceLoader\StartUpModule
 */
class StartUpModuleTest extends ResourceLoaderTestCase {

	protected static function expandPlaceholders( $text ) {
		return strtr( $text, [
			'{blankVer}' => self::BLANK_VERSION
		] );
	}

	public static function provideGetModuleRegistrations() {
		return [
			[ [
				'msg' => 'Empty registry',
				'modules' => [],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php"
});
mw.loader.register([]);'
			] ],
			[ [
				'msg' => 'Basic registry',
				'modules' => [
					'test.blank' => [ 'class' => ResourceLoaderTestModule::class ],
				],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php"
});
mw.loader.register([
    [
        "test.blank",
        ""
    ]
]);',
			] ],
			[ [
				'msg' => 'Optimise the dependency tree (basic case)',
				'modules' => [
					'a' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [ 'b', 'c', 'd' ],
					],
					'b' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [ 'c' ],
					],
					'c' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [],
					],
					'd' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [],
					],
				],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php"
});
mw.loader.register([
    [
        "a",
        "",
        [
            1,
            3
        ]
    ],
    [
        "b",
        "",
        [
            2
        ]
    ],
    [
        "c",
        ""
    ],
    [
        "d",
        ""
    ]
]);',
			] ],
			[ [
				'msg' => 'Optimise the dependency tree (tolerate unknown deps)',
				'modules' => [
					'a' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [ 'b', 'c', 'x' ]
					],
					'b' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [ 'c', 'x' ]
					],
					'c' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => []
					],
				],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php"
});
mw.loader.register([
    [
        "a",
        "",
        [
            1,
            "x"
        ]
    ],
    [
        "b",
        "",
        [
            2,
            "x"
        ]
    ],
    [
        "c",
        ""
    ]
]);',
			] ],
			[ [
				// Regression test for T223402.
				'msg' => 'Optimise the dependency tree (indirect circular dependency)',
				'modules' => [
					'top' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [ 'middle1', 'util' ],
					],
					'middle1' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [ 'middle2', 'util' ],
					],
					'middle2' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [ 'bottom' ],
					],
					'bottom' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [ 'top' ],
					],
					'util' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [],
					],
				],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php"
});
mw.loader.register([
    [
        "top",
        "",
        [
            1,
            4
        ]
    ],
    [
        "middle1",
        "",
        [
            2,
            4
        ]
    ],
    [
        "middle2",
        "",
        [
            3
        ]
    ],
    [
        "bottom",
        "",
        [
            0
        ]
    ],
    [
        "util",
        ""
    ]
]);',
			] ],
			[ [
				// Regression test for T223402.
				'msg' => 'Optimise the dependency tree (direct circular dependency)',
				'modules' => [
					'top' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [ 'util', 'top' ],
					],
					'util' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [],
					],
				],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php"
});
mw.loader.register([
    [
        "top",
        "",
        [
            1,
            0
        ]
    ],
    [
        "util",
        ""
    ]
]);',
			] ],
			[ [
				'msg' => 'Group signature',
				'modules' => [
					'test.blank' => [ 'class' => ResourceLoaderTestModule::class ],
					'test.group.foo' => [
						'class' => ResourceLoaderTestModule::class,
						'group' => 'x-foo',
					],
					'test.group.bar' => [
						'class' => ResourceLoaderTestModule::class,
						'group' => 'x-bar',
					],
				],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php"
});
mw.loader.register([
    [
        "test.blank",
        ""
    ],
    [
        "test.group.foo",
        "",
        [],
        2
    ],
    [
        "test.group.bar",
        "",
        [],
        3
    ]
]);'
			] ],
			[ [
				'msg' => 'Different skin (irrelevant skin modules should not be registered)',
				'modules' => [
					'test.blank' => [ 'class' => ResourceLoaderTestModule::class ],
					'test.skin.fallback' => [
						'class' => ResourceLoaderTestModule::class,
						'skins' => [ 'fallback' ],
					],
					'test.skin.foo' => [
						'class' => ResourceLoaderTestModule::class,
						'skins' => [ 'foo' ],
					],
				],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php"
});
mw.loader.register([
    [
        "test.blank",
        ""
    ],
    [
        "test.skin.fallback",
        ""
    ]
]);'
			] ],
			[ [
				'msg' => 'Safemode disabled (default; register all modules)',
				'modules' => [
					// Default origin: ORIGIN_CORE_SITEWIDE
					'test.blank' => [ 'class' => ResourceLoaderTestModule::class ],
					'test.core-generated' => [
						'class' => ResourceLoaderTestModule::class,
						'origin' => Module::ORIGIN_CORE_INDIVIDUAL
					],
					'test.sitewide' => [
						'class' => ResourceLoaderTestModule::class,
						'origin' => Module::ORIGIN_USER_SITEWIDE
					],
					'test.user' => [
						'class' => ResourceLoaderTestModule::class,
						'origin' => Module::ORIGIN_USER_INDIVIDUAL
					],
				],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php"
});
mw.loader.register([
    [
        "test.blank",
        ""
    ],
    [
        "test.core-generated",
        ""
    ],
    [
        "test.sitewide",
        ""
    ],
    [
        "test.user",
        ""
    ]
]);'
			] ],
			[ [
				'msg' => 'Safemode enabled (filter modules with user/site origin)',
				'extraQuery' => [ 'safemode' => '1' ],
				'modules' => [
					// Default origin: ORIGIN_CORE_SITEWIDE
					'test.blank' => [ 'class' => ResourceLoaderTestModule::class ],
					'test.core-generated' => [
						'class' => ResourceLoaderTestModule::class,
						'origin' => Module::ORIGIN_CORE_INDIVIDUAL
					],
					'test.sitewide' => [
						'class' => ResourceLoaderTestModule::class,
						'origin' => Module::ORIGIN_USER_SITEWIDE
					],
					'test.user' => [
						'class' => ResourceLoaderTestModule::class,
						'origin' => Module::ORIGIN_USER_INDIVIDUAL
					],
				],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php"
});
mw.loader.register([
    [
        "test.blank",
        ""
    ],
    [
        "test.core-generated",
        ""
    ]
]);'
			] ],
			[ [
				'msg' => 'Foreign source',
				'sources' => [
					'example' => [
						'loadScript' => 'http://example.org/w/load.php',
						'apiScript' => 'http://example.org/w/api.php',
					],
				],
				'modules' => [
					'test.blank' => [
						'class' => ResourceLoaderTestModule::class,
						'source' => 'example'
					],
				],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php",
    "example": "http://example.org/w/load.php"
});
mw.loader.register([
    [
        "test.blank",
        "",
        [],
        null,
        "example"
    ]
]);'
			] ],
			[ [
				'msg' => 'Conditional dependency function',
				'modules' => [
					'test.x.core' => [ 'class' => ResourceLoaderTestModule::class ],
					'test.x.polyfill' => [
						'class' => ResourceLoaderTestModule::class,
						'skipFunction' => 'return true;'
					],
					'test.y.polyfill' => [
						'class' => ResourceLoaderTestModule::class,
						'skipFunction' =>
							'return !!(' .
							'    window.JSON &&' .
							'    JSON.parse &&' .
							'    JSON.stringify' .
							');'
					],
					'test.z.foo' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [
							'test.x.core',
							'test.x.polyfill',
							'test.y.polyfill',
						],
					],
				],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php"
});
mw.loader.register([
    [
        "test.x.core",
        ""
    ],
    [
        "test.x.polyfill",
        "",
        [],
        null,
        null,
        "return true;"
    ],
    [
        "test.y.polyfill",
        "",
        [],
        null,
        null,
        "return !!(    window.JSON \u0026\u0026    JSON.parse \u0026\u0026    JSON.stringify);"
    ],
    [
        "test.z.foo",
        "",
        [
            0,
            1,
            2
        ]
    ]
]);',
			] ],
			[ [
				'msg' => 'ES6-only module',
				'modules' => [
					'test.es6' => [
						'class' => ResourceLoaderTestModule::class,
					],
				],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php"
});
mw.loader.register([
    [
        "test.es6",
        ""
    ]
]);',
			] ],
			[ [
				'msg' => 'noscript group omitted (T291735)',
				'modules' => [
					'test.not-noscript' => [
						'class' => ResourceLoaderTestModule::class,
					],
					'test.noscript' => [
						'class' => ResourceLoaderTestModule::class,
						'group' => 'noscript',
					],
					'test.also-noscript' => [
						'class' => ResourceLoaderTestModule::class,
						'group' => 'noscript',
					],
				],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php"
});
mw.loader.register([
    [
        "test.not-noscript",
        ""
    ]
]);',
			] ],
			[ [
				// This may seem like an edge case, but a plain MediaWiki core install
				// with a few extensions installed is likely far more complex than this
				// even, not to mention an install like Wikipedia.
				// TODO: Make this even more realistic.
				'msg' => 'Advanced (everything combined)',
				'sources' => [
					'example' => [
						'loadScript' => 'http://example.org/w/load.php',
						'apiScript' => 'http://example.org/w/api.php',
					],
				],
				'modules' => [
					'test.blank' => [ 'class' => ResourceLoaderTestModule::class ],
					'test.x.core' => [ 'class' => ResourceLoaderTestModule::class ],
					'test.x.util' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [
							'test.x.core',
						],
					],
					'test.x.foo' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [
							'test.x.core',
						],
					],
					'test.x.bar' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [
							'test.x.core',
							'test.x.util',
						],
					],
					'test.x.quux' => [
						'class' => ResourceLoaderTestModule::class,
						'dependencies' => [
							'test.x.foo',
							'test.x.bar',
							'test.x.util',
							'test.x.unknown',
						],
					],
					'test.group.foo.1' => [
						'class' => ResourceLoaderTestModule::class,
						'group' => 'x-foo',
					],
					'test.group.foo.2' => [
						'class' => ResourceLoaderTestModule::class,
						'group' => 'x-foo',
					],
					'test.group.bar.1' => [
						'class' => ResourceLoaderTestModule::class,
						'group' => 'x-bar',
					],
					'test.group.bar.2' => [
						'class' => ResourceLoaderTestModule::class,
						'group' => 'x-bar',
						'source' => 'example',
					],
					'test.es6' => [
						'class' => ResourceLoaderTestModule::class,
					]
				],
				'out' => '
mw.loader.addSource({
    "local": "/w/load.php",
    "example": "http://example.org/w/load.php"
});
mw.loader.register([
    [
        "test.blank",
        ""
    ],
    [
        "test.x.core",
        ""
    ],
    [
        "test.x.util",
        "",
        [
            1
        ]
    ],
    [
        "test.x.foo",
        "",
        [
            1
        ]
    ],
    [
        "test.x.bar",
        "",
        [
            2
        ]
    ],
    [
        "test.x.quux",
        "",
        [
            3,
            4,
            "test.x.unknown"
        ]
    ],
    [
        "test.group.foo.1",
        "",
        [],
        2
    ],
    [
        "test.group.foo.2",
        "",
        [],
        2
    ],
    [
        "test.group.bar.1",
        "",
        [],
        3
    ],
    [
        "test.group.bar.2",
        "",
        [],
        3,
        "example"
    ],
    [
        "test.es6",
        ""
    ]
]);'
			] ],
		];
	}

	/**
	 * @dataProvider provideGetModuleRegistrations
	 */
	public function testGetModuleRegistrations( $case ) {
		$this->clearHook( 'ResourceLoaderModifyEmbeddedSourceUrls' );

		$extraQuery = $case['extraQuery'] ?? [];
		$context = $this->getResourceLoaderContext( $extraQuery );
		$rl = $context->getResourceLoader();
		if ( isset( $case['sources'] ) ) {
			$rl->addSource( $case['sources'] );
		}
		$rl->register( $case['modules'] );
		$module = new StartUpModule();
		$module->setConfig( $rl->getConfig() );
		$module->setHookContainer( $this->getServiceContainer()->getHookContainer() );
		$out = ltrim( $case['out'], "\n" );

		// Disable log from getModuleRegistrations via MWExceptionHandler
		// for case where getVersionHash() is expected to throw.
		$this->setLogger( 'exception', new NullLogger() );

		$this->assertEquals(
			self::expandPlaceholders( $out ),
			$module->getModuleRegistrations( $context ),
			$case['msg']
		);
	}

	/**
	 * These test cases test behaviour that are specific to production mode.
	 *
	 * @see provideGetModuleRegistrations
	 */
	public function provideGetModuleRegistrationsProduction() {
		yield 'Version falls back gracefully if getModuleContent throws' => [ [
			'modules' => [
				'test.fail' => [
					'factory' => function () {
						$mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
							->onlyMethods( [ 'getModuleContent' ] )->getMock();
						$mock->method( 'getModuleContent' )->willThrowException( new Exception );
						return $mock;
					}
				]
			],
			'out' => 'mw.loader.addSource({"local":"/w/load.php"});' . "\n"
				. 'mw.loader.register([["test.fail",""]]);' . "\n"
				. 'mw.loader.state({"test.fail":"error"});',
		] ];
		yield 'Version falls back gracefully if getDefinitionSummary throws' => [ [
			'modules' => [
				'test.fail' => [
					'factory' => function () {
						$mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
							->onlyMethods( [
								'enableModuleContentVersion',
								'getDefinitionSummary'
							] )
							->getMock();
						$mock->method( 'enableModuleContentVersion' )->willReturn( false );
						$mock->method( 'getDefinitionSummary' )->willThrowException( new Exception );
						return $mock;
					}
				]
			],
			'out' => 'mw.loader.addSource({"local":"/w/load.php"});' . "\n"
				. 'mw.loader.register([["test.fail",""]]);' . "\n"
				. 'mw.loader.state({"test.fail":"error"});',
		] ];
	}

	/**
	 * @dataProvider provideGetModuleRegistrationsProduction
	 */
	public function testGetModuleRegistrationsProduction( array $case ) {
		$this->clearHook( 'ResourceLoaderModifyEmbeddedSourceUrls' );

		$context = $this->getResourceLoaderContext( [ 'debug' => 'false' ] );
		$rl = $context->getResourceLoader();
		$rl->register( $case['modules'] );
		$module = new StartUpModule();
		$module->setConfig( $rl->getConfig() );
		$module->setHookContainer( $this->getServiceContainer()->getHookContainer() );
		$out = ltrim( $case['out'], "\n" );

		// Tolerate exception logs for cases that expect getVersionHash() to throw.
		$this->setLogger( 'exception', new NullLogger() );

		$this->assertEquals(
			self::expandPlaceholders( $out ),
			$module->getModuleRegistrations( $context )
		);
	}

	public function testGetModuleRegistrations_hook() {
		$this->clearHook( 'ResourceLoaderModifyEmbeddedSourceUrls' );
		$this->setTemporaryHook( 'ResourceLoaderModifyEmbeddedSourceUrls', function ( &$urls ) {
			$urlUtils = $this->getServiceContainer()->getUrlUtils();
			$urls['local'] = $urlUtils->expand( $urls['local'] );
		} );

		$context = $this->getResourceLoaderContext();
		$rl = $context->getResourceLoader();
		$module = new StartUpModule();
		$module->setHookContainer( $this->getServiceContainer()->getHookContainer() );
		$module->setConfig( $rl->getConfig() );
		$out = 'mw.loader.addSource({
    "local": "https://example.org/w/load.php"
});
mw.loader.register([]);';
		$this->assertEquals(
			$out,
			$module->getModuleRegistrations( $context )
		);
	}

	public static function provideRegistrations() {
		return [
			[ [
				'test.blank' => [ 'class' => ResourceLoaderTestModule::class ],
				'test.min' => [
					'class' => ResourceLoaderTestModule::class,
					'skipFunction' =>
						'return !!(' .
						'    window.JSON &&' .
						'    JSON.parse &&' .
						'    JSON.stringify' .
						');',
					'dependencies' => [
						'test.blank',
					],
				],
			] ]
		];
	}

	/**
	 * @dataProvider provideRegistrations
	 */
	public function testRegistrationsMinified( $modules ) {
		$this->clearHook( 'ResourceLoaderModifyEmbeddedSourceUrls' );

		$context = $this->getResourceLoaderContext( [
			'debug' => 'false',
		] );
		$rl = $context->getResourceLoader();
		$rl->register( $modules );
		$module = new StartUpModule();
		$module->setConfig( $rl->getConfig() );
		$module->setHookContainer( $this->getServiceContainer()->getHookContainer() );
		$out = 'mw.loader.addSource({"local":"/w/load.php"});' . "\n"
		. 'mw.loader.register(['
		. '["test.blank","{blankVer}"],'
		. '["test.min","{blankVer}",[0],null,null,'
		. '"return!!(window.JSON\u0026\u0026JSON.parse\u0026\u0026JSON.stringify);"'
		. ']]);';

		$this->assertEquals(
			self::expandPlaceholders( $out ),
			$module->getModuleRegistrations( $context ),
			'Minified output'
		);
	}

	/**
	 * @dataProvider provideRegistrations
	 */
	public function testRegistrationsUnminified( $modules ) {
		$this->clearHook( 'ResourceLoaderModifyEmbeddedSourceUrls' );

		$context = $this->getResourceLoaderContext( [
			'debug' => 'true',
		] );
		$rl = $context->getResourceLoader();
		$rl->register( $modules );
		$module = new StartUpModule();
		$module->setConfig( $rl->getConfig() );
		$module->setHookContainer( $this->getServiceContainer()->getHookContainer() );
		$out =
'mw.loader.addSource({
    "local": "/w/load.php"
});
mw.loader.register([
    [
        "test.blank",
        ""
    ],
    [
        "test.min",
        "",
        [
            0
        ],
        null,
        null,
        "return !!(    window.JSON \u0026\u0026    JSON.parse \u0026\u0026    JSON.stringify);"
    ]
]);';

		$this->assertEquals(
			self::expandPlaceholders( $out ),
			$module->getModuleRegistrations( $context ),
			'Unminified output'
		);
	}

	public function testGetVersionHash_varyConfig() {
		$this->clearHook( 'ResourceLoaderModifyEmbeddedSourceUrls' );
		$context = $this->getResourceLoaderContext();

		$module = new StartUpModule();
		$module->setConfig( $context->getResourceLoader()->getConfig() );
		$version1 = $module->getVersionHash( $context );

		$module = new StartUpModule();
		$module->setConfig( $context->getResourceLoader()->getConfig() );
		$module->setHookContainer( $this->getServiceContainer()->getHookContainer() );
		$version2 = $module->getVersionHash( $context );

		$this->assertEquals(
			$version1,
			$version2,
			'Deterministic version hash'
		);
	}

	public function testGetVersionHash_varyModule() {
		$this->clearHook( 'ResourceLoaderModifyEmbeddedSourceUrls' );

		$context1 = $this->getResourceLoaderContext( [
			'debug' => 'false',
		] );
		$rl1 = $context1->getResourceLoader();
		$rl1->register( [
			'test.a' => [ 'class' => ResourceLoaderTestModule::class ],
			'test.b' => [ 'class' => ResourceLoaderTestModule::class ],
		] );
		$module = new StartUpModule();
		$module->setConfig( $rl1->getConfig() );
		$module->setHookContainer( $this->getServiceContainer()->getHookContainer() );
		$module->setName( 'test' );
		$version1 = $module->getVersionHash( $context1 );

		$context2 = $this->getResourceLoaderContext();
		$rl2 = $context2->getResourceLoader();
		$rl2->register( [
			'test.b' => [ 'class' => ResourceLoaderTestModule::class ],
			'test.c' => [ 'class' => ResourceLoaderTestModule::class ],
		] );
		$module = new StartUpModule();
		$module->setConfig( $rl2->getConfig() );
		$module->setHookContainer( $this->getServiceContainer()->getHookContainer() );
		$module->setName( 'test' );
		$version2 = $module->getVersionHash( $context2 );

		$context3 = $this->getResourceLoaderContext();
		$rl3 = $context3->getResourceLoader();
		$rl3->register( [
			'test.a' => [ 'class' => ResourceLoaderTestModule::class ],
			'test.b' => [
				'class' => ResourceLoaderTestModule::class,
				'script' => 'different',
			],
		] );
		$module = new StartUpModule();
		$module->setConfig( $rl3->getConfig() );
		$module->setHookContainer( $this->getServiceContainer()->getHookContainer() );
		$module->setName( 'test' );
		$version3 = $module->getVersionHash( $context3 );

		// Module name *is* significant (T201686)
		$this->assertNotEquals(
			$version1,
			$version2,
			'Module name is significant'
		);

		$this->assertNotEquals(
			$version1,
			$version3,
			'Hash change of any module impacts startup hash'
		);
	}

	public function testGetVersionHash_varyDeps() {
		$this->clearHook( 'ResourceLoaderModifyEmbeddedSourceUrls' );

		$context = $this->getResourceLoaderContext( [ 'debug' => 'false' ] );
		$rl = $context->getResourceLoader();
		$rl->register( [
			'test.a' => [
				'class' => ResourceLoaderTestModule::class,
				'dependencies' => [ 'x', 'y' ],
			],
		] );
		$module = new StartUpModule();
		$module->setConfig( $rl->getConfig() );
		$module->setHookContainer( $this->getServiceContainer()->getHookContainer() );
		$module->setName( 'test' );
		$version1 = $module->getVersionHash( $context );

		$context = $this->getResourceLoaderContext();
		$rl = $context->getResourceLoader();
		$rl->register( [
			'test.a' => [
				'class' => ResourceLoaderTestModule::class,
				'dependencies' => [ 'x', 'z' ],
			],
		] );
		$module = new StartUpModule();
		$module->setConfig( $rl->getConfig() );
		$module->setHookContainer( $this->getServiceContainer()->getHookContainer() );
		$module->setName( 'test' );
		$version2 = $module->getVersionHash( $context );

		// Dependencies *are* significant (T201686)
		$this->assertNotEquals(
			$version1,
			$version2,
			'Dependencies are significant'
		);
	}

}
PK       ! I&q7  q7  !  ResourceLoader/WikiModuleTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use LinkCacheTestTrait;
use MediaWiki\Config\Config;
use MediaWiki\Config\HashConfig;
use MediaWiki\Content\Content;
use MediaWiki\Content\CssContent;
use MediaWiki\Content\JavaScriptContent;
use MediaWiki\Content\JavaScriptContentHandler;
use MediaWiki\Content\WikitextContent;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageRecord;
use MediaWiki\Page\PageStore;
use MediaWiki\Request\FauxRequest;
use MediaWiki\ResourceLoader\Context;
use MediaWiki\ResourceLoader\DerivativeContext;
use MediaWiki\ResourceLoader\WikiModule;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use ReflectionMethod;
use RuntimeException;
use Wikimedia\Rdbms\IReadableDatabase;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @group ResourceLoader
 * @group Database
 * @covers \MediaWiki\ResourceLoader\WikiModule
 */
class WikiModuleTest extends ResourceLoaderTestCase {
	use LinkCacheTestTrait;

	/**
	 * @dataProvider provideConstructor
	 */
	public function testConstructor( $params ) {
		$module = new WikiModule( $params );
		$this->assertInstanceOf( WikiModule::class, $module );
	}

	public static function provideConstructor() {
		yield 'null' => [ null ];
		yield 'empty' => [ [] ];
		yield 'unknown settings' => [ [ 'foo' => 'baz' ] ];
		yield 'real settings' => [ [ 'MediaWiki:Common.js' ] ];
	}

	private function makeTitleInfo( array $mockInfo ) {
		$wikiModuleClass = TestingAccessWrapper::newFromClass( WikiModule::class );
		$info = [];
		foreach ( $mockInfo as $val ) {
			$title = $val['title'];
			unset( $val['title'] );
			$info[ $wikiModuleClass->makeTitleKey( $title ) ] = $val;
		}
		return $info;
	}

	/**
	 * @dataProvider provideGetPages
	 */
	public function testGetPages( $params, Config $config, $expected ) {
		$module = new WikiModule( $params );
		$module->setConfig( $config );

		// Because getPages is protected..
		$getPages = new ReflectionMethod( $module, 'getPages' );
		$getPages->setAccessible( true );
		$out = $getPages->invoke( $module, Context::newDummyContext() );
		$this->assertSame( $expected, $out );
	}

	public static function provideGetPages() {
		$settings = self::getSettings() + [
			MainConfigNames::UseSiteJs => true,
			MainConfigNames::UseSiteCss => true,
		];

		$params = [
			'styles' => [ 'MediaWiki:Common.css' ],
			'scripts' => [ 'MediaWiki:Common.js' ],
		];

		return [
			[ [], new HashConfig( $settings ), [] ],
			[ $params, new HashConfig( $settings ), [
				'MediaWiki:Common.js' => [ 'type' => 'script' ],
				'MediaWiki:Common.css' => [ 'type' => 'style' ]
			] ],
			[ $params, new HashConfig( [ MainConfigNames::UseSiteCss => false ] + $settings ), [
				'MediaWiki:Common.js' => [ 'type' => 'script' ],
			] ],
			[ $params, new HashConfig( [ MainConfigNames::UseSiteJs => false ] + $settings ), [
				'MediaWiki:Common.css' => [ 'type' => 'style' ],
			] ],
			[ $params,
				new HashConfig(
					[ MainConfigNames::UseSiteJs => false, MainConfigNames::UseSiteCss => false ]
				),
				[]
			],
		];
	}

	/**
	 * @dataProvider provideGetGroup
	 */
	public function testGetGroup( $params, $expected ) {
		$module = new WikiModule( $params );
		$this->assertSame( $expected, $module->getGroup() );
	}

	public static function provideGetGroup() {
		yield 'no group' => [ [], null ];
		yield 'some group' => [ [ 'group' => 'foobar' ], 'foobar' ];
	}

	/**
	 * @dataProvider provideGetType
	 */
	public function testGetType( $params, $expected ) {
		$module = new WikiModule( $params );
		$this->assertSame( $expected, $module->getType() );
	}

	public static function provideGetType() {
		yield 'empty' => [
			[],
			WikiModule::LOAD_GENERAL,
		];
		yield 'scripts' => [
			[ 'scripts' => [ 'Example.js' ] ],
			WikiModule::LOAD_GENERAL,
		];
		yield 'styles' => [
			[ 'styles' => [ 'Example.css' ] ],
			WikiModule::LOAD_STYLES,
		];
		yield 'styles and scripts' => [
			[ 'styles' => [ 'Example.css' ], 'scripts' => [ 'Example.js' ] ],
			WikiModule::LOAD_GENERAL,
		];
	}

	/**
	 * @dataProvider provideIsKnownEmpty
	 */
	public function testIsKnownEmpty( $titleInfo, $group, $dependencies, $expected ) {
		$module = $this->getMockBuilder( WikiModule::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getTitleInfo', 'getGroup', 'getDependencies' ] )
			->getMock();
		$module->method( 'getTitleInfo' )
			->willReturn( $this->makeTitleInfo( $titleInfo ) );
		$module->method( 'getGroup' )
			->willReturn( $group );
		$module->method( 'getDependencies' )
			->willReturn( $dependencies );
		$context = $this->createMock( Context::class );
		$this->assertSame( $expected, $module->isKnownEmpty( $context ) );
	}

	public static function provideIsKnownEmpty() {
		yield 'nothing' => [
			[],
			null,
			[],
			// No pages exist, considered empty.
			true,
		];

		yield 'an empty page exists (no group)' => [
			[ [ 'title' => new TitleValue( NS_USER, 'Example/foo.js' ), 'page_len' => 0 ] ],
			null,
			[],
			// There is an existing page, so we should let the module be queued.
			// Its emptiness might be temporary, hence considered non-empty (T70488).
			false,
		];
		yield 'an empty page exists (site group)' => [
			[ [ 'title' => new TitleValue( NS_MEDIAWIKI, 'Foo.js' ), 'page_len' => 0 ] ],
			'site',
			[],
			// There is an existing page, hence considered non-empty.
			false,
		];
		yield 'an empty page exists (user group)' => [
			[ [ 'title' => new TitleValue( NS_USER, 'Example/foo.js' ), 'page_len' => 0 ] ],
			'user',
			[],
			// There is an existing page, but it is empty.
			// For user-specific modules, don't bother loading a known-empty module.
			// Given user-specific HTML output, this will vary and re-appear if/when
			// the page becomes non-empty again.
			true,
		];

		yield 'no pages but having dependencies (no group)' => [
			[],
			null,
			[ 'another-module' ],
			false,
		];
		yield 'no pages but having dependencies (site group)' => [
			[],
			'site',
			[ 'another-module' ],
			false,
		];
		yield 'no pages but having dependencies (user group)' => [
			[],
			'user',
			[ 'another-module' ],
			false,
		];

		yield 'a non-empty page exists (user group)' => [
			[ [ 'title' => new TitleValue( NS_USER, 'Example/foo.js' ), 'page_len' => 25 ] ],
			'user',
			[],
			false,
		];
		yield 'a non-empty page exists (site group)' => [
			[ [ 'title' => new TitleValue( NS_MEDIAWIKI, 'Foo.js' ), 'page_len' => 25 ] ],
			'site',
			[],
			false,
		];
	}

	public function testGetPreloadedTitleInfo() {
		// Set up
		ConvertibleTimestamp::setFakeTime( '20110401090000' );
		$this->editPage( 'MediaWiki:TestA.css', '.mw-first {}', 'First' );
		$this->editPage( 'MediaWiki:TestEmpty.css', '', 'Empty' );
		$this->editPage( 'MediaWiki:TestB.css', '.mw-second {}', 'Second' );
		$rl = new EmptyResourceLoader();
		$rl->getConfig()->set( MainConfigNames::UseSiteJs, true );
		$rl->getConfig()->set( MainConfigNames::UseSiteCss, true );
		$rl->register( 'testmodule1', [
			'class' => TestResourceLoaderWikiModule::class,
			'styles' => [
				'MediaWiki:TestA.css',
				// Regression against T145673. It's impossible to statically declare page names in
				// a canonical way since the canonical prefix is localised. As such, the preload
				// cache computed the right cache key, but failed to find the results when
				// doing an intersect on the canonical result, producing an empty array.
				'mediawiki: testEmpty.css',
			],
		] );
		$rl->register( 'testmodule2', [
			'class' => TestResourceLoaderWikiModule::class,
			'styles' => [
				'MediaWiki:TestB.css',
			],
		] );
		$context = new Context( $rl, new FauxRequest() );

		// Warm up the cache
		WikiModule::preloadTitleInfo(
			$context,
			[ 'testmodule1', 'testmodule2' ]
		);
		// The module uses TestResourceLoaderWikiModule, which disables fetchTitleInfo() by default.
		// If getTitleInfo() returns the data here, it means preloadTitleInfo succeeded.
		$module1 = TestingAccessWrapper::newFromObject( $rl->getModule( 'testmodule1' ) );
		$this->assertArrayContains( [
			'8:TestA.css' => [ 'page_len' => '12', 'page_touched' => '20110401090000' ],
			'8:TestEmpty.css' => [ 'page_len' => '0', 'page_touched' => '20110401090000' ],
		], $module1->getTitleInfo( $context ), 'Title info' );
		$module2 = TestingAccessWrapper::newFromObject( $rl->getModule( 'testmodule2' ) );
		$this->assertArrayContains( [
			'8:TestB.css' => [ 'page_len' => '13', 'page_touched' => '20110401090000' ],
		], $module2->getTitleInfo( $context ), 'Title info' );
	}

	public function testGetPreloadedBadTitle() {
		// Set up
		TestResourceLoaderWikiModule::$returnFetchTitleInfo = [];
		$rl = new EmptyResourceLoader();
		$rl->getConfig()->set( MainConfigNames::UseSiteJs, true );
		$rl->getConfig()->set( MainConfigNames::UseSiteCss, true );
		$rl->register( 'testmodule', [
			'class' => TestResourceLoaderWikiModule::class,
			// Covers preloadTitleInfo branch for invalid page name
			'styles' => [ '[x]' ],
		] );
		$context = new Context( $rl, new FauxRequest() );

		// Act
		TestResourceLoaderWikiModule::preloadTitleInfo(
			$context,
			[ 'testmodule' ]
		);

		// Assert
		$module = TestingAccessWrapper::newFromObject( $rl->getModule( 'testmodule' ) );
		$this->assertSame( [], $module->getTitleInfo( $context ), 'Title info' );
	}

	public function testGetPreloadedTitleInfoEmpty() {
		$context = new Context( new EmptyResourceLoader(), new FauxRequest() );
		// This covers the early return case
		$this->assertSame(
			null,
			WikiModule::preloadTitleInfo(
				$context,
				[]
			)
		);
	}

	public static function provideGetContent() {
		yield 'Bad title' => [ null, '[x]' ];

		yield 'No JS content found' => [ null, [
			'text' => 'MediaWiki:Foo.js',
			'ns' => NS_MEDIAWIKI,
			'title' => 'Foo.js',
		] ];

		yield 'JS content' => [ 'code;', [
			'text' => 'MediaWiki:Foo.js',
			'ns' => NS_MEDIAWIKI,
			'title' => 'Foo.js',
		], new JavaScriptContent( 'code;' ) ];

		yield 'CSS content' => [ 'code {}', [
			'text' => 'MediaWiki:Foo.css',
			'ns' => NS_MEDIAWIKI,
			'title' => 'Foo.css',
		], new CssContent( 'code {}' ) ];

		yield 'Wikitext content' => [ null, [
			'text' => 'MediaWiki:Foo',
			'ns' => NS_MEDIAWIKI,
			'title' => 'Foo',
		], new WikitextContent( 'code;' ) ];
	}

	/**
	 * @dataProvider provideGetContent
	 */
	public function testGetContent( $expected, $title, ?Content $contentObj = null ) {
		$context = $this->getResourceLoaderContext( [], new EmptyResourceLoader );
		$module = $this->getMockBuilder( WikiModule::class )
			->onlyMethods( [ 'getContentObj' ] )->getMock();
		$module->method( 'getContentObj' )
			->willReturn( $contentObj );

		if ( is_array( $title ) ) {
			$title += [ 'ns' => NS_MAIN, 'id' => 1, 'len' => 1, 'redirect' => 0 ];
			$titleText = $title['text'];
			// Mock page table access via PageStore
			$pageStore = $this->createNoOpMock( PageStore::class, [ 'getPageByText' ] );
			$pageStore->method( 'getPageByText' )->willReturn(
				new PageIdentityValue(
					$title['id'], $title['ns'], $title['text'], PageRecord::LOCAL
				)
			);
		} else {
			$titleText = $title;
		}

		$module = TestingAccessWrapper::newFromObject( $module );
		$this->assertSame(
			$expected,
			$module->getContent( $titleText, $context )
		);
	}

	public function testContentOverrides() {
		$pages = [
			'MediaWiki:Common.css' => [ 'type' => 'style' ],
		];

		$rl = new EmptyResourceLoader();

		$module = $this->getMockBuilder( WikiModule::class )
			->onlyMethods( [ 'getPages' ] )
			->getMock();
		$module->method( 'getPages' )->willReturn( $pages );
		$module->setConfig( $rl->getConfig() );

		$context = new DerivativeContext(
			new Context( $rl, new FauxRequest() )
		);
		$context->setContentOverrideCallback( static function ( PageIdentity $t ) {
			if ( $t->getDBkey() === 'Common.css' ) {
				return new CssContent( '.override{}' );
			}
			return null;
		} );

		$this->assertTrue( $module->shouldEmbedModule( $context ) );
		$this->assertSame( [
			'all' => [
				"/*\nMediaWiki:Common.css\n*/\n.override{}"
			]
		], $module->getStyles( $context ) );

		$context->setContentOverrideCallback( static function ( PageIdentity $t ) {
			if ( $t->getDBkey() === 'Skin.css' ) {
				return new CssContent( '.override{}' );
			}
			return null;
		} );
		$this->assertFalse( $module->shouldEmbedModule( $context ) );
	}

	public function testGetContentForRedirects() {
		// Set up context and module object
		$context = new DerivativeContext(
			$this->getResourceLoaderContext( [], new EmptyResourceLoader )
		);
		$module = $this->getMockBuilder( WikiModule::class )
			->onlyMethods( [ 'getPages' ] )
			->getMock();
		$module->method( 'getPages' )
			->willReturn( [
				'MediaWiki:Redirect.js' => [ 'type' => 'script' ]
			] );
		$module->setConfig( $context->getResourceLoader()->getConfig() );
		$context->setContentOverrideCallback( static function ( PageIdentity $title ) {
			if ( $title->getDBkey() === 'Redirect.js' ) {
				$handler = new JavaScriptContentHandler();
				return $handler->makeRedirectContent(
					Title::makeTitle( NS_MEDIAWIKI, 'Target.js' )
				);
			} elseif ( $title->getDBkey() === 'Target.js' ) {
				return new JavaScriptContent( 'target;' );
			} else {
				return null;
			}
		} );

		// Mock away Title's db queries with LinkCache
		$this->addGoodLinkObject( 1, new TitleValue( NS_MEDIAWIKI, 'Redirect.js' ), 1, 1 );

		$this->assertSame(
			"/*\nMediaWiki:Redirect.js\n*/\ntarget;\n",
			$module->getScript( $context ),
			'Redirect resolved by getContent'
		);
	}

	protected function tearDown(): void {
		Title::clearCaches();
		parent::tearDown();
	}
}

class TestResourceLoaderWikiModule extends WikiModule {
	/** @var array|null */
	public static $returnFetchTitleInfo = null;

	protected static function fetchTitleInfo( IReadableDatabase $db, array $pages, $fname = null ) {
		$ret = self::$returnFetchTitleInfo;
		self::$returnFetchTitleInfo = null;
		if ( $ret === null ) {
			// If a call is expected, a mock return value must be planted first
			throw new RuntimeException( 'Unexpected fetchTitleInfo call' );
		}
		return $ret;
	}
}
PK       ! 0    (  ResourceLoader/LessVarFileModuleTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use MediaWiki\ResourceLoader\LessVarFileModule;
use ReflectionMethod;

/**
 * @group ResourceLoader
 * @covers \MediaWiki\ResourceLoader\LessVarFileModule
 */
class LessVarFileModuleTest extends ResourceLoaderTestCase {

	public static function providerWrapAndEscapeMessage() {
		return [
			[
				"Foo", '"Foo"',
			],
			[
				"Foo bananas", '"Foo bananas"',
			],
			[
				"Who's that test? Who's that test? It's Jess!",
				'"Who\\\'s that test? Who\\\'s that test? It\\\'s Jess!"',
			],
			[
				'Hello "he" said',
				'"Hello \"he\" said"',
			],
			[
				'boo";-o-link:javascript:alert(1);color:red;content:"',
				'"boo\";-o-link:javascript:alert(1);color:red;content:\""',
			],
			[
				'"jon\'s"',
				'"\"jon\\\'s\""'
			]
		];
	}

	/**
	 * @dataProvider providerWrapAndEscapeMessage
	 */
	public function testEscapeMessage( $msg, $expected ) {
		$method = new ReflectionMethod( LessVarFileModule::class, 'wrapAndEscapeMessage' );
		$method->setAccessible( true );
		$this->assertEquals( $expected, $method->invoke( null, $msg ) );
	}

	public function testLessMessagesFound() {
		$context = $this->getResourceLoaderContext( 'qqx' );
		$basePath = __DIR__ . '/../../data/less';
		$module = new LessVarFileModule( [
			'localBasePath' => $basePath,
			'styles' => [ 'less-messages.less' ],
			'lessMessages' => [ 'pieday' ],
		] );
		$module->setConfig( $context->getResourceLoader()->getConfig() );
		$module->setMessageBlob( '{"pieday":"March 14"}', 'qqx' );

		$styles = $module->getStyles( $context );
		$this->assertStringEqualsFile( $basePath . '/less-messages-exist.css', $styles['all'] );
	}

	public function testLessMessagesFailGraceful() {
		$context = $this->getResourceLoaderContext( 'qqx' );
		$basePath = __DIR__ . '/../../data/less';
		$module = new LessVarFileModule( [
			'localBasePath' => $basePath,
			'styles' => [ 'less-messages.less' ],
			'lessMessages' => [ 'pieday' ],
		] );
		$module->setConfig( $context->getResourceLoader()->getConfig() );
		$module->setMessageBlob( '{"something":"Else"}', 'qqx' );

		$styles = $module->getStyles( $context );
		$this->assertStringEqualsFile( $basePath . '/less-messages-nonexist.css', $styles['all'] );
	}
}
PK       ! u      '  ResourceLoader/templates/template2.htmlnu Iw        <div>goodbye</div>
PK       ! ͌      4  ResourceLoader/templates/template_awesome.handlebarsnu Iw        wow
PK       ! H      &  ResourceLoader/templates/template.htmlnu Iw        <strong>hello</strong>
PK       ! {s+  +  '  ResourceLoader/MessageBlobStoreTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use MediaWiki\ResourceLoader\MessageBlobStore;
use MediaWiki\ResourceLoader\ResourceLoader;
use MediaWikiCoversValidator;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;

/**
 * @group ResourceLoader
 * @covers \MediaWiki\ResourceLoader\MessageBlobStore
 */
class MessageBlobStoreTest extends TestCase {

	use MediaWikiCoversValidator;

	private const NAME = 'test.blobstore';

	/** @var WANObjectCache */
	private $wanCache;

	/** @var float */
	private $clock;

	protected function setUp(): void {
		parent::setUp();
		$this->wanCache = new WANObjectCache( [
			'cache' => new HashBagOStuff()
		] );

		$this->clock = 1301655600.000;
		$this->wanCache->setMockTime( $this->clock );
	}

	public function testBlobCreation() {
		$rl = new EmptyResourceLoader();
		$rl->register( self::NAME, [
			'factory' => function () {
				return $this->makeModule( [ 'mainpage' ] );
			}
		] );

		$blobStore = $this->makeBlobStore( null, $rl );
		$blob = $blobStore->getBlob( $rl->getModule( self::NAME ), 'en' );

		$this->assertEquals( '{"mainpage":"Main Page"}', $blob, 'Generated blob' );
	}

	public function testBlobCreation_empty() {
		$module = $this->makeModule( [] );
		$rl = new EmptyResourceLoader();

		$blobStore = $this->makeBlobStore( null, $rl );
		$blob = $blobStore->getBlob( $module, 'en' );

		$this->assertEquals( '{}', $blob, 'Generated blob' );
	}

	public function testBlobCreation_unknownMessage() {
		$module = $this->makeModule( [ 'i-dont-exist', 'mainpage', 'i-dont-exist2' ] );
		$rl = new EmptyResourceLoader();
		$blobStore = $this->makeBlobStore( null, $rl );

		// Generating a blob should continue without errors,
		// with keys of unknown messages excluded from the blob.
		$blob = $blobStore->getBlob( $module, 'en' );
		$this->assertEquals( '{"mainpage":"Main Page"}', $blob, 'Generated blob' );
	}

	public function testMessageCachingAndPurging() {
		$rl = new EmptyResourceLoader();
		// Register it so that MessageBlobStore::updateMessage can
		// discover it from the registry as a module that uses this message.
		$rl->register( self::NAME, [
			'factory' => function () {
				return $this->makeModule( [ 'example' ] );
			}
		] );
		$module = $rl->getModule( self::NAME );
		$blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );

		// Advance this new WANObjectCache instance to a normal state,
		// by doing one "get" and letting its hold off period expire.
		// Without this, the first real "get" would lazy-initialise the
		// checkKey and thus reject the first "set".
		$blobStore->getBlob( $module, 'en' );
		$this->clock += 20;

		// Arrange version 1 of a message
		$blobStore->expects( $this->once() )
			->method( 'fetchMessage' )
			->willReturn( 'First version' );

		// Assert
		$blob = $blobStore->getBlob( $module, 'en' );
		$this->assertEquals( '{"example":"First version"}', $blob, 'Blob for v1' );

		// Arrange version 2
		$blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
		$blobStore->expects( $this->once() )
			->method( 'fetchMessage' )
			->willReturn( 'Second version' );
		$this->clock += 20;

		// Assert
		// We do not validate whether a cached message is up-to-date.
		// Instead, changes to messages will send us a purge.
		// When cache is not purged or expired, it must be used.
		$blob = $blobStore->getBlob( $module, 'en' );
		$this->assertEquals( '{"example":"First version"}', $blob, 'Reuse cached v1 blob' );

		// Purge cache
		$blobStore->updateMessage( 'example' );
		$this->clock += 20;

		// Assert
		$blob = $blobStore->getBlob( $module, 'en' );
		$this->assertEquals( '{"example":"Second version"}', $blob, 'Updated blob for v2' );
	}

	public function testPurgeEverything() {
		$module = $this->makeModule( [ 'example' ] );
		$rl = new EmptyResourceLoader();
		$blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
		// Advance this new WANObjectCache instance to a normal state.
		$blobStore->getBlob( $module, 'en' );
		$this->clock += 20;

		// Arrange version 1 and 2
		$blobStore->expects( $this->exactly( 2 ) )
			->method( 'fetchMessage' )
			->willReturnOnConsecutiveCalls( 'First', 'Second' );

		// Assert
		$blob = $blobStore->getBlob( $module, 'en' );
		$this->assertEquals( '{"example":"First"}', $blob, 'Blob for v1' );

		$this->clock += 20;

		// Assert
		$blob = $blobStore->getBlob( $module, 'en' );
		$this->assertEquals( '{"example":"First"}', $blob, 'Blob for v1 again' );

		// Purge everything
		$blobStore->clear();
		$this->clock += 20;

		// Assert
		$blob = $blobStore->getBlob( $module, 'en' );
		$this->assertEquals( '{"example":"Second"}', $blob, 'Blob for v2' );
	}

	public function testValidateAgainstModuleRegistry() {
		// Arrange version 1 of a module
		$module = $this->makeModule( [ 'foo' ] );
		$rl = new EmptyResourceLoader();
		$blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
		$blobStore->expects( $this->once() )
			->method( 'fetchMessage' )
			->willReturnMap( [
				// message key, language code, message value
				[ 'foo', 'en', 'Hello' ],
			] );

		// Assert
		$blob = $blobStore->getBlob( $module, 'en' );
		$this->assertEquals( '{"foo":"Hello"}', $blob, 'Blob for v1' );

		// Arrange version 2 of module
		// While message values may be out of date, the set of messages returned
		// must always match the set of message keys required by the module.
		// We do not receive purges for this because no messages were changed.
		$module = $this->makeModule( [ 'foo', 'bar' ] );
		$rl = new EmptyResourceLoader();
		$blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
		$blobStore->expects( $this->exactly( 2 ) )
			->method( 'fetchMessage' )
			->willReturnMap( [
				// message key, language code, message value
				[ 'foo', 'en', 'Hello' ],
				[ 'bar', 'en', 'World' ],
			] );

		// Assert
		$blob = $blobStore->getBlob( $module, 'en' );
		$this->assertEquals( '{"foo":"Hello","bar":"World"}', $blob, 'Blob for v2' );
	}

	public function testSetLoggedIsVoid() {
		$blobStore = $this->makeBlobStore();
		$this->assertNull( $blobStore->setLogger( new NullLogger() ) );
	}

	private function makeBlobStore( $methods = null, $rl = null ) {
		$blobStore = $this->getMockBuilder( MessageBlobStore::class )
			->setConstructorArgs( [
				$rl ?? $this->createMock( ResourceLoader::class ),
				null,
				$this->wanCache
			] )
			->onlyMethods( $methods ?: [] )
			->getMock();

		return $blobStore;
	}

	private function makeModule( array $messages ) {
		$module = new ResourceLoaderTestModule( [ 'messages' => $messages ] );
		$module->setName( self::NAME );
		return $module;
	}
}
PK       ! L_N  N  "  ResourceLoader/ImageModuleTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\StaticHookRegistry;
use MediaWiki\Request\FauxRequest;
use MediaWiki\ResourceLoader\Context;
use MediaWiki\ResourceLoader\FilePath;
use MediaWiki\ResourceLoader\Image;
use MediaWiki\ResourceLoader\ImageModule;
use Wikimedia\TestingAccessWrapper;

/**
 * @group ResourceLoader
 * @covers \MediaWiki\ResourceLoader\ImageModule
 */
class ImageModuleTest extends ResourceLoaderTestCase {

	public const COMMON_IMAGE_DATA = [
		'abc' => 'abc.gif',
		'def' => [
			'file' => 'def.svg',
			'variants' => [ 'destructive' ],
		],
		'ghi' => [
			'file' => [
				'ltr' => 'ghi.svg',
				'rtl' => 'jkl.svg'
			],
		],
		'mno' => [
			'file' => [
				'ltr' => 'mno-ltr.svg',
				'rtl' => 'mno-rtl.svg',
				'lang' => [
					'he' => 'mno-ltr.svg',
				]
			],
		],
		'pqr' => [
			'file' => [
				'default' => 'pqr-a.svg',
				'lang' => [
					'en' => 'pqr-b.svg',
					'ar,de' => 'pqr-f.svg',
				]
			],
		]
	];

	private const COMMON_IMAGE_VARIANTS = [
		'invert' => [
			'color' => '#FFFFFF',
			'global' => true,
		],
		'primary' => [
			'color' => '#598AD1',
		],
		'constructive' => [
			'color' => '#00C697',
		],
		'destructive' => [
			'color' => '#E81915',
		],
	];

	public static function providerGetModules() {
		return [
			[
				[
					'class' => ImageModule::class,
					'prefix' => 'oo-ui-icon',
					'variants' => self::COMMON_IMAGE_VARIANTS,
					'images' => self::COMMON_IMAGE_DATA,
				],
				'.oo-ui-icon-abc {
	...
}
.oo-ui-icon-abc-invert {
	...
}
.oo-ui-icon-def {
	...
}
.oo-ui-icon-def-invert {
	...
}
.oo-ui-icon-def-destructive {
	...
}
.oo-ui-icon-ghi {
	...
}
.oo-ui-icon-ghi-invert {
	...
}
.oo-ui-icon-mno {
	...
}
.oo-ui-icon-mno-invert {
	...
}
.oo-ui-icon-pqr {
	...
}
.oo-ui-icon-pqr-invert {
	...
}',
			],
			[
				[
					'class' => ImageModule::class,
					'selectorWithoutVariant' => '.mw-ui-icon-{name}:after, .mw-ui-icon-{name}:before',
					'selectorWithVariant' =>
						'.mw-ui-icon-{name}-{variant}:after, .mw-ui-icon-{name}-{variant}:before',
					'variants' => self::COMMON_IMAGE_VARIANTS,
					'images' => self::COMMON_IMAGE_DATA,
				],
				'.mw-ui-icon-abc:after, .mw-ui-icon-abc:before {
	...
}
.mw-ui-icon-abc-invert:after, .mw-ui-icon-abc-invert:before {
	...
}
.mw-ui-icon-def:after, .mw-ui-icon-def:before {
	...
}
.mw-ui-icon-def-invert:after, .mw-ui-icon-def-invert:before {
	...
}
.mw-ui-icon-def-destructive:after, .mw-ui-icon-def-destructive:before {
	...
}
.mw-ui-icon-ghi:after, .mw-ui-icon-ghi:before {
	...
}
.mw-ui-icon-ghi-invert:after, .mw-ui-icon-ghi-invert:before {
	...
}
.mw-ui-icon-mno:after, .mw-ui-icon-mno:before {
	...
}
.mw-ui-icon-mno-invert:after, .mw-ui-icon-mno-invert:before {
	...
}
.mw-ui-icon-pqr:after, .mw-ui-icon-pqr:before {
	...
}
.mw-ui-icon-pqr-invert:after, .mw-ui-icon-pqr-invert:before {
	...
}',
			],
		];
	}

	/**
	 * Test reading files from elsewhere than localBasePath using FilePath.
	 *
	 * This mimics modules modified by skins using 'ResourceModuleSkinStyles' and 'OOUIThemePaths'
	 * skin attributes.
	 */
	public function testResourceLoaderFilePath() {
		$hookContainer = new HookContainer(
			new StaticHookRegistry(),
			$this->getServiceContainer()->getObjectFactory()
		);
		$basePath = __DIR__ . '/../../data/blahblah';
		$filePath = __DIR__ . '/../../data/rlfilepath';
		$testModule = new ImageModule( [
			'localBasePath' => $basePath,
			'remoteBasePath' => 'blahblah',
			'prefix' => 'foo',
			'images' => [
				'eye' => new FilePath( 'eye.svg', $filePath, 'rlfilepath' ),
				'flag' => [
					'file' => [
						'ltr' => new FilePath( 'flag-ltr.svg', $filePath, 'rlfilepath' ),
						'rtl' => new FilePath( 'flag-rtl.svg', $filePath, 'rlfilepath' ),
					],
				],
			],
		] );
		$testModule->setName( 'testModule' );
		$testModule->setHookContainer( $hookContainer );
		$expectedModule = new ImageModule( [
			'localBasePath' => $filePath,
			'remoteBasePath' => 'rlfilepath',
			'prefix' => 'foo',
			'images' => [
				'eye' => 'eye.svg',
				'flag' => [
					'file' => [
						'ltr' => 'flag-ltr.svg',
						'rtl' => 'flag-rtl.svg',
					],
				],
			],
		] );
		$expectedModule->setName( 'testModule' );
		$expectedModule->setHookContainer( $hookContainer );

		$context = $this->getResourceLoaderContext();
		$this->assertEquals(
			$expectedModule->getModuleContent( $context ),
			$testModule->getModuleContent( $context ),
			"Using ResourceLoaderFilePath works correctly"
		);
	}

	/**
	 * @dataProvider providerGetModules
	 */
	public function testGetStyles( $module, $expected ) {
		$module = new ImageModuleTestable(
			$module,
			__DIR__ . '/../../data/resourceloader'
		);
		$module->setHookContainer( new HookContainer(
			new StaticHookRegistry(),
			$this->getServiceContainer()->getObjectFactory()
		) );
		$styles = $module->getStyles( $this->getResourceLoaderContext() );
		$this->assertEquals( $expected, $styles['all'] );
	}

	public function testContext() {
		$context = new Context( new EmptyResourceLoader(), new FauxRequest() );
		$this->assertFalse( $context->getImageObj(), 'Missing image parameter' );

		$context = new Context( new EmptyResourceLoader(), new FauxRequest( [
			'image' => 'example',
		] ) );
		$this->assertFalse( $context->getImageObj(), 'Missing module parameter' );

		$context = new Context( new EmptyResourceLoader(), new FauxRequest( [
			'modules' => 'unknown',
			'image' => 'example',
		] ) );
		$this->assertFalse( $context->getImageObj(), 'Not an image module' );

		$rl = new EmptyResourceLoader();
		$rl->register( 'test', [
			'class' => ImageModule::class,
			'prefix' => 'test',
			'images' => [ 'example' => 'example.png' ],
		] );
		$context = new Context( $rl, new FauxRequest( [
			'modules' => 'test',
			'image' => 'unknown',
		] ) );
		$this->assertFalse( $context->getImageObj(), 'Unknown image' );

		$rl = new EmptyResourceLoader();
		$rl->register( 'test', [
			'class' => ImageModule::class,
			'prefix' => 'test',
			'images' => [ 'example' => 'example.png' ],
		] );
		$context = new Context( $rl, new FauxRequest( [
			'modules' => 'test',
			'image' => 'example',
		] ) );
		$this->assertInstanceOf( Image::class, $context->getImageObj() );
	}

	public static function providerGetStyleDeclarations() {
		return [
			[
				false,
				'background-image: url(original.svg);',
			],
			[
				'data:image/svg+xml',
				'background-image: url(data:image/svg+xml);',
			],
			[
				'data:image/svg+xml',
				"-webkit-mask-image: url(data:image/svg+xml);\n	mask-image: url(data:image/svg+xml);",
				true
			]
		];
	}

	/**
	 * @dataProvider providerGetStyleDeclarations
	 */
	public function testGetStyleDeclarations( $dataUriReturnValue, $expected, $useMaskImage = false ) {
		$module = TestingAccessWrapper::newFromObject( new ImageModule( [ 'useMaskImage' => $useMaskImage ] ) );
		$context = $this->getResourceLoaderContext();
		$image = $this->getImageMock( $context, $dataUriReturnValue );

		$styles = $module->getStyleDeclarations(
			$context,
			$image,
			'load.php'
		);

		$this->assertEquals( $expected, $styles );
	}

	private function getImageMock( Context $context, $dataUriReturnValue ) {
		$image = $this->createMock( Image::class );
		$image->method( 'getDataUri' )
			->willReturn( $dataUriReturnValue );
		$image->method( 'getUrl' )
			->willReturnMap( [
				[ $context, 'load.php', null, 'original', 'original.svg' ],
				[ $context, 'load.php', null, 'rasterized', 'rasterized.png' ],
			] );

		return $image;
	}
}

class ImageModuleTestable extends ImageModule {
	/**
	 * Replace with a stub to make test cases easier to write.
	 * @inheritDoc
	 */
	protected function getCssDeclarations( $primary ): array {
		return [ '...' ];
	}
}
PK       ! XNu<m  <m  !  ResourceLoader/FileModuleTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use LogicException;
use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\ResourceLoader\FileModule;
use MediaWiki\ResourceLoader\FilePath;
use MediaWiki\ResourceLoader\ResourceLoader;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use RuntimeException;
use SkinFactory;
use Wikimedia\TestingAccessWrapper;

/**
 * @group ResourceLoader
 * @covers \MediaWiki\ResourceLoader\FileModule
 */
class FileModuleTest extends ResourceLoaderTestCase {
	use DummyServicesTrait;

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

		$skinFactory = new SkinFactory( $this->getDummyObjectFactory(), [] );
		// The empty spec shouldn't matter since this test should never call it
		$skinFactory->register(
			'fakeskin',
			'FakeSkin',
			[]
		);
		$this->setService( 'SkinFactory', $skinFactory );

		// This test is not expected to query any database
		$this->getServiceContainer()->disableStorage();
	}

	private static function getModules() {
		$base = [
			'localBasePath' => __DIR__,
		];

		return [
			'noTemplateModule' => [],

			'htmlTemplateModule' => $base + [
				'templates' => [
					'templates/template.html',
					'templates/template2.html',
				]
			],

			'htmlTemplateUnknown' => $base + [
				'templates' => [
					'templates/notfound.html',
				]
			],

			'aliasedHtmlTemplateModule' => $base + [
				'templates' => [
					'foo.html' => 'templates/template.html',
					'bar.html' => 'templates/template2.html',
				]
			],

			'templateModuleHandlebars' => $base + [
				'templates' => [
					'templates/template_awesome.handlebars',
				],
			],

			'aliasFooFromBar' => $base + [
				'templates' => [
					'foo.foo' => 'templates/template.bar',
				],
			],
		];
	}

	public static function providerTemplateDependencies() {
		$modules = self::getModules();

		return [
			[
				$modules['noTemplateModule'],
				[],
			],
			[
				$modules['htmlTemplateModule'],
				[
					'mediawiki.template',
				],
			],
			[
				$modules['templateModuleHandlebars'],
				[
					'mediawiki.template',
					'mediawiki.template.handlebars',
				],
			],
			[
				$modules['aliasFooFromBar'],
				[
					'mediawiki.template',
					'mediawiki.template.foo',
				],
			],
		];
	}

	/**
	 * @dataProvider providerTemplateDependencies
	 */
	public function testTemplateDependencies( $module, $expected ) {
		$rl = new FileModule( $module );
		$rl->setName( 'testing' );
		$this->assertEquals( $expected, $rl->getDependencies() );
	}

	public function testGetScript() {
		$localBasePath = __DIR__ . '/../../data/resourceloader';
		$remoteBasePath = '/w';
		$module = new FileModule( [
			'localBasePath' => $localBasePath,
			'remoteBasePath' => $remoteBasePath,
			'scripts' => [ 'script-nosemi.js', 'script-comment.js' ],
		] );
		$module->setName( 'testing' );
		$ctx = $this->getResourceLoaderContext();
		$this->assertEquals(
			[
				'plainScripts' => [
					'script-nosemi.js' => [
						'name' => 'script-nosemi.js',
						'content' => "/* eslint-disable */\nmw.foo()\n",
						'type' => 'script',
						'filePath' => new FilePath(
							'script-nosemi.js',
							$localBasePath,
							$remoteBasePath
						)
					],
					'script-comment.js' => [
						'name' => 'script-comment.js',
						'content' => "/* eslint-disable */\nmw.foo()\n// mw.bar();\n",
						'type' => 'script',
						'filePath' => new FilePath(
							'script-comment.js',
							$localBasePath,
							$remoteBasePath
						)
					]
				]
			],
			$module->getScript( $ctx )
		);
	}

	/**
	 * @covers \MediaWiki\ResourceLoader\FileModule
	 * @covers \MediaWiki\ResourceLoader\Module
	 * @covers \MediaWiki\ResourceLoader\ResourceLoader
	 */
	public function testGetURLsForDebug() {
		$ctx = $this->getResourceLoaderContext();
		$module = new FileModule( [
			'localBasePath' => __DIR__ . '/../../data/resourceloader',
			'remoteBasePath' => '/w/something',
			'styles' => [ 'simple.css' ],
			'scripts' => [ 'script-comment.js' ],
		] );
		$module->setName( 'testing' );
		$module->setConfig( $ctx->getResourceLoader()->getConfig() );

		$this->assertEquals(
			[
				'https://example.org/w/something/script-comment.js'
			],
			$module->getScriptURLsForDebug( $ctx ),
			'script urls'
		);
		$this->assertEquals(
			[ 'all' => [
				'/w/something/simple.css'
			] ],
			$module->getStyleURLsForDebug( $ctx ),
			'style urls'
		);
	}

	public function testGetAllSkinStyleFiles() {
		$baseParams = [
			'scripts' => [
				'foo.js',
				'bar.js',
			],
			'styles' => [
				'foo.css',
				'bar.css' => [ 'media' => 'print' ],
				'screen.less' => [ 'media' => 'screen' ],
				'screen-query.css' => [ 'media' => 'screen and (min-width: 400px)' ],
			],
			'skinStyles' => [
				'default' => 'quux-fallback.less',
				'fakeskin' => [
					'baz-vector.css',
					'quux-vector.less',
				],
			],
			'messages' => [
				'hello',
				'world',
			],
		];

		$module = new FileModule( $baseParams );
		$module->setName( 'testing' );

		$this->assertEquals(
			[
				'foo.css',
				'baz-vector.css',
				'quux-vector.less',
				'quux-fallback.less',
				'bar.css',
				'screen.less',
				'screen-query.css',
			],
			array_map( 'basename', $module->getAllStyleFiles() )
		);
	}

	/**
	 * Strip @noflip annotations from CSS code.
	 * @param string $css
	 * @return string
	 */
	private static function stripNoflip( $css ) {
		return str_replace( '/*@noflip*/ ', '', $css );
	}

	/**
	 * Confirm that 'ResourceModuleSkinStyles' skin attributes get injected
	 * into the module, and have their file contents read correctly from their
	 * own (out-of-module) directories.
	 *
	 * @covers \MediaWiki\ResourceLoader\FileModule
	 * @covers \MediaWiki\ResourceLoader\ResourceLoader
	 */
	public function testInjectSkinStyles() {
		$moduleDir = __DIR__ . '/../../data/resourceloader';
		$skinDir = __DIR__ . '/../../data/resourceloader/myskin';
		$rl = new ResourceLoader( new HashConfig( self::getSettings() ) );
		$rl->setModuleSkinStyles( [
			'fakeskin' => [
				'localBasePath' => $skinDir,
				'testing' => [
					'override.css',
				],
			],
		] );
		$rl->register( 'testing', [
			'localBasePath' => $moduleDir,
			'styles' => [ 'simple.css' ],
		] );
		$ctx = $this->getResourceLoaderContext( [ 'skin' => 'fakeskin' ], $rl );

		$module = $rl->getModule( 'testing' );
		$this->assertInstanceOf( FileModule::class, $module );
		$this->assertEquals(
			[ 'all' => ".example { color: blue; }\n\n.override { line-height: 2; }\n" ],
			$module->getStyles( $ctx )
		);
	}

	/**
	 * Verify what happens when you mix @embed and @noflip.
	 */
	public function testMixedCssAnnotations() {
		$basePath = __DIR__ . '/../../data/css';
		$testModule = new ResourceLoaderFileTestModule( [
			'localBasePath' => $basePath,
			'styles' => [ 'test.css' ],
		] );
		$testModule->setName( 'testing' );
		$expectedModule = new ResourceLoaderFileTestModule( [
			'localBasePath' => $basePath,
			'styles' => [ 'expected.css' ],
		] );
		$expectedModule->setName( 'testing' );

		$contextLtr = $this->getResourceLoaderContext( [
			'lang' => 'en',
			'dir' => 'ltr',
		] );
		$contextRtl = $this->getResourceLoaderContext( [
			'lang' => 'he',
			'dir' => 'rtl',
		] );

		// Since we want to compare the effect of @noflip+@embed against the effect of just @embed, and
		// the @noflip annotations are always preserved, we need to strip them first.
		$this->assertEquals(
			$expectedModule->getStyles( $contextLtr ),
			self::stripNoflip( $testModule->getStyles( $contextLtr ) ),
			"/*@noflip*/ with /*@embed*/ gives correct results in LTR mode"
		);
		$this->assertEquals(
			$expectedModule->getStyles( $contextLtr ),
			self::stripNoflip( $testModule->getStyles( $contextRtl ) ),
			"/*@noflip*/ with /*@embed*/ gives correct results in RTL mode"
		);
	}

	public function testCssFlipping() {
		$plain = new ResourceLoaderFileTestModule( [
			'localBasePath' => __DIR__ . '/../../data/resourceloader',
			'styles' => [ 'direction.css' ],
		] );
		$plain->setName( 'test' );

		$context = $this->getResourceLoaderContext( [ 'lang' => 'en', 'dir' => 'ltr' ] );
		$this->assertEquals(
			[ 'all' => ".example { text-align: left; }\n" ],
			$plain->getStyles( $context ),
			'Unchanged styles in LTR mode'
		);
		$context = $this->getResourceLoaderContext( [ 'lang' => 'he', 'dir' => 'rtl' ] );
		$this->assertEquals(
			[ 'all' => ".example { text-align: right; }\n" ],
			$plain->getStyles( $context ),
			'Flipped styles in RTL mode'
		);

		$noflip = new ResourceLoaderFileTestModule( [
			'localBasePath' => __DIR__ . '/../../data/resourceloader',
			'styles' => [ 'direction.css' ],
			'noflip' => true,
		] );
		$noflip->setName( 'test' );
		$this->assertEquals(
			[ 'all' => ".example { text-align: right; }\n" ],
			$plain->getStyles( $context ),
			'Unchanged styles in RTL mode with noflip at module level'
		);
	}

	/**
	 * Test reading files from elsewhere than localBasePath using FilePath.
	 *
	 * The use of FilePath objects resembles the way that ResourceLoader::getModule()
	 * injects additional files when 'ResourceModuleSkinStyles' or 'OOUIThemePaths'
	 * skin attributes apply to a given module.
	 */
	public function testResourceLoaderFilePath() {
		$basePath = __DIR__ . '/../../data/blahblah';
		$filePath = __DIR__ . '/../../data/rlfilepath';
		$testModule = new FileModule( [
			'localBasePath' => $basePath,
			'remoteBasePath' => 'blahblah',
			'styles' => new FilePath( 'style.css', $filePath, 'rlfilepath' ),
			'skinStyles' => [
				'vector' => new FilePath( 'skinStyle.css', $filePath, 'rlfilepath' ),
			],
			'scripts' => new FilePath( 'script.js', $filePath, 'rlfilepath' ),
			'templates' => new FilePath( 'template.html', $filePath, 'rlfilepath' ),
		] );
		$testModule->setName( 'testModule' );
		$expectedModule = new FileModule( [
			'localBasePath' => $filePath,
			'remoteBasePath' => 'rlfilepath',
			'styles' => 'style.css',
			'skinStyles' => [
				'vector' => 'skinStyle.css',
			],
			'scripts' => 'script.js',
			'templates' => 'template.html',
		] );
		$expectedModule->setName( 'expectedModule' );

		$context = $this->getResourceLoaderContext();
		$this->assertEquals(
			$expectedModule->getModuleContent( $context ),
			$testModule->getModuleContent( $context ),
			"Using ResourceLoaderFilePath works correctly"
		);
	}

	public static function providerGetTemplates() {
		$modules = self::getModules();

		return [
			[
				$modules['noTemplateModule'],
				[],
			],
			[
				$modules['templateModuleHandlebars'],
				[
					'templates/template_awesome.handlebars' => "wow\n",
				],
			],
			[
				$modules['htmlTemplateModule'],
				[
					'templates/template.html' => "<strong>hello</strong>\n",
					'templates/template2.html' => "<div>goodbye</div>\n",
				],
			],
			[
				$modules['aliasedHtmlTemplateModule'],
				[
					'foo.html' => "<strong>hello</strong>\n",
					'bar.html' => "<div>goodbye</div>\n",
				],
			],
			[
				$modules['htmlTemplateUnknown'],
				false,
			],
		];
	}

	/**
	 * @dataProvider providerGetTemplates
	 */
	public function testGetTemplates( $module, $expected ) {
		$rl = new FileModule( $module );
		$rl->setName( 'testing' );

		if ( $expected === false ) {
			$this->expectException( RuntimeException::class );
			$rl->getTemplates();
		} else {
			$this->assertEquals( $expected, $rl->getTemplates() );
		}
	}

	public function testBomConcatenation() {
		$basePath = __DIR__ . '/../../data/css';
		$testModule = new ResourceLoaderFileTestModule( [
			'localBasePath' => $basePath,
			'styles' => [ 'bom.css' ],
		] );
		$testModule->setName( 'testing' );
		$this->assertEquals(
			"\xef\xbb\xbf.efbbbf",
			substr( file_get_contents( "$basePath/bom.css" ), 0, 10 ),
			'File has leading BOM'
		);

		$context = $this->getResourceLoaderContext();
		$this->assertEquals(
			[ 'all' => ".efbbbf_bom_char_at_start_of_file {}\n" ],
			$testModule->getStyles( $context ),
			'Leading BOM removed when concatenating files'
		);
	}

	public function testLessFileCompilation() {
		$context = $this->getResourceLoaderContext();
		$basePath = __DIR__ . '/../../data/less/module';
		$module = new ResourceLoaderFileTestModule( [
			'localBasePath' => $basePath,
			'styles' => [ 'styles.less' ],
			'lessVars' => [ 'foo' => '2px', 'Foo' => '#eeeeee' ]
		] );
		$module->setName( 'test.less' );
		$module->setConfig( $context->getResourceLoader()->getConfig() );
		$styles = $module->getStyles( $context );
		$this->assertStringEqualsFile( $basePath . '/styles.css', $styles['all'] );
	}

	public static function provideGetVersionHash() {
		$a = [];
		$b = [
			'lessVars' => [ 'key' => 'value' ],
		];
		yield 'with and without Less variables' => [ $a, $b, false ];

		$a = [
			'lessVars' => [ 'key' => 'value1' ],
		];
		$b = [
			'lessVars' => [ 'key' => 'value2' ],
		];
		yield 'different Less variables' => [ $a, $b, false ];

		$x = [
			'lessVars' => [ 'key' => 'value' ],
		];
		yield 'identical Less variables' => [ $x, $x, true ];

		$a = [
			'packageFiles' => [ [ 'name' => 'data.json', 'callback' => static function () {
				return [ 'aaa' ];
			} ] ]
		];
		$b = [
			'packageFiles' => [ [ 'name' => 'data.json', 'callback' => static function () {
				return [ 'bbb' ];
			} ] ]
		];
		yield 'packageFiles with different callback' => [ $a, $b, false ];

		$a = [
			'packageFiles' => [ [ 'name' => 'aaa.json', 'callback' => static function () {
				return [ 'x' ];
			} ] ]
		];
		$b = [
			'packageFiles' => [ [ 'name' => 'bbb.json', 'callback' => static function () {
				return [ 'x' ];
			} ] ]
		];
		yield 'packageFiles with different file name and a callback' => [ $a, $b, false ];

		$a = [
			'packageFiles' => [ [ 'name' => 'data.json', 'versionCallback' => static function () {
				return [ 'A-version' ];
			}, 'callback' => static function () {
				throw new LogicException( 'Unexpected computation' );
			} ] ]
		];
		$b = [
			'packageFiles' => [ [ 'name' => 'data.json', 'versionCallback' => static function () {
				return [ 'B-version' ];
			}, 'callback' => static function () {
				throw new LogicException( 'Unexpected computation' );
			} ] ]
		];
		yield 'packageFiles with different versionCallback' => [ $a, $b, false ];

		$a = [
			'packageFiles' => [ [ 'name' => 'aaa.json',
				'versionCallback' => static function () {
					return [ 'X-version' ];
				},
				'callback' => static function () {
					throw new LogicException( 'Unexpected computation' );
				}
			] ]
		];
		$b = [
			'packageFiles' => [ [ 'name' => 'bbb.json',
				'versionCallback' => static function () {
					return [ 'X-version' ];
				},
				'callback' => static function () {
					throw new LogicException( 'Unexpected computation' );
				}
			] ]
		];
		yield 'packageFiles with different file name and a versionCallback' => [ $a, $b, false ];
	}

	/**
	 * @dataProvider provideGetVersionHash
	 */
	public function testGetVersionHash( $a, $b, $isEqual ) {
		$context = $this->getResourceLoaderContext( [ 'debug' => 'false' ] );

		$moduleA = new ResourceLoaderFileTestModule( $a );
		$moduleA->setConfig( $context->getResourceLoader()->getConfig() );
		$versionA = $moduleA->getVersionHash( $context );
		$moduleB = new ResourceLoaderFileTestModule( $b );
		$moduleB->setConfig( $context->getResourceLoader()->getConfig() );
		$versionB = $moduleB->getVersionHash( $context );

		$this->assertSame(
			$isEqual,
			( $versionA === $versionB ),
			'Whether versions hashes are equal'
		);
	}

	public static function provideGetScriptPackageFiles() {
		$basePath = __DIR__ . '/../../data/resourceloader';
		$basePathB = __DIR__ . '/../../data/resourceloader-b';
		$base = [ 'localBasePath' => $basePath ];
		$commentScript = file_get_contents( "$basePath/script-comment.js" );
		$nosemiScript = file_get_contents( "$basePath/script-nosemi.js" );
		$nosemiBScript = file_get_contents( "$basePathB/script-nosemi.js" );
		$vueComponentDebug = trim( file_get_contents( "$basePath/vue-component-output-debug.js.txt" ) );
		$vueComponentNonDebug = trim( file_get_contents( "$basePath/vue-component-output-nondebug.js.txt" ) );
		$config = MediaWikiServices::getInstance()->getMainConfig();
		return [
			'plain package' => [
				$base + [
					'packageFiles' => [
						'script-comment.js',
						'script-nosemi.js'
					]
				],
				[
					'files' => [
						'script-comment.js' => [
							'type' => 'script',
							'content' => $commentScript,
							'filePath' => 'script-comment.js'
						],
						'script-nosemi.js' => [
							'type' => 'script',
							'content' => $nosemiScript,
							'filePath' => 'script-nosemi.js'
						]
					],
					'main' => 'script-comment.js'
				]
			],
			'explicit main file' => [
				$base + [
					'packageFiles' => [
						[ 'name' => 'init.js', 'file' => 'script-comment.js', 'main' => true ],
						[ 'name' => 'nosemi.js', 'file' => 'script-nosemi.js' ],
					]
				],
				[
					'files' => [
						'init.js' => [
							'type' => 'script',
							'content' => $commentScript,
							'filePath' => 'script-comment.js',
						],
						'nosemi.js' => [
							'type' => 'script',
							'content' => $nosemiScript,
							'filePath' => 'script-nosemi.js',
						]
					],
					'main' => 'init.js'
				]
			],
			'package file with callback' => [
				$base + [
					'packageFiles' => [
						[ 'name' => 'foo.json', 'content' => [ 'Hello' => 'world' ] ],
						'sample.json',
						[ 'name' => 'bar.js', 'content' => "console.log('Hello');" ],
						[
							'name' => 'data.json',
							'callback' => static function ( $context, $config, $extra ) {
								return [ 'langCode' => $context->getLanguage(), 'extra' => $extra ];
							},
							'callbackParam' => [ 'a' => 'b' ],
						],
						[ 'name' => 'config.json', 'config' => [
							'Sitename',
							'server' => 'ServerName',
						] ],
					]
				],
				[
					'files' => [
						'foo.json' => [
							'type' => 'data',
							'content' => [ 'Hello' => 'world' ],
							'virtualFilePath' => 'foo.json',
						],
						'sample.json' => [
							'type' => 'data',
							'content' => (object)[ 'foo' => 'bar', 'answer' => 42 ],
							'filePath' => 'sample.json',
						],
						'bar.js' => [
							'type' => 'script',
							'content' => "console.log('Hello');",
							'virtualFilePath' => 'bar.js',
						],
						'data.json' => [
							'type' => 'data',
							'content' => [ 'langCode' => 'fy', 'extra' => [ 'a' => 'b' ] ],
							'virtualFilePath' => 'data.json',
						],
						'config.json' => [
							'type' => 'data',
							'content' => [
								'Sitename' => $config->get( MainConfigNames::Sitename ),
								'server' => $config->get( MainConfigNames::ServerName ),
							],
							'virtualFilePath' => 'config.json',
						]
					],
					'main' => 'bar.js'
				],
				[
					'lang' => 'fy'
				]
			],
			'package file with callback and versionCallback' => [
				$base + [
					'packageFiles' => [
						[ 'name' => 'bar.js', 'content' => "console.log('Hello');" ],
						[
							'name' => 'data.json',
							'versionCallback' => static function ( $context ) {
								return 'x';
							},
							'callback' => static function ( $context, $config, $extra ) {
								return [ 'langCode' => $context->getLanguage(), 'extra' => $extra ];
							},
							'callbackParam' => [ 'A', 'B' ]
						],
					]
				],
				[
					'files' => [
						'bar.js' => [
							'type' => 'script',
							'content' => "console.log('Hello');",
							'virtualFilePath' => 'bar.js',
						],
						'data.json' => [
							'type' => 'data',
							'content' => [ 'langCode' => 'fy', 'extra' => [ 'A', 'B' ] ],
							'virtualFilePath' => 'data.json',
						],
					],
					'main' => 'bar.js'
				],
				[
					'lang' => 'fy'
				]
			],
			'package file with callback that returns a file (1)' => [
				$base + [
					'packageFiles' => [
						[ 'name' => 'dynamic.js', 'callback' => static function ( $context ) {
							$file = $context->getLanguage() === 'fy' ? 'script-comment.js' : 'script-nosemi.js';
							return new FilePath( $file );
						} ]
					]
				],
				[
					'files' => [
						'dynamic.js' => [
							'type' => 'script',
							'content' => $commentScript,
							'filePath' => 'script-comment.js',
						]
					],
					'main' => 'dynamic.js'
				],
				[
					'lang' => 'fy'
				]
			],
			'package file with callback that returns a file (2)' => [
				$base + [
					'packageFiles' => [
						[ 'name' => 'dynamic.js', 'callback' => static function ( $context ) {
							$file = $context->getLanguage() === 'fy' ? 'script-comment.js' : 'script-nosemi.js';
							return new FilePath( $file );
						} ]
					]
				],
				[
					'files' => [
						'dynamic.js' => [
							'type' => 'script',
							'content' => $nosemiScript,
							'filePath' => 'script-nosemi.js'
						]
					],
					'main' => 'dynamic.js'
				],
				[
					'lang' => 'nl'
				]
			],
			'package file with callback that returns a file with base path' => [
				$base + [
					'packageFiles' => [
						[ 'name' => 'dynamic.js', 'callback' => static function () use ( $basePathB ) {
							return new FilePath( 'script-nosemi.js', $basePathB );
						} ]
					]
				],
				[
					'files' => [
						'dynamic.js' => [
							'type' => 'script',
							'content' => $nosemiBScript,
							'filePath' => 'script-nosemi.js',
						]
					],
					'main' => 'dynamic.js'
				]
			],
			'.vue file in debug mode' => [
				$base + [
					'packageFiles' => [
						'vue-component.vue'
					]
				],
				[
					'files' => [
						'vue-component.vue' => [
							'type' => 'script',
							'content' => $vueComponentDebug,
							'filePath' => 'vue-component.vue',
						]
					],
					'main' => 'vue-component.vue',
				],
				[
					'debug' => 'true'
				]
			],
			'.vue file in non-debug mode' => [
				$base + [
					'packageFiles' => [
						'vue-component.vue'
					],
					'name' => 'nondebug',
				],
				[
					'files' => [
						'vue-component.vue' => [
							'type' => 'script',
							'content' => $vueComponentNonDebug,
							'filePath' => 'vue-component.vue',
						]
					],
					'main' => 'vue-component.vue'
				],
				[
					'debug' => 'false'
				]
			],
			'missing name' => [
				$base + [
					'packageFiles' => [
						[ 'file' => 'script-comment.js' ]
					]
				],
				LogicException::class
			],
			'package file with invalid callback' => [
				$base + [
					'packageFiles' => [
						[ 'name' => 'foo.json', 'callback' => 'functionThatDoesNotExist142857' ]
					]
				],
				LogicException::class
			],
			'config not valid for script type' => [
				$base + [
					'packageFiles' => [
						'foo.json' => [ 'type' => 'script', 'config' => [ 'Sitename' ] ]
					]
				],
				LogicException::class
			],
			'config not valid for *.js file' => [
				$base + [
					'packageFiles' => [
						[ 'name' => 'foo.js', 'config' => 'Sitename' ]
					]
				],
				LogicException::class
			],
			'missing type/name/file' => [
				$base + [
					'packageFiles' => [
						'foo.js' => [ 'garbage' => 'data' ]
					]
				],
				LogicException::class
			],
			'nonexistent file' => [
				$base + [
					'packageFiles' => [
						'filethatdoesnotexist142857.js'
					]
				],
				RuntimeException::class
			],
			'JSON can\'t be a main file' => [
				$base + [
					'packageFiles' => [
						'script-nosemi.js',
						[ 'name' => 'foo.json', 'content' => [ 'Hello' => 'world' ], 'main' => true ]
					]
				],
				LogicException::class
			]
		];
	}

	/**
	 * @dataProvider provideGetScriptPackageFiles
	 */
	public function testGetScriptPackageFiles( $moduleDefinition, $expected, $contextOptions = [] ) {
		$module = new FileModule( $moduleDefinition );
		$context = $this->getResourceLoaderContext( $contextOptions );
		$module->setConfig( $context->getResourceLoader()->getConfig() );
		if ( isset( $moduleDefinition['name'] ) ) {
			$module->setName( $moduleDefinition['name'] );
		}
		if ( is_string( $expected ) ) {
			// $expected is the class name of the expected exception
			$this->expectException( $expected );
			$module->getScript( $context );
			$this->fail( "$expected exception expected" );
		}

		// Check name property and convert filePath to plain data
		$result = $module->getScript( $context );
		foreach ( $result['files'] as $name => &$file ) {
			$this->assertSame( $name, $file['name'] );
			unset( $file['name'] );
			if ( isset( $file['filePath'] ) ) {
				$this->assertInstanceOf( FilePath::class, $file['filePath'] );
				$file['filePath'] = $file['filePath']->getPath();
			}
			if ( isset( $file['virtualFilePath'] ) ) {
				$this->assertInstanceOf( FilePath::class, $file['virtualFilePath'] );
				$file['virtualFilePath'] = $file['virtualFilePath']->getPath();
			}
		}
		// Check the rest of the result
		$this->assertEquals( $expected, $result );
	}

	public function testRequiresES6() {
		$module = new FileModule();
		$this->assertTrue( $module->requiresES6(), 'requiresES6 defaults to true' );
		$module = new FileModule( [ 'es6' => false ] );
		$this->assertTrue( $module->requiresES6(), 'requiresES6 is true even when set to false' );
		$module = new FileModule( [ 'es6' => true ] );
		$this->assertTrue( $module->requiresES6(), 'requiresES6 is true when set to true' );
	}

	/**
	 * @covers \Wikimedia\DependencyStore\DependencyStore
	 * @covers \Wikimedia\DependencyStore\KeyValueDependencyStore
	 */
	public function testIndirectDependencies() {
		$context = $this->getResourceLoaderContext();
		$moduleInfo = [ 'dir' => __DIR__ . '/../../data/less/module',
		'lessVars' => [ 'foo' => '2px', 'Foo' => '#eeeeee' ], 'name' => 'styles-dependencies' ];

		$module = $this->newModuleRequest( $moduleInfo, $context );
		$module->getStyles( $context );

		$module = $this->newModuleRequest( $moduleInfo, $context );
		$dependencies = $module->getFileDependencies( $context );

		$expectedDependencies = [ realpath( __DIR__ . '/../../data/less/common/test.common.mixins.less' ),
		realpath( __DIR__ . '/../../data/less/module/dependency.less' ) ];

		$this->assertEquals( $expectedDependencies, $dependencies );
	}

	/**
	 * @covers \Wikimedia\DependencyStore\DependencyStore
	 * @covers \Wikimedia\DependencyStore\KeyValueDependencyStore
	 */
	public function testIndirectDependenciesUpdate() {
		$context = $this->getResourceLoaderContext();
		$tempDir = $this->getNewTempDirectory();
		$moduleInfo = [ 'dir' => $tempDir, 'name' => 'new-dependencies' ];

		file_put_contents( "$tempDir/styles.less", "@import './test.less';" );
		file_put_contents( "$tempDir/test.less", "div { color: red; } " );

		$module = $this->newModuleRequest( $moduleInfo, $context );
		$module->getStyles( $context );

		$module = $this->newModuleRequest( $moduleInfo, $context );
		$dependencies = $module->getFileDependencies( $context );

		$expectedDependencies = [ realpath( $tempDir . '/test.less' ) ];

		$this->assertEquals( $expectedDependencies, $dependencies );

		file_put_contents( "$tempDir/styles.less", "@import './pink.less';" );
		file_put_contents( "$tempDir/pink.less", "div { color: pink; } " );

		$module = $this->newModuleRequest( $moduleInfo, $context );
		$module->getStyles( $context );

		$module = $this->newModuleRequest( $moduleInfo, $context );
		$dependencies = $module->getFileDependencies( $context );

		$expectedDependencies = [ realpath( $tempDir . '/pink.less' ) ];

		$this->assertEquals( $expectedDependencies, $dependencies );
	}

	public function newModuleRequest( $moduleInfo, $context ) {
		$module = new ResourceLoaderFileTestModule( [
			'localBasePath' => $moduleInfo['dir'],
			'styles' => [ 'styles.less' ],
			'lessVars' => $moduleInfo['lessVars'] ?? null
		] );

		$module->setName( $moduleInfo['name'] );
		$module->setConfig( $context->getResourceLoader()->getConfig() );
		$module->setDependencyAccessCallbacks(
			[ $context->getResourceLoader(), 'loadModuleDependenciesInternal' ],
			[ $context->getResourceLoader(), 'saveModuleDependenciesInternal' ]
		);
		$wrapper = TestingAccessWrapper::newFromObject( $module );
		return $wrapper;
	}
}
PK       ! lgs  s  /  ResourceLoader/ResourceLoaderEntryPointTest.phpnu Iw        <?php

namespace MediaWiki\Tests\ResourceLoader;

use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\ResourceLoader\ResourceLoader;
use MediaWiki\ResourceLoader\ResourceLoaderEntryPoint;
use MediaWiki\Tests\MockEnvironment;
use MediaWikiIntegrationTestCase;

/**
 * @group ResourceLoader
 * @group Database
 * @covers \MediaWiki\ResourceLoader\ResourceLoader
 */
class ResourceLoaderEntryPointTest extends MediaWikiIntegrationTestCase {

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

		$this->overrideConfigValue( MainConfigNames::ShowExceptionDetails, true );
		$this->setService( 'ResourceLoader', [ $this, 'newResourceLoader' ] );
	}

	public function newResourceLoader() {
		// See also ResourceLoaderTestCase::getResourceLoaderContext
		return new ResourceLoader(
			$this->getServiceContainer()->getMainConfig(),
			null,
			null,
			[
				'loadScript' => '/w/load.php',
			]
		);
	}

	private function getEntryPoint( FauxRequest $request ) {
		$env = new MockEnvironment( $request );
		return new ResourceLoaderEntryPoint(
			$env->makeFauxContext(),
			$env,
			$this->getServiceContainer()
		);
	}

	/**
	 * Further tested in StartUpModuleTest and ResourceLoaderTest
	 */
	public function testExecute() {
		// Simulate a real request, such as the one from RL\ClientHtml::getHeadHtml
		$request = new FauxRequest( [ 'modules' => 'startup', 'only' => 'scripts', 'raw' => '1' ] );
		$request->setRequestURL( '/w/load.php' );
		$entryPoint = $this->getEntryPoint( $request );

		$entryPoint->enableOutputCapture();
		$entryPoint->run();
		$content = $entryPoint->getCapturedOutput();

		$this->assertStringContainsString( 'function isCompatible', $content );
		$this->assertStringContainsString( 'mw.loader.addSource(', $content );

		// TODO: Assert headers once ResourceLoader doesn't call header()
		// directly (maybe a library version of WebResponse).
	}

}
PK       ! 3S  3S  $  filerepo/ThumbnailEntryPointTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\FileRepo\ThumbnailEntryPoint;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\SimpleAuthority;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\FileRepo\TestRepoTrait;
use MediaWiki\Tests\MockEnvironment;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentityValue;

/**
 * @covers \MediaWiki\FileRepo\ThumbnailEntryPoint
 * @group Database
 */
class ThumbnailEntryPointTest extends MediaWikiIntegrationTestCase {

	use TestRepoTrait;
	use MockHttpTrait;

	private const PNG_MAGIC = "\x89\x50\x4e\x47";
	private const JPEG_MAGIC = "\xff\xd8\xff\xe0";

	private const IMAGES_DIR = __DIR__ . '/../../data/media';

	/** @var int Counter for getting unique width values */
	private static $uniqueWidth = 20;

	private ?MockEnvironment $environment = null;

	/**
	 * will be called only once per test class
	 */
	public function addDBDataOnce() {
		// Set a named user account for the request context as the default,
		// so that these tests do not fail with temp accounts enabled
		RequestContext::getMain()->setUser( $this->getTestUser()->getUser() );
		// Create mock repo with test files
		$this->initTestRepoGroup();

		$this->importFileToTestRepo( self::IMAGES_DIR . '/greyscale-png.png', 'Test.png' );
		$this->importFileToTestRepo( self::IMAGES_DIR . '/test.jpg', 'Icon.jpg' );

		// Create a second version of Test.png and Icon.jpg
		$this->importFileToTestRepo( self::IMAGES_DIR . '/greyscale-na-png.png', 'Test.png' );
		$this->importFileToTestRepo( self::IMAGES_DIR . '/portrait-rotated.jpg', 'Icon.jpg' );

		// Create a redirect
		$title = Title::makeTitle( NS_FILE, 'Redirect_to_Test.png' );
		$this->editPage( $title, '#REDIRECT [[File:Test.png]]' );

		// Suppress the old version of Icon
		$file = $this->getTestRepo()->newFile( 'Icon.jpg' );
		$history = $file->getHistory();
		$oldFile = $history[0];

		$this->getDb()->newUpdateQueryBuilder()
			->table( 'oldimage' )
			->set( [ 'oi_deleted' => 1 ] )
			->where( [ 'oi_archive_name' => $oldFile->getArchiveName() ] )
			->caller( __METHOD__ )
			->execute();
	}

	public static function tearDownAfterClass(): void {
		self::destroyTestRepo();
		parent::tearDownAfterClass();
	}

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

		$this->overrideConfigValue( MainConfigNames::ThumbLimits, [ 16, 24 ] );
		$this->installTestRepoGroup();
	}

	private function recordHeader( string $header ) {
		$this->environment->getFauxResponse()->header( $header );
	}

	/**
	 * @param FauxRequest|string|array|null $request
	 *
	 * @return MockEnvironment
	 */
	private function makeEnvironment( $request ): MockEnvironment {
		if ( !$request ) {
			$request = new FauxRequest();
		}

		if ( is_string( $request ) ) {
			$request = [ 'f' => $request, 'width' => self::$uniqueWidth++ ];
		}

		if ( is_array( $request ) ) {
			$request = new FauxRequest( $request );
			$request->setRequestURL( '/w/img.php' );
		}

		$this->environment = new MockEnvironment( $request );
		return $this->environment;
	}

	/**
	 * @param MockEnvironment|null $environment
	 * @param FauxRequest|RequestContext|string|array|null $request
	 *
	 * @return ThumbnailEntryPoint
	 */
	private function getEntryPoint(
		?MockEnvironment $environment = null,
		$request = null
	) {
		if ( !$request && $environment ) {
			$request = $environment->getFauxRequest();
		}

		if ( $request instanceof RequestContext ) {
			$context = $request;
			$request = $context->getRequest();
		} else {
			$context = new RequestContext();
			$context->setRequest( $request );
			$context->setUser( $this->getTestUser()->getUser() );
		}

		if ( !$environment ) {
			$environment = $this->makeEnvironment( $request );
		}

		$context->setLanguage( 'qqx' );

		$entryPoint = new ThumbnailEntryPoint(
			$context,
			$environment,
			$this->getServiceContainer()
		);

		$entryPoint->enableOutputCapture();
		return $entryPoint;
	}

	public function testNotFound() {
		$env = $this->makeEnvironment( 'Missing_puppy.jpeg' );
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();

		$env->assertStatusCode( 404 );
		$env->assertHeaderValue(
			'text/html; charset=utf-8',
			'Content-Type'
		);

		$output = $entryPoint->getCapturedOutput();
		$this->assertStringContainsString(
			'<title>Error generating thumbnail</title>',
			$output
		);
	}

	public function testGenerateAndStreamThumbnail() {
		$env = $this->makeEnvironment(
			[
				'f' => 'Test.png',
				'width' => 12 // Must match the width in testStreamExistingThumbnail
			]
		);

		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$response = $env->getFauxResponse();
		$this->assertSame( 'image/png', $response->getHeader( 'Content-Type' ) );
		$this->assertGreaterThan( 500, (int)$response->getHeader( 'Content-Length' ) );

		$env->assertStatusCode( 200, $output );

		$this->assertThumbnail(
			[ 'magic' => self::PNG_MAGIC, 'width' => 12, ],
			$output
		);

		return [ 'data' => $output, 'width' => 12 ];
	}

	/**
	 * @depends testGenerateAndStreamThumbnail
	 */
	public function testStreamExistingThumbnail() {
		// Sabotage transformations, so this test will fail if we do not
		// use the existing thumbnail generated by testGenerateAndStreamThumbnail.
		$handler = $this->getMockBuilder( BitmapHandler::class )
			->onlyMethods( [ 'doTransform' ] )
			->getMock();

		$handler->expects( $this->never() )->method( 'doTransform' );

		$factory = $this->createNoOpMock( MediaHandlerFactory::class, [ 'getHandler' ] );
		$factory->method( 'getHandler' )->willReturn( $handler );
		$this->setService( 'MediaHandlerFactory', $factory );

		$env = $this->makeEnvironment(
			[
				'f' => 'Test.png',
				'width' => 12 // Must match the width in testGenerateAndStreamThumbnail
			]
		);
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 200 );

		$this->assertThumbnail(
			[ 'magic' => self::PNG_MAGIC, 'width' => 12, ],
			$output
		);
	}

	public function testNoThumbName() {
		// Make sure no handler is set, so that File::generateThumbName() returns null
		$factory = $this->createNoOpMock( MediaHandlerFactory::class, [ 'getHandler' ] );
		$factory->method( 'getHandler' )->willReturn( false );
		$this->setService( 'MediaHandlerFactory', $factory );

		$env = $this->makeEnvironment(
			[
				'f' => 'Test.png',
				'width' => self::$uniqueWidth++
			]
		);
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 400, $output );
	}

	public static function provideTransformError() {
		yield 'MediaTransformError' => [
			new MediaTransformError( 'testing', 200, 100 ),
			500
		];

		yield 'thumbnail_image-failure-limit' => [
			new MediaTransformError( 'thumbnail_image-failure-limit', 200, 100 ),
			429
		];

		yield 'no thumb' => [
			false,
			500
		];

		yield 'no file path' => [
			new ThumbnailImage(
				new UnregisteredLocalFile( false, false, 'dummy' ),
				'',
				false,
				[ 'width' => 1, 'height' => 1 ]
			),
			500
		];
	}

	/**
	 * @dataProvider provideTransformError
	 */
	public function testTransformError( $transformOutput, $expectedCode ) {
		// Mock transformations to return an error
		$handler = $this->getMockBuilder( BitmapHandler::class )
			->onlyMethods( [ 'doTransform' ] )
			->getMock();

		$handler->method( 'doTransform' )->willReturn( $transformOutput );

		$factory = $this->createNoOpMock( MediaHandlerFactory::class, [ 'getHandler' ] );
		$factory->method( 'getHandler' )->willReturn( $handler );
		$this->setService( 'MediaHandlerFactory', $factory );

		$env = $this->makeEnvironment(
			[
				'f' => 'Test.png',
				'width' => self::$uniqueWidth++
			]
		);
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( $expectedCode, $output );
	}

	public function testContentDisposition() {
		$env = $this->makeEnvironment(
			[
				'f' => 'Test.png',
				'width' => 12,
				'download' => 1
			]
		);

		$entryPoint = $this->getEntryPoint( $env );
		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 200 );
		$this->assertThumbnail( [ 'magic' => self::PNG_MAGIC, ], $output );

		$env->assertHeaderValue(
			'attachment;filename*=UTF-8\'\'Test.png',
			'Content-Disposition'
		);
	}

	public static function provideThumbNameParam() {
		yield [ '12px-Test.png' ];
		yield [ 'page123456-12px-xyz' ];
		yield [ '12px-xyz' ];
		yield [ 'xyzzy', 400 ];
	}

	/**
	 * @dataProvider provideThumbNameParam
	 */
	public function testThumbNameParam( $thumbName, $expected = 200 ) {
		$env = $this->makeEnvironment(
			[
				'f' => 'Test.png',
				'thumbName' => $thumbName,
			]
		);

		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( $expected, $output );

		if ( $expected < 300 ) {
			$expectedProps = [ 'magic' => self::PNG_MAGIC ];

			// get expected width
			if ( preg_match( '/\b(\d+)px/', $thumbName, $matches ) ) {
				$expectedProps['width'] = (int)$matches[1];
			}

			$this->assertThumbnail(
				$expectedProps,
				$output
			);
		}
	}

	public function testAccessDenied() {
		// Make the wiki non-public
		$this->setGroupPermissions( '*', 'read', false );

		// Make the user have no rights
		$authority = new SimpleAuthority(
			new UserIdentityValue( 7, 'Heather' ),
			[]
		);

		$env = $this->makeEnvironment( 'Test.png' );

		$context = $env->makeFauxContext();
		$context->setAuthority( $authority );

		$entryPoint = $this->getEntryPoint(
			$env,
			$context
		);

		$entryPoint->run();

		$env->assertStatusCode( 403 );
		$env->assertHeaderValue(
			'text/html; charset=utf-8',
			'Content-Type'
		);

		$output = $entryPoint->getCapturedOutput();
		$this->assertStringContainsString(
			'<title>Error generating thumbnail</title>',
			$output
		);
	}

	public function testAccessOnPrivateWiki() {
		// Make the wiki non-public, so we don't use the short-circuit code
		$this->setGroupPermissions( '*', 'read', false );

		// Make a user who is allowed to read
		$authority = new SimpleAuthority(
			new UserIdentityValue( 7, 'Heather' ),
			[ 'read', 'renderfile', 'renderfile-nonstandard' ]
		);

		$env = $this->makeEnvironment( 'Test.png' );

		$context = $env->makeFauxContext();
		$context->setAuthority( $authority );

		$entryPoint = $this->getEntryPoint(
			$env,
			$context
		);

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 200 );
		$this->assertThumbnail( [ 'magic' => self::PNG_MAGIC, ], $output );
	}

	public static function provideRateLimit() {
		// NOTE: The 12px thumbnail will have been generated at this point.
		//       We force 16 and 24 to be standard sizes during setup.
		//       Once the thumbnail is generated, the rate limit is no longer
		//       triggered.
		yield [ '16', '24', 'renderfile' ];
		yield [ self::$uniqueWidth++, self::$uniqueWidth++, 'renderfile-nonstandard' ];
	}

	/**
	 * @dataProvider provideRateLimit
	 */
	public function testRateLimited( $width1, $width2, $limit ) {
		// Set up rate limit config
		$rateLimits = $this->getConfVar( MainConfigNames::RateLimits );
		$rateLimits[$limit] = [
			'ip' => [ 1, 60 ],
			'newbie' => [ 1, 60 ],
			'user' => [ 1, 60 ],
		];

		$this->overrideConfigValue( MainConfigNames::RateLimits, $rateLimits );

		// First run should pass
		$env = $this->makeEnvironment( [ 'f' => 'Test.png', 'width' => $width1 ] );
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$entryPoint->getCapturedOutput();
		$env->assertStatusCode( 200 );

		// Second run should fail
		$env = $this->makeEnvironment( [ 'f' => 'Test.png', 'width' => $width2 ] );
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$entryPoint->getCapturedOutput();
		$env->assertStatusCode( 429 );

		$env->assertHeaderValue(
			'text/html; charset=utf-8',
			'Content-Type'
		);
	}

	/**
	 * @depends testGenerateAndStreamThumbnail
	 */
	public function testStreamOldFile( array $latestThumbnailInfo ) {
		$file = $this->getTestRepo()->newFile( 'Test.png' );
		$history = $file->getHistory();
		$oldFile = $history[0];

		$env = $this->makeEnvironment(
			[
				'f' => $oldFile->getArchiveName(),
				'width' => '12px', // use "px" suffix, just so we also cover that code path
				'archived' => 1,
			]
		);
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();
		$env->assertStatusCode( 200 );

		$this->assertNotSame(
			$latestThumbnailInfo['data'],
			$output,
			'Thumbnail for the old version should not be the same as the ' .
				'thumbnail for the latest version'
		);

		$this->assertThumbnail(
			[ 'magic' => self::PNG_MAGIC, 'width' => 12, ],
			$output
		);
	}

	public function testOldDeletedFile() {
		// Note that we manually set oi_deleted for this revision
		// in addDBDataOnce().
		$file = $this->getTestRepo()->newFile( 'Icon.jpg' );
		$history = $file->getHistory();
		$oldFile = $history[0];

		$env = $this->makeEnvironment(
			[
				'f' => $oldFile->getArchiveName(),
				'width' => '12px', // use "px" suffix, just so we also cover that code path
				'archived' => 1,
			]
		);
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();
		$env->assertStatusCode( 404, $output );
	}

	/**
	 * @depends testGenerateAndStreamThumbnail
	 */
	public function testStreamOldFileRedirect( array $latestThumbnailInfo ) {
		$file = $this->getTestRepo()->newFile( 'Test.png' );
		$history = $file->getHistory();
		$oldFile = $history[0];

		// Try accessing the old revision using a redirected title
		$archiveName = str_replace(
			'Test.png',
			'Redirect_to_Test.png',
			$oldFile->getArchiveName()
		);

		$env = $this->makeEnvironment(
			[
				'f' => $archiveName,
				'width' => 12,
				'archived' => 1,
			]
		);
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$response = $env->getFauxResponse();

		$this->assertSame( 302, $response->getStatusCode() );

		$expected = '/' . urlencode( $oldFile->getArchiveName() ) . '/12px-Test.png';
		$this->assertStringEndsWith(
			$expected,
			$response->getHeader( 'Location' )
		);

		$this->assertSame( '', $output );
	}

	public function testStreamTempFile() {
		$user = $this->getTestUser()->getUser();
		$stash = new UploadStash( $this->getTestRepo(), $user );
		$file = $stash->stashFile( self::IMAGES_DIR . '/adobergb.jpg' );

		$env = $this->makeEnvironment(
			[
				'f' => $file->getName(),
				'width' => 12,
				'temp' => 'yes',
			]
		);
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 200 );
		$this->assertThumbnail(
			[ 'magic' => self::JPEG_MAGIC, 'width' => 12, ],
			$output
		);
	}

	public function testRedirect() {
		$this->overrideConfigValue( MainConfigNames::VaryOnXFP, true );

		$env = $this->makeEnvironment(
			[
				'f' => 'Redirect_to_Test.png',
				'w' => 12
			]
		);
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$response = $env->getFauxResponse();

		$this->assertSame( 302, $response->getStatusCode() );
		$this->assertStringEndsWith(
			'/Test.png/12px-Test.png',
			$response->getHeader( 'Location' )
		);

		$this->assertSame( '', $output );
		$env->assertHeaderValue( 'X-Forwarded-Proto', 'Vary' );
	}

	public function testBadTitle() {
		$env = $this->makeEnvironment( '_/_' );
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 404 );
		$env->assertHeaderValue(
			'text/html; charset=utf-8',
			'Content-Type'
		);

		$this->assertStringContainsString(
			'(badtitletext)',
			$output
		);
	}

	public static function provideOldFileWithBadTitle() {
		yield 'invalid title' => [ '_/_' ];
		yield 'valid title without timestamp' => [ 'Test.png' ];
		yield 'invalid title with timestamp' => [ '20200101002233!_/_' ];
	}

	/**
	 * @dataProvider provideOldFileWithBadTitle
	 */
	public function testOldFileWithBadTitle( $badTitle ) {
		$env = $this->makeEnvironment( [
			'f' => $badTitle,
			'width' => 12,
			'archived' => 1
		] );
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 404 );
		$env->assertHeaderValue(
			'text/html; charset=utf-8',
			'Content-Type'
		);

		$this->assertStringContainsString(
			'(badtitletext)',
			$output
		);
	}

	public function testTooMuchWidth() {
		// Set the width larger than the size of the image
		$env = $this->makeEnvironment( [ 'f' => 'Test.png', 'width' => 1200 ] );

		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 400 );
		$env->assertHeaderValue(
			'text/html; charset=utf-8',
			'Content-Type'
		);

		$this->assertStringContainsString(
			'(thumbnail_error: ',
			$output
		);
		$this->assertStringContainsString(
			'bigger than the source',
			$output
		);
	}

	public function testDeletedFile() {
		// Delete Icon.jpg
		$icon = $this->getTestRepo()->newFile( 'Icon.jpg' );

		$this->assertTrue( $icon->exists() );// sanity
		$icon->deleteFile( 'testing', new UserIdentityValue( 0, 'Test' ) );

		$env = $this->makeEnvironment( 'Icon.jpg' );
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 404 );
		$env->assertHeaderValue(
			'text/html; charset=utf-8',
			'Content-Type'
		);

		$this->assertStringContainsString(
			'<title>Error generating thumbnail</title>',
			$output
		);
	}

	public function testNotModified() {
		$env = $this->makeEnvironment(
			[
				'f' => 'Test.png',
				'width' => 12
			]
		);

		$env->setServerInfo( 'HTTP_IF_MODIFIED_SINCE', '25250101001122' );

		$entryPoint = $this->getEntryPoint( $env );
		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$response = $env->getFauxResponse();
		$this->assertSame( 304, $response->getStatusCode() );
		$this->assertSame( '', $output );
	}

	public function testProxy() {
		$this->installTestRepoGroup( [ 'thumbProxyUrl' => 'https://images.acme.test/thumbnails/' ] );
		$this->installMockHttp( 'PROXY RESPONSE' );

		$env = $this->makeEnvironment(
			[
				'f' => 'Test.png',
				'width' => self::$uniqueWidth++
			]
		);
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$this->assertSame( 'PROXY RESPONSE', $output );
	}

	public static function provideRepoCouldNotStreamFile() {
		// TODO: figure out how to provoke an error in
		// MediaTransformOutput::streamFileWithStatus.
		// The below causes an error to be triggered too early.
		// Since MediaTransformOutput uses StreamFile directly, we have to also
		// sabotage transformations in the handler to return a ThumbnailImage
		// with no path. This is unfortunately brittle to implementation changes.

		// The width must match the one generated by testGenerateAndStreamThumbnail
		/*yield 'existing thumbnail' => [
			12,
			'Could not stream the file',
		];*/

		// The specific error message may change as the code evolves
		yield 'non-existing thumbnail' => [
			self::$uniqueWidth++,
			'No path supplied in thumbnail object',
		];
	}

	/**
	 * @dataProvider provideRepoCouldNotStreamFile
	 * @depends testGenerateAndStreamThumbnail
	 */
	public function testRepoCouldNotStreamFile( int $width, string $expectedError ) {
		$handler = $this->getMockBuilder( BitmapHandler::class )
			->onlyMethods( [ 'doTransform' ] )
			->getMock();

		$file = $this->getTestRepo()->newFile( 'Test.png' );
		$params = [ 'width' => $width, 'height' => $width ];
		$handler->method( 'doTransform' )->willReturn(
			new ThumbnailImage( $file, '', false, $params )
		);

		$factory = $this->createNoOpMock( MediaHandlerFactory::class, [ 'getHandler' ] );
		$factory->method( 'getHandler' )->willReturn( $handler );
		$this->setService( 'MediaHandlerFactory', $factory );

		$env = $this->makeEnvironment(
			[
				'f' => 'Test.png',
				'width' => $width
			]
		);
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 500, $output );
		$env->assertHeaderValue(
			'text/html; charset=utf-8',
			'Content-Type'
		);

		$this->assertStringContainsString( $expectedError, $output );
	}

	/**
	 * @param array $props
	 * @param string $output binary data
	 */
	private function assertThumbnail( array $props, string $output ): void {
		if ( isset( $props['magic'] ) ) {
			$this->assertStringStartsWith(
				$props['magic'],
				$output,
				'Magic number should match'
			);
		}

		if ( isset( $props['width'] ) && function_exists( 'getimagesizefromstring' ) ) {
			[ $width, ] = getimagesizefromstring( $output );
			$this->assertSame(
				$props['width'],
				$width
			);
		}
	}

}
PK       ! (  (    filerepo/LocalRepoTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use MediaWiki\WikiMap\WikiMap;
use Wikimedia\ObjectCache\EmptyBagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;

/**
 * @group Database
 * @covers \FileRepo
 * @covers \LocalRepo
 */
class LocalRepoTest extends MediaWikiIntegrationTestCase {
	/**
	 * @param array $extraInfo To pass to LocalRepo constructor
	 * @return LocalRepo
	 */
	private function newRepo( array $extraInfo = [] ) {
		return new LocalRepo( $extraInfo + [
			'name' => 'local',
			'backend' => 'local-backend',
		] );
	}

	/**
	 * @param array $extraInfo To pass to constructor
	 * @param bool $expected
	 * @dataProvider provideHasSha1Storage
	 */
	public function testHasSha1Storage( array $extraInfo, $expected ) {
		$this->assertSame( $expected, $this->newRepo( $extraInfo )->hasSha1Storage() );
	}

	public static function provideHasSha1Storage() {
		return [
			[ [], false ],
			[ [ 'storageLayout' => 'sha256' ], false ],
			[ [ 'storageLayout' => 'sha1' ], true ],
		];
	}

	/**
	 * @param string $prefix 'img' or 'oi'
	 * @param string $expectedClass 'LocalFile' or 'OldLocalFile'
	 * @dataProvider provideNewFileFromRow
	 */
	public function testNewFileFromRow( $prefix, $expectedClass ) {
		$this->editPage( 'File:Test_file', 'Some description' );

		$row = (object)[
			"{$prefix}_name" => 'Test_file',
			"{$prefix}_user" => '1',
			"{$prefix}_timestamp" => '12345678910111',
			"{$prefix}_metadata" => '',
			"{$prefix}_sha1" => sha1( '' ),
			"{$prefix}_size" => '0',
			"{$prefix}_height" => '0',
			"{$prefix}_width" => '0',
			"{$prefix}_bits" => '0',
			"{$prefix}_media_type" => 'UNKNOWN',
			"{$prefix}_description_text" => '',
			"{$prefix}_description_data" => null,
		];
		if ( $prefix === 'oi' ) {
			$row->oi_archive_name = 'Archive_name';
			$row->oi_deleted = '0';
		}
		$file = $this->newRepo()->newFileFromRow( $row );
		$this->assertInstanceOf( $expectedClass, $file );
		$this->assertSame( 'Test_file', $file->getName() );
		$this->assertSame( 1, $file->getUploader()->getId() );
	}

	public static function provideNewFileFromRow() {
		return [
			'img' => [ 'img', LocalFile::class ],
			'oi' => [ 'oi', OldLocalFile::class ],
		];
	}

	public function testNewFileFromRow_invalid() {
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( 'LocalRepo::newFileFromRow: invalid row' );

		$row = (object)[
			"img_user" => '1',
			"img_timestamp" => '12345678910111',
			"img_metadata" => '',
			"img_sha1" => sha1( '' ),
			"img_size" => '0',
			"img_height" => '0',
			"img_width" => '0',
			"img_bits" => '0',
		];
		$file = $this->newRepo()->newFileFromRow( $row );
	}

	public function testNewFromArchiveName() {
		$this->editPage( 'File:Test_file', 'Some description' );

		$file = $this->newRepo()->newFromArchiveName( 'Test_file', 'b' );
		$this->assertInstanceOf( OldLocalFile::class, $file );
		$this->assertSame( 'Test_file', $file->getName() );

		$page = $this->getExistingTestPage( 'File:Test_file' );
		$file = $this->newRepo()->newFromArchiveName( $page, 'b' );
		$this->assertInstanceOf( OldLocalFile::class, $file );
		$this->assertSame( 'Test_file', $file->getName() );
	}

	// TODO cleanupDeletedBatch, deletedFileHasKey, hiddenFileHasKey

	public function testCleanupDeletedBatch_sha1Storage() {
		$this->assertEquals( Status::newGood(),
			$this->newRepo( [ 'storageLayout' => 'sha1' ] )->cleanupDeletedBatch( [] ) );
	}

	/**
	 * @param string $input
	 * @param string $expected
	 * @dataProvider provideGetHashFromKey
	 */
	public function testGetHashFromKey( $input, $expected ) {
		$this->assertSame( $expected, LocalRepo::getHashFromKey( $input ) );
	}

	public static function provideGetHashFromKey() {
		return [
			[ '', false ],
			[ '.', false ],
			[ 'a.', 'a' ],
			[ '.b', 'b' ],
			[ '..c', 'c' ],
			[ 'd.x', 'd' ],
			[ '.e.x', 'e' ],
			[ '..f.x', 'f' ],
			[ 'g..x', 'g' ],
			[ '01234567890123456789012345678901.x', '1234567890123456789012345678901' ],
		];
	}

	public function testCheckRedirect_nonRedirect() {
		$this->editPage( 'File:Not a redirect', 'Not a redirect' );
		$this->assertFalse(
			$this->newRepo()->checkRedirect( Title::makeTitle( NS_FILE, 'Not a redirect' ) ) );
	}

	public function testCheckRedirect_redirect() {
		$this->editPage( 'File:Redirect', '#REDIRECT [[File:Target]]' );

		$target = $this->newRepo()->checkRedirect( Title::makeTitle( NS_FILE, 'Redirect' ) );
		$this->assertEquals( 'File:Target', $target->getPrefixedText() );

		$page = $this->getExistingTestPage( 'File:Redirect' );
		$target = $this->newRepo()->checkRedirect( $page );
		$this->assertEquals( 'File:Target', $target->getPrefixedText() );
	}

	public function testCheckRedirectSharedEmptyCache() {
		$dbDomain = WikiMap::getCurrentWikiDbDomain()->getId();
		$mockBag = $this->getMockBuilder( EmptyBagOStuff::class )
			->onlyMethods( [ 'makeKey', 'makeGlobalKey' ] )
			->getMock();
		$mockBag->expects( $this->never() )
			->method( 'makeKey' )
			->with(
				'filerepo-file-redirect', 'local', md5( 'Redirect' )
			);
		$mockBag->expects( $this->once() )
			->method( 'makeGlobalKey' )
			->with(
				'filerepo-file-redirect', $dbDomain, md5( 'Redirect' )
			)->willReturn(
				implode( ':', [ 'filerepo-file-redirect', $dbDomain, md5( 'Redirect' ) ] )
			);

		$wanCache = new WANObjectCache( [ 'cache' => $mockBag ] );
		$repo = $this->newRepo( [ 'wanCache' => $wanCache ] );

		$this->editPage( 'File:Redirect', '#REDIRECT [[File:Target]]' );
		$this->assertEquals( 'File:Target',
			$repo->checkRedirect( Title::makeTitle( NS_FILE, 'Redirect' ) )->getPrefixedText() );
	}

	public function testCheckRedirect_invalidFile() {
		$this->expectException( RuntimeException::class );
		$this->expectExceptionMessage( '`Notafile` is not a valid file title.' );
		$this->newRepo()->checkRedirect( Title::makeTitle( NS_MAIN, 'Notafile' ) );
	}

	public function testFindBySha1() {
		$this->markTestIncomplete( "Haven't figured out how to upload files yet" );

		$repo = $this->newRepo();

		$tmpFileFactory = $this->getServiceContainer()->getTempFSFileFactory();
		foreach ( [ 'File1', 'File2', 'File3' ] as $name ) {
			$fsFile = $tmpFileFactory->newTempFSFile( '' );
			file_put_contents( $fsFile->getPath(), "$name contents" );
			$localFile = $repo->newFile( $name );
			$localFile->upload( $fsFile, 'Uploaded', "$name desc" );
		}
	}

	public function testInvalidateImageRedirect() {
		global $wgTestMe;
		$wgTestMe = true;
		$repo = $this->newRepo(
			[ 'wanCache' => new WANObjectCache( [ 'cache' => new HashBagOStuff ] ) ] );

		$title = Title::makeTitle( NS_FILE, 'Redirect' );

		$this->editPage( 'File:Redirect', '#REDIRECT [[File:Target]]' );

		$this->assertSame( 'File:Target',
			$repo->checkRedirect( $title )->getPrefixedText() );

		$this->editPage( 'File:Redirect', 'No longer a redirect' );

		$this->assertSame( 'File:Target',
			$repo->checkRedirect( $title )->getPrefixedText() );

		$repo->invalidateImageRedirect( $title );

		$this->markTestIncomplete(
			"Can't figure out how to get image redirect validation to take effect" );

		$this->assertSame( false, $repo->checkRedirect( $title ) );
	}

	public function testGetInfo() {
		$this->overrideConfigValues( [
			MainConfigNames::Server => '//example.org',
			MainConfigNames::Favicon => 'https://global.example/favicon.ico',
			MainConfigNames::Sitename => 'Test my site',
		] );

		$repo = $this->newRepo( [ 'favicon' => '/img/favicon.ico' ] );

		$this->assertSame( [
			'name' => 'local',
			'displayname' => 'Test my site',
			'rootUrl' => false,
			'local' => true,
			'url' => false,
			'thumbUrl' => false,
			'initialCapital' => true,
			// This expands to HTTP instead of HTTPS because the test context imitates HTTP
			'favicon' => 'http://example.org/img/favicon.ico',
		], $repo->getInfo() );
	}

	// XXX The following getInfo tests are really testing FileRepo, not LocalRepo, but we want to
	// make sure they're true for LocalRepo too. How should we do this? A trait?

	public function testGetInfo_name() {
		$this->assertSame( 'some-name',
			$this->newRepo( [ 'name' => 'some-name' ] )->getInfo()['name'] );
	}

	public function testGetInfo_displayName() {
		$this->assertSame( wfMessage( 'shared-repo' )->text(),
			$this->newRepo( [ 'name' => 'not-local' ] )->getInfo()['displayname'] );
	}

	public function testGetInfo_displayNameCustomMsg() {
		$this->editPage( 'MediaWiki:Shared-repo-name-not-local', 'Name to display please' );
		// Allow the message to take effect
		$this->getServiceContainer()->getMessageCache()->enable();

		$this->assertSame( 'Name to display please',
			$this->newRepo( [ 'name' => 'not-local' ] )->getInfo()['displayname'] );
	}

	public function testGetInfo_rootUrl() {
		$this->assertSame( 'https://my.url',
			$this->newRepo( [ 'url' => 'https://my.url' ] )->getInfo()['rootUrl'] );
	}

	public function testGetInfo_rootUrlCustomized() {
		$this->assertSame(
			'https://my.url/some/sub/dir',
			$this->newRepo( [
				'url' => 'https://my.url',
				'zones' => [ 'public' => [ 'url' => 'https://my.url/some/sub/dir' ] ],
			] )->getInfo()['rootUrl']
		);
	}

	public function testGetInfo_local() {
		$this->assertFalse( $this->newRepo( [ 'name' => 'not-local' ] )->getInfo()['local'] );
	}

	/**
	 * @param string $setting
	 * @dataProvider provideGetInfo_optionalSettings
	 */
	public function testGetInfo_optionalSettings( $setting ) {
		$this->assertSame( 'dummy test value',
			$this->newRepo( [ $setting => 'dummy test value' ] )->getInfo()[$setting] );
	}

	public static function provideGetInfo_optionalSettings() {
		return [
			[ 'url' ],
			[ 'thumbUrl' ],
			[ 'initialCapital' ],
			[ 'descBaseUrl' ],
			[ 'scriptDirUrl' ],
			[ 'articleUrl' ],
			[ 'fetchDescription' ],
			[ 'descriptionCacheExpiry' ],
		];
	}

	/**
	 * @dataProvider provideSkipWriteOperationIfSha1
	 */
	public function testSkipWriteOperationIfSha1( $method, ...$args ) {
		$repo = $this->newRepo( [ 'storageLayout' => 'sha1' ] );
		$this->assertEquals( Status::newGood(), $repo->$method( ...$args ) );
	}

	public static function provideSkipWriteOperationIfSha1() {
		return [
			[ 'store', '', '', '' ],
			[ 'storeBatch', [ '' ] ],
			[ 'cleanupBatch', [ '' ] ],
			[ 'publish', '', '', '' ],
			[ 'publishBatch', [ '' ] ],
			[ 'delete', '', '' ],
			[ 'deleteBatch', [ '' ] ],
		];
	}
}
PK       ! L'=  =    filerepo/file/FileTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Title\TitleValue;
use MediaWiki\WikiMap\WikiMap;
use Wikimedia\FileBackend\FSFile\FSFile;
use Wikimedia\FileBackend\FSFile\TempFSFile;
use Wikimedia\FileBackend\FSFileBackend;

class FileTest extends MediaWikiMediaTestCase {

	/**
	 * @param string $filename
	 * @param bool $expected
	 * @dataProvider providerCanAnimate
	 * @covers \File::canAnimateThumbIfAppropriate
	 */
	public function testCanAnimateThumbIfAppropriate( $filename, $expected ) {
		$this->overrideConfigValue( MainConfigNames::MaxAnimatedGifArea, 9000 );
		$file = $this->dataFile( $filename );
		$this->assertEquals( $expected, $file->canAnimateThumbIfAppropriate() );
	}

	public static function providerCanAnimate() {
		return [
			[ 'nonanimated.gif', true ],
			[ 'jpeg-comment-utf.jpg', true ],
			[ 'test.tiff', true ],
			[ 'Animated_PNG_example_bouncing_beach_ball.png', false ],
			[ 'greyscale-png.png', true ],
			[ 'Toll_Texas_1.svg', true ],
			[ 'LoremIpsum.djvu', true ],
			[ '80x60-2layers.xcf', true ],
			[ 'Soccer_ball_animated.svg', false ],
			[ 'Bishzilla_blink.gif', false ],
			[ 'animated.gif', true ],
		];
	}

	/**
	 * @dataProvider getThumbnailBucketProvider
	 * @covers \File::getThumbnailBucket
	 */
	public function testGetThumbnailBucket( $data ) {
		$this->overrideConfigValues( [
			MainConfigNames::ThumbnailBuckets => $data['buckets'],
			MainConfigNames::ThumbnailMinimumBucketDistance => $data['minimumBucketDistance'],
		] );

		$fileMock = $this->getMockBuilder( File::class )
			->setConstructorArgs( [ 'fileMock', false ] )
			->onlyMethods( [ 'getWidth' ] )
			->getMockForAbstractClass();

		$fileMock->method( 'getWidth' )
			->willReturn( $data['width'] );

		$this->assertEquals(
			$data['expectedBucket'],
			$fileMock->getThumbnailBucket( $data['requestedWidth'] ),
			$data['message'] );
	}

	public static function getThumbnailBucketProvider() {
		$defaultBuckets = [ 256, 512, 1024, 2048, 4096 ];

		return [
			[ [
				'buckets' => $defaultBuckets,
				'minimumBucketDistance' => 0,
				'width' => 3000,
				'requestedWidth' => 120,
				'expectedBucket' => 256,
				'message' => 'Picking bucket bigger than requested size'
			] ],
			[ [
				'buckets' => $defaultBuckets,
				'minimumBucketDistance' => 0,
				'width' => 3000,
				'requestedWidth' => 300,
				'expectedBucket' => 512,
				'message' => 'Picking bucket bigger than requested size'
			] ],
			[ [
				'buckets' => $defaultBuckets,
				'minimumBucketDistance' => 0,
				'width' => 3000,
				'requestedWidth' => 1024,
				'expectedBucket' => 2048,
				'message' => 'Picking bucket bigger than requested size'
			] ],
			[ [
				'buckets' => $defaultBuckets,
				'minimumBucketDistance' => 0,
				'width' => 3000,
				'requestedWidth' => 2048,
				'expectedBucket' => false,
				'message' => 'Picking no bucket because none is bigger than the requested size'
			] ],
			[ [
				'buckets' => $defaultBuckets,
				'minimumBucketDistance' => 0,
				'width' => 3000,
				'requestedWidth' => 3500,
				'expectedBucket' => false,
				'message' => 'Picking no bucket because requested size is bigger than original'
			] ],
			[ [
				'buckets' => [ 1024 ],
				'minimumBucketDistance' => 0,
				'width' => 3000,
				'requestedWidth' => 1024,
				'expectedBucket' => false,
				'message' => 'Picking no bucket because requested size equals biggest bucket'
			] ],
			[ [
				'buckets' => null,
				'minimumBucketDistance' => 0,
				'width' => 3000,
				'requestedWidth' => 1024,
				'expectedBucket' => false,
				'message' => 'Picking no bucket because no buckets have been specified'
			] ],
			[ [
				'buckets' => [ 256, 512 ],
				'minimumBucketDistance' => 10,
				'width' => 3000,
				'requestedWidth' => 245,
				'expectedBucket' => 256,
				'message' => 'Requested width is distant enough from next bucket for it to be picked'
			] ],
			[ [
				'buckets' => [ 256, 512 ],
				'minimumBucketDistance' => 10,
				'width' => 3000,
				'requestedWidth' => 246,
				'expectedBucket' => 512,
				'message' => 'Requested width is too close to next bucket, picking next one'
			] ],
		];
	}

	/**
	 * @dataProvider getThumbnailSourceProvider
	 * @covers \File::getThumbnailSource
	 */
	public function testGetThumbnailSource( $data ) {
		$backendMock = $this->getMockBuilder( FSFileBackend::class )
			->setConstructorArgs( [ [ 'name' => 'backendMock', 'wikiId' => WikiMap::getCurrentWikiId() ] ] )
			->getMock();

		$repoMock = $this->getMockBuilder( FileRepo::class )
			->setConstructorArgs( [ [ 'name' => 'repoMock', 'backend' => $backendMock ] ] )
			->onlyMethods( [ 'fileExists', 'getLocalReference' ] )
			->getMock();

		$tempDir = wfTempDir();
		$fsFile = new FSFile( 'fsFilePath' );

		$repoMock->method( 'fileExists' )
			->willReturn( true );

		$repoMock->method( 'getLocalReference' )
			->willReturn( $fsFile );

		$handlerMock = $this->getMockBuilder( BitmapHandler::class )
			->onlyMethods( [ 'supportsBucketing' ] )->getMock();
		$handlerMock->method( 'supportsBucketing' )
			->willReturn( $data['supportsBucketing'] );

		$fileMock = $this->getMockBuilder( File::class )
			->setConstructorArgs( [ 'fileMock', $repoMock ] )
			->onlyMethods( [ 'getThumbnailBucket', 'getLocalRefPath', 'getHandler' ] )
			->getMockForAbstractClass();

		$fileMock->method( 'getThumbnailBucket' )
			->willReturn( $data['thumbnailBucket'] );

		$fileMock->method( 'getLocalRefPath' )
			->willReturn( 'localRefPath' );

		$fileMock->method( 'getHandler' )
			->willReturn( $handlerMock );

		$reflection = new ReflectionClass( $fileMock );
		$reflection_property = $reflection->getProperty( 'handler' );
		$reflection_property->setAccessible( true );
		$reflection_property->setValue( $fileMock, $handlerMock );

		if ( $data['tmpBucketedThumbCache'] !== null ) {
			foreach ( $data['tmpBucketedThumbCache'] as &$tmpBucketed ) {
				$tmpBucketed = str_replace( '/tmp', $tempDir, $tmpBucketed );
			}
			$reflection_property = $reflection->getProperty( 'tmpBucketedThumbCache' );
			$reflection_property->setAccessible( true );
			$reflection_property->setValue( $fileMock, $data['tmpBucketedThumbCache'] );
		}

		$result = $fileMock->getThumbnailSource(
			[ 'physicalWidth' => $data['physicalWidth'] ] );

		$this->assertEquals(
			str_replace( '/tmp', $tempDir, $data['expectedPath'] ),
			$result['path'],
			$data['message']
		);
	}

	public static function getThumbnailSourceProvider() {
		return [
			[ [
				'supportsBucketing' => true,
				'tmpBucketedThumbCache' => null,
				'thumbnailBucket' => 1024,
				'physicalWidth' => 2048,
				'expectedPath' => 'fsFilePath',
				'message' => 'Path downloaded from storage'
			] ],
			[ [
				'supportsBucketing' => true,
				'tmpBucketedThumbCache' => [ 1024 => '/tmp/shouldnotexist' . rand() ],
				'thumbnailBucket' => 1024,
				'physicalWidth' => 2048,
				'expectedPath' => 'fsFilePath',
				'message' => 'Path downloaded from storage because temp file is missing'
			] ],
			[ [
				'supportsBucketing' => true,
				'tmpBucketedThumbCache' => [ 1024 => '/tmp' ],
				'thumbnailBucket' => 1024,
				'physicalWidth' => 2048,
				'expectedPath' => '/tmp',
				'message' => 'Temporary path because temp file was found'
			] ],
			[ [
				'supportsBucketing' => false,
				'tmpBucketedThumbCache' => null,
				'thumbnailBucket' => 1024,
				'physicalWidth' => 2048,
				'expectedPath' => 'localRefPath',
				'message' => 'Original file path because bucketing is unsupported by handler'
			] ],
			[ [
				'supportsBucketing' => true,
				'tmpBucketedThumbCache' => null,
				'thumbnailBucket' => false,
				'physicalWidth' => 2048,
				'expectedPath' => 'localRefPath',
				'message' => 'Original file path because no width provided'
			] ],
		];
	}

	/**
	 * @dataProvider generateBucketsIfNeededProvider
	 * @covers \File::generateBucketsIfNeeded
	 */
	public function testGenerateBucketsIfNeeded( $data ) {
		$this->overrideConfigValue( MainConfigNames::ThumbnailBuckets, $data['buckets'] );

		$backendMock = $this->getMockBuilder( FSFileBackend::class )
			->setConstructorArgs( [ [ 'name' => 'backendMock', 'wikiId' => WikiMap::getCurrentWikiId() ] ] )
			->getMock();

		$repoMock = $this->getMockBuilder( FileRepo::class )
			->setConstructorArgs( [ [ 'name' => 'repoMock', 'backend' => $backendMock ] ] )
			->onlyMethods( [ 'fileExists', 'getLocalReference' ] )
			->getMock();

		$fileMock = $this->getMockBuilder( File::class )
			->setConstructorArgs( [ 'fileMock', $repoMock ] )
			->onlyMethods( [ 'getWidth', 'getBucketThumbPath', 'makeTransformTmpFile',
				'generateAndSaveThumb', 'getHandler' ] )
			->getMockForAbstractClass();

		$handlerMock = $this->getMockBuilder( JpegHandler::class )
			->onlyMethods( [ 'supportsBucketing' ] )->getMock();
		$handlerMock->method( 'supportsBucketing' )
			->willReturn( true );

		$fileMock->method( 'getHandler' )
			->willReturn( $handlerMock );

		$reflectionMethod = new ReflectionMethod( File::class, 'generateBucketsIfNeeded' );
		$reflectionMethod->setAccessible( true );

		$fileMock->method( 'getWidth' )
			->willReturn( $data['width'] );

		$fileMock->expects( $data['expectedGetBucketThumbPathCalls'] )
			->method( 'getBucketThumbPath' );

		$repoMock->expects( $data['expectedFileExistsCalls'] )
			->method( 'fileExists' )
			->willReturn( $data['fileExistsReturn'] );

		$fileMock->expects( $data['expectedMakeTransformTmpFile'] )
			->method( 'makeTransformTmpFile' )
			->willReturn( $data['makeTransformTmpFileReturn'] );

		$fileMock->expects( $data['expectedGenerateAndSaveThumb'] )
			->method( 'generateAndSaveThumb' )
			->willReturn( $data['generateAndSaveThumbReturn'] );

		$this->assertEquals( $data['expectedResult'],
			$reflectionMethod->invoke(
				$fileMock,
				[
					'physicalWidth' => $data['physicalWidth'],
					'physicalHeight' => $data['physicalHeight'] ]
				),
				$data['message'] );
	}

	public function generateBucketsIfNeededProvider() {
		$defaultBuckets = [ 256, 512, 1024, 2048, 4096 ];

		return [
			[ [
				'buckets' => $defaultBuckets,
				'width' => 256,
				'physicalWidth' => 256,
				'physicalHeight' => 100,
				'expectedGetBucketThumbPathCalls' => $this->never(),
				'expectedFileExistsCalls' => $this->never(),
				'fileExistsReturn' => null,
				'expectedMakeTransformTmpFile' => $this->never(),
				'makeTransformTmpFileReturn' => false,
				'expectedGenerateAndSaveThumb' => $this->never(),
				'generateAndSaveThumbReturn' => false,
				'expectedResult' => false,
				'message' => 'No bucket found, nothing to generate'
			] ],
			[ [
				'buckets' => $defaultBuckets,
				'width' => 5000,
				'physicalWidth' => 300,
				'physicalHeight' => 200,
				'expectedGetBucketThumbPathCalls' => $this->once(),
				'expectedFileExistsCalls' => $this->once(),
				'fileExistsReturn' => true,
				'expectedMakeTransformTmpFile' => $this->never(),
				'makeTransformTmpFileReturn' => false,
				'expectedGenerateAndSaveThumb' => $this->never(),
				'generateAndSaveThumbReturn' => false,
				'expectedResult' => false,
				'message' => 'File already exists, no reason to generate buckets'
			] ],
			[ [
				'buckets' => $defaultBuckets,
				'width' => 5000,
				'physicalWidth' => 300,
				'physicalHeight' => 200,
				'expectedGetBucketThumbPathCalls' => $this->once(),
				'expectedFileExistsCalls' => $this->once(),
				'fileExistsReturn' => false,
				'expectedMakeTransformTmpFile' => $this->once(),
				'makeTransformTmpFileReturn' => false,
				'expectedGenerateAndSaveThumb' => $this->never(),
				'generateAndSaveThumbReturn' => false,
				'expectedResult' => false,
				'message' => 'Cannot generate temp file for bucket'
			] ],
			[ [
				'buckets' => $defaultBuckets,
				'width' => 5000,
				'physicalWidth' => 300,
				'physicalHeight' => 200,
				'expectedGetBucketThumbPathCalls' => $this->once(),
				'expectedFileExistsCalls' => $this->once(),
				'fileExistsReturn' => false,
				'expectedMakeTransformTmpFile' => $this->once(),
				'makeTransformTmpFileReturn' => new TempFSFile( '/tmp/foo' ),
				'expectedGenerateAndSaveThumb' => $this->once(),
				'generateAndSaveThumbReturn' => false,
				'expectedResult' => false,
				'message' => 'Bucket image could not be generated'
			] ],
			[ [
				'buckets' => $defaultBuckets,
				'width' => 5000,
				'physicalWidth' => 300,
				'physicalHeight' => 200,
				'expectedGetBucketThumbPathCalls' => $this->once(),
				'expectedFileExistsCalls' => $this->once(),
				'fileExistsReturn' => false,
				'expectedMakeTransformTmpFile' => $this->once(),
				'makeTransformTmpFileReturn' => new TempFSFile( '/tmp/foo' ),
				'expectedGenerateAndSaveThumb' => $this->once(),
				'generateAndSaveThumbReturn' => new ThumbnailImage( false, 'bar', false, false ),
				'expectedResult' => true,
				'message' => 'Bucket image could not be generated'
			] ],
		];
	}

	/**
	 * @covers \File::getDisplayWidthHeight
	 * @dataProvider providerGetDisplayWidthHeight
	 * @param array $dim Array [maxWidth, maxHeight, width, height]
	 * @param array $expected Array [width, height] The width and height we expect to display at
	 */
	public function testGetDisplayWidthHeight( $dim, $expected ) {
		$fileMock = $this->getMockBuilder( File::class )
			->setConstructorArgs( [ 'fileMock', false ] )
			->onlyMethods( [ 'getWidth', 'getHeight' ] )
			->getMockForAbstractClass();

		$fileMock->method( 'getWidth' )->willReturn( $dim[2] );
		$fileMock->method( 'getHeight' )->willReturn( $dim[3] );

		$actual = $fileMock->getDisplayWidthHeight( $dim[0], $dim[1] );
		$this->assertEquals( $expected, $actual );
	}

	public static function providerGetDisplayWidthHeight() {
		return [
			[
				[ 1024.0, 768.0, 600.0, 600.0 ],
				[ 600.0, 600.0 ]
			],
			[
				[ 1024.0, 768.0, 1600.0, 600.0 ],
				[ 1024.0, 384.0 ]
			],
			[
				[ 1024.0, 768.0, 1024.0, 768.0 ],
				[ 1024.0, 768.0 ]
			],
			[
				[ 1024.0, 768.0, 800.0, 1000.0 ],
				[ 614.0, 768.0 ]
			],
			[
				[ 1024.0, 768.0, 0, 1000 ],
				[ 0, 0 ]
			],
			[
				[ 1024.0, 768.0, 2000, 0 ],
				[ 0, 0 ]
			],
		];
	}

	public static function provideNormalizeTitle() {
		yield [ 'some name.jpg', 'Some_name.jpg' ];
		yield [ new TitleValue( NS_FILE, 'Some_name.jpg' ), 'Some_name.jpg' ];
		yield [ new TitleValue( NS_MEDIA, 'Some_name.jpg' ), 'Some_name.jpg' ];
		yield [ new PageIdentityValue( 0, NS_FILE, 'Some_name.jpg', false ), 'Some_name.jpg' ];
	}

	/**
	 * @covers \File::normalizeTitle
	 * @dataProvider provideNormalizeTitle
	 */
	public function testNormalizeTitle( $title, $expected ) {
		$actual = File::normalizeTitle( $title );

		$this->assertSame( NS_FILE, $actual->getNamespace() );
		$this->assertSame( $expected, $actual->getDBkey() );
	}

	public static function provideNormalizeTitleFails() {
		yield [ '' ];
		yield [ '#' ];
		yield [ new TitleValue( NS_USER, 'Some_name.jpg' ) ];
		yield [ new PageIdentityValue( 0, NS_USER, 'Some_name.jpg', false ) ];
	}

	/**
	 * @covers \File::normalizeTitle
	 * @dataProvider provideNormalizeTitleFails
	 */
	public function testNormalizeTitleFails( $title ) {
		$actual = File::normalizeTitle( $title );
		$this->assertNull( $actual );

		$this->expectException( RuntimeException::class );
		File::normalizeTitle( $title, 'exception' );
	}

	/**
	 * @covers \File::setHandlerState
	 * @covers \File::getHandlerState
	 */
	public function testSetHandlerState() {
		$obj = (object)[];
		$file = new class extends File {
			public function __construct() {
			}
		};
		$this->assertNull( $file->getHandlerState( 'test' ) );
		$file->setHandlerState( 'test', $obj );
		$this->assertSame( $obj, $file->getHandlerState( 'test' ) );
	}
}
PK       ! 1}L|  L|    filerepo/file/LocalFileTest.phpnu Iw        <?php

/**
 * These tests should work regardless of $wgCapitalLinks
 * @todo Split tests into providers and test methods
 */

use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Permissions\Authority;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;
use MediaWiki\WikiMap\WikiMap;
use Wikimedia\FileBackend\FSFileBackend;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Database
 */
class LocalFileTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;

	private static function getDefaultInfo() {
		return [
			'name' => 'test',
			'directory' => '/testdir',
			'url' => '/testurl',
			'hashLevels' => 2,
			'transformVia404' => false,
			'backend' => new FSFileBackend( [
				'name' => 'local-backend',
				'wikiId' => WikiMap::getCurrentWikiId(),
				'containerPaths' => [
					'cont1' => "/testdir/local-backend/tempimages/cont1",
					'cont2' => "/testdir/local-backend/tempimages/cont2"
				]
			] )
		];
	}

	/**
	 * @covers \File::getHashPath
	 * @dataProvider provideGetHashPath
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 */
	public function testGetHashPath( $expected, $capitalLinks, array $info ) {
		$this->overrideConfigValue( MainConfigNames::CapitalLinks, $capitalLinks );
		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getHashPath() );
	}

	public static function provideGetHashPath() {
		return [
			[ '', true, [ 'hashLevels' => 0 ] ],
			[ 'a/a2/', true, [ 'hashLevels' => 2 ] ],
			[ 'c/c4/', false, [ 'initialCapital' => false ] ],
		];
	}

	/**
	 * @covers \File::getRel
	 * @dataProvider provideGetRel
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 */
	public function testGetRel( $expected, $capitalLinks, array $info ) {
		$this->overrideConfigValue( MainConfigNames::CapitalLinks, $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getRel() );
	}

	public static function provideGetRel() {
		return [
			[ 'Test!', true, [ 'hashLevels' => 0 ] ],
			[ 'a/a2/Test!', true, [ 'hashLevels' => 2 ] ],
			[ 'c/c4/test!', false, [ 'initialCapital' => false ] ],
		];
	}

	/**
	 * @covers \File::getUrlRel
	 * @dataProvider provideGetUrlRel
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 */
	public function testGetUrlRel( $expected, $capitalLinks, array $info ) {
		$this->overrideConfigValue( MainConfigNames::CapitalLinks, $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getUrlRel() );
	}

	public static function provideGetUrlRel() {
		return [
			[ 'Test%21', true, [ 'hashLevels' => 0 ] ],
			[ 'a/a2/Test%21', true, [ 'hashLevels' => 2 ] ],
			[ 'c/c4/test%21', false, [ 'initialCapital' => false ] ],
		];
	}

	/**
	 * @covers \File::getArchivePath
	 * @dataProvider provideGetArchivePath
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 * @param array $args
	 */
	public function testGetArchivePath( $expected, $capitalLinks, array $info, array $args ) {
		$this->overrideConfigValue( MainConfigNames::CapitalLinks, $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getArchivePath( ...$args ) );
	}

	public static function provideGetArchivePath() {
		return [
			[ 'mwstore://local-backend/test-public/archive', true, [ 'hashLevels' => 0 ], [] ],
			[ 'mwstore://local-backend/test-public/archive/a/a2', true, [ 'hashLevels' => 2 ], [] ],
			[
				'mwstore://local-backend/test-public/archive/!',
				true, [ 'hashLevels' => 0 ], [ '!' ]
			], [
				'mwstore://local-backend/test-public/archive/a/a2/!',
				true, [ 'hashLevels' => 2 ], [ '!' ]
			],
		];
	}

	/**
	 * @covers \File::getThumbPath
	 * @dataProvider provideGetThumbPath
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 * @param array $args
	 */
	public function testGetThumbPath( $expected, $capitalLinks, array $info, array $args ) {
		$this->overrideConfigValue( MainConfigNames::CapitalLinks, $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getThumbPath( ...$args ) );
	}

	public static function provideGetThumbPath() {
		return [
			[ 'mwstore://local-backend/test-thumb/Test!', true, [ 'hashLevels' => 0 ], [] ],
			[ 'mwstore://local-backend/test-thumb/a/a2/Test!', true, [ 'hashLevels' => 2 ], [] ],
			[
				'mwstore://local-backend/test-thumb/Test!/x',
				true, [ 'hashLevels' => 0 ], [ 'x' ]
			], [
				'mwstore://local-backend/test-thumb/a/a2/Test!/x',
				true, [ 'hashLevels' => 2 ], [ 'x' ]
			],
		];
	}

	/**
	 * @covers \File::getArchiveUrl
	 * @dataProvider provideGetArchiveUrl
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 * @param array $args
	 */
	public function testGetArchiveUrl( $expected, $capitalLinks, array $info, array $args ) {
		$this->overrideConfigValue( MainConfigNames::CapitalLinks, $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getArchiveUrl( ...$args ) );
	}

	public static function provideGetArchiveUrl() {
		return [
			[ '/testurl/archive', true, [ 'hashLevels' => 0 ], [] ],
			[ '/testurl/archive/a/a2', true, [ 'hashLevels' => 2 ], [] ],
			[ '/testurl/archive/%21', true, [ 'hashLevels' => 0 ], [ '!' ] ],
			[ '/testurl/archive/a/a2/%21', true, [ 'hashLevels' => 2 ], [ '!' ] ],
		];
	}

	/**
	 * @covers \File::getThumbUrl
	 * @dataProvider provideGetThumbUrl
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 * @param array $args
	 */
	public function testGetThumbUrl( $expected, $capitalLinks, array $info, array $args ) {
		$this->overrideConfigValue( MainConfigNames::CapitalLinks, $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getThumbUrl( ...$args ) );
	}

	public static function provideGetThumbUrl() {
		return [
			[ '/testurl/thumb/Test%21', true, [ 'hashLevels' => 0 ], [] ],
			[ '/testurl/thumb/a/a2/Test%21', true, [ 'hashLevels' => 2 ], [] ],
			[ '/testurl/thumb/Test%21/x', true, [ 'hashLevels' => 0 ], [ 'x' ] ],
			[ '/testurl/thumb/a/a2/Test%21/x', true, [ 'hashLevels' => 2 ], [ 'x' ] ],
		];
	}

	/**
	 * @covers \File::getArchiveVirtualUrl
	 * @dataProvider provideGetArchiveVirtualUrl
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 * @param array $args
	 */
	public function testGetArchiveVirtualUrl(
		$expected, $capitalLinks, array $info, array $args
	) {
		$this->overrideConfigValue( MainConfigNames::CapitalLinks, $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getArchiveVirtualUrl( ...$args ) );
	}

	public static function provideGetArchiveVirtualUrl() {
		return [
			[ 'mwrepo://test/public/archive', true, [ 'hashLevels' => 0 ], [] ],
			[ 'mwrepo://test/public/archive/a/a2', true, [ 'hashLevels' => 2 ], [] ],
			[ 'mwrepo://test/public/archive/%21', true, [ 'hashLevels' => 0 ], [ '!' ] ],
			[ 'mwrepo://test/public/archive/a/a2/%21', true, [ 'hashLevels' => 2 ], [ '!' ] ],
		];
	}

	/**
	 * @covers \File::getThumbVirtualUrl
	 * @dataProvider provideGetThumbVirtualUrl
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 * @param array $args
	 */
	public function testGetThumbVirtualUrl( $expected, $capitalLinks, array $info, array $args ) {
		$this->overrideConfigValue( MainConfigNames::CapitalLinks, $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getThumbVirtualUrl( ...$args ) );
	}

	public static function provideGetThumbVirtualUrl() {
		return [
			[ 'mwrepo://test/thumb/Test%21', true, [ 'hashLevels' => 0 ], [] ],
			[ 'mwrepo://test/thumb/a/a2/Test%21', true, [ 'hashLevels' => 2 ], [] ],
			[ 'mwrepo://test/thumb/Test%21/%21', true, [ 'hashLevels' => 0 ], [ '!' ] ],
			[ 'mwrepo://test/thumb/a/a2/Test%21/%21', true, [ 'hashLevels' => 2 ], [ '!' ] ],
		];
	}

	/**
	 * @covers \File::getUrl
	 * @dataProvider provideGetUrl
	 * @param string $expected
	 * @param bool $capitalLinks
	 * @param array $info
	 */
	public function testGetUrl( $expected, $capitalLinks, array $info ) {
		$this->overrideConfigValue( MainConfigNames::CapitalLinks, $capitalLinks );

		$this->assertSame( $expected, ( new LocalRepo( $info + self::getDefaultInfo() ) )
			->newFile( 'test!' )->getUrl() );
	}

	public static function provideGetUrl() {
		return [
			[ '/testurl/Test%21', true, [ 'hashLevels' => 0 ] ],
			[ '/testurl/a/a2/Test%21', true, [ 'hashLevels' => 2 ] ],
		];
	}

	/**
	 * @covers \LocalFile::getUploader
	 */
	public function testGetUploaderForNonExistingFile() {
		$file = ( new LocalRepo( self::getDefaultInfo() ) )->newFile( 'test!' );
		$this->assertNull( $file->getUploader() );
	}

	public function providePermissionChecks() {
		$capablePerformer = $this->mockRegisteredAuthorityWithPermissions( [ 'deletedhistory', 'deletedtext' ] );
		$incapablePerformer = $this->mockRegisteredAuthorityWithoutPermissions( [ 'deletedhistory', 'deletedtext' ] );
		yield 'Deleted, RAW' => [
			'performer' => $incapablePerformer,
			'audience' => File::RAW,
			'deleted' => File::DELETED_USER | File::DELETED_COMMENT,
			'expected' => true,
		];
		yield 'No permission, not deleted' => [
			'performer' => $incapablePerformer,
			'audience' => File::FOR_THIS_USER,
			'deleted' => 0,
			'expected' => true,
		];
		yield 'No permission, deleted' => [
			'performer' => $incapablePerformer,
			'audience' => File::FOR_THIS_USER,
			'deleted' => File::DELETED_USER | File::DELETED_COMMENT,
			'expected' => false,
		];
		yield 'Not deleted, public' => [
			'performer' => $capablePerformer,
			'audience' => File::FOR_PUBLIC,
			'deleted' => 0,
			'expected' => true,
		];
		yield 'Deleted, public' => [
			'performer' => $capablePerformer,
			'audience' => File::FOR_PUBLIC,
			'deleted' => File::DELETED_USER | File::DELETED_COMMENT,
			'expected' => false,
		];
		yield 'With permission, deleted' => [
			'performer' => $capablePerformer,
			'audience' => File::FOR_THIS_USER,
			'deleted' => File::DELETED_USER | File::DELETED_COMMENT,
			'expected' => true,
		];
	}

	private function getOldLocalFileWithDeletion(
		UserIdentity $uploader,
		int $deletedFlags
	): OldLocalFile {
		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'oldimage' )
			->row( [
				'oi_name' => 'Random-11m.png',
				'oi_archive_name' => 'Random-11m.png',
				'oi_size' => 10816824,
				'oi_width' => 1000,
				'oi_height' => 1800,
				'oi_metadata' => '',
				'oi_bits' => 16,
				'oi_media_type' => 'BITMAP',
				'oi_major_mime' => 'image',
				'oi_minor_mime' => 'png',
				'oi_description_id' => $this->getServiceContainer()
					->getCommentStore()
					->createComment( $this->getDb(), 'comment' )->id,
				'oi_actor' => $this->getServiceContainer()
					->getActorStore()
					->acquireActorId( $uploader, $this->getDb() ),
				'oi_timestamp' => $this->getDb()->timestamp( '20201105235242' ),
				'oi_sha1' => 'sy02psim0bgdh0jt4vdltuzoh7j80ru',
				'oi_deleted' => $deletedFlags,
			] )
			->caller( __METHOD__ )
			->execute();
		$file = OldLocalFile::newFromTitle(
			Title::makeTitle( NS_FILE, 'Random-11m.png' ),
			$this->getServiceContainer()->getRepoGroup()->getLocalRepo(),
			'20201105235242'
		);
		$this->assertInstanceOf( File::class, $file, 'Created a test file' );
		return $file;
	}

	private function getArchivedFileWithDeletion(
		UserIdentity $uploader,
		int $deletedFlags
	): ArchivedFile {
		return ArchivedFile::newFromRow( (object)[
				'fa_id' => 1,
				'fa_storage_group' => 'test',
				'fa_storage_key' => 'bla',
				'fa_name' => 'Random-11m.png',
				'fa_archive_name' => 'Random-11m.png',
				'fa_size' => 10816824,
				'fa_width' => 1000,
				'fa_height' => 1800,
				'fa_metadata' => '',
				'fa_bits' => 16,
				'fa_media_type' => 'BITMAP',
				'fa_major_mime' => 'image',
				'fa_minor_mime' => 'png',
				'fa_description_id' => $this->getServiceContainer()
					->getCommentStore()
					->createComment( $this->getDb(), 'comment' )->id,
				'fa_actor' => $this->getServiceContainer()
					->getActorStore()
					->acquireActorId( $uploader, $this->getDb() ),
				'fa_user' => $uploader->getId(),
				'fa_user_text' => $uploader->getName(),
				'fa_timestamp' => $this->getDb()->timestamp( '20201105235242' ),
				'fa_sha1' => 'sy02psim0bgdh0jt4vdltuzoh7j80ru',
				'fa_deleted' => $deletedFlags,
			]
		);
	}

	/**
	 * @dataProvider providePermissionChecks
	 * @covers \LocalFile::getUploader
	 */
	public function testGetUploader(
		Authority $performer,
		int $audience,
		int $deleted,
		bool $expected
	) {
		$file = $this->getOldLocalFileWithDeletion( $performer->getUser(), $deleted );
		if ( $expected ) {
			$this->assertTrue( $performer->getUser()->equals( $file->getUploader( $audience, $performer ) ) );
		} else {
			$this->assertNull( $file->getUploader( $audience, $performer ) );
		}
	}

	/**
	 * @dataProvider providePermissionChecks
	 * @covers \ArchivedFile::getDescription
	 */
	public function testGetDescription(
		Authority $performer,
		int $audience,
		int $deleted,
		bool $expected
	) {
		$file = $this->getArchivedFileWithDeletion( $performer->getUser(), $deleted );
		if ( $expected ) {
			$this->assertSame( 'comment', $file->getDescription( $audience, $performer ) );
		} else {
			$this->assertSame( '', $file->getDescription( $audience, $performer ) );
		}
	}

	/**
	 * @dataProvider providePermissionChecks
	 * @covers \ArchivedFile::getUploader
	 */
	public function testArchivedGetUploader(
		Authority $performer,
		int $audience,
		int $deleted,
		bool $expected
	) {
		$file = $this->getArchivedFileWithDeletion( $performer->getUser(), $deleted );
		if ( $expected ) {
			$this->assertTrue( $performer->getUser()->equals( $file->getUploader( $audience, $performer ) ) );
		} else {
			$this->assertNull( $file->getUploader( $audience, $performer ) );
		}
	}

	/**
	 * @dataProvider providePermissionChecks
	 * @covers \LocalFile::getDescription
	 */
	public function testArchivedGetDescription(
		Authority $performer,
		int $audience,
		int $deleted,
		bool $expected
	) {
		$file = $this->getOldLocalFileWithDeletion( $performer->getUser(), $deleted );
		if ( $expected ) {
			$this->assertSame( 'comment', $file->getDescription( $audience, $performer ) );
		} else {
			$this->assertSame( '', $file->getDescription( $audience, $performer ) );
		}
	}

	/**
	 * @covers \File::getDescriptionShortUrl
	 */
	public function testDescriptionShortUrlForNonExistingFile() {
		$file = ( new LocalRepo( self::getDefaultInfo() ) )->newFile( 'test!' );
		$this->assertNull( $file->getDescriptionShortUrl() );
	}

	/**
	 * @covers \LocalFile::getDescriptionText
	 */
	public function testDescriptionText_NonExisting() {
		$file = ( new LocalRepo( self::getDefaultInfo() ) )->newFile( 'test!' );
		$this->assertFalse( $file->getDescriptionText() );
	}

	/**
	 * @covers \LocalFile::getDescriptionText
	 */
	public function testDescriptionText_Existing() {
		$this->assertTrue( $this->editPage(
			__METHOD__,
			'TEST CONTENT',
			'',
			NS_FILE
		)->isOK() );
		$file = ( new LocalRepo( self::getDefaultInfo() ) )->newFile( __METHOD__ );
		$this->assertStringContainsString( 'TEST CONTENT', $file->getDescriptionText() );
	}

	public static function provideLoadFromDBAndCache() {
		return [
			'legacy' => [
				'a:6:{s:10:"frameCount";i:0;s:9:"loopCount";i:1;s:8:"duration";d:0;s:8:"bitDepth";i:16;s:9:"colorType";s:10:"truecolour";s:8:"metadata";a:2:{s:8:"DateTime";s:19:"2019:07:30 13:52:32";s:15:"_MW_PNG_VERSION";i:1;}}',
				[],
				false,
			],
			'json' => [
				'{"data":{"frameCount":0,"loopCount":1,"duration":0,"bitDepth":16,"colorType":"truecolour","metadata":{"DateTime":"2019:07:30 13:52:32","_MW_PNG_VERSION":1}}}',
				[],
				false,
			],
			'json with blobs' => [
				'{"blobs":{"colorType":"__BLOB0__"},"data":{"frameCount":0,"loopCount":1,"duration":0,"bitDepth":16,"metadata":{"DateTime":"2019:07:30 13:52:32","_MW_PNG_VERSION":1}}}',
				[ '"truecolour"' ],
				false,
			],
			'large (>100KB triggers uncached case)' => [
				'{"data":{"large":"' . str_repeat( 'x', 102401 ) . '","frameCount":0,"loopCount":1,"duration":0,"bitDepth":16,"colorType":"truecolour","metadata":{"DateTime":"2019:07:30 13:52:32","_MW_PNG_VERSION":1}}}',
				[],
				102401,
			],
			'large json blob' => [
				'{"blobs":{"large":"__BLOB0__"},"data":{"frameCount":0,"loopCount":1,"duration":0,"bitDepth":16,"colorType":"truecolour","metadata":{"DateTime":"2019:07:30 13:52:32","_MW_PNG_VERSION":1}}}',
				[ '"' . str_repeat( 'x', 102401 ) . '"' ],
				102401,
			],
		];
	}

	/**
	 * Test loadFromDB() and loadFromCache() and helpers
	 *
	 * @dataProvider provideLoadFromDBAndCache
	 * @covers \File
	 * @covers \LocalFile
	 * @param string $meta
	 * @param array $blobs Metadata blob values
	 * @param int|false $largeItemSize The size of the "large" metadata item,
	 *   or false if there will be no such item.
	 */
	public function testLoadFromDBAndCache( $meta, $blobs, $largeItemSize ) {
		$services = $this->getServiceContainer();

		$cache = new HashBagOStuff;
		$this->setService(
			'MainWANObjectCache',
			new WANObjectCache( [
				'cache' => $cache
			] )
		);

		$dbw = $this->getDb();
		$norm = $services->getActorNormalization();
		$user = $this->getTestSysop()->getUserIdentity();
		$actorId = $norm->acquireActorId( $user, $dbw );
		$comment = $services->getCommentStore()->createComment( $dbw, 'comment' );
		$title = Title::makeTitle( NS_FILE, 'Random-11m.png' );

		if ( $blobs ) {
			$blobStore = $services->getBlobStore();
			foreach ( $blobs as $i => $value ) {
				$address = $blobStore->storeBlob( $value );
				$meta = str_replace( "__BLOB{$i}__", $address, $meta );
			}
		}

		// The provided metadata strings should all unserialize to this
		$expectedMetaArray = [
			'frameCount' => 0,
			'loopCount' => 1,
			'duration' => 0.0,
			'bitDepth' => 16,
			'colorType' => 'truecolour',
			'metadata' => [
				'DateTime' => '2019:07:30 13:52:32',
				'_MW_PNG_VERSION' => 1,
			],
		];
		if ( $largeItemSize ) {
			$expectedMetaArray['large'] = str_repeat( 'x', $largeItemSize );
		}
		$expectedProps = [
			'name' => 'Random-11m.png',
			'size' => 10816824,
			'width' => 1000,
			'height' => 1800,
			'metadata' => $expectedMetaArray,
			'bits' => 16,
			'media_type' => 'BITMAP',
			'mime' => 'image/png',
			'timestamp' => '20201105235242',
			'sha1' => 'sy02psim0bgdh0jt4vdltuzoh7j80ru'
		];

		$dbw->newInsertQueryBuilder()
			->insertInto( 'image' )
			->row( [
				'img_name' => 'Random-11m.png',
				'img_size' => 10816824,
				'img_width' => 1000,
				'img_height' => 1800,
				'img_metadata' => $dbw->encodeBlob( $meta ),
				'img_bits' => 16,
				'img_media_type' => 'BITMAP',
				'img_major_mime' => 'image',
				'img_minor_mime' => 'png',
				'img_description_id' => $comment->id,
				'img_actor' => $actorId,
				'img_timestamp' => $dbw->timestamp( '20201105235242' ),
				'img_sha1' => 'sy02psim0bgdh0jt4vdltuzoh7j80ru',
			] )
			->caller( __METHOD__ )
			->execute();
		$repo = $services->getRepoGroup()->getLocalRepo();
		$file = $repo->findFile( $title );

		$this->assertFileProperties( $expectedProps, $file );
		$this->assertSame( 'truecolour', $file->getMetadataItem( 'colorType' ) );
		$this->assertSame(
			[ 'loopCount' => 1, 'bitDepth' => 16 ],
			$file->getMetadataItems( [ 'loopCount', 'bitDepth', 'nonexistent' ] )
		);
		$this->assertSame( 'comment', $file->getDescription() );
		$this->assertTrue( $user->equals( $file->getUploader() ) );

		// Test cache by corrupting DB
		// Don't wipe img_metadata though since that will be loaded by loadExtraFromDB()
		$dbw->newUpdateQueryBuilder()
			->update( 'image' )
			->set( [ 'img_size' => 0 ] )
			->where( [ 'img_name' => 'Random-11m.png' ] )
			->caller( __METHOD__ )->execute();
		$file = LocalFile::newFromTitle( $title, $repo );

		$this->assertFileProperties( $expectedProps, $file );
		$this->assertSame( 'truecolour', $file->getMetadataItem( 'colorType' ) );
		$this->assertSame(
			[ 'loopCount' => 1, 'bitDepth' => 16 ],
			$file->getMetadataItems( [ 'loopCount', 'bitDepth', 'nonexistent' ] )
		);
		$this->assertSame( 'comment', $file->getDescription() );
		$this->assertTrue( $user->equals( $file->getUploader() ) );

		// Make sure we were actually hitting the WAN cache
		$dbw->newDeleteQueryBuilder()
			->deleteFrom( 'image' )
			->where( [ 'img_name' => 'Random-11m.png' ] )
			->caller( __METHOD__ )->execute();
		$file->invalidateCache();
		$file = LocalFile::newFromTitle( $title, $repo );
		$this->assertSame( false, $file->exists() );
	}

	private function assertFileProperties( $expectedProps, $file ) {
		// Compare metadata without ordering
		if ( isset( $expectedProps['metadata'] ) ) {
			$this->assertArrayEquals( $expectedProps['metadata'], $file->getMetadataArray() );
		}

		// Filter out unsupported expected properties
		$expectedProps = array_intersect_key(
			$expectedProps,
			array_fill_keys( [
				'name', 'size', 'width', 'height',
				'bits', 'media_type', 'mime', 'timestamp', 'sha1'
			], true )
		);

		// Compare the other properties
		$actualProps = [
			'name' => $file->getName(),
			'size' => $file->getSize(),
			'width' => $file->getWidth(),
			'height' => $file->getHeight(),
			'bits' => $file->getBitDepth(),
			'media_type' => $file->getMediaType(),
			'mime' => $file->getMimeType(),
			'timestamp' => $file->getTimestamp(),
			'sha1' => $file->getSha1()
		];
		$actualProps = array_intersect_key( $actualProps, $expectedProps );
		$this->assertArrayEquals( $expectedProps, $actualProps, false, true );
	}

	public static function provideLegacyMetadataRoundTrip() {
		return [
			[ '0' ],
			[ '-1' ],
			[ '' ]
		];
	}

	/**
	 * Test the legacy function LocalFile::getMetadata()
	 * @dataProvider provideLegacyMetadataRoundTrip
	 * @covers \LocalFile
	 */
	public function testLegacyMetadataRoundTrip( $meta ) {
		$file = new class( $meta ) extends LocalFile {
			public function __construct( $meta ) {
				$repo = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo();
				parent::__construct(
					Title::makeTitle( NS_FILE, 'TestLegacyMetadataRoundTrip' ),
					$repo );
				$this->loadMetadataFromString( $meta );
				$this->dataLoaded = true;
			}
		};
		$this->assertSame( $meta, $file->getMetadata() );
	}

	public static function provideRecordUpload3() {
		$files = [
			'test.jpg' => [
				'width' => 20,
				'height' => 20,
				'bits' => 8,
				'metadata' => [
					'ImageDescription' => 'Test file',
					'XResolution' => '72/1',
					'YResolution' => '72/1',
					'ResolutionUnit' => 2,
					'YCbCrPositioning' => 1,
					'JPEGFileComment' => [
						'Created with GIMP',
					],
					'MEDIAWIKI_EXIF_VERSION' => 2,
				],
				'fileExists' => true,
				'size' => 437,
				'file-mime' => 'image/jpeg',
				'major_mime' => 'image',
				'minor_mime' => 'jpeg',
				'mime' => 'image/jpeg',
				'sha1' => '620ezvucfyia1mltnavzpqg9gmai2gf',
				'media_type' => 'BITMAP',
			],
			'large-text.pdf' => [
				'width' => 1275,
				'height' => 1650,
				'fileExists' => true,
				'size' => 10598657,
				'file-mime' => 'application/pdf',
				'major_mime' => 'application',
				'minor_mime' => 'pdf',
				'mime' => 'application/pdf',
				'sha1' => '1o3l1yqjue2diq07grnnyq9kyapfpor',
				'bits' => 0,
				'media_type' => 'OFFICE',
				'metadata' => [
					'Pages' => '6',
					'text' => [
						'Page 1 text .................................',
						'Page 2 text .................................',
						'Page 3 text .................................',
						'Page 4 text .................................',
						'Page 5 text .................................',
						'Page 6 text .................................',
					]
				]
			],
			'no-text.pdf' => [
				'width' => 1275,
				'height' => 1650,
				'fileExists' => true,
				'size' => 10598657,
				'file-mime' => 'application/pdf',
				'major_mime' => 'application',
				'minor_mime' => 'pdf',
				'mime' => 'application/pdf',
				'sha1' => '1o3l1yqjue2diq07grnnyq9kyapfpor',
				'bits' => 0,
				'media_type' => 'OFFICE',
				'metadata' => [
					'Pages' => '6',
				]
			]
		];
		$configurations = [
			[],
			[ 'useJsonMetadata' => true ],
			[
				'useJsonMetadata' => true,
				'useSplitMetadata' => true,
				'splitMetadataThreshold' => 50
			]
		];
		return ArrayUtils::cartesianProduct( $files, $configurations );
	}

	private function getMockPdfHandler() {
		return new class extends ImageHandler {
			public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
			}

			public function useSplitMetadata() {
				return true;
			}
		};
	}

	/**
	 * Test recordUpload3() and confirm that file properties are reflected back
	 * after loading the new file from the DB.
	 *
	 * @covers \LocalFile
	 * @dataProvider provideRecordUpload3
	 * @param array $props File properties
	 * @param array $conf LocalRepo configuration overrides
	 */
	public function testRecordUpload3( $props, $conf ) {
		$repo = new LocalRepo(
			[
				'class' => LocalRepo::class,
				'name' => 'test',
				'backend' => new FSFileBackend( [
					'name' => 'test-backend',
					'wikiId' => WikiMap::getCurrentWikiId(),
					'basePath' => '/nonexistent'
				] )
			] + $conf
		);
		$title = Title::makeTitle( NS_FILE, 'Test.jpg' );
		$file = new LocalFile( $title, $repo );

		if ( $props['mime'] === 'application/pdf' ) {
			TestingAccessWrapper::newFromObject( $file )->handler = $this->getMockPdfHandler();
		}

		$status = $file->recordUpload3(
			'oldver',
			'comment',
			'page text',
			$this->getTestSysop()->getUser(),
			$props
		);
		$this->assertStatusGood( $status );
		// Check properties of the same object immediately after upload
		$this->assertFileProperties( $props, $file );
		// Check round-trip through the DB
		$file = new LocalFile( $title, $repo );
		$this->assertFileProperties( $props, $file );
	}

	/**
	 * @covers \LocalFile
	 */
	public function testUpload() {
		$repo = new LocalRepo(
			[
				'class' => LocalRepo::class,
				'name' => 'test',
				'backend' => new FSFileBackend( [
					'name' => 'test-backend',
					'wikiId' => WikiMap::getCurrentWikiId(),
					'basePath' => $this->getNewTempDirectory()
				] )
			]
		);
		$title = Title::makeTitle( NS_FILE, 'Test.jpg' );
		$file = new LocalFile( $title, $repo );
		$path = __DIR__ . '/../../../data/media/test.jpg';
		$status = $file->upload(
			$path,
			'comment',
			'page text',
			0,
			false,
			false,
			$this->getTestUser()->getUser()
		);
		$this->assertStatusGood( $status );

		// Test reupload
		$file = new LocalFile( $title, $repo );
		$path = __DIR__ . '/../../../data/media/jpeg-xmp-nullchar.jpg';
		$status = $file->upload(
			$path,
			'comment',
			'page text',
			0,
			false,
			false,
			$this->getTestUser()->getUser()
		);
		$this->assertStatusGood( $status );
	}

	public static function provideReserializeMetadata() {
		return [
			[
				'',
				''
			],
			[
				'a:1:{s:4:"test";i:1;}',
				'{"data":{"test":1}}'
			],
			[
				serialize( [ 'test' => str_repeat( 'x', 100 ) ] ),
				'{"data":[],"blobs":{"test":"tt:%d"}}'
			]
		];
	}

	/**
	 * Test reserializeMetadata() via maybeUpgradeRow()
	 *
	 * @covers \LocalFile::maybeUpgradeRow
	 * @covers \LocalFile::reserializeMetadata
	 * @dataProvider provideReserializeMetadata
	 */
	public function testReserializeMetadata( $input, $expected ) {
		$dbw = $this->getDb();
		$services = $this->getServiceContainer();
		$norm = $services->getActorNormalization();
		$user = $this->getTestSysop()->getUserIdentity();
		$actorId = $norm->acquireActorId( $user, $dbw );
		$comment = $services->getCommentStore()->createComment( $dbw, 'comment' );

		$dbw->newInsertQueryBuilder()
			->insertInto( 'image' )
			->row( [
				'img_name' => 'Test.pdf',
				'img_size' => 1,
				'img_width' => 1,
				'img_height' => 1,
				'img_metadata' => $dbw->encodeBlob( $input ),
				'img_bits' => 0,
				'img_media_type' => 'OFFICE',
				'img_major_mime' => 'application',
				'img_minor_mime' => 'pdf',
				'img_description_id' => $comment->id,
				'img_actor' => $actorId,
				'img_timestamp' => $dbw->timestamp( '20201105235242' ),
				'img_sha1' => 'hhhh',
			] )
			->caller( __METHOD__ )
			->execute();

		$repo = new LocalRepo( [
			'class' => LocalRepo::class,
			'name' => 'test',
			'useJsonMetadata' => true,
			'useSplitMetadata' => true,
			'splitMetadataThreshold' => 50,
			'updateCompatibleMetadata' => true,
			'reserializeMetadata' => true,
			'backend' => new FSFileBackend( [
				'name' => 'test-backend',
				'wikiId' => WikiMap::getCurrentWikiId(),
				'basePath' => '/nonexistent'
			] )
		] );
		$title = Title::makeTitle( NS_FILE, 'Test.pdf' );
		$file = new LocalFile( $title, $repo );
		TestingAccessWrapper::newFromObject( $file )->handler = $this->getMockPdfHandler();
		$file->load();
		$file->maybeUpgradeRow();

		$metadata = $dbw->decodeBlob( $dbw->newSelectQueryBuilder()
			->select( 'img_metadata' )
			->from( 'image' )
			->where( [ 'img_name' => 'Test.pdf' ] )
			->caller( __METHOD__ )->fetchField()
		);
		$this->assertStringMatchesFormat( $expected, $metadata );
	}

	/**
	 * Test upgradeRow() via maybeUpgradeRow()
	 *
	 * @covers \LocalFile::maybeUpgradeRow
	 * @covers \LocalFile::upgradeRow
	 */
	public function testUpgradeRow() {
		$repo = new LocalRepo( [
			'class' => LocalRepo::class,
			'name' => 'test',
			'updateCompatibleMetadata' => true,
			'useJsonMetadata' => true,
			'hashLevels' => 0,
			'backend' => new FSFileBackend( [
				'name' => 'test-backend',
				'wikiId' => WikiMap::getCurrentWikiId(),
				'containerPaths' => [ 'test-public' => __DIR__ . '/../../../data/media' ]
			] )
		] );
		$dbw = $this->getDb();
		$services = $this->getServiceContainer();
		$norm = $services->getActorNormalization();
		$user = $this->getTestSysop()->getUserIdentity();
		$actorId = $norm->acquireActorId( $user, $dbw );
		$comment = $services->getCommentStore()->createComment( $dbw, 'comment' );

		$dbw->newInsertQueryBuilder()
			->insertInto( 'image' )
			->row( [
				'img_name' => 'Png-native-test.png',
				'img_size' => 1,
				'img_width' => 1,
				'img_height' => 1,
				'img_metadata' => $dbw->encodeBlob( 'a:1:{s:8:"metadata";a:1:{s:15:"_MW_PNG_VERSION";i:0;}}' ),
				'img_bits' => 0,
				'img_media_type' => 'OFFICE',
				'img_major_mime' => 'image',
				'img_minor_mime' => 'png',
				'img_description_id' => $comment->id,
				'img_actor' => $actorId,
				'img_timestamp' => $dbw->timestamp( '20201105235242' ),
				'img_sha1' => 'hhhh',
			] )
			->caller( __METHOD__ )
			->execute();

		$title = Title::makeTitle( NS_FILE, 'Png-native-test.png' );
		$file = new LocalFile( $title, $repo );
		$file->load();
		$file->maybeUpgradeRow();
		$metadata = $dbw->decodeBlob( $dbw->newSelectQueryBuilder()
			->select( 'img_metadata' )
			->from( 'image' )
			->where( [ 'img_name' => 'Png-native-test.png' ] )
			->fetchField()
		);
		// Just confirm that it looks like JSON with real metadata
		$this->assertStringStartsWith( '{"data":{"frameCount":0,', $metadata );

		$file = new LocalFile( $title, $repo );
		$this->assertFileProperties(
			[
				'size' => 4665,
				'width' => 420,
				'height' => 300,
				'sha1' => '3n69qtiaif1swp3kyfueqjtmw2u4c2b',
				'bits' => 8,
				'media_type' => 'BITMAP',
			],
			$file );
	}
}
PK       ! otȔ    )  filerepo/FileBackendDBRepoWrapperTest.phpnu Iw        <?php

use MediaWiki\WikiMap\WikiMap;
use Wikimedia\FileBackend\FSFileBackend;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\SelectQueryBuilder;

class FileBackendDBRepoWrapperTest extends MediaWikiIntegrationTestCase {
	private const BACKEND_NAME = 'foo-backend';
	private const REPO_NAME = 'pureTestRepo';

	/**
	 * @dataProvider getBackendPathsProvider
	 * @covers \FileBackendDBRepoWrapper::getBackendPaths
	 */
	public function testGetBackendPaths(
		$mocks,
		$latest,
		$dbReadsExpected,
		$dbReturnValue,
		$originalPath,
		$expectedBackendPath,
		$message ) {
		[ $dbMock, $backendMock, $wrapperMock ] = $mocks;

		$dbMock->expects( $dbReadsExpected )
			->method( 'selectField' )
			->willReturn( $dbReturnValue );
		$dbMock->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $dbMock ) );

		$newPaths = $wrapperMock->getBackendPaths( [ $originalPath ], $latest );

		$this->assertEquals(
			$expectedBackendPath,
			$newPaths[0],
			$message );
	}

	public function getBackendPathsProvider() {
		$prefix = 'mwstore://' . self::BACKEND_NAME . '/' . self::REPO_NAME;
		$mocksForCaching = $this->getMocks();

		return [
			[
				$mocksForCaching,
				false,
				$this->once(),
				'96246614d75ba1703bdfd5d7660bb57407aaf5d9',
				$prefix . '-public/f/o/foobar.jpg',
				$prefix . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9',
				'Public path translated correctly',
			],
			[
				$mocksForCaching,
				false,
				$this->never(),
				'96246614d75ba1703bdfd5d7660bb57407aaf5d9',
				$prefix . '-public/f/o/foobar.jpg',
				$prefix . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9',
				'LRU cache leveraged',
			],
			[
				$this->getMocks(),
				true,
				$this->once(),
				'96246614d75ba1703bdfd5d7660bb57407aaf5d9',
				$prefix . '-public/f/o/foobar.jpg',
				$prefix . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9',
				'Latest obtained',
			],
			[
				$this->getMocks(),
				true,
				$this->never(),
				'96246614d75ba1703bdfd5d7660bb57407aaf5d9',
				$prefix . '-deleted/f/o/foobar.jpg',
				$prefix . '-original/f/o/o/foobar',
				'Deleted path translated correctly',
			],
			[
				$this->getMocks(),
				true,
				$this->once(),
				null,
				$prefix . '-public/b/a/baz.jpg',
				$prefix . '-public/b/a/baz.jpg',
				'Path left untouched if no sha1 can be found',
			],
		];
	}

	/**
	 * @covers \FileBackendDBRepoWrapper::getFileContentsMulti
	 */
	public function testGetFileContentsMulti() {
		[ $dbMock, $backendMock, $wrapperMock ] = $this->getMocks();

		$sha1Path = 'mwstore://' . self::BACKEND_NAME . '/' . self::REPO_NAME
			. '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9';
		$filenamePath = 'mwstore://' . self::BACKEND_NAME . '/' . self::REPO_NAME
			. '-public/f/o/foobar.jpg';

		$dbMock->expects( $this->once() )
			->method( 'selectField' )
			->willReturn( '96246614d75ba1703bdfd5d7660bb57407aaf5d9' );
		$dbMock->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $dbMock ) );

		$backendMock->expects( $this->once() )
			->method( 'getFileContentsMulti' )
			->willReturn( [ $sha1Path => 'foo' ] );

		$result = $wrapperMock->getFileContentsMulti( [ 'srcs' => [ $filenamePath ] ] );

		$this->assertEquals(
			[ $filenamePath => 'foo' ],
			$result,
			'File contents paths translated properly'
		);
	}

	protected function getMocks() {
		$dbMock = $this->getMockBuilder( IDatabase::class )
			->disableOriginalClone()
			->disableOriginalConstructor()
			->getMock();
		$dbMock->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $dbMock ) );

		$backendMock = $this->getMockBuilder( FSFileBackend::class )
			->setConstructorArgs( [ [
					'name' => self::BACKEND_NAME,
					'wikiId' => WikiMap::getCurrentWikiId()
				] ] )
			->getMock();

		$wrapperMock = $this->getMockBuilder( FileBackendDBRepoWrapper::class )
			->onlyMethods( [ 'getDB' ] )
			->setConstructorArgs( [ [
					'backend' => $backendMock,
					'repoName' => self::REPO_NAME,
					'dbHandleFactory' => null
				] ] )
			->getMock();

		$wrapperMock->method( 'getDB' )->willReturn( $dbMock );

		return [ $dbMock, $backendMock, $wrapperMock ];
	}
}
PK       ! 1         filerepo/FileRepoTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use Wikimedia\FileBackend\FSFileBackend;

/**
 * @covers \FileRepo
 */
class FileRepoTest extends MediaWikiIntegrationTestCase {

	public function testFileRepoConstructionOptionCanNotBeNull() {
		$this->expectException( InvalidArgumentException::class );
		new FileRepo();
	}

	public function testFileRepoConstructionOptionCanNotBeAnEmptyArray() {
		$this->expectException( InvalidArgumentException::class );
		new FileRepo( [] );
	}

	public function testFileRepoConstructionOptionNeedNameKey() {
		$this->expectException( InvalidArgumentException::class );
		new FileRepo( [
			'backend' => 'foobar'
		] );
	}

	public function testFileRepoConstructionOptionNeedBackendKey() {
		$this->expectException( InvalidArgumentException::class );
		new FileRepo( [
			'name' => 'foobar'
		] );
	}

	public function testFileRepoConstructionWithRequiredOptions() {
		$f = new FileRepo( [
			'name' => 'FileRepoTestRepository',
			'backend' => new FSFileBackend( [
				'name' => 'local-testing',
				'wikiId' => 'test_wiki',
				'containerPaths' => []
			] )
		] );
		$this->assertInstanceOf( FileRepo::class, $f );
	}

	public function testFileRepoConstructionWithInvalidCasing() {
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( 'File repos with initial capital false' );

		$this->overrideConfigValue( MainConfigNames::CapitalLinks, true );

		new FileRepo( [
			'name' => 'foobar',
			'backend' => 'local-backend',
			'initialCapital' => false,
		] );
	}
}
PK       ! 
V  V  '  filerepo/Thumbnail404EntryPointTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\FileRepo\Thumbnail404EntryPoint;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\FileRepo\TestRepoTrait;
use MediaWiki\Tests\MockEnvironment;
use MediaWiki\Title\Title;

/**
 * @covers \MediaWiki\FileRepo\Thumbnail404EntryPoint
 * @group Database
 */
class Thumbnail404EntryPointTest extends MediaWikiIntegrationTestCase {

	use TestRepoTrait;
	use MockHttpTrait;

	private const PNG_MAGIC = "\x89\x50\x4e\x47";
	private const JPEG_MAGIC = "\xff\xd8\xff\xe0";

	private const IMAGES_DIR = __DIR__ . '/../../data/media';

	/**
	 * will be called only once per test class
	 */
	public function addDBDataOnce() {
		// Set a named user account for the request context as the default,
		// so that these tests do not fail with temp accounts enabled
		RequestContext::getMain()->setUser( $this->getTestUser()->getUser() );
		// Create mock repo with test files
		$this->initTestRepoGroup();

		$this->importFileToTestRepo( self::IMAGES_DIR . '/greyscale-png.png', 'Test.png' );
		$this->importFileToTestRepo( self::IMAGES_DIR . '/Animated_PNG_example_bouncing_beach_ball.png' );
		$this->importFileToTestRepo( self::IMAGES_DIR . '/test.jpg', 'Icon.jpg' );

		// Create a second version of Test.png
		$this->importFileToTestRepo( self::IMAGES_DIR . '/greyscale-na-png.png', 'Test.png' );

		// Create a redirect
		$title = Title::makeTitle( NS_FILE, 'Redirect_to_Test.png' );
		$this->editPage( $title, '#REDIRECT [[File:Test.png]]' );
	}

	public static function tearDownAfterClass(): void {
		self::destroyTestRepo();
		parent::tearDownAfterClass();
	}

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

		$this->installTestRepoGroup();
	}

	/**
	 * @param FauxRequest|string|null $request
	 *
	 * @return MockEnvironment
	 */
	private function makeEnvironment( $request ): MockEnvironment {
		if ( !$request ) {
			$request = new FauxRequest();
		}

		if ( is_string( $request ) ) {
			$req = new FauxRequest( [] );
			$req->setRequestURL( $request );
			$request = $req;
		}

		return new MockEnvironment( $request );
	}

	/**
	 * @param MockEnvironment|null $environment
	 * @param FauxRequest|RequestContext|string|array|null $request
	 *
	 * @return Thumbnail404EntryPoint
	 */
	private function getEntryPoint(
		?MockEnvironment $environment = null,
		$request = null
	) {
		if ( !$request && $environment ) {
			$request = $environment->getFauxRequest();
		}

		if ( $request instanceof RequestContext ) {
			$context = $request;
			$request = $context->getRequest();
		} else {
			$context = new RequestContext();
			$context->setRequest( $request );
			$context->setUser( $this->getTestUser()->getUser() );
		}

		if ( !$environment ) {
			$environment = $this->makeEnvironment( $request );
		}

		$entryPoint = new Thumbnail404EntryPoint(
			$context,
			$environment,
			$this->getServiceContainer()
		);

		$entryPoint->enableOutputCapture();
		return $entryPoint;
	}

	public static function provideNotFound() {
		yield 'non-existing image' => [
			'/w/images/thumb/a/aa/Xyzzy.png/13px-Xyzzy.png',
			404
		];
		yield 'malformed name' => [
			'/w/images/thumb/x/xx/XyzzyXyzzy',
			400
		];
	}

	/**
	 * @dataProvider provideNotFound
	 */
	public function testNotFound( $req, $expectedStatus ) {
		$env = $this->makeEnvironment( $req );
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( $expectedStatus );
		$env->assertHeaderValue(
			'text/html; charset=utf-8',
			'Content-Type'
		);

		$this->assertStringContainsString(
			'<title>Error generating thumbnail</title>',
			$output
		);
	}

	public function testStreamFile() {
		$file = $this->getTestRepo()->newFile( 'Test.png' );
		$rel = $file->getRel();
		$name = $file->getName();

		$env = $this->makeEnvironment( "/w/images/thumb/$rel/13px-$name" );
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 200 );

		$this->assertThumbnail(
			[ 'magic' => self::PNG_MAGIC, 'width' => 13, ],
			$output
		);

		return [ 'data' => $output, 'width' => 13 ];
	}

	public function testStreamFileWithThumbPath() {
		$this->overrideConfigValue( MainConfigNames::ThumbPath, '/thumbnails/' );

		$file = $this->getTestRepo()->newFile( 'Test.png' );
		$rel = $file->getRel();

		$env = $this->makeEnvironment( "/thumbnails/$rel/13px-Test.png" );
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 200 );

		$this->assertThumbnail(
			[ 'magic' => self::PNG_MAGIC, 'width' => 13, ],
			$output
		);
	}

	public function testStreamFileWithLongName() {
		$this->overrideConfigValue( MainConfigNames::VaryOnXFP, true );

		// Note that abbrvThreshold is 16 per MockRepTrait
		$file = $this->getTestRepo()->newFile( 'Animated_PNG_example_bouncing_beach_ball.png' );
		$rel = $file->getRel();
		$name = $file->getName();

		// use abbreviated name
		$env = $this->makeEnvironment( "/w/images/thumb/$rel/13px-thumbnail.png" );
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 200, $output );
		$env->assertHeaderValue( null, 'Vary' );

		// use long name
		$env = $this->makeEnvironment( "/w/images/thumb/$rel/13px-$name" );
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 301, $output );
		$env->assertHeaderValue( 'X-Forwarded-Proto', 'Vary' );

		$this->assertStringEndsWith(
			"/w/images/thumb/$rel/13px-thumbnail.png",
			$env->getFauxResponse()->getHeader( 'Location' )
		);
	}

	/**
	 * @depends testStreamFile
	 */
	public function testStreamOldFile( array $latestThumbnailInfo ) {
		$file = $this->getTestRepo()->newFile( 'Test.png' );
		$history = $file->getHistory();
		$oldFile = $history[0];

		$uri = '/w/images/thumb/' . $oldFile->getArchiveRel()
			. '/' . $oldFile->getArchiveName() . '/13px-Test.png';

		$env = $this->makeEnvironment( $uri );
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();
		$env->assertStatusCode( 200 );

		$this->assertNotSame(
			$latestThumbnailInfo['data'],
			$output,
			'Thumbnail for the old version should not be the same as the ' .
			'thumbnail for the latest version'
		);

		$this->assertThumbnail(
			[ 'magic' => self::PNG_MAGIC, 'width' => 13, ],
			$output
		);
	}

	public function testStreamTempFile() {
		$user = $this->getTestUser()->getUser();
		$stash = new UploadStash( $this->getTestRepo(), $user );
		$file = $stash->stashFile( self::IMAGES_DIR . '/adobergb.jpg' );

		$uri = '/w/images/thumb/temp/' . $file->getRel()
			. '/13px-' . $file->getName();

		$env = $this->makeEnvironment( $uri );
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 200 );
		$this->assertThumbnail(
			[ 'magic' => self::JPEG_MAGIC, 'width' => 13, ],
			$output
		);
	}

	public function testBadPath() {
		$file = $this->getTestRepo()->newFile( 'Test.png' );
		$rel = $file->getRel();

		$uri = "/w/images/thumb/$rel/148px-XYZZY";

		$env = $this->makeEnvironment( $uri );
		$entryPoint = $this->getEntryPoint( $env );

		$entryPoint->run();
		$entryPoint->getCapturedOutput();

		$env->assertStatusCode( 404 );
	}

	/**
	 * @param array $props
	 * @param string $output binary data
	 */
	private function assertThumbnail( array $props, string $output ): void {
		if ( isset( $props['magic'] ) ) {
			$this->assertStringStartsWith(
				$props['magic'],
				$output,
				'Magic number should match'
			);
		}

		if ( isset( $props['width'] ) && function_exists( 'getimagesizefromstring' ) ) {
			[ $width, ] = getimagesizefromstring( $output );
			$this->assertSame(
				$props['width'],
				$width
			);
		}
	}

}
PK       !     &  filerepo/MigrateFileRepoLayoutTest.phpnu Iw        <?php

use MediaWiki\WikiMap\WikiMap;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\FileBackend\FSFile\TempFSFile;
use Wikimedia\FileBackend\FSFileBackend;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\SelectQueryBuilder;

/**
 * @covers \MigrateFileRepoLayout
 */
class MigrateFileRepoLayoutTest extends MediaWikiIntegrationTestCase {
	/** @var string */
	protected $tmpPrefix;
	/** @var MigrateFileRepoLayout&MockObject */
	protected $migratorMock;
	/** @var string */
	protected $tmpFilepath;
	private const TEXT = 'testing';

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

		$filename = 'Foo.png';

		$this->tmpPrefix = $this->getNewTempDirectory();

		$backend = new FSFileBackend( [
			'name' => 'local-migratefilerepolayouttest',
			'wikiId' => WikiMap::getCurrentWikiId(),
			'containerPaths' => [
				'migratefilerepolayouttest-original' => "{$this->tmpPrefix}-original",
				'migratefilerepolayouttest-public' => "{$this->tmpPrefix}-public",
				'migratefilerepolayouttest-thumb' => "{$this->tmpPrefix}-thumb",
				'migratefilerepolayouttest-temp' => "{$this->tmpPrefix}-temp",
				'migratefilerepolayouttest-deleted' => "{$this->tmpPrefix}-deleted",
			]
		] );

		$dbMock = $this->createMock( IDatabase::class );

		$imageRow = (object)[
			'img_name' => $filename,
			'img_sha1' => sha1( self::TEXT ),
		];

		$dbMock->method( 'select' )
			->willReturnOnConsecutiveCalls(
				new FakeResultWrapper( [ $imageRow ] ), // image
				new FakeResultWrapper( [] ), // image
				new FakeResultWrapper( [] ) // filearchive
			);
		$dbMock->method( 'newSelectQueryBuilder' )->willReturnCallback( fn () => new SelectQueryBuilder( $dbMock ) );

		$repoMock = $this->getMockBuilder( LocalRepo::class )
			->onlyMethods( [ 'getPrimaryDB', 'getReplicaDB' ] )
			->setConstructorArgs( [ [
					'name' => 'migratefilerepolayouttest',
					'backend' => $backend
				] ] )
			->getMock();

		$repoMock
			->method( 'getPrimaryDB' )
			->willReturn( $dbMock );
		$replicaDB = $this->createMock( IDatabase::class );
		$replicaDB->method( 'getSessionLagStatus' )->willReturn( [ 'lag' => 0, 'since' => time() ] );
		$repoMock->method( 'getReplicaDB' )->willReturn( $replicaDB );

		$this->migratorMock = $this->getMockBuilder( MigrateFileRepoLayout::class )
			->onlyMethods( [ 'getRepo' ] )->getMock();
		$this->migratorMock
			->method( 'getRepo' )
			->willReturn( $repoMock );

		$this->tmpFilepath = TempFSFile::factory(
			'migratefilelayout-test-', 'png', wfTempDir() )->getPath();

		file_put_contents( $this->tmpFilepath, self::TEXT );

		$hashPath = $repoMock->getHashPath( $filename );

		$status = $repoMock->store(
			$this->tmpFilepath,
			'public',
			$hashPath . $filename,
			FileRepo::OVERWRITE
		);
	}

	protected function deleteFilesRecursively( $directory ) {
		foreach ( glob( $directory . '/*' ) as $file ) {
			if ( is_dir( $file ) ) {
				$this->deleteFilesRecursively( $file );
			} else {
				unlink( $file );
			}
		}

		rmdir( $directory );
	}

	protected function tearDown(): void {
		foreach ( glob( $this->tmpPrefix . '*' ) as $directory ) {
			$this->deleteFilesRecursively( $directory );
		}

		unlink( $this->tmpFilepath );

		parent::tearDown();
	}

	public function testMigration() {
		$this->migratorMock->loadParamsAndArgs(
			null,
			[ 'oldlayout' => 'name', 'newlayout' => 'sha1' ]
		);

		ob_start();

		$this->migratorMock->execute();

		ob_end_clean();

		$sha1 = sha1( self::TEXT );

		$expectedOriginalFilepath = $this->tmpPrefix
			. '-original/'
			. substr( $sha1, 0, 1 )
			. '/'
			. substr( $sha1, 1, 1 )
			. '/'
			. substr( $sha1, 2, 1 )
			. '/'
			. $sha1;

		$this->assertEquals(
			self::TEXT,
			file_get_contents( $expectedOriginalFilepath ),
			'New sha1 file should be exist and have the right contents'
		);

		$expectedPublicFilepath = $this->tmpPrefix . '-public/f/f8/Foo.png';

		$this->assertEquals(
			self::TEXT,
			file_get_contents( $expectedPublicFilepath ),
			'Existing name file should still and have the right contents'
		);
	}
}
PK       ! CQ      filerepo/StoreBatchTest.phpnu Iw        <?php

use MediaWiki\Status\Status;
use MediaWiki\WikiMap\WikiMap;
use Wikimedia\FileBackend\FSFileBackend;

/**
 * @group FileRepo
 * @group medium
 */
class StoreBatchTest extends MediaWikiIntegrationTestCase {

	/** @var string[] */
	protected $createdFiles;
	/** @var string */
	protected $date;
	/** @var FileRepo */
	protected $repo;

	protected function setUp(): void {
		global $wgFileBackends;
		parent::setUp();

		# Forge a FileRepo object to not have to rely on local wiki settings
		$tmpPrefix = $this->getNewTempDirectory();
		if ( $this->getCliArg( 'use-filebackend' ) ) {
			$name = $this->getCliArg( 'use-filebackend' );
			$useConfig = [];
			foreach ( $wgFileBackends as $conf ) {
				if ( $conf['name'] == $name ) {
					$useConfig = $conf;
				}
			}
			$useConfig['lockManager'] = $this->getServiceContainer()->getLockManagerGroupFactory()
				->getLockManagerGroup()->get( $useConfig['lockManager'] );
			$useConfig['name'] = 'local-testing'; // swap name
			$class = $useConfig['class'];
			$backend = new $class( $useConfig );
		} else {
			$backend = new FSFileBackend( [
				'name' => 'local-testing',
				'wikiId' => WikiMap::getCurrentWikiId(),
				'containerPaths' => [
					'unittests-public' => "{$tmpPrefix}/public",
					'unittests-thumb' => "{$tmpPrefix}/thumb",
					'unittests-temp' => "{$tmpPrefix}/temp",
					'unittests-deleted' => "{$tmpPrefix}/deleted",
				]
			] );
		}
		$this->repo = new FileRepo( [
			'name' => 'unittests',
			'backend' => $backend
		] );

		$this->date = gmdate( "YmdHis" );
		$this->createdFiles = [];
	}

	protected function tearDown(): void {
		// Delete files
		$this->repo->cleanupBatch( $this->createdFiles );
		parent::tearDown();
	}

	/**
	 * Store a file or virtual URL source into a media file name.
	 *
	 * @param string $originalName The title of the image
	 * @param string $srcPath The filepath or virtual URL
	 * @param int $flags Flags to pass into repo::store().
	 * @return Status
	 */
	private function storeit( $originalName, $srcPath, $flags ) {
		$hashPath = $this->repo->getHashPath( $originalName );
		$dstRel = "$hashPath{$this->date}!$originalName";
		$dstUrlRel = $hashPath . $this->date . '!' . rawurlencode( $originalName );

		$result = $this->repo->store( $srcPath, 'temp', $dstRel, $flags );
		$result->value = $this->repo->getVirtualUrl( 'temp' ) . '/' . $dstUrlRel;
		$this->createdFiles[] = $result->value;

		return $result;
	}

	/**
	 * Test storing a file using different flags.
	 *
	 * @param string $fn The title of the image
	 * @param string $infn The name of the file (in the filesystem)
	 * @param string $otherfn The name of the different file (in the filesystem)
	 * @param bool $fromrepo 'true' if we want to copy from a virtual URL out of the Repo.
	 */
	private function storecohort( $fn, $infn, $otherfn, $fromrepo ) {
		$f = $this->storeit( $fn, $infn, 0 );
		$this->assertStatusGood( $f, 'failed to store a new file' );
		$this->assertSame( 0, $f->failCount, "counts wrong {$f->successCount} {$f->failCount}" );
		$this->assertSame( 1, $f->successCount, "counts wrong {$f->successCount} {$f->failCount}" );
		if ( $fromrepo ) {
			$f = $this->storeit( "Other-$fn", $infn, FileRepo::OVERWRITE );
			$infn = $f->value;
		}
		// This should work because we're allowed to overwrite
		$f = $this->storeit( $fn, $infn, FileRepo::OVERWRITE );
		$this->assertStatusGood( $f, 'We should be allowed to overwrite' );
		$this->assertSame( 0, $f->failCount, "counts wrong {$f->successCount} {$f->failCount}" );
		$this->assertSame( 1, $f->successCount, "counts wrong {$f->successCount} {$f->failCount}" );
		// This should fail because we're overwriting.
		$f = $this->storeit( $fn, $infn, 0 );
		$this->assertStatusError( 'backend-fail-alreadyexists', $f, 'We should not be allowed to overwrite' );
		$this->assertSame( 1, $f->failCount, "counts wrong {$f->successCount} {$f->failCount}" );
		$this->assertSame( 0, $f->successCount, "counts wrong {$f->successCount} {$f->failCount}" );
		// This should succeed because we're overwriting the same content.
		$f = $this->storeit( $fn, $infn, FileRepo::OVERWRITE_SAME );
		$this->assertStatusGood( $f, 'We should be able to overwrite the same content' );
		$this->assertSame( 0, $f->failCount, "counts wrong {$f->successCount} {$f->failCount}" );
		$this->assertSame( 1, $f->successCount, "counts wrong {$f->successCount} {$f->failCount}" );
		// This should fail because we're overwriting different content.
		if ( $fromrepo ) {
			$f = $this->storeit( "Other-$fn", $otherfn, FileRepo::OVERWRITE );
			$otherfn = $f->value;
		}
		$f = $this->storeit( $fn, $otherfn, FileRepo::OVERWRITE_SAME );
		$this->assertStatusError( 'backend-fail-notsame', $f, 'We should not be allowed to overwrite different content' );
		$this->assertSame( 1, $f->failCount, "counts wrong {$f->successCount} {$f->failCount}" );
		$this->assertSame( 0, $f->successCount, "counts wrong {$f->successCount} {$f->failCount}" );
	}

	/**
	 * @covers \FileRepo::store
	 */
	public function teststore() {
		global $IP;
		$this->storecohort(
			"Test1.png",
			"$IP/tests/phpunit/data/filerepo/wiki.png",
			"$IP/tests/phpunit/data/filerepo/video.png",
			false
		);
		$this->storecohort(
			"Test2.png",
			"$IP/tests/phpunit/data/filerepo/wiki.png",
			"$IP/tests/phpunit/data/filerepo/video.png",
			true
		);
	}
}
PK       ! .u.  .  ,  filerepo/AuthenticatedFileEntryPointTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\FileRepo\AuthenticatedFileEntryPoint;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\FileRepo\TestRepoTrait;
use MediaWiki\Tests\MockEnvironment;
use MediaWiki\Title\Title;

/**
 * @covers \MediaWiki\FileRepo\AuthenticatedFileEntryPoint
 * @group Database
 */
class AuthenticatedFileEntryPointTest extends MediaWikiIntegrationTestCase {
	use TestRepoTrait;

	private const PNG_MAGIC = "\x89\x50\x4e\x47";
	private const JPEG_MAGIC = "\xff\xd8\xff\xe0";

	private const IMAGES_DIR = __DIR__ . '/../../data/media';

	private ?MockEnvironment $environment = null;

	/**
	 * will be called only once per test class
	 */
	public function addDBDataOnce() {
		// Set a named user account for the request context as the default,
		// so that these tests do not fail with temp accounts enabled
		RequestContext::getMain()->setUser( $this->getTestUser()->getUser() );

		// Create mock repo with test files
		$this->initTestRepoGroup();

		$this->importFileToTestRepo( self::IMAGES_DIR . '/greyscale-png.png', 'Test.png' );
		$this->importFileToTestRepo( self::IMAGES_DIR . '/test.jpg', 'Icon.jpg' );

		// Create a second version of Test.png and Icon.jpg
		$this->importFileToTestRepo( self::IMAGES_DIR . '/greyscale-na-png.png', 'Test.png' );
		$this->importFileToTestRepo( self::IMAGES_DIR . '/portrait-rotated.jpg', 'Icon.jpg' );

		// Create a thumbnail
		$this->copyFileToTestBackend(
			self::IMAGES_DIR . '/greyscale-na-png.png',
			'/thumb/Test.png'
		);

		// Create a redirect
		$title = Title::makeTitle( NS_FILE, 'Redirect_to_Test.png' );
		$this->editPage( $title, '#REDIRECT [[File:Test.png]]' );

		// Suppress the old version of Icon
		$file = $this->getTestRepo()->newFile( 'Icon.jpg' );
		$history = $file->getHistory();
		$oldFile = $history[0];

		$this->getDb()->newUpdateQueryBuilder()
			->table( 'oldimage' )
			->set( [ 'oi_deleted' => 1 ] )
			->where( [ 'oi_archive_name' => $oldFile->getArchiveName() ] )
			->caller( __METHOD__ )
			->execute();
	}

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValue(
			MainConfigNames::ImgAuthDetails,
			true
		);
		$this->overrideConfigValue(
			MainConfigNames::ForeignFileRepos,
			[]
		);
		$this->overrideConfigValue(
			MainConfigNames::UseInstantCommons,
			false
		);
		$this->overrideConfigValue(
			MainConfigNames::ImgAuthUrlPathMap,
			[ '/testing' => 'mwstore://test/test-thumb/' ]
		);
		$this->overrideConfigValue(
			MainConfigNames::ImgAuthPath,
			'/img_auth/'
		);
		$this->installTestRepoGroup();
	}

	private function recordHeader( string $header ) {
		$this->environment->getFauxResponse()->header( $header );
	}

	private function getFileUrlPath( string $name, string $prefix = '' ): string {
		if ( $prefix !== '' && !str_ends_with( $prefix, '/' ) ) {
			$prefix = $prefix . '/';
		}

		if ( !str_starts_with( $prefix, '/' ) ) {
			// Unauthenticated path
			$prefix = '/w/images/' . $prefix;
		}

		$file = $this->getTestRepo()->newFile( $name );
		if ( $file ) {
			$name = $file->getRel();
		}

		return $prefix . $name;
	}

	/**
	 * @param FauxRequest|string|array|null $request
	 *
	 * @return MockEnvironment
	 */
	private function makeEnvironment( $request ): MockEnvironment {
		if ( !$request ) {
			$request = new FauxRequest();
		}

		if ( is_string( $request ) ) {
			$url = $request;
			$request = new FauxRequest();
			$request->setRequestURL( $url );
		}

		if ( is_array( $request ) ) {
			$request = new FauxRequest( $request );
		}

		$this->environment = new MockEnvironment( $request );
		return $this->environment;
	}

	/**
	 * @param MockEnvironment|null $environment
	 * @param FauxRequest|RequestContext|string|array|null $request
	 *
	 * @return AuthenticatedFileEntryPoint
	 */
	private function getEntryPoint( ?MockEnvironment $environment = null, $request = null ) {
		if ( !$request && $environment ) {
			$request = $environment->getFauxRequest();
		}

		if ( $request instanceof RequestContext ) {
			$context = $request;
			$request = $context->getRequest();
		} else {
			$context = new RequestContext();
			$context->setRequest( $request );
			$context->setUser( $this->getTestUser()->getUser() );
		}

		if ( !$environment ) {
			$environment = $this->makeEnvironment( $request );
		}

		$entryPoint = new AuthenticatedFileEntryPoint(
			$context,
			$environment,
			$this->getServiceContainer()
		);

		$entryPoint->enableOutputCapture();
		return $entryPoint;
	}

	public static function provideGetRequestPathSuffix() {
		yield [ '/upload', '/upload/file', 'file' ];
		yield [ '/upload', '/upload/file?q=x', 'file' ];
		yield [ '/upload', '/upload/x%25y', 'x%y' ];
		yield [ '/foo', '/upload/file', false ];
	}

	/**
	 * @dataProvider provideGetRequestPathSuffix
	 *
	 * @param string $basePath
	 * @param string $requestURL
	 * @param string|false $expected
	 *
	 * @covers \MediaWiki\MediaWikiEntryPoint::getRequestPathSuffix
	 */
	public function testGetRequestPathSuffix( string $basePath, string $requestURL, $expected ) {
		$entryPoint = $this->getEntryPoint( $this->makeEnvironment( $requestURL ) );
		$this->assertSame( $expected, $entryPoint->getRequestPathSuffix( $basePath ) );
	}

	public static function provideStreamFile() {
		yield 'public wiki' => [
			'',
		];

		yield 'private wiki' => [
			'',
			[
				'*' => [],
				'user' => [ 'read' => true ],
			],
			[],
			[],
			[
				'cache-control' => 'private',
				'vary' => 'Cookie',
			]
		];

		yield 'range' => [
			'',
			[],
			[],
			[ 'HTTP_RANGE' => 'bytes=0-99' ],
			[ 'content-range' => 'bytes 0-99/365', 'content-length' => '100' ],
			206
		];

		yield 'download' => [
			'',
			[],
			[ 'download' => 1 ],
			[],
			[ 'content-disposition' => 'attachment' ]
		];

		yield 'thumb zone' => [
			// Path under /w/images/
			'thumb',
		];

		yield 'mapped prefix' => [
			// Path under /w/images/
			'testing', // per ImgAuthUrlPathMap
		];

		yield 'use ImgAuthPath' => [
			// If the prefix starts with a "/" it's the full path.
			'/img_auth/', // per ImgAuthPath
		];
	}

	/**
	 * @dataProvider provideStreamFile
	 *
	 * @param string $prefix
	 * @param array $permissions
	 * @param array $requestData
	 * @param array $serverInfo
	 * @param array $expectedHeaders
	 * @param int $expectedCode
	 *
	 * @throws Exception
	 */
	public function testStreamFile(
		string $prefix,
		array $permissions = [],
		array $requestData = [],
		array $serverInfo = [],
		array $expectedHeaders = [],
		int $expectedCode = 200
	) {
		if ( !isset( $permissions['*'] ) ) {
			// public wiki
			$permissions['*'] = [ 'read' => true ];
		}

		$this->overrideConfigValue( MainConfigNames::GroupPermissions, $permissions );

		$name = 'Test.png';
		$url = $this->getFileUrlPath( $name, $prefix );
		$request = new FauxRequest( $requestData );
		$request->setRequestURL( $url );
		$env = $this->makeEnvironment( $request );

		foreach ( $serverInfo as $key => $value ) {
			$env->setServerInfo( $key, $value );
		}

		$entryPoint = $this->getEntryPoint( $env );
		$entryPoint->run();
		$data = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( $expectedCode, $data );

		$this->assertStringStartsWith(
			self::PNG_MAGIC,
			$data
		);

		$env->assertHeaderValue( 'image/png', 'Content-Type' );
		foreach ( $expectedHeaders as $name => $exp ) {
			$env->assertHeaderValue( $exp, $name );
		}
	}

	public function testStreamFile_archive() {
		$this->overrideConfigValue(
			MainConfigNames::GroupPermissions,
			[ '*' => [ 'read' => true ] ]
		);

		$name = 'Test.png';
		$file = $this->getTestRepo()->newFile( $name );
		$history = $file->getHistory();
		$oldFile = $history[0];
		$url = '/img_auth/' . $oldFile->getArchiveRel() . '/' . $oldFile->getArchiveName();

		$env = $this->makeEnvironment( $url );

		$entryPoint = $this->getEntryPoint( $env );
		$entryPoint->run();
		$data = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 200, $data );
	}

	public function testNotModified() {
		$this->overrideConfigValue(
			MainConfigNames::GroupPermissions,
			[ '*' => [ 'read' => true ] ]
		);

		$url = $this->getFileUrlPath( 'Test.png' );
		$env = $this->makeEnvironment( $url );
		$env->setServerInfo( 'HTTP_IF_MODIFIED_SINCE', '25250101001122' );

		$entryPoint = $this->getEntryPoint( $env );
		$entryPoint->run();

		// Not modified
		$env->assertStatusCode( 304 );
	}

	public function testAccessDenied_deleted() {
		$this->overrideConfigValue(
			MainConfigNames::GroupPermissions,
			[ '*' => [ 'read' => true ] ]
		);

		$name = 'Icon.jpg';
		$file = $this->getTestRepo()->newFile( $name );
		$history = $file->getHistory();

		// This old revision is marked as deleted (supressed) in the database
		$oldFile = $history[0];
		$url = '/img_auth/' . $oldFile->getArchiveRel() . '/' . $oldFile->getArchiveName();
		$env = $this->makeEnvironment( $url );

		$entryPoint = $this->getEntryPoint( $env );
		$entryPoint->run();
		$data = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 403, $data );
	}

	public static function provideAccessDenied() {
		yield 'no prefix' => [ '' ];
		yield 'thumb zone' => [ 'thumb' ];
		yield 'mapped prefix' => [ 'testing' ];
	}

	/**
	 * @dataProvider provideAccessDenied
	 */
	public function testAccessDenied(
		string $prefix,
		string $expected = 'User does not have access to read'
	) {
		$this->overrideConfigValue(
			MainConfigNames::GroupPermissions,
			[ '*' => [], 'user' => [], ]
		);

		$env = $this->makeEnvironment( $this->getFileUrlPath( 'Test.png', $prefix ) );

		$entryPoint = $this->getEntryPoint( $env );
		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 403 );
		$env->assertHeaderValue( 'no-cache', 'cache-control' );
		$this->assertStringContainsString( '<h1>Access denied</h1>', $output );
		$this->assertStringContainsString( $expected, $output );
	}

	public function testAccessDenied_hook() {
		$this->setUserLang( 'qqx' );

		$this->overrideConfigValue(
			MainConfigNames::GroupPermissions,
			[ '*' => [], 'user' => [ 'read' => true ], ]
		);

		$this->setTemporaryHook(
			'ImgAuthBeforeStream',
			static function ( $title, $path, $name, ?array &$result ) {
				$result = [ 'test-title', 'test-detail' ];
				return false;
			}
		);

		$env = $this->makeEnvironment( $this->getFileUrlPath( 'Test.png' ) );

		$entryPoint = $this->getEntryPoint( $env );
		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		$env->assertStatusCode( 403 );
		$env->assertHeaderValue( 'no-cache', 'cache-control' );
		$this->assertStringContainsString( '<h1>⧼test-title⧽</h1>', $output );
		$this->assertStringContainsString( '<p>⧼test-detail⧽</p>', $output );
	}

	public static function provideNotFOund() {
		yield 'no prefix, missing file' =>
			[ 'No-such-file.png', '' ];

		yield 'no prefix, bad title' =>
			[ '_<>_', '' ];

		yield 'thumb zone' =>
			[ 'No-such-file.png', 'thumb' ];

		yield 'mapped prefix' =>
			[ 'No-such-file.png', 'testing' ];

		yield 'unrecognized base path' =>
			[
				'No-such-file.png',
				'/bad/base/path',
				'Requested path is not in the configured',
			];
	}

	/**
	 * @dataProvider provideNotFOund
	 */
	public function testNotFound( string $name, string $prefix, $expected = 'does not exist' ) {
		$this->overrideConfigValue(
			MainConfigNames::GroupPermissions,
			[
				'*' => [ 'read' => 'true' ],
			]
		);

		$env = $this->makeEnvironment( $this->getFileUrlPath( $name, $prefix ) );

		$entryPoint = $this->getEntryPoint( $env );
		$entryPoint->run();
		$output = $entryPoint->getCapturedOutput();

		// Missing files are also "forbidden"
		$env->assertStatusCode( 403 );
		$env->assertHeaderValue( 'no-cache', 'cache-control' );
		$this->assertStringContainsString(
			'<h1>Access denied</h1>',
			$output
		);
		$this->assertStringContainsString(
			$expected,
			$output
		);
	}

}
PK       ! 5y\  \    filerepo/RepoGroupTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @covers \RepoGroup
 */
class RepoGroupTest extends MediaWikiIntegrationTestCase {

	public function testHasForeignRepoNegative() {
		$this->overrideConfigValue( MainConfigNames::ForeignFileRepos, [] );
		$this->assertFalse( $this->getServiceContainer()->getRepoGroup()->hasForeignRepos() );
	}

	public function testHasForeignRepoPositive() {
		$this->setUpForeignRepo();
		$this->assertTrue( $this->getServiceContainer()->getRepoGroup()->hasForeignRepos() );
	}

	public function testForEachForeignRepo() {
		$this->setUpForeignRepo();
		$fakeCallback = $this->createMock( RepoGroupTestHelper::class );
		$fakeCallback->expects( $this->once() )->method( 'callback' );
		$this->getServiceContainer()->getRepoGroup()->forEachForeignRepo(
			[ $fakeCallback, 'callback' ], [ [] ] );
	}

	public function testForEachForeignRepoNone() {
		$this->overrideConfigValue( MainConfigNames::ForeignFileRepos, [] );
		$fakeCallback = $this->createMock( RepoGroupTestHelper::class );
		$fakeCallback->expects( $this->never() )->method( 'callback' );
		$this->getServiceContainer()->getRepoGroup()->forEachForeignRepo(
			[ $fakeCallback, 'callback' ], [ [] ] );
	}

	private function setUpForeignRepo() {
		global $wgUploadDirectory;
		$this->overrideConfigValue( MainConfigNames::ForeignFileRepos, [ [
			'class' => ForeignAPIRepo::class,
			'name' => 'wikimediacommons',
			'backend' => 'wikimediacommons-backend',
			'apibase' => 'https://commons.wikimedia.org/w/api.php',
			'hashLevels' => 2,
			'fetchDescription' => true,
			'descriptionCacheExpiry' => 43200,
			'apiThumbCacheExpiry' => 86400,
			'directory' => $wgUploadDirectory
		] ] );
	}
}

/**
 * Quick helper class to use as a mock callback for RepoGroup::forEachForeignRepo.
 */
class RepoGroupTestHelper {
	public function callback( FileRepo $repo, array $foo ) {
		return true;
	}
}
PK       ! /      TestUserRegistry.phpnu Iw        <?php

use MediaWiki\User\User;

/**
 * @since 1.28
 */
class TestUserRegistry {

	/** @var TestUser[] (group key => TestUser) */
	private static $testUsers = [];

	/** @var int Count of users that have been generated */
	private static $counter = 0;

	/** @var int Random int, included in IDs */
	private static $randInt;

	public static function getNextId() {
		if ( !self::$randInt ) {
			self::$randInt = mt_rand( 1, 0xFFFFFF );
		}
		return sprintf( '%06x.%03x', self::$randInt, ++self::$counter );
	}

	/**
	 * Get a TestUser object that the caller may modify.
	 *
	 * @since 1.28
	 *
	 * @param string $testName Caller's __CLASS__ or arbitrary string. Used to generate the
	 *  user's username.
	 * @param string|string[] $groups Groups the test user should be added to.
	 * @param string|null $userPrefix if non-null, the user prefix will be as specified instead of "TestUser"
	 * @return TestUser
	 */
	public static function getMutableTestUser( $testName, $groups = [], $userPrefix = null ) {
		$id = self::getNextId();
		$testUserName = "$testName $id";
		$userPrefix ??= "TestUser";
		$testUser = new TestUser(
			"$userPrefix $testName $id",
			"Name $id",
			"$id@mediawiki.test",
			(array)$groups
		);
		$testUser->getUser()->clearInstanceCache();
		return $testUser;
	}

	/**
	 * Get a TestUser object that the caller may not modify.
	 *
	 * Whenever possible, unit tests should use immutable users, because
	 * immutable users can be reused in multiple tests, which helps keep
	 * the unit tests fast.
	 *
	 * @since 1.28
	 *
	 * @param string|string[] $groups Groups the test user should be added to.
	 * @return TestUser
	 */
	public static function getImmutableTestUser( $groups = [] ) {
		$groups = array_unique( (array)$groups );
		sort( $groups );
		$key = implode( ',', $groups );

		$testUser = self::$testUsers[$key] ?? false;

		if ( !$testUser || !$testUser->getUser()->isRegistered() ) {
			$id = self::getNextId();
			// Hack! If this is the primary sysop account, make the username
			// be 'UTSysop', for back-compat, and for the sake of PHPUnit data
			// provider methods, which are executed before the test database
			// is set up. See T136348.
			if ( $groups === [ 'bureaucrat', 'sysop' ] ) {
				$username = 'UTSysop';
			} else {
				$username = "TestUser $id";
			}
			self::$testUsers[$key] = $testUser = new TestUser(
				$username,
				"Name $id",
				"$id@mediawiki.test",
				$groups
			);
		}

		$testUser->getUser()->clearInstanceCache();
		return self::$testUsers[$key];
	}

	/**
	 * TestUsers created by this class will not be deleted, but any handles
	 * to existing immutable TestUsers will be deleted, ensuring these users
	 * are not reused. We don't reset the counter or random string by design.
	 *
	 * @since 1.28
	 */
	public static function clear() {
		self::$testUsers = [];
	}

	/**
	 * Call clearInstanceCache() on all User objects known to the registry.
	 * This ensures that the User objects do not retain stale references
	 * to service objects.
	 *
	 * @since 1.39
	 */
	public static function clearInstanceCaches() {
		foreach ( self::$testUsers as $user ) {
			$user->getUser()->clearInstanceCache();
		}
	}

	/**
	 * @todo It would be nice if this were a non-static method of TestUser
	 * instead, but that doesn't seem possible without friends?
	 *
	 * @param User $user
	 * @return bool True if it's safe to modify the user
	 */
	public static function isMutable( User $user ) {
		foreach ( self::$testUsers as $key => $testUser ) {
			if ( $user === $testUser->getUser() ) {
				return false;
			}
		}
		return true;
	}
}
PK       ! $      session/CsrfTokenSetTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Session;

use MediaWiki\Request\WebRequest;
use MediaWiki\Session\CsrfTokenSet;
use MediaWiki\Session\SessionManager;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Session\CsrfTokenSet
 * @group Database
 */
class CsrfTokenSetTest extends MediaWikiIntegrationTestCase {

	private function makeRequest( bool $userRegistered ): WebRequest {
		$webRequest = new WebRequest();
		$session1 = SessionManager::singleton()->getEmptySession( $webRequest );
		$session1->setUser( $userRegistered ? $this->getTestUser()->getUser() : new User() );
		return $webRequest;
	}

	public function testCSRFTokens_anon() {
		$webRequest1 = $this->makeRequest( false );
		$tokenRepo1 = new CsrfTokenSet( $webRequest1 );
		$token = $tokenRepo1->getToken()->toString();
		$webRequest2 = $this->makeRequest( false );
		$tokenRepo2 = new CsrfTokenSet( $webRequest2 );
		$this->assertTrue( $tokenRepo2->matchToken( $token ) );
		$webRequest2->setVal( 'wpBlabla', $token );
		$this->assertTrue( $tokenRepo2->matchTokenField( 'wpBlabla' ) );
	}

	public function testCSRFTokens_registered() {
		$webRequest1 = $this->makeRequest( true );
		$tokenRepo1 = new CsrfTokenSet( $webRequest1 );
		$token = $tokenRepo1->getToken()->toString();
		$this->assertTrue( $tokenRepo1->matchToken( $token ) );
		$this->assertFalse( $tokenRepo1->matchTokenField( 'wpBlabla' ) );
		$webRequest1->setVal( 'wpBlabla', $token );
		$this->assertTrue( $tokenRepo1->matchTokenField( 'wpBlabla' ) );
		$webRequest2 = $this->makeRequest( true );
		$webRequest2->setVal( 'wpBlabla', $token );
		$tokenRepo2 = new CsrfTokenSet( $webRequest2 );
		$this->assertFalse( $tokenRepo2->matchTokenField( 'wpBlabla' ) );
		$this->assertFalse( $tokenRepo2->matchToken( $token ) );
	}
}
PK       ! ?4   4     session/UserInfoTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Session;

use InvalidArgumentException;
use MediaWiki\Session\UserInfo;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;

/**
 * @group Session
 * @group Database
 * @covers \MediaWiki\Session\UserInfo
 */
class UserInfoTest extends MediaWikiIntegrationTestCase {

	public function testNewAnonymous() {
		$userinfo = UserInfo::newAnonymous();

		$this->assertTrue( $userinfo->isAnon() );
		$this->assertTrue( $userinfo->isVerified() );
		$this->assertSame( 0, $userinfo->getId() );
		$this->assertSame( null, $userinfo->getName() );
		$this->assertSame( '', $userinfo->getToken() );
		$this->assertNotNull( $userinfo->getUser() );
		$this->assertSame( $userinfo, $userinfo->verified() );
		$this->assertSame( '<anon>', (string)$userinfo );
	}

	public function testNewFromId() {
		$id = $this->getDb()->newSelectQueryBuilder()
			->select( 'MAX(user_id)' )
			->from( 'user' )
			->fetchField() + 1;
		try {
			UserInfo::newFromId( $id );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Invalid ID', $ex->getMessage() );
		}

		$user = $this->getTestSysop()->getUser();
		$userinfo = UserInfo::newFromId( $user->getId() );
		$this->assertFalse( $userinfo->isAnon() );
		$this->assertFalse( $userinfo->isVerified() );
		$this->assertSame( $user->getId(), $userinfo->getId() );
		$this->assertSame( $user->getName(), $userinfo->getName() );
		$this->assertSame( $user->getToken( true ), $userinfo->getToken() );
		$this->assertInstanceOf( User::class, $userinfo->getUser() );
		$userinfo2 = $userinfo->verified();
		$this->assertNotSame( $userinfo2, $userinfo );
		$this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );

		$this->assertFalse( $userinfo2->isAnon() );
		$this->assertTrue( $userinfo2->isVerified() );
		$this->assertSame( $user->getId(), $userinfo2->getId() );
		$this->assertSame( $user->getName(), $userinfo2->getName() );
		$this->assertSame( $user->getToken( true ), $userinfo2->getToken() );
		$this->assertInstanceOf( User::class, $userinfo2->getUser() );
		$this->assertSame( $userinfo2, $userinfo2->verified() );
		$this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );

		$userinfo = UserInfo::newFromId( $user->getId(), true );
		$this->assertTrue( $userinfo->isVerified() );
		$this->assertSame( $userinfo, $userinfo->verified() );
	}

	public function testNewFromName() {
		try {
			UserInfo::newFromName( '<bad name>' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Invalid user name', $ex->getMessage() );
		}

		// User name that exists
		$user = $this->getTestSysop()->getUser();
		$userinfo = UserInfo::newFromName( $user->getName() );
		$this->assertFalse( $userinfo->isAnon() );
		$this->assertFalse( $userinfo->isVerified() );
		$this->assertSame( $user->getId(), $userinfo->getId() );
		$this->assertSame( $user->getName(), $userinfo->getName() );
		$this->assertSame( $user->getToken( true ), $userinfo->getToken() );
		$this->assertInstanceOf( User::class, $userinfo->getUser() );
		$userinfo2 = $userinfo->verified();
		$this->assertNotSame( $userinfo2, $userinfo );
		$this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );

		$this->assertFalse( $userinfo2->isAnon() );
		$this->assertTrue( $userinfo2->isVerified() );
		$this->assertSame( $user->getId(), $userinfo2->getId() );
		$this->assertSame( $user->getName(), $userinfo2->getName() );
		$this->assertSame( $user->getToken( true ), $userinfo2->getToken() );
		$this->assertInstanceOf( User::class, $userinfo2->getUser() );
		$this->assertSame( $userinfo2, $userinfo2->verified() );
		$this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );

		$userinfo = UserInfo::newFromName( $user->getName(), true );
		$this->assertTrue( $userinfo->isVerified() );
		$this->assertSame( $userinfo, $userinfo->verified() );

		// User name that does not exist should still be non-anon
		$user = User::newFromName( 'DoesNotExist' );
		$this->assertSame( 0, $user->getId(), 'User id is 0' );
		$userinfo = UserInfo::newFromName( $user->getName() );
		$this->assertFalse( $userinfo->isAnon() );
		$this->assertFalse( $userinfo->isVerified() );
		$this->assertSame( $user->getId(), $userinfo->getId() );
		$this->assertSame( $user->getName(), $userinfo->getName() );
		$this->assertSame( '', $userinfo->getToken() );
		$this->assertInstanceOf( User::class, $userinfo->getUser() );
		$userinfo2 = $userinfo->verified();
		$this->assertNotSame( $userinfo2, $userinfo );
		$this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );

		$this->assertFalse( $userinfo2->isAnon() );
		$this->assertTrue( $userinfo2->isVerified() );
		$this->assertSame( $user->getId(), $userinfo2->getId() );
		$this->assertSame( $user->getName(), $userinfo2->getName() );
		$this->assertSame( '', $userinfo2->getToken() );
		$this->assertInstanceOf( User::class, $userinfo2->getUser() );
		$this->assertSame( $userinfo2, $userinfo2->verified() );
		$this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );

		$userinfo = UserInfo::newFromName( $user->getName(), true );
		$this->assertTrue( $userinfo->isVerified() );
		$this->assertSame( $userinfo, $userinfo->verified() );
	}

	public function testNewFromUser() {
		// User that exists
		$user = $this->getTestSysop()->getUser();
		$userinfo = UserInfo::newFromUser( $user );
		$this->assertFalse( $userinfo->isAnon() );
		$this->assertFalse( $userinfo->isVerified() );
		$this->assertSame( $user->getId(), $userinfo->getId() );
		$this->assertSame( $user->getName(), $userinfo->getName() );
		$this->assertSame( $user->getToken( true ), $userinfo->getToken() );
		$this->assertSame( $user, $userinfo->getUser() );
		$userinfo2 = $userinfo->verified();
		$this->assertNotSame( $userinfo2, $userinfo );
		$this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );

		$this->assertFalse( $userinfo2->isAnon() );
		$this->assertTrue( $userinfo2->isVerified() );
		$this->assertSame( $user->getId(), $userinfo2->getId() );
		$this->assertSame( $user->getName(), $userinfo2->getName() );
		$this->assertSame( $user->getToken( true ), $userinfo2->getToken() );
		$this->assertSame( $user, $userinfo2->getUser() );
		$this->assertSame( $userinfo2, $userinfo2->verified() );
		$this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );

		$userinfo = UserInfo::newFromUser( $user, true );
		$this->assertTrue( $userinfo->isVerified() );
		$this->assertSame( $userinfo, $userinfo->verified() );

		// User name that does not exist should still be non-anon
		$user = User::newFromName( 'DoesNotExist' );
		$this->assertSame( 0, $user->getId() );
		$userinfo = UserInfo::newFromUser( $user );
		$this->assertFalse( $userinfo->isAnon() );
		$this->assertFalse( $userinfo->isVerified() );
		$this->assertSame( $user->getId(), $userinfo->getId() );
		$this->assertSame( $user->getName(), $userinfo->getName() );
		$this->assertSame( '', $userinfo->getToken() );
		$this->assertSame( $user, $userinfo->getUser() );
		$userinfo2 = $userinfo->verified();
		$this->assertNotSame( $userinfo2, $userinfo );
		$this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );

		$this->assertFalse( $userinfo2->isAnon() );
		$this->assertTrue( $userinfo2->isVerified() );
		$this->assertSame( $user->getId(), $userinfo2->getId() );
		$this->assertSame( $user->getName(), $userinfo2->getName() );
		$this->assertSame( '', $userinfo2->getToken() );
		$this->assertSame( $user, $userinfo2->getUser() );
		$this->assertSame( $userinfo2, $userinfo2->verified() );
		$this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );

		$userinfo = UserInfo::newFromUser( $user, true );
		$this->assertTrue( $userinfo->isVerified() );
		$this->assertSame( $userinfo, $userinfo->verified() );

		// Anonymous user gives anon
		$userinfo = UserInfo::newFromUser( new User, false );
		$this->assertTrue( $userinfo->isVerified() );
		$this->assertSame( 0, $userinfo->getId() );
		$this->assertSame( null, $userinfo->getName() );
	}

}
PK       ! M`  `  %  session/CookieSessionProviderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Session;

use ArrayUtils;
use InvalidArgumentException;
use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\FauxResponse;
use MediaWiki\Session\CookieSessionProvider;
use MediaWiki\Session\SessionBackend;
use MediaWiki\Session\SessionId;
use MediaWiki\Session\SessionInfo;
use MediaWiki\Session\SessionManager;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use Psr\Log\LogLevel;
use Psr\Log\NullLogger;
use TestLogger;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Session
 * @group Database
 * @covers \MediaWiki\Session\CookieSessionProvider
 */
class CookieSessionProviderTest extends MediaWikiIntegrationTestCase {
	use SessionProviderTestTrait;

	private function getConfig() {
		return new HashConfig( [
			MainConfigNames::CookiePrefix => 'CookiePrefix',
			MainConfigNames::CookiePath => 'CookiePath',
			MainConfigNames::CookieDomain => 'CookieDomain',
			MainConfigNames::CookieSecure => true,
			MainConfigNames::CookieHttpOnly => true,
			MainConfigNames::CookieSameSite => '',
			MainConfigNames::SessionName => false,
			MainConfigNames::CookieExpiration => 100,
			MainConfigNames::ExtendedLoginCookieExpiration => 200,
			MainConfigNames::ForceHTTPS => false,
		] );
	}

	/**
	 * Provider for testing both values of $wgForceHTTPS
	 */
	public static function provideForceHTTPS() {
		return [
			[ false ],
			[ true ]
		];
	}

	public function testConstructor() {
		try {
			new CookieSessionProvider();
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'MediaWiki\\Session\\CookieSessionProvider::__construct: priority must be specified',
				$ex->getMessage()
			);
		}

		try {
			new CookieSessionProvider( [ 'priority' => 'foo' ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority',
				$ex->getMessage()
			);
		}
		try {
			new CookieSessionProvider( [ 'priority' => SessionInfo::MIN_PRIORITY - 1 ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority',
				$ex->getMessage()
			);
		}
		try {
			new CookieSessionProvider( [ 'priority' => SessionInfo::MAX_PRIORITY + 1 ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority',
				$ex->getMessage()
			);
		}

		try {
			new CookieSessionProvider( [ 'priority' => 1, 'cookieOptions' => null ] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'MediaWiki\\Session\\CookieSessionProvider::__construct: cookieOptions must be an array',
				$ex->getMessage()
			);
		}

		$config = $this->getConfig();
		$provider = new CookieSessionProvider( [ 'priority' => 1 ] );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$this->initProvider( $provider, new TestLogger(), $config );
		$this->assertSame( 1, $providerPriv->priority );
		$this->assertEquals( [
			'sessionName' => 'CookiePrefix_session',
		], $providerPriv->params );
		$this->assertEquals( [
			'prefix' => 'CookiePrefix',
			'path' => 'CookiePath',
			'domain' => 'CookieDomain',
			'secure' => true,
			'httpOnly' => true,
			'sameSite' => '',
		], $providerPriv->cookieOptions );

		$config->set( MainConfigNames::SessionName, 'SessionName' );
		$provider = new CookieSessionProvider( [ 'priority' => 3 ] );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$this->initProvider( $provider, new TestLogger(), $config );
		$this->assertEquals( 3, $providerPriv->priority );
		$this->assertEquals( [
			'sessionName' => 'SessionName',
		], $providerPriv->params );
		$this->assertEquals( [
			'prefix' => 'CookiePrefix',
			'path' => 'CookiePath',
			'domain' => 'CookieDomain',
			'secure' => true,
			'httpOnly' => true,
			'sameSite' => '',
		], $providerPriv->cookieOptions );

		$provider = new CookieSessionProvider( [
			'priority' => 10,
			'cookieOptions' => [
				'prefix' => 'XPrefix',
				'path' => 'XPath',
				'domain' => 'XDomain',
				'secure' => 'XSecure',
				'httpOnly' => 'XHttpOnly',
				'sameSite' => 'XSameSite',
			],
			'sessionName' => 'XSession',
		] );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$this->initProvider( $provider, new TestLogger(), $config );
		$this->assertEquals( 10, $providerPriv->priority );
		$this->assertEquals( [
			'sessionName' => 'XSession',
		], $providerPriv->params );
		$this->assertEquals( [
			'prefix' => 'XPrefix',
			'path' => 'XPath',
			'domain' => 'XDomain',
			'secure' => 'XSecure',
			'httpOnly' => 'XHttpOnly',
			'sameSite' => 'XSameSite',
		], $providerPriv->cookieOptions );
	}

	public function testBasics() {
		$provider = new CookieSessionProvider( [ 'priority' => 10 ] );

		$this->assertTrue( $provider->persistsSessionId() );
		$this->assertTrue( $provider->canChangeUser() );

		$extendedCookies = [ 'UserID', 'UserName', 'Token' ];

		$this->assertEquals(
			$extendedCookies,
			TestingAccessWrapper::newFromObject( $provider )->getExtendedLoginCookies(),
			'List of extended cookies (subclasses can add values, but we\'re calling the core one here)'
		);

		$msg = $provider->whyNoSession();
		$this->assertInstanceOf( Message::class, $msg );
		$this->assertSame( 'sessionprovider-nocookies', $msg->getKey() );
	}

	public function testProvideSessionInfo() {
		$params = [
			'priority' => 20,
			'sessionName' => 'session',
			'cookieOptions' => [ 'prefix' => 'x' ],
		];
		$provider = new CookieSessionProvider( $params );
		$logger = new TestLogger( true );
		$this->initProvider( $provider, $logger, $this->getConfig(), new SessionManager() );

		$user = static::getTestSysop()->getUser();
		$id = $user->getId();
		$name = $user->getName();
		$token = $user->getToken( true );
		$sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';

		// No data
		$request = new FauxRequest();
		$info = $provider->provideSessionInfo( $request );
		$this->assertNull( $info );
		$this->assertSame( [], $logger->getBuffer() );
		$logger->clearBuffer();

		// Session key only
		$request = new FauxRequest();
		$request->setCookies( [
			'session' => $sessionId,
		], '' );
		$info = $provider->provideSessionInfo( $request );
		$this->assertNotNull( $info );
		$this->assertSame( $params['priority'], $info->getPriority() );
		$this->assertSame( $sessionId, $info->getId() );
		$this->assertNotNull( $info->getUserInfo() );
		$this->assertSame( 0, $info->getUserInfo()->getId() );
		$this->assertNull( $info->getUserInfo()->getName() );
		$this->assertFalse( $info->forceHTTPS() );
		$this->assertSame( [
			[
				LogLevel::DEBUG,
				'Session "{session}" requested without UserID cookie',
			],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// User, no session key
		$request = new FauxRequest();
		$request->setCookies( [
			'xUserID' => $id,
			'xToken' => $token,
		], '' );
		$info = $provider->provideSessionInfo( $request );
		$this->assertNotNull( $info );
		$this->assertSame( $params['priority'], $info->getPriority() );
		$this->assertNotSame( $sessionId, $info->getId() );
		$this->assertNotNull( $info->getUserInfo() );
		$this->assertSame( $id, $info->getUserInfo()->getId() );
		$this->assertSame( $name, $info->getUserInfo()->getName() );
		$this->assertFalse( $info->forceHTTPS() );
		$this->assertSame( [], $logger->getBuffer() );
		$logger->clearBuffer();

		// User and session key
		$request = new FauxRequest();
		$request->setCookies( [
			'session' => $sessionId,
			'xUserID' => $id,
			'xToken' => $token,
		], '' );
		$info = $provider->provideSessionInfo( $request );
		$this->assertNotNull( $info );
		$this->assertSame( $params['priority'], $info->getPriority() );
		$this->assertSame( $sessionId, $info->getId() );
		$this->assertNotNull( $info->getUserInfo() );
		$this->assertSame( $id, $info->getUserInfo()->getId() );
		$this->assertSame( $name, $info->getUserInfo()->getName() );
		$this->assertFalse( $info->forceHTTPS() );
		$this->assertSame( [], $logger->getBuffer() );
		$logger->clearBuffer();

		// User with bad token
		$request = new FauxRequest();
		$request->setCookies( [
			'session' => $sessionId,
			'xUserID' => $id,
			'xToken' => 'BADTOKEN',
		], '' );
		$info = $provider->provideSessionInfo( $request );
		$this->assertNull( $info );
		$this->assertSame( [
			[
				LogLevel::WARNING,
				'Session "{session}" requested with invalid Token cookie.'
			],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// User id with no token
		$request = new FauxRequest();
		$request->setCookies( [
			'session' => $sessionId,
			'xUserID' => $id,
		], '' );
		$info = $provider->provideSessionInfo( $request );
		$this->assertNotNull( $info );
		$this->assertSame( $params['priority'], $info->getPriority() );
		$this->assertSame( $sessionId, $info->getId() );
		$this->assertNotNull( $info->getUserInfo() );
		$this->assertFalse( $info->getUserInfo()->isVerified() );
		$this->assertSame( $id, $info->getUserInfo()->getId() );
		$this->assertSame( $name, $info->getUserInfo()->getName() );
		$this->assertFalse( $info->forceHTTPS() );
		$this->assertSame( [], $logger->getBuffer() );
		$logger->clearBuffer();

		$request = new FauxRequest();
		$request->setCookies( [
			'xUserID' => $id,
		], '' );
		$info = $provider->provideSessionInfo( $request );
		$this->assertNull( $info );
		$this->assertSame( [], $logger->getBuffer() );
		$logger->clearBuffer();

		// User and session key, with forceHTTPS flag
		$request = new FauxRequest();
		$request->setCookies( [
			'session' => $sessionId,
			'xUserID' => $id,
			'xToken' => $token,
			'forceHTTPS' => true,
		], '' );
		$info = $provider->provideSessionInfo( $request );
		$this->assertNotNull( $info );
		$this->assertSame( $params['priority'], $info->getPriority() );
		$this->assertSame( $sessionId, $info->getId() );
		$this->assertNotNull( $info->getUserInfo() );
		$this->assertSame( $id, $info->getUserInfo()->getId() );
		$this->assertSame( $name, $info->getUserInfo()->getName() );
		$this->assertTrue( $info->forceHTTPS() );
		$this->assertSame( [], $logger->getBuffer() );
		$logger->clearBuffer();

		// Invalid user id
		$request = new FauxRequest();
		$request->setCookies( [
			'session' => $sessionId,
			'xUserID' => '-1',
		], '' );
		$info = $provider->provideSessionInfo( $request );
		$this->assertNull( $info );
		$this->assertSame( [], $logger->getBuffer() );
		$logger->clearBuffer();

		// User id with matching name
		$request = new FauxRequest();
		$request->setCookies( [
			'session' => $sessionId,
			'xUserID' => $id,
			'xUserName' => $name,
		], '' );
		$info = $provider->provideSessionInfo( $request );
		$this->assertNotNull( $info );
		$this->assertSame( $params['priority'], $info->getPriority() );
		$this->assertSame( $sessionId, $info->getId() );
		$this->assertNotNull( $info->getUserInfo() );
		$this->assertFalse( $info->getUserInfo()->isVerified() );
		$this->assertSame( $id, $info->getUserInfo()->getId() );
		$this->assertSame( $name, $info->getUserInfo()->getName() );
		$this->assertFalse( $info->forceHTTPS() );
		$this->assertSame( [], $logger->getBuffer() );
		$logger->clearBuffer();

		// User id with wrong name
		$request = new FauxRequest();
		$request->setCookies( [
			'session' => $sessionId,
			'xUserID' => $id,
			'xUserName' => 'Wrong',
		], '' );
		$info = $provider->provideSessionInfo( $request );
		$this->assertNull( $info );
		$this->assertSame( [
			[
				LogLevel::WARNING,
				'Session "{session}" requested with mismatched UserID and UserName cookies.',
			],
		], $logger->getBuffer() );
		$logger->clearBuffer();
	}

	public function testGetVaryCookies() {
		$provider = new CookieSessionProvider( [
			'priority' => 1,
			'sessionName' => 'MySessionName',
			'cookieOptions' => [ 'prefix' => 'MyCookiePrefix' ],
		] );
		$this->assertArrayEquals( [
			'MyCookiePrefixToken',
			'MyCookiePrefixLoggedOut',
			'MySessionName',
			'forceHTTPS',
		], $provider->getVaryCookies() );
	}

	public function testSuggestLoginUsername() {
		$provider = new CookieSessionProvider( [
			'priority' => 1,
			'sessionName' => 'MySessionName',
			'cookieOptions' => [ 'prefix' => 'x' ],
		] );
		$this->initProvider(
			$provider, null, $this->getConfig(), null, null, $this->getServiceContainer()->getUserNameUtils()
		);

		$request = new FauxRequest();
		$this->assertNull( $provider->suggestLoginUsername( $request ) );

		$request->setCookies( [
			'xUserName' => 'Example',
		], '' );
		$this->assertEquals( 'Example', $provider->suggestLoginUsername( $request ) );
	}

	/** @dataProvider provideForceHTTPS */
	public function testPersistSession( $forceHTTPS ) {
		$provider = new CookieSessionProvider( [
			'priority' => 1,
			'sessionName' => 'MySessionName',
			'cookieOptions' => [ 'prefix' => 'x' ],
		] );
		$config = $this->getConfig();
		$config->set( MainConfigNames::ForceHTTPS, $forceHTTPS );
		$hookContainer = $this->createHookContainer();
		$this->initProvider( $provider, new TestLogger(), $config, SessionManager::singleton(), $hookContainer );

		$sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
		$store = new TestBagOStuff();

		// For User::requiresHTTPS
		$this->overrideConfigValue( MainConfigNames::ForceHTTPS, $forceHTTPS );

		$user = static::getTestSysop()->getUser();
		$anon = new User;

		$backend = new SessionBackend(
			new SessionId( $sessionId ),
			new SessionInfo( SessionInfo::MIN_PRIORITY, [
				'provider' => $provider,
				'id' => $sessionId,
				'persisted' => true,
				'idIsSafe' => true,
			] ),
			$store,
			new NullLogger(),
			$hookContainer,
			10
		);
		TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;

		// Anonymous user
		$backend->setUser( $anon );
		$backend->setRememberUser( true );
		$backend->setForceHTTPS( false );
		$request = new FauxRequest();
		$provider->persistSession( $backend, $request );
		$this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
		$this->assertSame( '', $request->response()->getCookie( 'xUserID' ) );
		$this->assertSame( null, $request->response()->getCookie( 'xUserName' ) );
		$this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
		if ( $forceHTTPS ) {
			$this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) );
		} else {
			$this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
		}
		$this->assertSame( [], $backend->getData() );

		// Logged-in user, no remember
		$backend->setUser( $user );
		$backend->setRememberUser( false );
		$backend->setForceHTTPS( false );
		$request = new FauxRequest();
		$provider->persistSession( $backend, $request );
		$this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
		$this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
		$this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
		$this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
		if ( $forceHTTPS ) {
			$this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) );
		} else {
			$this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
		}
		$this->assertSame( [], $backend->getData() );

		// Logged-in user, remember
		$backend->setUser( $user );
		$backend->setRememberUser( true );
		$backend->setForceHTTPS( true );
		$request = new FauxRequest();
		$provider->persistSession( $backend, $request );
		$this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
		$this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
		$this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
		$this->assertSame( $user->getToken(), $request->response()->getCookie( 'xToken' ) );
		if ( $forceHTTPS ) {
			$this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) );
		} else {
			$this->assertSame( 'true', $request->response()->getCookie( 'forceHTTPS' ) );
		}
		$this->assertSame( [], $backend->getData() );
	}

	/**
	 * @dataProvider provideCookieData
	 * @param bool $secure
	 * @param bool $remember
	 * @param bool $forceHTTPS
	 */
	public function testCookieData( $secure, $remember, $forceHTTPS ) {
		$this->overrideConfigValues( [
			MainConfigNames::SecureLogin => false,
			MainConfigNames::ForceHTTPS => $forceHTTPS,
		] );

		$provider = new CookieSessionProvider( [
			'priority' => 1,
			'sessionName' => 'MySessionName',
			'cookieOptions' => [ 'prefix' => 'x' ],
		] );
		$config = $this->getConfig();
		$config->set( MainConfigNames::CookieSecure, $secure );
		$config->set( MainConfigNames::ForceHTTPS, $forceHTTPS );
		$hookContainer = $this->createHookContainer();
		$this->initProvider( $provider, new TestLogger(), $config, SessionManager::singleton(), $hookContainer );

		$sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
		$user = static::getTestSysop()->getUser();
		$this->assertSame( $user->requiresHTTPS(), $forceHTTPS );

		$backend = new SessionBackend(
			new SessionId( $sessionId ),
			new SessionInfo( SessionInfo::MIN_PRIORITY, [
				'provider' => $provider,
				'id' => $sessionId,
				'persisted' => true,
				'idIsSafe' => true,
			] ),
			new TestBagOStuff(),
			new NullLogger(),
			$hookContainer,
			10
		);
		TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;
		$backend->setUser( $user );
		$backend->setRememberUser( $remember );
		$backend->setForceHTTPS( $secure );
		$request = new FauxRequest();
		$time = time();
		$provider->persistSession( $backend, $request );

		$defaults = [
			'expire' => (int)100,
			'path' => $config->get( MainConfigNames::CookiePath ),
			'domain' => $config->get( MainConfigNames::CookieDomain ),
			'secure' => $secure || $forceHTTPS,
			'httpOnly' => $config->get( MainConfigNames::CookieHttpOnly ),
			'raw' => false,
		];

		$normalExpiry = $config->get( MainConfigNames::CookieExpiration );
		$extendedExpiry = $config->get( MainConfigNames::ExtendedLoginCookieExpiration );
		$extendedExpiry = (int)( $extendedExpiry ?? 0 );
		$expect = [
			'MySessionName' => [
				'value' => (string)$sessionId,
				'expire' => 0,
			] + $defaults,
			'xUserID' => [
				'value' => (string)$user->getId(),
				'expire' => $remember ? $extendedExpiry : $normalExpiry,
			] + $defaults,
			'xUserName' => [
				'value' => $user->getName(),
				'expire' => $remember ? $extendedExpiry : $normalExpiry
			] + $defaults,
			'xToken' => [
				'value' => $remember ? $user->getToken() : '',
				'expire' => $remember ? $extendedExpiry : -31536000,
			] + $defaults
		];
		if ( !$forceHTTPS ) {
			$expect['forceHTTPS'] = [
				'value' => $secure ? 'true' : '',
				'secure' => false,
				'expire' => $secure ? ( $remember ? $defaults['expire'] : 0 ) : -31536000,
			] + $defaults;
		}
		foreach ( $expect as $key => $value ) {
			$actual = $request->response()->getCookieData( $key );
			if ( $actual && $actual['expire'] > 0 ) {
				// Round expiry so we don't randomly fail if the seconds ticked during the test.
				$actual['expire'] = round( $actual['expire'] - $time, -2 );
			}
			$this->assertEquals( $value, $actual, "Cookie $key" );
		}
	}

	public static function provideCookieData() {
		return ArrayUtils::cartesianProduct(
			[ false, true ], // $secure
			[ false, true ], // $remember
			[ false, true ] // $forceHTTPS
		);
	}

	protected function getSentRequest() {
		$sentResponse = $this->getMockBuilder( FauxResponse::class )
			->onlyMethods( [ 'headersSent', 'setCookie', 'header' ] )->getMock();
		$sentResponse->method( 'headersSent' )
			->willReturn( true );
		$sentResponse->expects( $this->never() )->method( 'setCookie' );
		$sentResponse->expects( $this->never() )->method( 'header' );

		$sentRequest = $this->getMockBuilder( FauxRequest::class )
			->onlyMethods( [ 'response' ] )->getMock();
		$sentRequest->method( 'response' )
			->willReturn( $sentResponse );
		return $sentRequest;
	}

	public function testUnpersistSession() {
		$provider = new CookieSessionProvider( [
			'priority' => 1,
			'sessionName' => 'MySessionName',
			'cookieOptions' => [ 'prefix' => 'x' ],
		] );
		$this->initProvider(
			$provider, null, $this->getConfig(), SessionManager::singleton(), $this->createHookContainer()
		);

		$request = new FauxRequest();
		$provider->unpersistSession( $request );
		$this->assertSame( '', $request->response()->getCookie( 'MySessionName' ) );
		$this->assertSame( '', $request->response()->getCookie( 'xUserID' ) );
		$this->assertSame( null, $request->response()->getCookie( 'xUserName' ) );
		$this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
		$this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );

		$provider->unpersistSession( $this->getSentRequest() );
	}

	public function testSetLoggedOutCookie() {
		$provider = new CookieSessionProvider( [
			'priority' => 1,
			'sessionName' => 'MySessionName',
			'cookieOptions' => [ 'prefix' => 'x' ],
		] );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$this->initProvider(
			$provider, null, $this->getConfig(), SessionManager::singleton(), $this->createHookContainer()
		);

		$t1 = time();
		$t2 = time() - 86400 * 2;

		// Set it
		$request = new FauxRequest();
		$providerPriv->setLoggedOutCookie( $t1, $request );
		$this->assertSame( (string)$t1, $request->response()->getCookie( 'xLoggedOut' ) );

		// Too old
		$request = new FauxRequest();
		$providerPriv->setLoggedOutCookie( $t2, $request );
		$this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) );

		// Don't reset if it's already set
		$request = new FauxRequest();
		$request->setCookies( [
			'xLoggedOut' => $t1,
		], '' );
		$providerPriv->setLoggedOutCookie( $t1, $request );
		$this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) );
	}

	public function testGetCookie() {
		$provider = new CookieSessionProvider( [
			'priority' => 1,
			'sessionName' => 'MySessionName',
			'cookieOptions' => [ 'prefix' => 'x' ],
		] );
		$this->initProvider(
			$provider, null, $this->getConfig(), SessionManager::singleton(), $this->createHookContainer()
		);
		$provider = TestingAccessWrapper::newFromObject( $provider );

		$request = new FauxRequest();
		$request->setCookies( [
			'xFoo' => 'foo!',
			'xBar' => 'deleted',
		], '' );
		$this->assertSame( 'foo!', $provider->getCookie( $request, 'Foo', 'x' ) );
		$this->assertNull( $provider->getCookie( $request, 'Bar', 'x' ) );
		$this->assertNull( $provider->getCookie( $request, 'Baz', 'x' ) );
	}

	public function testGetRememberUserDuration() {
		$config = $this->getConfig();
		$provider = new CookieSessionProvider( [ 'priority' => 10 ] );
		$this->initProvider( $provider, null, $config, SessionManager::singleton(), $this->createHookContainer() );

		$this->assertSame( 200, $provider->getRememberUserDuration() );

		$config->set( MainConfigNames::ExtendedLoginCookieExpiration, null );

		$this->assertSame( 100, $provider->getRememberUserDuration() );

		$config->set( MainConfigNames::ExtendedLoginCookieExpiration, 0 );

		$this->assertSame( null, $provider->getRememberUserDuration() );
	}

	public function testGetLoginCookieExpiration() {
		$config = $this->getConfig();
		$provider = new CookieSessionProvider( [
			'priority' => 10
		] );
		$providerPriv = TestingAccessWrapper::newFromObject( $provider );
		$this->initProvider( $provider, null, $config, SessionManager::singleton(), $this->createHookContainer() );

		// First cookie is an extended cookie, remember me true
		$this->assertSame( 200, $providerPriv->getLoginCookieExpiration( 'Token', true ) );
		$this->assertSame( 100, $providerPriv->getLoginCookieExpiration( 'User', true ) );

		// First cookie is an extended cookie, remember me false
		$this->assertSame( 100, $providerPriv->getLoginCookieExpiration( 'UserID', false ) );
		$this->assertSame( 100, $providerPriv->getLoginCookieExpiration( 'User', false ) );

		$config->set( MainConfigNames::ExtendedLoginCookieExpiration, null );

		$this->assertSame( 100, $providerPriv->getLoginCookieExpiration( 'Token', true ) );
		$this->assertSame( 100, $providerPriv->getLoginCookieExpiration( 'User', true ) );

		$this->assertSame( 100, $providerPriv->getLoginCookieExpiration( 'Token', false ) );
		$this->assertSame( 100, $providerPriv->getLoginCookieExpiration( 'User', false ) );
	}
}
PK       ! Iɀߕ      session/SessionTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Session;

use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use Psr\Log\LogLevel;
use TestLogger;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Session
 * @covers \MediaWiki\Session\Session
 */
class SessionTest extends MediaWikiIntegrationTestCase {

	public function testClear() {
		$session = TestUtils::getDummySession();
		$priv = TestingAccessWrapper::newFromObject( $session );

		$backend = $this->getMockBuilder( DummySessionBackend::class )
			->addMethods( [ 'canSetUser', 'setUser', 'save' ] )
			->getMock();
		$backend->expects( $this->once() )->method( 'canSetUser' )
			->willReturn( true );
		$backend->expects( $this->once() )->method( 'setUser' )
			->with( $this->callback( static function ( $user ) {
				return $user instanceof User && $user->isAnon();
			} ) );
		$backend->expects( $this->once() )->method( 'save' );
		$priv->backend = $backend;
		$session->clear();
		$this->assertSame( [], $backend->data );
		$this->assertTrue( $backend->dirty );

		$backend = $this->getMockBuilder( DummySessionBackend::class )
			->addMethods( [ 'canSetUser', 'setUser', 'save' ] )
			->getMock();
		$backend->data = [];
		$backend->expects( $this->once() )->method( 'canSetUser' )
			->willReturn( true );
		$backend->expects( $this->once() )->method( 'setUser' )
			->with( $this->callback( static function ( $user ) {
				return $user instanceof User && $user->isAnon();
			} ) );
		$backend->expects( $this->once() )->method( 'save' );
		$priv->backend = $backend;
		$session->clear();
		$this->assertFalse( $backend->dirty );

		$backend = $this->getMockBuilder( DummySessionBackend::class )
			->addMethods( [ 'canSetUser', 'setUser', 'save' ] )
			->getMock();
		$backend->expects( $this->once() )->method( 'canSetUser' )
			->willReturn( false );
		$backend->expects( $this->never() )->method( 'setUser' );
		$backend->expects( $this->once() )->method( 'save' );
		$priv->backend = $backend;
		$session->clear();
		$this->assertSame( [], $backend->data );
		$this->assertTrue( $backend->dirty );
	}

	public function testSecrets() {
		$logger = new TestLogger;
		$session = TestUtils::getDummySession( null, -1, $logger );

		// Simple defaulting
		$this->assertEquals( 'defaulted', $session->getSecret( 'test', 'defaulted' ) );

		// Bad encrypted data
		$session->set( 'test', 'foobar' );
		$logger->setCollect( true );
		$this->assertEquals( 'defaulted', $session->getSecret( 'test', 'defaulted' ) );
		$logger->setCollect( false );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Invalid sealed-secret format' ]
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// Tampered data
		$session->setSecret( 'test', 'foobar' );
		$encrypted = $session->get( 'test' );
		$session->set( 'test', $encrypted . 'x' );
		$logger->setCollect( true );
		$this->assertEquals( 'defaulted', $session->getSecret( 'test', 'defaulted' ) );
		$logger->setCollect( false );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Sealed secret has been tampered with, aborting.' ]
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// Unserializable data
		$iv = random_bytes( 16 );
		[ $encKey, $hmacKey ] = TestingAccessWrapper::newFromObject( $session )->getSecretKeys();
		$ciphertext = openssl_encrypt( 'foobar', 'aes-256-ctr', $encKey, OPENSSL_RAW_DATA, $iv );
		$sealed = base64_encode( $iv ) . '.' . base64_encode( $ciphertext );
		$hmac = hash_hmac( 'sha256', $sealed, $hmacKey, true );
		$encrypted = base64_encode( $hmac ) . '.' . $sealed;
		$session->set( 'test', $encrypted );
		$this->assertEquals( 'defaulted', @$session->getSecret( 'test', 'defaulted' ) );
	}

	/**
	 * @dataProvider provideSecretsRoundTripping
	 */
	public function testSecretsRoundTripping( $data ) {
		$session = TestUtils::getDummySession();

		// Simple round-trip
		$session->setSecret( 'secret', $data );
		// Cast to strings because PHPUnit sometimes considers true as equal to a string,
		// depending on the other of the parameters (T317750)
		$raw = $session->get( 'secret' );
		$this->assertIsString( $raw );
		if ( is_scalar( $data ) ) {
			$this->assertNotSame( (string)$data, $raw );
		}
		$this->assertEquals( $data, $session->getSecret( 'secret', 'defaulted' ) );
	}

	public static function provideSecretsRoundTripping() {
		return [
			[ 'Foobar' ],
			[ 42 ],
			[ [ 'foo', 'bar' => 'baz', 'subarray' => [ 1, 2, 3 ] ] ],
			[ (object)[ 'foo', 'bar' => 'baz', 'subarray' => [ 1, 2, 3 ] ] ],
			[ true ],
			[ false ],
			[ null ],
		];
	}

}
PK       ! %-  -  !  session/PHPSessionHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Session;

use BadMethodCallException;
use DummySessionProvider;
use MediaWiki\MainConfigNames;
use MediaWiki\Session\PHPSessionHandler;
use MediaWiki\Session\SessionManager;
use MediaWikiIntegrationTestCase;
use Psr\Log\LogLevel;
use TestLogger;
use UnexpectedValueException;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Session
 * @covers \MediaWiki\Session\PHPSessionHandler
 */
class PHPSessionHandlerTest extends MediaWikiIntegrationTestCase {

	private function getResetter( &$staticAccess = null ) {
		$reset = [];

		$staticAccess = TestingAccessWrapper::newFromClass( PHPSessionHandler::class );
		if ( $staticAccess->instance ) {
			$old = TestingAccessWrapper::newFromObject( $staticAccess->instance );
			$oldManager = $old->manager;
			$oldStore = $old->store;
			$oldLogger = $old->logger;
			$reset[] = new ScopedCallback(
				[ PHPSessionHandler::class, 'install' ],
				[ $oldManager, $oldStore, $oldLogger ]
			);
		}

		return $reset;
	}

	public function testEnableFlags() {
		$handler = TestingAccessWrapper::newFromObject(
			$this->createPartialMock( PHPSessionHandler::class, [] )
		);

		$staticAccess = TestingAccessWrapper::newFromClass( PHPSessionHandler::class );
		$oldValue = $staticAccess->instance;
		$reset = new ScopedCallback( static function () use ( $staticAccess, $oldValue ) {
			$staticAccess->instance = $oldValue;
		} );
		$staticAccess->instance = $handler;

		$handler->setEnableFlags( 'enable' );
		$this->assertTrue( $handler->enable );
		$this->assertFalse( $handler->warn );
		$this->assertTrue( PHPSessionHandler::isEnabled() );

		$handler->setEnableFlags( 'warn' );
		$this->assertTrue( $handler->enable );
		$this->assertTrue( $handler->warn );
		$this->assertTrue( PHPSessionHandler::isEnabled() );

		$handler->setEnableFlags( 'disable' );
		$this->assertFalse( $handler->enable );
		$this->assertFalse( PHPSessionHandler::isEnabled() );

		$staticAccess->instance = null;
		$this->assertFalse( PHPSessionHandler::isEnabled() );
	}

	public function testInstall() {
		$reset = $this->getResetter( $staticAccess );
		$staticAccess->instance = null;

		session_write_close();
		ini_set( 'session.use_cookies', 1 );

		$store = new TestBagOStuff();
		// Tolerate debug message, anything else is unexpected
		$logger = new TestLogger( false, static function ( $m ) {
			return preg_match( '/^SessionManager using store/', $m ) ? null : $m;
		} );
		$manager = new SessionManager( [
			'store' => $store,
			'logger' => $logger,
		] );

		$this->assertFalse( PHPSessionHandler::isInstalled() );
		PHPSessionHandler::install( $manager );
		$this->assertTrue( PHPSessionHandler::isInstalled() );

		$this->assertFalse( wfIniGetBool( 'session.use_cookies' ) );

		$this->assertNotNull( $staticAccess->instance );
		$priv = TestingAccessWrapper::newFromObject( $staticAccess->instance );
		$this->assertSame( $manager, $priv->manager );
		$this->assertSame( $store, $priv->store );
		$this->assertSame( $logger, $priv->logger );
	}

	/**
	 * @dataProvider provideHandlers
	 * @param string $handler php serialize_handler to use
	 */
	public function testSessionHandling( $handler ) {
		// Tracked under T352913
		$this->markTestSkippedIfPhp( '>=', '8.3' );

		$this->hideDeprecated( '$_SESSION' );
		$reset = $this->getResetter( $staticAccess );

		$this->overrideConfigValues( [
			MainConfigNames::SessionProviders => [ [ 'class' => DummySessionProvider::class ] ],
			MainConfigNames::ObjectCacheSessionExpiry => 2,
		] );

		$store = new TestBagOStuff();
		$logger = new TestLogger( true, static function ( $m ) {
			return (
				// Discard all log events starting with expected prefix
				preg_match( '/^SessionBackend "\{session\}" /', $m )
				// Also discard logs from T264793
				|| preg_match( '/^(Persisting|Unpersisting) session (for|due to)/', $m )
			) ? null : $m;
		} );
		$manager = new SessionManager( [
			'store' => $store,
			'logger' => $logger,
		] );
		PHPSessionHandler::install( $manager );
		$wrap = TestingAccessWrapper::newFromObject( $staticAccess->instance );
		$reset[] = new ScopedCallback(
			[ $wrap, 'setEnableFlags' ],
			[ $wrap->enable ? ( $wrap->warn ? 'warn' : 'enable' ) : 'disable' ]
		);
		$wrap->setEnableFlags( 'warn' );

		@ini_set( 'session.serialize_handler', $handler );
		if ( ini_get( 'session.serialize_handler' ) !== $handler ) {
			$this->markTestSkipped( "Cannot set session.serialize_handler to \"$handler\"" );
		}

		// Session IDs for testing
		$sessionA = str_repeat( 'a', 32 );
		$sessionB = str_repeat( 'b', 32 );
		$sessionC = str_repeat( 'c', 32 );

		// Set up garbage data in the session
		$_SESSION['AuthenticationSessionTest'] = 'bogus';

		session_id( $sessionA );
		session_start();
		$this->assertSame( [], $_SESSION );
		$this->assertSame( $sessionA, session_id() );

		// Set some data in the session so we can see if it works.
		$rand = mt_rand();
		$_SESSION['AuthenticationSessionTest'] = $rand;
		$expect = [ 'AuthenticationSessionTest' => $rand ];
		session_write_close();
		$this->assertSame( [
			[ LogLevel::DEBUG, 'SessionManager using store MediaWiki\Tests\Session\TestBagOStuff' ],
			[ LogLevel::WARNING, 'Something wrote to $_SESSION!' ],
		], $logger->getBuffer() );

		// Screw up $_SESSION so we can tell the difference between "this
		// worked" and "this did nothing"
		$_SESSION['AuthenticationSessionTest'] = 'bogus';

		// Re-open the session and see that data was actually reloaded
		session_start();
		$this->assertSame( $expect, $_SESSION );

		// Make sure session_reset() works too.
		$_SESSION['AuthenticationSessionTest'] = 'bogus';
		session_reset();
		$this->assertSame( $expect, $_SESSION );

		// Re-fill the session, then test that session_destroy() works.
		$_SESSION['AuthenticationSessionTest'] = $rand;
		session_write_close();
		session_start();
		$this->assertSame( $expect, $_SESSION );
		session_destroy();
		session_id( $sessionA );
		session_start();
		$this->assertSame( [], $_SESSION );
		session_write_close();

		// Test that our session handler won't clone someone else's session
		session_id( $sessionB );
		session_start();
		$this->assertSame( $sessionB, session_id() );
		$_SESSION['id'] = 'B';
		session_write_close();

		session_id( $sessionC );
		session_start();
		$this->assertSame( [], $_SESSION );
		$_SESSION['id'] = 'C';
		session_write_close();

		session_id( $sessionB );
		session_start();
		$this->assertSame( [ 'id' => 'B' ], $_SESSION );
		session_write_close();

		session_id( $sessionC );
		session_start();
		$this->assertSame( [ 'id' => 'C' ], $_SESSION );
		session_destroy();

		session_id( $sessionB );
		session_start();
		$this->assertSame( [ 'id' => 'B' ], $_SESSION );

		// Test merging between Session and $_SESSION
		session_write_close();

		$session = $manager->getEmptySession();
		$session->set( 'Unchanged', 'setup' );
		$session->set( 'Unchanged, null', null );
		$session->set( 'Changed in $_SESSION', 'setup' );
		$session->set( 'Changed in Session', 'setup' );
		$session->set( 'Changed in both', 'setup' );
		$session->set( 'Deleted in Session', 'setup' );
		$session->set( 'Deleted in $_SESSION', 'setup' );
		$session->set( 'Deleted in both', 'setup' );
		$session->set( 'Deleted in Session, changed in $_SESSION', 'setup' );
		$session->set( 'Deleted in $_SESSION, changed in Session', 'setup' );
		$session->persist();
		$session->save();

		session_id( $session->getId() );
		session_start();
		$session->set( 'Added in Session', 'Session' );
		$session->set( 'Added in both', 'Session' );
		$session->set( 'Changed in Session', 'Session' );
		$session->set( 'Changed in both', 'Session' );
		$session->set( 'Deleted in $_SESSION, changed in Session', 'Session' );
		$session->remove( 'Deleted in Session' );
		$session->remove( 'Deleted in both' );
		$session->remove( 'Deleted in Session, changed in $_SESSION' );
		$session->save();
		$_SESSION['Added in $_SESSION'] = '$_SESSION';
		$_SESSION['Added in both'] = '$_SESSION';
		$_SESSION['Changed in $_SESSION'] = '$_SESSION';
		$_SESSION['Changed in both'] = '$_SESSION';
		$_SESSION['Deleted in Session, changed in $_SESSION'] = '$_SESSION';
		unset( $_SESSION['Deleted in $_SESSION'] );
		unset( $_SESSION['Deleted in both'] );
		unset( $_SESSION['Deleted in $_SESSION, changed in Session'] );
		session_write_close();

		$this->assertEquals( [
			'Added in Session' => 'Session',
			'Added in $_SESSION' => '$_SESSION',
			'Added in both' => 'Session',
			'Unchanged' => 'setup',
			'Unchanged, null' => null,
			'Changed in Session' => 'Session',
			'Changed in $_SESSION' => '$_SESSION',
			'Changed in both' => 'Session',
			'Deleted in Session, changed in $_SESSION' => '$_SESSION',
			'Deleted in $_SESSION, changed in Session' => 'Session',
		], iterator_to_array( $session ) );

		$session->clear();
		$session->set( 42, 'forty-two' );
		$session->set( 'forty-two', 42 );
		$session->set( 'wrong', 43 );
		$session->persist();
		$session->save();

		session_start();
		$this->assertArrayHasKey( 'forty-two', $_SESSION );
		$this->assertSame( 42, $_SESSION['forty-two'] );
		$this->assertArrayHasKey( 'wrong', $_SESSION );
		unset( $_SESSION['wrong'] );
		session_write_close();

		$this->assertEquals( [
			42 => 'forty-two',
			'forty-two' => 42,
		], iterator_to_array( $session ) );

		// Test that write doesn't break if the session is invalid
		$session = $manager->getEmptySession();
		$session->persist();
		$id = $session->getId();
		unset( $session );
		session_id( $id );
		session_start();
		$this->setTemporaryHook(
			'SessionCheckInfo',
			static function ( &$reason ) {
				$reason = 'Testing';
				return false;
			}
		);
		$this->assertNull( $manager->getSessionById( $id, true ) );
		session_write_close();

		$this->clearHook( 'SessionCheckInfo' );
		$this->assertNotNull( $manager->getSessionById( $id, true ) );
	}

	public static function provideHandlers() {
		return [
			[ 'php' ],
			[ 'php_binary' ],
			[ 'php_serialize' ],
		];
	}

	/**
	 * @dataProvider provideDisabled
	 */
	public function testDisabled( $method, $args ) {
		$staticAccess = TestingAccessWrapper::newFromClass( PHPSessionHandler::class );
		$handler = $this->createPartialMock( PHPSessionHandler::class, [] );
		TestingAccessWrapper::newFromObject( $handler )->setEnableFlags( 'disable' );
		$oldValue = $staticAccess->instance;
		$staticAccess->instance = $handler;
		$reset = new ScopedCallback( static function () use ( $staticAccess, $oldValue ) {
			$staticAccess->instance = $oldValue;
		} );

		$this->expectException( BadMethodCallException::class );
		$this->expectExceptionMessage( "Attempt to use PHP session management" );
		$handler->$method( ...$args );
	}

	public static function provideDisabled() {
		return [
			[ 'open', [ '', '' ] ],
			[ 'read', [ '' ] ],
			[ 'write', [ '', '' ] ],
			[ 'destroy', [ '' ] ],
		];
	}

	/**
	 * @dataProvider provideWrongInstance
	 */
	public function testWrongInstance( $method, $args ) {
		$handler = $this->createPartialMock( PHPSessionHandler::class, [] );
		TestingAccessWrapper::newFromObject( $handler )->setEnableFlags( 'enable' );

		$this->expectException( UnexpectedValueException::class );
		$this->expectExceptionMessageMatches( "/: Wrong instance called!$/" );
		$handler->$method( ...$args );
	}

	public static function provideWrongInstance() {
		return [
			[ 'open', [ '', '' ] ],
			[ 'close', [] ],
			[ 'read', [ '' ] ],
			[ 'write', [ '', '' ] ],
			[ 'destroy', [ '' ] ],
			[ 'gc', [ 0 ] ],
		];
	}

}
PK       ! VHx:  :    session/TestUtils.phpnu Iw        <?php

namespace MediaWiki\Tests\Session;

use MediaWiki\Session\PHPSessionHandler;
use MediaWiki\Session\Session;
use MediaWiki\Session\SessionBackend;
use MediaWiki\Session\SessionManager;
use PHPUnit\Framework\Assert;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use TestLogger;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;

/**
 * Utility functions for Session unit tests
 */
class TestUtils {

	/**
	 * Override the singleton for unit testing
	 * @param SessionManager|null $manager
	 * @return ScopedCallback|null
	 */
	public static function setSessionManagerSingleton( ?SessionManager $manager = null ) {
		session_write_close();

		$staticAccess = TestingAccessWrapper::newFromClass( SessionManager::class );

		$oldInstance = $staticAccess->instance;

		$reset = [
			[ 'instance', $oldInstance ],
			[ 'globalSession', $staticAccess->globalSession ],
			[ 'globalSessionRequest', $staticAccess->globalSessionRequest ],
		];

		$staticAccess->instance = $manager;
		$staticAccess->globalSession = null;
		$staticAccess->globalSessionRequest = null;
		if ( $manager && PHPSessionHandler::isInstalled() ) {
			PHPSessionHandler::install( $manager );
		}

		return new ScopedCallback( static function () use ( $reset, $staticAccess, $oldInstance ) {
			foreach ( $reset as [ $property, $oldValue ] ) {
				$staticAccess->$property = $oldValue;
			}
			if ( $oldInstance && PHPSessionHandler::isInstalled() ) {
				PHPSessionHandler::install( $oldInstance );
			}
		} );
	}

	/**
	 * If you need a SessionBackend for testing but don't want to create a real
	 * one, use this.
	 * @return SessionBackend Unconfigured! Use reflection to set any private
	 *  fields necessary.
	 */
	public static function getDummySessionBackend() {
		$rc = new ReflectionClass( SessionBackend::class );
		if ( !method_exists( $rc, 'newInstanceWithoutConstructor' ) ) {
			Assert::markTestSkipped(
				'ReflectionClass::newInstanceWithoutConstructor isn\'t available'
			);
		}

		$ret = $rc->newInstanceWithoutConstructor();
		TestingAccessWrapper::newFromObject( $ret )->logger = new TestLogger;
		return $ret;
	}

	/**
	 * If you need a Session for testing but don't want to create a backend to
	 * construct one, use this.
	 * @phpcs:ignore MediaWiki.Commenting.FunctionComment.ObjectTypeHintParam
	 * @param object|null $backend Object to serve as the SessionBackend
	 * @param int $index
	 * @param LoggerInterface|null $logger
	 * @return Session
	 */
	public static function getDummySession( $backend = null, $index = -1, $logger = null ) {
		$rc = new ReflectionClass( Session::class );

		$session = $rc->newInstanceWithoutConstructor();
		$priv = TestingAccessWrapper::newFromObject( $session );
		$priv->backend = $backend ?? new DummySessionBackend();
		$priv->index = $index;
		$priv->logger = $logger ?? new TestLogger();
		return $session;
	}

}
PK       ! .U      session/SessionManagerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Session;

use DummySessionProvider;
use InvalidArgumentException;
use MediaWiki\Config\HashConfig;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\ProxyLookup;
use MediaWiki\Session\CookieSessionProvider;
use MediaWiki\Session\MetadataMergeException;
use MediaWiki\Session\PHPSessionHandler;
use MediaWiki\Session\Session;
use MediaWiki\Session\SessionInfo;
use MediaWiki\Session\SessionManager;
use MediaWiki\Session\SessionOverflowException;
use MediaWiki\Session\SessionProvider;
use MediaWiki\Session\UserInfo;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiIntegrationTestCase;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Psr\Log\NullLogger;
use ReflectionClass;
use stdClass;
use TestLogger;
use UnexpectedValueException;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Session
 * @group Database
 * @covers \MediaWiki\Session\SessionManager
 */
class SessionManagerTest extends MediaWikiIntegrationTestCase {
	use SessionProviderTestTrait;

	/** @var HashConfig */
	private $config;

	/** @var TestLogger */
	private $logger;

	/** @var TestBagOStuff */
	private $store;

	protected function getManager() {
		$this->store = new TestBagOStuff();
		$cacheType = $this->setMainCache( $this->store );

		$this->config = new HashConfig( [
			MainConfigNames::LanguageCode => 'en',
			MainConfigNames::SessionCacheType => $cacheType,
			MainConfigNames::ObjectCacheSessionExpiry => 100,
			MainConfigNames::SessionProviders => [
				[ 'class' => DummySessionProvider::class ],
			]
		] );
		$this->logger = new TestLogger( false, static function ( $m ) {
			return ( str_starts_with( $m, 'SessionBackend ' )
				|| str_starts_with( $m, 'SessionManager using store ' )
				// These were added for T264793 and behave somewhat erratically, not worth testing
				|| str_starts_with( $m, 'Failed to load session, unpersisting' )
				|| preg_match( '/^(Persisting|Unpersisting) session (for|due to)/', $m )
			) ? null : $m;
		} );

		return new SessionManager( [
			'config' => $this->config,
			'logger' => $this->logger,
			'store' => $this->store,
		] );
	}

	protected function objectCacheDef( $object ) {
		return [ 'factory' => static function () use ( $object ) {
			return $object;
		} ];
	}

	public function testSingleton() {
		$reset = TestUtils::setSessionManagerSingleton( null );

		$singleton = SessionManager::singleton();
		$this->assertInstanceOf( SessionManager::class, $singleton );
		$this->assertSame( $singleton, SessionManager::singleton() );
	}

	public function testGetGlobalSession() {
		$context = RequestContext::getMain();

		if ( !PHPSessionHandler::isInstalled() ) {
			PHPSessionHandler::install( SessionManager::singleton() );
		}
		$staticAccess = TestingAccessWrapper::newFromClass( PHPSessionHandler::class );
		$handler = TestingAccessWrapper::newFromObject( $staticAccess->instance );
		$oldEnable = $handler->enable;
		$reset[] = new ScopedCallback( static function () use ( $handler, $oldEnable ) {
			if ( $handler->enable ) {
				session_write_close();
			}
			$handler->enable = $oldEnable;
		} );
		$reset[] = TestUtils::setSessionManagerSingleton( $this->getManager() );

		$handler->enable = true;
		$request = new FauxRequest();
		$context->setRequest( $request );
		$id = $request->getSession()->getId();

		session_write_close();
		session_id( '' );
		$session = SessionManager::getGlobalSession();
		$this->assertSame( $id, $session->getId() );

		session_id( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' );
		$session = SessionManager::getGlobalSession();
		$this->assertSame( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $session->getId() );
		$this->assertSame( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $request->getSession()->getId() );

		session_write_close();
		$handler->enable = false;
		$request = new FauxRequest();
		$context->setRequest( $request );
		$id = $request->getSession()->getId();

		session_id( '' );
		$session = SessionManager::getGlobalSession();
		$this->assertSame( $id, $session->getId() );

		session_id( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' );
		$session = SessionManager::getGlobalSession();
		$this->assertSame( $id, $session->getId() );
		$this->assertSame( $id, $request->getSession()->getId() );
	}

	public function testConstructor() {
		$manager = TestingAccessWrapper::newFromObject( $this->getManager() );
		$this->assertSame( $this->config, $manager->config );
		$this->assertSame( $this->logger, $manager->logger );
		$this->assertSame( $this->store, $manager->store );

		$manager = TestingAccessWrapper::newFromObject( new SessionManager() );
		$this->assertSame( $this->getServiceContainer()->getMainConfig(), $manager->config );

		$manager = TestingAccessWrapper::newFromObject( new SessionManager( [
			'config' => $this->config,
			'store' => $this->store,
		] ) );
		$this->assertSame( $this->store, $manager->store );

		foreach ( [
			'config' => '$options[\'config\'] must be an instance of Config',
			'logger' => '$options[\'logger\'] must be an instance of LoggerInterface',
			'store' => '$options[\'store\'] must be an instance of BagOStuff',
		] as $key => $error ) {
			try {
				new SessionManager( [ $key => new stdClass ] );
				$this->fail( 'Expected exception not thrown' );
			} catch ( InvalidArgumentException $ex ) {
				$this->assertSame( $error, $ex->getMessage() );
			}
		}
	}

	public function testGetSessionForRequest() {
		$manager = $this->getManager();
		$request = new FauxRequest();
		$requestUnpersist1 = false;
		$requestUnpersist2 = false;
		$requestInfo1 = null;
		$requestInfo2 = null;

		$id1 = '';
		$id2 = '';
		$idEmpty = 'empty-session-------------------';

		$providerBuilder = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods(
				[ 'provideSessionInfo', 'newSessionInfo', '__toString', 'describe', 'unpersistSession' ]
			);

		$provider1 = $providerBuilder->getMock();
		$provider1->method( 'provideSessionInfo' )
			->with( $this->identicalTo( $request ) )
			->willReturnCallback( static function ( $request ) use ( &$requestInfo1 ) {
				return $requestInfo1;
			} );
		$provider1->method( 'newSessionInfo' )
			->willReturnCallback( static function () use ( $idEmpty, $provider1 ) {
				return new SessionInfo( SessionInfo::MIN_PRIORITY, [
					'provider' => $provider1,
					'id' => $idEmpty,
					'persisted' => true,
					'idIsSafe' => true,
				] );
			} );
		$provider1->method( '__toString' )
			->willReturn( 'Provider1' );
		$provider1->method( 'describe' )
			->willReturn( '#1 sessions' );
		$provider1->method( 'unpersistSession' )
			->willReturnCallback( static function ( $request ) use ( &$requestUnpersist1 ) {
				$requestUnpersist1 = true;
			} );

		$provider2 = $providerBuilder->getMock();
		$provider2->method( 'provideSessionInfo' )
			->with( $this->identicalTo( $request ) )
			->willReturnCallback( static function ( $request ) use ( &$requestInfo2 ) {
				return $requestInfo2;
			} );
		$provider2->method( '__toString' )
			->willReturn( 'Provider2' );
		$provider2->method( 'describe' )
			->willReturn( '#2 sessions' );
		$provider2->method( 'unpersistSession' )
			->willReturnCallback( static function ( $request ) use ( &$requestUnpersist2 ) {
				$requestUnpersist2 = true;
			} );

		$this->config->set( MainConfigNames::SessionProviders, [
			$this->objectCacheDef( $provider1 ),
			$this->objectCacheDef( $provider2 ),
		] );

		// No provider returns info
		$session = $manager->getSessionForRequest( $request );
		$this->assertInstanceOf( Session::class, $session );
		$this->assertSame( $idEmpty, $session->getId() );
		$this->assertFalse( $requestUnpersist1 );
		$this->assertFalse( $requestUnpersist2 );

		// Both providers return info, picks best one
		$requestInfo1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [
			'provider' => $provider1,
			'id' => ( $id1 = $manager->generateSessionId() ),
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$requestInfo2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, [
			'provider' => $provider2,
			'id' => ( $id2 = $manager->generateSessionId() ),
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$session = $manager->getSessionForRequest( $request );
		$this->assertInstanceOf( Session::class, $session );
		$this->assertSame( $id2, $session->getId() );
		$this->assertFalse( $requestUnpersist1 );
		$this->assertFalse( $requestUnpersist2 );

		$requestInfo1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, [
			'provider' => $provider1,
			'id' => ( $id1 = $manager->generateSessionId() ),
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$requestInfo2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [
			'provider' => $provider2,
			'id' => ( $id2 = $manager->generateSessionId() ),
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$session = $manager->getSessionForRequest( $request );
		$this->assertInstanceOf( Session::class, $session );
		$this->assertSame( $id1, $session->getId() );
		$this->assertFalse( $requestUnpersist1 );
		$this->assertFalse( $requestUnpersist2 );

		// Tied priorities
		$requestInfo1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
			'provider' => $provider1,
			'id' => ( $id1 = $manager->generateSessionId() ),
			'persisted' => true,
			'userInfo' => UserInfo::newAnonymous(),
			'idIsSafe' => true,
		] );
		$requestInfo2 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
			'provider' => $provider2,
			'id' => ( $id2 = $manager->generateSessionId() ),
			'persisted' => true,
			'userInfo' => UserInfo::newAnonymous(),
			'idIsSafe' => true,
		] );
		try {
			$manager->getSessionForRequest( $request );
			$this->fail( 'Expcected exception not thrown' );
		} catch ( SessionOverflowException $ex ) {
			$this->assertStringStartsWith(
				'Multiple sessions for this request tied for top priority: ',
				$ex->getMessage()
			);
			$this->assertCount( 2, $ex->getSessionInfos() );
			$this->assertContains( $requestInfo1, $ex->getSessionInfos() );
			$this->assertContains( $requestInfo2, $ex->getSessionInfos() );
		}
		$this->assertFalse( $requestUnpersist1 );
		$this->assertFalse( $requestUnpersist2 );

		// Bad provider
		$requestInfo1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
			'provider' => $provider2,
			'id' => ( $id1 = $manager->generateSessionId() ),
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$requestInfo2 = null;
		try {
			$manager->getSessionForRequest( $request );
			$this->fail( 'Expcected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame(
				'Provider1 returned session info for a different provider: ' . $requestInfo1,
				$ex->getMessage()
			);
		}
		$this->assertFalse( $requestUnpersist1 );
		$this->assertFalse( $requestUnpersist2 );

		// Unusable session info
		$this->logger->setCollect( true );
		$requestInfo1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
			'provider' => $provider1,
			'id' => ( $id1 = $manager->generateSessionId() ),
			'persisted' => true,
			'userInfo' => UserInfo::newFromName( 'TestGetSessionForRequest', false ),
			'idIsSafe' => true,
		] );
		$requestInfo2 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider2,
			'id' => ( $id2 = $manager->generateSessionId() ),
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$session = $manager->getSessionForRequest( $request );
		$this->assertInstanceOf( Session::class, $session );
		$this->assertSame( $id2, $session->getId() );
		$this->logger->setCollect( false );
		$this->assertTrue( $requestUnpersist1 );
		$this->assertFalse( $requestUnpersist2 );
		$requestUnpersist1 = false;

		$this->logger->setCollect( true );
		$requestInfo1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
			'provider' => $provider1,
			'id' => ( $id1 = $manager->generateSessionId() ),
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$requestInfo2 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
			'provider' => $provider2,
			'id' => ( $id2 = $manager->generateSessionId() ),
			'persisted' => true,
			'userInfo' => UserInfo::newFromName( 'TestGetSessionForRequest', false ),
			'idIsSafe' => true,
		] );
		$session = $manager->getSessionForRequest( $request );
		$this->assertInstanceOf( Session::class, $session );
		$this->assertSame( $id1, $session->getId() );
		$this->logger->setCollect( false );
		$this->assertFalse( $requestUnpersist1 );
		$this->assertTrue( $requestUnpersist2 );
		$requestUnpersist2 = false;

		// Unpersisted session ID
		$requestInfo1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
			'provider' => $provider1,
			'id' => ( $id1 = $manager->generateSessionId() ),
			'persisted' => false,
			'userInfo' => UserInfo::newFromName( 'TestGetSessionForRequest', true ),
			'idIsSafe' => true,
		] );
		$requestInfo2 = null;
		$session = $manager->getSessionForRequest( $request );
		$this->assertInstanceOf( Session::class, $session );
		$this->assertSame( $id1, $session->getId() );
		$this->assertTrue( $requestUnpersist1 ); // The saving of the session does it
		$this->assertFalse( $requestUnpersist2 );
		$session->persist();
		$this->assertTrue( $session->isPersistent() );
	}

	public function testGetSessionById() {
		$manager = $this->getManager();
		try {
			$manager->getSessionById( 'bad' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Invalid session ID', $ex->getMessage() );
		}

		// Unknown session ID
		$id = $manager->generateSessionId();
		$session = $manager->getSessionById( $id, true );
		$this->assertInstanceOf( Session::class, $session );
		$this->assertSame( $id, $session->getId() );

		$id = $manager->generateSessionId();
		$this->assertNull( $manager->getSessionById( $id, false ) );

		$userIdentity = $this->getTestSysop()->getUserIdentity();
		// Known but unloadable session ID
		$this->logger->setCollect( true );
		$id = $manager->generateSessionId();
		$this->store->setSession( $id, [ 'metadata' => [
			'userId' => $userIdentity->getId(),
			'userToken' => 'bad',
		] ] );

		$this->assertNull( $manager->getSessionById( $id, true ) );
		$this->assertNull( $manager->getSessionById( $id, false ) );
		$this->logger->setCollect( false );

		// Known session ID
		$this->store->setSession( $id, [] );
		$session = $manager->getSessionById( $id, false );
		$this->assertInstanceOf( Session::class, $session );
		$this->assertSame( $id, $session->getId() );

		// Store isn't checked if the session is already loaded
		$this->store->setSession( $id, [ 'metadata' => [
			'userId' => $userIdentity->getId(),
			'userToken' => 'bad',
		] ] );
		$session2 = $manager->getSessionById( $id, false );
		$this->assertInstanceOf( Session::class, $session2 );
		$this->assertSame( $id, $session2->getId() );
		unset( $session, $session2 );
		$this->logger->setCollect( true );
		$this->assertNull( $manager->getSessionById( $id, true ) );
		$this->logger->setCollect( false );

		// Failure to create an empty session
		$manager = $this->getManager();
		$provider = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'provideSessionInfo', 'newSessionInfo', '__toString' ] )
			->getMock();
		$provider->method( 'provideSessionInfo' )
			->willReturn( null );
		$provider->method( 'newSessionInfo' )
			->willReturn( null );
		$provider->method( '__toString' )
			->willReturn( 'MockProvider' );
		$this->config->set( MainConfigNames::SessionProviders, [
			$this->objectCacheDef( $provider ),
		] );
		$this->logger->setCollect( true );
		$this->assertNull( $manager->getSessionById( $id, true ) );
		$this->logger->setCollect( false );
		$this->assertSame( [
			[ LogLevel::ERROR, 'Failed to create empty session: {exception}' ]
		], $this->logger->getBuffer() );
	}

	public function testGetEmptySession() {
		$manager = $this->getManager();
		$pmanager = TestingAccessWrapper::newFromObject( $manager );

		$providerBuilder = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'provideSessionInfo', 'newSessionInfo', '__toString' ] );

		$expectId = null;
		$info1 = null;
		$info2 = null;

		$provider1 = $providerBuilder->getMock();
		$provider1->method( 'provideSessionInfo' )
			->willReturn( null );
		$provider1->method( 'newSessionInfo' )
			->with( $this->callback( static function ( $id ) use ( &$expectId ) {
				return $id === $expectId;
			} ) )
			->willReturnCallback( static function () use ( &$info1 ) {
				return $info1;
			} );
		$provider1->method( '__toString' )
			->willReturn( 'MockProvider1' );

		$provider2 = $providerBuilder->getMock();
		$provider2->method( 'provideSessionInfo' )
			->willReturn( null );
		$provider2->method( 'newSessionInfo' )
			->with( $this->callback( static function ( $id ) use ( &$expectId ) {
				return $id === $expectId;
			} ) )
			->willReturnCallback( static function () use ( &$info2 ) {
				return $info2;
			} );
		$provider1->method( '__toString' )
			->willReturn( 'MockProvider2' );

		$this->config->set( MainConfigNames::SessionProviders, [
			$this->objectCacheDef( $provider1 ),
			$this->objectCacheDef( $provider2 ),
		] );

		// No info
		$expectId = null;
		$info1 = null;
		$info2 = null;
		try {
			$manager->getEmptySession();
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame(
				'No provider could provide an empty session!',
				$ex->getMessage()
			);
		}

		// Info
		$expectId = null;
		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider1,
			'id' => 'empty---------------------------',
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$info2 = null;
		$session = $manager->getEmptySession();
		$this->assertInstanceOf( Session::class, $session );
		$this->assertSame( 'empty---------------------------', $session->getId() );

		// Info, explicitly
		$expectId = 'expected------------------------';
		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider1,
			'id' => $expectId,
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$info2 = null;
		$session = $pmanager->getEmptySessionInternal( null, $expectId );
		$this->assertInstanceOf( Session::class, $session );
		$this->assertSame( $expectId, $session->getId() );

		// Wrong ID
		$expectId = 'expected-----------------------2';
		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider1,
			'id' => "un$expectId",
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$info2 = null;
		try {
			$pmanager->getEmptySessionInternal( null, $expectId );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame(
				'MockProvider1 returned empty session info with a wrong id: ' .
					"un$expectId != $expectId",
				$ex->getMessage()
			);
		}

		// Unsafe ID
		$expectId = 'expected-----------------------2';
		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider1,
			'id' => $expectId,
			'persisted' => true,
		] );
		$info2 = null;
		try {
			$pmanager->getEmptySessionInternal( null, $expectId );
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame(
				'MockProvider1 returned empty session info with id flagged unsafe',
				$ex->getMessage()
			);
		}

		// Wrong provider
		$expectId = null;
		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider2,
			'id' => 'empty---------------------------',
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$info2 = null;
		try {
			$manager->getEmptySession();
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame(
				'MockProvider1 returned an empty session info for a different provider: ' . $info1,
				$ex->getMessage()
			);
		}

		// Highest priority wins
		$expectId = null;
		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [
			'provider' => $provider1,
			'id' => 'empty1--------------------------',
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider2,
			'id' => 'empty2--------------------------',
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$session = $manager->getEmptySession();
		$this->assertInstanceOf( Session::class, $session );
		$this->assertSame( 'empty1--------------------------', $session->getId() );

		$expectId = null;
		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [
			'provider' => $provider1,
			'id' => 'empty1--------------------------',
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, [
			'provider' => $provider2,
			'id' => 'empty2--------------------------',
			'persisted' => true,
			'idIsSafe' => true,
		] );
		$session = $manager->getEmptySession();
		$this->assertInstanceOf( Session::class, $session );
		$this->assertSame( 'empty2--------------------------', $session->getId() );

		// Tied priorities throw an exception
		$expectId = null;
		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider1,
			'id' => 'empty1--------------------------',
			'persisted' => true,
			'userInfo' => UserInfo::newAnonymous(),
			'idIsSafe' => true,
		] );
		$info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider2,
			'id' => 'empty2--------------------------',
			'persisted' => true,
			'userInfo' => UserInfo::newAnonymous(),
			'idIsSafe' => true,
		] );
		try {
			$manager->getEmptySession();
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertStringStartsWith(
				'Multiple empty sessions tied for top priority: ',
				$ex->getMessage()
			);
		}

		// Bad id
		try {
			$pmanager->getEmptySessionInternal( null, 'bad' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Invalid session ID', $ex->getMessage() );
		}

		// Session already exists
		$expectId = 'expected-----------------------3';
		$this->store->setSessionMeta( $expectId, [
			'provider' => 'MockProvider2',
			'userId' => 0,
			'userName' => null,
			'userToken' => null,
		] );
		try {
			$pmanager->getEmptySessionInternal( null, $expectId );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Session ID already exists', $ex->getMessage() );
		}
	}

	public function testInvalidateSessionsForUser() {
		$user = $this->getTestSysop()->getUser();
		$manager = $this->getManager();

		$providerBuilder = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'invalidateSessionsForUser', '__toString' ] );

		$provider1 = $providerBuilder->getMock();
		$provider1->expects( $this->once() )->method( 'invalidateSessionsForUser' )
			->with( $this->identicalTo( $user ) );
		$provider1->method( '__toString' )
			->willReturn( 'MockProvider1' );

		$provider2 = $providerBuilder->getMock();
		$provider2->expects( $this->once() )->method( 'invalidateSessionsForUser' )
			->with( $this->identicalTo( $user ) );
		$provider2->method( '__toString' )
			->willReturn( 'MockProvider2' );

		$this->config->set( MainConfigNames::SessionProviders, [
			$this->objectCacheDef( $provider1 ),
			$this->objectCacheDef( $provider2 ),
		] );

		$oldToken = $user->getToken( true );
		$manager->invalidateSessionsForUser( $user );
		$this->assertNotEquals( $oldToken, $user->getToken() );
	}

	public function testGetVaryHeaders() {
		$manager = $this->getManager();

		$providerBuilder = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'getVaryHeaders', '__toString' ] );

		$provider1 = $providerBuilder->getMock();
		$provider1->expects( $this->once() )->method( 'getVaryHeaders' )
			->willReturn( [
				'Foo' => null,
				'Bar' => [ 'X', 'Bar1' ],
				'Quux' => null,
			] );
		$provider1->method( '__toString' )
			->willReturn( 'MockProvider1' );

		$provider2 = $providerBuilder->getMock();
		$provider2->expects( $this->once() )->method( 'getVaryHeaders' )
			->willReturn( [
				'Baz' => null,
				'Bar' => [ 'X', 'Bar2' ],
				'Quux' => [ 'Quux' ],
			] );
		$provider2->method( '__toString' )
			->willReturn( 'MockProvider2' );

		$this->config->set( MainConfigNames::SessionProviders, [
			$this->objectCacheDef( $provider1 ),
			$this->objectCacheDef( $provider2 ),
		] );

		$expect = [
			'Foo' => null,
			'Bar' => null,
			'Quux' => null,
			'Baz' => null,
		];

		$this->assertEquals( $expect, $manager->getVaryHeaders() );

		// Again, to ensure it's cached
		$this->assertEquals( $expect, $manager->getVaryHeaders() );
	}

	public function testGetVaryCookies() {
		$manager = $this->getManager();

		$providerBuilder = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'getVaryCookies', '__toString' ] );

		$provider1 = $providerBuilder->getMock();
		$provider1->expects( $this->once() )->method( 'getVaryCookies' )
			->willReturn( [ 'Foo', 'Bar' ] );
		$provider1->method( '__toString' )
			->willReturn( 'MockProvider1' );

		$provider2 = $providerBuilder->getMock();
		$provider2->expects( $this->once() )->method( 'getVaryCookies' )
			->willReturn( [ 'Foo', 'Baz' ] );
		$provider2->method( '__toString' )
			->willReturn( 'MockProvider2' );

		$this->config->set( MainConfigNames::SessionProviders, [
			$this->objectCacheDef( $provider1 ),
			$this->objectCacheDef( $provider2 ),
		] );

		$expect = [ 'Foo', 'Bar', 'Baz' ];

		$this->assertEquals( $expect, $manager->getVaryCookies() );

		// Again, to ensure it's cached
		$this->assertEquals( $expect, $manager->getVaryCookies() );
	}

	public function testGetProviders() {
		$realManager = $this->getManager();
		$manager = TestingAccessWrapper::newFromObject( $realManager );

		$this->config->set( MainConfigNames::SessionProviders, [
			[ 'class' => DummySessionProvider::class ],
		] );
		$providers = $manager->getProviders();
		$this->assertArrayHasKey( 'DummySessionProvider', $providers );
		$provider = TestingAccessWrapper::newFromObject( $providers['DummySessionProvider'] );
		$this->assertSame( $manager->logger, $provider->logger );
		$this->assertSame( $manager->config, $provider->getConfig() );
		$this->assertSame( $realManager, $provider->getManager() );

		$this->config->set( MainConfigNames::SessionProviders, [
			[ 'class' => DummySessionProvider::class ],
			[ 'class' => DummySessionProvider::class ],
		] );
		$manager->sessionProviders = null;
		try {
			$manager->getProviders();
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame(
				'Duplicate provider name "DummySessionProvider"',
				$ex->getMessage()
			);
		}
	}

	public function testShutdown() {
		$manager = TestingAccessWrapper::newFromObject( $this->getManager() );
		$manager->setLogger( new NullLogger() );

		$mock = $this->getMockBuilder( stdClass::class )
			->addMethods( [ 'shutdown' ] )->getMock();
		$mock->expects( $this->once() )->method( 'shutdown' );

		$manager->allSessionBackends = [ $mock ];
		$manager->shutdown();
	}

	public function testGetSessionFromInfo() {
		$manager = TestingAccessWrapper::newFromObject( $this->getManager() );
		$request = new FauxRequest();

		$id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $manager->getProvider( 'DummySessionProvider' ),
			'id' => $id,
			'persisted' => true,
			'userInfo' => UserInfo::newFromName( 'TestGetSessionFromInfo', true ),
			'idIsSafe' => true,
		] );
		TestingAccessWrapper::newFromObject( $info )->idIsSafe = true;
		$session1 = TestingAccessWrapper::newFromObject(
			$manager->getSessionFromInfo( $info, $request )
		);
		$session2 = TestingAccessWrapper::newFromObject(
			$manager->getSessionFromInfo( $info, $request )
		);

		$this->assertSame( $session1->backend, $session2->backend );
		$this->assertNotEquals( $session1->index, $session2->index );
		$this->assertSame( $session1->getSessionId(), $session2->getSessionId() );
		$this->assertSame( $id, $session1->getId() );

		TestingAccessWrapper::newFromObject( $info )->idIsSafe = false;
		$session3 = $manager->getSessionFromInfo( $info, $request );
		$this->assertNotSame( $id, $session3->getId() );
	}

	public function testBackendRegistration() {
		$manager = $this->getManager();

		$session = $manager->getSessionForRequest( new FauxRequest );
		$backend = TestingAccessWrapper::newFromObject( $session )->backend;
		$sessionId = $session->getSessionId();
		$id = (string)$sessionId;

		$this->assertSame( $sessionId, $manager->getSessionById( $id, true )->getSessionId() );

		$manager->changeBackendId( $backend );
		$this->assertSame( $sessionId, $session->getSessionId() );
		$this->assertNotEquals( $id, (string)$sessionId );
		$id = (string)$sessionId;

		$this->assertSame( $sessionId, $manager->getSessionById( $id, true )->getSessionId() );

		// Destruction of the session here causes the backend to be deregistered
		$session = null;

		try {
			$manager->changeBackendId( $backend );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Backend was not registered with this SessionManager', $ex->getMessage()
			);
		}

		try {
			$manager->deregisterSessionBackend( $backend );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Backend was not registered with this SessionManager', $ex->getMessage()
			);
		}

		$session = $manager->getSessionById( $id, true );
		$this->assertSame( $sessionId, $session->getSessionId() );
	}

	public function testGenerateSessionId() {
		$manager = $this->getManager();

		$id = $manager->generateSessionId();
		$this->assertTrue( SessionManager::validateSessionId( $id ), "Generated ID: $id" );
	}

	public function testPreventSessionsForUser() {
		$manager = $this->getManager();

		$providerBuilder = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'preventSessionsForUser', '__toString' ] );

		$username = 'TestPreventSessionsForUser';
		$provider1 = $providerBuilder->getMock();
		$provider1->expects( $this->once() )->method( 'preventSessionsForUser' )
			->with( $username );
		$provider1->method( '__toString' )
			->willReturn( 'MockProvider1' );

		$this->config->set( MainConfigNames::SessionProviders, [
			$this->objectCacheDef( $provider1 ),
		] );

		$this->assertFalse( $manager->isUserSessionPrevented( $username ) );
		$manager->preventSessionsForUser( $username );
		$this->assertTrue( $manager->isUserSessionPrevented( $username ) );
	}

	public function testLoadSessionInfoFromStore() {
		$manager = $this->getManager();
		$logger = new TestLogger( true );
		$manager->setLogger( $logger );
		$request = new FauxRequest();

		// TestingAccessWrapper can't handle methods with reference arguments, sigh.
		$rClass = new ReflectionClass( $manager );
		$rMethod = $rClass->getMethod( 'loadSessionInfoFromStore' );
		$rMethod->setAccessible( true );
		$loadSessionInfoFromStore = static function ( &$info ) use ( $rMethod, $manager, $request ) {
			return $rMethod->invokeArgs( $manager, [ &$info, $request ] );
		};

		$username = $this->getTestSysop()->getUserIdentity()->getName();
		$userInfo = UserInfo::newFromName( $username, true );
		$unverifiedUserInfo = UserInfo::newFromName( $username, false );

		$id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
		$metadata = [
			'userId' => $userInfo->getId(),
			'userName' => $userInfo->getName(),
			'userToken' => $userInfo->getToken( true ),
			'provider' => 'Mock',
		];

		$builder = $this->getMockBuilder( SessionProvider::class )
			->onlyMethods( [ '__toString', 'mergeMetadata', 'refreshSessionInfo' ] );

		$provider = $builder->getMockForAbstractClass();
		$this->initProvider( $provider, null, null, $manager );
		$provider->method( 'persistsSessionId' )
			->willReturn( true );
		$provider->method( 'canChangeUser' )
			->willReturn( true );
		$provider->method( 'refreshSessionInfo' )
			->willReturn( true );
		$provider->method( '__toString' )
			->willReturn( 'Mock' );
		$provider->method( 'mergeMetadata' )
			->willReturnCallback( static function ( $a, $b ) {
				if ( $b === [ 'Throw' ] ) {
					throw new MetadataMergeException( 'no merge!' );
				}
				return [ 'Merged' ];
			} );

		$provider2 = $builder->getMockForAbstractClass();
		$this->initProvider( $provider2, null, null, $manager );
		$provider2->method( 'persistsSessionId' )
			->willReturn( false );
		$provider2->method( 'canChangeUser' )
			->willReturn( false );
		$provider2->method( '__toString' )
			->willReturn( 'Mock2' );
		$provider2->method( 'refreshSessionInfo' )
			->willReturnCallback( static function ( $info, $request, &$metadata ) {
				$metadata['changed'] = true;
				return true;
			} );

		$provider3 = $builder->getMockForAbstractClass();
		$this->initProvider( $provider3, null, null, $manager );
		$provider3->method( 'persistsSessionId' )
			->willReturn( true );
		$provider3->method( 'canChangeUser' )
			->willReturn( true );
		$provider3->expects( $this->once() )->method( 'refreshSessionInfo' )
			->willReturn( false );
		$provider3->method( '__toString' )
			->willReturn( 'Mock3' );

		TestingAccessWrapper::newFromObject( $manager )->sessionProviders = [
			(string)$provider => $provider,
			(string)$provider2 => $provider2,
			(string)$provider3 => $provider3,
		];

		// No metadata, basic usage
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertSame( [], $logger->getBuffer() );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'userInfo' => $userInfo
		] );
		$this->assertTrue( $info->isIdSafe() );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertTrue( $info->isIdSafe() );
		$this->assertSame( [], $logger->getBuffer() );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider2,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertTrue( $info->isIdSafe() );
		$this->assertSame( [], $logger->getBuffer() );

		// Unverified user, no metadata
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $unverifiedUserInfo
		] );
		$this->assertSame( $unverifiedUserInfo, $info->getUserInfo() );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[
				LogLevel::INFO,
				'Session "{session}": Unverified user provided and no metadata to auth it',
			]
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// No metadata, missing data
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Session "{session}": Null provider and no metadata' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
		] );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertInstanceOf( UserInfo::class, $info->getUserInfo() );
		$this->assertTrue( $info->getUserInfo()->isVerified() );
		$this->assertTrue( $info->getUserInfo()->isAnon() );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertSame( [], $logger->getBuffer() );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider2,
			'id' => $id,
		] );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[ LogLevel::INFO, 'Session "{session}": No user provided and provider cannot set user' ]
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// Incomplete/bad metadata
		$this->store->setRawSession( $id, true );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Session "{session}": Bad data' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		$this->store->setRawSession( $id, [ 'data' => [] ] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Session "{session}": Bad data structure' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		$this->store->deleteSession( $id );
		$this->store->setRawSession( $id, [ 'metadata' => $metadata ] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Session "{session}": Bad data structure' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		$this->store->setRawSession( $id, [ 'metadata' => $metadata, 'data' => true ] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Session "{session}": Bad data structure' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		$this->store->setRawSession( $id, [ 'metadata' => true, 'data' => [] ] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Session "{session}": Bad data structure' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		foreach ( $metadata as $key => $dummy ) {
			$tmp = $metadata;
			unset( $tmp[$key] );
			$this->store->setRawSession( $id, [ 'metadata' => $tmp, 'data' => [] ] );
			$this->assertFalse( $loadSessionInfoFromStore( $info ) );
			$this->assertSame( [
				[ LogLevel::WARNING, 'Session "{session}": Bad metadata' ],
			], $logger->getBuffer() );
			$logger->clearBuffer();
		}

		// Basic usage with metadata
		$this->store->setRawSession( $id, [ 'metadata' => $metadata, 'data' => [] ] );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertTrue( $info->isIdSafe() );
		$this->assertSame( [], $logger->getBuffer() );

		// Mismatched provider
		$this->store->setSessionMeta( $id, [ 'provider' => 'Bad' ] + $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Session "{session}": Wrong provider Bad !== Mock' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// Unknown provider
		$this->store->setSessionMeta( $id, [ 'provider' => 'Bad' ] + $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Session "{session}": Unknown provider Bad' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// Fill in provider
		$this->store->setSessionMeta( $id, $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertTrue( $info->isIdSafe() );
		$this->assertSame( [], $logger->getBuffer() );

		// Bad user metadata
		$this->store->setSessionMeta( $id, [ 'userId' => -1, 'userToken' => null ] + $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
		] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[ LogLevel::ERROR, 'Session "{session}": {exception}' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		$this->store->setSessionMeta(
			$id, [ 'userId' => 0, 'userName' => '<X>', 'userToken' => null ] + $metadata
		);
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
		] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[ LogLevel::ERROR, 'Session "{session}": {exception}', ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// Mismatched user by ID
		$this->store->setSessionMeta(
			$id, [ 'userId' => $userInfo->getId() + 1, 'userToken' => null ] + $metadata
		);
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// Mismatched user by name
		$this->store->setSessionMeta(
			$id, [ 'userId' => 0, 'userName' => 'X', 'userToken' => null ] + $metadata
		);
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Session "{session}": User name mismatch, {uname_a} !== {uname_b}' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// ID matches, name doesn't
		$this->store->setSessionMeta(
			$id, [ 'userId' => $userInfo->getId(), 'userName' => 'X', 'userToken' => null ] + $metadata
		);
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[
				LogLevel::WARNING,
				'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}'
			],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// Mismatched anon user
		$this->store->setSessionMeta(
			$id, [ 'userId' => 0, 'userName' => null, 'userToken' => null ] + $metadata
		);
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[
				LogLevel::WARNING,
				'Session "{session}": the session store entry is for an anonymous user, ' .
					'but the session metadata indicates a non-anonynmous user',
			],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// Lookup user by ID
		$this->store->setSessionMeta( $id, [ 'userToken' => null ] + $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
		] );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( $userInfo->getId(), $info->getUserInfo()->getId() );
		$this->assertTrue( $info->isIdSafe() );
		$this->assertSame( [], $logger->getBuffer() );

		// Lookup user by name
		$this->store->setSessionMeta(
			$id, [ 'userId' => 0, 'userName' => $username, 'userToken' => null ] + $metadata
		);
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
		] );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( $userInfo->getId(), $info->getUserInfo()->getId() );
		$this->assertTrue( $info->isIdSafe() );
		$this->assertSame( [], $logger->getBuffer() );

		// Lookup anonymous user
		$this->store->setSessionMeta(
			$id, [ 'userId' => 0, 'userName' => null, 'userToken' => null ] + $metadata
		);
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
		] );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertTrue( $info->getUserInfo()->isAnon() );
		$this->assertTrue( $info->isIdSafe() );
		$this->assertSame( [], $logger->getBuffer() );

		// Unverified user with metadata
		$this->store->setSessionMeta( $id, $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $unverifiedUserInfo
		] );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertTrue( $info->getUserInfo()->isVerified() );
		$this->assertSame( $unverifiedUserInfo->getId(), $info->getUserInfo()->getId() );
		$this->assertSame( $unverifiedUserInfo->getName(), $info->getUserInfo()->getName() );
		$this->assertTrue( $info->isIdSafe() );
		$this->assertSame( [], $logger->getBuffer() );

		// Unverified user with metadata
		$this->store->setSessionMeta( $id, $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $unverifiedUserInfo
		] );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertTrue( $info->getUserInfo()->isVerified() );
		$this->assertSame( $unverifiedUserInfo->getId(), $info->getUserInfo()->getId() );
		$this->assertSame( $unverifiedUserInfo->getName(), $info->getUserInfo()->getName() );
		$this->assertTrue( $info->isIdSafe() );
		$this->assertSame( [], $logger->getBuffer() );

		// Wrong token
		$this->store->setSessionMeta( $id, [ 'userToken' => 'Bad' ] + $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Session "{session}": User token mismatch' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// Provider metadata
		$this->store->setSessionMeta( $id, [ 'provider' => 'Mock2' ] + $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider2,
			'id' => $id,
			'userInfo' => $userInfo,
			'metadata' => [ 'Info' ],
		] );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [ 'Info', 'changed' => true ], $info->getProviderMetadata() );
		$this->assertSame( [], $logger->getBuffer() );

		$this->store->setSessionMeta( $id, [ 'providerMetadata' => [ 'Saved' ] ] + $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo,
		] );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [ 'Saved' ], $info->getProviderMetadata() );
		$this->assertSame( [], $logger->getBuffer() );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo,
			'metadata' => [ 'Info' ],
		] );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [ 'Merged' ], $info->getProviderMetadata() );
		$this->assertSame( [], $logger->getBuffer() );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo,
			'metadata' => [ 'Throw' ],
		] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [
			[
				LogLevel::WARNING,
				'Session "{session}": Metadata merge failed: {exception}',
			],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		// Remember from session
		$this->store->setSessionMeta( $id, $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
		] );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertFalse( $info->wasRemembered() );
		$this->assertSame( [], $logger->getBuffer() );

		$this->store->setSessionMeta( $id, [ 'remember' => true ] + $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
		] );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertTrue( $info->wasRemembered() );
		$this->assertSame( [], $logger->getBuffer() );

		$this->store->setSessionMeta( $id, [ 'remember' => false ] + $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertTrue( $info->wasRemembered() );
		$this->assertSame( [], $logger->getBuffer() );

		// forceHTTPS from session
		$this->store->setSessionMeta( $id, $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertFalse( $info->forceHTTPS() );
		$this->assertSame( [], $logger->getBuffer() );

		$this->store->setSessionMeta( $id, [ 'forceHTTPS' => true ] + $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertTrue( $info->forceHTTPS() );
		$this->assertSame( [], $logger->getBuffer() );

		$this->store->setSessionMeta( $id, [ 'forceHTTPS' => false ] + $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo,
			'forceHTTPS' => true
		] );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertTrue( $info->forceHTTPS() );
		$this->assertSame( [], $logger->getBuffer() );

		// "Persist" flag from session
		$this->store->setSessionMeta( $id, $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertFalse( $info->wasPersisted() );
		$this->assertSame( [], $logger->getBuffer() );

		$this->store->setSessionMeta( $id, [ 'persisted' => true ] + $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertTrue( $info->wasPersisted() );
		$this->assertSame( [], $logger->getBuffer() );

		$this->store->setSessionMeta( $id, [ 'persisted' => false ] + $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo,
			'persisted' => true
		] );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertTrue( $info->wasPersisted() );
		$this->assertSame( [], $logger->getBuffer() );

		// Provider refreshSessionInfo() returning false
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider3,
		] );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertSame( [], $logger->getBuffer() );

		// Hook
		$called = false;
		$data = [ 'foo' => 1 ];
		$this->store->setSession( $id, [ 'metadata' => $metadata, 'data' => $data ] );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$manager->setHookContainer( $this->createHookContainer( [
			'SessionCheckInfo' => function ( &$reason, $i, $r, $m, $d ) use (
				$info, $metadata, $data, $request, &$called
			) {
				$this->assertSame( $info->getId(), $i->getId() );
				$this->assertSame( $info->getProvider(), $i->getProvider() );
				$this->assertSame( $info->getUserInfo(), $i->getUserInfo() );
				$this->assertSame( $request, $r );
				$this->assertEquals( $metadata, $m );
				$this->assertEquals( $data, $d );
				$called = true;
				return false;
			}
		] ) );
		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
		$this->assertTrue( $called );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Session "{session}": Hook aborted' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();
		$manager->setHookContainer( $this->createHookContainer() );

		// forceUse deletes bad backend data
		$this->store->setSessionMeta( $id, [ 'userToken' => 'Bad' ] + $metadata );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo,
			'forceUse' => true,
		] );
		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
		$this->assertFalse( $this->store->getSession( $id ) );
		$this->assertSame( [
			[ LogLevel::WARNING, 'Session "{session}": User token mismatch' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();
	}

	/**
	 * @dataProvider provideLogPotentialSessionLeakage
	 */
	public function testLogPotentialSessionLeakage(
		$ip, $mwuser, $sessionData, $expectedSessionData, $expectedLogLevel
	) {
		MWTimestamp::setFakeTime( 1234567 );
		$this->overrideConfigValue( MainConfigNames::SuspiciousIpExpiry, 600 );
		$manager = new SessionManager();
		$logger = $this->createMock( LoggerInterface::class );
		$this->setLogger( 'session-ip', $logger );
		$request = new FauxRequest();
		$request->setIP( $ip );
		$request->setCookie( 'mwuser-sessionId', $mwuser );

		$proxyLookup = $this->createMock( ProxyLookup::class );
		$proxyLookup->method( 'isConfiguredProxy' )->willReturnCallback( static function ( $ip ) {
			return $ip === '11.22.33.44';
		} );
		$this->setService( 'ProxyLookup', $proxyLookup );

		$session = $this->createMock( Session::class );
		$session->method( 'isPersistent' )->willReturn( true );
		$session->method( 'getUser' )->willReturn( $this->getTestSysop()->getUser() );
		$session->method( 'getRequest' )->willReturn( $request );
		$session->method( 'getProvider' )->willReturn(
			$this->createMock( CookieSessionProvider::class ) );
		$session->method( 'get' )
			->with( 'SessionManager-logPotentialSessionLeakage' )
			->willReturn( $sessionData );
		$session->expects( $this->exactly( isset( $expectedSessionData ) ) )->method( 'set' )
			->with( 'SessionManager-logPotentialSessionLeakage', $expectedSessionData );

		$logger->expects( $this->exactly( isset( $expectedLogLevel ) ) )->method( 'log' )
			->with( $expectedLogLevel );

		$manager->logPotentialSessionLeakage( $session );
	}

	public static function provideLogPotentialSessionLeakage() {
		$now = 1234567;
		$valid = $now - 100;
		$expired = $now - 1000;
		return [
			'no log for new IP' => [
				'ip' => '1.2.3.4',
				'mwuser' => null,
				'sessionData' => [],
				'expectedSessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => null, 'timestamp' => $now ],
				'expectedLogLevel' => null,
			],
			'no log for same IP' => [
				'ip' => '1.2.3.4',
				'mwuser' => null,
				'sessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => null, 'timestamp' => $valid ],
				'expectedSessionData' => null,
				'expectedLogLevel' => null,
			],
			'no log for expired IP' => [
				'ip' => '1.2.3.4',
				'mwuser' => null,
				'sessionData' => [ 'ip' => '10.20.30.40', 'mwuser' => null, 'timestamp' => $expired ],
				'expectedSessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => null, 'timestamp' => $now ],
				'expectedLogLevel' => null,
			],
			'INFO log for changed IP' => [
				'ip' => '1.2.3.4',
				'mwuser' => null,
				'sessionData' => [ 'ip' => '10.20.30.40', 'mwuser' => null, 'timestamp' => $valid ],
				'expectedSessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => null, 'timestamp' => $now ],
				'expectedLogLevel' => LogLevel::INFO,
			],

			'no log for new mwuser' => [
				'ip' => '1.2.3.4',
				'mwuser' => 'new',
				'sessionData' => [],
				'expectedSessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => 'new', 'timestamp' => $now ],
				'expectedLogLevel' => null,
			],
			'no log for same mwuser' => [
				'ip' => '1.2.3.4',
				'mwuser' => 'old',
				'sessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => 'old', 'timestamp' => $valid ],
				'expectedSessionData' => null,
				'expectedLogLevel' => null,
			],
			'NOTICE log for changed mwuser' => [
				'ip' => '1.2.3.4',
				'mwuser' => 'new',
				'sessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => 'old', 'timestamp' => $valid ],
				'expectedSessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => 'new', 'timestamp' => $now ],
				'expectedLogLevel' => LogLevel::NOTICE,
			],
			'no expiration for mwuser' => [
				'ip' => '1.2.3.4',
				'mwuser' => 'new',
				'sessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => 'old', 'timestamp' => $expired ],
				'expectedSessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => 'new', 'timestamp' => $now ],
				'expectedLogLevel' => LogLevel::NOTICE,
			],
			'WARNING log for changed IP + mwuser' => [
				'ip' => '1.2.3.4',
				'mwuser' => 'new',
				'sessionData' => [ 'ip' => '10.20.30.40', 'mwuser' => 'old', 'timestamp' => $valid ],
				'expectedSessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => 'new', 'timestamp' => $now ],
				'expectedLogLevel' => LogLevel::WARNING,
			],

			'special IPs are ignored (1)' => [
				'ip' => '127.0.0.1',
				'mwuser' => 'new',
				'sessionData' => [ 'ip' => '10.20.30.40', 'mwuser' => 'old', 'timestamp' => $valid ],
				'expectedSessionData' => null,
				'expectedLogLevel' => null,
			],
			'special IPs are ignored (2)' => [
				'ip' => '11.22.33.44',
				'mwuser' => 'new',
				'sessionData' => [ 'ip' => '10.20.30.40', 'mwuser' => 'old', 'timestamp' => $valid ],
				'expectedSessionData' => null,
				'expectedLogLevel' => null,
			],
		];
	}
}
PK       ! )JX(  (  2  session/ImmutableSessionProviderWithCookieTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Session;

use ArrayUtils;
use BadMethodCallException;
use InvalidArgumentException;
use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Request\FauxResponse;
use MediaWiki\Session\ImmutableSessionProviderWithCookie;
use MediaWiki\Session\SessionBackend;
use MediaWiki\Session\SessionId;
use MediaWiki\Session\SessionInfo;
use MediaWiki\Session\SessionManager;
use MediaWiki\Session\UserInfo;
use MediaWikiIntegrationTestCase;
use Psr\Log\NullLogger;
use TestLogger;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Session
 * @group Database
 * @covers \MediaWiki\Session\ImmutableSessionProviderWithCookie
 */
class ImmutableSessionProviderWithCookieTest extends MediaWikiIntegrationTestCase {
	use SessionProviderTestTrait;

	private function getProvider( $name, $prefix = null, $forceHTTPS = false, $logger = null ) {
		$config = new HashConfig();
		$config->set( MainConfigNames::CookiePrefix, 'wgCookiePrefix' );
		$config->set( MainConfigNames::ForceHTTPS, $forceHTTPS );

		$params = [
			'sessionCookieName' => $name,
			'sessionCookieOptions' => [],
		];
		if ( $prefix !== null ) {
			$params['sessionCookieOptions']['prefix'] = $prefix;
		}

		$provider = $this->getMockBuilder( ImmutableSessionProviderWithCookie::class )
			->setConstructorArgs( [ $params ] )
			->getMockForAbstractClass();
		$this->initProvider( $provider, $logger ?? new TestLogger(), $config, new SessionManager() );

		return $provider;
	}

	public function testConstructor() {
		$provider = $this->getMockBuilder( ImmutableSessionProviderWithCookie::class )
			->getMockForAbstractClass();
		$priv = TestingAccessWrapper::newFromObject( $provider );
		$this->assertNull( $priv->sessionCookieName );
		$this->assertSame( [], $priv->sessionCookieOptions );

		$provider = $this->getMockBuilder( ImmutableSessionProviderWithCookie::class )
			->setConstructorArgs( [ [
				'sessionCookieName' => 'Foo',
				'sessionCookieOptions' => [ 'Bar' ],
			] ] )
			->getMockForAbstractClass();
		$priv = TestingAccessWrapper::newFromObject( $provider );
		$this->assertSame( 'Foo', $priv->sessionCookieName );
		$this->assertSame( [ 'Bar' ], $priv->sessionCookieOptions );

		try {
			$provider = $this->getMockBuilder( ImmutableSessionProviderWithCookie::class )
				->setConstructorArgs( [ [
					'sessionCookieName' => false,
				] ] )
				->getMockForAbstractClass();
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'sessionCookieName must be a string',
				$ex->getMessage()
			);
		}

		try {
			$provider = $this->getMockBuilder( ImmutableSessionProviderWithCookie::class )
				->setConstructorArgs( [ [
					'sessionCookieOptions' => 'x',
				] ] )
				->getMockForAbstractClass();
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'sessionCookieOptions must be an array',
				$ex->getMessage()
			);
		}
	}

	public function testBasics() {
		$provider = $this->getProvider( null );
		$this->assertFalse( $provider->persistsSessionId() );
		$this->assertFalse( $provider->canChangeUser() );

		$provider = $this->getProvider( 'Foo' );
		$this->assertTrue( $provider->persistsSessionId() );
		$this->assertFalse( $provider->canChangeUser() );

		$msg = $provider->whyNoSession();
		$this->assertInstanceOf( Message::class, $msg );
		$this->assertSame( 'sessionprovider-nocookies', $msg->getKey() );
	}

	public function testGetVaryCookies() {
		$provider = $this->getProvider( null );
		$this->assertSame( [], $provider->getVaryCookies() );

		$provider = $this->getProvider( 'Foo' );
		$this->assertSame( [ 'wgCookiePrefixFoo' ], $provider->getVaryCookies() );

		$provider = $this->getProvider( 'Foo', 'Bar' );
		$this->assertSame( [ 'BarFoo' ], $provider->getVaryCookies() );

		$provider = $this->getProvider( 'Foo', '' );
		$this->assertSame( [ 'Foo' ], $provider->getVaryCookies() );
	}

	public function testGetSessionIdFromCookie() {
		$this->overrideConfigValue( MainConfigNames::CookiePrefix, 'wgCookiePrefix' );
		$request = new FauxRequest();
		$request->setCookies( [
			'' => 'empty---------------------------',
			'Foo' => 'foo-----------------------------',
			'wgCookiePrefixFoo' => 'wgfoo---------------------------',
			'BarFoo' => 'foobar--------------------------',
			'bad' => 'bad',
		], '' );

		$provider = TestingAccessWrapper::newFromObject( $this->getProvider( null ) );
		try {
			$provider->getSessionIdFromCookie( $request );
			$this->fail( 'Expected exception not thrown' );
		} catch ( BadMethodCallException $ex ) {
			$this->assertSame(
				'MediaWiki\\Session\\ImmutableSessionProviderWithCookie::getSessionIdFromCookie ' .
					'may not be called when $this->sessionCookieName === null',
				$ex->getMessage()
			);
		}

		$provider = TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo' ) );
		$this->assertSame(
			'wgfoo---------------------------',
			$provider->getSessionIdFromCookie( $request )
		);

		$provider = TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo', 'Bar' ) );
		$this->assertSame(
			'foobar--------------------------',
			$provider->getSessionIdFromCookie( $request )
		);

		$provider = TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo', '' ) );
		$this->assertSame(
			'foo-----------------------------',
			$provider->getSessionIdFromCookie( $request )
		);

		$provider = TestingAccessWrapper::newFromObject( $this->getProvider( 'bad', '' ) );
		$this->assertSame( null, $provider->getSessionIdFromCookie( $request ) );

		$provider = TestingAccessWrapper::newFromObject( $this->getProvider( 'none', '' ) );
		$this->assertSame( null, $provider->getSessionIdFromCookie( $request ) );
	}

	protected function getSentRequest() {
		$sentResponse = $this->getMockBuilder( FauxResponse::class )
			->onlyMethods( [ 'headersSent', 'setCookie', 'header' ] )
			->getMock();
		$sentResponse->method( 'headersSent' )
			->willReturn( true );
		$sentResponse->expects( $this->never() )->method( 'setCookie' );
		$sentResponse->expects( $this->never() )->method( 'header' );

		$sentRequest = $this->getMockBuilder( FauxRequest::class )
			->onlyMethods( [ 'response' ] )->getMock();
		$sentRequest->method( 'response' )
			->willReturn( $sentResponse );
		return $sentRequest;
	}

	/**
	 * @dataProvider providePersistSession
	 * @param bool $secure
	 * @param bool $remember
	 * @param bool $forceHTTPS
	 */
	public function testPersistSession( $secure, $remember, $forceHTTPS ) {
		$this->overrideConfigValues( [
			MainConfigNames::CookieExpiration => 100,
			MainConfigNames::SecureLogin => false,
			MainConfigNames::ForceHTTPS => $forceHTTPS,
		] );

		$provider = $this->getProvider( 'session', null, $forceHTTPS, new NullLogger() );
		$priv = TestingAccessWrapper::newFromObject( $provider );
		$priv->sessionCookieOptions = [
			'prefix' => 'x',
			'path' => 'CookiePath',
			'domain' => 'CookieDomain',
			'secure' => false,
			'httpOnly' => true,
		];

		$sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
		$user = $this->getTestSysop()->getUser();
		$this->assertSame( $forceHTTPS, $user->requiresHTTPS() );

		$backend = new SessionBackend(
			new SessionId( $sessionId ),
			new SessionInfo( SessionInfo::MIN_PRIORITY, [
				'provider' => $provider,
				'id' => $sessionId,
				'persisted' => true,
				'userInfo' => UserInfo::newFromUser( $user, true ),
				'idIsSafe' => true,
			] ),
			new TestBagOStuff(),
			new NullLogger(),
			$this->createHookContainer(),
			10
		);
		TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;
		$backend->setRememberUser( $remember );
		$backend->setForceHTTPS( $secure );

		// No cookie
		$priv->sessionCookieName = null;
		$request = new FauxRequest();
		$provider->persistSession( $backend, $request );
		$this->assertSame( [], $request->response()->getCookies() );

		// Cookie
		$priv->sessionCookieName = 'session';
		$request = new FauxRequest();
		$time = time();
		$provider->persistSession( $backend, $request );

		$cookie = $request->response()->getCookieData( 'xsession' );
		$this->assertIsArray( $cookie );
		if ( isset( $cookie['expire'] ) && $cookie['expire'] > 0 ) {
			// Round expiry so we don't randomly fail if the seconds ticked during the test.
			$cookie['expire'] = round( $cookie['expire'] - $time, -2 );
		}
		$this->assertEquals( [
			'value' => $sessionId,
			'expire' => null,
			'path' => 'CookiePath',
			'domain' => 'CookieDomain',
			'secure' => $secure || $forceHTTPS,
			'httpOnly' => true,
			'raw' => false,
		], $cookie );

		$cookie = $request->response()->getCookieData( 'forceHTTPS' );
		if ( $secure && !$forceHTTPS ) {
			$this->assertIsArray( $cookie );
			if ( isset( $cookie['expire'] ) && $cookie['expire'] > 0 ) {
				// Round expiry so we don't randomly fail if the seconds ticked during the test.
				$cookie['expire'] = round( $cookie['expire'] - $time, -2 );
			}
			$this->assertEquals( [
				'value' => 'true',
				'expire' => null,
				'path' => 'CookiePath',
				'domain' => 'CookieDomain',
				'secure' => false,
				'httpOnly' => true,
				'raw' => false,
			], $cookie );
		} else {
			$this->assertNull( $cookie );
		}

		// Headers sent
		$request = $this->getSentRequest();
		$provider->persistSession( $backend, $request );
		$this->assertSame( [], $request->response()->getCookies() );
	}

	public static function providePersistSession() {
		return ArrayUtils::cartesianProduct(
			[ false, true ], // $secure
			[ false, true ], // $remember
			[ false, true ] // $forceHTTPS
		);
	}

	public function testUnpersistSession() {
		$provider = $this->getProvider( 'session', '', false, new NullLogger() );
		$priv = TestingAccessWrapper::newFromObject( $provider );

		// No cookie
		$priv->sessionCookieName = null;
		$request = new FauxRequest();
		$provider->unpersistSession( $request );
		$this->assertSame( null, $request->response()->getCookie( 'session', '' ) );

		// Cookie
		$priv->sessionCookieName = 'session';
		$request = new FauxRequest();
		$provider->unpersistSession( $request );
		$this->assertSame( '', $request->response()->getCookie( 'session', '' ) );

		// Headers sent
		$request = $this->getSentRequest();
		$provider->unpersistSession( $request );
		$this->assertSame( null, $request->response()->getCookie( 'session', '' ) );
	}

}
PK       ! |O1  O1    session/SessionInfoTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Session;

use InvalidArgumentException;
use MediaWiki\Session\SessionInfo;
use MediaWiki\Session\SessionManager;
use MediaWiki\Session\SessionProvider;
use MediaWiki\Session\UserInfo;
use MediaWikiIntegrationTestCase;
use stdClass;

/**
 * @group Session
 * @group Database
 * @covers \MediaWiki\Session\SessionInfo
 */
class SessionInfoTest extends MediaWikiIntegrationTestCase {
	use SessionProviderTestTrait;

	public function testBasics() {
		$anonInfo = UserInfo::newAnonymous();
		$username = 'SessionInfoTestTestBasics';
		$userInfo = UserInfo::newFromName( $username, true );
		$unverifiedUserInfo = UserInfo::newFromName( $username, false );

		try {
			new SessionInfo( SessionInfo::MIN_PRIORITY - 1, [] );
			$this->fail( 'Expected exception not thrown', 'priority < min' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Invalid priority', $ex->getMessage(), 'priority < min' );
		}

		try {
			new SessionInfo( SessionInfo::MAX_PRIORITY + 1, [] );
			$this->fail( 'Expected exception not thrown', 'priority > max' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Invalid priority', $ex->getMessage(), 'priority > max' );
		}

		try {
			new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'id' => 'ABC?' ] );
			$this->fail( 'Expected exception not thrown', 'bad session ID' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Invalid session ID', $ex->getMessage(), 'bad session ID' );
		}

		try {
			new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'userInfo' => new stdClass ] );
			$this->fail( 'Expected exception not thrown', 'bad userInfo' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Invalid userInfo', $ex->getMessage(), 'bad userInfo' );
		}

		try {
			new SessionInfo( SessionInfo::MIN_PRIORITY, [] );
			$this->fail( 'Expected exception not thrown', 'no provider, no id' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Must supply an ID when no provider is given', $ex->getMessage(),
				'no provider, no id' );
		}

		try {
			new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'copyFrom' => new stdClass ] );
			$this->fail( 'Expected exception not thrown', 'bad copyFrom' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Invalid copyFrom', $ex->getMessage(),
				'bad copyFrom' );
		}

		$manager = new SessionManager();
		$provider = $this->getMockBuilder( SessionProvider::class )
			->onlyMethods( [ 'persistsSessionId', 'canChangeUser', '__toString' ] )
			->getMockForAbstractClass();
		$this->initProvider( $provider, null, null, $manager );
		$provider->method( 'persistsSessionId' )
			->willReturn( true );
		$provider->method( 'canChangeUser' )
			->willReturn( true );
		$provider->method( '__toString' )
			->willReturn( 'Mock' );

		$provider2 = $this->getMockBuilder( SessionProvider::class )
			->onlyMethods( [ 'persistsSessionId', 'canChangeUser', '__toString' ] )
			->getMockForAbstractClass();
		$this->initProvider( $provider2, null, null, $manager );
		$provider2->method( 'persistsSessionId' )
			->willReturn( true );
		$provider2->method( 'canChangeUser' )
			->willReturn( true );
		$provider2->method( '__toString' )
			->willReturn( 'Mock2' );

		try {
			new SessionInfo( SessionInfo::MIN_PRIORITY, [
				'provider' => $provider,
				'userInfo' => $anonInfo,
				'metadata' => 'foo',
			] );
			$this->fail( 'Expected exception not thrown', 'bad metadata' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Invalid metadata', $ex->getMessage(), 'bad metadata' );
		}

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'provider' => $provider,
			'userInfo' => $anonInfo
		] );
		$this->assertSame( $provider, $info->getProvider() );
		$this->assertNotNull( $info->getId() );
		$this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
		$this->assertSame( $anonInfo, $info->getUserInfo() );
		$this->assertTrue( $info->isIdSafe() );
		$this->assertFalse( $info->forceUse() );
		$this->assertFalse( $info->wasPersisted() );
		$this->assertFalse( $info->wasRemembered() );
		$this->assertFalse( $info->forceHTTPS() );
		$this->assertNull( $info->getProviderMetadata() );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'provider' => $provider,
			'userInfo' => $unverifiedUserInfo,
			'metadata' => [ 'Foo' ],
		] );
		$this->assertSame( $provider, $info->getProvider() );
		$this->assertNotNull( $info->getId() );
		$this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
		$this->assertSame( $unverifiedUserInfo, $info->getUserInfo() );
		$this->assertTrue( $info->isIdSafe() );
		$this->assertFalse( $info->forceUse() );
		$this->assertFalse( $info->wasPersisted() );
		$this->assertFalse( $info->wasRemembered() );
		$this->assertFalse( $info->forceHTTPS() );
		$this->assertSame( [ 'Foo' ], $info->getProviderMetadata() );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'provider' => $provider,
			'userInfo' => $userInfo
		] );
		$this->assertSame( $provider, $info->getProvider() );
		$this->assertNotNull( $info->getId() );
		$this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
		$this->assertSame( $userInfo, $info->getUserInfo() );
		$this->assertTrue( $info->isIdSafe() );
		$this->assertFalse( $info->forceUse() );
		$this->assertFalse( $info->wasPersisted() );
		$this->assertTrue( $info->wasRemembered() );
		$this->assertFalse( $info->forceHTTPS() );
		$this->assertNull( $info->getProviderMetadata() );

		$id = $manager->generateSessionId();

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'provider' => $provider,
			'id' => $id,
			'persisted' => true,
			'userInfo' => $anonInfo
		] );
		$this->assertSame( $provider, $info->getProvider() );
		$this->assertSame( $id, $info->getId() );
		$this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
		$this->assertSame( $anonInfo, $info->getUserInfo() );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertFalse( $info->forceUse() );
		$this->assertTrue( $info->wasPersisted() );
		$this->assertFalse( $info->wasRemembered() );
		$this->assertFalse( $info->forceHTTPS() );
		$this->assertNull( $info->getProviderMetadata() );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'provider' => $provider,
			'id' => $id,
			'userInfo' => $userInfo
		] );
		$this->assertSame( $provider, $info->getProvider() );
		$this->assertSame( $id, $info->getId() );
		$this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
		$this->assertSame( $userInfo, $info->getUserInfo() );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertFalse( $info->forceUse() );
		$this->assertFalse( $info->wasPersisted() );
		$this->assertTrue( $info->wasRemembered() );
		$this->assertFalse( $info->forceHTTPS() );
		$this->assertNull( $info->getProviderMetadata() );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'id' => $id,
			'persisted' => true,
			'userInfo' => $userInfo,
			'metadata' => [ 'Foo' ],
		] );
		$this->assertSame( $id, $info->getId() );
		$this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
		$this->assertSame( $userInfo, $info->getUserInfo() );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertFalse( $info->forceUse() );
		$this->assertTrue( $info->wasPersisted() );
		$this->assertFalse( $info->wasRemembered() );
		$this->assertFalse( $info->forceHTTPS() );
		$this->assertNull( $info->getProviderMetadata() );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'id' => $id,
			'remembered' => true,
			'userInfo' => $userInfo,
		] );
		$this->assertFalse( $info->wasRemembered(), 'no provider' );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'provider' => $provider,
			'id' => $id,
			'remembered' => true,
		] );
		$this->assertFalse( $info->wasRemembered(), 'no user' );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'provider' => $provider,
			'id' => $id,
			'remembered' => true,
			'userInfo' => $anonInfo,
		] );
		$this->assertFalse( $info->wasRemembered(), 'anonymous user' );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'provider' => $provider,
			'id' => $id,
			'remembered' => true,
			'userInfo' => $unverifiedUserInfo,
		] );
		$this->assertFalse( $info->wasRemembered(), 'unverified user' );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'provider' => $provider,
			'id' => $id,
			'remembered' => false,
			'userInfo' => $userInfo,
		] );
		$this->assertFalse( $info->wasRemembered(), 'specific override' );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'id' => $id,
			'idIsSafe' => true,
		] );
		$this->assertSame( $id, $info->getId() );
		$this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
		$this->assertTrue( $info->isIdSafe() );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'id' => $id,
			'forceUse' => true,
		] );
		$this->assertFalse( $info->forceUse(), 'no provider' );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'provider' => $provider,
			'forceUse' => true,
		] );
		$this->assertFalse( $info->forceUse(), 'no id' );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [
			'provider' => $provider,
			'id' => $id,
			'forceUse' => true,
		] );
		$this->assertTrue( $info->forceUse(), 'correct use' );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'id' => $id,
			'forceHTTPS' => 1,
		] );
		$this->assertTrue( $info->forceHTTPS() );

		$fromInfo = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'id' => $id . 'A',
			'provider' => $provider,
			'userInfo' => $userInfo,
			'idIsSafe' => true,
			'forceUse' => true,
			'persisted' => true,
			'remembered' => true,
			'forceHTTPS' => true,
			'metadata' => [ 'foo!' ],
		] );
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 4, [
			'copyFrom' => $fromInfo,
		] );
		$this->assertSame( $id . 'A', $info->getId() );
		$this->assertSame( SessionInfo::MIN_PRIORITY + 4, $info->getPriority() );
		$this->assertSame( $provider, $info->getProvider() );
		$this->assertSame( $userInfo, $info->getUserInfo() );
		$this->assertTrue( $info->isIdSafe() );
		$this->assertTrue( $info->forceUse() );
		$this->assertTrue( $info->wasPersisted() );
		$this->assertTrue( $info->wasRemembered() );
		$this->assertTrue( $info->forceHTTPS() );
		$this->assertSame( [ 'foo!' ], $info->getProviderMetadata() );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY + 4, [
			'id' => $id . 'X',
			'provider' => $provider2,
			'userInfo' => $unverifiedUserInfo,
			'idIsSafe' => false,
			'forceUse' => false,
			'persisted' => false,
			'remembered' => false,
			'forceHTTPS' => false,
			'metadata' => null,
			'copyFrom' => $fromInfo,
		] );
		$this->assertSame( $id . 'X', $info->getId() );
		$this->assertSame( SessionInfo::MIN_PRIORITY + 4, $info->getPriority() );
		$this->assertSame( $provider2, $info->getProvider() );
		$this->assertSame( $unverifiedUserInfo, $info->getUserInfo() );
		$this->assertFalse( $info->isIdSafe() );
		$this->assertFalse( $info->forceUse() );
		$this->assertFalse( $info->wasPersisted() );
		$this->assertFalse( $info->wasRemembered() );
		$this->assertFalse( $info->forceHTTPS() );
		$this->assertNull( $info->getProviderMetadata() );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'id' => $id,
		] );
		$this->assertSame(
			'[' . SessionInfo::MIN_PRIORITY . "]null<null>$id",
			(string)$info,
			'toString'
		);

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'persisted' => true,
			'userInfo' => $userInfo
		] );
		$this->assertSame(
			'[' . SessionInfo::MIN_PRIORITY . "]Mock<+:{$userInfo->getId()}:$username>$id",
			(string)$info,
			'toString'
		);

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $provider,
			'id' => $id,
			'persisted' => true,
			'userInfo' => $unverifiedUserInfo
		] );
		$this->assertSame(
			'[' . SessionInfo::MIN_PRIORITY . "]Mock<-:{$userInfo->getId()}:$username>$id",
			(string)$info,
			'toString'
		);
	}

	public function testCompare() {
		$id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [ 'id' => $id ] );
		$info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, [ 'id' => $id ] );

		$this->assertLessThan( 0, SessionInfo::compare( $info1, $info2 ), '<' );
		$this->assertGreaterThan( 0, SessionInfo::compare( $info2, $info1 ), '>' );
		$this->assertSame( 0, SessionInfo::compare( $info1, $info1 ), '==' );
	}
}
PK       ! .ྚ      session/SessionBackendTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Session;

use BadMethodCallException;
use DummySessionProvider;
use InvalidArgumentException;
use MediaWiki\Config\Config;
use MediaWiki\Config\HashConfig;
use MediaWiki\Context\RequestContext;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Session\PHPSessionHandler;
use MediaWiki\Session\Session;
use MediaWiki\Session\SessionBackend;
use MediaWiki\Session\SessionId;
use MediaWiki\Session\SessionInfo;
use MediaWiki\Session\SessionManager;
use MediaWiki\Session\SessionProvider;
use MediaWiki\Session\UserInfo;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use Psr\Log\NullLogger;
use UnexpectedValueException;
use Wikimedia\ObjectCache\CachedBagOStuff;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Session
 * @group Database
 * @covers \MediaWiki\Session\SessionBackend
 */
class SessionBackendTest extends MediaWikiIntegrationTestCase {
	use SessionProviderTestTrait;

	private const SESSIONID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';

	/** @var SessionManager */
	protected $manager;

	/** @var Config */
	protected $config;

	/** @var SessionProvider */
	protected $provider;

	/** @var TestBagOStuff */
	protected $store;

	/** @var bool */
	protected $onSessionMetadataCalled = false;

	/**
	 * @return HookContainer
	 */
	private function getHookContainer() {
		// Need a real HookContainer to support modification of $wgHooks in the test
		return $this->getServiceContainer()->getHookContainer();
	}

	/**
	 * Returns a non-persistent backend that thinks it has at least one session active
	 * @param User|null $user
	 * @param string|null $id
	 * @return SessionBackend
	 */
	protected function getBackend( ?User $user = null, $id = null ) {
		if ( !$this->config ) {
			$this->config = new HashConfig();
			$this->manager = null;
		}
		if ( !$this->store ) {
			$this->store = new TestBagOStuff();
			$this->manager = null;
		}

		$logger = new NullLogger();
		if ( !$this->manager ) {
			$this->manager = new SessionManager( [
				'store' => $this->store,
				'logger' => $logger,
				'config' => $this->config,
			] );
		}

		$hookContainer = $this->getHookContainer();

		if ( !$this->provider ) {
			$this->provider = new DummySessionProvider();
		}
		$this->initProvider( $this->provider, null, $this->config, $this->manager, $hookContainer );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $this->provider,
			'id' => $id ?: self::SESSIONID,
			'persisted' => true,
			'userInfo' => UserInfo::newFromUser( $user ?: new User, true ),
			'idIsSafe' => true,
		] );
		$id = new SessionId( $info->getId() );

		$backend = new SessionBackend( $id, $info, $this->store, $logger, $hookContainer, 10 );
		$priv = TestingAccessWrapper::newFromObject( $backend );
		$priv->persist = false;
		$priv->requests = [ 100 => new FauxRequest() ];
		$priv->requests[100]->setSessionId( $id );
		$priv->usePhpSessionHandling = false;

		$manager = TestingAccessWrapper::newFromObject( $this->manager );
		$manager->allSessionBackends = [ $backend->getId() => $backend ] + $manager->allSessionBackends;
		$manager->allSessionIds = [ $backend->getId() => $id ] + $manager->allSessionIds;
		$manager->sessionProviders = [ (string)$this->provider => $this->provider ];

		return $backend;
	}

	public function testConstructor() {
		$username = 'TestConstructor';
		// Set variables
		$this->getBackend();

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $this->provider,
			'id' => self::SESSIONID,
			'persisted' => true,
			'userInfo' => UserInfo::newFromName( $username, false ),
			'idIsSafe' => true,
		] );
		$id = new SessionId( $info->getId() );
		$logger = new NullLogger();
		$hookContainer = $this->getHookContainer();
		try {
			new SessionBackend( $id, $info, $this->store, $logger, $hookContainer, 10 );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Refusing to create session for unverified user ' . $info->getUserInfo(),
				$ex->getMessage()
			);
		}

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'id' => self::SESSIONID,
			'userInfo' => UserInfo::newFromName( $username, true ),
			'idIsSafe' => true,
		] );
		$id = new SessionId( $info->getId() );
		try {
			new SessionBackend( $id, $info, $this->store, $logger, $hookContainer, 10 );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Cannot create session without a provider', $ex->getMessage() );
		}

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $this->provider,
			'id' => self::SESSIONID,
			'persisted' => true,
			'userInfo' => UserInfo::newFromName( $username, true ),
			'idIsSafe' => true,
		] );
		$id = new SessionId( '!' . $info->getId() );
		try {
			new SessionBackend( $id, $info, $this->store, $logger, $hookContainer, 10 );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'SessionId and SessionInfo don\'t match',
				$ex->getMessage()
			);
		}

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $this->provider,
			'id' => self::SESSIONID,
			'persisted' => true,
			'userInfo' => UserInfo::newFromName( $username, true ),
			'idIsSafe' => true,
		] );
		$id = new SessionId( $info->getId() );
		$backend = new SessionBackend( $id, $info, $this->store, $logger, $hookContainer, 10 );
		$this->assertSame( self::SESSIONID, $backend->getId() );
		$this->assertSame( $id, $backend->getSessionId() );
		$this->assertSame( $this->provider, $backend->getProvider() );
		$this->assertInstanceOf( User::class, $backend->getUser() );
		$this->assertSame( $username, $backend->getUser()->getName() );
		$this->assertSame( $info->wasPersisted(), $backend->isPersistent() );
		$this->assertSame( $info->wasRemembered(), $backend->shouldRememberUser() );
		$this->assertSame( $info->forceHTTPS(), $backend->shouldForceHTTPS() );

		$expire = time() + 100;
		$this->store->setSessionMeta( self::SESSIONID, [ 'expires' => $expire ] );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'provider' => $this->provider,
			'id' => self::SESSIONID,
			'persisted' => true,
			'forceHTTPS' => true,
			'metadata' => [ 'foo' ],
			'idIsSafe' => true,
		] );
		$id = new SessionId( $info->getId() );
		$backend = new SessionBackend( $id, $info, $this->store, $logger, $hookContainer, 10 );
		$this->assertSame( self::SESSIONID, $backend->getId() );
		$this->assertSame( $id, $backend->getSessionId() );
		$this->assertSame( $this->provider, $backend->getProvider() );
		$this->assertInstanceOf( User::class, $backend->getUser() );
		$this->assertTrue( $backend->getUser()->isAnon() );
		$this->assertSame( $info->wasPersisted(), $backend->isPersistent() );
		$this->assertSame( $info->wasRemembered(), $backend->shouldRememberUser() );
		$this->assertSame( $info->forceHTTPS(), $backend->shouldForceHTTPS() );
		$this->assertSame( $expire, TestingAccessWrapper::newFromObject( $backend )->expires );
		$this->assertSame( [ 'foo' ], $backend->getProviderMetadata() );
	}

	public function testSessionStuff() {
		$backend = $this->getBackend();
		$priv = TestingAccessWrapper::newFromObject( $backend );
		$priv->requests = []; // Remove dummy session

		$manager = TestingAccessWrapper::newFromObject( $this->manager );

		$request1 = new FauxRequest();
		$session1 = $backend->getSession( $request1 );
		$request2 = new FauxRequest();
		$session2 = $backend->getSession( $request2 );

		$this->assertInstanceOf( Session::class, $session1 );
		$this->assertInstanceOf( Session::class, $session2 );
		$this->assertCount( 2, $priv->requests );

		$index = TestingAccessWrapper::newFromObject( $session1 )->index;

		$this->assertSame( $request1, $backend->getRequest( $index ) );
		$this->assertSame( null, $backend->suggestLoginUsername( $index ) );
		$request1->setCookie( 'UserName', 'Example' );
		$this->assertSame( 'Example', $backend->suggestLoginUsername( $index ) );

		$session1 = null;
		$this->assertCount( 1, $priv->requests );
		$this->assertArrayHasKey( $backend->getId(), $manager->allSessionBackends );
		$this->assertSame( $backend, $manager->allSessionBackends[$backend->getId()] );
		try {
			$backend->getRequest( $index );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Invalid session index', $ex->getMessage() );
		}
		try {
			$backend->suggestLoginUsername( $index );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Invalid session index', $ex->getMessage() );
		}

		$session2 = null;
		$this->assertSame( [], $priv->requests );
		$this->assertArrayNotHasKey( $backend->getId(), $manager->allSessionBackends );
		$this->assertArrayHasKey( $backend->getId(), $manager->allSessionIds );
	}

	public function testSetProviderMetadata() {
		$backend = $this->getBackend();
		$priv = TestingAccessWrapper::newFromObject( $backend );
		$priv->providerMetadata = [ 'dummy' ];

		try {
			$backend->setProviderMetadata( 'foo' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( '$metadata must be an array or null', $ex->getMessage() );
		}

		try {
			$backend->setProviderMetadata( (object)[] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( '$metadata must be an array or null', $ex->getMessage() );
		}

		$this->assertFalse( $this->store->getSession( self::SESSIONID ) );
		$backend->setProviderMetadata( [ 'dummy' ] );
		$this->assertFalse( $this->store->getSession( self::SESSIONID ) );

		$this->assertFalse( $this->store->getSession( self::SESSIONID ) );
		$backend->setProviderMetadata( [ 'test' ] );
		$this->assertNotFalse( $this->store->getSession( self::SESSIONID ) );
		$this->assertSame( [ 'test' ], $backend->getProviderMetadata() );
		$this->store->deleteSession( self::SESSIONID );

		$this->assertFalse( $this->store->getSession( self::SESSIONID ) );
		$backend->setProviderMetadata( null );
		$this->assertNotFalse( $this->store->getSession( self::SESSIONID ) );
		$this->assertSame( null, $backend->getProviderMetadata() );
		$this->store->deleteSession( self::SESSIONID );
	}

	public function testResetId() {
		$id = session_id();

		$builder = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'persistsSessionId', 'sessionIdWasReset' ] );

		$this->provider = $builder->getMock();
		$this->provider->method( 'persistsSessionId' )
			->willReturn( false );
		$this->provider->expects( $this->never() )->method( 'sessionIdWasReset' );
		$backend = $this->getBackend( User::newFromName( 'TestResetId' ) );
		$manager = TestingAccessWrapper::newFromObject( $this->manager );
		$sessionId = $backend->getSessionId();
		$backend->resetId();
		$this->assertSame( self::SESSIONID, $backend->getId() );
		$this->assertSame( $backend->getId(), $sessionId->getId() );
		$this->assertSame( $id, session_id() );
		$this->assertSame( $backend, $manager->allSessionBackends[self::SESSIONID] );

		$this->provider = $builder->getMock();
		$this->provider->method( 'persistsSessionId' )
			->willReturn( true );
		$backend = $this->getBackend();
		$this->provider->expects( $this->once() )->method( 'sessionIdWasReset' )
			->with( $this->identicalTo( $backend ), $this->identicalTo( self::SESSIONID ) );
		$manager = TestingAccessWrapper::newFromObject( $this->manager );
		$sessionId = $backend->getSessionId();
		$backend->resetId();
		$this->assertNotEquals( self::SESSIONID, $backend->getId() );
		$this->assertSame( $backend->getId(), $sessionId->getId() );
		$this->assertIsArray( $this->store->getSession( $backend->getId() ) );
		$this->assertFalse( $this->store->getSession( self::SESSIONID ) );
		$this->assertSame( $id, session_id() );
		$this->assertArrayNotHasKey( self::SESSIONID, $manager->allSessionBackends );
		$this->assertArrayHasKey( $backend->getId(), $manager->allSessionBackends );
		$this->assertSame( $backend, $manager->allSessionBackends[$backend->getId()] );
	}

	public function testPersist() {
		$this->provider = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'persistSession' ] )->getMock();
		$this->provider->expects( $this->once() )->method( 'persistSession' );
		$backend = $this->getBackend();
		$this->assertFalse( $backend->isPersistent() );
		$backend->save(); // This one shouldn't call $provider->persistSession()

		$backend->persist();
		$this->assertTrue( $backend->isPersistent() );

		$this->provider = null;
		$backend = $this->getBackend();
		$wrap = TestingAccessWrapper::newFromObject( $backend );
		$wrap->persist = true;
		$wrap->expires = 0;
		$backend->persist();
		$this->assertNotEquals( 0, $wrap->expires );
	}

	public function testUnpersist() {
		$this->provider = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'unpersistSession' ] )->getMock();
		$this->provider->expects( $this->once() )->method( 'unpersistSession' );
		$backend = $this->getBackend();
		$wrap = TestingAccessWrapper::newFromObject( $backend );
		$wrap->store = new CachedBagOStuff( $this->store );
		$wrap->persist = true;
		$wrap->dataDirty = true;

		$backend->save(); // This one shouldn't call $provider->persistSession(), but should save
		$this->assertTrue( $backend->isPersistent() );
		$this->assertNotFalse( $this->store->getSession( self::SESSIONID ) );

		$backend->unpersist();
		$this->assertFalse( $backend->isPersistent() );
		$this->assertFalse( $this->store->getSession( self::SESSIONID ) );
		$this->assertNotFalse(
			$wrap->store->get( $wrap->store->makeKey( 'MWSession', self::SESSIONID ) )
		);
	}

	public function testRememberUser() {
		$backend = $this->getBackend();

		$remembered = $backend->shouldRememberUser();
		$backend->setRememberUser( !$remembered );
		$this->assertNotEquals( $remembered, $backend->shouldRememberUser() );
		$backend->setRememberUser( $remembered );
		$this->assertEquals( $remembered, $backend->shouldRememberUser() );
	}

	public function testForceHTTPS() {
		$backend = $this->getBackend();

		$force = $backend->shouldForceHTTPS();
		$backend->setForceHTTPS( !$force );
		$this->assertNotEquals( $force, $backend->shouldForceHTTPS() );
		$backend->setForceHTTPS( $force );
		$this->assertEquals( $force, $backend->shouldForceHTTPS() );
	}

	public function testLoggedOutTimestamp() {
		$backend = $this->getBackend();

		$backend->setLoggedOutTimestamp( 42 );
		$this->assertSame( 42, $backend->getLoggedOutTimestamp() );
		$backend->setLoggedOutTimestamp( '123' );
		$this->assertSame( 123, $backend->getLoggedOutTimestamp() );
	}

	public function testSetUser() {
		$user = static::getTestSysop()->getUser();

		$this->provider = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'canChangeUser' ] )->getMock();
		$this->provider->method( 'canChangeUser' )
			->willReturn( false );
		$backend = $this->getBackend();
		$this->assertFalse( $backend->canSetUser() );
		try {
			$backend->setUser( $user );
			$this->fail( 'Expected exception not thrown' );
		} catch ( BadMethodCallException $ex ) {
			$this->assertSame(
				'Cannot set user on this session; check $session->canSetUser() first',
				$ex->getMessage()
			);
		}
		$this->assertNotSame( $user, $backend->getUser() );

		$this->provider = null;
		$backend = $this->getBackend();
		$this->assertTrue( $backend->canSetUser() );
		$this->assertNotSame( $user, $backend->getUser() );
		$backend->setUser( $user );
		$this->assertSame( $user, $backend->getUser() );
	}

	public function testDirty() {
		$backend = $this->getBackend();
		$priv = TestingAccessWrapper::newFromObject( $backend );
		$priv->dataDirty = false;
		$backend->dirty();
		$this->assertTrue( $priv->dataDirty );
	}

	public function testGetData() {
		$backend = $this->getBackend();
		$data = $backend->getData();
		$this->assertSame( [], $data );
		$this->assertTrue( TestingAccessWrapper::newFromObject( $backend )->dataDirty );
		$data['???'] = '!!!';
		$this->assertSame( [ '???' => '!!!' ], $data );

		$testData = [ 'foo' => 'foo!', 'bar', [ 'baz', null ] ];
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend();
		$this->assertSame( $testData, $backend->getData() );
		$this->assertFalse( TestingAccessWrapper::newFromObject( $backend )->dataDirty );
	}

	public function testAddData() {
		$backend = $this->getBackend();
		$priv = TestingAccessWrapper::newFromObject( $backend );

		$priv->data = [ 'foo' => 1 ];
		$priv->dataDirty = false;
		$backend->addData( [ 'foo' => 1 ] );
		$this->assertSame( [ 'foo' => 1 ], $priv->data );
		$this->assertFalse( $priv->dataDirty );

		$priv->data = [ 'foo' => 1 ];
		$priv->dataDirty = false;
		$backend->addData( [ 'foo' => '1' ] );
		$this->assertSame( [ 'foo' => '1' ], $priv->data );
		$this->assertTrue( $priv->dataDirty );

		$priv->data = [ 'foo' => 1 ];
		$priv->dataDirty = false;
		$backend->addData( [ 'bar' => 2 ] );
		$this->assertSame( [ 'foo' => 1, 'bar' => 2 ], $priv->data );
		$this->assertTrue( $priv->dataDirty );
	}

	public function testDelaySave() {
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
		$backend = $this->getBackend();
		$priv = TestingAccessWrapper::newFromObject( $backend );
		$priv->persist = true;

		// Saves happen normally when no delay is in effect
		$this->onSessionMetadataCalled = false;
		$priv->metaDirty = true;
		$backend->save();
		$this->assertTrue( $this->onSessionMetadataCalled );

		$this->onSessionMetadataCalled = false;
		$priv->metaDirty = true;
		$priv->autosave();
		$this->assertTrue( $this->onSessionMetadataCalled );

		$delay = $backend->delaySave();

		// Autosave doesn't happen when no delay is in effect
		$this->onSessionMetadataCalled = false;
		$priv->metaDirty = true;
		$priv->autosave();
		$this->assertFalse( $this->onSessionMetadataCalled );

		// Save still does happen when no delay is in effect
		$priv->save();
		$this->assertTrue( $this->onSessionMetadataCalled );

		// Save happens when delay is consumed
		$this->onSessionMetadataCalled = false;
		$priv->metaDirty = true;
		ScopedCallback::consume( $delay );
		$this->assertTrue( $this->onSessionMetadataCalled );

		// Test multiple delays
		$delay1 = $backend->delaySave();
		$delay2 = $backend->delaySave();
		$delay3 = $backend->delaySave();
		$this->onSessionMetadataCalled = false;
		$priv->metaDirty = true;
		$priv->autosave();
		$this->assertFalse( $this->onSessionMetadataCalled );
		ScopedCallback::consume( $delay3 );
		$this->assertFalse( $this->onSessionMetadataCalled );
		ScopedCallback::consume( $delay1 );
		$this->assertFalse( $this->onSessionMetadataCalled );
		ScopedCallback::consume( $delay2 );
		$this->assertTrue( $this->onSessionMetadataCalled );
	}

	public function testSave() {
		$user = static::getTestSysop()->getUser();
		$this->store = new TestBagOStuff();
		$testData = [ 'foo' => 'foo!', 'bar', [ 'baz', null ] ];

		$neverHook = $this->getMockBuilder( __CLASS__ )
			->onlyMethods( [ 'onSessionMetadata' ] )->getMock();
		$neverHook->expects( $this->never() )->method( 'onSessionMetadata' );

		$builder = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'persistSession', 'unpersistSession' ] );

		$neverProvider = $builder->getMock();
		$neverProvider->expects( $this->never() )->method( 'persistSession' );
		$neverProvider->expects( $this->never() )->method( 'unpersistSession' );

		// Not persistent or dirty
		$this->provider = $neverProvider;
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $neverHook ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		$this->store->deleteSession( self::SESSIONID );
		$this->assertFalse( $backend->isPersistent() );
		TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
		TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
		$backend->save();
		$this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );

		// (but does unpersist if forced)
		$this->provider = $builder->getMock();
		$this->provider->expects( $this->never() )->method( 'persistSession' );
		$this->provider->expects( $this->atLeastOnce() )->method( 'unpersistSession' );
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $neverHook ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		$this->store->deleteSession( self::SESSIONID );
		TestingAccessWrapper::newFromObject( $backend )->persist = false;
		TestingAccessWrapper::newFromObject( $backend )->forcePersist = true;
		$this->assertFalse( $backend->isPersistent() );
		TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
		TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
		$backend->save();
		$this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );

		// (but not to a WebRequest associated with a different session)
		$this->provider = $neverProvider;
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $neverHook ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		TestingAccessWrapper::newFromObject( $backend )->requests[100]
			->setSessionId( new SessionId( 'x' ) );
		$this->store->deleteSession( self::SESSIONID );
		TestingAccessWrapper::newFromObject( $backend )->persist = false;
		TestingAccessWrapper::newFromObject( $backend )->forcePersist = true;
		$this->assertFalse( $backend->isPersistent() );
		TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
		TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
		$backend->save();
		$this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );

		// Not persistent, but dirty
		$this->provider = $neverProvider;
		$this->onSessionMetadataCalled = false;
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		$this->store->deleteSession( self::SESSIONID );
		$this->assertFalse( $backend->isPersistent() );
		TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
		TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
		$backend->save();
		$this->assertTrue( $this->onSessionMetadataCalled );
		$blob = $this->store->getSession( self::SESSIONID );
		$this->assertIsArray( $blob );
		$this->assertArrayHasKey( 'metadata', $blob );
		$metadata = $blob['metadata'];
		$this->assertIsArray( $metadata );
		$this->assertArrayHasKey( '???', $metadata );
		$this->assertSame( '!!!', $metadata['???'] );
		$this->assertFalse( $this->store->getSessionFromBackend( self::SESSIONID ),
			'making sure it didn\'t save to backend' );

		// Persistent, not dirty
		$this->provider = $neverProvider;
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $neverHook ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		$this->store->deleteSession( self::SESSIONID );
		TestingAccessWrapper::newFromObject( $backend )->persist = true;
		$this->assertTrue( $backend->isPersistent() );
		TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
		TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
		$backend->save();
		$this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );

		// (but will persist if forced)
		$this->provider = $builder->getMock();
		$this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
		$this->provider->expects( $this->never() )->method( 'unpersistSession' );
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $neverHook ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		$this->store->deleteSession( self::SESSIONID );
		TestingAccessWrapper::newFromObject( $backend )->persist = true;
		TestingAccessWrapper::newFromObject( $backend )->forcePersist = true;
		$this->assertTrue( $backend->isPersistent() );
		TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
		TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
		$backend->save();
		$this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );

		// Persistent and dirty
		$this->provider = $neverProvider;
		$this->onSessionMetadataCalled = false;
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		$this->store->deleteSession( self::SESSIONID );
		TestingAccessWrapper::newFromObject( $backend )->persist = true;
		$this->assertTrue( $backend->isPersistent() );
		TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
		TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
		$backend->save();
		$this->assertTrue( $this->onSessionMetadataCalled );
		$blob = $this->store->getSession( self::SESSIONID );
		$this->assertIsArray( $blob );
		$this->assertArrayHasKey( 'metadata', $blob );
		$metadata = $blob['metadata'];
		$this->assertIsArray( $metadata );
		$this->assertArrayHasKey( '???', $metadata );
		$this->assertSame( '!!!', $metadata['???'] );
		$this->assertIsArray( $this->store->getSessionFromBackend( self::SESSIONID ),
			'making sure it did save to backend' );

		// (also persists if forced)
		$this->provider = $builder->getMock();
		$this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
		$this->provider->expects( $this->never() )->method( 'unpersistSession' );
		$this->onSessionMetadataCalled = false;
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		$this->store->deleteSession( self::SESSIONID );
		TestingAccessWrapper::newFromObject( $backend )->persist = true;
		TestingAccessWrapper::newFromObject( $backend )->forcePersist = true;
		$this->assertTrue( $backend->isPersistent() );
		TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
		TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
		$backend->save();
		$this->assertTrue( $this->onSessionMetadataCalled );
		$blob = $this->store->getSession( self::SESSIONID );
		$this->assertIsArray( $blob );
		$this->assertArrayHasKey( 'metadata', $blob );
		$metadata = $blob['metadata'];
		$this->assertIsArray( $metadata );
		$this->assertArrayHasKey( '???', $metadata );
		$this->assertSame( '!!!', $metadata['???'] );
		$this->assertIsArray( $this->store->getSessionFromBackend( self::SESSIONID ),
			'making sure it did save to backend' );

		// (also persists if metadata dirty)
		$this->provider = $builder->getMock();
		$this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
		$this->provider->expects( $this->never() )->method( 'unpersistSession' );
		$this->onSessionMetadataCalled = false;
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		$this->store->deleteSession( self::SESSIONID );
		TestingAccessWrapper::newFromObject( $backend )->persist = true;
		$this->assertTrue( $backend->isPersistent() );
		TestingAccessWrapper::newFromObject( $backend )->metaDirty = true;
		TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
		$backend->save();
		$this->assertTrue( $this->onSessionMetadataCalled );
		$blob = $this->store->getSession( self::SESSIONID );
		$this->assertIsArray( $blob );
		$this->assertArrayHasKey( 'metadata', $blob );
		$metadata = $blob['metadata'];
		$this->assertIsArray( $metadata );
		$this->assertArrayHasKey( '???', $metadata );
		$this->assertSame( '!!!', $metadata['???'] );
		$this->assertIsArray( $this->store->getSessionFromBackend( self::SESSIONID ),
			'making sure it did save to backend' );

		// Not marked dirty, but dirty data
		// (e.g. indirect modification from ArrayAccess::offsetGet)
		$this->provider = $neverProvider;
		$this->onSessionMetadataCalled = false;
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		$this->store->deleteSession( self::SESSIONID );
		TestingAccessWrapper::newFromObject( $backend )->persist = true;
		$this->assertTrue( $backend->isPersistent() );
		TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
		TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
		TestingAccessWrapper::newFromObject( $backend )->dataHash = 'Doesn\'t match';
		$backend->save();
		$this->assertTrue( $this->onSessionMetadataCalled );
		$blob = $this->store->getSession( self::SESSIONID );
		$this->assertIsArray( $blob );
		$this->assertArrayHasKey( 'metadata', $blob );
		$metadata = $blob['metadata'];
		$this->assertIsArray( $metadata );
		$this->assertArrayHasKey( '???', $metadata );
		$this->assertSame( '!!!', $metadata['???'] );
		$this->assertIsArray( $this->store->getSessionFromBackend( self::SESSIONID ),
			'making sure it did save to backend' );

		// Bad hook
		$this->provider = null;
		$mockHook = $this->getMockBuilder( __CLASS__ )
			->onlyMethods( [ 'onSessionMetadata' ] )->getMock();
		$mockHook->method( 'onSessionMetadata' )
			->willReturnCallback(
				static function ( SessionBackend $backend, array &$metadata, array $requests ) {
					$metadata['userId']++;
				}
			);
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $mockHook ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		$backend->dirty();
		try {
			$backend->save();
			$this->fail( 'Expected exception not thrown' );
		} catch ( UnexpectedValueException $ex ) {
			$this->assertSame(
				'SessionMetadata hook changed metadata key "userId"',
				$ex->getMessage()
			);
		}

		// SessionManager::preventSessionsForUser
		TestingAccessWrapper::newFromObject( $this->manager )->preventUsers = [
			$user->getName() => true,
		];
		$this->provider = $neverProvider;
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $neverHook ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		$this->store->deleteSession( self::SESSIONID );
		TestingAccessWrapper::newFromObject( $backend )->persist = true;
		$this->assertTrue( $backend->isPersistent() );
		TestingAccessWrapper::newFromObject( $backend )->metaDirty = true;
		TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
		$backend->save();
		$this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
	}

	public function testRenew() {
		$user = static::getTestSysop()->getUser();
		$this->store = new TestBagOStuff();
		$testData = [ 'foo' => 'foo!', 'bar', [ 'baz', null ] ];

		// Not persistent
		$this->provider = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'persistSession' ] )->getMock();
		$this->provider->expects( $this->never() )->method( 'persistSession' );
		$this->onSessionMetadataCalled = false;
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		$this->store->deleteSession( self::SESSIONID );
		$wrap = TestingAccessWrapper::newFromObject( $backend );
		$this->assertFalse( $backend->isPersistent() );
		$wrap->metaDirty = false;
		$wrap->dataDirty = false;
		$wrap->forcePersist = false;
		$wrap->expires = 0;
		$backend->renew();
		$this->assertTrue( $this->onSessionMetadataCalled );
		$blob = $this->store->getSession( self::SESSIONID );
		$this->assertIsArray( $blob );
		$this->assertArrayHasKey( 'metadata', $blob );
		$metadata = $blob['metadata'];
		$this->assertIsArray( $metadata );
		$this->assertArrayHasKey( '???', $metadata );
		$this->assertSame( '!!!', $metadata['???'] );
		$this->assertNotEquals( 0, $wrap->expires );

		// Persistent
		$this->provider = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'persistSession' ] )->getMock();
		$this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
		$this->onSessionMetadataCalled = false;
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		$this->store->deleteSession( self::SESSIONID );
		$wrap = TestingAccessWrapper::newFromObject( $backend );
		$wrap->persist = true;
		$this->assertTrue( $backend->isPersistent() );
		$wrap->metaDirty = false;
		$wrap->dataDirty = false;
		$wrap->forcePersist = false;
		$wrap->expires = 0;
		$backend->renew();
		$this->assertTrue( $this->onSessionMetadataCalled );
		$blob = $this->store->getSession( self::SESSIONID );
		$this->assertIsArray( $blob );
		$this->assertArrayHasKey( 'metadata', $blob );
		$metadata = $blob['metadata'];
		$this->assertIsArray( $metadata );
		$this->assertArrayHasKey( '???', $metadata );
		$this->assertSame( '!!!', $metadata['???'] );
		$this->assertNotEquals( 0, $wrap->expires );

		// Not persistent, not expiring
		$this->provider = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'persistSession' ] )->getMock();
		$this->provider->expects( $this->never() )->method( 'persistSession' );
		$this->onSessionMetadataCalled = false;
		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionMetadata' => [ $this ] ] );
		$this->store->setSessionData( self::SESSIONID, $testData );
		$backend = $this->getBackend( $user );
		$this->store->deleteSession( self::SESSIONID );
		$wrap = TestingAccessWrapper::newFromObject( $backend );
		$this->assertFalse( $backend->isPersistent() );
		$wrap->metaDirty = false;
		$wrap->dataDirty = false;
		$wrap->forcePersist = false;
		$expires = time() + $wrap->lifetime + 100;
		$wrap->expires = $expires;
		$backend->renew();
		$this->assertFalse( $this->onSessionMetadataCalled );
		$this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
		$this->assertEquals( $expires, $wrap->expires );
	}

	public function onSessionMetadata( SessionBackend $backend, array &$metadata, array $requests ) {
		$this->onSessionMetadataCalled = true;
		$metadata['???'] = '!!!';
	}

	public function testTakeOverGlobalSession() {
		if ( !PHPSessionHandler::isInstalled() ) {
			PHPSessionHandler::install( SessionManager::singleton() );
		}
		if ( !PHPSessionHandler::isEnabled() ) {
			$staticAccess = TestingAccessWrapper::newFromClass( PHPSessionHandler::class );
			$handler = TestingAccessWrapper::newFromObject( $staticAccess->instance );
			$resetHandler = new ScopedCallback( static function () use ( $handler ) {
				session_write_close();
				$handler->enable = false;
			} );
			$handler->enable = true;
		}

		$backend = $this->getBackend( static::getTestSysop()->getUser() );
		TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = true;

		$resetSingleton = TestUtils::setSessionManagerSingleton( $this->manager );

		$manager = TestingAccessWrapper::newFromObject( $this->manager );
		$request = RequestContext::getMain()->getRequest();
		$manager->globalSession = $backend->getSession( $request );
		$manager->globalSessionRequest = $request;

		session_id( '' );
		TestingAccessWrapper::newFromObject( $backend )->checkPHPSession();
		$this->assertSame( $backend->getId(), session_id() );
		session_write_close();

		$backend2 = $this->getBackend(
			User::newFromName( 'TestTakeOverGlobalSession' ), 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
		);
		TestingAccessWrapper::newFromObject( $backend2 )->usePhpSessionHandling = true;

		session_id( '' );
		TestingAccessWrapper::newFromObject( $backend2 )->checkPHPSession();
		$this->assertSame( '', session_id() );
	}

	public function testResetIdOfGlobalSession() {
		if ( !PHPSessionHandler::isInstalled() ) {
			PHPSessionHandler::install( SessionManager::singleton() );
		}
		if ( !PHPSessionHandler::isEnabled() ) {
			$staticAccess = TestingAccessWrapper::newFromClass( PHPSessionHandler::class );
			$handler = TestingAccessWrapper::newFromObject( $staticAccess->instance );
			$resetHandler = new ScopedCallback( static function () use ( $handler ) {
				session_write_close();
				$handler->enable = false;
			} );
			$handler->enable = true;
		}

		$backend = $this->getBackend( User::newFromName( 'TestResetIdOfGlobalSession' ) );
		TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = true;

		$resetSingleton = TestUtils::setSessionManagerSingleton( $this->manager );

		$manager = TestingAccessWrapper::newFromObject( $this->manager );
		$request = RequestContext::getMain()->getRequest();
		$manager->globalSession = $backend->getSession( $request );
		$manager->globalSessionRequest = $request;

		session_id( self::SESSIONID );
		@session_start();
		$_SESSION['foo'] = __METHOD__;
		$backend->resetId();
		$this->assertNotEquals( self::SESSIONID, $backend->getId() );
		$this->assertSame( $backend->getId(), session_id() );
		$this->assertArrayHasKey( 'foo', $_SESSION );
		$this->assertSame( __METHOD__, $_SESSION['foo'] );
		session_write_close();
	}

	public function testUnpersistOfGlobalSession() {
		if ( !PHPSessionHandler::isInstalled() ) {
			PHPSessionHandler::install( SessionManager::singleton() );
		}
		if ( !PHPSessionHandler::isEnabled() ) {
			$staticAccess = TestingAccessWrapper::newFromClass( PHPSessionHandler::class );
			$handler = TestingAccessWrapper::newFromObject( $staticAccess->instance );
			$resetHandler = new ScopedCallback( static function () use ( $handler ) {
				session_write_close();
				$handler->enable = false;
			} );
			$handler->enable = true;
		}

		$backend = $this->getBackend( User::newFromName( 'TestUnpersistOfGlobalSession' ) );
		$wrap = TestingAccessWrapper::newFromObject( $backend );
		$wrap->usePhpSessionHandling = true;
		$wrap->persist = true;

		$resetSingleton = TestUtils::setSessionManagerSingleton( $this->manager );

		$manager = TestingAccessWrapper::newFromObject( $this->manager );
		$request = RequestContext::getMain()->getRequest();
		$manager->globalSession = $backend->getSession( $request );
		$manager->globalSessionRequest = $request;

		session_id( self::SESSIONID . 'x' );
		@session_start();
		$backend->unpersist();
		$this->assertSame( self::SESSIONID . 'x', session_id() );
		session_write_close();

		session_id( self::SESSIONID );
		$wrap->persist = true;
		$backend->unpersist();
		$this->assertSame( '', session_id() );
	}

	public function testGetAllowedUserRights() {
		$this->provider = $this->getMockBuilder( DummySessionProvider::class )
			->onlyMethods( [ 'getAllowedUserRights' ] )
			->getMock();
		$this->provider->method( 'getAllowedUserRights' )
			->willReturn( [ 'foo', 'bar' ] );

		$backend = $this->getBackend();
		$this->assertSame( [ 'foo', 'bar' ], $backend->getAllowedUserRights() );
	}

}
PK       ! tfb      session/SessionProviderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Session;

use BadMethodCallException;
use InvalidArgumentException;
use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Session\MetadataMergeException;
use MediaWiki\Session\SessionInfo;
use MediaWiki\Session\SessionManager;
use MediaWiki\Session\SessionProvider;
use MediaWiki\User\User;
use MediaWiki\User\UserNameUtils;
use MediaWikiIntegrationTestCase;
use TestLogger;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Session
 * @group Database
 * @covers \MediaWiki\Session\SessionProvider
 */
class SessionProviderTest extends MediaWikiIntegrationTestCase {
	use SessionProviderTestTrait;

	public function testBasics() {
		$this->hideDeprecated( 'MediaWiki\Session\SessionProvider::setConfig' );
		$this->hideDeprecated( 'MediaWiki\Session\SessionProvider::setLogger' );
		$this->hideDeprecated( 'MediaWiki\Session\SessionProvider::setManager' );
		$this->hideDeprecated( 'MediaWiki\Session\SessionProvider::setHookContainer' );

		$manager = new SessionManager();
		$logger = new TestLogger();
		$config = new HashConfig();
		$hookContainer = $this->createHookContainer();
		$userNameUtils = $this->createNoOpMock( UserNameUtils::class );

		$provider = $this->getMockForAbstractClass( SessionProvider::class );
		$priv = TestingAccessWrapper::newFromObject( $provider );

		$this->initProvider( $provider, $logger, $config, $manager, $hookContainer, $userNameUtils );
		$this->assertSame( $logger, $priv->logger );
		$this->assertSame( $config, $priv->getConfig() );
		$this->assertSame( $manager, $priv->manager );
		$this->assertSame( $manager, $provider->getManager() );
		$this->assertSame( $hookContainer, $priv->getHookContainer() );
		$this->assertSame( $userNameUtils, $priv->userNameUtils );
		$provider->setConfig( $config );
		$this->assertSame( $config, $priv->getConfig() );
		$provider->setLogger( $logger );
		$this->assertSame( $logger, $priv->logger );
		$provider->setManager( $manager );
		$this->assertSame( $manager, $priv->manager );
		$this->assertSame( $manager, $provider->getManager() );
		$provider->setHookContainer( $hookContainer );
		$this->assertSame( $hookContainer, $priv->getHookContainer() );

		$provider->invalidateSessionsForUser( new User );

		$this->assertSame( [], $provider->getVaryHeaders() );
		$this->assertSame( [], $provider->getVaryCookies() );
		$this->assertSame( null, $provider->suggestLoginUsername( new FauxRequest ) );

		$this->assertSame( get_class( $provider ), (string)$provider );

		$this->assertNull( $provider->getRememberUserDuration() );

		$this->assertNull( $provider->whyNoSession() );
		$this->assertFalse( $provider->safeAgainstCsrf() );

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
			'id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
			'provider' => $provider,
		] );
		$metadata = [ 'foo' ];
		$this->assertTrue( $provider->refreshSessionInfo( $info, new FauxRequest, $metadata ) );
		$this->assertSame( [ 'foo' ], $metadata );
	}

	/**
	 * @dataProvider provideNewSessionInfo
	 * @param bool $persistId Return value for ->persistsSessionId()
	 * @param bool $persistUser Return value for ->persistsSessionUser()
	 * @param bool $ok Whether a SessionInfo is provided
	 */
	public function testNewSessionInfo( $persistId, $persistUser, $ok ) {
		$manager = new SessionManager();

		$provider = $this->getMockBuilder( SessionProvider::class )
			->onlyMethods( [ 'canChangeUser', 'persistsSessionId' ] )
			->getMockForAbstractClass();
		$provider->method( 'persistsSessionId' )
			->willReturn( $persistId );
		$provider->method( 'canChangeUser' )
			->willReturn( $persistUser );
		$this->initProvider( $provider, null, null, $manager );

		if ( $ok ) {
			$info = $provider->newSessionInfo();
			$this->assertNotNull( $info );
			$this->assertFalse( $info->wasPersisted() );
			$this->assertTrue( $info->isIdSafe() );

			$id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
			$info = $provider->newSessionInfo( $id );
			$this->assertNotNull( $info );
			$this->assertSame( $id, $info->getId() );
			$this->assertFalse( $info->wasPersisted() );
			$this->assertTrue( $info->isIdSafe() );
		} else {
			$this->assertNull( $provider->newSessionInfo() );
		}
	}

	public function testMergeMetadata() {
		$provider = $this->getMockBuilder( SessionProvider::class )
			->getMockForAbstractClass();

		try {
			$provider->mergeMetadata(
				[ 'foo' => 1, 'baz' => 3 ],
				[ 'bar' => 2, 'baz' => '3' ]
			);
			$this->fail( 'Expected exception not thrown' );
		} catch ( MetadataMergeException $ex ) {
			$this->assertSame( 'Key "baz" changed', $ex->getMessage() );
			$this->assertSame(
				[ 'old_value' => 3, 'new_value' => '3' ], $ex->getContext() );
		}

		$res = $provider->mergeMetadata(
			[ 'foo' => 1, 'baz' => 3 ],
			[ 'bar' => 2, 'baz' => 3 ]
		);
		$this->assertSame( [ 'bar' => 2, 'baz' => 3 ], $res );
	}

	public static function provideNewSessionInfo() {
		return [
			[ false, false, false ],
			[ true, false, false ],
			[ false, true, false ],
			[ true, true, true ],
		];
	}

	public function testImmutableSessions() {
		$provider = $this->getMockBuilder( SessionProvider::class )
			->onlyMethods( [ 'canChangeUser', 'persistsSessionId' ] )
			->getMockForAbstractClass();
		$provider->method( 'canChangeUser' )
			->willReturn( true );
		$provider->preventSessionsForUser( 'Foo' );

		$provider = $this->getMockBuilder( SessionProvider::class )
			->onlyMethods( [ 'canChangeUser', 'persistsSessionId' ] )
			->getMockForAbstractClass();
		$provider->method( 'canChangeUser' )
			->willReturn( false );
		try {
			$provider->preventSessionsForUser( 'Foo' );
			$this->fail( 'Expected exception not thrown' );
		} catch ( BadMethodCallException $ex ) {
			$this->assertSame(
				'MediaWiki\\Session\\SessionProvider::preventSessionsForUser must be implemented ' .
					'when canChangeUser() is false',
				$ex->getMessage()
			);
		}
	}

	public function testHashToSessionId() {
		$config = new HashConfig( [
			MainConfigNames::SecretKey => 'Shhh!',
		] );

		$provider = $this->getMockForAbstractClass( SessionProvider::class,
			[], 'MockSessionProvider' );
		$this->initProvider( $provider, null, $config );
		$priv = TestingAccessWrapper::newFromObject( $provider );

		$this->assertSame( 'eoq8cb1mg7j30ui5qolafps4hg29k5bb', $priv->hashToSessionId( 'foobar' ) );
		$this->assertSame( '4do8j7tfld1g8tte9jqp3csfgmulaun9',
			$priv->hashToSessionId( 'foobar', 'secret' ) );

		try {
			$priv->hashToSessionId( [] );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'$data must be a string, array was passed',
				$ex->getMessage()
			);
		}
		try {
			$priv->hashToSessionId( '', false );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'$key must be a string or null, bool was passed',
				$ex->getMessage()
			);
		}
	}

	public function testDescribe() {
		$provider = $this->getMockForAbstractClass( SessionProvider::class,
			[], 'MockSessionProvider' );

		$this->assertSame(
			'MockSessionProvider sessions',
			$provider->describe(
				$this->getServiceContainer()->getLanguageFactory()->getLanguage( 'en' ) )
		);
	}

	public function testGetAllowedUserRights() {
		$provider = $this->getMockForAbstractClass( SessionProvider::class );
		$backend = TestUtils::getDummySessionBackend();

		try {
			$provider->getAllowedUserRights( $backend );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'Backend\'s provider isn\'t $this',
				$ex->getMessage()
			);
		}

		TestingAccessWrapper::newFromObject( $backend )->provider = $provider;
		$this->assertNull( $provider->getAllowedUserRights( $backend ) );
	}

}
PK       ! ǭV0  0  *  session/BotPasswordSessionProviderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Session;

use InvalidArgumentException;
use MediaWiki\Config\HashConfig;
use MediaWiki\Config\MultiConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Session\BotPasswordSessionProvider;
use MediaWiki\Session\Session;
use MediaWiki\Session\SessionInfo;
use MediaWiki\Session\SessionManager;
use MediaWiki\Session\UserInfo;
use MediaWiki\User\BotPassword;
use MediaWikiIntegrationTestCase;
use Psr\Log\LogLevel;
use Psr\Log\NullLogger;
use TestLogger;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Session
 * @group Database
 * @covers \MediaWiki\Session\BotPasswordSessionProvider
 */
class BotPasswordSessionProviderTest extends MediaWikiIntegrationTestCase {
	use SessionProviderTestTrait;

	/** @var HashConfig */
	private $config;
	/** @var string */
	private $configHash;

	private function getProvider( $name = null, $prefix = null, $isApiRequest = true ) {
		global $wgSessionProviders;

		$params = [
			'priority' => 40,
			'sessionCookieName' => $name,
			'sessionCookieOptions' => [],
			'isApiRequest' => $isApiRequest,
		];
		if ( $prefix !== null ) {
			$params['sessionCookieOptions']['prefix'] = $prefix;
		}

		$configHash = json_encode( [ $name, $prefix, $isApiRequest ] );
		if ( !$this->config || $this->configHash !== $configHash ) {
			$this->config = new HashConfig( [
				MainConfigNames::CookiePrefix => 'wgCookiePrefix',
				MainConfigNames::EnableBotPasswords => true,
				MainConfigNames::SessionProviders => $wgSessionProviders + [
					BotPasswordSessionProvider::class => [
						'class' => BotPasswordSessionProvider::class,
						'args' => [ $params ],
						'services' => [ 'GrantsInfo' ],
					]
				],
			] );
			$this->configHash = $configHash;
		}
		$manager = new SessionManager( [
			'config' => new MultiConfig( [ $this->config, $this->getServiceContainer()->getMainConfig() ] ),
			'logger' => new NullLogger,
			'store' => new TestBagOStuff,
		] );

		return $manager->getProvider( BotPasswordSessionProvider::class );
	}

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

		$this->overrideConfigValues( [
			MainConfigNames::EnableBotPasswords => true,
			MainConfigNames::CentralIdLookupProvider => 'local',
			MainConfigNames::GrantPermissions => [
				'test' => [ 'read' => true ],
			],
		] );
	}

	public function addDBDataOnce() {
		$passwordFactory = $this->getServiceContainer()->getPasswordFactory();
		$passwordHash = $passwordFactory->newFromPlaintext( 'foobaz' );

		$sysop = static::getTestSysop()->getUser();
		$userId = $this->getServiceContainer()
			->getCentralIdLookupFactory()
			->getLookup( 'local' )
			->centralIdFromName( $sysop->getName() );

		$dbw = $this->getDb();
		$dbw->newDeleteQueryBuilder()
			->deleteFrom( 'bot_passwords' )
			->where( [ 'bp_user' => $userId, 'bp_app_id' => 'BotPasswordSessionProvider' ] )
			->caller( __METHOD__ )->execute();
		$dbw->newInsertQueryBuilder()
			->insertInto( 'bot_passwords' )
			->row( [
				'bp_user' => $userId,
				'bp_app_id' => 'BotPasswordSessionProvider',
				'bp_password' => $passwordHash->toString(),
				'bp_token' => 'token!',
				'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
				'bp_grants' => '["test"]',
			] )
			->caller( __METHOD__ )
			->execute();
	}

	public function testConstructor() {
		$grantsInfo = $this->getServiceContainer()->getGrantsInfo();

		try {
			$provider = new BotPasswordSessionProvider( $grantsInfo );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: priority must be specified',
				$ex->getMessage()
			);
		}

		try {
			$provider = new BotPasswordSessionProvider(
				$grantsInfo,
				[
					'priority' => SessionInfo::MIN_PRIORITY - 1
				]
			);
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: Invalid priority',
				$ex->getMessage()
			);
		}

		try {
			$provider = new BotPasswordSessionProvider(
				$grantsInfo,
				[
					'priority' => SessionInfo::MAX_PRIORITY + 1
				]
			);
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame(
				'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: Invalid priority',
				$ex->getMessage()
			);
		}

		$provider = new BotPasswordSessionProvider(
			$grantsInfo,
			[ 'priority' => 40 ]
		);
		$priv = TestingAccessWrapper::newFromObject( $provider );
		$this->assertSame( 40, $priv->priority );
		$this->assertSame( '_BPsession', $priv->sessionCookieName );
		$this->assertSame( [], $priv->sessionCookieOptions );

		$provider = new BotPasswordSessionProvider(
			$grantsInfo,
			[
				'priority' => 40,
				'sessionCookieName' => null,
			]
		);
		$priv = TestingAccessWrapper::newFromObject( $provider );
		$this->assertSame( '_BPsession', $priv->sessionCookieName );

		$provider = new BotPasswordSessionProvider(
			$grantsInfo,
			[
				'priority' => 40,
				'sessionCookieName' => 'Foo',
				'sessionCookieOptions' => [ 'Bar' ],
			]
		);
		$priv = TestingAccessWrapper::newFromObject( $provider );
		$this->assertSame( 'Foo', $priv->sessionCookieName );
		$this->assertSame( [ 'Bar' ], $priv->sessionCookieOptions );
	}

	public function testBasics() {
		$provider = $this->getProvider();

		$this->assertTrue( $provider->persistsSessionId() );
		$this->assertFalse( $provider->canChangeUser() );

		$this->assertNull( $provider->newSessionInfo() );
		$this->assertNull( $provider->newSessionInfo( 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ) );
	}

	public function testProvideSessionInfo() {
		$request = new FauxRequest;
		$request->setCookie( '_BPsession', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'wgCookiePrefix' );

		$provider = $this->getProvider( null, null, false );
		$this->assertNull( $provider->provideSessionInfo( $request ) );

		$provider = $this->getProvider();

		$info = $provider->provideSessionInfo( $request );
		$this->assertInstanceOf( SessionInfo::class, $info );
		$this->assertSame( 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $info->getId() );

		$this->config->set( MainConfigNames::EnableBotPasswords, false );
		$this->assertNull( $provider->provideSessionInfo( $request ) );
		$this->config->set( MainConfigNames::EnableBotPasswords, true );

		$this->assertNull( $provider->provideSessionInfo( new FauxRequest ) );
	}

	public function testNewSessionInfoForRequest() {
		$provider = $this->getProvider();
		$user = static::getTestSysop()->getUser();
		$request = $this->getMockBuilder( FauxRequest::class )
			->onlyMethods( [ 'getIP' ] )->getMock();
		$request->method( 'getIP' )
			->willReturn( '127.0.0.1' );
		$bp = BotPassword::newFromUser( $user, 'BotPasswordSessionProvider' );

		$session = $provider->newSessionForRequest( $user, $bp, $request );
		$this->assertInstanceOf( Session::class, $session );

		$this->assertEquals( $session->getId(), $request->getSession()->getId() );
		$this->assertEquals( $user->getName(), $session->getUser()->getName() );

		$this->assertEquals( [
			'centralId' => $bp->getUserCentralId(),
			'appId' => $bp->getAppId(),
			'token' => $bp->getToken(),
			'rights' => [ 'read' ],
			'restrictions' => $bp->getRestrictions()->toJson(),
		], $session->getProviderMetadata() );

		$this->assertEquals( [ 'read' ], $session->getAllowedUserRights() );
	}

	public function testCheckSessionInfo() {
		$logger = new TestLogger( true );
		$provider = $this->getProvider();
		$this->initProvider( $provider, $logger );

		$user = static::getTestSysop()->getUser();
		$request = $this->getMockBuilder( FauxRequest::class )
			->onlyMethods( [ 'getIP' ] )->getMock();
		$request->method( 'getIP' )
			->willReturn( '127.0.0.1' );
		$bp = BotPassword::newFromUser( $user, 'BotPasswordSessionProvider' );

		$data = [
			'provider' => $provider,
			'id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
			'userInfo' => UserInfo::newFromUser( $user, true ),
			'persisted' => false,
			'metadata' => [
				'centralId' => $bp->getUserCentralId(),
				'appId' => $bp->getAppId(),
				'token' => $bp->getToken(),
			],
		];
		$dataMD = $data['metadata'];

		foreach ( $data['metadata'] as $key => $_ ) {
			$data['metadata'] = $dataMD;
			unset( $data['metadata'][$key] );
			$info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
			$metadata = $info->getProviderMetadata();

			$this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
			$this->assertSame( [
				[ LogLevel::INFO, 'Session "{session}": Missing metadata: {missing}' ]
			], $logger->getBuffer() );
			$logger->clearBuffer();
		}

		$data['metadata'] = $dataMD;
		$data['metadata']['appId'] = 'Foobar';
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
		$metadata = $info->getProviderMetadata();
		$this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
		$this->assertSame( [
			[ LogLevel::INFO, 'Session "{session}": No BotPassword for {centralId} {appId}' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		$data['metadata'] = $dataMD;
		$data['metadata']['token'] = 'Foobar';
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
		$metadata = $info->getProviderMetadata();
		$this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
		$this->assertSame( [
			[ LogLevel::INFO, 'Session "{session}": BotPassword token check failed' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		$request2 = $this->getMockBuilder( FauxRequest::class )
			->onlyMethods( [ 'getIP' ] )->getMock();
		$request2->method( 'getIP' )
			->willReturn( '10.0.0.1' );
		$data['metadata'] = $dataMD;
		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
		$metadata = $info->getProviderMetadata();
		$this->assertFalse( $provider->refreshSessionInfo( $info, $request2, $metadata ) );
		$this->assertSame( [
			[ LogLevel::INFO, 'Session "{session}": Restrictions check failed' ],
		], $logger->getBuffer() );
		$logger->clearBuffer();

		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
		$metadata = $info->getProviderMetadata();
		$this->assertTrue( $provider->refreshSessionInfo( $info, $request, $metadata ) );
		$this->assertSame( [], $logger->getBuffer() );
		$this->assertEquals( $dataMD + [ 'rights' => [ 'read' ] ], $metadata );
	}

	public function testGetAllowedUserRights() {
		$logger = new TestLogger( true );
		$provider = $this->getProvider();
		$this->initProvider( $provider, $logger );

		$backend = TestUtils::getDummySessionBackend();
		$backendPriv = TestingAccessWrapper::newFromObject( $backend );

		try {
			$provider->getAllowedUserRights( $backend );
			$this->fail( 'Expected exception not thrown' );
		} catch ( InvalidArgumentException $ex ) {
			$this->assertSame( 'Backend\'s provider isn\'t $this', $ex->getMessage() );
		}

		$backendPriv->provider = $provider;
		$backendPriv->providerMetadata = [ 'rights' => [ 'foo', 'bar', 'baz' ] ];
		$this->assertSame( [ 'foo', 'bar', 'baz' ], $provider->getAllowedUserRights( $backend ) );
		$this->assertSame( [], $logger->getBuffer() );

		$backendPriv->providerMetadata = [ 'foo' => 'bar' ];
		$this->assertSame( [], $provider->getAllowedUserRights( $backend ) );
		$this->assertSame( [
			[
				LogLevel::DEBUG,
				'MediaWiki\\Session\\BotPasswordSessionProvider::getAllowedUserRights: ' .
					'No provider metadata, returning no rights allowed'
			]
		], $logger->getBuffer() );
		$logger->clearBuffer();

		$backendPriv->providerMetadata = [ 'rights' => 'bar' ];
		$this->assertSame( [], $provider->getAllowedUserRights( $backend ) );
		$this->assertSame( [
			[
				LogLevel::DEBUG,
				'MediaWiki\\Session\\BotPasswordSessionProvider::getAllowedUserRights: ' .
					'No provider metadata, returning no rights allowed'
			]
		], $logger->getBuffer() );
		$logger->clearBuffer();

		$backendPriv->providerMetadata = null;
		$this->assertSame( [], $provider->getAllowedUserRights( $backend ) );
		$this->assertSame( [
			[
				LogLevel::DEBUG,
				'MediaWiki\\Session\\BotPasswordSessionProvider::getAllowedUserRights: ' .
					'No provider metadata, returning no rights allowed'
			]
		], $logger->getBuffer() );
		$logger->clearBuffer();
	}
}
PK       ! ѿ2  2    session/TestBagOStuff.phpnu Iw        <?php

namespace MediaWiki\Tests\Session;

use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use Wikimedia\ObjectCache\CachedBagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;

/**
 * BagOStuff with utility functions for MediaWiki\\Session\\* testing
 */
class TestBagOStuff extends CachedBagOStuff {

	public function __construct() {
		parent::__construct( new HashBagOStuff );
	}

	/**
	 * @param string $id Session ID
	 * @param array $data Session data
	 */
	public function setSessionData( $id, array $data ) {
		$this->setSession( $id, [ 'data' => $data ] );
	}

	/**
	 * @param string $id Session ID
	 * @param array $metadata Session metadata
	 */
	public function setSessionMeta( $id, array $metadata ) {
		$this->setSession( $id, [ 'metadata' => $metadata ] );
	}

	/**
	 * @param string $id Session ID
	 * @param array $blob Session metadata and data
	 */
	public function setSession( $id, array $blob ) {
		$blob += [
			'data' => [],
			'metadata' => [],
		];
		$blob['metadata'] += [
			'userId' => 0,
			'userName' => null,
			'userToken' => null,
			'provider' => 'DummySessionProvider',
		];

		$this->setRawSession( $id, $blob );
	}

	/**
	 * @param string $id Session ID
	 * @param array|mixed $blob Session metadata and data
	 */
	public function setRawSession( $id, $blob ) {
		$expiry = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::ObjectCacheSessionExpiry );
		$this->set( $this->makeKey( 'MWSession', $id ), $blob, $expiry );
	}

	/**
	 * @param string $id Session ID
	 * @return mixed
	 */
	public function getSession( $id ) {
		return $this->get( $this->makeKey( 'MWSession', $id ) );
	}

	/**
	 * @param string $id Session ID
	 * @return mixed
	 */
	public function getSessionFromBackend( $id ) {
		return $this->store->get( $this->makeKey( 'MWSession', $id ) );
	}

	/**
	 * @param string $id Session ID
	 */
	public function deleteSession( $id ) {
		$this->delete( $this->makeKey( 'MWSession', $id ) );
	}

}

/** @deprecated class alias since 1.42 */
class_alias( TestBagOStuff::class, 'MediaWiki\\Session\\TestBagOStuff' );
PK       ! VprL
  L
    page/WikiFilePageTest.phpnu Iw        <?php

use MediaWiki\Linker\LinkTarget;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;

/**
 * @covers \WikiFilePage
 * @group Database
 */
class WikiFilePageTest extends MediaWikiLangTestCase {

	public static function provideFollowRedirect() {
		yield 'local nonexisting' => [ null, [ 'exists' => false ], false ];
		yield 'local existing' => [ 'Bla bla', [], false ];
		yield 'local redirect' => [
			'#REDIRECT [[Image:Target.png]]',
			[],
			new TitleValue( NS_FILE, 'Target.png' ),
		];

		yield 'remote nonexisting' => [ null,
			[
				'isLocal' => false,
				'exists' => false,
			],
			false,
		];
		yield 'remote existing' => [
			null,
			[ 'isLocal' => false, ],
			false,
		];
		yield 'remote redirect' => [
			null,
			[
				'isLocal' => false,
				'redirectedFrom' => 'Test.png',
				'name' => 'Target.png',
			],
			new TitleValue( NS_FILE, 'Target.png' ),
		];
	}

	/**
	 * @dataProvider provideFollowRedirect
	 */
	public function testFollowRedirect( ?string $content, array $fileProps, $expected ) {
		$fileProps += [ 'name' => 'Test.png' ];
		$this->installMockFileRepo( $fileProps );

		if ( $content === null ) {
			$pageIdentity = $this->getNonexistingTestPage( 'Image:Test.png' );
		} else {
			$status = $this->editPage( 'Image:Test.png', $content );
			$pageIdentity = $status->getNewRevision()->getPage();
		}

		$page = new WikiFilePage( Title::newFromPageIdentity( $pageIdentity ) );
		$target = $page->followRedirect();

		if ( $expected instanceof LinkTarget ) {
			$this->assertTrue( $expected->isSameLinkAs( $target ) );
		} else {
			$this->assertSame( $expected, $target );
		}
	}

	private function installMockFileRepo( array $props = [] ): void {
		$repo = $this->createNoOpMock(
			FileRepo::class,
			[]
		);
		$file = $this->createNoOpMock(
			File::class,
			[
				'isLocal',
				'exists',
				'getRepo',
				'getRedirected',
				'getName',
			]
		);
		$file->method( 'isLocal' )->willReturn( $props['isLocal'] ?? true );
		$file->method( 'exists' )->willReturn( $props['exists'] ?? true );
		$file->method( 'getRepo' )->willReturn( $repo );
		$file->method( 'getRedirected' )->willReturn( $props['redirectedFrom'] ?? null );
		$file->method( 'getName' )->willReturn( $props['name'] ?? 'Test.png' );

		$localRepo = $this->createNoOpMock(
			FileRepo::class,
			[ 'invalidateImageRedirect' ]
		);

		$repoGroup = $this->createNoOpMock(
			RepoGroup::class,
			[ 'findFile', 'getLocalRepo' ]
		);
		$repoGroup->method( 'getLocalRepo' )->willReturn( $localRepo );
		$repoGroup->method( 'findFile' )->willReturn( $file );

		$this->setService(
			'RepoGroup',
			$repoGroup
		);
	}

}
PK       ! V+!  +!    page/ArticleTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\MainConfigSchema;
use MediaWiki\Message\Message;
use MediaWiki\Page\ParserOutputAccess;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use MediaWiki\User\User;

/**
 * @group Database
 */
class ArticleTest extends \MediaWikiIntegrationTestCase {

	/**
	 * @param Title $title
	 * @param User|null $user
	 *
	 * @return Article
	 */
	private function newArticle( Title $title, ?User $user = null ): Article {
		if ( !$user ) {
			$user = $this->getTestUser()->getUser();
		}

		$context = new RequestContext();
		$article = new Article( $title );
		$context->setUser( $user );
		$context->setTitle( $title );
		$article->setContext( $context );

		return $article;
	}

	/**
	 * @covers \Article::__sleep
	 */
	public function testSerialization_fails() {
		$article = new Article( Title::newMainPage() );

		$this->expectException( LogicException::class );
		serialize( $article );
	}

	/**
	 * Tests that missing article page shows parser contents
	 * of the well-known system message for NS_MEDIAWIKI pages
	 * @covers \Article::showMissingArticle
	 */
	public function testMissingArticleMessage() {
		// Use a well-known system message
		$title = Title::makeTitle( NS_MEDIAWIKI, 'Uploadedimage' );
		$article = $this->newArticle( $title );

		$article->showMissingArticle();
		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString(
			Message::newFromKey( 'uploadedimage' )->parse(),
			$output->getHTML()
		);
	}

	/**
	 * Test if patrol footer is possible to show
	 * @covers \Article::showPatrolFooter
	 * @dataProvider provideShowPatrolFooter
	 */
	public function testShowPatrolFooter( $group, $title, $editPageText, $isEditedBySameUser, $expectedResult ) {
		$testPage = $this->getNonexistingTestPage( $title );
		$user1 = $this->getTestUser( $group )->getUser();
		$user2 = $this->getTestUser()->getUser();
		if ( $editPageText !== null ) {
			$editedUser = $isEditedBySameUser ? $user1 : $user2;
			$editIsGood = $this->editPage( $testPage, $editPageText, '', NS_MAIN, $editedUser )->isGood();
			$this->assertTrue( $editIsGood, 'edited a page' );
		}

		$article = $this->newArticle( $title, $user1 );
		$this->assertSame( $expectedResult, $article->showPatrolFooter() );
	}

	public static function provideShowPatrolFooter() {
		yield 'UserAllowedRevExist' => [
			'sysop',
			Title::makeTitle( NS_MAIN, 'Page1' ),
			'EditPage1',
			false,
			true
		];

		yield 'UserNotAllowedRevExist' => [
			null,
			Title::makeTitle( NS_MAIN, 'Page2' ),
			'EditPage2',
			false,
			false
		];

		yield 'UserAllowedNoRev' => [
			'sysop',
			Title::makeTitle( NS_MAIN, 'Page3' ),
			null,
			false,
			false
		];

		yield 'UserAllowedRevExistBySameUser' => [
			'sysop',
			Title::makeTitle( NS_MAIN, 'Page4' ),
			'EditPage4',
			true,
			false
		];
	}

	/**
	 * Show patrol footer even if the page was moved (T162871).
	 *
	 * @covers \Article::showPatrolFooter
	 */
	public function testShowPatrolFooterMovedPage() {
		$oldTitle = Title::makeTitle( NS_USER, 'NewDraft' );
		$newTitle = Title::makeTitle( NS_MAIN, 'NewDraft' );
		$editor = $this->getTestUser()->getUser();

		$editIsGood = $this->editPage( $oldTitle, 'Content', '', NS_USER, $editor )->isGood();
		$this->assertTrue( $editIsGood, 'edited a page' );

		$status = $this->getServiceContainer()
			->getMovePageFactory()
			->newMovePage( $oldTitle, $newTitle )
			->move( $this->getTestUser()->getUser() );
		$this->assertTrue( $status->isOK() );

		$sysop = $this->getTestUser( 'sysop' )->getUser();
		$article = $this->newArticle( $newTitle, $sysop );

		$this->assertTrue( $article->showPatrolFooter() );
	}

	/**
	 * Ensure that content that is present in the parser cache will be used.
	 *
	 * @covers \Article::generateContentOutput
	 */
	public function testUsesCachedOutput() {
		$title = $this->getExistingTestPage()->getTitle();

		$parserOutputAccess = $this->createNoOpMock( ParserOutputAccess::class, [ 'getCachedParserOutput' ] );
		$parserOutputAccess->method( 'getCachedParserOutput' )
			->willReturn( new ParserOutput( 'Kittens' ) );

		$this->setService( 'ParserOutputAccess', $parserOutputAccess );

		$article = $this->newArticle( $title );
		$article->view();
		$this->assertStringContainsString( 'Kittens', $article->getContext()->getOutput()->getHTML() );
	}

	/**
	 * Ensure that content that is present in the parser cache will be used.
	 *
	 * @covers \Article::generateContentOutput
	 */
	public function testOutputIsCached() {
		$this->overrideConfigValue(
			MainConfigNames::ParsoidCacheConfig,
			[ 'WarmParsoidParserCache' => true ]
			+ MainConfigSchema::getDefaultValue( MainConfigNames::ParsoidCacheConfig )
		);
		$title = $this->getExistingTestPage()->getTitle();
		// Run any jobs enqueued by the creation of the test page
		$this->runJobs( [ 'minJobs' => 0 ] );

		$beforePreWarm = true;
		$parserOutputAccess = $this->createNoOpMock(
			ParserOutputAccess::class,
			[ 'getCachedParserOutput', 'getParserOutput', ]
		);
		$parserOutputAccess->method( 'getCachedParserOutput' )
			->willReturn( null );
		$parserOutputAccess
			->expects( $this->exactly( 2 ) ) // This is the key assertion in this test case.
			->method( 'getParserOutput' )
			->with(
				$this->anything(),
				$this->callback( function ( ParserOptions $parserOptions ) use ( &$beforePreWarm ) {
					$expectedReason = $beforePreWarm ? 'page-view' : 'view';
					$this->assertSame( $expectedReason, $parserOptions->getRenderReason() );
					return true;
				} ),
				$this->anything(),
				$this->callback( function ( $options ) use ( &$beforePreWarm ) {
					if ( $beforePreWarm ) {
						$this->assertTrue( (bool)( $options & ParserOutputAccess::OPT_NO_CHECK_CACHE ),
							"The cache is not checked again" );
						$this->assertTrue( (bool)( $options & ParserOutputAccess::OPT_LINKS_UPDATE ),
							"WikiPage::triggerOpportunisticLinksUpdate is attempted" );
					}
					return true;
				} )
			)
			->willReturnCallback( static function ( $page, $parserOptions, $revision, $options ) use ( &$beforePreWarm ) {
				$content = $beforePreWarm ? 'Old Kittens' : 'New Kittens';
				return Status::newGood( new ParserOutput( $content ) );
			} );

		$this->setService( 'ParserOutputAccess', $parserOutputAccess );

		$article = $this->newArticle( $title );
		$article->view();

		$beforePreWarm = false;
		$this->runJobs( [ 'minJobs' => 1, 'maxJobs' => 1 ], [ 'type' => 'parsoidCachePrewarm' ] );

		// This is just a sanity check, not the key assertion.
		$this->assertStringContainsString( 'Old Kittens', $article->getContext()->getOutput()->getHTML() );
	}

	/**
	 * Ensure that protection indicators are shown when the page is protected.
	 * @covers \Article::showProtectionIndicator
	 */
	public function testShowProtectionIndicator() {
		$this->overrideConfigValue(
			MainConfigNames::EnableProtectionIndicators,
			true
		);
		$title = $this->getExistingTestPage()->getTitle();
		$article = $this->newArticle( $title );

		$wikiPage = new WikiPage( $title );
		$cascade = false;
		$wikiPage->doUpdateRestrictions( [
				'edit' => 'autoconfirmed',
			],
			[ 'edit' => 'infinity' ],
			$cascade,
			'Test reason',
			$this->getTestSysop()->getUser()
		);

		$article->showProtectionIndicator();
		$output = $article->getContext()->getOutput();
		$this->assertArrayHasKey( 'protection-autoconfirmed', $output->getIndicators(), 'Protection indicators are shown when a page is protected' );

		$templateTitle = Title::newFromText( 'CascadeProtectionTest', NS_TEMPLATE );
		$this->editPage( $templateTitle, 'Some text here', 'Test', NS_TEMPLATE, $this->getTestSysop()->getUser() );
		$articleTitle = $this->getExistingTestPage()->getTitle();
		$this->editPage( $articleTitle, '{{CascadeProtectionTest}}', 'Test', NS_MAIN, $this->getTestSysop()->getUser() );
		$wikiPage = new WikiPage( $articleTitle );
		$cascade = true;
		$wikiPage->doUpdateRestrictions( [
				'edit' => 'sysop',
			],
			[ 'edit' => 'infinity' ],
			$cascade,
			'Test reason',
			$this->getTestSysop()->getUser()
		);

		$template = $this->newArticle( $templateTitle );

		$template->showProtectionIndicator();
		$output = $template->getContext()->getOutput();
		$this->assertArrayHasKey(
			'protection-sysop-cascade',
			$output->getIndicators(),
			'Protection indicators are shown when a page protected using cascade protection'
		);
	}
}
PK       ! 	.(~"  ~"    page/MergeHistoryTest.phpnu Iw        <?php

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Page\MergeHistory;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Title\Title;
use MediaWiki\Utils\MWTimestamp;

/**
 * @group Database
 */
class MergeHistoryTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;

	/**
	 * Make some pages to work with
	 */
	public function addDBDataOnce() {
		// Pages that won't actually be merged
		$this->insertPage( 'Test' );
		$this->insertPage( 'Test2' );

		// Pages that will be merged
		$this->insertPage( 'Merge1' );
		$this->insertPage( 'Merge2' );

		// Exclusive for testSourceUpdateForNoRedirectSupport()
		$this->insertPage( 'Merge3' );
		$this->insertPage( 'Merge4' );

		// Exclusive for testSourceUpdateWithRedirectSupport()
		$this->insertPage( 'Merge5' );
		$this->insertPage( 'Merge6' );
	}

	/**
	 * @dataProvider provideIsValidMerge
	 * @covers \MediaWiki\Page\MergeHistory::isValidMerge
	 * @param string $source Source page
	 * @param string $dest Destination page
	 * @param string|bool $timestamp Timestamp up to which revisions are merged (or false for all)
	 * @param string|bool $error Expected error for test (or true for no error)
	 */
	public function testIsValidMerge( $source, $dest, $timestamp, $error ) {
		if ( $timestamp === true ) {
			// Although this timestamp is after the latest timestamp of both pages,
			// MergeHistory should select the latest source timestamp up to this which should
			// still work for the merge.
			$timestamp = time() + ( 24 * 3600 );
		}
		$factory = $this->getServiceContainer()->getMergeHistoryFactory();
		$mh = $factory->newMergeHistory(
			Title::newFromText( $source ),
			Title::newFromText( $dest ),
			$timestamp
		);
		$status = $mh->isValidMerge();
		if ( $error === true ) {
			$this->assertStatusGood( $status );
		} else {
			$this->assertStatusError( $error, $status );
		}
	}

	public static function provideIsValidMerge() {
		return [
			// for MergeHistory::isValidMerge
			[ 'Test', 'Test2', false, true ],
			// Timestamp of `true` is a placeholder for "in the future""
			[ 'Test', 'Test2', true, true ],
			[ 'Test', 'Test', false, 'mergehistory-fail-self-merge' ],
			[ 'Nonexistant', 'Test2', false, 'mergehistory-fail-invalid-source' ],
			[ 'Test', 'Nonexistant', false, 'mergehistory-fail-invalid-dest' ],
			[
				'Test',
				'Test2',
				'This is obviously an invalid timestamp',
				'mergehistory-fail-bad-timestamp'
			],
		];
	}

	/**
	 * Test merge revision limit checking
	 * @covers \MediaWiki\Page\MergeHistory::isValidMerge
	 */
	public function testIsValidMergeRevisionLimit() {
		$limit = MergeHistory::REVISION_LIMIT;
		$mh = $this->getMockBuilder( MergeHistory::class )
			->onlyMethods( [ 'getRevisionCount' ] )
			->setConstructorArgs( [
				Title::makeTitle( NS_MAIN, 'Test' ),
				Title::makeTitle( NS_MAIN, 'Test2' ),
				null,
				$this->getServiceContainer()->getConnectionProvider(),
				$this->getServiceContainer()->getContentHandlerFactory(),
				$this->getServiceContainer()->getRevisionStore(),
				$this->getServiceContainer()->getWatchedItemStore(),
				$this->getServiceContainer()->getSpamChecker(),
				$this->getServiceContainer()->getHookContainer(),
				$this->getServiceContainer()->getWikiPageFactory(),
				$this->getServiceContainer()->getTitleFormatter(),
				$this->getServiceContainer()->getTitleFactory(),
				$this->getServiceContainer()->getLinkTargetLookup(),
				$this->getServiceContainer()->getDeletePageFactory(),
			] )
			->getMock();
		$mh->expects( $this->once() )
			->method( 'getRevisionCount' )
			->willReturn( $limit + 1 );

		$status = $mh->isValidMerge();

		$this->assertStatusNotOK( $status );
		$this->assertStatusMessagesExactly(
			StatusValue::newFatal( 'mergehistory-fail-toobig', Message::numParam( $limit ) ),
			$status
		);
	}

	/**
	 * Test user permission checking
	 * @covers \MediaWiki\Page\MergeHistory::authorizeMerge
	 * @covers \MediaWiki\Page\MergeHistory::probablyCanMerge
	 */
	public function testCheckPermissions() {
		$factory = $this->getServiceContainer()->getMergeHistoryFactory();
		$mh = $factory->newMergeHistory(
			Title::makeTitle( NS_MAIN, 'Test' ),
			Title::makeTitle( NS_MAIN, 'Test2' )
		);

		foreach ( [ 'authorizeMerge', 'probablyCanMerge' ] as $method ) {
			// Sysop with mergehistory permission
			$status = $mh->$method(
				$this->mockRegisteredUltimateAuthority(),
				''
			);
			$this->assertStatusOK( $status );

			$status = $mh->$method(
				$this->mockRegisteredAuthorityWithoutPermissions( [ 'mergehistory' ] ),
				''
			);
			$this->assertStatusError( 'mergehistory-fail-permission', $status );
		}
	}

	/**
	 * Test merged revision count
	 * @covers \MediaWiki\Page\MergeHistory::getMergedRevisionCount
	 */
	public function testGetMergedRevisionCount() {
		$factory = $this->getServiceContainer()->getMergeHistoryFactory();
		$mh = $factory->newMergeHistory(
			Title::makeTitle( NS_MAIN, 'Merge1' ),
			Title::makeTitle( NS_MAIN, 'Merge2' )
		);

		$sysop = static::getTestSysop()->getUser();
		$mh->merge( $sysop );
		$this->assertSame( 1, $mh->getMergedRevisionCount() );
	}

	/**
	 * Test update to source page for pages with
	 * content model that supports redirects
	 *
	 * @covers \MediaWiki\Page\MergeHistory::merge
	 */
	public function testSourceUpdateWithRedirectSupport() {
		$title = Title::makeTitle( NS_MAIN, 'Merge5' );
		$title2 = Title::makeTitle( NS_MAIN, 'Merge6' );

		$factory = $this->getServiceContainer()->getMergeHistoryFactory();
		$mh = $factory->newMergeHistory( $title, $title2 );

		$this->assertTrue( $title->exists() );

		$status = $mh->merge( static::getTestSysop()->getUser() );
		$this->assertStatusOK( $status );

		$this->assertTrue( $title->exists() );
	}

	/**
	 * Test update to source page for pages with
	 * content model that does not support redirects
	 *
	 * @covers \MediaWiki\Page\MergeHistory::merge
	 */
	public function testSourceUpdateForNoRedirectSupport() {
		$this->overrideConfigValues( [
			MainConfigNames::ExtraNamespaces => [
				2030 => 'NoRedirect',
				2031 => 'NoRedirect_talk'
			],

			MainConfigNames::NamespaceContentModels => [
				2030 => 'testing'
			],
			MainConfigNames::ContentHandlers => [
				// Relies on the DummyContentHandlerForTesting not
				// supporting redirects by default. If this ever gets
				// changed this test has to be fixed.
				'testing' => DummyContentHandlerForTesting::class
			]
		] );

		$title = Title::makeTitle( NS_MAIN, 'Merge3' );
		$title->setContentModel( 'testing' );
		$title2 = Title::makeTitle( NS_MAIN, 'Merge4' );
		$title2->setContentModel( 'testing' );

		$factory = $this->getServiceContainer()->getMergeHistoryFactory();
		$mh = $factory->newMergeHistory( $title, $title2 );

		$this->assertTrue( $title->exists() );

		$status = $mh->merge( static::getTestSysop()->getUser() );
		$this->assertStatusOK( $status );

		$this->assertFalse( $title->exists() );
	}

	/**
	 * @covers \MediaWiki\Page\MergeHistory::initTimestampLimits
	 */
	public function testSplitTimestamp() {
		// Create the source page with two revisions with the same timestamp
		$user = static::getTestSysop()->getUser();
		$title1 = $this->insertPage( "Merge7" )["title"];
		$timestamp = MWTimestamp::now( TS_MW );
		$store = $this->getServiceContainer()->getRevisionStore();
		$revision = MutableRevisionRecord::newFromParentRevision( $store->getFirstRevision( $title1 ) );
		$revision->setTimestamp( $timestamp );
		$revision->setComment( CommentStoreComment::newUnsavedComment( "testing" ) );
		$revision->setUser( $user );
		$dbw = $this->getDB();
		$revid1 = $store->insertRevisionOn( $revision, $dbw )->getID();

		$revision2 = MutableRevisionRecord::newFromParentRevision( $store->getFirstRevision( $title1 ) );
		$revision2->setTimestamp( $timestamp );
		$revision2->setComment( CommentStoreComment::newUnsavedComment( "testing" ) );
		$revision2->setUser( $user );
		$revid2 = $store->insertRevisionOn( $revision2, $dbw )->getID();
		// Create the destination page (here to ensure its timestamp is the same or later than the above)
		$title2 = $this->insertPage( "Merge8" )["title"];

		// Now do the merge
		$factory = $this->getServiceContainer()->getMergeHistoryFactory();
		$mh = $factory->newMergeHistory( $title1, $title2, $timestamp . '|' . $revid1 );
		$status = $mh->merge( $user );
		$this->assertStatusOK( $status );

		$this->assertNull( $store->getRevisionByPageId( $title1->getId(), $revid1 ) );
		$this->assertNotNull( $store->getRevisionByPageId( $title1->getId(), $revid2 ) );
		$this->assertNotNull( $store->getRevisionByPageId( $title2->getId(), $revid1 ) );
		$this->assertNull( $store->getRevisionByPageId( $title2->getId(), $revid2 ) );
	}
}
PK       ! L      page/WikiPageDbTest.phpnu Iw        <?php

use MediaWiki\Category\Category;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\Content;
use MediaWiki\Content\ContentHandler;
use MediaWiki\Content\Renderer\ContentRenderer;
use MediaWiki\Content\TextContent;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Deferred\SiteStatsUpdate;
use MediaWiki\Edit\PreparedEdit;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\RevisionSlotsUpdate;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\Utils\MWTimestamp;
use PHPUnit\Framework\Assert;
use Wikimedia\Rdbms\IDBAccessObject;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \WikiPage
 * @group Database
 */
class WikiPageDbTest extends MediaWikiLangTestCase {
	use DummyServicesTrait;
	use MockAuthorityTrait;
	use TempUserTestTrait;

	protected function tearDown(): void {
		ParserOptions::clearStaticCache();
		parent::tearDown();
	}

	/**
	 * @param Title|string $title
	 * @param string|null $model
	 * @return WikiPage
	 */
	private function newPage( $title, $model = null ) {
		if ( is_string( $title ) ) {
			$ns = $this->getDefaultWikitextNS();
			$title = Title::newFromText( $title, $ns );
		}

		return new WikiPage( $title );
	}

	/**
	 * @param string|Title|WikiPage $page
	 * @param string|Content|Content[] $content
	 * @param int|null $model
	 * @param Authority|null $performer
	 *
	 * @return WikiPage
	 */
	protected function createPage( $page, $content, $model = null, ?Authority $performer = null ) {
		if ( is_string( $page ) || $page instanceof Title ) {
			$page = $this->newPage( $page, $model );
		}

		$performer ??= $this->getTestUser()->getUser();

		if ( is_string( $content ) ) {
			$content = ContentHandler::makeContent( $content, $page->getTitle(), $model );
		}

		if ( !is_array( $content ) ) {
			$content = [ SlotRecord::MAIN => $content ];
		}

		$updater = $page->newPageUpdater( $performer );

		foreach ( $content as $role => $cnt ) {
			$updater->setContent( $role, $cnt );
		}

		$updater->saveRevision( CommentStoreComment::newUnsavedComment( "testing" ) );
		if ( !$updater->wasSuccessful() ) {
			$this->fail( $updater->getStatus()->getWikiText() );
		}

		return $page;
	}

	public function testSerialization_fails() {
		$this->expectException( LogicException::class );
		$page = $this->createPage( __METHOD__, __METHOD__ );
		serialize( $page );
	}

	public static function provideTitlesThatCannotExist() {
		yield 'Special' => [ NS_SPECIAL, 'Recentchanges' ]; // existing special page
		yield 'Invalid character' => [ NS_MAIN, '#' ]; // bad character
	}

	/**
	 * @dataProvider provideTitlesThatCannotExist
	 */
	public function testConstructionWithPageThatCannotExist( $ns, $text ) {
		$title = Title::makeTitle( $ns, $text );
		$this->expectException( InvalidArgumentException::class );
		new WikiPage( $title );
	}

	public function testPrepareContentForEdit() {
		$performer = $this->mockUserAuthorityWithPermissions(
			$this->getTestUser()->getUserIdentity(),
			[ 'edit' ]
		);
		$sysop = $this->getTestUser( [ 'sysop' ] )->getUserIdentity();

		$page = $this->createPage( __METHOD__, __METHOD__, null, $performer );
		$title = $page->getTitle();

		$content = ContentHandler::makeContent(
			"[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam "
			. " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.",
			$title,
			CONTENT_MODEL_WIKITEXT
		);
		$content2 = ContentHandler::makeContent(
			"At vero eos et accusam et justo duo [[dolores]] et ea rebum. "
			. "Stet clita kasd [[gubergren]], no sea takimata sanctus est. ~~~~",
			$title,
			CONTENT_MODEL_WIKITEXT
		);

		$edit = $page->prepareContentForEdit( $content, null, $performer->getUser(), null, false );

		$this->assertInstanceOf(
			ParserOptions::class,
			$edit->popts,
			"pops"
		);
		$this->assertStringContainsString( '</a>', $edit->output->getRawText(), "output" );
		$this->assertStringContainsString(
			'consetetur sadipscing elitr',
			$edit->output->getRawText(), "output"
		);

		$this->assertTrue( $content->equals( $edit->newContent ), "newContent field" );
		$this->assertTrue( $content->equals( $edit->pstContent ), "pstContent field" );
		$this->assertSame( $edit->output, $edit->output, "output field" );
		$this->assertSame( $edit->popts, $edit->popts, "popts field" );
		$this->assertSame( null, $edit->revid, "revid field" );

		// PreparedUpdate matches PreparedEdit
		$update = $page->getCurrentUpdate();
		$this->assertSame( $edit->output, $update->getCanonicalParserOutput() );

		// Re-using the prepared info if possible
		$sameEdit = $page->prepareContentForEdit( $content, null, $performer->getUser(), null, false );
		$this->assertPreparedEditEquals( $edit, $sameEdit, 'equivalent PreparedEdit' );
		$this->assertSame( $edit->pstContent, $sameEdit->pstContent, 're-use output' );
		$this->assertSame( $edit->output, $sameEdit->output, 're-use output' );

		// re-using the same PreparedUpdate
		$this->assertSame( $update, $page->getCurrentUpdate() );

		// Not re-using the same PreparedEdit if not possible
		$edit2 = $page->prepareContentForEdit( $content2, null, $performer->getUser(), null, false );
		$this->assertPreparedEditNotEquals( $edit, $edit2 );
		$this->assertStringContainsString( 'At vero eos', $edit2->pstContent->serialize(), "content" );

		// Not re-using the same PreparedUpdate
		$this->assertNotSame( $update, $page->getCurrentUpdate() );

		// Check pre-safe transform
		$this->assertStringContainsString( '[[gubergren]]', $edit2->pstContent->serialize() );
		$this->assertStringNotContainsString( '~~~~', $edit2->pstContent->serialize() );

		$edit3 = $page->prepareContentForEdit( $content2, null, $sysop, null, false );
		$this->assertPreparedEditNotEquals( $edit2, $edit3 );

		// TODO: test with passing revision, then same without revision.
	}

	public function testDoEditUpdates() {
		$user = $this->getTestUser()->getUserIdentity();

		// NOTE: if site stats get out of whack and drop below 0,
		// that causes a DB error during tear-down. So bump the
		// numbers high enough to not drop below 0.
		$siteStatsUpdate = SiteStatsUpdate::factory(
			[ 'edits' => 1000, 'articles' => 1000, 'pages' => 1000 ]
		);
		$siteStatsUpdate->doUpdate();

		$page = $this->createPage( __METHOD__, __METHOD__ );

		$comment = CommentStoreComment::newUnsavedComment( __METHOD__ );

		// PST turns [[|foo]] into [[foo]]
		$content = $this->getServiceContainer()
			->getContentHandlerFactory()
			->getContentHandler( CONTENT_MODEL_WIKITEXT )
			->unserializeContent( __METHOD__ . ' [[|foo]][[bar]]' );

		$revRecord = new MutableRevisionRecord( $page->getTitle() );
		$revRecord->setContent( SlotRecord::MAIN, $content );
		$revRecord->setUser( $user );
		$revRecord->setTimestamp( '20170707040404' );
		$revRecord->setPageId( $page->getId() );
		$revRecord->setId( 9989 );
		$revRecord->setMinorEdit( true );
		$revRecord->setComment( $comment );

		$page->doEditUpdates( $revRecord, $user );

		// TODO: test various options; needs temporary hooks

		$res = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'pagelinks' )
			->where( [ 'pl_from' => $page->getId() ] )
			->fetchResultSet();
		$n = $res->numRows();
		$res->free();

		$this->assertSame( 1, $n, 'pagelinks should contain only one link if PST was not applied' );
	}

	public function testDoUserEditContent() {
		$this->overrideConfigValue( MainConfigNames::PageCreationLog, true );

		$page = $this->newPage( __METHOD__ );
		$title = $page->getTitle();

		$user1 = $this->getTestUser()->getUser();
		// Use the confirmed group for user2 to make sure the user is different
		$user2 = $this->getTestUser( [ 'confirmed' ] )->getUser();

		$content = ContentHandler::makeContent(
			"[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam "
				. " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.",
			$title,
			CONTENT_MODEL_WIKITEXT
		);

		$preparedEditBefore = $page->prepareContentForEdit( $content, null, $user1 );

		$status = $page->doUserEditContent( $content, $user1, "[[testing]] 1", EDIT_NEW );

		$this->assertStatusGood( $status );
		$this->assertTrue( $status->value['new'], 'new' );
		$this->assertNotNull( $status->getNewRevision(), 'revision-record' );

		$statusRevRecord = $status->getNewRevision();
		$this->assertSame( $statusRevRecord->getId(), $page->getRevisionRecord()->getId() );
		$this->assertSame( $statusRevRecord->getSha1(), $page->getRevisionRecord()->getSha1() );
		$this->assertTrue(
			$statusRevRecord->getContent( SlotRecord::MAIN )->equals( $content ),
			'equals'
		);

		$revRecord = $page->getRevisionRecord();
		$recentChange = $this->getServiceContainer()
			->getRevisionStore()
			->getRecentChange( $revRecord );
		$preparedEditAfter = $page->prepareContentForEdit( $content, $revRecord, $user1 );

		$this->assertNotNull( $recentChange );
		$this->assertSame( $revRecord->getId(), (int)$recentChange->getAttribute( 'rc_this_oldid' ) );

		// make sure that cached ParserOutput gets re-used throughout
		$this->assertSame( $preparedEditBefore->output, $preparedEditAfter->output );

		$id = $page->getId();

		// Test page creation logging
		$this->newSelectQueryBuilder()
			->select( [ 'log_type', 'log_action' ] )
			->from( 'logging' )
			->where( [ 'log_page' => $id ] )
			->assertResultSet( [ [ 'create', 'create' ] ] );

		$this->assertGreaterThan( 0, $title->getArticleID(), "Title object should have new page id" );
		$this->assertGreaterThan( 0, $id, "WikiPage should have new page id" );
		$this->assertTrue( $title->exists(), "Title object should indicate that the page now exists" );
		$this->assertTrue( $page->exists(), "WikiPage object should indicate that the page now exists" );

		# ------------------------
		$res = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'pagelinks' )
			->where( [ 'pl_from' => $id ] )
			->fetchResultSet();
		$n = $res->numRows();
		$res->free();

		$this->assertSame( 1, $n, 'pagelinks should contain one link from the page' );

		# ------------------------
		$page = new WikiPage( $title );

		$retrieved = $page->getContent();
		$this->assertTrue( $content->equals( $retrieved ), 'retrieved content doesn\'t equal original' );

		# ------------------------
		$page = new WikiPage( $title );

		// try null edit, with a different user
		$status = $page->doUserEditContent( $content, $user2, 'This changes nothing', EDIT_UPDATE, false );
		$this->assertStatusWarning( 'edit-no-change', $status );
		$this->assertFalse( $status->value['new'], 'new' );
		$this->assertNull( $status->getNewRevision(), 'revision-record' );
		$this->assertNotNull( $page->getRevisionRecord() );
		$this->assertTrue(
			$page->getRevisionRecord()->getContent( SlotRecord::MAIN )->equals( $content ),
			'equals'
		);

		# ------------------------
		$content = ContentHandler::makeContent(
			"At vero eos et accusam et justo duo [[dolores]] et ea rebum. "
				. "Stet clita kasd [[gubergren]], no sea takimata sanctus est. ~~~~",
			$title,
			CONTENT_MODEL_WIKITEXT
		);

		$status = $page->doUserEditContent( $content, $user1, "testing 2", EDIT_UPDATE );
		$this->assertStatusGood( $status );
		$this->assertFalse( $status->value['new'], 'new' );
		$this->assertNotNull( $status->getNewRevision(), 'revision-record' );
		$statusRevRecord = $status->getNewRevision();
		$this->assertSame( $statusRevRecord->getId(), $page->getRevisionRecord()->getId() );
		$this->assertSame( $statusRevRecord->getSha1(), $page->getRevisionRecord()->getSha1() );
		$this->assertFalse(
			$statusRevRecord->getContent( SlotRecord::MAIN )->equals( $content ),
			'not equals (PST must substitute signature)'
		);

		$revRecord = $page->getRevisionRecord();
		$recentChange = $this->getServiceContainer()
			->getRevisionStore()
			->getRecentChange( $revRecord );
		$this->assertNotNull( $recentChange );
		$this->assertSame( $revRecord->getId(), (int)$recentChange->getAttribute( 'rc_this_oldid' ) );

		# ------------------------
		$page = new WikiPage( $title );

		$retrieved = $page->getContent();
		$newText = $retrieved->serialize();
		$this->assertStringContainsString( '[[gubergren]]', $newText, 'New text must replace old text.' );
		$this->assertStringNotContainsString( '~~~~', $newText, 'PST must substitute signature.' );

		# ------------------------
		$res = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'pagelinks' )
			->where( [ 'pl_from' => $id ] )
			->fetchResultSet();
		$n = $res->numRows();
		$res->free();

		// two in page text and two in signature
		$this->assertEquals( 4, $n, 'pagelinks should contain four links from the page' );
	}

	public static function provideNonPageTitle() {
		yield 'bad case and char' => [ Title::makeTitle( NS_MAIN, 'lower case and bad # char' ) ];
		yield 'empty' => [ Title::makeTitle( NS_MAIN, '' ) ];
		yield 'special' => [ Title::makeTitle( NS_SPECIAL, 'Dummy' ) ];
		yield 'relative' => [ Title::makeTitle( NS_MAIN, '', '#section' ) ];
		yield 'interwiki' => [ Title::makeTitle( NS_MAIN, 'Foo', '', 'acme' ) ];
	}

	/**
	 * @dataProvider provideNonPageTitle
	 */
	public function testDoUserEditContent_bad_page( $title ) {
		$user1 = $this->getTestUser()->getUser();

		$content = ContentHandler::makeContent(
			"Yadda yadda",
			$title,
			CONTENT_MODEL_WIKITEXT
		);

		$this->filterDeprecated( '/WikiPage constructed on a Title that cannot exist as a page/' );
		try {
			$page = $this->newPage( $title );
			$page->doUserEditContent( $content, $user1, "[[testing]] 1", EDIT_NEW );
		} catch ( Exception $ex ) {
			// Throwing is an acceptable way to react to an invalid title,
			// as long as no garbage is written to the database.
		}

		$row = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'page' )
			->where( [ 'page_namespace' => $title->getNamespace(), 'page_title' => $title->getDBkey() ] )
			->fetchRow();

		$this->assertFalse( $row );
	}

	public function testDoUserEditContent_twice() {
		$title = Title::newFromText( __METHOD__ );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$content = ContentHandler::makeContent( '$1 van $2', $title );

		$user = $this->getTestUser()->getUser();

		// Make sure we can do the exact same save twice.
		// This tests checks that internal caches are reset as appropriate.
		$status1 = $page->doUserEditContent( $content, $user, __METHOD__ );
		$status2 = $page->doUserEditContent( $content, $user, __METHOD__ );

		$this->assertStatusGood( $status1 );
		$this->assertStatusWarning( 'edit-no-change', $status2 );

		$this->assertNotNull( $status1->getNewRevision(), 'OK' );
		$this->assertNull( $status2->getNewRevision(), 'OK' );
	}

	/**
	 * Undeletion is covered in PageArchiveTest::testUndeleteRevisions()
	 *
	 * TODO: Revision deletion
	 */
	public function testDoDeleteArticleReal() {
		$this->overrideConfigValues( [
			MainConfigNames::RCWatchCategoryMembership => false,
		] );

		$page = $this->createPage(
			__METHOD__,
			"[[original text]] foo",
			CONTENT_MODEL_WIKITEXT
		);
		$id = $page->getId();
		$user = $this->getTestSysop()->getUser();

		$reason = "testing deletion";
		$status = $page->doDeleteArticleReal( $reason, $user );

		$this->assertFalse(
			$page->getTitle()->getArticleID() > 0,
			"Title object should now have page id 0"
		);
		$this->assertSame( 0, $page->getId(), "WikiPage should now have page id 0" );
		$this->assertFalse(
			$page->exists(),
			"WikiPage::exists should return false after page was deleted"
		);
		$this->assertNull(
			$page->getContent(),
			"WikiPage::getContent should return null after page was deleted"
		);

		$t = Title::newFromText( $page->getTitle()->getPrefixedText() );
		$this->assertFalse(
			$t->exists(),
			"Title::exists should return false after page was deleted"
		);

		// Run the job queue
		$this->runJobs();

		# ------------------------
		$res = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'pagelinks' )
			->where( [ 'pl_from' => $id ] )
			->fetchResultSet();
		$n = $res->numRows();
		$res->free();

		$this->assertSame( 0, $n, 'pagelinks should contain no more links from the page' );

		// Test deletion logging
		$logId = $status->getValue();
		$commentQuery = $this->getServiceContainer()->getCommentStore()->getJoin( 'log_comment' );
		$this->newSelectQueryBuilder()
			->select( [
				'log_type',
				'log_action',
				'log_comment' => $commentQuery['fields']['log_comment_text'],
				'log_actor',
				'log_namespace',
				'log_title',
			] )
			->from( 'logging' )
			->tables( $commentQuery['tables'] )
			->where( [ 'log_id' => $logId ] )
			->joinConds( $commentQuery['joins'] )
			->assertRowValue( [
				'delete',
				'delete',
				$reason,
				(string)$user->getActorId(),
				(string)$page->getTitle()->getNamespace(),
				$page->getTitle()->getDBkey(),
			] );
	}

	/**
	 * TODO: Test more stuff about suppression.
	 */
	public function testDoDeleteArticleReal_suppress() {
		$page = $this->createPage(
			__METHOD__,
			"[[original text]] foo",
			CONTENT_MODEL_WIKITEXT
		);

		$user = $this->getTestSysop()->getUser();
		$status = $page->doDeleteArticleReal(
			/* reason */ "testing deletion",
			$user,
			/* suppress */ true
		);

		// Test suppression logging
		$logId = $status->getValue();
		$commentQuery = $this->getServiceContainer()->getCommentStore()->getJoin( 'log_comment' );
		$this->newSelectQueryBuilder()
			->select( [
				'log_type',
				'log_action',
				'log_comment' => $commentQuery['fields']['log_comment_text'],
				'log_actor',
				'log_namespace',
				'log_title',
			] )
			->from( 'logging' )
			->tables( $commentQuery['tables'] )
			->where( [ 'log_id' => $logId ] )
			->joinConds( $commentQuery['joins'] )
			->assertRowValue( [
				'suppress',
				'delete',
				'testing deletion',
				(string)$user->getActorId(),
				(string)$page->getTitle()->getNamespace(),
				$page->getTitle()->getDBkey(),
			] );

		$lookup = $this->getServiceContainer()->getArchivedRevisionLookup();
		$archivedRevs = $lookup->listRevisions( $page->getTitle() );
		if ( !$archivedRevs || $archivedRevs->numRows() !== 1 ) {
			$this->fail( 'Unexpected number of archived revisions' );
		}
		$archivedRev = $this->getServiceContainer()->getRevisionStore()
			->newRevisionFromArchiveRow( $archivedRevs->current() );

		$this->assertNull(
			$archivedRev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ),
			"Archived content should be null after the page was suppressed for general users"
		);

		$this->assertNull(
			$archivedRev->getContent(
				SlotRecord::MAIN,
				RevisionRecord::FOR_THIS_USER,
				$this->getTestUser()->getUser()
			),
			"Archived content should be null after the page was suppressed for individual users"
		);

		$this->hideDeprecated( 'ContentHandler::getSlotDiffRendererInternal' );
		$this->assertNull(
			$archivedRev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $user ),
			"Archived content should be null after the page was suppressed even for a sysop"
		);
	}

	public function testGetContent() {
		$page = $this->newPage( __METHOD__ );

		$content = $page->getContent();
		$this->assertNull( $content );

		$this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT );

		$content = $page->getContent();
		$this->assertEquals( "some text", $content->getText() );
	}

	public function testExists() {
		$page = $this->newPage( __METHOD__ );
		$this->assertFalse( $page->exists() );

		$this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT );
		$this->assertTrue( $page->exists() );

		$page = new WikiPage( $page->getTitle() );
		$this->assertTrue( $page->exists() );

		$this->deletePage( $page, "done testing" );
		$this->assertFalse( $page->exists() );

		$page = new WikiPage( $page->getTitle() );
		$this->assertFalse( $page->exists() );
	}

	public static function provideHasViewableContent() {
		return [
			[ 'WikiPageTest_testHasViewableContent', false, true ],
			[ 'MediaWiki:WikiPageTest_testHasViewableContent', false ],
			[ 'MediaWiki:help', true ],
		];
	}

	/**
	 * @dataProvider provideHasViewableContent
	 */
	public function testHasViewableContent( $title, $viewable, $create = false ) {
		$page = $this->newPage( $title );
		$this->assertEquals( $viewable, $page->hasViewableContent() );

		if ( $create ) {
			$this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT );
			$this->assertTrue( $page->hasViewableContent() );

			$page = new WikiPage( $page->getTitle() );
			$this->assertTrue( $page->hasViewableContent() );
		}
	}

	public static function provideGetRedirectTarget() {
		return [
			[ 'WikiPageTest_testGetRedirectTarget_1', CONTENT_MODEL_WIKITEXT, "hello world", null ],
			[
				'WikiPageTest_testGetRedirectTarget_2',
				CONTENT_MODEL_WIKITEXT,
				"#REDIRECT [[hello world]]",
				"Hello world"
			],
			// The below added to protect against Media namespace
			// redirects which throw a fatal: (T203942)
			[
				'WikiPageTest_testGetRedirectTarget_3',
				CONTENT_MODEL_WIKITEXT,
				"#REDIRECT [[Media:hello_world]]",
				"File:Hello world"
			],
			// Test fragments longer than 255 bytes (T207876)
			[
				'WikiPageTest_testGetRedirectTarget_4',
				CONTENT_MODEL_WIKITEXT,
				'#REDIRECT [[Foobar#🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿]]',
				'Foobar#🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬󠁦󠁲󠁿🏴󠁮󠁬'
			]
		];
	}

	/**
	 * @dataProvider provideGetRedirectTarget
	 * @covers \WikiPage
	 * @covers \MediaWiki\Page\RedirectStore
	 */
	public function testGetRedirectTarget( $title, $model, $text, $target ) {
		$this->overrideConfigValues( [
			MainConfigNames::CapitalLinks => true,
			// The file redirect can trigger http request with UseInstantCommons = true
			MainConfigNames::ForeignFileRepos => [],
		] );

		$titleFormatter = $this->getServiceContainer()->getTitleFormatter();

		$page = $this->createPage( $title, $text, $model );

		# double check, because this test seems to fail for no reason for some people.
		$c = $page->getContent();
		$this->assertEquals( WikitextContent::class, get_class( $c ) );

		# now, test the actual redirect
		$redirectStore = $this->getServiceContainer()->getRedirectStore();
		$t = $redirectStore->getRedirectTarget( $page );

		$this->assertEquals( $target, $t ? $titleFormatter->getFullText( $t ) : null );
	}

	/**
	 * @dataProvider provideGetRedirectTarget
	 */
	public function testIsRedirect( $title, $model, $text, $target ) {
		// The file redirect can trigger http request with UseInstantCommons = true
		$this->overrideConfigValue( MainConfigNames::ForeignFileRepos, [] );

		$page = $this->createPage( $title, $text, $model );
		$this->assertEquals( $target !== null, $page->isRedirect() );
	}

	public static function provideIsCountable() {
		return [

			// any
			[ 'WikiPageTest_testIsCountable',
				CONTENT_MODEL_WIKITEXT,
				'',
				'any',
				true
			],
			[ 'WikiPageTest_testIsCountable',
				CONTENT_MODEL_WIKITEXT,
				'Foo',
				'any',
				true
			],

			// link
			[ 'WikiPageTest_testIsCountable',
				CONTENT_MODEL_WIKITEXT,
				'Foo',
				'link',
				false
			],
			[ 'WikiPageTest_testIsCountable',
				CONTENT_MODEL_WIKITEXT,
				'Foo [[bar]]',
				'link',
				true
			],

			// redirects
			[ 'WikiPageTest_testIsCountable',
				CONTENT_MODEL_WIKITEXT,
				'#REDIRECT [[bar]]',
				'any',
				false
			],
			[ 'WikiPageTest_testIsCountable',
				CONTENT_MODEL_WIKITEXT,
				'#REDIRECT [[bar]]',
				'link',
				false
			],

			// not a content namespace
			[ 'Talk:WikiPageTest_testIsCountable',
				CONTENT_MODEL_WIKITEXT,
				'Foo',
				'any',
				false
			],
			[ 'Talk:WikiPageTest_testIsCountable',
				CONTENT_MODEL_WIKITEXT,
				'Foo [[bar]]',
				'link',
				false
			],

			// not a content namespace, different model
			[ 'MediaWiki:WikiPageTest_testIsCountable.js',
				null,
				'Foo',
				'any',
				false
			],
			[ 'MediaWiki:WikiPageTest_testIsCountable.js',
				null,
				'Foo [[bar]]',
				'link',
				false
			],
		];
	}

	/**
	 * @dataProvider provideIsCountable
	 */
	public function testIsCountable( $title, $model, $text, $mode, $expected ) {
		$this->overrideConfigValue( MainConfigNames::ArticleCountMethod, $mode );

		$title = Title::newFromText( $title );

		$page = $this->createPage( $title, $text, $model );

		$editInfo = $page->prepareContentForEdit(
			$page->getContent(),
			null,
			$this->getTestUser()->getUser()
		);

		$v = $page->isCountable();
		$w = $page->isCountable( $editInfo );

		$this->assertEquals(
			$expected,
			$v,
			"isCountable( null ) returned unexpected value " . var_export( $v, true )
				. " instead of " . var_export( $expected, true )
			. " in mode `$mode` for text \"$text\""
		);

		$this->assertEquals(
			$expected,
			$w,
			"isCountable( \$editInfo ) returned unexpected value " . var_export( $v, true )
				. " instead of " . var_export( $expected, true )
			. " in mode `$mode` for text \"$text\""
		);
	}

	/**
	 * @dataProvider provideMakeParserOptions
	 */
	public function testMakeParserOptions( int $ns, string $title, string $model, $context, callable $expectation ) {
		// Ensure we're working with the default values during this test.
		$this->overrideConfigValues( [
			MainConfigNames::TextModelsToParse => [
				CONTENT_MODEL_WIKITEXT,
				CONTENT_MODEL_JAVASCRIPT,
				CONTENT_MODEL_CSS,
			],
			MainConfigNames::DisableLangConversion => false,
		] );
		// Call the context function first, which lets us setup the
		// overall wiki context before invoking the function-under-test
		if ( is_callable( $context ) ) {
			$context = $context( $this );
		}
		$page = $this->createPage(
			Title::makeTitle( $ns, $title ), __METHOD__, $model
		);
		$parserOptions = $page->makeParserOptions( $context );
		$expected = $expectation();
		$this->assertTrue( $expected->matches( $parserOptions ) );
	}

	/**
	 * @dataProvider provideMakeParserOptions
	 */
	public function testMakeParserOptionsFromTitleAndModel( int $ns, string $title, string $model, $context, callable $expectation ) {
		// Ensure we're working with the default values during this test.
		$this->overrideConfigValues( [
			MainConfigNames::TextModelsToParse => [
				CONTENT_MODEL_WIKITEXT,
				CONTENT_MODEL_JAVASCRIPT,
				CONTENT_MODEL_CSS,
			],
			MainConfigNames::DisableLangConversion => false,
		] );
		// Call the context function first, which lets us setup the
		// overall wiki context before invoking the function-under-test
		if ( is_callable( $context ) ) {
			$context = $context( $this );
		}
		$parserOptions = WikiPage::makeParserOptionsFromTitleAndModel(
			Title::makeTitle( $ns, $title ), $model, $context
		);
		$expected = $expectation();
		$this->assertTrue( $expected->matches( $parserOptions ) );
	}

	public static function provideMakeParserOptions() {
		// Default canonical parser options for a normal wikitext page
		yield [
			NS_MAIN, 'Main Page', CONTENT_MODEL_WIKITEXT, 'canonical',
			static function () {
				return ParserOptions::newFromAnon();
			},
		];
		// JavaScript should have Table Of Contents suppressed
		yield [
			NS_MAIN, 'JavaScript Test', CONTENT_MODEL_JAVASCRIPT, 'canonical',
			static function () {
				return ParserOptions::newFromAnon();
			},
		];
		// CSS should have Table Of Contents suppressed
		yield [
			NS_MAIN, 'CSS Test', CONTENT_MODEL_CSS, 'canonical',
			static function () {
				return ParserOptions::newFromAnon();
			},
		];
		// Language Conversion tables have content conversion disabled
		yield [
			NS_MEDIAWIKI, 'Conversiontable/Test', CONTENT_MODEL_WIKITEXT,
			static function ( $test ) {
				// Switch wiki to a language where LanguageConverter is enabled
				$test->setContentLang( 'zh' );
				$test->setUserLang( 'en' );
				return 'canonical';
			},
			static function () {
				$po = ParserOptions::newFromAnon();
				$po->disableContentConversion();
				// "Canonical" PO should use content language not user language
				Assert::assertSame( 'zh', $po->getUserLang() );
				return $po;
			},
		];
		// Test "non-canonical" options: parser option should use user
		// language here, not content language
		$user = null;
		yield [
			NS_MAIN, 'Main Page', CONTENT_MODEL_WIKITEXT,
			static function ( $test ) use ( &$user ) {
				$test->setContentLang( 'qqx' );
				$test->setUserLang( 'fr' );
				$user = $test->getTestUser()->getUser();
				return $user;
			},
			static function () use ( &$user ) {
				$po = ParserOptions::newFromUser( $user );
				Assert::assertSame( 'fr', $po->getUserLang() );
				return $po;
			},
		];
	}

	public static function provideGetParserOutput() {
		return [
			[
				CONTENT_MODEL_WIKITEXT,
				"hello ''world''\n",
				'<div class="mw-content-ltr mw-parser-output" lang="en" dir="ltr"><p>hello <i>world</i></p></div>'
			],
			[
				CONTENT_MODEL_JAVASCRIPT,
				"var test='<h2>not really a heading</h2>';",
				"<pre class=\"mw-code mw-js\" dir=\"ltr\">\nvar test='&lt;h2>not really a heading&lt;/h2>';\n</pre>",
			],
			[
				CONTENT_MODEL_CSS,
				"/* Not ''wikitext'' */",
				"<pre class=\"mw-code mw-css\" dir=\"ltr\">\n/* Not ''wikitext'' */\n</pre>",
			],
			// @todo more...?
		];
	}

	/**
	 * @dataProvider provideGetParserOutput
	 */
	public function testGetParserOutput( $model, $text, $expectedHtml ) {
		$page = $this->createPage( __METHOD__, $text, $model );

		$opt = $page->makeParserOptions( 'canonical' );
		$po = $page->getParserOutput( $opt );
		$pipeline = MediaWikiServices::getInstance()->getDefaultOutputPipeline();
		$text = $pipeline->run( $po, $opt, [] )->getContentHolderText();

		$text = trim( preg_replace( '/<!--.*?-->/sm', '', $text ) ); # strip injected comments
		$text = preg_replace( '!\s*(</p>|</div>)!m', '\1', $text ); # don't let tidy confuse us

		$this->assertEquals( $expectedHtml, $text );
	}

	public function testGetParserOutput_nonexisting() {
		$page = new WikiPage( Title::newFromText( __METHOD__ ) );

		$opt = ParserOptions::newFromAnon();
		$po = $page->getParserOutput( $opt );

		$this->assertFalse( $po, "getParserOutput() shall return false for non-existing pages." );
	}

	public function testGetParserOutput_badrev() {
		$page = $this->createPage( __METHOD__, 'dummy', CONTENT_MODEL_WIKITEXT );

		$opt = ParserOptions::newFromAnon();
		$po = $page->getParserOutput( $opt, $page->getLatest() + 1234 );

		// @todo would be neat to also test deleted revision

		$this->assertFalse( $po, "getParserOutput() shall return false for non-existing revisions." );
	}

	public const SECTIONS =

		"Intro

== stuff ==
hello world

== test ==
just a test

== foo ==
more stuff
";

	public function dataReplaceSection() {
		// NOTE: assume the Help namespace to contain wikitext
		return [
			[ 'Help:WikiPageTest_testReplaceSection',
				CONTENT_MODEL_WIKITEXT,
				self::SECTIONS,
				"0",
				"No more",
				null,
				trim( preg_replace( '/^Intro/m', 'No more', self::SECTIONS ) )
			],
			[ 'Help:WikiPageTest_testReplaceSection',
				CONTENT_MODEL_WIKITEXT,
				self::SECTIONS,
				"",
				"No more",
				null,
				"No more"
			],
			[ 'Help:WikiPageTest_testReplaceSection',
				CONTENT_MODEL_WIKITEXT,
				self::SECTIONS,
				"2",
				"== TEST ==\nmore fun",
				null,
				trim( preg_replace( '/^== test ==.*== foo ==/sm',
					"== TEST ==\nmore fun\n\n== foo ==",
					self::SECTIONS ) )
			],
			[ 'Help:WikiPageTest_testReplaceSection',
				CONTENT_MODEL_WIKITEXT,
				self::SECTIONS,
				"8",
				"No more",
				null,
				trim( self::SECTIONS )
			],
			[ 'Help:WikiPageTest_testReplaceSection',
				CONTENT_MODEL_WIKITEXT,
				self::SECTIONS,
				"new",
				"No more",
				"New",
				trim( self::SECTIONS ) . "\n\n== New ==\n\nNo more"
			],
		];
	}

	/**
	 * @dataProvider dataReplaceSection
	 */
	public function testReplaceSectionContent( $title, $model, $text, $section,
		$with, $sectionTitle, $expected
	) {
		$page = $this->createPage( $title, $text, $model );

		$content = ContentHandler::makeContent( $with, $page->getTitle(), $page->getContentModel() );
		/** @var TextContent $c */
		$c = $page->replaceSectionContent( $section, $content, $sectionTitle );

		$this->assertEquals( $expected, $c ? trim( $c->getText() ) : null );
	}

	/**
	 * @dataProvider dataReplaceSection
	 */
	public function testReplaceSectionAtRev( $title, $model, $text, $section,
		$with, $sectionTitle, $expected
	) {
		$page = $this->createPage( $title, $text, $model );
		$baseRevId = $page->getLatest();

		$content = ContentHandler::makeContent( $with, $page->getTitle(), $page->getContentModel() );
		/** @var TextContent $c */
		$c = $page->replaceSectionAtRev( $section, $content, $sectionTitle, $baseRevId );

		$this->assertEquals( $expected, $c ? trim( $c->getText() ) : null );
	}

	public static function provideGetAutoDeleteReason() {
		return [
			[
				[],
				false,
				false
			],

			[
				[
					[ "first edit", null ],
				],
				"/first edit.*only contributor/",
				false
			],

			[
				[
					[ "first edit", null ],
					[ "second edit", null ],
				],
				"/second edit.*only contributor/",
				true
			],

			[
				[
					[ "first edit", "127.0.2.22" ],
					[ "second edit", "127.0.3.33" ],
				],
				"/second edit/",
				true
			],

			[
				[
					[
						"first edit: "
							. "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam "
							. " nonumy eirmod tempor invidunt ut labore et dolore magna "
							. "aliquyam erat, sed diam voluptua. At vero eos et accusam "
							. "et justo duo dolores et ea rebum. Stet clita kasd gubergren, "
							. "no sea  takimata sanctus est Lorem ipsum dolor sit amet. "
							. " this here is some more filler content added to try and "
							. "reach the maximum automatic summary length so that this is"
							. " truncated ipot sodit colrad ut ad olve amit basul dat"
							. "Dorbet romt crobit trop bri. DannyS712 put me here lor pe"
							. " ode quob zot bozro see also T22281 for background pol sup"
							. "Lorem ipsum dolor sit amet'",
						null
					],
				],
				'/first edit:.*\.\.\."/',
				false
			],

			[
				[
					[ "first edit", "127.0.2.22" ],
					[ "", "127.0.3.33" ],
				],
				"/before blanking.*first edit/",
				true
			],

		];
	}

	/**
	 * @dataProvider provideGetAutoDeleteReason
	 */
	public function testGetAutoDeleteReason( $edits, $expectedResult, $expectedHistory ) {
		$this->disableAutoCreateTempUser();

		// NOTE: assume Help namespace to contain wikitext
		$page = $this->newPage( "Help:WikiPageTest_testGetAutoDeleteReason" );

		$c = 1;

		foreach ( $edits as $edit ) {
			$user = new User();

			if ( !empty( $edit[1] ) ) {
				$user->setName( $edit[1] );
			} else {
				$user = new User;
			}

			$content = ContentHandler::makeContent( $edit[0], $page->getTitle(), $page->getContentModel() );

			$page->doUserEditContent( $content, $user, "test edit $c", $c < 2 ? EDIT_NEW : 0 );

			$c += 1;
		}

		$this->hideDeprecated( 'WikiPage::getAutoDeleteReason:' );
		$this->hideDeprecated( 'MediaWiki\\Content\\ContentHandler::getAutoDeleteReason:' );
		$reason = $page->getAutoDeleteReason( $hasHistory );

		if ( is_bool( $expectedResult ) || $expectedResult === null ) {
			$this->assertEquals( $expectedResult, $reason );
		} else {
			$this->assertTrue( (bool)preg_match( $expectedResult, $reason ),
				"Autosummary didn't match expected pattern $expectedResult: $reason" );
		}

		$this->assertEquals( $expectedHistory, $hasHistory,
			"expected \$hasHistory to be " . var_export( $expectedHistory, true ) );
	}

	public static function providePreSaveTransform() {
		return [
			[ 'hello this is ~~~',
				"hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]",
			],
			[ 'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
				'hello \'\'this\'\' is <nowiki>~~~</nowiki>',
			],
		];
	}

	public function testLoadPageData() {
		$title = Title::makeTitle( NS_MAIN, 'SomePage' );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );

		$page->loadPageData( IDBAccessObject::READ_NORMAL );
		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );

		$page->loadPageData( IDBAccessObject::READ_LATEST );
		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );

		$page->loadPageData( IDBAccessObject::READ_LOCKING );
		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
		$this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );

		$page->loadPageData( IDBAccessObject::READ_EXCLUSIVE );
		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
		$this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
	}

	public function testUpdateCategoryCounts() {
		$page = new WikiPage( Title::newFromText( __METHOD__ ) );

		// Add an initial category
		$page->updateCategoryCounts( [ 'A' ], [], 0 );

		$this->assertSame( 1, Category::newFromName( 'A' )->getMemberCount() );
		$this->assertSame( 0, Category::newFromName( 'B' )->getMemberCount() );
		$this->assertSame( 0, Category::newFromName( 'C' )->getMemberCount() );

		// Add a new category
		$page->updateCategoryCounts( [ 'B' ], [], 0 );

		$this->assertSame( 1, Category::newFromName( 'A' )->getMemberCount() );
		$this->assertSame( 1, Category::newFromName( 'B' )->getMemberCount() );
		$this->assertSame( 0, Category::newFromName( 'C' )->getMemberCount() );

		// Add and remove a category
		$page->updateCategoryCounts( [ 'C' ], [ 'A' ], 0 );

		$this->assertSame( 0, Category::newFromName( 'A' )->getMemberCount() );
		$this->assertSame( 1, Category::newFromName( 'B' )->getMemberCount() );
		$this->assertSame( 1, Category::newFromName( 'C' )->getMemberCount() );
	}

	public static function provideUpdateRedirectOn() {
		yield [ '#REDIRECT [[Foo]]', true, null, true, true, [] ];
		yield [ '#REDIRECT [[Foo]]', true, 'Foo', true, true, [ [ NS_MAIN, 'Foo' ] ] ];
		yield [ 'SomeText', false, null, false, true, [] ];
		yield [ 'SomeText', false, 'Foo', false, true, [ [ NS_MAIN, 'Foo' ] ] ];
	}

	/**
	 * @dataProvider provideUpdateRedirectOn
	 *
	 * @param string $initialText
	 * @param bool $initialRedirectState
	 * @param string|null $redirectTitle
	 * @param bool|null $lastRevIsRedirect
	 * @param bool $expectedSuccess
	 * @param array $expectedRows
	 */
	public function testUpdateRedirectOn(
		$initialText,
		$initialRedirectState,
		$redirectTitle,
		$lastRevIsRedirect,
		$expectedSuccess,
		$expectedRows
	) {
		static $pageCounter = 0;
		$pageCounter++;

		$page = $this->createPage( Title::newFromText( __METHOD__ . $pageCounter ), $initialText );
		$this->assertSame( $initialRedirectState, $page->isRedirect() );

		$redirectTitle = is_string( $redirectTitle )
			? Title::newFromText( $redirectTitle )
			: $redirectTitle;

		$success = $this->getServiceContainer()->getRedirectStore()
			->updateRedirectTarget( $page, $redirectTitle, $lastRevIsRedirect );
		$this->assertSame( $expectedSuccess, $success, 'Success assertion' );
		/**
		 * updateRedirectTarget explicitly updates the redirect table (and not the page table).
		 * Most of core checks the page table for redirect status, so we have to be ugly and
		 * assert a select from the table here.
		 */
		$this->assertRedirectTableCountForPageId( $page->getId(), $expectedRows );
	}

	private function assertRedirectTableCountForPageId( $pageId, $expectedRows ) {
		$this->newSelectQueryBuilder()
			->select( [ 'rd_namespace', 'rd_title' ] )
			->from( 'redirect' )
			->where( [ 'rd_from' => $pageId ] )
			->assertResultSet( $expectedRows );
	}

	public function testInsertRedirectEntry_insertsRedirectEntry() {
		$page = $this->createPage( Title::newFromText( __METHOD__ ), 'A' );
		$this->assertRedirectTableCountForPageId( $page->getId(), [] );

		$targetTitle = Title::newFromText( 'SomeTarget#Frag' );
		$reflectedTitle = TestingAccessWrapper::newFromObject( $targetTitle );
		$reflectedTitle->mInterwiki = 'eninter';
		$this->getServiceContainer()->getRedirectStore()
			->updateRedirectTarget( $page, $targetTitle );

		$this->newSelectQueryBuilder()
			->select( [ 'rd_from', 'rd_namespace', 'rd_title', 'rd_fragment', 'rd_interwiki' ] )
			->from( 'redirect' )
			->where( [ 'rd_from' => $page->getId() ] )
			->assertResultSet( [ [
				strval( $page->getId() ),
				strval( $targetTitle->getNamespace() ),
				strval( $targetTitle->getDBkey() ),
				strval( $targetTitle->getFragment() ),
				strval( $targetTitle->getInterwiki() ),
			] ] );
	}

	public function testInsertRedirectEntry_T278367() {
		$page = $this->createPage( Title::newFromText( __METHOD__ ), 'A' );
		$this->assertRedirectTableCountForPageId( $page->getId(), [] );

		$targetTitle = Title::newFromText( '#Frag' );
		$ok = $this->getServiceContainer()->getRedirectStore()
			->updateRedirectTarget( $page, $targetTitle );

		$this->assertFalse( $ok );
		$this->assertRedirectTableCountForPageId( $page->getId(), [] );
	}

	public function testUpdateRevisionOn_existingPage() {
		$user = $this->getTestSysop()->getUser();
		$page = $this->createPage( __METHOD__, 'StartText' );

		$revisionRecord = new MutableRevisionRecord( $page );
		$revisionRecord->setContent(
			SlotRecord::MAIN,
			new WikitextContent( __METHOD__ . '-text' )
		);
		$revisionRecord->setUser( $user );
		$revisionRecord->setTimestamp( '20170707040404' );
		$revisionRecord->setPageId( $page->getId() );
		$revisionRecord->setId( 9989 );
		$revisionRecord->setSize( strlen( __METHOD__ . '-text' ) );
		$revisionRecord->setMinorEdit( true );
		$revisionRecord->setComment( CommentStoreComment::newUnsavedComment( __METHOD__ ) );

		$result = $page->updateRevisionOn( $this->getDb(), $revisionRecord );
		$this->assertTrue( $result );
		$this->assertSame( 9989, $page->getLatest() );
		$this->assertEquals( $revisionRecord, $page->getRevisionRecord() );
	}

	public function testUpdateRevisionOn_NonExistingPage() {
		$user = $this->getTestSysop()->getUser();
		$page = $this->createPage( __METHOD__, 'StartText' );
		$this->deletePage( $page, '', $user );

		$revisionRecord = new MutableRevisionRecord( $page );
		$revisionRecord->setContent(
			SlotRecord::MAIN,
			new WikitextContent( __METHOD__ . '-text' )
		);
		$revisionRecord->setUser( $user );
		$revisionRecord->setTimestamp( '20170707040404' );
		$revisionRecord->setPageId( $page->getId() );
		$revisionRecord->setId( 9989 );
		$revisionRecord->setSize( strlen( __METHOD__ . '-text' ) );
		$revisionRecord->setMinorEdit( true );
		$revisionRecord->setComment( CommentStoreComment::newUnsavedComment( __METHOD__ ) );

		$result = $page->updateRevisionOn( $this->getDb(), $revisionRecord );
		$this->assertFalse( $result );
	}

	public function testInsertOn() {
		$title = Title::newFromText( __METHOD__ );
		$page = new WikiPage( $title );

		$startTimeStamp = wfTimestampNow();
		$result = $page->insertOn( $this->getDb() );
		$endTimeStamp = wfTimestampNow();

		$this->assertIsInt( $result );
		$this->assertGreaterThan( 0, $result );

		$condition = [ 'page_id' => $result ];

		// Check the default fields have been filled
		$this->newSelectQueryBuilder()
			->select( [
				'page_namespace',
				'page_title',
				'page_is_redirect',
				'page_is_new',
				'page_latest',
				'page_len',
			] )
			->from( 'page' )
			->where( $condition )
			->assertResultSet( [ [
				'0',
				__METHOD__,
				'0',
				'1',
				'0',
				'0',
			] ] );

		// Check the page_random field has been filled
		$pageRandom = $this->getDb()->newSelectQueryBuilder()
			->select( 'page_random' )
			->from( 'page' )
			->where( $condition )
			->fetchField();
		$this->assertTrue( (float)$pageRandom < 1 && (float)$pageRandom > 0 );

		// Assert the touched timestamp in the DB is roughly when we inserted the page
		$pageTouched = $this->getDb()->newSelectQueryBuilder()
			->select( 'page_touched' )
			->from( 'page' )
			->where( $condition )
			->fetchField();
		$this->assertTrue(
			wfTimestamp( TS_UNIX, $startTimeStamp )
			<= wfTimestamp( TS_UNIX, $pageTouched )
		);
		$this->assertTrue(
			wfTimestamp( TS_UNIX, $endTimeStamp )
			>= wfTimestamp( TS_UNIX, $pageTouched )
		);

		// Try inserting the same page again and checking the result is false (no change)
		$result = $page->insertOn( $this->getDb() );
		$this->assertFalse( $result );
	}

	public function testInsertOn_idSpecified() {
		$title = Title::newFromText( __METHOD__ );
		$page = new WikiPage( $title );
		$id = 1478952189;

		$result = $page->insertOn( $this->getDb(), $id );

		$this->assertSame( $id, $result );

		$condition = [ 'page_id' => $result ];

		// Check there is actually a row in the db
		$this->newSelectQueryBuilder()
			->select( 'page_title' )
			->from( 'page' )
			->where( $condition )
			->assertResultSet( [ [ __METHOD__ ] ] );
	}

	public static function provideTestDoUpdateRestrictions_setBasicRestrictions() {
		// Note: Once the current dates passes the date in these tests they will fail.
		yield 'move something' => [
			true,
			[ 'move' => 'something' ],
			[],
			[ 'edit' => [], 'move' => [ 'something' ] ],
			[],
		];
		yield 'move something, edit blank' => [
			true,
			[ 'move' => 'something', 'edit' => '' ],
			[],
			[ 'edit' => [], 'move' => [ 'something' ] ],
			[],
		];
		yield 'edit sysop, with expiry' => [
			true,
			[ 'edit' => 'sysop' ],
			[ 'edit' => '21330101020202' ],
			[ 'edit' => [ 'sysop' ], 'move' => [] ],
			[ 'edit' => '21330101020202' ],
		];
		yield 'move and edit, move with expiry' => [
			true,
			[ 'move' => 'something', 'edit' => 'another' ],
			[ 'move' => '22220202010101' ],
			[ 'edit' => [ 'another' ], 'move' => [ 'something' ] ],
			[ 'move' => '22220202010101' ],
		];
		yield 'move and edit, edit with infinity expiry' => [
			true,
			[ 'move' => 'something', 'edit' => 'another' ],
			[ 'edit' => 'infinity' ],
			[ 'edit' => [ 'another' ], 'move' => [ 'something' ] ],
			[ 'edit' => 'infinity' ],
		];
		yield 'non existing, create something' => [
			false,
			[ 'create' => 'something' ],
			[],
			[ 'create' => [ 'something' ] ],
			[],
		];
		yield 'non existing, create something with expiry' => [
			false,
			[ 'create' => 'something' ],
			[ 'create' => '23451212112233' ],
			[ 'create' => [ 'something' ] ],
			[ 'create' => '23451212112233' ],
		];
	}

	/**
	 * @dataProvider provideTestDoUpdateRestrictions_setBasicRestrictions
	 */
	public function testDoUpdateRestrictions_setBasicRestrictions(
		$pageExists,
		array $limit,
		array $expiry,
		array $expectedRestrictions,
		array $expectedRestrictionExpiries
	) {
		if ( $pageExists ) {
			$page = $this->createPage( __METHOD__, 'ABC' );
		} else {
			$page = $this->getNonexistingTestPage( Title::newFromText( __METHOD__ . '-nonexist' ) );
		}
		$user = $this->getTestSysop()->getUser();
		$userIdentity = $this->getTestSysop()->getUserIdentity();

		$cascade = false;

		$status = $page->doUpdateRestrictions( $limit, $expiry, $cascade, 'aReason', $userIdentity, [] );

		$logId = $status->getValue();
		$restrictionStore = $this->getServiceContainer()->getRestrictionStore();
		$allRestrictions = $restrictionStore->getAllRestrictions( $page->getTitle() );

		$this->assertStatusGood( $status );
		$this->assertIsInt( $logId );
		$this->assertSame( $expectedRestrictions, $allRestrictions );
		foreach ( $expectedRestrictionExpiries as $key => $value ) {
			$this->assertSame( $value, $restrictionStore->getRestrictionExpiry( $page->getTitle(), $key ) );
		}

		// Make sure the log entry looks good
		// log_params is not checked here
		$commentQuery = $this->getServiceContainer()->getCommentStore()->getJoin( 'log_comment' );
		$this->newSelectQueryBuilder()
			->select( [
				'log_comment' => $commentQuery['fields']['log_comment_text'],
				'log_actor',
				'log_namespace',
				'log_title',
			] )
			->from( 'logging' )
			->tables( $commentQuery['tables'] )
			->where( [ 'log_id' => $logId ] )
			->joinConds( $commentQuery['joins'] )
			->assertRowValue( [
				'aReason',
				(string)$user->getActorId(),
				(string)$page->getTitle()->getNamespace(),
				$page->getTitle()->getDBkey(),
			] );
	}

	public function testDoUpdateRestrictions_failsOnReadOnly() {
		$page = $this->createPage( __METHOD__, 'ABC' );
		$user = $this->getTestSysop()->getUser();
		$cascade = false;

		// Set read only
		$readOnly = $this->getDummyReadOnlyMode( true );
		$this->setService( 'ReadOnlyMode', $readOnly );

		$status = $page->doUpdateRestrictions( [], [], $cascade, 'aReason', $user, [] );
		$this->assertStatusError( 'readonlytext', $status );
	}

	public function testDoUpdateRestrictions_returnsGoodIfNothingChanged() {
		$page = $this->createPage( __METHOD__, 'ABC' );
		$user = $this->getTestSysop()->getUser();
		$cascade = false;
		$limit = [ 'edit' => 'sysop' ];

		$status = $page->doUpdateRestrictions(
			$limit,
			[],
			$cascade,
			'aReason',
			$user,
			[]
		);

		// The first entry should have a logId as it did something
		$this->assertStatusGood( $status );
		$this->assertIsInt( $status->getValue() );

		$status = $page->doUpdateRestrictions(
			$limit,
			[],
			$cascade,
			'aReason',
			$user,
			[]
		);

		// The second entry should not have a logId as nothing changed
		$this->assertStatusGood( $status );
		$this->assertNull( $status->getValue() );
	}

	public function testDoUpdateRestrictions_logEntryTypeAndAction() {
		$page = $this->createPage( __METHOD__, 'ABC' );
		$user = $this->getTestSysop()->getUser();
		$cascade = false;

		// Protect the page
		$status = $page->doUpdateRestrictions(
			[ 'edit' => 'sysop' ],
			[],
			$cascade,
			'aReason',
			$user,
			[]
		);
		$this->assertStatusGood( $status );
		$this->assertIsInt( $status->getValue() );
		$this->newSelectQueryBuilder()
			->select( [ 'log_type', 'log_action' ] )
			->from( 'logging' )
			->where( [ 'log_id' => $status->getValue() ] )
			->assertResultSet( [ [ 'protect', 'protect' ] ] );

		// Modify the protection
		$status = $page->doUpdateRestrictions(
			[ 'edit' => 'somethingElse' ],
			[],
			$cascade,
			'aReason',
			$user,
			[]
		);
		$this->assertStatusGood( $status );
		$this->assertIsInt( $status->getValue() );
		$this->newSelectQueryBuilder()
			->select( [ 'log_type', 'log_action' ] )
			->from( 'logging' )
			->where( [ 'log_id' => $status->getValue() ] )
			->assertResultSet( [ [ 'protect', 'modify' ] ] );

		// Remove the protection
		$status = $page->doUpdateRestrictions(
			[],
			[],
			$cascade,
			'aReason',
			$user,
			[]
		);
		$this->assertStatusGood( $status );
		$this->assertIsInt( $status->getValue() );
		$this->newSelectQueryBuilder()
			->select( [ 'log_type', 'log_action' ] )
			->from( 'logging' )
			->where( [ 'log_id' => $status->getValue() ] )
			->assertResultSet( [ [ 'protect', 'unprotect' ] ] );
	}

	public function testNewPageUpdater() {
		$user = $this->getTestUser()->getUser();
		$page = $this->newPage( __METHOD__, __METHOD__ );
		$content = new WikitextContent( 'Hello World' );

		/** @var ContentRenderer $contentRenderer */
		$contentRenderer = $this->getMockBuilder( ContentRenderer::class )
			->onlyMethods( [ 'getParserOutput' ] )
			->disableOriginalConstructor()
			->getMock();
		$contentRenderer->expects( $this->once() )
			->method( 'getParserOutput' )
			->willReturn( new ParserOutput( 'HTML' ) );

		$this->setService( 'ContentRenderer', $contentRenderer );

		$preparedEditBefore = $page->prepareContentForEdit( $content, null, $user );
		$preparedUpdateBefore = $page->getCurrentUpdate();

		// provide context, so the cache can be kept in place
		$slotsUpdate = new revisionSlotsUpdate();
		$slotsUpdate->modifyContent( SlotRecord::MAIN, $content );

		$revision = $page->newPageUpdater( $user, $slotsUpdate )
			->setContent( SlotRecord::MAIN, $content )
			->saveRevision( CommentStoreComment::newUnsavedComment( 'test' ), EDIT_NEW );

		$preparedEditAfter = $page->prepareContentForEdit( $content, $revision, $user );
		$preparedUpdateAfter = $page->getCurrentUpdate();

		$this->assertSame( $revision->getId(), $page->getLatest() );

		// Parsed output must remain cached throughout.
		$this->assertSame(
			$preparedEditBefore->output,
			$preparedEditAfter->output
		);
		$this->assertSame(
			$preparedEditBefore->output,
			$preparedUpdateBefore->getCanonicalParserOutput()
		);
		$this->assertSame(
			$preparedEditBefore->output,
			$preparedUpdateAfter->getCanonicalParserOutput()
		);
	}

	public function testGetDerivedDataUpdater() {
		$admin = $this->getTestSysop()->getUser();

		/** @var object $page */
		$page = $this->createPage( __METHOD__, __METHOD__ );
		$page = TestingAccessWrapper::newFromObject( $page );

		$revision = $page->getRevisionRecord();
		$user = $revision->getUser();

		$slotsUpdate = new RevisionSlotsUpdate();
		$slotsUpdate->modifyContent( SlotRecord::MAIN, new WikitextContent( 'Hello World' ) );

		// get a virgin updater
		$updater1 = $page->getDerivedDataUpdater( $user );
		$this->assertFalse( $updater1->isUpdatePrepared() );

		$updater1->prepareUpdate( $revision );

		// Re-use updater with same revision or content, even if base changed
		$this->assertSame( $updater1, $page->getDerivedDataUpdater( $user, $revision ) );

		$slotsUpdate = RevisionSlotsUpdate::newFromContent(
			[ SlotRecord::MAIN => $revision->getContent( SlotRecord::MAIN ) ]
		);
		$this->assertSame( $updater1, $page->getDerivedDataUpdater( $user, null, $slotsUpdate ) );

		// Don't re-use for edit if base revision ID changed
		$this->assertNotSame(
			$updater1,
			$page->getDerivedDataUpdater( $user, null, $slotsUpdate, true )
		);

		// Don't re-use with different user
		$updater2a = $page->getDerivedDataUpdater( $admin, null, $slotsUpdate );
		$updater2a->prepareContent( $admin, $slotsUpdate, false );

		$updater2b = $page->getDerivedDataUpdater( $user, null, $slotsUpdate );
		$updater2b->prepareContent( $user, $slotsUpdate, false );
		$this->assertNotSame( $updater2a, $updater2b );

		// Don't re-use with different content
		$updater3 = $page->getDerivedDataUpdater( $admin, null, $slotsUpdate );
		$updater3->prepareUpdate( $revision );
		$this->assertNotSame( $updater2b, $updater3 );

		// Don't re-use if no context given
		$updater4 = $page->getDerivedDataUpdater( $admin );
		$updater4->prepareUpdate( $revision );
		$this->assertNotSame( $updater3, $updater4 );

		// Don't re-use if AGAIN no context given
		$updater5 = $page->getDerivedDataUpdater( $admin );
		$this->assertNotSame( $updater4, $updater5 );

		// Don't re-use cached "virgin" unprepared updater
		$updater6 = $page->getDerivedDataUpdater( $admin, $revision );
		$this->assertNotSame( $updater5, $updater6 );
	}

	protected function assertPreparedEditEquals(
		PreparedEdit $edit, PreparedEdit $edit2, $message = ''
	) {
		// suppress differences caused by a clock tick between generating the two PreparedEdits
		$timestamp1 = $edit->getOutput()->getCacheTime();
		$timestamp2 = $edit2->getOutput()->getCacheTime();
		$this->assertEquals( $edit, $edit2, $message );
		$this->assertLessThan( 3, abs( $timestamp1 - $timestamp2 ), $message );
	}

	protected function assertPreparedEditNotEquals(
		PreparedEdit $edit, PreparedEdit $edit2, $message = ''
	) {
		$this->assertNotEquals( $edit, $edit2, $message );
	}

	/**
	 * This is just to confirm that WikiPage::updateRevisionOn() updates the
	 * Title and LinkCache with the correct redirect value. Failing to do so
	 * causes subtle test failures in extensions, such as Cognate (T283654)
	 * and Echo (no task, but see code review of I12542fc899).
	 */
	public function testUpdateSetsTitleRedirectCache() {
		// Get a title object without using the title cache
		$title = Title::makeTitleSafe( NS_MAIN, 'A new redirect' );
		$this->assertFalse( $title->isRedirect() );

		$dbw = $this->getDb();
		$store = $this->getServiceContainer()->getRevisionStore();
		$page = $this->newPage( $title );
		$page->insertOn( $dbw );

		$revision = new MutableRevisionRecord( $page );
		$revision->setContent(
			SlotRecord::MAIN,
			new WikitextContent( '#REDIRECT [[Target]]' )
		);
		$revision->setTimestamp( wfTimestampNow() );
		$revision->setComment( CommentStoreComment::newUnsavedComment( '' ) );
		$revision->setUser( $this->getTestUser()->getUser() );

		$revision = $store->insertRevisionOn( $revision, $dbw );

		$page->updateRevisionOn( $dbw, $revision );
		// check the title cache
		$this->assertTrue( $title->isRedirect() );
		// check the link cache with a fresh title
		$title = Title::makeTitleSafe( NS_MAIN, 'A new redirect' );
		$this->assertTrue( $title->isRedirect() );
	}

	public function testGetTitle() {
		$page = $this->createPage( __METHOD__, 'whatever' );

		$title = $page->getTitle();
		$this->assertSame( __METHOD__, $title->getText() );

		$this->assertSame( $page->getId(), $title->getId() );
		$this->assertSame( $page->getNamespace(), $title->getNamespace() );
		$this->assertSame( $page->getDBkey(), $title->getDBkey() );
		$this->assertSame( $page->getWikiId(), $title->getWikiId() );
		$this->assertSame( $page->canExist(), $title->canExist() );
	}

	public function testToPageRecord() {
		$page = $this->createPage( __METHOD__, 'whatever' );
		$record = $page->toPageRecord();

		$this->assertSame( $page->getId(), $record->getId() );
		$this->assertSame( $page->getNamespace(), $record->getNamespace() );
		$this->assertSame( $page->getDBkey(), $record->getDBkey() );
		$this->assertSame( $page->getWikiId(), $record->getWikiId() );
		$this->assertSame( $page->canExist(), $record->canExist() );

		$this->assertSame( $page->getLatest(), $record->getLatest() );
		$this->assertSame( $page->getTouched(), $record->getTouched() );
		$this->assertSame( $page->isNew(), $record->isNew() );
		$this->assertSame( $page->isRedirect(), $record->isRedirect() );
	}

	public function testGetTouched() {
		$page = $this->createPage( __METHOD__, 'whatever' );

		$touched = $this->getDb()->newSelectQueryBuilder()
			->select( 'page_touched' )
			->from( 'page' )
			->where( [ 'page_id' => $page->getId() ] )
			->fetchField();
		$touched = MWTimestamp::convert( TS_MW, $touched );

		// Internal cache of the touched time was set after the page was created
		$this->assertSame( $touched, $page->getTouched() );

		$touched = MWTimestamp::convert( TS_MW, MWTimestamp::convert( TS_UNIX, $touched ) + 100 );
		$page->getTitle()->invalidateCache( $touched );

		// Re-load touched time
		$page = $this->newPage( $page->getTitle() );
		$this->assertSame( $touched, $page->getTouched() );

		// Cause the latest revision to be loaded
		$page->getRevisionRecord();

		// Make sure the internal cache of the touched time was not overwritten
		$this->assertSame( $touched, $page->getTouched() );
	}

}
PK       ! R -Q}  }  #  page/PageSelectQueryBuilderTest.phpnu Iw        <?php
namespace MediaWiki\Tests\Page;

use Exception;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageSelectQueryBuilder;
use MediaWiki\Page\PageStore;
use MediaWikiIntegrationTestCase;

/**
 * @group Database
 */
class PageSelectQueryBuilderTest extends MediaWikiIntegrationTestCase {

	public function addDBDataOnce() {
		$this->getExistingTestPage( 'AA' );
		$this->getExistingTestPage( 'AB' );
		$this->getExistingTestPage( 'BB' );

		$this->getExistingTestPage( 'Talk:AA' );
		$this->getExistingTestPage( 'User:AB' );
	}

	/**
	 * @return PageStore
	 * @throws Exception
	 */
	private function getPageStore() {
		$services = $this->getServiceContainer();

		$serviceOptions = new ServiceOptions(
			PageStore::CONSTRUCTOR_OPTIONS,
			[
				MainConfigNames::LanguageCode => $services->getContentLanguage()->getCode(),
				MainConfigNames::PageLanguageUseDB => true,
			]
		);

		return new PageStore(
			$serviceOptions,
			$services->getDBLoadBalancer(),
			$services->getNamespaceInfo(),
			$services->getTitleParser(),
			$services->getLinkCache(),
			$services->getStatsFactory()
		);
	}

	/**
	 * @covers \MediaWiki\Page\PageSelectQueryBuilder::wherePageIds
	 * @covers \MediaWiki\Page\PageSelectQueryBuilder::fetchPageRecordArray
	 */
	public function testFetchBatchOfPagesById() {
		$pageStore = $this->getPageStore();

		$recAA = $pageStore->getPageByName( NS_MAIN, 'AA' );
		$recAB = $pageStore->getPageByName( NS_MAIN, 'AB' );

		$recs = $pageStore->newSelectQueryBuilder()
			->wherePageIds( [] )
			->fetchPageRecordArray();

		$this->assertCount( 0, $recs );

		$recs = $pageStore->newSelectQueryBuilder()
			->wherePageIds( [ $recAA->getId(), $recAB->getId() ] )
			->fetchPageRecordArray();

		$this->assertCount( 2, $recs );
		$this->assertSame( 'AA', $recs[ $recAA->getId() ]->getDBkey() );
		$this->assertSame( 'AB', $recs[ $recAB->getId() ]->getDBkey() );
	}

	/**
	 * @covers \MediaWiki\Page\PageSelectQueryBuilder::wherePageIds
	 * @covers \MediaWiki\Page\PageSelectQueryBuilder::fetchPageRecord
	 */
	public function testFetchSinglePageById() {
		$pageStore = $this->getPageStore();

		$recAB = $pageStore->getPageByName( NS_MAIN, 'AB' );

		$rec = $pageStore->newSelectQueryBuilder()
			->wherePageIds( $recAB->getId() )
			->fetchPageRecord();

		$this->assertTrue( $recAB->isSamePageAs( $rec ) );

		$rec = $pageStore->newSelectQueryBuilder()
			->wherePageIds( 348529043 )
			->fetchPageRecord();

		$this->assertNull( $rec );
	}

	/**
	 * @covers \MediaWiki\Page\PageSelectQueryBuilder::whereTitles
	 * @covers \MediaWiki\Page\PageSelectQueryBuilder::fetchPageRecordArray
	 * @covers \MediaWiki\Page\PageSelectQueryBuilder::orderByPageId
	 */
	public function testFindBatchOfPageIdsByTitle() {
		$pageStore = $this->getPageStore();

		$recAA = $pageStore->getPageByName( NS_MAIN, 'AA' );
		$recAB = $pageStore->getPageByName( NS_MAIN, 'AB' );
		$recAC = $pageStore->getPageByName( NS_MAIN, 'BB' );

		$recs = $pageStore->newSelectQueryBuilder()
			->whereTitles( NS_FILE, [ 'AA', 'AB', 'BB' ] )
			->fetchPageIds();

		$this->assertCount( 0, $recs );

		$recs = $pageStore->newSelectQueryBuilder()
			->whereTitles( NS_MAIN, [ 'AA', 'AB', 'BB' ] )
			->orderByPageId( PageSelectQueryBuilder::SORT_DESC )
			->fetchPageIds();

		$expectedIds = [ $recAA->getId(), $recAB->getId(), $recAC->getId() ];
		sort( $expectedIds );
		$expectedIds = array_reverse( $expectedIds );

		$this->assertSame( $expectedIds, $recs );
	}

	/**
	 * @covers \MediaWiki\Page\PageSelectQueryBuilder::whereTitles
	 * @covers \MediaWiki\Page\PageSelectQueryBuilder::fetchPageRecord
	 */
	public function testFetchSinglePageByTitle() {
		$pageStore = $this->getPageStore();

		$recAB = $pageStore->getPageByName( NS_MAIN, 'AB' );

		$rec = $pageStore->newSelectQueryBuilder()
			->whereTitles( NS_MAIN, 'AB' )
			->fetchPageRecord();

		$this->assertTrue( $recAB->isSamePageAs( $rec ) );

		// The page should have ended up in the LinkCache
		$linkCache = $this->getServiceContainer()->getLinkCache();
		$this->assertSame( $rec->getId(), $linkCache->getGoodLinkID( $rec ) );

		$rec = $pageStore->newSelectQueryBuilder()
			->whereTitles( NS_TALK, 'AB' )
			->fetchPageRecord();

		$this->assertNull( $rec );
	}

	/**
	 * @covers \MediaWiki\Page\PageSelectQueryBuilder::whereNamespace
	 * @covers \MediaWiki\Page\PageSelectQueryBuilder::fetchPageRecordArray
	 */
	public function testFilterByNamespace() {
		$pageStore = $this->getPageStore();

		$recAA = $pageStore->getPageByName( NS_TALK, 'AA' );

		$recs = $pageStore->newSelectQueryBuilder()
			->whereNamespace( NS_TALK )
			->fetchPageRecordArray();

		$this->assertCount( 1, $recs );
		$this->assertSame( 'AA', $recs[ $recAA->getId() ]->getDBkey() );
	}

	/**
	 * @covers \MediaWiki\Page\PageSelectQueryBuilder::whereTitlePrefix
	 * @covers \MediaWiki\Page\PageSelectQueryBuilder::fetchPageRecords
	 * @covers \MediaWiki\Page\PageSelectQueryBuilder::orderByTitle
	 */
	public function testListPagesByPrefix() {
		$pageStore = $this->getPageStore();

		$recs = $pageStore->newSelectQueryBuilder()
			->whereTitlePrefix( NS_MAIN, 'A' )
			->orderByTitle( PageSelectQueryBuilder::SORT_DESC )
			->fetchPageRecords();

		$recs = iterator_to_array( $recs );

		$this->assertCount( 2, $recs );

		// descending order
		$this->assertSame( 'AB', $recs[0]->getDBkey() );
		$this->assertSame( 'AA', $recs[1]->getDBkey() );

		$recs = $pageStore->newSelectQueryBuilder()
			->whereTitlePrefix( NS_TALK, 'A' )
			->fetchPageRecords();

		$this->assertCount( 1, iterator_to_array( $recs ) );

		$recs = $pageStore->newSelectQueryBuilder()
			->whereTitlePrefix( NS_MAIN, 'XX' )
			->fetchPageRecords();

		$this->assertCount( 0, iterator_to_array( $recs ) );
	}

}
PK       ! Li  i    page/ArticleViewTest.phpnu Iw        <?php

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\Content;
use MediaWiki\Content\ContentHandler;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Output\OutputPage;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\Utils\MWTimestamp;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \Article::view()
 * @group Database
 */
class ArticleViewTest extends MediaWikiIntegrationTestCase {

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

		$this->setUserLang( 'qqx' );
	}

	private function getHtml( OutputPage $output ) {
		return preg_replace( '/<!--.*?-->/s', '', $output->getHTML() );
	}

	/**
	 * @param string|Title $title
	 * @param Content[]|string[] $revisionContents Content of the revisions to create
	 *        (as Content or string).
	 * @param RevisionRecord[] &$revisions will be filled with the RevisionRecord for $content.
	 *
	 * @return WikiPage
	 */
	private function getPage( $title, array $revisionContents = [], array &$revisions = [] ) {
		if ( is_string( $title ) ) {
			$title = Title::makeTitle( $this->getDefaultWikitextNS(), $title );
		}

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$user = $this->getTestUser()->getUser();

		// Make sure all revision have different timestamps all the time,
		// to make timestamp asserts below deterministic.
		$time = time() - 86400;
		MWTimestamp::setFakeTime( $time );

		foreach ( $revisionContents as $key => $cont ) {
			if ( is_string( $cont ) ) {
				$cont = new WikitextContent( $cont );
			}

			$rev = $page->newPageUpdater( $user )
				->setContent( SlotRecord::MAIN, $cont )
				->saveRevision( CommentStoreComment::newUnsavedComment( 'Rev ' . $key ) );

			$revisions[ $key ] = $rev;
			MWTimestamp::setFakeTime( ++$time );
		}
		MWTimestamp::setFakeTime( false );

		// Clear content model cache to support tests that mock the revision
		$this->getServiceContainer()->getMainWANObjectCache()->clearProcessCache();

		return $page;
	}

	/**
	 * @covers \Article::getOldId()
	 * @covers \Article::getRevIdFetched()
	 */
	public function testGetOldId() {
		$revisions = [];
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );

		$idA = $revisions[1]->getId();
		$idB = $revisions[2]->getId();

		// oldid in constructor
		$article = new Article( $page->getTitle(), $idA );
		$this->assertSame( $idA, $article->getOldID() );
		$article->fetchRevisionRecord();
		$this->assertSame( $idA, $article->getRevIdFetched() );

		// oldid 0 in constructor
		$article = new Article( $page->getTitle(), 0 );
		$this->assertSame( 0, $article->getOldID() );
		$article->fetchRevisionRecord();
		$this->assertSame( $idB, $article->getRevIdFetched() );

		// oldid in request
		$article = new Article( $page->getTitle() );
		$context = new RequestContext();
		$context->setRequest( new FauxRequest( [ 'oldid' => $idA ] ) );
		$article->setContext( $context );
		$this->assertSame( $idA, $article->getOldID() );
		$article->fetchRevisionRecord();
		$this->assertSame( $idA, $article->getRevIdFetched() );

		// no oldid
		$article = new Article( $page->getTitle() );
		$context = new RequestContext();
		$context->setRequest( new FauxRequest( [] ) );
		$article->setContext( $context );
		$this->assertSame( 0, $article->getOldID() );
		$article->fetchRevisionRecord();
		$this->assertSame( $idB, $article->getRevIdFetched() );
	}

	public function testView() {
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ] );

		$article = new Article( $page->getTitle(), 0 );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( 'Test B', $this->getHtml( $output ) );
		$this->assertStringNotContainsString( 'id="mw-revision-info"', $this->getHtml( $output ) );
		$this->assertStringNotContainsString( 'id="mw-revision-nav"', $this->getHtml( $output ) );
	}

	public function testViewCached() {
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ] );

		$po = new ParserOutput( 'Cached Text' );

		$article = new Article( $page->getTitle(), 0 );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );

		$cache = $this->getServiceContainer()->getParserCache();
		$cache->save( $po, $page, $article->getParserOptions() );

		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( 'Cached Text', $this->getHtml( $output ) );
		$this->assertStringNotContainsString( 'Test A', $this->getHtml( $output ) );
		$this->assertStringNotContainsString( 'Test B', $this->getHtml( $output ) );
	}

	/**
	 * @covers \Article::getPage
	 * @covers \WikiPage::getRedirectTarget
	 * @covers \MediaWiki\Page\RedirectLookup::getRedirectTarget
	 */
	public function testViewRedirect() {
		$target = Title::makeTitle( $this->getDefaultWikitextNS(), 'Test_Target' );
		$redirectText = '#REDIRECT [[' . $target->getPrefixedText() . ']]';

		$page = $this->getPage( __METHOD__, [ $redirectText ] );

		$article = new Article( $page->getTitle(), 0 );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
		$article->view();

		$redirectStore = $this->getServiceContainer()->getRedirectStore();
		$titleFormatter = $this->getServiceContainer()->getTitleFormatter();

		$this->assertNotNull(
			$redirectStore->getRedirectTarget( $article->getPage() )
		);
		$this->assertSame(
			$target->getPrefixedDBkey(),
			$titleFormatter->getPrefixedDBkey( $redirectStore->getRedirectTarget( $article->getPage() ) )
		);

		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( 'class="redirectText"', $this->getHtml( $output ) );
		$this->assertStringContainsString(
			'>' . htmlspecialchars( $target->getPrefixedText() ) . '<',
			$this->getHtml( $output )
		);
	}

	public function testViewNonText() {
		$dummy = $this->getPage( __METHOD__, [ 'Dummy' ] );
		$dummyRev = $dummy->getRevisionRecord();
		$title = $dummy->getTitle();

		/** @var MockObject|ContentHandler $mockHandler */
		$mockHandler = $this->getMockBuilder( ContentHandler::class )
			->onlyMethods(
				[
					'isParserCacheSupported',
					'serializeContent',
					'unserializeContent',
					'makeEmptyContent',
					'getParserOutput',
				]
			)
			->setConstructorArgs( [ 'NotText', [ 'application/frobnitz' ] ] )
			->getMock();

		$mockHandler->method( 'isParserCacheSupported' )
			->willReturn( false );
		$mockHandler->method( 'getParserOutput' )
			->willReturn( new ParserOutput( 'Structured Output' ) );

		$this->setTemporaryHook(
			'ContentHandlerForModelID',
			static function ( $id, &$handler ) use ( $mockHandler ) {
				$handler = $mockHandler;
			}
		);

		/** @var MockObject|Content $content */
		$content = $this->createMock( Content::class );
		$content->method( 'getModel' )
			->willReturn( 'NotText' );
		$content->expects( $this->never() )->method( 'getNativeData' );
		$content->method( 'copy' )->willReturnSelf();

		$rev = new MutableRevisionRecord( $title );
		$rev->setId( $dummyRev->getId() );
		$rev->setPageId( $title->getArticleID() );
		$rev->setUser( $dummyRev->getUser() );
		$rev->setComment( $dummyRev->getComment() );
		$rev->setTimestamp( $dummyRev->getTimestamp() );

		$rev->setContent( SlotRecord::MAIN, $content );

		/** @var MockObject|WikiPage $page */
		$page = $this->getMockBuilder( WikiPage::class )
			->onlyMethods( [ 'getRevisionRecord', 'getLatest', 'getContentHandler' ] )
			->setConstructorArgs( [ $title ] )
			->getMock();

		$page->method( 'getRevisionRecord' )
			->willReturn( $rev );
		$page->method( 'getLatest' )
			->willReturn( $rev->getId() );
		$page->method( 'getContentHandler' )
			->willReturn( $mockHandler );

		$article = Article::newFromWikiPage( $page, RequestContext::getMain() );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( 'Structured Output', $this->getHtml( $output ) );
		$this->assertStringNotContainsString( 'Dummy', $this->getHtml( $output ) );
	}

	public function testViewOfOldRevision() {
		$revisions = [];
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
		$idA = $revisions[1]->getId();

		$article = new Article( $page->getTitle(), $idA );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( 'Test A', $this->getHtml( $output ) );
		$this->assertStringContainsString( 'id="mw-revision-info"', $output->getSubtitle() );
		$this->assertStringContainsString( 'id="mw-revision-nav"', $output->getSubtitle() );

		$this->assertStringNotContainsString( 'id="revision-info-current"', $output->getSubtitle() );
		$this->assertStringNotContainsString( 'Test B', $this->getHtml( $output ) );
		$this->assertSame( $idA, $output->getRevisionId() );
		$this->assertSame( $revisions[1]->getTimestamp(), $output->getRevisionTimestamp() );
	}

	public function testViewOfOldRevisionFromCache() {
		$this->overrideConfigValues( [
			MainConfigNames::OldRevisionParserCacheExpireTime => 100500,
			MainConfigNames::MainCacheType => CACHE_HASH,
		] );

		$revisions = [];
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
		$idA = $revisions[1]->getId();
		$context = RequestContext::getMain();
		$context->setTitle( $page->getTitle() );

		// View the revision once (to get it into the cache)
		$article = new Article( $page->getTitle(), $idA );
		$article->view();

		// Reset the output page and view the revision again (from ParserCache)
		$article = new Article( $page->getTitle(), $idA );
		$context->setOutput( new OutputPage( $context ) );
		$article->setContext( $context );

		$outputPageBeforeHTMLRevisionId = null;
		$this->setTemporaryHook( 'OutputPageBeforeHTML',
			static function ( OutputPage $out ) use ( &$outputPageBeforeHTMLRevisionId ) {
				$outputPageBeforeHTMLRevisionId = $out->getRevisionId();
			}
		);

		$article->view();
		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( 'Test A', $this->getHtml( $output ) );
		$this->assertSame( 1, substr_count( $output->getSubtitle(), 'cdx-message--warning' ) );
		$this->assertSame( $idA, $output->getRevisionId() );
		$this->assertSame( $idA, $outputPageBeforeHTMLRevisionId );
		$this->assertSame( $revisions[1]->getTimestamp(), $output->getRevisionTimestamp() );
	}

	public function testViewOfCurrentRevision() {
		$revisions = [];
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
		$idB = $revisions[2]->getId();

		$article = new Article( $page->getTitle(), $idB );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( 'Test B', $this->getHtml( $output ) );
		$this->assertStringContainsString( 'id="mw-revision-info-current"', $output->getSubtitle() );
		$this->assertStringContainsString( 'id="mw-revision-nav"', $output->getSubtitle() );
	}

	public function testViewOfCurrentRevisionDirty() {
		$this->overrideConfigValue(
			MainConfigNames::PoolCounterConf,
			[
				'ArticleView' => [
					'class' => MockPoolCounterFailing::class,
				]
			]
		);

		$revisions = [];
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A' ], $revisions );
		$idA = $revisions[1]->getId();

		// Do the next edit without ParserCache produce an outdated cache entry
		$parserCacheFactory = $this->getServiceContainer()->getParserCacheFactory();
		$this->overrideConfigValue( MainConfigNames::ParserCacheType, CACHE_NONE );
		$latestEditStatus = $this->editPage( $page, 'Test B' );
		// Restore the old cache instance with the now outdated cache entry
		$this->setService( 'ParserCacheFactory', $parserCacheFactory );

		// Request the article for the latest
		$article = new Article( $page->getTitle() );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
		$article->view();

		// Expected the old values to return
		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( 'Test A', $this->getHtml( $output ) );
		$this->assertSame( $idA, $output->getRevisionId() );
		$this->assertSame( $revisions[1]->getTimestamp(), $output->getRevisionTimestamp() );
	}

	public function testViewOfMissingRevision() {
		$revisions = [];
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A' ], $revisions );
		$badId = $revisions[1]->getId() + 100;

		$article = new Article( $page->getTitle(), $badId );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( 'missing-revision: ' . $badId, $this->getHtml( $output ) );

		$this->assertStringNotContainsString( 'Test A', $this->getHtml( $output ) );
	}

	public function testViewOfDeletedRevision() {
		$revisions = [];
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
		$idA = $revisions[1]->getId();

		$revDelList = $this->getRevDelRevisionList( $page->getTitle(), $idA );
		$revDelList->setVisibility( [
			'value' => [ RevisionRecord::DELETED_TEXT => 1 ],
			'comment' => "Testing",
		] );

		$article = new Article( $page->getTitle(), $idA );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( 'rev-deleted-text-permission', $this->getHtml( $output ) );

		$this->assertStringNotContainsString( 'Test A', $this->getHtml( $output ) );
		$this->assertStringNotContainsString( 'Test B', $this->getHtml( $output ) );
	}

	public function testUnhiddenViewOfDeletedRevision() {
		$revisions = [];
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
		$idA = $revisions[1]->getId();

		$revDelList = $this->getRevDelRevisionList( $page->getTitle(), $idA );
		$revDelList->setVisibility( [
			'value' => [
				RevisionRecord::DELETED_TEXT => 1,
				RevisionRecord::DELETED_COMMENT => 1,
				RevisionRecord::DELETED_USER => 1,
			],
			'comment' => "Testing",
		] );

		$realContext = RequestContext::getMain();
		$oldUser = $realContext->getUser();
		$oldLanguage = $realContext->getLanguage();

		$article = new Article( $page->getTitle(), $idA );
		$context = new DerivativeContext( $realContext );
		$article->setContext( $context );
		$context->getOutput()->setTitle( $page->getTitle() );
		$context->getRequest()->setVal( 'unhide', 1 );
		$context->setUser( $this->getTestUser( [ 'sysop' ] )->getUser() );

		// Need global user set to sysop, global state in Linker::revUserTools/Linker::revComment (T309479)
		$realContext->setUser( $context->getUser() );
		// Language is resetted in setUser
		$this->setUserLang( $oldLanguage );

		$article->view();

		$output = $article->getContext()->getOutput();
		$subtitle = $output->getSubtitle();
		$html = $this->getHtml( $output );

		// Test that oldid is select, not the current version
		$this->assertStringNotContainsString( 'Test B', $html );

		// Warning about rev-del must exists
		$this->assertStringContainsString( 'rev-deleted-text-view', $html );

		// Test for the hidden values
		$this->assertStringContainsString( 'Test A', $html );
		$this->assertStringContainsString( $revisions[1]->getUser()->getName(), $subtitle );
		$this->assertStringContainsString( '(parentheses: Rev 1)', $subtitle );

		// Should not contain the rev-del messages
		$this->assertStringNotContainsString( '(rev-deleted-user)', $subtitle );
		$this->assertStringNotContainsString( '(rev-deleted-comment)', $subtitle );

		$realContext->setUser( $oldUser );
	}

	public function testHiddenViewOfDeletedRevision() {
		$revisions = [];
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
		$idA = $revisions[1]->getId();

		$revDelList = $this->getRevDelRevisionList( $page->getTitle(), $idA );
		$revDelList->setVisibility( [
			'value' => [
				RevisionRecord::DELETED_TEXT => 1,
				RevisionRecord::DELETED_COMMENT => 1,
				RevisionRecord::DELETED_USER => 1,
			],
			'comment' => "Testing",
		] );

		$realContext = RequestContext::getMain();
		$oldUser = $realContext->getUser();
		$oldLanguage = $realContext->getLanguage();

		$article = new Article( $page->getTitle(), $idA );
		$context = new DerivativeContext( $realContext );
		$article->setContext( $context );
		$context->getOutput()->setTitle( $page->getTitle() );
		// No unhide=1 is set in this test case
		$context->setUser( $this->getTestUser( [ 'sysop' ] )->getUser() );

		// Need global user set to sysop, global state in Linker::revUserTools/Linker::revComment (T309479)
		$realContext->setUser( $context->getUser() );
		// Language is resetted in setUser
		$this->setUserLang( $oldLanguage );

		$article->view();

		$output = $article->getContext()->getOutput();
		$subtitle = $output->getSubtitle();
		$html = $this->getHtml( $output );

		// Test that oldid is select, not the current version
		$this->assertStringNotContainsString( 'Test B', $html );

		// Warning about rev-del must exists
		$this->assertStringContainsString( 'rev-deleted-text-unhide', $html );

		// Test for the rev-del messages
		$this->assertStringContainsString( '(rev-deleted-user)', $subtitle );
		$this->assertStringContainsString( '(rev-deleted-comment)', $subtitle );

		// Should not contain the hidden values
		$this->assertStringNotContainsString( 'Test A', $html );
		$this->assertStringNotContainsString( $revisions[1]->getUser()->getName(), $subtitle );
		$this->assertStringNotContainsString( '(parentheses: Rev 1)', $subtitle );

		$realContext->setUser( $oldUser );
	}

	public function testViewMissingPage() {
		$page = $this->getPage( __METHOD__ );

		$article = new Article( $page->getTitle() );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( '(noarticletextanon)', $this->getHtml( $output ) );
	}

	public function testViewDeletedPage() {
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ] );
		$this->deletePage( $page );

		$article = new Article( $page->getTitle() );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( 'moveddeleted', $this->getHtml( $output ) );
		$this->assertStringContainsString( 'logentry-delete-delete', $this->getHtml( $output ) );
		$this->assertStringContainsString( '(noarticletextanon)', $this->getHtml( $output ) );

		$this->assertStringNotContainsString( 'Test A', $this->getHtml( $output ) );
		$this->assertStringNotContainsString( 'Test B', $this->getHtml( $output ) );
	}

	public function testViewMessagePage() {
		$title = Title::makeTitle( NS_MEDIAWIKI, 'Mainpage' );
		$page = $this->getPage( $title );

		$article = new Article( $page->getTitle() );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString(
			wfMessage( 'mainpage' )->inContentLanguage()->parse(),
			$this->getHtml( $output )
		);
		$this->assertStringNotContainsString( '(noarticletextanon)', $this->getHtml( $output ) );
	}

	public function testViewMissingUserPage() {
		$user = $this->getTestUser()->getUser();
		$user->addToDatabase();

		$title = Title::makeTitle( NS_USER, $user->getName() );

		$page = $this->getPage( $title );

		$article = new Article( $page->getTitle() );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( '(noarticletextanon)', $this->getHtml( $output ) );
		$this->assertStringNotContainsString(
			'(userpage-userdoesnotexist-view)',
			$this->getHtml( $output )
		);
	}

	public function testViewUserPageOfNonexistingUser() {
		$user = User::newFromName( 'Testing ' . __METHOD__ );

		$title = Title::makeTitle( NS_USER, $user->getName() );

		$page = $this->getPage( $title );

		$article = new Article( $page->getTitle() );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );
		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( '(noarticletextanon)', $this->getHtml( $output ) );
		$this->assertStringContainsString(
			'(userpage-userdoesnotexist-view:',
			$this->getHtml( $output )
		);
	}

	public function testArticleViewHeaderHook() {
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A' ] );

		$article = new Article( $page->getTitle(), 0 );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );

		$this->setTemporaryHook(
			'ArticleViewHeader',
			function ( Article $articlePage, &$outputDone, &$useParserCache ) use ( $article ) {
				$this->assertSame( $article, $articlePage, '$articlePage' );

				$outputDone = new ParserOutput( 'Hook Text' );
				$outputDone->setTitleText( 'Hook Title' );

				$articlePage->getContext()->getOutput()->addParserOutput( $outputDone );
			}
		);

		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringNotContainsString( 'Test A', $this->getHtml( $output ) );
		$this->assertStringContainsString( 'Hook Text', $this->getHtml( $output ) );
		$this->assertSame( 'Hook Title', $output->getPageTitle() );
	}

	public function testArticleRevisionViewCustomHook() {
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A' ] );

		$article = new Article( $page->getTitle(), 0 );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );

		// use ArticleViewHeader hook to bypass the parser cache
		$this->setTemporaryHook(
			'ArticleViewHeader',
			static function ( Article $articlePage, &$outputDone, &$useParserCache ) {
				$useParserCache = false;
			}
		);

		$this->setTemporaryHook(
			'ArticleRevisionViewCustom',
			function ( RevisionRecord $rev, Title $title, $oldid, OutputPage $output ) use ( $page ) {
				$content = $rev->getContent( SlotRecord::MAIN );
				$this->assertSame( $page->getTitle(), $title, '$title' );
				$this->assertSame( 'Test A', $content->getText(), '$content' );

				$output->addHTML( 'Hook Text' );
				return false;
			}
		);

		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringNotContainsString( 'Test A', $this->getHtml( $output ) );
		$this->assertStringContainsString( 'Hook Text', $this->getHtml( $output ) );
	}

	public function testShowMissingArticleHook() {
		$page = $this->getPage( __METHOD__ );

		$article = new Article( $page->getTitle() );
		$article->getContext()->getOutput()->setTitle( $page->getTitle() );

		$this->setTemporaryHook(
			'ShowMissingArticle',
			function ( Article $articlePage ) use ( $article ) {
				$this->assertSame( $article, $articlePage, '$articlePage' );

				$articlePage->getContext()->getOutput()->addHTML( 'Hook Text' );
			}
		);

		$article->view();

		$output = $article->getContext()->getOutput();
		$this->assertStringContainsString( '(noarticletextanon)', $this->getHtml( $output ) );
		$this->assertStringContainsString( 'Hook Text', $this->getHtml( $output ) );
	}

	/**
	 * @covers \Article::showViewError()
	 */
	public function testViewLatestError() {
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A' ] );

		$article = new Article( $page->getTitle(), 0 );
		$output = $article->getContext()->getOutput();
		$output->setTitle( $page->getTitle() );

		// use ArticleViewHeader hook to bypass the parser cache
		$this->setTemporaryHook(
			'ArticleViewHeader',
			static function ( Article $articlePage, &$outputDone, &$useParserCache ) {
				$useParserCache = false;
			}
		);

		$article = TestingAccessWrapper::newFromObject( $article );
		$article->fetchResult = Status::newFatal(
			'rev-deleted-text-permission',
			$page->getTitle()->getPrefixedDBkey()
		);

		$article->view();

		$this->assertStringContainsString(
			'rev-deleted-text-permission: ArticleViewTest::testViewLatestError',
			$this->getHtml( $output )
		);
	}

	/**
	 * @covers \Article::showViewError()
	 */
	public function testViewOldError() {
		$revisions = [];
		$page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
		$idA = $revisions[1]->getId();

		$article = new Article( $page->getTitle(), $idA );
		$output = $article->getContext()->getOutput();
		$output->setTitle( $page->getTitle() );

		$article = TestingAccessWrapper::newFromObject( $article );
		$article->fetchResult = Status::newFatal(
			'rev-deleted-text-permission',
			$page->getTitle()->getPrefixedDBkey()
		);

		$article->view();

		$this->assertStringContainsString(
			'rev-deleted-text-permission: ArticleViewTest::testViewOldError',
			$this->getHtml( $output )
		);
	}

	private function getRevDelRevisionList( $title, $revisionId ) {
		$services = $this->getServiceContainer();
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser(
			$this->getTestUser( [ 'sysop' ] )->getUser()
		);
		return new RevDelRevisionList(
			$context,
			$title,
			[ $revisionId ],
			$services->getConnectionProvider(),
			$services->getHookContainer(),
			$services->getHtmlCacheUpdater(),
			$services->getRevisionStore()
		);
	}

	/**
	 * Test the "useParsoid" parser option and the ArticleParserOptions
	 * hook.
	 */
	public function testUseParsoid() {
		// Create an appropriate test page.
		$title = Title::makeTitle( NS_MAIN, 'UseParsoidTest' );
		$article = new Article( $title );
		$page = $this->getExistingTestPage( $title );
		$page->doUserEditContent(
			ContentHandler::makeContent(
				'[[Foo]]',
				$title,
				// Force this page to be wikitext
				CONTENT_MODEL_WIKITEXT
			),
			$this->getTestSysop()->getUser(),
			'TestUseParsoid Summary',
			EDIT_SUPPRESS_RC
		);
		$article->view();
		$html = $this->getHtml( $article->getContext()->getOutput() );
		// Confirm that this is NOT parsoid-generated HTML
		$this->assertStringNotContainsString(
			'rel="mw:WikiLink"',
			$html
		);

		// Now enable Parsoid via the ArticleParserOptions hook
		$article = new Article( $title );
		$this->setTemporaryHook( 'ArticleParserOptions', static function ( $article, $popts ) {
			$popts->setUseParsoid();
		} );
		$article->view();
		$html = $this->getHtml( $article->getContext()->getOutput() );
		// Look for a marker that this is Parsoid-generated HTML
		$this->assertStringContainsString(
			'rel="mw:WikiLink"',
			$html
		);
	}
}
PK       ! ;G$>O  O    page/ArticleTablesTest.phpnu Iw        <?php

use MediaWiki\Content\WikitextContent;
use MediaWiki\Title\Title;

/**
 * @group Database
 */
class ArticleTablesTest extends MediaWikiLangTestCase {

	/**
	 * Make sure that T16404 doesn't strike again. We don't want
	 * templatelinks based on the user language when {{int:}} is used, only the
	 * content language.
	 *
	 * @covers \MediaWiki\Title\Title::getTemplateLinksFrom
	 * @covers \MediaWiki\Title\Title::getLinksFrom
	 */
	public function testTemplatelinksUsesContentLanguage() {
		$title = Title::makeTitle( NS_MAIN, 'T16404' );
		$wikiPageFactory = $this->getServiceContainer()->getWikiPageFactory();
		$page = $wikiPageFactory->newFromTitle( $title );
		$user = $this->getTestUser()->getUser();
		$this->overrideUserPermissions( $user, [ 'createpage', 'edit', 'purge' ] );
		$this->setContentLang( 'es' );
		$this->setUserLang( 'fr' );

		$page->doUserEditContent(
			new WikitextContent( '{{:{{int:history}}}}' ),
			$user,
			'Test code for T16404'
		);
		$templates1 = $title->getTemplateLinksFrom();

		$this->setUserLang( 'de' );
		$page = $wikiPageFactory->newFromTitle( $title ); // In order to force the re-rendering of the same wikitext

		// We need an edit, a purge is not enough to regenerate the tables
		$page->doUserEditContent(
			new WikitextContent( '{{:{{int:history}}}}' ),
			$user,
			'Test code for T16404',
			EDIT_UPDATE
		);
		$templates2 = $title->getTemplateLinksFrom();

		/**
		 * @var Title[] $templates1
		 * @var Title[] $templates2
		 */
		$this->assertEquals( $templates1, $templates2 );
		$this->assertSame( 'Historial', $templates1[0]->getFullText() );
	}
}
PK       ! gb%  b%    page/PagePropsTest.phpnu Iw        <?php

use MediaWiki\Title\Title;
use Wikimedia\Rdbms\FakeResultWrapper;

/**
 * @covers \MediaWiki\Page\PageProps
 * @group Database
 * @group medium
 */
class PagePropsTest extends MediaWikiLangTestCase {

	private ?array $expectedProperties = null;
	private Title $title1;
	private Title $title2;

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

		if ( !$this->expectedProperties ) {
			$this->expectedProperties = [
				"property1" => "value1",
				"property2" => "value2",
				"property3" => "value3",
				"property4" => "value4"
			];

			$page = $this->getExistingTestPage( 'PagePropsTest_page_1' );
			$this->title1 = $page->getTitle();
			$page1ID = $this->title1->getArticleID();
			$this->setProperties( $page1ID, $this->expectedProperties );

			$page = $this->getExistingTestPage( 'PagePropsTest_page_2' );
			$this->title2 = $page->getTitle();
			$page2ID = $this->title2->getArticleID();
			$this->setProperties( $page2ID, $this->expectedProperties );
		}
	}

	/**
	 * Test getting a single property from a single page. The property was
	 * set in setUp().
	 */
	public function testGetSingleProperty() {
		$pageProps = $this->getServiceContainer()->getPageProps();
		$page1ID = $this->title1->getArticleID();
		$result = $pageProps->getProperties( $this->title1, "property1" );
		$this->assertArrayHasKey( $page1ID, $result, "Found property" );
		$this->assertSame( "value1", $result[$page1ID], "Get property" );
	}

	/**
	 * Test getting a single property from multiple pages. The property was
	 * set in setUp(). Using Title[].
	 */
	public function testGetSinglePropertyMultiplePages() {
		$pageProps = $this->getServiceContainer()->getPageProps();
		$page1ID = $this->title1->getArticleID();
		$page2ID = $this->title2->getArticleID();
		$titles = [
			$this->title1,
			$this->title2
		];
		$result = $pageProps->getProperties( $titles, "property1" );
		$this->assertArrayHasKey( $page1ID, $result, "Found page 1 property" );
		$this->assertArrayHasKey( $page2ID, $result, "Found page 2 property" );
		$this->assertSame( "value1", $result[$page1ID], "Get property page 1" );
		$this->assertSame( "value1", $result[$page2ID], "Get property page 2" );
	}

	/**
	 * Test getting a single property from multiple pages. The property was
	 * set in setUp(). Using TitleArray.
	 */
	public function testGetSinglePropertyMultiplePagesTitleArray() {
		$services = $this->getServiceContainer();
		$pageProps = $services->getPageProps();
		$page1ID = $this->title1->getArticleID();
		$page2ID = $this->title2->getArticleID();
		$rows = [
			$this->createRowFromTitle( $this->title1 ),
			$this->createRowFromTitle( $this->title2 )
		];
		$resultWrapper = new FakeResultWrapper( $rows );
		$titles = $services->getTitleFactory()->newTitleArrayFromResult( $resultWrapper );
		$result = $pageProps->getProperties( $titles, "property1" );
		$this->assertArrayHasKey( $page1ID, $result, "Found page 1 property" );
		$this->assertArrayHasKey( $page2ID, $result, "Found page 2 property" );
		$this->assertSame( "value1", $result[$page1ID], "Get property page 1" );
		$this->assertSame( "value1", $result[$page2ID], "Get property page 2" );
	}

	/**
	 * Test getting multiple properties from multiple pages. The properties
	 * were set in setUp().
	 */
	public function testGetMultiplePropertiesMultiplePages() {
		$pageProps = $this->getServiceContainer()->getPageProps();
		$page1ID = $this->title1->getArticleID();
		$page2ID = $this->title2->getArticleID();
		$titles = [
			$this->title1->toPageIdentity(),
			$this->title2->toPageIdentity()
		];
		$properties = [
			"property1",
			"property2"
		];
		$result = $pageProps->getProperties( $titles, $properties );
		$this->assertArrayHasKey( $page1ID, $result, "Found page 1 property" );
		$this->assertArrayHasKey( "property1", $result[$page1ID], "Found page 1 property 1" );
		$this->assertArrayHasKey( "property2", $result[$page1ID], "Found page 1 property 2" );
		$this->assertArrayHasKey( $page2ID, $result, "Found page 2 property" );
		$this->assertArrayHasKey( "property1", $result[$page2ID], "Found page 2 property 1" );
		$this->assertArrayHasKey( "property2", $result[$page2ID], "Found page 2 property 2" );
		$this->assertSame( "value1", $result[$page1ID]["property1"], "Get page 1 property 1" );
		$this->assertSame( "value2", $result[$page1ID]["property2"], "Get page 1 property 2" );
		$this->assertSame( "value1", $result[$page2ID]["property1"], "Get page 2 property 1" );
		$this->assertSame( "value2", $result[$page2ID]["property2"], "Get page 2 property 2" );
	}

	/**
	 * Test getting all properties from a single page. The properties were
	 * set in setUp(). The properties retrieved from the page may include
	 * additional properties not set in the test case that are added by
	 * other extensions. Therefore, rather than checking to see if the
	 * properties that were set in the test case exactly match the
	 * retrieved properties, we need to check to see if they are a
	 * subset of the retrieved properties.
	 */
	public function testGetAllProperties() {
		$pageProps = $this->getServiceContainer()->getPageProps();
		$page1ID = $this->title1->getArticleID();
		$result = $pageProps->getAllProperties( $this->title1 );
		$this->assertArrayHasKey( $page1ID, $result, "Found properties" );

		$properties = $result[$page1ID];
		$subset = array_intersect_key( $properties, $this->expectedProperties );
		$this->assertEquals( $this->expectedProperties, $subset, "Get all properties" );
	}

	/**
	 * Test getting all properties from multiple pages. The properties were
	 * set in setUp(). See getAllProperties() above for more information.
	 */
	public function testGetAllPropertiesMultiplePages() {
		$pageProps = $this->getServiceContainer()->getPageProps();
		$page1ID = $this->title1->getArticleID();
		$page2ID = $this->title2->getArticleID();
		$titles = [
			$this->title1,
			$this->title2
		];
		$result = $pageProps->getAllProperties( $titles );
		$this->assertArrayHasKey( $page1ID, $result, "Found page 1 properties" );
		$this->assertArrayHasKey( $page2ID, $result, "Found page 2 properties" );

		$properties = $result[$page1ID];
		$subset = array_intersect_key( $properties, $this->expectedProperties );
		$this->assertEquals( $this->expectedProperties, $subset, "Properties of page 1" );

		$properties = $result[$page2ID];
		$subset = array_intersect_key( $properties, $this->expectedProperties );
		$this->assertEquals( $this->expectedProperties, $subset, "Properties of page 2" );
	}

	/**
	 * Test caching when retrieving single properties by getting a property,
	 * saving a new value for the property, then getting the property
	 * again. The cached value for the property rather than the new value
	 * of the property should be returned.
	 */
	public function testSingleCache() {
		$pageProps = $this->getServiceContainer()->getPageProps();
		$page1ID = $this->title1->getArticleID();
		$value1 = $pageProps->getProperties( $this->title1, "property1" );
		$this->setProperty( $page1ID, "property1", "another value" );
		$value2 = $pageProps->getProperties( $this->title1, "property1" );

		$this->assertEquals( $value1, $value2, "Single cache" );
	}

	/**
	 * Test caching when retrieving all properties by getting all
	 * properties, saving a new value for a property, then getting all
	 * properties again. The cached value for the properties rather than the
	 * new value of the properties should be returned.
	 */
	public function testMultiCache() {
		$pageProps = $this->getServiceContainer()->getPageProps();
		$page1ID = $this->title1->getArticleID();
		$properties1 = $pageProps->getAllProperties( $this->title1 );
		$this->setProperty( $page1ID, "property1", "another value" );
		$properties2 = $pageProps->getAllProperties( $this->title1 );

		$this->assertEquals( $properties1, $properties2, "Multi Cache" );
	}

	/**
	 * Test that getting all properties clears the single properties
	 * that have been cached by getting a property, saving a new value for
	 * the property, getting all properties (which clears the cached single
	 * properties), then getting the property again. The new value for the
	 * property rather than the cached value of the property should be
	 * returned.
	 */
	public function testClearCache() {
		$pageProps = $this->getServiceContainer()->getPageProps();
		$page1ID = $this->title1->getArticleID();
		$pageProps->getProperties( $this->title1, "property1" );
		$new_value = "another value";
		$this->setProperty( $page1ID, "property1", $new_value );
		$pageProps->getAllProperties( $this->title1 );
		$result = $pageProps->getProperties( $this->title1, "property1" );
		$this->assertArrayHasKey( $page1ID, $result, "Found property" );
		$this->assertSame( "another value", $result[$page1ID], "Clear cache" );
	}

	protected function setProperties( $pageID, $properties ) {
		$queryBuilder = $this->getDb()->newReplaceQueryBuilder()
			->replaceInto( 'page_props' )
			->uniqueIndexFields( [ 'pp_page', 'pp_propname' ] );
		foreach ( $properties as $propertyName => $propertyValue ) {
			$queryBuilder->row( [
				'pp_page' => $pageID,
				'pp_propname' => $propertyName,
				'pp_value' => $propertyValue
			] );
		}
		$queryBuilder->caller( __METHOD__ )->execute();
	}

	protected function setProperty( $pageID, $propertyName, $propertyValue ) {
		$properties = [
			$propertyName => $propertyValue
		];
		$this->setProperties( $pageID, $properties );
	}

	protected function createRowFromTitle( $title ) {
		return (object)[
			'page_namespace' => $title->getNamespace(),
			'page_title' => $title->getText()
		];
	}
}
PK       !  ,      page/WikiCategoryPageTest.phpnu Iw        <?php

use MediaWiki\Page\PageProps;
use MediaWiki\Title\Title;

class WikiCategoryPageTest extends MediaWikiLangTestCase {

	/**
	 * @covers \WikiCategoryPage::isHidden
	 */
	public function testHiddenCategory_PropertyNotSet() {
		$title = Title::makeTitle( NS_CATEGORY, 'CategoryPage' );
		$title->resetArticleID( 42 );
		$categoryPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$pageProps = $this->createMock( PageProps::class );
		$pageProps->expects( $this->once() )
			->method( 'getProperties' )
			->with( $title, 'hiddencat' )
			->willReturn( [] );

		$this->setService( 'PageProps', $pageProps );

		$this->assertFalse( $categoryPage->isHidden() );
	}

	public static function provideCategoryContent() {
		return [
			[ true ],
			[ false ],
		];
	}

	/**
	 * @dataProvider provideCategoryContent
	 * @covers \WikiCategoryPage::isHidden
	 */
	public function testHiddenCategory_PropertyIsSet( $isHidden ) {
		$categoryPageID = 42;
		$categoryTitle = Title::makeTitle( NS_CATEGORY, 'CategoryPage' );
		$categoryTitle->resetArticleID( $categoryPageID );
		$categoryPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $categoryTitle );

		$pageProps = $this->createMock( PageProps::class );
		$pageProps->expects( $this->once() )
			->method( 'getProperties' )
			->with( $categoryTitle, 'hiddencat' )
			->willReturn( $isHidden ? [ $categoryPageID => '' ] : [] );

		$this->setService( 'PageProps', $pageProps );

		$this->assertEquals( $isHidden, $categoryPage->isHidden() );
	}

	/**
	 * @covers \WikiCategoryPage::isExpectedUnusedCategory
	 */
	public function testExpectUnusedCategory_PropertyNotSet() {
		$title = Title::makeTitle( NS_CATEGORY, 'CategoryPage' );
		$title->resetArticleID( 42 );
		$categoryPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$pageProps = $this->createMock( PageProps::class );
		$pageProps->expects( $this->once() )
			->method( 'getProperties' )
			->with( $title, 'expectunusedcategory' )
			->willReturn( [] );

		$this->setService( 'PageProps', $pageProps );

		$this->assertFalse( $categoryPage->isExpectedUnusedCategory() );
	}

	/**
	 * @dataProvider provideCategoryContent
	 * @covers \WikiCategoryPage::isExpectedUnusedCategory
	 */
	public function testExpectUnusedCategory_PropertyIsSet( $isExpectedUnusedCategory ) {
		$categoryPageID = 42;
		$categoryTitle = Title::makeTitle( NS_CATEGORY, 'CategoryPage' );
		$categoryTitle->resetArticleID( $categoryPageID );
		$categoryPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $categoryTitle );
		$returnValue = $isExpectedUnusedCategory ? [ $categoryPageID => '' ] : [];

		$pageProps = $this->createMock( PageProps::class );
		$pageProps->expects( $this->once() )
			->method( 'getProperties' )
			->with( $categoryTitle, 'expectunusedcategory' )
			->willReturn( $returnValue );

		$this->setService( 'PageProps', $pageProps );

		$this->assertEquals( $isExpectedUnusedCategory, $categoryPage->isExpectedUnusedCategory() );
	}
}
PK       ! b  b    page/PageStoreTest.phpnu Iw        <?php
namespace MediaWiki\Tests\Page;

use Exception;
use InvalidArgumentException;
use LinkCacheTestTrait;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\DAO\WikiAwareEntity;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageRecord;
use MediaWiki\Page\PageStore;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use MediaWikiIntegrationTestCase;
use MockTitleTrait;
use Wikimedia\Assert\PreconditionException;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IDBAccessObject;
use Wikimedia\Rdbms\LoadBalancer;
use Wikimedia\Stats\Metrics\MetricInterface;
use Wikimedia\Stats\StatsFactory;

/**
 * @group Database
 */
class PageStoreTest extends MediaWikiIntegrationTestCase {

	use MockTitleTrait;
	use LinkCacheTestTrait;

	private StatsFactory $statsFactory;
	private MetricInterface $linkCacheAccesses;

	protected function setUp(): void {
		parent::setUp();
		$this->statsFactory = StatsFactory::newNull();

		$this->linkCacheAccesses = $this->statsFactory->getCounter( 'pagestore_linkcache_accesses_total' );
	}

	/**
	 * @param array $options
	 * @param array $params
	 *
	 * @return PageStore
	 * @throws Exception
	 */
	private function getPageStore( $options = [], $params = [] ) {
		$services = $this->getServiceContainer();

		$serviceOptions = new ServiceOptions(
			PageStore::CONSTRUCTOR_OPTIONS,
			$options + [
				MainConfigNames::LanguageCode => $services->getContentLanguage()->getCode(),
				MainConfigNames::PageLanguageUseDB => true,
			]
		);

		return new PageStore(
			$serviceOptions,
			$params['dbLoadBalancer'] ?? $services->getDBLoadBalancer(),
			$services->getNamespaceInfo(),
			$services->getTitleParser(),
			array_key_exists( 'linkCache', $params )
				? $params['linkCache']
				: $services->getLinkCache(),
			$this->statsFactory,
			$params['wikiId'] ?? WikiAwareEntity::LOCAL
		);
	}

	/**
	 * @param PageIdentity $expected
	 * @param PageIdentity $actual
	 */
	private function assertSamePage( PageIdentity $expected, PageIdentity $actual ) {
		// NOTE: Leave it to the caller to compare the wiki IDs. $expected may be local
		//       even if $actual belongs to a (pretend) sister site.
		$expWiki = $expected->getWikiId();
		$actWiki = $actual->getWikiId();

		$this->assertSame( $expected->getId( $expWiki ), $actual->getId( $actWiki ) );
		$this->assertSame( $expected->getNamespace(), $actual->getNamespace() );
		$this->assertSame( $expected->getDBkey(), $actual->getDBkey() );

		if ( $expected instanceof PageRecord ) {
			$this->assertInstanceOf( PageRecord::class, $actual );

			/** @var PageRecord $actual */
			$this->assertSame( $expected->getLatest( $expWiki ), $actual->getLatest( $actWiki ) );
			$this->assertSame( $expected->getLanguage(), $actual->getLanguage() );
			$this->assertSame( $expected->getTouched(), $actual->getTouched() );
		}
	}

	/**
	 * Test that we get a PageIdentity for a link referencing a non-existing page
	 * @covers \MediaWiki\Page\PageStore::getPageForLink
	 */
	public function testGetPageForLink_nonexisting() {
		$nonexistingPage = $this->getNonexistingTestPage();
		$pageStore = $this->getPageStore();

		$page = $pageStore->getPageForLink( $nonexistingPage->getTitle() );

		$this->assertTrue( $page->canExist() );
		$this->assertFalse( $page->exists() );

		$this->assertSamePage( $nonexistingPage->getTitle(), $page );
	}

	/**
	 * Test that we get a PageRecord for a link to an existing page.
	 * @covers \MediaWiki\Page\PageStore::getPageForLink
	 */
	public function testGetPageForLink_existing() {
		$existingPage = $this->getExistingTestPage();
		$pageStore = $this->getPageStore();

		$page = $pageStore->getPageForLink( $existingPage->getTitle() );

		$this->assertTrue( $page->exists() );
		$this->assertInstanceOf( PageRecord::class, $page );
		$this->assertSamePage( $existingPage->toPageRecord(), $page );
	}

	/**
	 * Test that getPageForLink() can get a PageIdentity from another wiki
	 * @covers \MediaWiki\Page\PageStore::getPageForLink
	 */
	public function testGetPageForLink_crossWiki() {
		$wikiId = $this->getDb()->getDomainID(); // pretend sister site

		$nonexistingPage = $this->getNonexistingTestPage();
		$pageStore = $this->getPageStore( [], [ 'wikiId' => $wikiId, 'linkCache' => null ] );

		$page = $pageStore->getPageForLink( $nonexistingPage->getTitle() );

		$this->assertSame( $wikiId, $page->getWikiId() );
		$this->assertSamePage( $nonexistingPage->getTitle(), $page );
	}

	/**
	 * Test that getPageForLink() maps NS_MEDIA to NS_FILE
	 * @covers \MediaWiki\Page\PageStore::getPageForLink
	 */
	public function testGetPageForLink_media() {
		$link = new TitleValue( NS_MEDIA, 'Test913847659234.jpg' );
		$pageStore = $this->getPageStore();

		$page = $pageStore->getPageForLink( $link );

		$this->assertTrue( $page->canExist() );
		$this->assertSame( NS_FILE, $page->getNamespace() );
		$this->assertSame( $link->getDBkey(), $page->getDBkey() );
	}

	public static function provideInvalidLinks() {
		yield 'section link' => [ new TitleValue( NS_MAIN, '', '#References' ) ];
		yield 'special page' => [ new TitleValue( NS_SPECIAL, 'Test' ) ];
		yield 'interwiki link' => [ new TitleValue( NS_MAIN, 'Test', '', 'acme' ) ];
	}

	/**
	 * Test that getPageForLink() throws InvalidArgumentException when presented with
	 * a link that does not refer to a proper page.
	 *
	 * @dataProvider provideInvalidLinks
	 * @covers \MediaWiki\Page\PageStore::getPageForLink
	 */
	public function testGetPageForLink_invalid( $link ) {
		$pageStore = $this->getPageStore();

		$this->expectException( InvalidArgumentException::class );
		$pageStore->getPageForLink( $link );
	}

	/**
	 * Test that we get a PageRecord for an existing page by name
	 * @covers \MediaWiki\Page\PageStore::getPageByName
	 */
	public function testGetPageByName_existing() {
		$existingPage = $this->getExistingTestPage();
		$ns = $existingPage->getNamespace();
		$dbkey = $existingPage->getDBkey();

		$pageStore = $this->getPageStore();
		$page = $pageStore->getPageByName( $ns, $dbkey );

		$this->assertTrue( $page->exists() );
		$this->assertSamePage( $existingPage, $page );

		$linkCache = $this->getServiceContainer()->getLinkCache();
		$this->assertSame( $page->getId(), $linkCache->getGoodLinkID( $page ) );
	}

	/**
	 * Test that we get a PageRecord for an existing page by name
	 * with no LinkCache provided.
	 * @covers \MediaWiki\Page\PageStore::getPageByName
	 */
	public function testGetPageByName_existing_noLinkCache() {
		$existingPage = $this->getExistingTestPage();
		$ns = $existingPage->getNamespace();
		$dbkey = $existingPage->getDBkey();

		$pageStore = $this->getPageStore( [], [ 'linkCache' => null ] );
		$page = $pageStore->getPageByName( $ns, $dbkey );

		$this->assertTrue( $page->exists() );
		$this->assertSamePage( $existingPage, $page );

		$linkCache = $this->getServiceContainer()->getLinkCache();
		$this->assertSame( $page->getId(), $linkCache->getGoodLinkID( $page ) );
		$this->assertSame( 0, $this->linkCacheAccesses->getSampleCount() );
	}

	/**
	 * Test that we get null if we look up a non-existing page by name
	 * @covers \MediaWiki\Page\PageStore::getPageByName
	 */
	public function testGetPageByName_nonexisting() {
		$nonexistingPage = $this->getNonexistingTestPage();
		$ns = $nonexistingPage->getNamespace();
		$dbkey = $nonexistingPage->getDBkey();

		$pageStore = $this->getPageStore();
		$page = $pageStore->getPageByName( $ns, $dbkey );

		$this->assertNull( $page );

		$linkCache = $this->getServiceContainer()->getLinkCache();
		$this->assertTrue( $linkCache->isBadLink( $nonexistingPage ) );
	}

	/**
	 * Test that we get null if we look up a page known to be not existing,
	 * without hitting the database.
	 * @covers \MediaWiki\Page\PageStore::getPageByName
	 */
	public function testGetPageByName_nonexisting_cached() {
		$nonexistingPage = $this->getNonexistingTestPage();
		$ns = $nonexistingPage->getNamespace();
		$dbkey = $nonexistingPage->getDBkey();

		$linkCache = $this->getServiceContainer()->getLinkCache();
		$linkCache->addBadLinkObj( $nonexistingPage );

		$mockLB = $this->createNoOpMock( LoadBalancer::class );
		$pageStore = $this->getPageStore( [], [ 'doLoadBalancer' => $mockLB ] );
		$page = $pageStore->getPageByName( $ns, $dbkey );

		$this->assertNull( $page );
		$this->assertTrue( $linkCache->isBadLink( $nonexistingPage ) );
		$this->assertSame( 1, $this->linkCacheAccesses->getSampleCount() );
	}

	/**
	 * Test that we get a PageRecord from a cached row
	 * @covers \MediaWiki\Page\PageStore::getPageByName
	 */
	public function testGetPageByName_cachedFullRow() {
		$nonexistingPage = $this->getNonexistingTestPage();
		$ns = $nonexistingPage->getNamespace();
		$dbkey = $nonexistingPage->getDBkey();

		$row = (object)[
			'page_id' => 8,
			'page_namespace' => $ns,
			'page_title' => $dbkey,
			'page_is_redirect' => 0,
			'page_is_new' => 1,
			'page_touched' => '12345',
			'page_links_updated' => '12345',
			'page_latest' => 118,
			'page_len' => 155,
			'page_content_model' => CONTENT_FORMAT_TEXT,
			'page_lang' => 'xyz',
		];

		$linkCache = $this->getServiceContainer()->getLinkCache();
		$linkCache->addGoodLinkObjFromRow( $nonexistingPage, $row );

		$mockLB = $this->createNoOpMock( LoadBalancer::class );

		$pageStore = $this->getPageStore( [], [ 'doLoadBalancer' => $mockLB ] );
		$page = $pageStore->getPageByName( $ns, $dbkey );

		$this->assertSame( $row->page_id, $page->getId() );
		$this->assertSame( $row->page_namespace, $page->getNamespace() );
		$this->assertSame( $row->page_title, $page->getDBkey() );
		$this->assertSame( $row->page_latest, $page->getLatest() );
		$this->assertSame( 1, $this->linkCacheAccesses->getSampleCount() );
	}

	/**
	 * Test that we get a PageRecord when an incomplete row exists in the cache
	 * @covers \MediaWiki\Page\PageStore::getPageByName
	 */
	public function testGetPageByName_cachedFakeRow() {
		$nonexistingPage = $this->getNonexistingTestPage();
		$ns = $nonexistingPage->getNamespace();
		$dbkey = $nonexistingPage->getDBkey();

		$linkCache = $this->getServiceContainer()->getLinkCache();
		$linkCache->clearLink( $nonexistingPage );

		// Has all fields needed by LinkCache, but not all fields needed by PageStore.
		// This may happen when legacy code injects rows directly into LinkCache.
		$this->addGoodLinkObject( 8, $nonexistingPage );

		$pageStore = $this->getPageStore();
		$page = $pageStore->getPageByName( $ns, $dbkey );

		$this->assertSame( 8, $page->getId() );
		$this->assertSame( $nonexistingPage->getNamespace(), $page->getNamespace() );
		$this->assertSame( $nonexistingPage->getDBkey(), $page->getDBkey() );
		$this->assertSame( 1, $this->linkCacheAccesses->getSampleCount() );
	}

	/**
	 * @covers \MediaWiki\Page\PageStore::getPageByText
	 */
	public function testGetPageByText_existing() {
		$existingPage = $this->getExistingTestPage();

		$pageStore = $this->getPageStore();
		$page = $pageStore->getPageByText( $existingPage->getTitle()->getPrefixedText() );

		$this->assertTrue( $page->exists() );
		$this->assertSamePage( $existingPage, $page );

		$page = $pageStore->getExistingPageByText( $existingPage->getTitle()->getPrefixedText() );

		$this->assertTrue( $page->exists() );
		$this->assertSamePage( $existingPage, $page );
	}

	/**
	 * @covers \MediaWiki\Page\PageStore::getPageByText
	 */
	public function testGetPageByText_nonexisting() {
		$nonexistingPage = $this->getNonexistingTestPage();
		$pageStore = $this->getPageStore();
		$page = $pageStore->getPageByText( $nonexistingPage->getTitle()->getPrefixedText() );
		$this->assertFalse( $page->exists() );
		$this->assertTrue( $nonexistingPage->isSamePageAs( $page ) );
	}

	/**
	 * @covers \MediaWiki\Page\PageStore::getExistingPageByText
	 */
	public function testGetExistingPageByText_existing() {
		$existingPage = $this->getExistingTestPage();

		$pageStore = $this->getPageStore();
		$page = $pageStore->getExistingPageByText( $existingPage->getTitle()->getPrefixedText() );

		$this->assertTrue( $page->exists() );
		$this->assertSamePage( $existingPage, $page );
	}

	/**
	 * @covers \MediaWiki\Page\PageStore::getExistingPageByText
	 */
	public function testGetExistingPageByText_nonexisting() {
		$nonexistingPage = $this->getNonexistingTestPage();
		$pageStore = $this->getPageStore();
		$page = $pageStore->getExistingPageByText( $nonexistingPage->getTitle()->getPrefixedText() );
		$this->assertNull( $page );
	}

	/**
	 * Configure the load balancer to route queries for the "foreign" domain to the test DB.
	 *
	 * @param string $wikiId
	 */
	private function setDomainAlias( $wikiId ) {
		$dbLoadBalancer = $this->getServiceContainer()->getDBLoadBalancer();
		$dbLoadBalancer->setDomainAliases( [ $wikiId => $dbLoadBalancer->getLocalDomainID() ] );
	}

	/**
	 * Test that we get a PageRecord from another wiki by name
	 * @covers \MediaWiki\Page\PageStore::getPageByName
	 */
	public function testGetPageByName_crossWiki() {
		$wikiId = 'acme';
		$this->setDomainAlias( $wikiId );

		$existingPage = $this->getExistingTestPage();
		$ns = $existingPage->getNamespace();
		$dbkey = $existingPage->getDBkey();

		$pageStore = $this->getPageStore( [], [ 'wikiId' => $wikiId, 'linkCache' => null ] );
		$page = $pageStore->getPageByName( $ns, $dbkey );

		$this->assertSame( $wikiId, $page->getWikiId() );
		$this->assertSamePage( $existingPage, $page );
	}

	public static function provideGetPageByName_invalid() {
		yield 'empty title' => [ NS_MAIN, '' ];
		yield 'spaces in title' => [ NS_MAIN, 'Foo Bar' ];
		yield 'special page' => [ NS_SPECIAL, 'Test' ];
		yield 'media link' => [ NS_MEDIA, 'Test' ];
	}

	/**
	 * Test that getPageByName() throws InvalidArgumentException when presented with
	 * a link that does not refer to a proper page.
	 *
	 * @dataProvider provideGetPageByName_invalid
	 * @covers \MediaWiki\Page\PageStore::getPageByName
	 */
	public function testGetPageByName_invalid( $ns, $dbkey ) {
		$pageStore = $this->getPageStore();

		$this->expectException( InvalidArgumentException::class );
		$pageStore->getPageByName( $ns, $dbkey );
	}

	public static function provideInvalidTitleText() {
		yield 'empty' => [ '' ];
		yield 'section' => [ '#foo' ];
		yield 'autoblock' => [ 'User:#12345' ];
		yield 'special' => [ 'Special:RecentChanges' ];
		yield 'invalid' => [ 'foo|bar' ];
	}

	/**
	 * @dataProvider provideInvalidTitleText
	 * @covers \MediaWiki\Page\PageStore::getPageByText
	 */
	public function testGetPageByText_invalid( $text ) {
		$pageStore = $this->getPageStore();
		$page = $pageStore->getPageByText( $text );
		$this->assertNull( $page );
	}

	/**
	 * @dataProvider provideInvalidTitleText
	 * @covers \MediaWiki\Page\PageStore::getExistingPageByText
	 */
	public function testGetExistingPageByText_invalid( $text ) {
		$pageStore = $this->getPageStore();
		$page = $pageStore->getExistingPageByText( $text );
		$this->assertNull( $page );
	}

	/**
	 * Test that we get a PageRecord for an existing page by id
	 * @covers \MediaWiki\Page\PageStore::getPageById
	 */
	public function testGetPageById_existing() {
		$existingPage = $this->getExistingTestPage();

		$pageStore = $this->getPageStore();
		$page = $pageStore->getPageById( $existingPage->getId() );

		$this->assertTrue( $page->exists() );
		$this->assertSamePage( $existingPage, $page );
	}

	/**
	 * Test that we get null if we look up a non-existing page by id
	 * @covers \MediaWiki\Page\PageStore::getPageById
	 */
	public function testGetPageById_nonexisting() {
		$pageStore = $this->getPageStore();
		$page = $pageStore->getPageById( 813451092 );

		$this->assertNull( $page );
	}

	/**
	 * Test that we get a PageRecord from another wiki by id
	 * @covers \MediaWiki\Page\PageStore::getPageById
	 */
	public function testGetPageById_crossWiki() {
		$wikiId = 'acme';
		$this->setDomainAlias( $wikiId );

		$existingPage = $this->getExistingTestPage();

		$pageStore = $this->getPageStore( [], [ 'wikiId' => $wikiId, 'linkCache' => null ] );
		$page = $pageStore->getPageById( $existingPage->getId() );

		$this->assertSame( $wikiId, $page->getWikiId() );
		$this->assertSamePage( $existingPage, $page );
	}

	/**
	 * Test that we can correctly emulate the page_lang field.
	 * @covers \MediaWiki\Page\PageStore::getPageById
	 */
	public function testGetPageById_noLanguage() {
		$existingPage = $this->getExistingTestPage();

		$pageStore = $this->getPageStore();
		$page = $pageStore->getPageById( $existingPage->getId() );

		$this->assertNull( $page->getLanguage() );
	}

	public static function provideGetPageById_invalid() {
		yield 'zero' => [ 0 ];
		yield 'negative' => [ -1 ];
	}

	/**
	 * Test that getPageById() throws InvalidArgumentException for bad IDs.
	 *
	 * @dataProvider provideGetPageById_invalid
	 * @covers \MediaWiki\Page\PageStore::getPageById
	 */
	public function testGetPageById_invalid( $id ) {
		$pageStore = $this->getPageStore();

		$this->expectException( InvalidArgumentException::class );
		$pageStore->getPageById( $id );
	}

	/**
	 * Test that we get a PageRecord for an existing page by id
	 *
	 * @covers \MediaWiki\Page\PageStore::getPageByReference
	 */
	public function testGetPageByIdentity_existing() {
		$existingPage = $this->getExistingTestPage();
		$identity = $existingPage->getTitle()->toPageIdentity();

		$pageStore = $this->getPageStore();
		$page = $pageStore->getPageByReference( $identity );

		$this->assertTrue( $page->exists() );
		$this->assertSamePage( $existingPage, $page );
	}

	/**
	 * Test that we get a PageRecord from cached data even if we pass in a
	 * PageIdentity that provides a page ID (T296063#7520023).
	 *
	 * @covers \MediaWiki\Page\PageStore::getPageByReference
	 */
	public function testGetPageByIdentity_cached() {
		$title = $this->makeMockTitle( __METHOD__, [ 'id' => 23 ] );
		$this->addGoodLinkObject( 23, $title );

		$pageStore = $this->getPageStore();
		$page = $pageStore->getPageByReference( $title );

		$this->assertNotNull( $page );
		$this->assertSame( 23, $page->getId() );
	}

	/**
	 * Test that we get null if we look up a page with ID 0
	 *
	 * @covers \MediaWiki\Page\PageStore::getPageByReference
	 */
	public function testGetPageByIdentity_knowNonexisting() {
		$nonexistingPage = new PageIdentityValue( 0, NS_MAIN, 'Test', PageIdentity::LOCAL );

		$pageStore = $this->getPageStore();
		$page = $pageStore->getPageByReference( $nonexistingPage );

		$this->assertNull( $page );
	}

	/**
	 * Test that we get null if we look up a page with an ID that does not exist
	 *
	 * @covers \MediaWiki\Page\PageStore::getPageByReference
	 */
	public function testGetPageByIdentity_notFound() {
		$nonexistingPage = new PageIdentityValue( 523478562, NS_MAIN, 'Test', PageIdentity::LOCAL );

		$pageStore = $this->getPageStore();
		$page = $pageStore->getPageByReference( $nonexistingPage );

		$this->assertNull( $page );
	}

	/**
	 * Test that getPageByIdentity() returns any ExistingPageRecord unchanged
	 *
	 * @covers \MediaWiki\Page\PageStore::getPageByReference
	 */
	public function testGetPageByIdentity_PageRecord() {
		$existingPage = $this->getExistingTestPage();
		$rec = $existingPage->toPageRecord();

		$pageStore = $this->getPageStore();
		$page = $pageStore->getPageByReference( $rec );

		$this->assertSame( $rec, $page );
	}

	/**
	 * Test that we get a PageRecord from another wiki by id
	 *
	 * @covers \MediaWiki\Page\PageStore::getPageByReference
	 */
	public function testGetPageByIdentity_crossWiki() {
		$wikiId = 'acme';
		$this->setDomainAlias( $wikiId );

		$existingPage = $this->getExistingTestPage();

		$identity = new PageIdentityValue(
			$existingPage->getId(),
			$existingPage->getNamespace(),
			$existingPage->getDBkey(),
			$wikiId
		);

		$pageStore = $this->getPageStore( [], [ 'wikiId' => $wikiId, 'linkCache' => null ] );
		$page = $pageStore->getPageByReference( $identity );

		$this->assertSame( $wikiId, $page->getWikiId() );
		$this->assertSamePage( $existingPage, $page );
	}

	public function provideGetPageByIdentity_invalid() {
		yield 'section' => [
			$this->makeMockTitle( '', [ 'fragment' => 'See also' ] ),
			InvalidArgumentException::class
		];
		yield 'special' => [
			$this->makeMockTitle( 'Blankpage', [ 'namespace' => NS_SPECIAL ] ),
			InvalidArgumentException::class
		];
		yield 'interwiki' => [
			$this->makeMockTitle( 'Foo', [ 'interwiki' => 'acme' ] ),
			InvalidArgumentException::class
		];

		$identity = new PageIdentityValue( 7, NS_MAIN, 'Test', 'acme' );
		yield 'cross-wiki' => [ $identity, PreconditionException::class ];
	}

	/**
	 * Test that getPageByIdentity() throws InvalidArgumentException for bad IDs.
	 *
	 * @dataProvider provideGetPageByIdentity_invalid
	 * @covers \MediaWiki\Page\PageStore::getPageByReference
	 */
	public function testGetPageByIdentity_invalid( $identity, $exception ) {
		$pageStore = $this->getPageStore();

		$this->expectException( $exception );
		$pageStore->getPageByReference( $identity );
	}

	/**
	 * @covers \MediaWiki\Page\PageStore::newPageRecordFromRow
	 * @covers \MediaWiki\Page\PageStore::getSelectFields
	 */
	public function testNewPageRecordFromRow() {
		$existingPage = $this->getExistingTestPage();
		$pageStore = $this->getPageStore();

		$row = $this->getDb()->newSelectQueryBuilder()
			->select( $pageStore->getSelectFields() )
			->from( 'page' )
			->where( [ 'page_id' => $existingPage->getId() ] )
			->fetchRow();

		$rec = $pageStore->newPageRecordFromRow( $row );
		$this->assertSamePage( $existingPage, $rec );
	}

	/**
	 * @covers \MediaWiki\Page\PageStore::newSelectQueryBuilder
	 */
	public function testNewSelectQueryBuilder() {
		$existingPage = $this->getExistingTestPage();

		$wikiId = 'acme';
		$this->setDomainAlias( $wikiId );

		$pageStore = $this->getPageStore( [], [ 'wikiId' => $wikiId, 'linkCache' => null ] );

		$rec = $pageStore->newSelectQueryBuilder()
			->wherePageIds( $existingPage->getId() )
			->fetchPageRecord();

		$this->assertSame( $wikiId, $rec->getWikiId() );
		$this->assertSamePage( $existingPage, $rec );
	}

	/**
	 * @covers \MediaWiki\Page\PageStore::newSelectQueryBuilder
	 */
	public function testNewSelectQueryBuilder_passDatabase() {
		$pageStore = $this->getPageStore();

		// Test that the provided DB connection is used.
		$db = $this->createMock( IDatabase::class );
		$db->expects( $this->atLeastOnce() )->method( 'selectRow' )->willReturn( false );

		$pageStore->newSelectQueryBuilder( $db )
			->fetchPageRecord();
	}

	/**
	 * @covers \MediaWiki\Page\PageStore::newSelectQueryBuilder
	 */
	public function testNewSelectQueryBuilder_passFlags() {
		// Test that the provided DB connection is used.
		$db = $this->createMock( IDatabase::class );
		$db->expects( $this->atLeastOnce() )->method( 'selectRow' )->willReturn( false );

		// Test that the load balancer is asked for a master connection
		$lb = $this->createMock( LoadBalancer::class );
		$lb->expects( $this->atLeastOnce() )
			->method( 'getConnection' )
			->with( DB_PRIMARY )
			->willReturn( $db );

		$pageStore = $this->getPageStore(
			[
				MainConfigNames::LanguageCode => 'qxx',
				MainConfigNames::PageLanguageUseDB => true,
			],
			[ 'dbLoadBalancer' => $lb ]
		);

		$pageStore->newSelectQueryBuilder( IDBAccessObject::READ_LATEST )
			->fetchPageRecord();
	}

	/**
	 * @covers \MediaWiki\Page\PageStore::getSubpages
	 */
	public function testGetSubpages() {
		$existingPage = $this->getExistingTestPage();
		$title = $existingPage->getTitle();

		$this->overrideConfigValue(
			MainConfigNames::NamespacesWithSubpages,
			[ $title->getNamespace() => true ]
		);

		$existingSubpageA = $this->getExistingTestPage( $title->getSubpage( 'A' ) );
		$existingSubpageB = $this->getExistingTestPage( $title->getSubpage( 'B' ) );

		$notQuiteSubpageTitle = $title->getPrefixedDBkey() . 'X'; // no slash!
		$this->getExistingTestPage( $notQuiteSubpageTitle );

		$pageStore = $this->getPageStore();

		$subpages = iterator_to_array( $pageStore->getSubpages( $title, 100 ) );

		$this->assertCount( 2, $subpages );
		$this->assertTrue( $existingSubpageA->isSamePageAs( $subpages[0] ) );
		$this->assertTrue( $existingSubpageB->isSamePageAs( $subpages[1] ) );

		// make sure the limit works as well
		$this->assertCount( 1, iterator_to_array( $pageStore->getSubpages( $title, 1 ) ) );
	}

	/**
	 * @covers \MediaWiki\Page\PageStore::getSubpages
	 */
	public function testGetSubpages_disabled() {
		$this->overrideConfigValue( MainConfigNames::NamespacesWithSubpages, [] );

		$existingPage = $this->getExistingTestPage();
		$title = $existingPage->getTitle();

		$this->getExistingTestPage( $title->getSubpage( 'A' ) );
		$this->getExistingTestPage( $title->getSubpage( 'B' ) );

		$pageStore = $this->getPageStore();
		$this->assertCount( 0, $pageStore->getSubpages( $title, 100 ) );
	}

	/**
	 * See T295931. If removing TitleExists hook, remove this test.
	 *
	 * @covers \MediaWiki\Page\PageStore::getPageByReference
	 */
	public function testGetPageByReferenceTitleExistsHook() {
		$this->setTemporaryHook( 'TitleExists', static function ( $title, &$exists ) {
			$exists = true;
		} );
		$this->assertNull(
			$this->getPageStore()->getPageByReference(
				Title::newFromText( __METHOD__ )
			)
		);
	}

}
PK       ! &@n  n    page/ParserOutputAccessTest.phpnu Iw        <?php

use MediaWiki\Content\WikitextContent;
use MediaWiki\Json\JsonCodec;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\Logger\Spi as LoggerSpi;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Page\Hook\OpportunisticLinksUpdateHook;
use MediaWiki\Page\PageRecord;
use MediaWiki\Page\ParserOutputAccess;
use MediaWiki\Page\WikiPageFactory;
use MediaWiki\Parser\ParserCache;
use MediaWiki\Parser\ParserCacheFactory;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\RevisionOutputCache;
use MediaWiki\PoolCounter\PoolCounter;
use MediaWiki\PoolCounter\PoolCounterWork;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionRenderer;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Status\Status;
use MediaWiki\Title\TitleFormatter;
use MediaWiki\Utils\MWTimestamp;
use Psr\Log\NullLogger;
use Wikimedia\ObjectCache\EmptyBagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Rdbms\ChronologyProtector;
use Wikimedia\Rdbms\ILBFactory;
use Wikimedia\Stats\StatsFactory;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Page\ParserOutputAccess
 * @group Database
 */
class ParserOutputAccessTest extends MediaWikiIntegrationTestCase {

	public int $actualCallsToPoolWorkArticleView = 0;
	public int $expectedCallsToPoolWorkArticleView = 0;

	public function tearDown(): void {
		$this->assertSame(
			$this->expectedCallsToPoolWorkArticleView,
			$this->actualCallsToPoolWorkArticleView,
			'Calls to newPoolWorkArticleView'
		);

		parent::tearDown();
	}

	private function getHtml( $value ) {
		if ( $value instanceof StatusValue ) {
			$value = $value->getValue();
		}

		if ( $value instanceof ParserOutput ) {
			$pipeline = MediaWikiServices::getInstance()->getDefaultOutputPipeline();
			$value = $pipeline->run( $value, $this->getParserOptions(), [] )->getContentHolderText();
		}

		$html = preg_replace( '/<!--.*?-->/s', '', $value );
		$html = trim( preg_replace( '/[\r\n]{2,}/', "\n", $html ) );
		$html = trim( preg_replace( '/\s{2,}/', ' ', $html ) );
		return $html;
	}

	private function assertContainsHtml( $needle, $actual, $msg = '' ) {
		$this->assertNotNull( $actual );

		if ( $actual instanceof StatusValue ) {
			$this->assertStatusOK( $actual, 'isOK' );
		}

		$this->assertStringContainsString( $needle, $this->getHtml( $actual ), $msg );
	}

	private function assertSameHtml( $expected, $actual, $msg = '' ) {
		$this->assertNotNull( $actual );

		if ( $actual instanceof StatusValue ) {
			$this->assertStatusOK( $actual, 'isOK' );
		}

		$this->assertSame( $this->getHtml( $expected ), $this->getHtml( $actual ), $msg );
	}

	private function assertNotSameHtml( $expected, $actual, $msg = '' ) {
		$this->assertNotNull( $actual );

		if ( $actual instanceof StatusValue ) {
			$this->assertStatusOK( $actual, 'isOK' );
		}

		$this->assertNotSame( $this->getHtml( $expected ), $this->getHtml( $actual ), $msg );
	}

	private function getParserCache( $bag = null ) {
		$parserCache = new ParserCache(
			'test',
			$bag ?: new HashBagOStuff(),
			'19900220000000',
			$this->getServiceContainer()->getHookContainer(),
			new JsonCodec( $this->getServiceContainer() ),
			StatsFactory::newNull(),
			new NullLogger(),
			$this->getServiceContainer()->getTitleFactory(),
			$this->getServiceContainer()->getWikiPageFactory(),
			$this->getServiceContainer()->getGlobalIdGenerator()
		);

		return $parserCache;
	}

	private function getRevisionOutputCache( $bag = null, $expiry = 3600 ) {
		$wanCache = new WANObjectCache( [ 'cache' => $bag ?: new HashBagOStuff() ] );
		$revisionOutputCache = new RevisionOutputCache(
			'test',
			$wanCache,
			$expiry,
			'19900220000000',
			new JsonCodec( $this->getServiceContainer() ),
			StatsFactory::newNull(),
			new NullLogger(),
			$this->getServiceContainer()->getGlobalIdGenerator()
		);

		return $revisionOutputCache;
	}

	/**
	 * @param ParserCache|null $parserCache
	 * @param RevisionOutputCache|null $revisionOutputCache
	 * @param int|bool $maxRenderCalls
	 *
	 * @return ParserOutputAccess
	 * @throws Exception
	 */
	private function getParserOutputAccessWithCache(
		$parserCache = null,
		$revisionOutputCache = null,
		$maxRenderCalls = false
	): ParserOutputAccess {
		return $this->getParserOutputAccess( [
			'parserCache' => $parserCache ?? new HashBagOStuff(),
			'revisionOutputCache' => $revisionOutputCache ?? new HashBagOStuff(),
			'maxRenderCalls' => $maxRenderCalls
		] );
	}

	/**
	 * @param array $options
	 *
	 * @return ParserOutputAccess
	 * @throws Exception
	 */
	private function getParserOutputAccess( array $options = [] ): ParserOutputAccess {
		$parserCacheFactory = $options['parserCacheFactory'] ?? null;
		$maxRenderCalls = $options['maxRenderCalls'] ?? null;
		$parserCache = $options['parserCache'] ?? null;
		$revisionOutputCache = $options['revisionOutputCache'] ?? null;
		$expectPoolCounterCalls = $options['expectPoolCounterCalls'] ?? 0;

		if ( !$parserCacheFactory ) {
			if ( !$parserCache instanceof ParserCache ) {
				$parserCache = $this->getParserCache(
					$parserCache ?? new EmptyBagOStuff()
				);
			}

			if ( !$revisionOutputCache instanceof RevisionOutputCache ) {
				$revisionOutputCache = $this->getRevisionOutputCache(
					$revisionOutputCache ?? new EmptyBagOStuff()
				);
			}

			$parserCacheFactory = $this->createMock( ParserCacheFactory::class );

			$parserCacheFactory->method( 'getParserCache' )
				->willReturn( $parserCache );

			$parserCacheFactory->method( 'getRevisionOutputCache' )
				->willReturn( $revisionOutputCache );
		}

		$revRenderer = $this->getServiceContainer()->getRevisionRenderer();
		if ( $maxRenderCalls ) {
			$realRevRenderer = $revRenderer;

			$revRenderer =
				$this->createNoOpMock( RevisionRenderer::class, [ 'getRenderedRevision' ] );

			$revRenderer->expects( $this->atMost( $maxRenderCalls ) )
				->method( 'getRenderedRevision' )
				->willReturnCallback( [ $realRevRenderer, 'getRenderedRevision' ] );
		}

		$mock = new class (
				$parserCacheFactory,
				$this->getServiceContainer()->getRevisionLookup(),
				$revRenderer,
				$this->getServiceContainer()->getStatsFactory(),
				$this->getServiceContainer()->getDBLoadBalancerFactory(),
				$this->getServiceContainer()->getChronologyProtector(),
				LoggerFactory::getProvider(),
				$this->getServiceContainer()->getWikiPageFactory(),
				$this->getServiceContainer()->getTitleFormatter(),
				$this
		) extends ParserOutputAccess {
			private ParserOutputAccessTest $test;

			public function __construct(
				ParserCacheFactory $parserCacheFactory,
				RevisionLookup $revisionLookup,
				RevisionRenderer $revisionRenderer,
				StatsFactory $statsFactory,
				ILBFactory $lbFactory,
				ChronologyProtector $chronologyProtector,
				LoggerSpi $loggerSpi,
				WikiPageFactory $wikiPageFactory,
				TitleFormatter $titleFormatter,
				ParserOutputAccessTest $test
			) {
				parent::__construct(
					$parserCacheFactory,
					$revisionLookup,
					$revisionRenderer,
					$statsFactory,
					$lbFactory,
					$chronologyProtector,
					$loggerSpi,
					$wikiPageFactory,
					$titleFormatter
				);

				$this->test = $test;
			}

			protected function newPoolWorkArticleView(
				PageRecord $page,
				ParserOptions $parserOptions,
				RevisionRecord $revision,
				int $options
			): PoolCounterWork {
				$this->test->actualCallsToPoolWorkArticleView++;
				return parent::newPoolWorkArticleView( $page, $parserOptions, $revision, $options );
			}
		};

		$this->expectedCallsToPoolWorkArticleView += $expectPoolCounterCalls;

		return $mock;
	}

	/**
	 * @param WikiPage $page
	 * @param string $text
	 *
	 * @return RevisionRecord
	 */
	private function makeFakeRevision( WikiPage $page, $text ) {
		// construct fake revision with no ID
		$content = new WikitextContent( $text );
		$rev = new MutableRevisionRecord( $page->getTitle() );
		$rev->setPageId( $page->getId() );
		$rev->setContent( SlotRecord::MAIN, $content );

		return $rev;
	}

	/**
	 * @return ParserOptions
	 */
	private function getParserOptions() {
		return ParserOptions::newFromAnon();
	}

	/**
	 * Install OpportunisticLinksUpdateHook to inspect whether WikiPage::triggerOpportunisticLinksUpdate
	 * is called or not, the hook implementation will return false disabling the
	 * WikiPage::triggerOpportunisticLinksUpdate to proceed completely.
	 * @param bool $called whether WikiPage::triggerOpportunisticLinksUpdate is expected to be called or not
	 * @return void
	 */
	private function installOpportunisticUpdateHook( bool $called ): void {
		$opportunisticUpdateHook =
			$this->createMock( OpportunisticLinksUpdateHook::class );
		// WikiPage::triggerOpportunisticLinksUpdate is not called by default
		$opportunisticUpdateHook->expects( $this->exactly( $called ? 1 : 0 ) )
			->method( 'onOpportunisticLinksUpdate' )
			->willReturn( false );
		$this->setTemporaryHook( 'OpportunisticLinksUpdate', $opportunisticUpdateHook );
	}

	/**
	 * Tests that we can get rendered output for the latest revision.
	 */
	public function testOutputForLatestRevision() {
		$access = $this->getParserOutputAccess( [
			'parserCache' => new HashBagOStuff()
		] );

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'Hello \'\'World\'\'!' );

		$parserOptions = $this->getParserOptions();
		// WikiPage::triggerOpportunisticLinksUpdate is not called by default
		$this->installOpportunisticUpdateHook( false );
		$status = $access->getParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( 'Hello <i>World</i>!', $status );

		$this->assertNotNull( $access->getCachedParserOutput( $page, $parserOptions ) );
	}

	/**
	 * Tests that we can get rendered output for the latest revision.
	 */
	public function testOutputForLatestRevisionUsingPoolCounter() {
		$access = $this->getParserOutputAccess( [
			'expectPoolCounterCalls' => 1
		] );

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'Hello \'\'World\'\'!' );

		$parserOptions = $this->getParserOptions();

		// WikiPage::triggerOpportunisticLinksUpdate is not called by default
		$this->installOpportunisticUpdateHook( false );

		$status = $access->getParserOutput(
			$page,
			$parserOptions,
			null,
			ParserOutputAccess::OPT_FOR_ARTICLE_VIEW
		);
		$this->assertContainsHtml( 'Hello <i>World</i>!', $status );
	}

	/**
	 * Tests that we can get rendered output for the latest revision.
	 */
	public function testOutputForLatestRevisionWithLinksUpdate() {
		$access = $this->getParserOutputAccess();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'Hello \'\'World\'\'!' );

		$parserOptions = $this->getParserOptions();
		// With ParserOutputAccess::OPT_LINKS_UPDATE WikiPage::triggerOpportunisticLinksUpdate can be called
		$this->installOpportunisticUpdateHook( true );
		$status = $access->getParserOutput( $page, $parserOptions, null, ParserOutputAccess::OPT_LINKS_UPDATE );
		$this->assertContainsHtml( 'Hello <i>World</i>!', $status );
	}

	/**
	 * Tests that we can get rendered output for the latest revision.
	 */
	public function testOutputForLatestRevisionWithLinksUpdateWithPoolCounter() {
		$access = $this->getParserOutputAccess( [
			'expectPoolCounterCalls' => 1
		] );

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'Hello \'\'World\'\'!' );

		$parserOptions = $this->getParserOptions();
		// With ParserOutputAccess::OPT_LINKS_UPDATE WikiPage::triggerOpportunisticLinksUpdate can be called
		$this->installOpportunisticUpdateHook( true );

		$status = $access->getParserOutput(
			$page,
			$parserOptions,
			null,
			ParserOutputAccess::OPT_LINKS_UPDATE | ParserOutputAccess::OPT_FOR_ARTICLE_VIEW
		);
		$this->assertContainsHtml( 'Hello <i>World</i>!', $status );
	}

	/**
	 * Tests that cached output in the ParserCache will be used for the latest revision.
	 */
	public function testLatestRevisionUseCached() {
		// Allow only one render call, use default caches
		$access = $this->getParserOutputAccessWithCache( null, null, 1 );

		$parserOptions = $this->getParserOptions();
		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'Hello \'\'World\'\'!' );

		$access->getParserOutput( $page, $parserOptions );

		// The second call should use cached output
		$status = $access->getParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( 'Hello <i>World</i>!', $status );
	}

	/**
	 * Tests that cached output in the ParserCache will not be used
	 * for the latest revision if the FORCE_PARSE option is given.
	 */
	public function testLatestRevisionForceParse() {
		$parserCache = $this->getParserCache( new HashBagOStuff() );
		$access = $this->getParserOutputAccessWithCache( $parserCache );

		$parserOptions = ParserOptions::newFromAnon();
		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'Hello \'\'World\'\'!' );

		// Put something else into the cache, so we'd notice if it got used
		$cachedOutput = new ParserOutput( 'Cached Text' );
		$parserCache->save( $cachedOutput, $page, $parserOptions );

		$status = $access->getParserOutput(
			$page,
			$parserOptions,
			null,
			ParserOutputAccess::OPT_FORCE_PARSE
		);
		$this->assertNotSameHtml( $cachedOutput, $status );
		$this->assertContainsHtml( 'Hello <i>World</i>!', $status );
	}

	/**
	 * Tests that an error is reported if the latest revision cannot be loaded.
	 */
	public function testLatestRevisionCantLoad() {
		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'Hello \'\'World\'\'!' );

		$revisionStore = $this->createNoOpMock(
			RevisionStore::class,
			[ 'getRevisionByTitle', 'getKnownCurrentRevision', 'getRevisionById' ]
		);
		$revisionStore->method( 'getRevisionById' )->willReturn( null );
		$revisionStore->method( 'getRevisionByTitle' )->willReturn( null );
		$revisionStore->method( 'getKnownCurrentRevision' )->willReturn( false );
		$this->setService( 'RevisionStore', $revisionStore );
		$this->setService( 'RevisionLookup', $revisionStore );

		$page->clear();

		$access = $this->getParserOutputAccess();

		$parserOptions = $this->getParserOptions();
		$status = $access->getParserOutput( $page, $parserOptions );
		$this->assertStatusError( 'missing-revision', $status );
	}

	/**
	 * Tests that getCachedParserOutput() will return previously generated output.
	 */
	public function testGetCachedParserOutput() {
		$access = $this->getParserOutputAccessWithCache();
		$parserOptions = $this->getParserOptions();

		$page = $this->getNonexistingTestPage( __METHOD__ );

		$output = $access->getCachedParserOutput( $page, $parserOptions );
		$this->assertNull( $output );

		$this->editPage( $page, 'Hello \'\'World\'\'!' );
		$access->getParserOutput( $page, $parserOptions );

		$output = $access->getCachedParserOutput( $page, $parserOptions );
		$this->assertNotNull( $output );
		$this->assertContainsHtml( 'Hello <i>World</i>!', $output );
	}

	/**
	 * Tests that getCachedParserOutput() will not return output for current revision when
	 * a fake revision with no ID is supplied.
	 */
	public function testGetCachedParserOutputForFakeRevision() {
		$access = $this->getParserOutputAccessWithCache();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'Hello \'\'World\'\'!' );

		$parserOptions = $this->getParserOptions();
		$access->getParserOutput( $page, $parserOptions );

		$rev = $this->makeFakeRevision( $page, 'fake text' );

		$output = $access->getCachedParserOutput( $page, $parserOptions, $rev );
		$this->assertNull( $output );
	}

	/**
	 * Tests that getPageOutput() will place the generated output for the latest revision
	 * in the parser cache.
	 */
	public function testLatestRevisionIsCached() {
		$access = $this->getParserOutputAccessWithCache();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'Hello \'\'World\'\'!' );

		$parserOptions = $this->getParserOptions();
		$access->getParserOutput( $page, $parserOptions );

		$cachedOutput = $access->getCachedParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( 'World', $cachedOutput );
	}

	/**
	 * Tests that the cache for the current revision is split on parser options.
	 */
	public function testLatestRevisionCacheSplit() {
		$access = $this->getParserOutputAccessWithCache();

		$frenchOptions = ParserOptions::newFromAnon();
		$frenchOptions->setUserLang( 'fr' );

		$tongaOptions = ParserOptions::newFromAnon();
		$tongaOptions->setUserLang( 'to' );

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'Test {{int:ok}}!' );

		$frenchResult = $access->getParserOutput( $page, $frenchOptions );
		$this->assertContainsHtml( 'Test', $frenchResult );

		// Check that French output was cached
		$cachedFrenchOutput =
			$access->getCachedParserOutput( $page, $frenchOptions );
		$this->assertNotNull( $cachedFrenchOutput, 'French output should be in the cache' );

		// check that we don't get the French output when asking for Tonga
		$cachedTongaOutput =
			$access->getCachedParserOutput( $page, $tongaOptions );
		$this->assertNull( $cachedTongaOutput, 'Tonga output should not be in the cache yet' );

		// check that we can generate the Tonga output, and it's different from French
		$tongaResult = $access->getParserOutput( $page, $tongaOptions );
		$this->assertContainsHtml( 'Test', $tongaResult );
		$this->assertNotSameHtml(
			$frenchResult,
			$tongaResult,
			'Tonga output should be different from French'
		);

		// check that the Tonga output is cached
		$cachedTongaOutput =
			$access->getCachedParserOutput( $page, $tongaOptions );
		$this->assertNotNull( $cachedTongaOutput, 'Tonga output should be in the cache' );
	}

	/**
	 * Tests that getPageOutput() will place the generated output in the parser cache if the
	 * latest revision is passed explicitly. In other words, thins ensures that the current
	 * revision won't get treated like an old revision.
	 */
	public function testLatestRevisionIsDetectedAndCached() {
		$access = $this->getParserOutputAccessWithCache();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$rev = $this->editPage( $page, 'Hello \'\'World\'\'!' )->getNewRevision();

		// When $rev is passed, it should be detected to be the latest revision.
		$parserOptions = $this->getParserOptions();
		$access->getParserOutput( $page, $parserOptions, $rev );

		$cachedOutput = $access->getCachedParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( 'World', $cachedOutput );
	}

	/**
	 * Tests that getPageOutput() will generate output for an old revision, and
	 * that we still have the output for the current revision cached afterwards.
	 */
	public function testOutputForOldRevision() {
		$access = $this->getParserOutputAccessWithCache();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$firstRev = $this->editPage( $page, 'First' )->getNewRevision();
		$secondRev = $this->editPage( $page, 'Second' )->getNewRevision();

		// output is for the second revision (write to ParserCache)
		$parserOptions = $this->getParserOptions();
		$status = $access->getParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( 'Second', $status );

		// output is for the first revision (not written to ParserCache)
		$status = $access->getParserOutput( $page, $parserOptions, $firstRev );
		$this->assertContainsHtml( 'First', $status );

		// Latest revision is still the one in the ParserCache
		$output = $access->getCachedParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( 'Second', $output );
	}

	/**
	 * Tests that getPageOutput() will generate output for an old revision, and
	 * that we still have the output for the current revision cached afterwards.
	 */
	public function testOutputForOldRevisionUsingPoolCounter() {
		$access = $this->getParserOutputAccess( [
			'expectPoolCounterCalls' => 2,
			'parserCache' => new HashBagOStuff(),
			'revisionOutputCache' => new HashBagOStuff()
		] );

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$firstRev = $this->editPage( $page, 'First' )->getNewRevision();
		$secondRev = $this->editPage( $page, 'Second' )->getNewRevision();

		// output is for the second revision (write to ParserCache)
		$parserOptions = $this->getParserOptions();
		$status = $access->getParserOutput(
			$page,
			$parserOptions,
			null,
			ParserOutputAccess::OPT_FOR_ARTICLE_VIEW
		);
		$this->assertContainsHtml( 'Second', $status );

		// output is for the first revision (not written to ParserCache)
		$status = $access->getParserOutput(
			$page,
			$parserOptions,
			$firstRev,
			ParserOutputAccess::OPT_FOR_ARTICLE_VIEW
		);
		$this->assertContainsHtml( 'First', $status );

		// Latest revision is still the one in the ParserCache
		$output = $access->getCachedParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( 'Second', $output );
	}

	/**
	 * Tests that trying to get output for a suppressed old revision is denied.
	 */
	public function testOldRevisionSuppressedDenied() {
		$access = $this->getParserOutputAccess();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$firstRev = $this->editPage( $page, 'First' )->getNewRevision();
		$secondRev = $this->editPage( $page, 'Second' )->getNewRevision();

		$this->revisionDelete( $firstRev );
		$firstRev =
			$this->getServiceContainer()->getRevisionStore()->getRevisionById( $firstRev->getId() );

		// output is for the first revision denied
		$parserOptions = $this->getParserOptions();
		$status = $access->getParserOutput( $page, $parserOptions, $firstRev );
		$this->assertStatusError( 'missing-revision-permission', $status );
		// TODO: Once PoolWorkArticleView properly reports errors, check that the correct error
		//       is propagated.
	}

	/**
	 * Tests that getting output for a suppressed old revision is possible when NO_AUDIENCE_CHECK
	 * is set.
	 */
	public function testOldRevisionSuppressedAllowed() {
		$access = $this->getParserOutputAccess();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$firstRev = $this->editPage( $page, 'First' )->getNewRevision();
		$secondRev = $this->editPage( $page, 'Second' )->getNewRevision();

		$this->revisionDelete( $firstRev );
		$firstRev =
			$this->getServiceContainer()->getRevisionStore()->getRevisionById( $firstRev->getId() );

		// output is for the first revision (even though it's suppressed)
		$parserOptions = $this->getParserOptions();
		$status = $access->getParserOutput(
			$page,
			$parserOptions,
			$firstRev,
			ParserOutputAccess::OPT_NO_AUDIENCE_CHECK
		);
		$this->assertContainsHtml( 'First', $status );

		// even though the output was generated, it wasn't cached, since it's not public
		$cachedOutput = $access->getCachedParserOutput( $page, $parserOptions, $firstRev );
		$this->assertNull( $cachedOutput );
	}

	/**
	 * Tests that output for an old revision is fetched from the secondary parser cache if possible.
	 */
	public function testOldRevisionUseCached() {
		// Allow only one render call, use default caches
		$access = $this->getParserOutputAccessWithCache( null, null, 1 );

		$parserOptions = $this->getParserOptions();
		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'First' );
		$oldRev = $page->getRevisionRecord();

		$this->editPage( $page, 'Second' );

		$firstStatus = $access->getParserOutput( $page, $parserOptions, $oldRev );

		// The second call should use cached output
		$secondStatus = $access->getParserOutput( $page, $parserOptions, $oldRev );
		$this->assertSameHtml( $firstStatus, $secondStatus );
	}

	/**
	 * Tests that output for an old revision is fetched from the secondary parser cache if possible.
	 */
	public function testOldRevisionDisableCached() {
		// Use default caches, but expiry 0 for the secondary cache
		$access = $this->getParserOutputAccessWithCache(
			null,
			$this->getRevisionOutputCache( null, 0 )
		);

		$parserOptions = $this->getParserOptions();
		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'First' );
		$oldRev = $page->getRevisionRecord();

		$this->editPage( $page, 'Second' );
		$access->getParserOutput( $page, $parserOptions, $oldRev );

		// Should not be cached!
		$cachedOutput = $access->getCachedParserOutput( $page, $parserOptions, $oldRev );
		$this->assertNull( $cachedOutput );
	}

	/**
	 * Tests that the secondary cache for output for old revisions is split on parser options.
	 */
	public function testOldRevisionCacheSplit() {
		$access = $this->getParserOutputAccessWithCache();

		$frenchOptions = ParserOptions::newFromAnon();
		$frenchOptions->setUserLang( 'fr' );

		$tongaOptions = ParserOptions::newFromAnon();
		$tongaOptions->setUserLang( 'to' );

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'Test {{int:ok}}!' );
		$oldRev = $page->getRevisionRecord();

		$this->editPage( $page, 'Latest Test' );

		$frenchResult = $access->getParserOutput( $page, $frenchOptions, $oldRev );
		$this->assertContainsHtml( 'Test', $frenchResult );

		// Check that French output was cached
		$cachedFrenchOutput =
			$access->getCachedParserOutput( $page, $frenchOptions, $oldRev );
		$this->assertNotNull( $cachedFrenchOutput, 'French output should be in the cache' );

		// check that we don't get the French output when asking for Tonga
		$cachedTongaOutput =
			$access->getCachedParserOutput( $page, $tongaOptions, $oldRev );
		$this->assertNull( $cachedTongaOutput, 'Tonga output should not be in the cache yet' );

		// check that we can generate the Tonga output, and it's different from French
		$tongaResult = $access->getParserOutput( $page, $tongaOptions, $oldRev );
		$this->assertContainsHtml( 'Test', $tongaResult );
		$this->assertNotSameHtml(
			$frenchResult,
			$tongaResult,
			'Tonga output should be different from French'
		);

		// check that the Tonga output is cached
		$cachedTongaOutput =
			$access->getCachedParserOutput( $page, $tongaOptions, $oldRev );
		$this->assertNotNull( $cachedTongaOutput, 'Tonga output should be in the cache' );
	}

	/**
	 * Tests that a RevisionRecord with no ID can be rendered if OPT_NO_CACHE is set.
	 */
	public function testFakeRevisionNoCache() {
		$access = $this->getParserOutputAccessWithCache();

		$page = $this->getExistingTestPage( __METHOD__ );
		$rev = $this->makeFakeRevision( $page, 'fake text' );

		// Render fake
		$parserOptions = $this->getParserOptions();
		$fakeResult = $access->getParserOutput(
			$page,
			$parserOptions,
			$rev,
			ParserOutputAccess::OPT_NO_CACHE
		);
		$this->assertContainsHtml( 'fake text', $fakeResult );

		// check that fake output isn't cached
		$cachedOutput = $access->getCachedParserOutput( $page, $parserOptions );
		if ( $cachedOutput ) {
			// we may have a cache entry for original edit
			$this->assertNotSameHtml( $fakeResult, $cachedOutput );
		}
	}

	/**
	 * Tests that a RevisionRecord with no ID cannot be rendered if OPT_NO_CACHE is not set.
	 */
	public function testFakeRevisionError() {
		$access = $this->getParserOutputAccess();
		$parserOptions = $this->getParserOptions();

		$page = $this->getExistingTestPage( __METHOD__ );
		$rev = $this->makeFakeRevision( $page, 'fake text' );

		// Render should fail
		$this->expectException( InvalidArgumentException::class );
		$access->getParserOutput( $page, $parserOptions, $rev );
	}

	/**
	 * Tests that trying to render a RevisionRecord for another page will throw an exception.
	 */
	public function testPageIdMismatchError() {
		$access = $this->getParserOutputAccess();
		$parserOptions = $this->getParserOptions();

		$page1 = $this->getExistingTestPage( __METHOD__ . '-1' );
		$page2 = $this->getExistingTestPage( __METHOD__ . '-2' );

		$this->expectException( InvalidArgumentException::class );
		$access->getParserOutput( $page1, $parserOptions, $page2->getRevisionRecord() );
	}

	/**
	 * Tests that trying to render a non-existing page will be reported as an error.
	 */
	public function testNonExistingPage() {
		$access = $this->getParserOutputAccess();

		$page = $this->getNonexistingTestPage( __METHOD__ );

		$parserOptions = $this->getParserOptions();
		$status = $access->getParserOutput( $page, $parserOptions );
		$this->assertStatusError( 'nopagetext', $status );
	}

	/**
	 * @param Status $status
	 * @param bool $fastStale
	 */
	private function setPoolCounterFactory( $status, $fastStale = false ) {
		$this->overrideConfigValue( MainConfigNames::PoolCounterConf, [
			'ArticleView' => [
				'class' => MockPoolCounterFailing::class,
				'fastStale' => $fastStale,
				'mockAcquire' => $status,
				'mockRelease' => Status::newGood( PoolCounter::RELEASED ),
			],
		] );
	}

	public static function providePoolWorkDirty() {
		yield [ Status::newGood( PoolCounter::QUEUE_FULL ), false, 'view-pool-overload' ];
		yield [ Status::newGood( PoolCounter::TIMEOUT ), false, 'view-pool-overload' ];
		yield [ Status::newGood( PoolCounter::TIMEOUT ), true, 'view-pool-contention' ];
	}

	/**
	 * Tests that under some circumstances, stale cache entries will be returned, but get
	 * flagged as "dirty".
	 *
	 * @dataProvider providePoolWorkDirty
	 */
	public function testPoolWorkDirty( $status, $fastStale, $expectedMessage ) {
		$this->overrideConfigValues( [
			MainConfigNames::ParserCacheExpireTime => 60,
		] );
		$this->setPoolCounterFactory( Status::newGood( PoolCounter::LOCKED ), $fastStale );
		MWTimestamp::setFakeTime( '2020-04-04T01:02:03' );

		$access = $this->getParserOutputAccess( [
			'expectPoolCounterCalls' => 2,
			'parserCache' => new HashBagOStuff()
		] );

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'Hello \'\'World\'\'!' );

		$parserOptions = $this->getParserOptions();
		$result = $access->getParserOutput(
			$page,
			$parserOptions,
			null,
			ParserOutputAccess::OPT_FOR_ARTICLE_VIEW
		);
		$this->assertContainsHtml( 'World', $result, 'fresh result' );

		$testingAccess = TestingAccessWrapper::newFromObject( $access );
		$testingAccess->localCache->clear();

		$this->setPoolCounterFactory( $status, $fastStale );

		// expire parser cache
		MWTimestamp::setFakeTime( '2020-05-05T01:02:03' );

		$parserOptions = $this->getParserOptions();
		$cachedResult = $access->getParserOutput(
			$page,
			$parserOptions,
			null,
			ParserOutputAccess::OPT_FOR_ARTICLE_VIEW
		);
		$this->assertContainsHtml( 'World', $cachedResult, 'cached result' );

		$this->assertStatusWarning( $expectedMessage, $cachedResult );
		$this->assertStatusWarning( 'view-pool-dirty-output', $cachedResult );
	}

	/**
	 * Tests that a failure to acquire a work lock will be reported as an error if no
	 * stale output can be returned.
	 */
	public function testPoolWorkTimeout() {
		$this->overrideConfigValues( [
			MainConfigNames::ParserCacheExpireTime => 60,
		] );
		$this->setPoolCounterFactory( Status::newGood( PoolCounter::TIMEOUT ) );

		$access = $this->getParserOutputAccess( [
			'expectPoolCounterCalls' => 1
		] );

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'Hello \'\'World\'\'!' );

		$parserOptions = $this->getParserOptions();
		$result = $access->getParserOutput(
			$page,
			$parserOptions,
			null,
			ParserOutputAccess::OPT_FOR_ARTICLE_VIEW
		);
		$this->assertStatusError( 'pool-timeout', $result );
	}

	/**
	 * Tests that a PoolCounter error does not prevent output from being generated.
	 */
	public function testPoolWorkError() {
		$this->overrideConfigValues( [
			MainConfigNames::ParserCacheExpireTime => 60,
		] );
		$this->setPoolCounterFactory( Status::newFatal( 'some-error' ) );

		$access = $this->getParserOutputAccess();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, 'Hello \'\'World\'\'!' );

		$parserOptions = $this->getParserOptions();
		$result = $access->getParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( 'World', $result );
	}

	public function testParsoidCacheSplit() {
		$parserCacheFactory = $this->createMock( ParserCacheFactory::class );
		$revisionOutputCache = $this->getRevisionOutputCache( new HashBagOStuff() );
		$caches = [
			$this->getParserCache( new HashBagOStuff() ),
			$this->getParserCache( new HashBagOStuff() ),
		];
		$calls = [];
		$parserCacheFactory
			->method( 'getParserCache' )
			->willReturnCallback( static function ( $cacheName ) use ( &$calls, $caches ) {
				static $cacheList = [];
				$calls[] = $cacheName;
				$which = array_search( $cacheName, $cacheList );
				if ( $which === false ) {
					$which = count( $cacheList );
					$cacheList[] = $cacheName;
				}
				return $caches[$which];
			} );
		$parserCacheFactory
			->method( 'getRevisionOutputCache' )
			->willReturn( $revisionOutputCache );

		$access = $this->getParserOutputAccess( [
			'parserCacheFactory' => $parserCacheFactory
		] );
		$parserOptions0 = $this->getParserOptions();
		$page = $this->getNonexistingTestPage( __METHOD__ );
		$output = $access->getCachedParserOutput( $page, $parserOptions0 );
		$this->assertNull( $output );
		// $calls[0] will remember what cache name we used.
		$this->assertCount( 1, $calls );

		$parserOptions1 = $this->getParserOptions();
		$parserOptions1->setUseParsoid();
		$output = $access->getCachedParserOutput( $page, $parserOptions1 );
		$this->assertNull( $output );
		$this->assertCount( 2, $calls );
		// Check that we used a different cache name this time.
		$this->assertNotEquals( $calls[1], $calls[0], "Should use different caches" );

		// Try this again, with actual content.
		$calls = [];
		$this->editPage( $page, "__NOTOC__" );
		$status0 = $access->getParserOutput( $page, $parserOptions0 );
		$this->assertContainsHtml( '<div class="mw-content-ltr mw-parser-output" lang="en" dir="ltr"></div>', $status0 );
		$status1 = $access->getParserOutput( $page, $parserOptions1 );
		$this->assertContainsHtml( '<meta property="mw:PageProp/notoc"', $status1 );
		$this->assertNotSameHtml( $status0, $status1 );
	}

	public function testParsoidRevisionCacheSplit() {
		$parserCacheFactory = $this->createMock( ParserCacheFactory::class );
		$parserCache = $this->getParserCache( new HashBagOStuff() );
		$caches = [
			$this->getRevisionOutputCache( new HashBagOStuff() ),
			$this->getRevisionOutputCache( new HashBagOStuff() ),
		];
		$calls = [];
		$parserCacheFactory
			->method( 'getParserCache' )
			->willReturn( $parserCache );
		$parserCacheFactory
			->method( 'getRevisionOutputCache' )
			->willReturnCallback( static function ( $cacheName ) use ( &$calls, $caches ) {
				static $cacheList = [];
				$calls[] = $cacheName;
				$which = array_search( $cacheName, $cacheList );
				if ( $which === false ) {
					$which = count( $cacheList );
					$cacheList[] = $cacheName;
				}
				return $caches[$which];
			} );

		$access = $this->getParserOutputAccess( [
			'parserCacheFactory' => $parserCacheFactory
		] );
		$page = $this->getNonexistingTestPage( __METHOD__ );
		$firstRev = $this->editPage( $page, 'First __NOTOC__' )->getNewRevision();
		$secondRev = $this->editPage( $page, 'Second __NOTOC__' )->getNewRevision();

		$parserOptions0 = $this->getParserOptions();
		$status = $access->getParserOutput( $page, $parserOptions0, $firstRev );
		$this->assertContainsHtml( 'First', $status );
		// Check that we used the "not parsoid" revision cache
		$this->assertNotEmpty( $calls );
		$notParsoid = $calls[0];
		$this->assertEquals( array_fill( 0, count( $calls ), $notParsoid ), $calls );

		$calls = [];
		$parserOptions1 = $this->getParserOptions();
		$parserOptions1->setUseParsoid();
		$status = $access->getParserOutput( $page, $parserOptions1, $firstRev );
		$this->assertContainsHtml( 'First', $status );
		$this->assertContainsHtml( '<meta property="mw:PageProp/notoc"', $status );
		$this->assertNotEmpty( $calls );
		$parsoid = $calls[0];
		$this->assertNotEquals( $notParsoid, $parsoid, "Should use different caches" );
		$this->assertEquals( array_fill( 0, count( $calls ), $parsoid ), $calls );
	}
}
PK       ! 1      page/PageArchiveTest.phpnu Iw        <?php

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\ContentHandler;
use MediaWiki\Content\TextContent;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\IPUtils;

/**
 * @group Database
 * @coversDefaultClass \PageArchive
 * @covers ::__construct
 */
class PageArchiveTest extends MediaWikiIntegrationTestCase {

	use TempUserTestTrait;

	/**
	 * @var int
	 */
	protected $pageId;

	/**
	 * @var Title
	 */
	protected $archivedPage;

	/**
	 * A logged out user who edited the page before it was archived.
	 * @var string
	 */
	protected $ipEditor;

	/**
	 * Revision of the first (initial) edit
	 * @var RevisionRecord
	 */
	protected $firstRev;

	/**
	 * Revision of the IP edit (the second edit)
	 * @var RevisionRecord
	 */
	protected $ipRev;

	protected function setUp(): void {
		parent::setUp();
		$this->disableAutoCreateTempUser();

		// First create our dummy page
		$this->archivedPage = Title::makeTitle( NS_MAIN, 'PageArchiveTest_thePage' );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $this->archivedPage );
		$content = ContentHandler::makeContent(
			'testing',
			$page->getTitle(),
			CONTENT_MODEL_WIKITEXT
		);

		$user = $this->getTestUser()->getUser();
		$page->doUserEditContent( $content, $user, 'testing', EDIT_NEW | EDIT_SUPPRESS_RC );

		$this->pageId = $page->getId();
		$this->firstRev = $page->getRevisionRecord();

		// Insert IP revision
		$this->ipEditor = '2001:DB8:0:0:0:0:0:1';

		$revisionStore = $this->getServiceContainer()->getRevisionStore();

		$ipTimestamp = wfTimestamp(
			TS_MW,
			wfTimestamp( TS_UNIX, $this->firstRev->getTimestamp() ) + 1
		);
		$rev = new MutableRevisionRecord( $page );
		$rev->setUser( UserIdentityValue::newAnonymous( $this->ipEditor ) );
		$rev->setTimestamp( $ipTimestamp );
		$rev->setContent( SlotRecord::MAIN, new TextContent( 'Lorem Ipsum' ) );
		$rev->setComment( CommentStoreComment::newUnsavedComment( 'just a test' ) );

		$dbw = $this->getDb();
		$this->ipRev = $revisionStore->insertRevisionOn( $rev, $dbw );

		$this->deletePage( $page, '', $user );
	}

	/**
	 * @covers \PageArchive::undeleteAsUser
	 */
	public function testUndeleteRevisions() {
		$this->hideDeprecated( 'PageArchive::undeleteAsUser' );

		// TODO: MCR: Test undeletion with multiple slots. Check that slots remain untouched.
		$revisionStore = $this->getServiceContainer()->getRevisionStore();

		// First make sure old revisions are archived
		$dbr = $this->getDb();
		$row = $revisionStore->newArchiveSelectQueryBuilder( $dbr )
			->joinComment()
			->where( [ 'ar_rev_id' => $this->ipRev->getId() ] )
			->caller( __METHOD__ )->fetchRow();
		$this->assertEquals( $this->ipEditor, $row->ar_user_text );

		// Should not be in revision
		$row = $dbr->newSelectQueryBuilder()
			->select( '1' )
			->from( 'revision' )
			->where( [ 'rev_id' => $this->ipRev->getId() ] )
			->fetchRow();
		$this->assertFalse( $row );

		// Should not be in ip_changes
		$row = $dbr->newSelectQueryBuilder()
			->select( '1' )
			->from( 'ip_changes' )
			->where( [ 'ipc_rev_id' => $this->ipRev->getId() ] )
			->fetchRow();
		$this->assertFalse( $row );

		// Restore the page
		$archive = new PageArchive( $this->archivedPage );
		$archive->undeleteAsUser( [], $this->getTestSysop()->getUser() );

		// Should be back in revision
		$row = $revisionStore->newSelectQueryBuilder( $dbr )
			->where( [ 'rev_id' => $this->ipRev->getId() ] )
			->caller( __METHOD__ )->fetchRow();
		$this->assertNotFalse( $row, 'row exists in revision table' );
		$this->assertEquals( $this->ipEditor, $row->rev_user_text );

		// Should be back in ip_changes
		$row = $dbr->newSelectQueryBuilder()
			->select( [ 'ipc_hex' ] )
			->from( 'ip_changes' )
			->where( [ 'ipc_rev_id' => $this->ipRev->getId() ] )
			->fetchRow();
		$this->assertNotFalse( $row, 'row exists in ip_changes table' );
		$this->assertEquals( IPUtils::toHex( $this->ipEditor ), $row->ipc_hex );
	}

	protected function getExpectedArchiveRows() {
		return [
			[
				'ar_minor_edit' => '0',
				'ar_user' => null,
				'ar_user_text' => $this->ipEditor,
				'ar_actor' => (string)$this->getServiceContainer()->getActorNormalization()
					->acquireActorId( new UserIdentityValue( 0, $this->ipEditor ), $this->getDb() ),
				'ar_len' => '11',
				'ar_deleted' => '0',
				'ar_rev_id' => strval( $this->ipRev->getId() ),
				'ar_timestamp' => $this->getDb()->timestamp( $this->ipRev->getTimestamp() ),
				'ar_sha1' => '0qdrpxl537ivfnx4gcpnzz0285yxryy',
				'ar_page_id' => strval( $this->ipRev->getPageId() ),
				'ar_comment_text' => 'just a test',
				'ar_comment_data' => null,
				'ar_comment_cid' => strval( $this->ipRev->getComment()->id ),
				'ts_tags' => null,
				'ar_id' => '2',
				'ar_namespace' => '0',
				'ar_title' => 'PageArchiveTest_thePage',
				'ar_parent_id' => strval( $this->ipRev->getParentId() ),
			],
			[
				'ar_minor_edit' => '0',
				'ar_user' => (string)$this->getTestUser()->getUser()->getId(),
				'ar_user_text' => $this->getTestUser()->getUser()->getName(),
				'ar_actor' => (string)$this->getTestUser()->getUser()->getActorId(),
				'ar_len' => '7',
				'ar_deleted' => '0',
				'ar_rev_id' => strval( $this->firstRev->getId() ),
				'ar_timestamp' => $this->getDb()->timestamp( $this->firstRev->getTimestamp() ),
				'ar_sha1' => 'pr0s8e18148pxhgjfa0gjrvpy8fiyxc',
				'ar_page_id' => strval( $this->firstRev->getPageId() ),
				'ar_comment_text' => 'testing',
				'ar_comment_data' => null,
				'ar_comment_cid' => strval( $this->firstRev->getComment()->id ),
				'ts_tags' => null,
				'ar_id' => '1',
				'ar_namespace' => '0',
				'ar_title' => 'PageArchiveTest_thePage',
				'ar_parent_id' => '0',
			],
		];
	}

	/**
	 * @covers \PageArchive::listPagesBySearch
	 * @covers \PageArchive::listPagesByPrefix
	 * @covers \PageArchive::listPages
	 */
	public function testListPagesBySearch() {
		$pages = PageArchive::listPagesBySearch( 'PageArchiveTest_thePage' );
		$this->assertSame( 1, $pages->numRows() );

		$page = (array)$pages->current();

		$this->assertSame(
			[
				'ar_namespace' => '0',
				'ar_title' => 'PageArchiveTest_thePage',
				'count' => '2',
			],
			$page
		);
	}

	/**
	 * @covers \PageArchive::listPagesByPrefix
	 * @covers \PageArchive::listPages
	 */
	public function testListPagesByPrefix() {
		$pages = PageArchive::listPagesByPrefix( 'PageArchiveTest' );
		$this->assertSame( 1, $pages->numRows() );

		$page = (array)$pages->current();

		$this->assertSame(
			[
				'ar_namespace' => '0',
				'ar_title' => 'PageArchiveTest_thePage',
				'count' => '2',
			],
			$page
		);
	}

}
PK       ! da@C  C    page/ImagePageTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Title\Title;
use Wikimedia\TestingAccessWrapper;

class ImagePageTest extends MediaWikiMediaTestCase {

	protected function setUp(): void {
		$this->overrideConfigValue(
			MainConfigNames::ImageLimits,
			[
				[ 320, 240 ],
				[ 640, 480 ],
				[ 800, 600 ],
				[ 1024, 768 ],
				[ 1280, 1024 ]
			]
		);
		parent::setUp();
	}

	public function getImagePage( $filename ) {
		$title = Title::makeTitleSafe( NS_FILE, $filename );
		$title->setContentModel( CONTENT_MODEL_WIKITEXT );
		$file = $this->dataFile( $filename );
		$iPage = new ImagePage( $title );
		$iPage->setFile( $file );
		return $iPage;
	}

	/**
	 * @covers \ImagePage::getThumbSizes
	 * @dataProvider providerGetThumbSizes
	 * @param string $filename
	 * @param int $expectedNumberThumbs How many thumbnails to show
	 */
	public function testGetThumbSizes( $filename, $expectedNumberThumbs ) {
		$iPage = $this->getImagePage( $filename );
		$reflection = new ReflectionClass( $iPage );
		$reflMethod = $reflection->getMethod( 'getThumbSizes' );
		$reflMethod->setAccessible( true );

		$actual = $reflMethod->invoke( $iPage, 545, 700 );
		$this->assertCount( $expectedNumberThumbs, $actual );
	}

	public static function providerGetThumbSizes() {
		return [
			[ 'animated.gif', 2 ],
			[ 'Toll_Texas_1.svg', 1 ],
			[ '80x60-Greyscale.xcf', 1 ],
			[ 'jpeg-comment-binary.jpg', 2 ],
		];
	}

	/**
	 * @covers \ImagePage::getLanguageForRendering()
	 * @dataProvider provideGetLanguageForRendering
	 *
	 * @param string|null $expected Expected IETF language code
	 * @param string $wikiLangCode Wiki language code (zh)
	 * @param string|null $wikiLangVariant Wiki language code variant (zh-cn)
	 * @param string|null $lang lang=... URL parameter
	 */
	public function testGetLanguageForRendering( $expected, $wikiLangCode, $wikiLangVariant = null, $lang = null ) {
		$params = [];
		if ( $lang !== null ) {
			$params['lang'] = $lang;
		}
		$request = new FauxRequest( $params );
		$this->overrideConfigValues( [
			MainConfigNames::LanguageCode => $wikiLangCode,
			MainConfigNames::DefaultLanguageVariant => $wikiLangVariant
		] );

		$page = $this->getImagePage( 'translated.svg' );
		$page = TestingAccessWrapper::newFromObject( $page );

		/** @var ImagePage $page */
		$result = $page->getLanguageForRendering( $request, $page->getDisplayedFile() );
		$this->assertEquals( $expected, $result );
	}

	public static function provideGetLanguageForRendering() {
		return [
			[ 'ru', 'ru' ],
			[ 'ru', 'ru', null, 'ru' ],
			[ null, 'en' ],
			[ null, 'fr' ],
			[ null, 'en', null, 'en' ],
			[ null, 'fr', null, 'fr' ],
			[ null, 'ru', null, 'en' ],
			[ 'de', 'ru', null, 'de' ],
			[ 'gsw', 'als' ], /* als MW lang code (which is not a valid IETF lang code) */
			[ 'als', 'en', null, 'als' ], /* als IETF lang code */
			[ 'zh-hans-cn', 'zh', 'zh-cn' ],
			[ 'zh-hant-tw', 'zh', 'zh-tw' ],
			[ 'zh-hans-cn', 'zh-Hans-cn' ], /* Should not happen, not a MW lang code */
			[ 'zh-hans-cn', 'de', null, 'zh-Hans' ],
			[ null, 'de', null, 'zh-cn' ], /* MW language code via param */
			[ 'zh-hans-cn', 'zh', 'zh-cn', 'zh' ],
			[ 'zh-hans-cn', 'zh', 'zh-cn', 'zh-Hans' ],
			[ 'zh-hans-cn', 'zh', 'zh-cn', 'zh-Hans-CN' ],
			[ 'zh-hant-tw', 'zh', 'zh-tw', 'zh-Hant-TW' ],
			[ 'zh-hant-tw', 'zh', 'zh-cn', 'zh-Hant-TW' ],
		];
	}
}
PK       ! htL  L    page/MovePageTest.phpnu Iw        <?php

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Interwiki\InterwikiLookup;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Page\MovePage;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Tests\Rest\Handler\MediaTestTrait;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\Watchlist\WatchedItemStore;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * @covers \MediaWiki\Page\MovePage
 * @group Database
 */
class MovePageTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;
	use MediaTestTrait;

	/**
	 * @param Title $old
	 * @param Title $new
	 * @param array $params Valid keys are: db, options,
	 *   options is an indexed array that will overwrite our defaults, not a ServiceOptions, so it
	 *   need not contain all keys.
	 * @return MovePage
	 */
	private function newMovePageWithMocks( $old, $new, array $params = [] ): MovePage {
		$mockProvider = $this->createNoOpMock( IConnectionProvider::class, [ 'getPrimaryDatabase' ] );
		$mockProvider->method( 'getPrimaryDatabase' )
			->willReturn( $params['db'] ?? $this->createNoOpMock( IDatabase::class ) );

		return new MovePage(
			$old,
			$new,
			new ServiceOptions(
				MovePage::CONSTRUCTOR_OPTIONS,
				$params['options'] ?? [],
				[
					MainConfigNames::CategoryCollation => 'uppercase',
					MainConfigNames::MaximumMovedPages => 100,
				]
			),
			$mockProvider,
			$this->getDummyNamespaceInfo(),
			$this->createMock( WatchedItemStore::class ),
			$this->makeMockRepoGroup(
				[ 'Existent.jpg', 'Existent2.jpg', 'Existent-file-no-page.jpg' ]
			),
			$this->getServiceContainer()->getContentHandlerFactory(),
			$this->getServiceContainer()->getRevisionStore(),
			$this->getServiceContainer()->getSpamChecker(),
			$this->getServiceContainer()->getHookContainer(),
			$this->getServiceContainer()->getWikiPageFactory(),
			$this->getServiceContainer()->getUserFactory(),
			$this->getServiceContainer()->getUserEditTracker(),
			$this->getServiceContainer()->getMovePageFactory(),
			$this->getServiceContainer()->getCollationFactory(),
			$this->getServiceContainer()->getPageUpdaterFactory(),
			$this->getServiceContainer()->getRestrictionStore(),
			$this->getServiceContainer()->getDeletePageFactory(),
			$this->getServiceContainer()->getLogFormatterFactory()
		);
	}

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

		// To avoid problems with namespace localization
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'en' );

		// Ensure we have some pages that are guaranteed to exist or not
		$this->getExistingTestPage( 'Existent' );
		$this->getExistingTestPage( 'Existent2' );
		$this->getExistingTestPage( 'File:Existent.jpg' );
		$this->getExistingTestPage( 'File:Existent2.jpg' );
		$this->getExistingTestPage( 'File:Non-file.jpg' );
		// Special treatment as we can't just add wikitext to a JS page
		$this->insertPage( 'MediaWiki:Existent.js', '// Hello this is JavaScript!' );
		$this->getExistingTestPage( 'Hooked in place' );
		$this->getNonexistingTestPage( 'Nonexistent' );
		$this->getNonexistingTestPage( 'Nonexistent2' );
		$this->getNonexistingTestPage( 'File:Nonexistent.jpg' );
		$this->getNonexistingTestPage( 'File:Nonexistent.png' );
		$this->getNonexistingTestPage( 'File:Existent-file-no-page.jpg' );
		$this->getNonexistingTestPage( 'MediaWiki:Nonexistent' );
		$this->getNonexistingTestPage( 'No content allowed' );

		// Set a couple of hooks for specific pages
		$this->setTemporaryHook( 'ContentModelCanBeUsedOn',
			static function ( $modelId, Title $title, &$ok ) {
				if ( $title->getPrefixedText() === 'No content allowed' ) {
					$ok = false;
				}
			}
		);

		$this->setTemporaryHook( 'TitleIsMovable',
			static function ( Title $title, &$result ) {
				if ( strtolower( $title->getPrefixedText() ) === 'hooked in place' ) {
					$result = false;
				}
			}
		);
	}

	/**
	 * @dataProvider provideIsValidMove
	 * @covers \MediaWiki\Page\MovePage::isValidMove
	 * @covers \MediaWiki\Page\MovePage::isValidMoveTarget
	 * @covers \MediaWiki\Page\MovePage::isValidFileMove
	 * @covers \MediaWiki\Page\MovePage::__construct
	 *
	 * @param string|Title $old
	 * @param string|Title $new
	 * @param StatusValue $expectedStatus
	 * @param array $extraOptions
	 */
	public function testIsValidMove(
		$old, $new, StatusValue $expectedStatus, array $extraOptions = []
	) {
		$iwLookup = $this->createMock( InterwikiLookup::class );
		$iwLookup->method( 'isValidInterwiki' )
			->willReturn( true );

		$this->setService( 'InterwikiLookup', $iwLookup );

		$old = $old instanceof Title ? $old : Title::newFromText( $old );
		$new = $new instanceof Title ? $new : Title::newFromText( $new );
		$mp = $this->newMovePageWithMocks( $old, $new, [ 'options' => $extraOptions ] );
		$this->assertStatusMessagesExactly(
			$expectedStatus,
			$mp->isValidMove()
		);
	}

	public static function provideIsValidMove() {
		$ret = [
			'Valid move with redirect' => [
				'Existent',
				'Nonexistent',
				StatusValue::newGood(),
				[ 'createRedirect' => true ]
			],
			'Valid move without redirect' => [
				'Existent',
				'Nonexistent',
				StatusValue::newGood(),
				[ 'createRedirect' => false ]
			],
			'Self move' => [
				'Existent',
				'Existent',
				StatusValue::newFatal( 'selfmove' ),
			],
			'Move from empty name' => [
				Title::makeTitle( NS_MAIN, '' ),
				'Nonexistent',
				// @todo More specific error message, or make the move valid if the page actually
				// exists somehow in the database
				StatusValue::newFatal( 'badarticleerror' ),
			],
			'Move to empty name' => [
				'Existent',
				Title::makeTitle( NS_MAIN, '' ),
				StatusValue::newFatal( 'movepage-invalid-target-title' ),
			],
			'Move to invalid name' => [
				'Existent',
				Title::makeTitle( NS_MAIN, '<' ),
				StatusValue::newFatal( 'movepage-invalid-target-title' ),
			],
			'Move between invalid names' => [
				Title::makeTitle( NS_MAIN, '<' ),
				Title::makeTitle( NS_MAIN, '>' ),
				// @todo First error message should be more specific, or maybe we should make moving
				// such pages valid if they actually exist somehow in the database
				StatusValue::newFatal( 'movepage-source-doesnt-exist', '<' )
					->fatal( 'movepage-invalid-target-title' ),
			],
			'Move nonexistent' => [
				'Nonexistent',
				'Nonexistent2',
				StatusValue::newFatal( 'movepage-source-doesnt-exist', 'Nonexistent' ),
			],
			'Move over existing' => [
				'Existent',
				'Existent2',
				StatusValue::newFatal( 'articleexists', 'Existent2' ),
			],
			'Move from another wiki' => [
				Title::makeTitle( NS_MAIN, 'Test', '', 'otherwiki' ),
				'Nonexistent',
				StatusValue::newFatal( 'immobile-source-namespace-iw' ),
			],
			'Move special page' => [
				'Special:FooBar',
				'Nonexistent',
				StatusValue::newFatal( 'immobile-source-namespace', 'Special' ),
			],
			'Move to another wiki' => [
				'Existent',
				Title::makeTitle( NS_MAIN, 'Test', '', 'otherwiki' ),
				StatusValue::newFatal( 'immobile-target-namespace-iw' ),
			],
			'Move to special page' => [
				'Existent',
				'Special:FooBar',
				StatusValue::newFatal( 'immobile-target-namespace', 'Special' ),
			],
			'Move to allowed content model' => [
				'MediaWiki:Existent.js',
				'MediaWiki:Nonexistent',
				StatusValue::newGood(),
			],
			'Move to prohibited content model' => [
				'Existent',
				'No content allowed',
				StatusValue::newFatal( 'content-not-allowed-here', 'wikitext', 'No content allowed', 'main' ),
			],
			'Aborted by hook' => [
				'Hooked in place',
				'Nonexistent',
				StatusValue::newFatal( 'immobile-source-namespace', '(Main)' ),
			],
			'Doubly aborted by hook' => [
				'Hooked in place',
				'Hooked In Place',
				StatusValue::newFatal( 'immobile-source-namespace', '(Main)' )
					->fatal( 'immobile-target-namespace', '(Main)' ),
			],
			'Non-file to file' => [
				'Existent',
				'File:Nonexistent.jpg',
				StatusValue::newFatal( 'nonfile-cannot-move-to-file' ),
			],
			'File to non-file' => [
				'File:Existent.jpg',
				'Nonexistent',
				StatusValue::newFatal( 'imagenocrossnamespace' ),
			],
			'Existing file to non-existing file' => [
				'File:Existent.jpg',
				'File:Nonexistent.jpg',
				StatusValue::newGood(),
			],
			'Existing file to existing file' => [
				'File:Existent.jpg',
				'File:Existent2.jpg',
				StatusValue::newFatal( 'articleexists', 'File:Existent2.jpg' ),
			],
			'Existing file to existing file with no page' => [
				'File:Existent.jpg',
				'File:Existent-file-no-page.jpg',
				// @todo Is this correct? Moving over an existing file with no page should succeed?
				StatusValue::newGood(),
			],
			'Existing file to name with slash' => [
				'File:Existent.jpg',
				'File:Existent/slashed.jpg',
				StatusValue::newFatal( 'imageinvalidfilename' ),
			],
			'Mismatched file extension' => [
				'File:Existent.jpg',
				'File:Nonexistent.png',
				StatusValue::newFatal( 'imagetypemismatch' ),
			],
			'Non-file page in the File namespace' => [
				'File:Non-file.jpg',
				'File:Non-file-new.png',
				StatusValue::newGood(),
			],
			'File too long' => [
				'File:Existent.jpg',
				'File:0123456789012345678901234567890123456789012345678901234567890123456789' .
					'0123456789012345678901234567890123456789012345678901234567890123456789' .
					'0123456789012345678901234567890123456789012345678901234567890123456789' .
					'012345678901234567890123456789-long.jpg',
				StatusValue::newFatal( 'filename-toolong' ),
			],
			// The FileRepo mock does not return true for ->backendSupportsUnicodePaths()
			'Non-ascii' => [
				'File:Existent.jpg',
				'File:🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈.jpg',
				StatusValue::newFatal( 'filename-toolong' )
					->fatal( 'windows-nonascii-filename' ),
			],
			'Non-file move long with unicode' => [
				'File:Non-file.jpg',
				'File:🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈🏳️‍🌈🏳️‍🌈🏳️‍🌈 🏳️‍🌈.jpg',
				StatusValue::newGood()
			],
			'File just extension' => [
				'File:Existent.jpg',
				'File:.jpg',
				StatusValue::newFatal( 'filename-tooshort' )
					->fatal( 'imagetypemismatch' ),
			],
		];
		return $ret;
	}

	/**
	 * @dataProvider provideIsValidMove
	 *
	 * @param string|Title $old Old name
	 * @param string|Title $new New name
	 * @param StatusValue $expectedStatus
	 * @param array $extraOptions
	 */
	public function testMove( $old, $new, StatusValue $expectedStatus, array $extraOptions = [] ) {
		$iwLookup = $this->createMock( InterwikiLookup::class );
		$iwLookup->method( 'isValidInterwiki' )
			->willReturn( true );

		$this->setService( 'InterwikiLookup', $iwLookup );

		$old = $old instanceof Title ? $old : Title::newFromText( $old );
		$new = $new instanceof Title ? $new : Title::newFromText( $new );

		$createRedirect = $extraOptions['createRedirect'] ?? true;
		unset( $extraOptions['createRedirect'] );
		$params = [ 'options' => $extraOptions ];

		if ( !$expectedStatus->isGood() ) {
			$obj = $this->newMovePageWithMocks( $old, $new, $params );
			$status = $obj->move( $this->getTestUser()->getUser() );
			$this->assertStatusMessagesExactly(
				$expectedStatus,
				$status
			);
		} else {
			$oldPageId = $old->getArticleID();
			$status = $this->getServiceContainer()
				->getMovePageFactory()
				->newMovePage( $old, $new )
				->move( $this->getTestUser()->getUser(), 'move reason', $createRedirect );
			$this->assertStatusOK( $status );
			$this->assertMoved( $old, $new, $oldPageId, $createRedirect );

			[
				'nullRevision' => $nullRevision,
				'redirectRevision' => $redirectRevision
			] = $status->getValue();
			$this->assertInstanceOf( RevisionRecord::class, $nullRevision );
			$this->assertSame( $oldPageId, $nullRevision->getPageId() );
			if ( $createRedirect ) {
				$this->assertInstanceOf( RevisionRecord::class, $redirectRevision );
				$this->assertSame( $old->getArticleID( IDBAccessObject::READ_LATEST ), $redirectRevision->getPageId() );
			} else {
				$this->assertNull( $redirectRevision );
			}
		}
	}

	/**
	 * Test for the move operation being aborted via the TitleMove hook
	 * @covers \MediaWiki\Page\MovePage::move
	 */
	public function testMoveAbortedByTitleMoveHook() {
		$error = 'Preventing move operation with TitleMove hook.';
		$this->setTemporaryHook( 'TitleMove',
			static function ( $old, $new, $user, $reason, $status ) use ( $error ) {
				$status->fatal( $error );
			}
		);

		$oldTitle = Title::makeTitle( NS_MAIN, 'Some old title' );
		$this->editPage(
			$oldTitle,
			new WikitextContent( 'foo' ),
			'bar',
			NS_MAIN,
			$this->getTestSysop()->getAuthority()
		);
		$newTitle = Title::makeTitle( NS_MAIN, 'A brand new title' );
		$mp = $this->newMovePageWithMocks( $oldTitle, $newTitle );
		$user = User::newFromName( 'TitleMove tester' );
		$status = $mp->move( $user, 'Reason', true );
		$this->assertStatusError( $error, $status );
	}

	/**
	 * Test moving subpages from one page to another
	 * @covers \MediaWiki\Page\MovePage::moveSubpages
	 */
	public function testMoveSubpages() {
		$name = ucfirst( __FUNCTION__ );

		$subPages = [ "Talk:$name/1", "Talk:$name/2" ];
		$ids = [];
		$pages = [
			$name,
			"Talk:$name",
			"$name 2",
			"Talk:$name 2",
		];
		foreach ( array_merge( $pages, $subPages ) as $page ) {
			$ids[$page] = $this->createPage( $page );
		}

		$oldTitle = Title::newFromText( "Talk:$name" );
		$newTitle = Title::newFromText( "Talk:$name 2" );
		$status = $this->getServiceContainer()
			->getMovePageFactory()
			->newMovePage( $oldTitle, $newTitle )
			->moveSubpages( $this->getTestUser()->getUser(), 'Reason', true );

		$this->assertStatusGood( $status,
			"Moving subpages from Talk:{$name} to Talk:{$name} 2 was not completely successful." );
		foreach ( $subPages as $page ) {
			$this->assertMoved( $page, str_replace( $name, "$name 2", $page ), $ids[$page] );
		}
	}

	/**
	 * Test moving subpages from one page to another
	 * @covers \MediaWiki\Page\MovePage::moveSubpagesIfAllowed
	 */
	public function testMoveSubpagesIfAllowed() {
		$name = ucfirst( __FUNCTION__ );

		$subPages = [ "Talk:$name/1", "Talk:$name/2" ];
		$ids = [];
		$pages = [
			$name,
			"Talk:$name",
			"$name 2",
			"Talk:$name 2",
		];
		foreach ( array_merge( $pages, $subPages ) as $page ) {
			$ids[$page] = $this->createPage( $page );
		}

		$oldTitle = Title::newFromText( "Talk:$name" );
		$newTitle = Title::newFromText( "Talk:$name 2" );
		$status = $this->getServiceContainer()
			->getMovePageFactory()
			->newMovePage( $oldTitle, $newTitle )
			->moveSubpagesIfAllowed( $this->getTestUser()->getUser(), 'Reason', true );

		$this->assertStatusGood( $status,
			"Moving subpages from Talk:{$name} to Talk:{$name} 2 was not completely successful." );
		foreach ( $subPages as $page ) {
			$this->assertMoved( $page, str_replace( $name, "$name 2", $page ), $ids[$page] );
		}
	}

	/**
	 * Shortcut function to create a page and return its id.
	 *
	 * @param string $name Page to create
	 * @return int ID of created page
	 */
	protected function createPage( $name ) {
		return $this->editPage( $name, 'Content' )->getNewRevision()->getPageId();
	}

	/**
	 * @param string $from Prefixed name of source
	 * @param string|Title $to Prefixed name of destination
	 * @param string|Title $id Page id of the page to move
	 * @param bool $createRedirect
	 */
	protected function assertMoved( $from, $to, $id, bool $createRedirect = true ) {
		Title::clearCaches();
		$fromTitle = $from instanceof Title ? $from : Title::newFromText( $from );
		$toTitle = $to instanceof Title ? $to : Title::newFromText( $to );

		$this->assertTrue( $toTitle->exists(),
			"Destination {$toTitle->getPrefixedText()} does not exist" );

		if ( !$createRedirect ) {
			$this->assertFalse( $fromTitle->exists(),
				"Source {$fromTitle->getPrefixedText()} exists" );
		} else {
			$this->assertTrue( $fromTitle->exists(),
				"Source {$fromTitle->getPrefixedText()} does not exist" );
			$this->assertTrue( $fromTitle->isRedirect(),
				"Source {$fromTitle->getPrefixedText()} is not a redirect" );

			$target = $this->getServiceContainer()
				->getRevisionLookup()
				->getRevisionByTitle( $fromTitle )
				->getContent( SlotRecord::MAIN )
				->getRedirectTarget();
			$this->assertSame( $toTitle->getPrefixedText(), $target->getPrefixedText() );
		}

		$this->assertSame( $id, $toTitle->getArticleID() );
	}

	/**
	 * Test redirect handling
	 *
	 * @covers \MediaWiki\Page\MovePage::isValidMove
	 */
	public function testRedirects() {
		$this->editPage( 'ExistentRedirect', '#REDIRECT [[Existent]]' );
		$mp = $this->newMovePageWithMocks(
			Title::makeTitle( NS_MAIN, 'Existent' ),
			Title::makeTitle( NS_MAIN, 'ExistentRedirect' )
		);
		$this->assertStatusGood(
			$mp->isValidMove(),
			'Can move over normal redirect'
		);

		$this->editPage( 'ExistentRedirect3', '#REDIRECT [[Existent]]' );
		$mp = $this->newMovePageWithMocks(
			Title::makeTitle( NS_MAIN, 'Existent2' ),
			Title::makeTitle( NS_MAIN, 'ExistentRedirect3' )
		);
		$this->assertStatusError(
			'redirectexists',
			$mp->isValidMove(),
			'Cannot move over redirect with a different target'
		);

		$this->editPage( 'ExistentRedirect3', '#REDIRECT [[Existent2]]' );
		$mp = $this->newMovePageWithMocks(
			Title::makeTitle( NS_MAIN, 'Existent' ),
			Title::makeTitle( NS_MAIN, 'ExistentRedirect3' )
		);
		$this->assertStatusError(
			'articleexists',
			$mp->isValidMove(),
			'Multi-revision redirects count as articles'
		);
	}

	/**
	 * Assert that links tables are updated after cross namespace page move (T299275).
	 */
	public function testCrossNamespaceLinksUpdate() {
		$title = Title::makeTitle( NS_TEMPLATE, 'Test' );
		$this->getExistingTestPage( $title );

		$wikitext = "[[Test]], [[Image:Existent.jpg]], {{Test}}";

		$old = Title::makeTitle( NS_USER, __METHOD__ );
		$this->editPage( $old, $wikitext );
		$pageId = $old->getId();

		// do a cross-namespace move
		$new = Title::makeTitle( NS_PROJECT, __METHOD__ );
		$obj = $this->newMovePageWithMocks( $old, $new, [ 'db' => $this->getDb() ] );
		$status = $obj->move( $this->getTestUser()->getUser() );

		// sanity checks
		$this->assertStatusOK( $status );
		$this->assertSame( $pageId, $new->getId() );
		$this->assertNotSame( $pageId, $old->getId() );

		// ensure links tables where updated
		$this->newSelectQueryBuilder()
			->select( [ 'lt_namespace', 'lt_title', 'pl_from_namespace' ] )
			->from( 'pagelinks' )
			->join( 'linktarget', null, 'pl_target_id=lt_id' )
			->where( [ 'pl_from' => $pageId ] )
			->assertResultSet( [
				[ NS_MAIN, 'Test', NS_PROJECT ]
			] );
		$targetId = MediaWikiServices::getInstance()->getLinkTargetLookup()->getLinkTargetId( $title );
		$this->newSelectQueryBuilder()
			->select( [ 'tl_target_id', 'tl_from_namespace' ] )
			->from( 'templatelinks' )
			->where( [ 'tl_from' => $pageId ] )
			->assertResultSet( [
				[ $targetId, NS_PROJECT ]
			] );
		$this->newSelectQueryBuilder()
			->select( [ 'il_to', 'il_from_namespace' ] )
			->from( 'imagelinks' )
			->where( [ 'il_from' => $pageId ] )
			->assertResultSet( [
				[ 'Existent.jpg', NS_PROJECT ]
			] );
	}

}
PK       ! O      page/UndeletePageTest.phpnu Iw        <?php

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\ContentHandler;
use MediaWiki\Page\UndeletePage;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\IPUtils;

/**
 * @group Database
 * @coversDefaultClass \MediaWiki\Page\UndeletePage
 */
class UndeletePageTest extends MediaWikiIntegrationTestCase {

	use TempUserTestTrait;

	/**
	 * @var array
	 */
	private $pages = [];

	/**
	 * A logged out user who edited the page before it was archived.
	 * @var string
	 */
	private $ipEditor;

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

		$this->ipEditor = '2001:DB8:0:0:0:0:0:1';
		$this->setupPage( 'UndeletePageTest_thePage', NS_MAIN, ' ' );
		$this->setupPage( 'UndeletePageTest_thePage', NS_TALK, ' ' );
	}

	/**
	 * @param string $titleText
	 * @param int $ns
	 * @param string $content
	 */
	private function setupPage( string $titleText, int $ns, string $content ): void {
		$this->disableAutoCreateTempUser();
		$title = Title::makeTitle( $ns, $titleText );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$performer = static::getTestUser()->getUser();
		$content = ContentHandler::makeContent( $content, $page->getTitle(), CONTENT_MODEL_WIKITEXT );
		$updater = $page->newPageUpdater( UserIdentityValue::newAnonymous( $this->ipEditor ) )
			->setContent( SlotRecord::MAIN, $content );

		$revisionRecord = $updater->saveRevision( CommentStoreComment::newUnsavedComment( "testing" ) );
		if ( !$updater->wasSuccessful() ) {
			$this->fail( $updater->getStatus()->getWikiText() );
		}

		$this->pages[] = [ 'page' => $page, 'revId' => $revisionRecord->getId() ];
		$this->deletePage( $page, '', $performer );
	}

	/**
	 * @covers ::undeleteUnsafe
	 * @covers ::undeleteRevisions
	 * @covers \MediaWiki\Revision\RevisionStoreFactory::getRevisionStoreForUndelete
	 * @covers \MediaWiki\User\ActorStoreFactory::getActorStoreForUndelete
	 */
	public function testUndeleteRevisions() {
		// TODO: MCR: Test undeletion with multiple slots. Check that slots remain untouched.
		$revisionStore = $this->getServiceContainer()->getRevisionStore();

		// First make sure old revisions are archived
		$dbr = $this->getDb();

		foreach ( [ 0, 1 ] as $key ) {
			$row = $revisionStore->newArchiveSelectQueryBuilder( $dbr )
				->joinComment()
				->where( [ 'ar_rev_id' => $this->pages[$key]['revId'] ] )
				->caller( __METHOD__ )->fetchRow();
			$this->assertEquals( $this->ipEditor, $row->ar_user_text );

			// Should not be in revision
			$row = $dbr->newSelectQueryBuilder()
				->select( '1' )
				->from( 'revision' )
				->where( [ 'rev_id' => $this->pages[$key]['revId'] ] )
				->fetchRow();
			$this->assertFalse( $row );

			// Should not be in ip_changes
			$row = $dbr->newSelectQueryBuilder()
				->select( '1' )
				->from( 'ip_changes' )
				->where( [ 'ipc_rev_id' => $this->pages[$key]['revId'] ] )
				->fetchRow();
			$this->assertFalse( $row );
		}

		// Enable autocreation of temporary users to test that undeletion of revisions performed by IP addresses works
		// when temporary accounts are enabled.
		$this->enableAutoCreateTempUser();
		// Restore the page
		$undeletePage = $this->getServiceContainer()->getUndeletePageFactory()->newUndeletePage(
			$this->pages[0]['page'],
			$this->getTestSysop()->getUser()
		);

		$status = $undeletePage->setUndeleteAssociatedTalk( true )->undeleteUnsafe( '' );
		$this->assertEquals( 2, $status->value[UndeletePage::REVISIONS_RESTORED] );

		// check subject page and talk page are both back in the revision table
		foreach ( [ 0, 1 ] as $key ) {
			$row = $revisionStore->newSelectQueryBuilder( $dbr )
				->where( [ 'rev_id' => $this->pages[$key]['revId'] ] )
				->caller( __METHOD__ )->fetchRow();

			$this->assertNotFalse( $row, 'row exists in revision table' );
			$this->assertEquals( $this->ipEditor, $row->rev_user_text );

			// Should be back in ip_changes
			$row = $dbr->newSelectQueryBuilder()
				->select( [ 'ipc_hex' ] )
				->from( 'ip_changes' )
				->where( [ 'ipc_rev_id' => $this->pages[$key]['revId'] ] )
				->fetchRow();
			$this->assertNotFalse( $row, 'row exists in ip_changes table' );
			$this->assertEquals( IPUtils::toHex( $this->ipEditor ), $row->ipc_hex );
		}
	}
}
PK       ! 'l3      page/ImagePage404Test.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Title\Title;

/**
 * For doing Image Page tests that rely on 404 thumb handling
 */
class ImagePage404Test extends MediaWikiMediaTestCase {

	protected function getRepoOptions() {
		return parent::getRepoOptions() + [ 'transformVia404' => true ];
	}

	protected function setUp(): void {
		$this->overrideConfigValue(
			MainConfigNames::ImageLimits,
			[
				[ 320, 240 ],
				[ 640, 480 ],
				[ 800, 600 ],
				[ 1024, 768 ],
				[ 1280, 1024 ]
			]
		);
		parent::setUp();
	}

	private function getImagePage( $filename ) {
		$title = Title::makeTitleSafe( NS_FILE, $filename );
		$file = $this->dataFile( $filename );
		$iPage = new ImagePage( $title );
		$iPage->setFile( $file );
		return $iPage;
	}

	/**
	 * @covers \ImagePage::getThumbSizes
	 * @dataProvider providerGetThumbSizes
	 * @param string $filename
	 * @param int $expectedNumberThumbs How many thumbnails to show
	 */
	public function testGetThumbSizes( $filename, $expectedNumberThumbs ) {
		$iPage = $this->getImagePage( $filename );
		$reflection = new ReflectionClass( $iPage );
		$reflMethod = $reflection->getMethod( 'getThumbSizes' );
		$reflMethod->setAccessible( true );

		$actual = $reflMethod->invoke( $iPage, 545, 700 );
		$this->assertCount( $expectedNumberThumbs, $actual );
	}

	public static function providerGetThumbSizes() {
		return [
			[ 'animated.gif', 6 ],
			[ 'Toll_Texas_1.svg', 6 ],
			[ '80x60-Greyscale.xcf', 6 ],
			[ 'jpeg-comment-binary.jpg', 6 ],
		];
	}
}
PK       ! @F  F  %  pager/RangeChronologicalPagerTest.phpnu Iw        <?php

use MediaWiki\MediaWikiServices;
use MediaWiki\Pager\RangeChronologicalPager;

/**
 * Test class for RangeChronologicalPagerTest logic.
 *
 * @group Pager
 * @group Database
 *
 * @author Geoffrey Mon <geofbot@gmail.com>
 */
class RangeChronologicalPagerTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers \MediaWiki\Pager\RangeChronologicalPager::getDateCond
	 * @dataProvider getDateCondProvider
	 */
	public function testGetDateCond( $inputYear, $inputMonth, $inputDay, $expected ) {
		$pager = $this->getMockForAbstractClass( RangeChronologicalPager::class );
		$this->assertEquals(
			$expected,
			wfTimestamp( TS_MW, $pager->getDateCond( $inputYear, $inputMonth, $inputDay ) )
		);
	}

	/**
	 * Data provider in [ input year, input month, input day, expected timestamp output ] format
	 */
	public static function getDateCondProvider() {
		return [
			[ 2016, 12, 5, '20161206000000' ],
			[ 2016, 12, 31, '20170101000000' ],
			[ 2016, 12, 1337, '20170101000000' ],
			[ 2016, 1337, 1337, '20170101000000' ],
			[ 2016, 1337, -1, '20170101000000' ],
			[ 2016, 12, 32, '20170101000000' ],
			[ 2016, 12, -1, '20170101000000' ],
			[ 2016, -1, -1, '20170101000000' ],
		];
	}

	/**
	 * @covers \MediaWiki\Pager\RangeChronologicalPager::getDateRangeCond
	 * @dataProvider getDateRangeCondProvider
	 */
	public function testGetDateRangeCond( $start, $end, $expected ) {
		$pager = $this->getMockForAbstractClass( RangeChronologicalPager::class );
		$this->assertArrayEquals( $expected, $pager->getDateRangeCond( $start, $end ) );
	}

	/**
	 * Data provider in [ start, end, [ expected output has start condition, has end cond ] ] format
	 */
	public static function getDateRangeCondProvider() {
		$dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();

		return [
			[
				'20161201000000',
				'20161202235959',
				[
					$dbw->buildComparison( '>=', [ '' => $dbw->timestamp( '20161201000000' ) ] ),
					$dbw->buildComparison( '<', [ '' => $dbw->timestamp( '20161203000000' ) ] ),
				],
			],
			[
				'',
				'20161202235959',
				[
					$dbw->buildComparison( '<', [ '' => $dbw->timestamp( '20161203000000' ) ] ),
				],
			],
			[
				'20161201000000',
				'',
				[
					$dbw->buildComparison( '>=', [ '' => $dbw->timestamp( '20161201000000' ) ] ),
				],
			],
			[ '', '', [] ],
		];
	}

	/**
	 * @covers \MediaWiki\Pager\RangeChronologicalPager::getDateRangeCond
	 * @dataProvider getDateRangeCondInvalidProvider
	 */
	public function testGetDateRangeCondInvalid( $start, $end ) {
		$pager = $this->getMockForAbstractClass( RangeChronologicalPager::class );
		$this->assertNull( $pager->getDateRangeCond( $start, $end ) );
	}

	public static function getDateRangeCondInvalidProvider() {
		return [
			[ '-2016-12-01', '2017-12-01', ],
			[ '2016-12-01', '-2017-12-01', ],
			[ 'abcdefghij', 'klmnopqrstu', ],
		];
	}

}
PK       ! RJӵ       pager/ContributionsPagerTest.phpnu Iw        <?php

use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\CommentFormatter\CommentFormatter;
use MediaWiki\Context\RequestContext;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Pager\ContributionsPager;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentity;

/**
 * Test class for ContributionsPagerTest handling of revision and archive modes.
 *
 * @group Pager
 * @group Database
 * @covers \MediaWiki\Pager\ContributionsPager
 */
class ContributionsPagerTest extends MediaWikiIntegrationTestCase {

	private static User $user;

	private function getPagerForTryCreatingRevisionRecord( $isArchive = false ) {
		$revisionStore = $this->createMock( RevisionStore::class );
		$revisionStore->method( 'isRevisionRow' )
			->willReturn( true );
		$revisionStore->expects( $this->never() )
			->method( $isArchive ? 'newRevisionFromRow' : 'newRevisionFromArchiveRow' );

		return $this->getMockForAbstractClass(
			ContributionsPager::class,
			[
				$this->createMock( LinkRenderer::class ),
				$this->createMock( LinkBatchFactory::class ),
				$this->createMock( HookContainer::class ),
				$this->createMock( RevisionStore::class ),
				$this->createMock( NamespaceInfo::class ),
				$this->createMock( CommentFormatter::class ),
				$this->createMock( UserFactory::class ),
				new RequestContext(),
				[
					'isArchive' => $isArchive,
				],
				$this->createMock( UserIdentity::class ),
			]
		);
	}

	/**
	 * @dataProvider provideIsArchive
	 */
	public function testTryCreatingRevisionRecord( $isArchive, $row ) {
		$pager = $this->getPagerForTryCreatingRevisionRecord( $isArchive );
		$pager->tryCreatingRevisionRecord( $row );
	}

	/**
	 * @dataProvider provideIsArchive
	 */
	public function testCreateRevisionRecord( $isArchive, $row ) {
		$pager = $this->getPagerForTryCreatingRevisionRecord( $isArchive );
		$pager->createRevisionRecord( $row );
	}

	public function provideIsArchive() {
		return [
			'Get revisions from the revision table' => [ false, [ 'rev_id' => 6789 ] ],
			'Get revisions from the archive table' => [ true, [ 'ar_rev_id' => 9876 ] ],
		];
	}

	private function getPager( $context, $target ) {
		$services = $this->getServiceContainer();

		// Define a pager in 'archive' mode.
		$pager = new class(
			$services->getLinkRenderer(),
			$services->getLinkBatchFactory(),
			$services->getHookContainer(),
			$services->getRevisionStore(),
			$services->getNamespaceInfo(),
			$services->getCommentFormatter(),
			$services->getUserFactory(),
			$context,
			[
				'isArchive' => true,
				'target' => $target,
				// The topOnly filter should be ignored and not throw an error: T371495
				'topOnly' => true,
			],
			$this->createMock( UserIdentity::class ),
		) extends ContributionsPager {
			protected string $revisionIdField = 'ar_rev_id';
			protected string $revisionParentIdField = 'ar_parent_id';
			protected string $revisionTimestampField = 'ar_timestamp';
			protected string $revisionLengthField = 'ar_len';
			protected string $revisionDeletedField = 'ar_deleted';
			protected string $revisionMinorField = 'ar_minor_edit';
			protected string $userNameField = 'ar_user_text';
			protected string $pageNamespaceField = 'ar_namespace';
			protected string $pageTitleField = 'ar_title';

			protected function getRevisionQuery() {
				$revQuery = $this->revisionStore->newArchiveSelectQueryBuilder( $this->getDatabase() )
					->joinComment()
					->where( [ 'actor_name' => $this->target ] )
					->andWhere( $this->getNamespaceCond() )
					->getQueryInfo( 'joins' );
				return [
					'tables' => $revQuery['tables'],
					'fields' => $revQuery['fields'],
					'conds' => [],
					'options' => [],
					'join_conds' => $revQuery['joins'],
				];
			}

			public function getIndexField() {
				return 'ar_timestamp';
			}
		};

		return $pager;
	}

	/**
	 * Test the pager in 'archive' mode. This involves making a new class, since no
	 * concrete subclass in MediaWiki core currently uses this mode.
	 *
	 * In the future, SpecialDeletedContributions could use a subclass of ContributionsPager
	 * instead of DeletedContribsPager. In that case, this test can be moved to the tests
	 * for that class.
	 */
	public function testFormatRow() {
		$context = new RequestContext();
		$context->setLanguage( 'qqx' );

		$pager = $this->getPager( $context, self::$user->getName() );

		// Perform assertions
		$this->assertSame( 1, $pager->getNumRows() );

		$html = $pager->getBody();
		$this->assertStringContainsString( 'deletionlog', $html );
		$this->assertStringContainsString( 'undeleteviewlink', $html );

		// The performing user does not have the right to undelete
		$this->assertStringNotContainsString( 'mw-changeslist-date', $html );
	}

	public function testFormatRowDateLinks() {
		$context = new RequestContext();
		$context->setUser( $this->getTestSysop()->getUser() );

		$pager = $this->getPager( $context, self::$user->getName() );

		$html = $pager->getBody();
		$this->assertStringContainsString( 'mw-changeslist-date', $html );
	}

	public function addDbDataOnce() {
		// Set up data
		self::$user = $this->getTestUser()->getUser();
		$this->editPage(
			'Test page for deletion', 'Test Content', 'test', NS_MAIN, self::$user
		);
		$title = Title::newFromText( 'Test page for deletion' );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		$this->deletePage( $page );
	}

}
PK       ! e      pager/HistoryPagerTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Output\OutputPage;
use MediaWiki\Pager\HistoryPager;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Title\Title;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\TestingAccessWrapper;

/**
 * Test class for HistoryPager methods.
 *
 * @group Pager
 * @group Database
 */
class HistoryPagerTest extends MediaWikiIntegrationTestCase {
	/**
	 * @param array $results for passing to FakeResultWrapper and deriving
	 *  RevisionRecords and formatted comments.
	 * @return HistoryPager
	 */
	private function getHistoryPager( array $results ) {
		$wikiPageMock = $this->createMock( WikiPage::class );
		$contextMock = $this->getMockBuilder( RequestContext::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getRequest', 'getWikiPage', 'getTitle' ] )
			->getMock();
		$contextMock->method( 'getRequest' )->willReturn(
			new FauxRequest( [] )
		);
		$title = Title::makeTitle( NS_MAIN, 'HistoryPagerTest' );
		$contextMock->method( 'getTitle' )->willReturn( $title );

		$contextMock->method( 'getWikiPage' )->willReturn( $wikiPageMock );
		$articleMock = $this->getMockBuilder( Article::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getContext' ] )
			->getMock();
		$articleMock->method( 'getContext' )->willReturn( $contextMock );

		$actionMock = $this->getMockBuilder( HistoryAction::class )
			->setConstructorArgs( [
				$articleMock,
				$contextMock
			] )
			->getMock();
		$actionMock->method( 'getArticle' )->willReturn( $articleMock );
		$actionMock->message = [
			'cur' => 'cur',
			'last' => 'last',
			'tooltip-cur' => '',
			'tooltip-last' => '',
		];

		$outputMock = $this->getMockBuilder( OutputPage::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'wrapWikiMsg' ] )
			->getMock();

		$pager = $this->getMockBuilder( HistoryPager::class )
			->onlyMethods( [ 'reallyDoQuery', 'doBatchLookups',
				'getOutput' ] )
			->setConstructorArgs( [
				$actionMock
			] )
			->getMock();

		$pager->method( 'getOutput' )->willReturn( $outputMock );
		$pager->method( 'reallyDoQuery' )->willReturn(
			new FakeResultWrapper( $results )
		);

		// make all the methods in our mock public
		$pager = TestingAccessWrapper::newFromObject( $pager );
		// and update the private properties...
		$pager->formattedComments = array_map( static function ( $result ) {
			return 'dummy comment';
		}, $results );

		$pager->revisions = array_map( static function ( $result ) {
			$title = Title::makeTitle( NS_MAIN, 'HistoryPagerTest' );
			$r = new MutableRevisionRecord( $title );
			$r->setId( $result[ 'rev_id' ] );
			return $r;
		}, $results );

		return $pager;
	}

	/**
	 * @covers \MediaWiki\Pager\HistoryPager::getBody
	 */
	public function testGetBodyEmpty() {
		$pager = $this->getHistoryPager( [] );
		$html = $pager->getBody();
		$this->assertStringContainsString( 'No matching revisions were found.', $html );
		$this->assertStringNotContainsString( '<h4', $html );
	}

	/**
	 * @covers \MediaWiki\Pager\HistoryPager::getBody
	 */
	public function testGetBodyOneHeading() {
		$pager = $this->getHistoryPager(
			[
				[
					'rev_page' => 'title',
					'ts_tags' => '',
					'rev_deleted' => false,
					'rev_minor_edit' => false,
					'rev_parent_id' => 1,
					'user_name' => 'Jdlrobson',
					'rev_id' => 2,
					'rev_comment_data' => '{}',
					'rev_comment_cid' => '1',
					'rev_comment_text' => 'Created page',
					'rev_timestamp' => '20220101001122',
				]
			]
		);
		$html = $pager->getBody();
		$this->assertStringContainsString( '<h4', $html );
	}

	/**
	 * @covers \MediaWiki\Pager\HistoryPager::getBody
	 */
	public function testGetBodyTwoHeading() {
		$pagerData = [
			'rev_page' => 'title',
			'rev_deleted' => false,
			'rev_minor_edit' => false,
			'ts_tags' => '',
			'rev_parent_id' => 1,
			'user_name' => 'Jdlrobson',
			'rev_comment_data' => '{}',
			'rev_comment_cid' => '1',
			'rev_comment_text' => 'Fixed typo',
		];
		$pager = $this->getHistoryPager(
			[
				$pagerData + [
					'rev_id' => 3,
					'rev_timestamp' => '20220301001122',
				],
				$pagerData + [
					'rev_id' => 2,
					'rev_timestamp' => '20220101001122',
				],
			]
		);
		$html = preg_replace( "/\n+/", '', $pager->getBody() );
		$firstHeader = '<h4 class="mw-index-pager-list-header-first mw-index-pager-list-header">1 March 2022</h4>'
			. '<ul class="mw-contributions-list">'
			. '<li data-mw-revid="3">';
		$secondHeader = '<h4 class="mw-index-pager-list-header">1 January 2022</h4>'
			. '<ul class="mw-contributions-list">'
			. '<li data-mw-revid="2">';

		// Check that the undo links are correct in the topmost displayed row (for rev_id=3).
		// This is tricky because the other rev number (in this example, '2') is magically
		// pulled from the next row, before we've processed that row.
		$this->assertStringContainsString( '&amp;undoafter=2&amp;undo=3', $html );

		$this->assertStringContainsString( $firstHeader, $html );
		$this->assertStringContainsString( $secondHeader, $html );
		$this->assertStringContainsString( '<section id="pagehistory"', $html );
	}

	/**
	 * @covers \MediaWiki\Pager\HistoryPager::getBody
	 */
	public function testGetBodyLastItem() {
		$pagerData = [
			'rev_page' => 'title',
			'rev_deleted' => false,
			'rev_minor_edit' => false,
			'ts_tags' => '',
			'rev_parent_id' => 1,
			'user_name' => 'Jdlrobson',
			'rev_comment_data' => '{}',
			'rev_comment_cid' => '1',
			'rev_comment_text' => 'Fixed typo',
		];
		$pager = $this->getHistoryPager(
			[
				$pagerData + [
					'rev_id' => 2,
					'rev_timestamp' => '20220301001111',
				],
				$pagerData + [
					'rev_id' => 3,
					'rev_timestamp' => '20220301001122',
				],
			]
		);
		$html = preg_replace( "/\n+/", '', $pager->getBody() );
		$this->assertSame( 1, substr_count( $html, '<h4' ),
			'There is exactly one header if the date is the same for all edits' );
		$this->assertSame( 1, substr_count( $html, '<ul' ),
			'There is exactly one list if the date is the same for all edits' );
	}
}
PK       ! No
  
  '  pager/ReverseChronologicalPagerTest.phpnu Iw        <?php

use MediaWiki\Pager\ReverseChronologicalPager;
use MediaWiki\Utils\MWTimestamp;
use Wikimedia\TestingAccessWrapper;

/**
 * Test class for ReverseChronologicalPagerTest methods.
 *
 * @group Pager
 * @group Database
 *
 * @author Geoffrey Mon <geofbot@gmail.com>
 */
class ReverseChronologicalPagerTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers \MediaWiki\Pager\ReverseChronologicalPager::getDateCond
	 * @dataProvider provideGetDateCond
	 */
	public function testGetDateCond( $params, $expected ) {
		$pager = $this->getMockForAbstractClass( ReverseChronologicalPager::class );
		$pagerWrapper = TestingAccessWrapper::newFromObject( $pager );

		$pager->getDateCond( ...$params );
		$this->assertEquals( $pagerWrapper->endOffset, $this->getDb()->timestamp( $expected ) );
	}

	/**
	 * Data provider in description => [ [ param1, ... ], expected output ] format
	 */
	public static function provideGetDateCond() {
		yield 'Test year and month' => [
			[ 2006, 6 ], '20060701000000'
		];
		yield 'Test year, month, and day' => [
			[ 2006, 6, 5 ], '20060606000000'
		];
		yield 'Test month overflow into the next year' => [
			[ 2006, 12 ], '20070101000000'
		];
		yield 'Test day overflow to the next month' => [
			[ 2006, 6, 30 ], '20060701000000'
		];
		yield 'Test invalid month (should use end of year)' => [
			[ 2006, -1 ], '20070101000000'
		];
		yield 'Test invalid day (should use end of month)' => [
			[ 2006, 6, 1337 ], '20060701000000'
		];
		yield 'Test last day of year' => [
			[ 2006, 12, 31 ], '20070101000000'
		];
		yield 'Test invalid day that overflows to next year' => [
			[ 2006, 12, 32 ], '20070101000000'
		];
		yield '3-digit year, T287621' => [
			[ 720, 1, 5 ], '07200106000000'
		];
		yield 'Y2K38' => [
			[ 2042, 1, 5 ], '20420106000000'
		];
	}

	/**
	 * @covers \MediaWiki\Pager\ReverseChronologicalPager::getDateCond
	 */
	public function testGetDateCondSpecial() {
		$pager = $this->getMockForAbstractClass( ReverseChronologicalPager::class );
		$pagerWrapper = TestingAccessWrapper::newFromObject( $pager );
		$timestamp = MWTimestamp::getInstance();
		$db = $this->getDb();

		$currYear = $timestamp->format( 'Y' );
		$currMonth = $timestamp->format( 'n' );

		// Test that getDateCond sets and returns offset
		$this->assertEquals( $pager->getDateCond( 2006, 6 ), $pagerWrapper->endOffset );

		// Test month past current month (should use previous year)
		if ( $currMonth < 5 ) {
			$pager->getDateCond( -1, 5 );
			$this->assertEquals( $pagerWrapper->endOffset, $db->timestamp( $currYear - 1 . '0601000000' ) );
		}
		if ( $currMonth < 12 ) {
			$pager->getDateCond( -1, 12 );
			$this->assertEquals( $pagerWrapper->endOffset, $db->timestamp( $currYear . '0101000000' ) );
		}
	}
}
PK       ! X0/Gn  n    changetags/ChangeTagsTest.phpnu Iw        <?php

use MediaWiki\Language\RawMessage;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use Wikimedia\Rdbms\Platform\ISQLPlatform;

/**
 * @covers \ChangeTags
 * @group Database
 */
class ChangeTagsTest extends MediaWikiIntegrationTestCase {

	protected function tearDown(): void {
		parent::tearDown();
	}

	private function emptyChangeTagsTables() {
		$dbw = $this->getDb();
		$dbw->newDeleteQueryBuilder()
			->deleteFrom( 'change_tag' )
			->where( ISQLPlatform::ALL_ROWS )
			->execute();
		$dbw->newDeleteQueryBuilder()
			->deleteFrom( 'change_tag_def' )
			->where( ISQLPlatform::ALL_ROWS )
			->execute();
	}

	// TODO most methods are not tested

	public function testBuildTagFilterSelector_allTags() {
		// Set `activeOnly` to false
		// Expect that at least all the software defined tags are returned
		$allTags = MediaWikiServices::getInstance()->getChangeTagsStore()->listDefinedTags();
		$allTagsList = ChangeTags::getChangeTagListSummary(
			RequestContext::getMain(),
			RequestContext::getMain()->getLanguage(),
			ChangeTags::TAG_SET_ALL
		);
		$this->assertTrue(
			count( $allTagsList ) >= count( $allTags ),
			'`activeOnly` is false, expect all software tags'
		);
	}

	public function testBuildTagFilterSelector_allSoftwareTags() {
		// Set both `activeOnly` and `useAllTags` to false
		// Expect that only software defined tags are returned
		$allSoftwareTags = MediaWikiServices::getInstance()->getChangeTagsStore()->getSoftwareTags( true );
		$allSoftwareTagsList = ChangeTags::getChangeTagListSummary(
			RequestContext::getMain(),
			RequestContext::getMain()->getLanguage(),
			ChangeTags::TAG_SET_ALL,
			ChangeTags::USE_SOFTWARE_TAGS_ONLY
		);
		$this->assertTrue(
			count( $allSoftwareTagsList ) == count( $allSoftwareTags ),
			'`activeOnly` and `useAllTags` are false, expect only software tags'
		);
	}

	public function testBuildTagFilterSelector_activeOnlyNoHits() {
		// Enable and test `activeOnly` and expect no tags returned,
		// as there are currently no tagged edits in the test database
		$emptyTagListSummary = ChangeTags::getChangeTagListSummary(
			RequestContext::getMain(),
			RequestContext::getMain()->getLanguage(),
			ChangeTags::TAG_SET_ACTIVE_ONLY
		);
		$this->assertCount( 0, $emptyTagListSummary, '`activeOnly` is true and no hits, expect no tags' );

		// Assert that by default, an empty select is returned, as no tags have been used yet
		$this->assertEquals(
			[
				'<label for="tagfilter"><a href="/wiki/Special:Tags" title="Special:Tags">Tag</a> filter:</label>',
				'<input class="mw-tagfilter-input mw-ui-input mw-ui-input-inline" size="20" id="tagfilter" list="tagfilter-datalist" name="tagfilter"><datalist id="tagfilter-datalist"></datalist>'
			],
			ChangeTags::buildTagFilterSelector(
				'', false, RequestContext::getMain()
			)
		);
	}

	public function testBuildTagFilterSelector_activeOnly() {
		// Disable patrolling so reverts will happen without approval
		$this->overrideConfigValues( [ MainConfigNames::UseRCPatrol => false ] );

		// Make an edit and replace the content, adding the `mw-replace` tag to the revision
		$page = $this->getExistingTestPage();
		$this->editPage( $page, '1' );
		$this->editPage(
			$page, '0', '', NS_MAIN, $this->getTestUser()->getUser()
		);

		// Ensure all deferred updates are run
		DeferredUpdates::doUpdates();

		// Assert that only the `mw-replace` tag is returned
		$replaceOnlyTagList = ChangeTags::getChangeTagListSummary(
			RequestContext::getMain(),
			RequestContext::getMain()->getLanguage()
		);
		$this->assertCount( 1, $replaceOnlyTagList, '`activeOnly` is true with 1 hit, return 1 tag' );
		$this->assertEquals(
			'mw-replace', $replaceOnlyTagList[0]['name'],
			'`activeOnly` is true with 1 hit, return expected tag'
		);

		// Assert that the tag is reflected in the default markup returned
		$this->assertEquals(
			[
				'<label for="tagfilter"><a href="/wiki/Special:Tags" title="Special:Tags">Tag</a> filter:</label>',
				'<input class="mw-tagfilter-input mw-ui-input mw-ui-input-inline" size="20" id="tagfilter" list="tagfilter-datalist" name="tagfilter"><datalist id="tagfilter-datalist"><option value="mw-replace">Replaced</option></datalist>'
			],
			ChangeTags::buildTagFilterSelector(
				'', false, RequestContext::getMain()
			),
			'`activeOnly` is true with 1 hit, return expected tag markup'
		);
	}

	/** @dataProvider provideModifyDisplayQuery */
	public function testModifyDisplayQuery(
		$origQuery,
		$filter_tag,
		$useTags,
		$avoidReopeningTables,
		$modifiedQuery,
		$exclude = false
	) {
		$this->overrideConfigValue( MainConfigNames::UseTagFilter, $useTags );
		if (
			$avoidReopeningTables &&
			$this->getDb()->getType() === 'mysql' &&
			str_contains( $this->getDb()->getSoftwareLink(), 'MySQL' )
		) {
			$this->markTestSkipped( 'See T256006' );
		}

		$rcId = 123;
		ChangeTags::updateTags( [ 'foo', 'bar', '0' ], [], $rcId );
		// HACK resolve deferred group concats (see comment in provideModifyDisplayQuery)
		if ( isset( $modifiedQuery['fields']['ts_tags'] ) ) {
			$modifiedQuery['fields']['ts_tags'] = $this->getDb()
				->buildGroupConcatField( ...$modifiedQuery['fields']['ts_tags'] );
		}
		if ( isset( $modifiedQuery['exception'] ) ) {
			$this->expectException( $modifiedQuery['exception'] );
		}
		ChangeTags::modifyDisplayQuery(
			$origQuery['tables'],
			$origQuery['fields'],
			$origQuery['conds'],
			$origQuery['join_conds'],
			$origQuery['options'],
			$filter_tag,
			$exclude
		);
		if ( !isset( $modifiedQuery['exception'] ) ) {
			$this->assertArrayEquals(
				$modifiedQuery,
				$origQuery,
				/* ordered = */ false,
				/* named = */ true
			);
		}
	}

	public static function provideModifyDisplayQuery() {
		// HACK if we call $dbr->buildGroupConcatField() now, it will return the wrong table names
		// We have to have the test runner call it instead
		$baseConcats = [ ',', [ 'change_tag', 'change_tag_def' ], 'ctd_name' ];
		$joinConds = [ 'change_tag_def' => [ 'JOIN', 'ct_tag_id=ctd_id' ] ];
		$groupConcats = [
			'recentchanges' => array_merge( $baseConcats, [ [ 'ct_rc_id=rc_id' ], $joinConds ] ),
			'logging' => array_merge( $baseConcats, [ [ 'ct_log_id=log_id' ], $joinConds ] ),
			'revision' => array_merge( $baseConcats, [ [ 'ct_rev_id=rev_id' ], $joinConds ] ),
			'archive' => array_merge( $baseConcats, [ [ 'ct_rev_id=ar_rev_id' ], $joinConds ] ),
		];

		return [
			'simple recentchanges query' => [
				[
					'tables' => [ 'recentchanges' ],
					'fields' => [ 'rc_id', 'rc_timestamp' ],
					'conds' => [ "rc_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ],
				],
				'', // no tag filter
				true, // tag filtering enabled
				false, // not avoiding reopening tables
				[
					'tables' => [ 'recentchanges' ],
					'fields' => [ 'rc_id', 'rc_timestamp', 'ts_tags' => $groupConcats['recentchanges'] ],
					'conds' => [ "rc_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ],
				]
			],
			"simple query with strings, tagfilter=''" => [
				[
					'tables' => 'recentchanges',
					'fields' => 'rc_id',
					'conds' => "rc_timestamp > '20170714183203'",
					'join_conds' => [],
					'options' => 'ORDER BY rc_timestamp DESC',
				],
				'', // no tag filter
				true, // tag filtering enabled
				false, // not avoiding reopening tables
				[
					'tables' => [ 'recentchanges' ],
					'fields' => [ 'rc_id', 'ts_tags' => $groupConcats['recentchanges'] ],
					'conds' => [ "rc_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY rc_timestamp DESC' ],
				]
			],
			'simple query with strings, tagfilter=false' => [
				[
					'tables' => 'recentchanges',
					'fields' => 'rc_id',
					'conds' => "rc_timestamp > '20170714183203'",
					'join_conds' => [],
					'options' => 'ORDER BY rc_timestamp DESC',
				],
				false, // no tag filter
				true, // tag filtering enabled
				false, // not avoiding reopening tables
				[
					'tables' => [ 'recentchanges' ],
					'fields' => [ 'rc_id', 'ts_tags' => $groupConcats['recentchanges'] ],
					'conds' => [ "rc_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY rc_timestamp DESC' ],
				]
			],
			'simple query with strings, tagfilter=null' => [
				[
					'tables' => 'recentchanges',
					'fields' => 'rc_id',
					'conds' => "rc_timestamp > '20170714183203'",
					'join_conds' => [],
					'options' => 'ORDER BY rc_timestamp DESC',
				],
				null, // no tag filter
				true, // tag filtering enabled
				false, // not avoiding reopening tables
				[
					'tables' => [ 'recentchanges' ],
					'fields' => [ 'rc_id', 'ts_tags' => $groupConcats['recentchanges'] ],
					'conds' => [ "rc_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY rc_timestamp DESC' ],
				]
			],
			'recentchanges query with single tag filter' => [
				[
					'tables' => [ 'recentchanges' ],
					'fields' => [ 'rc_id', 'rc_timestamp' ],
					'conds' => [ "rc_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ],
				],
				'foo',
				true, // tag filtering enabled
				false, // not avoiding reopening tables
				[
					'tables' => [ 'recentchanges', 'changetagdisplay' => 'change_tag' ],
					'fields' => [ 'rc_id', 'rc_timestamp', 'ts_tags' => $groupConcats['recentchanges'] ],
					'conds' => [ "rc_timestamp > '20170714183203'", 'changetagdisplay.ct_tag_id' => [ 1 ] ],
					'join_conds' => [ 'changetagdisplay' => [ 'JOIN', 'changetagdisplay.ct_rc_id=rc_id' ] ],
					'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ],
				]
			],
			'recentchanges query with "0" tag filter' => [
				[
					'tables' => [ 'recentchanges' ],
					'fields' => [ 'rc_id', 'rc_timestamp' ],
					'conds' => [ "rc_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ],
				],
				'0',
				true, // tag filtering enabled
				false, // not avoiding reopening tables
				[
					'tables' => [ 'recentchanges', 'changetagdisplay' => 'change_tag' ],
					'fields' => [ 'rc_id', 'rc_timestamp', 'ts_tags' => $groupConcats['recentchanges'] ],
					'conds' => [ "rc_timestamp > '20170714183203'", 'changetagdisplay.ct_tag_id' => [ 3 ] ],
					'join_conds' => [ 'changetagdisplay' => [ 'JOIN', 'changetagdisplay.ct_rc_id=rc_id' ] ],
					'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ],
				]
			],
			'logging query with single tag filter and strings' => [
				[
					'tables' => 'logging',
					'fields' => 'log_id',
					'conds' => "log_timestamp > '20170714183203'",
					'join_conds' => [],
					'options' => [ 'ORDER BY' => [ 'log_timestamp DESC', 'log_id DESC' ] ],
				],
				'foo',
				true, // tag filtering enabled
				false, // not avoiding reopening tables
				[
					'tables' => [ 'logging', 'changetagdisplay' => 'change_tag' ],
					'fields' => [ 'log_id', 'ts_tags' => $groupConcats['logging'] ],
					'conds' => [ "log_timestamp > '20170714183203'", 'changetagdisplay.ct_tag_id' => [ 1 ] ],
					'join_conds' => [ 'changetagdisplay' => [ 'JOIN', 'changetagdisplay.ct_log_id=log_id' ] ],
					'options' => [ 'ORDER BY' => [ 'log_timestamp DESC', 'log_id DESC' ] ],
				]
			],
			'revision query with single tag filter' => [
				[
					'tables' => [ 'revision' ],
					'fields' => [ 'rev_id', 'rev_timestamp' ],
					'conds' => [ "rev_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY' => 'rev_timestamp DESC' ],
				],
				'foo',
				true, // tag filtering enabled
				false, // not avoiding reopening tables
				[
					'tables' => [ 'revision', 'changetagdisplay' => 'change_tag' ],
					'fields' => [ 'rev_id', 'rev_timestamp', 'ts_tags' => $groupConcats['revision'] ],
					'conds' => [ "rev_timestamp > '20170714183203'", 'changetagdisplay.ct_tag_id' => [ 1 ] ],
					'join_conds' => [ 'changetagdisplay' => [ 'JOIN', 'changetagdisplay.ct_rev_id=rev_id' ] ],
					'options' => [ 'ORDER BY' => 'rev_timestamp DESC' ],
				]
			],
			'archive query with single tag filter' => [
				[
					'tables' => [ 'archive' ],
					'fields' => [ 'ar_id', 'ar_timestamp' ],
					'conds' => [ "ar_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY' => 'ar_timestamp DESC' ],
				],
				'foo',
				true, // tag filtering enabled
				false, // not avoiding reopening tables
				[
					'tables' => [ 'archive', 'changetagdisplay' => 'change_tag' ],
					'fields' => [ 'ar_id', 'ar_timestamp', 'ts_tags' => $groupConcats['archive'] ],
					'conds' => [ "ar_timestamp > '20170714183203'", 'changetagdisplay.ct_tag_id' => [ 1 ] ],
					'join_conds' => [ 'changetagdisplay' => [ 'JOIN', 'changetagdisplay.ct_rev_id=ar_rev_id' ] ],
					'options' => [ 'ORDER BY' => 'ar_timestamp DESC' ],
				]
			],
			'archive query with single tag filter, avoiding reopening tables' => [
				[
					'tables' => [ 'archive' ],
					'fields' => [ 'ar_id', 'ar_timestamp' ],
					'conds' => [ "ar_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY' => 'ar_timestamp DESC' ],
				],
				'foo',
				true, // tag filtering enabled
				true, // avoid reopening tables
				[
					'tables' => [ 'archive', 'changetagdisplay' => 'change_tag' ],
					'fields' => [ 'ar_id', 'ar_timestamp', 'ts_tags' => $groupConcats['archive'] ],
					'conds' => [ "ar_timestamp > '20170714183203'", 'changetagdisplay.ct_tag_id' => [ 1 ] ],
					'join_conds' => [ 'changetagdisplay' => [ 'JOIN', 'changetagdisplay.ct_rev_id=ar_rev_id' ] ],
					'options' => [ 'ORDER BY' => 'ar_timestamp DESC' ],
				]
			],
			'unsupported table name throws exception (even without tag filter)' => [
				[
					'tables' => [ 'foobar' ],
					'fields' => [ 'fb_id', 'fb_timestamp' ],
					'conds' => [ "fb_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY' => 'fb_timestamp DESC' ],
				],
				'',
				true, // tag filtering enabled
				false, // not avoiding reopening tables
				[ 'exception' => InvalidArgumentException::class ]
			],
			'tag filter ignored when tag filtering is disabled' => [
				[
					'tables' => [ 'archive' ],
					'fields' => [ 'ar_id', 'ar_timestamp' ],
					'conds' => [ "ar_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY' => 'ar_timestamp DESC' ],
				],
				'foo',
				false, // tag filtering disabled
				false, // not avoiding reopening tables
				[
					'tables' => [ 'archive' ],
					'fields' => [ 'ar_id', 'ar_timestamp', 'ts_tags' => $groupConcats['archive'] ],
					'conds' => [ "ar_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY' => 'ar_timestamp DESC' ],
				]
			],
			'recentchanges query with multiple tag filter' => [
				[
					'tables' => [ 'recentchanges' ],
					'fields' => [ 'rc_id', 'rc_timestamp' ],
					'conds' => [ "rc_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ],
				],
				[ 'foo', 'bar' ],
				true, // tag filtering enabled
				false, // not avoiding reopening tables
				[
					'tables' => [ 'recentchanges', 'changetagdisplay' => 'change_tag' ],
					'fields' => [ 'rc_id', 'rc_timestamp', 'ts_tags' => $groupConcats['recentchanges'] ],
					'conds' => [ "rc_timestamp > '20170714183203'", 'changetagdisplay.ct_tag_id' => [ 1, 2 ] ],
					'join_conds' => [ 'changetagdisplay' => [ 'JOIN', 'changetagdisplay.ct_rc_id=rc_id' ] ],
					'options' => [ 'ORDER BY' => 'rc_timestamp DESC', 'DISTINCT' ],
				]
			],
			'recentchanges query with exclusive multiple tag filter' => [
				[
					'tables' => [ 'recentchanges' ],
					'fields' => [ 'rc_id', 'rc_timestamp' ],
					'conds' => [ "rc_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ],
				],
				[ 'foo', 'bar' ],
				true, // tag filtering enabled
				false, // not avoiding reopening tables
				[
					'tables' => [ 'recentchanges', 'changetagdisplay' => 'change_tag' ],
					'fields' => [ 'rc_id', 'rc_timestamp', 'ts_tags' => $groupConcats['recentchanges'] ],
					'conds' => [ "rc_timestamp > '20170714183203'", 'changetagdisplay.ct_tag_id' => null ],
					'join_conds' => [ 'changetagdisplay' => [ 'LEFT JOIN', [ 'changetagdisplay.ct_rc_id=rc_id', 'changetagdisplay.ct_tag_id' => [ 1, 2 ] ] ] ],
					'options' => [ 'ORDER BY' => 'rc_timestamp DESC' ],
				],
				true // exclude
			],
			'recentchanges query with multiple tag filter that already has DISTINCT' => [
				[
					'tables' => [ 'recentchanges' ],
					'fields' => [ 'rc_id', 'rc_timestamp' ],
					'conds' => [ "rc_timestamp > '20170714183203'" ],
					'join_conds' => [],
					'options' => [ 'DISTINCT', 'ORDER BY' => 'rc_timestamp DESC' ],
				],
				[ 'foo', 'bar' ],
				true, // tag filtering enabled
				false, // not avoiding reopening tables
				[
					'tables' => [ 'recentchanges', 'changetagdisplay' => 'change_tag' ],
					'fields' => [ 'rc_id', 'rc_timestamp', 'ts_tags' => $groupConcats['recentchanges'] ],
					'conds' => [ "rc_timestamp > '20170714183203'", 'changetagdisplay.ct_tag_id' => [ 1, 2 ] ],
					'join_conds' => [ 'changetagdisplay' => [ 'JOIN', 'changetagdisplay.ct_rc_id=rc_id' ] ],
					'options' => [ 'DISTINCT', 'ORDER BY' => 'rc_timestamp DESC' ],
				]
			],
			'recentchanges query with multiple tag filter with strings' => [
				[
					'tables' => 'recentchanges',
					'fields' => 'rc_id',
					'conds' => "rc_timestamp > '20170714183203'",
					'join_conds' => [],
					'options' => 'ORDER BY rc_timestamp DESC',
				],
				[ 'foo', 'bar' ],
				true, // tag filtering enabled
				false, // not avoiding reopening tables
				[
					'tables' => [ 'recentchanges', 'changetagdisplay' => 'change_tag' ],
					'fields' => [ 'rc_id', 'ts_tags' => $groupConcats['recentchanges'] ],
					'conds' => [ "rc_timestamp > '20170714183203'", 'changetagdisplay.ct_tag_id' => [ 1, 2 ] ],
					'join_conds' => [ 'changetagdisplay' => [ 'JOIN', 'changetagdisplay.ct_rc_id=rc_id' ] ],
					'options' => [ 'ORDER BY rc_timestamp DESC', 'DISTINCT' ],
				]
			],
			'recentchanges query with multiple tag filter with strings, avoiding reopening tables' => [
				[
					'tables' => 'recentchanges',
					'fields' => 'rc_id',
					'conds' => "rc_timestamp > '20170714183203'",
					'join_conds' => [],
					'options' => 'ORDER BY rc_timestamp DESC',
				],
				[ 'foo', 'bar' ],
				true, // tag filtering enabled
				true, // avoid reopening tables
				[
					'tables' => [ 'recentchanges', 'changetagdisplay' => 'change_tag' ],
					'fields' => [ 'rc_id', 'ts_tags' => $groupConcats['recentchanges'] ],
					'conds' => [ "rc_timestamp > '20170714183203'", 'changetagdisplay.ct_tag_id' => [ 1, 2 ] ],
					'join_conds' => [ 'changetagdisplay' => [ 'JOIN', 'changetagdisplay.ct_rc_id=rc_id' ] ],
					'options' => [ 'ORDER BY rc_timestamp DESC', 'DISTINCT' ],
				]
			],
		];
	}

	public static function dataGetSoftwareTags() {
		return [
			[
				[
					'mw-contentModelChange' => true,
					'mw-redirect' => true,
					'mw-rollback' => true,
					'mw-blank' => true,
					'mw-replace' => true,
				],
				[
					'mw-rollback',
					'mw-replace',
					'mw-blank',
				]
			],

			[
				[
					'mw-contentmodelchanged' => true,
					'mw-replace' => true,
					'mw-new-redirects' => true,
					'mw-changed-redirect-target' => true,
					'mw-rolback' => true,
					'mw-blanking' => false
				],
				[
					'mw-replace',
					'mw-changed-redirect-target'
				]
			],

			[
				[
					null,
					false,
					'Lorem ipsum',
					'mw-translation'
				],
				[]
			],

			[
				[],
				[]
			]
		];
	}

	/**
	 * @dataProvider dataGetSoftwareTags
	 * @covers \ChangeTags::getSoftwareTags
	 */
	public function testGetSoftwareTags( $softwareTags, $expected ) {
		$this->overrideConfigValue( MainConfigNames::SoftwareTags, $softwareTags );

		$actual = ChangeTags::getSoftwareTags();
		// Order of tags in arrays is not important
		sort( $expected );
		sort( $actual );
		$this->assertEquals( $expected, $actual );
	}

	public function testUpdateTags() {
		$this->emptyChangeTagsTables();

		$rcId = 123;
		$revId = 341;
		ChangeTags::updateTags( [ 'tag1', 'tag2' ], [], $rcId, $revId );

		$this->newSelectQueryBuilder()
			->select( [ 'ctd_name', 'ctd_id', 'ctd_count' ] )
			->from( 'change_tag_def' )
			->assertResultSet( [
				// values of fields 'ctd_name', 'ctd_id', 'ctd_count'
				[ 'tag1', 1, 1 ],
				[ 'tag2', 2, 1 ],
			] );
		$this->newSelectQueryBuilder()
			->from( 'change_tag' )
			->select( [ 'ct_tag_id', 'ct_rc_id', 'ct_rev_id' ] )
			->assertResultSet( [
				// values of fields 'ct_tag_id', 'ct_rc_id', 'ct_rev_id'
				[ 1, 123, 341 ],
				[ 2, 123, 341 ],
			] );

		$rcId = 124;
		$revId = 342;
		ChangeTags::updateTags( [ 'tag1' ], [], $rcId, $revId );
		ChangeTags::updateTags( [ 'tag3' ], [], $rcId, $revId );

		$this->newSelectQueryBuilder()
			->select( [ 'ctd_name', 'ctd_id', 'ctd_count' ] )
			->from( 'change_tag_def' )
			->assertResultSet( [
				// values of fields 'ctd_name', 'ctd_id', 'ctd_count'
				[ 'tag1', 1, 2 ],
				[ 'tag2', 2, 1 ],
				[ 'tag3', 3, 1 ],
			] );
		$this->newSelectQueryBuilder()
			->select( [ 'ct_tag_id', 'ct_rc_id', 'ct_rev_id' ] )
			->from( 'change_tag' )
			->assertResultSet( [
				// values of fields 'ct_tag_id', 'ct_rc_id', 'ct_rev_id'
				[ 1, 123, 341 ],
				[ 1, 124, 342 ],
				[ 2, 123, 341 ],
				[ 3, 124, 342 ],
			] );
	}

	public function testUpdateTagsSkipDuplicates() {
		$this->emptyChangeTagsTables();

		$rcId = 123;
		ChangeTags::updateTags( [ 'tag1', 'tag2' ], [], $rcId );
		ChangeTags::updateTags( [ 'tag2', 'tag3' ], [], $rcId );

		$this->newSelectQueryBuilder()
			->select( [ 'ctd_name', 'ctd_id', 'ctd_count' ] )
			->from( 'change_tag_def' )
			->assertResultSet( [
				// values of fields 'ctd_name', 'ctd_id', 'ctd_count'
				[ 'tag1', 1, 1 ],
				[ 'tag2', 2, 1 ],
				[ 'tag3', 3, 1 ],
			] );
		$this->newSelectQueryBuilder()
			->select( [ 'ct_tag_id', 'ct_rc_id' ] )
			->from( 'change_tag' )
			->assertResultSet( [
				// values of fields 'ct_tag_id', 'ct_rc_id',
				[ 1, 123 ],
				[ 2, 123 ],
				[ 3, 123 ],
			] );
	}

	public function testUpdateTagsDoNothingOnRepeatedCall() {
		$this->emptyChangeTagsTables();

		$rcId = 123;
		ChangeTags::updateTags( [ 'tag1', 'tag2' ], [], $rcId );
		$res = ChangeTags::updateTags( [ 'tag2', 'tag1' ], [], $rcId );
		$this->assertEquals( [ [], [], [ 'tag1', 'tag2' ] ], $res );

		$this->newSelectQueryBuilder()
			->select( [ 'ctd_name', 'ctd_id', 'ctd_count' ] )
			->from( 'change_tag_def' )
			->assertResultSet( [
				// values of fields 'ctd_name', 'ctd_id', 'ctd_count'
				[ 'tag1', 1, 1 ],
				[ 'tag2', 2, 1 ],
			] );
		$this->newSelectQueryBuilder()
			->select( [ 'ct_tag_id', 'ct_rc_id' ] )
			->from( 'change_tag' )
			->assertResultSet( [
				// values of fields 'ct_tag_id', 'ct_rc_id',
				[ 1, 123 ],
				[ 2, 123 ],
			] );
	}

	public function testDeleteTags() {
		$this->emptyChangeTagsTables();
		$this->getServiceContainer()->resetServiceForTesting( 'NameTableStoreFactory' );

		$rcId = 123;
		ChangeTags::updateTags( [ 'tag1', 'tag2' ], [], $rcId );
		ChangeTags::updateTags( [], [ 'tag2' ], $rcId );

		$this->newSelectQueryBuilder()
			->select( [ 'ctd_name', 'ctd_id', 'ctd_count' ] )
			->from( 'change_tag_def' )
			->assertResultSet( [
				// values of fields 'ctd_name', 'ctd_id', 'ctd_count'
				[ 'tag1', 1, 1 ],
			] );
		$this->newSelectQueryBuilder()
			->select( [ 'ct_tag_id', 'ct_rc_id' ] )
			->from( 'change_tag' )
			->assertResultSet( [
				// values of fields 'ct_tag_id', 'ct_rc_id',
				[ 1, 123 ],
			] );
	}

	public static function provideTags() {
		$tags = [ 'tag 1', 'tag 2', 'tag 3' ];
		$rcId = 123;
		$revId = 456;
		$logId = 789;

		yield [ $tags, $rcId, null, null ];
		yield [ $tags, null, $revId, null ];
		yield [ $tags, null, null, $logId ];
		yield [ $tags, $rcId, $revId, null ];
		yield [ $tags, $rcId, null, $logId ];
		yield [ $tags, $rcId, $revId, $logId ];
	}

	/**
	 * @dataProvider provideTags
	 */
	public function testGetTags( array $tags, $rcId, $revId, $logId ) {
		ChangeTags::addTags( $tags, $rcId, $revId, $logId );

		$actualTags = ChangeTags::getTags( $this->getDb(), $rcId, $revId, $logId );

		$this->assertSame( $tags, $actualTags );
	}

	public function testGetTags_multiple_arguments() {
		$rcId = 123;
		$revId = 456;
		$logId = 789;

		ChangeTags::addTags( [ 'tag 1' ], $rcId );
		ChangeTags::addTags( [ 'tag 2' ], $rcId, $revId );
		ChangeTags::addTags( [ 'tag 3' ], $rcId, $revId, $logId );

		$tags3 = [ 'tag 3' ];
		$tags2 = array_merge( $tags3, [ 'tag 2' ] );
		$tags1 = array_merge( $tags2, [ 'tag 1' ] );
		$this->assertArrayEquals( $tags3, ChangeTags::getTags( $this->getDb(), $rcId, $revId, $logId ) );
		$this->assertArrayEquals( $tags2, ChangeTags::getTags( $this->getDb(), $rcId, $revId ) );
		$this->assertArrayEquals( $tags1, ChangeTags::getTags( $this->getDb(), $rcId ) );
	}

	public function testGetTagsWithData() {
		$rcId1 = 123;
		$rcId2 = 456;
		$rcId3 = 789;
		ChangeTags::addTags( [ 'tag 1' ], $rcId1, null, null, 'data1' );
		ChangeTags::addTags( [ 'tag 3_1' ], $rcId3, null, null );
		ChangeTags::addTags( [ 'tag 3_2' ], $rcId3, null, null, 'data3_2' );

		$data = ChangeTags::getTagsWithData( $this->getDb(), $rcId1 );
		$this->assertSame( [ 'tag 1' => 'data1' ], $data );

		$data = ChangeTags::getTagsWithData( $this->getDb(), $rcId2 );
		$this->assertSame( [], $data );

		$data = ChangeTags::getTagsWithData( $this->getDb(), $rcId3 );
		$this->assertArrayEquals( [ 'tag 3_1' => null, 'tag 3_2' => 'data3_2' ], $data, false, true );
	}

	public function testTagUsageStatistics() {
		$this->emptyChangeTagsTables();
		$this->getServiceContainer()->resetServiceForTesting( 'NameTableStoreFactory' );

		$rcId = 123;
		ChangeTags::updateTags( [ 'tag1', 'tag2' ], [], $rcId );

		$rcId = 124;
		ChangeTags::updateTags( [ 'tag1' ], [], $rcId );

		$this->assertEquals( [ 'tag1' => 2, 'tag2' => 1 ], ChangeTags::tagUsageStatistics() );
	}

	public function testListExplicitlyDefinedTags() {
		$this->emptyChangeTagsTables();

		$rcId = 123;
		ChangeTags::updateTags( [ 'tag1', 'tag2' ], [], $rcId );
		ChangeTags::defineTag( 'tag2' );

		$this->assertEquals( [ 'tag2' ], ChangeTags::listExplicitlyDefinedTags() );

		$this->newSelectQueryBuilder()
			->select( [ 'ctd_name', 'ctd_user_defined' ] )
			->from( 'change_tag_def' )
			->assertResultSet( [
				// values of fields 'ctd_name', 'ctd_user_defined'
				[ 'tag1', 0 ],
				[ 'tag2', 1 ],
			] );
	}

	public static function provideFormatSummaryRow() {
		yield 'nothing' => [ '', [ '', [] ] ];
		yield 'valid tag' => [
			'tag1',
			[
				'<span class="mw-tag-markers">(tag-list-wrapper: 1, '
				. '<span class="mw-tag-marker mw-tag-marker-tag1">(tag-tag1)</span>'
				. ')</span>',
				[ 'mw-tag-tag1' ]
			]
		];
		yield '0 tag' => [
			'0',
			[
				'<span class="mw-tag-markers">(tag-list-wrapper: 1, '
				. '<span class="mw-tag-marker mw-tag-marker-0">(tag-0)</span>'
				. ')</span>',
				[ 'mw-tag-0' ]
			]
		];
		yield 'hidden tag' => [
			'hidden-tag',
			[
				'',
				[ 'mw-tag-hidden-tag' ]
			]
		];
		yield 'mutliple tags' => [
			'tag1,0,,hidden-tag',
			[
				'<span class="mw-tag-markers">(tag-list-wrapper: 2, '
				. '<span class="mw-tag-marker mw-tag-marker-tag1">(tag-tag1)</span>'
				. ' <span class="mw-tag-marker mw-tag-marker-0">(tag-0)</span>'
				. ')</span>',
				[ 'mw-tag-tag1', 'mw-tag-0', 'mw-tag-hidden-tag' ]
			]
		];
	}

	/**
	 * @dataProvider provideFormatSummaryRow
	 */
	public function testFormatSummaryRow( $tags, $expected ) {
		$qqx = new MockMessageLocalizer();
		$localizer = $this->createMock( MessageLocalizer::class );
		$localizer->method( 'msg' )
			->willReturnCallback( static function ( $key, ...$params ) use ( $qqx ) {
				if ( $key === 'tag-hidden-tag' ) {
					return new RawMessage( '-' );
				}
				return $qqx->msg( $key, ...$params );
			} );

		$out = ChangeTags::formatSummaryRow( $tags, 'dummy', $localizer );
		$this->assertSame( $expected, $out );
	}

}
PK       ! >ư    !  changetags/ChangeTagsListTest.phpnu Iw        <?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
 * @since 1.42
 */

namespace MediaWiki\Tests\ChangeTags;

use ChangeTagsList;
use ChangeTagsLogList;
use ChangeTagsRevisionList;
use InvalidArgumentException;
use MediaWiki\Context\IContextSource;
use MediaWiki\Page\PageIdentity;
use MediaWikiIntegrationTestCase;

/**
 * @covers ChangeTagsList
 * @group ChangeTag
 * @group Database
 */
class ChangeTagsListTest extends MediaWikiIntegrationTestCase {

	public function testFactoryWithRevision() {
		$context = $this->createMock( IContextSource::class );
		$page = $this->createMock( PageIdentity::class );
		$ids = [ 1, 2, 3 ];

		// Instantiate a ChangeTagsList with revision type.
		$revisionList = ChangeTagsList::factory( 'revision', $context, $page, $ids );

		// Assert that the returned list is an instance of ChangeTagsRevisionList.
		$this->assertInstanceOf( ChangeTagsRevisionList::class, $revisionList );
	}

	public function testFactoryWithLogentry() {
		$context = $this->createMock( IContextSource::class );
		$page = $this->createMock( PageIdentity::class );
		$ids = [ 1, 2, 3 ];

		// Instantiate a ChangeTagsList with logentry type.
		$logList = ChangeTagsList::factory( 'logentry', $context, $page, $ids );

		// Assert that the returned list is an instance of ChangeTagsLogList.
		$this->assertInstanceOf( ChangeTagsLogList::class, $logList );
	}

	public function testFactoryWithUnknownType() {
		$this->expectException( InvalidArgumentException::class );
		$context = $this->createMock( IContextSource::class );
		$page = $this->createMock( PageIdentity::class );
		$ids = [];

		// Instantiate a ChangeTagsList with an unknown type.
		ChangeTagsList::factory( 'unknownType', $context, $page, $ids );
	}

	public function testUpdateChangeTagsOnAll() {
		$this->expectException( InvalidArgumentException::class );
		$context = $this->createMock( IContextSource::class );
		$page = $this->createMock( PageIdentity::class );
		$ids = [];

		// Instantiate a ChangeTagsList.
		$changeTagsList = ChangeTagsList::factory( 'revision', $context, $page, $ids );

		// Mock an Authority (e.g., a User with appropriate permissions).
		$user = $this->getTestUser()->getUser();
		$authority = $user;

		// Mock the tags to add and remove, as well as the reason for the change.
		$tagsToAdd = [ 'mockTagToAddOne', 'mockTagToAddTwo' ];
		$tagsToRemove = [ 'mockTagToRemoveOne', 'mockTagToRemoveTwo' ];
		$params = null;
		$reason = "Test reason for changing tags";

		// Attempt to update the change tags on all items in the list.
		$status = $changeTagsList->updateChangeTagsOnAll( $tagsToAdd, $tagsToRemove, $params, $reason, $authority );

		// Assert that the operation was successful.
		$this->assertStatusOK( $status, 'Updating change tags failed' );
	}

}
PK       ! Ap  p  #  ThrottleFilterPresentationModel.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Extension\Notifications\Formatters\EchoEventPresentationModel;
use MediaWiki\Message\Message;

class ThrottleFilterPresentationModel extends EchoEventPresentationModel {

	/**
	 * @inheritDoc
	 */
	public function getIconType() {
		return 'placeholder';
	}

	/**
	 * @inheritDoc
	 */
	public function getHeaderMessage() {
		$text = $this->event->getTitle()->getText();
		[ , $filter ] = explode( '/', $text, 2 );
		$disabledActions = $this->event->getExtraParam( 'throttled-actions' );
		if ( $disabledActions === null ) {
			// BC for when we didn't include the actions here.
			return $this->msg( 'notification-header-throttle-filter' )
				->params( $this->getViewingUserForGender() )
				->numParams( $filter );
		}
		if ( $disabledActions ) {
			$specsFormatter = AbuseFilterServices::getSpecsFormatter();
			$specsFormatter->setMessageLocalizer( $this );
			$disabledActionsLocalized = [];
			foreach ( $disabledActions as $action ) {
				$disabledActionsLocalized[] = $specsFormatter->getActionMessage( $action )->text();
			}
			return $this->msg( 'notification-header-throttle-filter-actions' )
				->params( $this->getViewingUserForGender() )
				->numParams( $filter )
				->params( Message::listParam( $disabledActionsLocalized ) )
				->params( count( $disabledActionsLocalized ) );
		}
		return $this->msg( 'notification-header-throttle-filter-no-actions' )
			->params( $this->getViewingUserForGender() )
			->numParams( $filter );
	}

	/**
	 * @inheritDoc
	 */
	public function getSubjectMessage() {
		return $this->msg( 'notification-subject-throttle-filter' )
			->params( $this->getViewingUserForGender() );
	}

	/**
	 * @inheritDoc
	 */
	public function getPrimaryLink() {
		return [
			'url' => $this->event->getTitle()->getFullURL(),
			'label' => $this->msg( 'notification-link-text-show-filter' )->text()
		];
	}
}
PK       ! ]>$\      SpecsFormatter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Extension\AbuseFilter\Filter\AbstractFilter;
use MediaWiki\Language\Language;
use MediaWiki\Language\RawMessage;
use MediaWiki\Message\Message;
use MessageLocalizer;

/**
 * @todo Improve this once DI around Message objects is improved in MW core.
 */
class SpecsFormatter {
	public const SERVICE_NAME = 'AbuseFilterSpecsFormatter';

	/** @var MessageLocalizer */
	private $messageLocalizer;

	/**
	 * @param MessageLocalizer $messageLocalizer
	 */
	public function __construct( MessageLocalizer $messageLocalizer ) {
		$this->messageLocalizer = $messageLocalizer;
	}

	/**
	 * @param MessageLocalizer $messageLocalizer
	 */
	public function setMessageLocalizer( MessageLocalizer $messageLocalizer ): void {
		$this->messageLocalizer = $messageLocalizer;
	}

	/**
	 * @param string $action
	 * @return string HTML
	 * @todo Replace usage with getActionMessage
	 */
	public function getActionDisplay( string $action ): string {
		// Give grep a chance to find the usages:
		// abusefilter-action-tag, abusefilter-action-throttle, abusefilter-action-warn,
		// abusefilter-action-blockautopromote, abusefilter-action-block, abusefilter-action-degroup,
		// abusefilter-action-rangeblock, abusefilter-action-disallow
		$msg = $this->messageLocalizer->msg( "abusefilter-action-$action" );
		return $msg->isDisabled() ? htmlspecialchars( $action ) : $msg->escaped();
	}

	/**
	 * @param string $action
	 * @return Message
	 */
	public function getActionMessage( string $action ): Message {
		// Give grep a chance to find the usages:
		// abusefilter-action-tag, abusefilter-action-throttle, abusefilter-action-warn,
		// abusefilter-action-blockautopromote, abusefilter-action-block, abusefilter-action-degroup,
		// abusefilter-action-rangeblock, abusefilter-action-disallow
		$msg = $this->messageLocalizer->msg( "abusefilter-action-$action" );
		// XXX Why do we expect the message to be disabled?
		return $msg->isDisabled() ? new RawMessage( $action ) : $msg;
	}

	/**
	 * @param string $action
	 * @param string[] $parameters
	 * @param Language $lang
	 * @return string
	 */
	public function formatAction( string $action, array $parameters, Language $lang ): string {
		if ( count( $parameters ) === 0 || ( $action === 'block' && count( $parameters ) !== 3 ) ) {
			$displayAction = $this->getActionDisplay( $action );
		} elseif ( $action === 'block' ) {
			// Needs to be treated separately since the message is more complex
			$messages = [
				$this->messageLocalizer->msg( 'abusefilter-block-anon' )->escaped() .
				$this->messageLocalizer->msg( 'colon-separator' )->escaped() .
				$lang->translateBlockExpiry( $parameters[1] ),
				$this->messageLocalizer->msg( 'abusefilter-block-user' )->escaped() .
				$this->messageLocalizer->msg( 'colon-separator' )->escaped() .
				$lang->translateBlockExpiry( $parameters[2] )
			];
			if ( $parameters[0] === 'blocktalk' ) {
				$messages[] = $this->messageLocalizer->msg( 'abusefilter-block-talk' )->escaped();
			}
			$displayAction = $lang->commaList( $messages );
		} elseif ( $action === 'throttle' ) {
			array_shift( $parameters );
			[ $actions, $time ] = explode( ',', array_shift( $parameters ) );

			// Join comma-separated groups in a commaList with a final "and", and convert to messages.
			// Messages used here: abusefilter-throttle-ip, abusefilter-throttle-user,
			// abusefilter-throttle-site, abusefilter-throttle-creationdate, abusefilter-throttle-editcount
			// abusefilter-throttle-range, abusefilter-throttle-page, abusefilter-throttle-none
			foreach ( $parameters as &$val ) {
				if ( strpos( $val, ',' ) !== false ) {
					$subGroups = explode( ',', $val );
					foreach ( $subGroups as &$group ) {
						$msg = $this->messageLocalizer->msg( "abusefilter-throttle-$group" );
						// We previously accepted literally everything in this field, so old entries
						// may have weird stuff.
						$group = $msg->exists() ? $msg->text() : $group;
					}
					unset( $group );
					$val = $lang->listToText( $subGroups );
				} else {
					$msg = $this->messageLocalizer->msg( "abusefilter-throttle-$val" );
					$val = $msg->exists() ? $msg->text() : $val;
				}
			}
			unset( $val );
			$groups = $lang->semicolonList( $parameters );

			$displayAction = $this->getActionDisplay( $action ) .
				$this->messageLocalizer->msg( 'colon-separator' )->escaped() .
				$this->messageLocalizer->msg( 'abusefilter-throttle-details' )
					->params( $actions, $time, $groups )->escaped();
		} else {
			$displayAction = $this->getActionDisplay( $action ) .
				$this->messageLocalizer->msg( 'colon-separator' )->escaped() .
				$lang->semicolonList( array_map( 'htmlspecialchars', $parameters ) );
		}

		return $displayAction;
	}

	/**
	 * @param string $value
	 * @param Language $lang
	 * @return string
	 */
	public function formatFlags( string $value, Language $lang ): string {
		$flags = array_filter( explode( ',', $value ) );
		$flagsDisplay = [];
		foreach ( $flags as $flag ) {
			$flagsDisplay[] = $this->messageLocalizer->msg( "abusefilter-history-$flag" )->escaped();
		}

		return $lang->commaList( $flagsDisplay );
	}

	/**
	 * @param AbstractFilter $filter
	 * @param Language $lang
	 * @return string
	 */
	public function formatFilterFlags( AbstractFilter $filter, Language $lang ): string {
		$flags = array_filter( [
			'enabled' => $filter->isEnabled(),
			'deleted' => $filter->isDeleted(),
			'hidden' => $filter->isHidden(),
			'protected' => $filter->isProtected(),
			'global' => $filter->isGlobal()
		] );
		$flagsDisplay = [];
		foreach ( $flags as $flag => $_ ) {
			// The following messages are generated here:
			// * abusefilter-history-enabled
			// * abusefilter-history-deleted
			// * abusefilter-history-hidden
			// * abusefilter-history-protected
			// * abusefilter-history-global
			$flagsDisplay[] = $this->messageLocalizer->msg( "abusefilter-history-$flag" )->escaped();
		}

		return $lang->commaList( $flagsDisplay );
	}

	/**
	 * Gives either the user-specified name for a group,
	 * or spits the input back out when the message for the group is disabled
	 * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups)
	 * @return string A name for that filter group, or the input.
	 */
	public function nameGroup( string $group ): string {
		// Give grep a chance to find the usages: abusefilter-group-default
		$msg = $this->messageLocalizer->msg( "abusefilter-group-$group" );
		return $msg->isDisabled() ? $group : $msg->escaped();
	}
}
PK       ! _k!  !  )  VariableGenerator/RCVariableGenerator.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\VariableGenerator;

use LogicException;
use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\Page\WikiPageFactory;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use MWFileProps;
use RecentChange;
use RepoGroup;
use Wikimedia\Mime\MimeAnalyzer;

/**
 * This class contains the logic used to create variable holders used to
 * examine a RecentChanges row.
 */
class RCVariableGenerator extends VariableGenerator {
	/**
	 * @var RecentChange
	 */
	private $rc;

	/** @var User */
	private $contextUser;

	/** @var MimeAnalyzer */
	private $mimeAnalyzer;
	/** @var RepoGroup */
	private $repoGroup;
	/** @var WikiPageFactory */
	private $wikiPageFactory;

	/**
	 * @param AbuseFilterHookRunner $hookRunner
	 * @param UserFactory $userFactory
	 * @param MimeAnalyzer $mimeAnalyzer
	 * @param RepoGroup $repoGroup
	 * @param WikiPageFactory $wikiPageFactory
	 * @param RecentChange $rc
	 * @param User $contextUser
	 * @param VariableHolder|null $vars
	 */
	public function __construct(
		AbuseFilterHookRunner $hookRunner,
		UserFactory $userFactory,
		MimeAnalyzer $mimeAnalyzer,
		RepoGroup $repoGroup,
		WikiPageFactory $wikiPageFactory,
		RecentChange $rc,
		User $contextUser,
		?VariableHolder $vars = null
	) {
		parent::__construct( $hookRunner, $userFactory, $vars );

		$this->mimeAnalyzer = $mimeAnalyzer;
		$this->repoGroup = $repoGroup;
		$this->wikiPageFactory = $wikiPageFactory;
		$this->rc = $rc;
		$this->contextUser = $contextUser;
	}

	/**
	 * @return VariableHolder|null
	 */
	public function getVars(): ?VariableHolder {
		if ( $this->rc->getAttribute( 'rc_source' ) === RecentChange::SRC_LOG ) {
			switch ( $this->rc->getAttribute( 'rc_log_type' ) ) {
				case 'move':
					$this->addMoveVars();
					break;
				case 'newusers':
					$this->addCreateAccountVars();
					break;
				case 'delete':
					$this->addDeleteVars();
					break;
				case 'upload':
					$this->addUploadVars();
					break;
				default:
					return null;
			}
		} elseif ( $this->rc->getAttribute( 'rc_this_oldid' ) ) {
			// It's an edit (or a page creation).
			$this->addEditVarsForRow();
		} elseif (
			!$this->hookRunner->onAbuseFilterGenerateVarsForRecentChange(
				$this, $this->rc, $this->vars, $this->contextUser )
		) {
			// @codeCoverageIgnoreStart
			throw new LogicException( 'Cannot understand the given recentchanges row!' );
			// @codeCoverageIgnoreEnd
		}

		$this->addGenericVars( $this->rc );

		return $this->vars;
	}

	/**
	 * @return $this
	 */
	private function addMoveVars(): self {
		$userIdentity = $this->rc->getPerformerIdentity();

		$oldTitle = Title::castFromPageReference( $this->rc->getPage() ) ?: Title::makeTitle( NS_SPECIAL, 'BadTitle' );
		$newTitle = Title::newFromText( $this->rc->getParam( '4::target' ) );

		$this->addUserVars( $userIdentity, $this->rc )
			->addTitleVars( $oldTitle, 'moved_from', $this->rc )
			->addTitleVars( $newTitle, 'moved_to', $this->rc );

		$this->vars->setVar( 'summary', $this->rc->getAttribute( 'rc_comment' ) );
		$this->vars->setVar( 'action', 'move' );

		$this->vars->setLazyLoadVar(
			'moved_from_last_edit_age',
			'previous-revision-age',
			// rc_last_oldid is zero (RecentChange::newLogEntry)
			[ 'revid' => $this->rc->getAttribute( 'rc_this_oldid' ) ]
		);
		// TODO: add moved_to_last_edit_age (is it possible?)
		// TODO: add old_wikitext etc. (T320347)

		return $this;
	}

	/**
	 * @return $this
	 */
	private function addCreateAccountVars(): self {
		$this->vars->setVar(
			'action',
			// XXX: as of 1.43, the following is never true
			$this->rc->getAttribute( 'rc_log_action' ) === 'autocreate'
				? 'autocreateaccount'
				: 'createaccount'
		);

		$name = Title::castFromPageReference( $this->rc->getPage() )->getText();
		// Add user data if the account was created by a registered user
		$userIdentity = $this->rc->getPerformerIdentity();
		if ( $userIdentity->isRegistered() && $name !== $userIdentity->getName() ) {
			$this->addUserVars( $userIdentity, $this->rc );
		} else {
			// Set the user_type so that creations of temporary accounts vs named accounts can be filtered for an
			// abuse filter that matches account creations.
			$this->vars->setLazyLoadVar(
				'user_type',
				'user-type',
				[ 'user-identity' => $userIdentity ]
			);
		}

		$this->vars->setVar( 'accountname', $name );

		return $this;
	}

	/**
	 * @return $this
	 */
	private function addDeleteVars(): self {
		$title = Title::castFromPageReference( $this->rc->getPage() ) ?: Title::makeTitle( NS_SPECIAL, 'BadTitle' );
		$userIdentity = $this->rc->getPerformerIdentity();

		$this->addUserVars( $userIdentity, $this->rc )
			->addTitleVars( $title, 'page', $this->rc );

		$this->vars->setVar( 'action', 'delete' );
		$this->vars->setVar( 'summary', $this->rc->getAttribute( 'rc_comment' ) );
		// TODO: add page_last_edit_age
		// TODO: add old_wikitext etc. (T173663)

		return $this;
	}

	/**
	 * @return $this
	 */
	private function addUploadVars(): self {
		$title = Title::castFromPageReference( $this->rc->getPage() ) ?: Title::makeTitle( NS_SPECIAL, 'BadTitle' );
		$userIdentity = $this->rc->getPerformerIdentity();

		$this->addUserVars( $userIdentity, $this->rc )
			->addTitleVars( $title, 'page', $this->rc );

		$this->vars->setVar( 'action', 'upload' );
		$this->vars->setVar( 'summary', $this->rc->getAttribute( 'rc_comment' ) );

		$this->vars->setLazyLoadVar(
			'page_last_edit_age',
			'previous-revision-age',
			// rc_last_oldid is zero (RecentChange::newLogEntry)
			[ 'revid' => $this->rc->getAttribute( 'rc_this_oldid' ) ]
		);

		$time = $this->rc->getParam( 'img_timestamp' );
		$file = $this->repoGroup->findFile(
			$title, [ 'time' => $time, 'private' => $this->contextUser ]
		);
		if ( !$file ) {
			// @fixme Ensure this cannot happen!
			// @codeCoverageIgnoreStart
			$logger = LoggerFactory::getInstance( 'AbuseFilter' );
			$logger->warning( "Cannot find file from RC row with title $title" );
			return $this;
			// @codeCoverageIgnoreEnd
		}

		// This is the same as FilteredActionsHandler::filterUpload, but from a different source
		$this->vars->setVar( 'file_sha1', \Wikimedia\base_convert( $file->getSha1(), 36, 16, 40 ) );
		$this->vars->setVar( 'file_size', $file->getSize() );

		$this->vars->setVar( 'file_mime', $file->getMimeType() );
		$this->vars->setVar(
			'file_mediatype',
			$this->mimeAnalyzer->getMediaType( null, $file->getMimeType() )
		);
		$this->vars->setVar( 'file_width', $file->getWidth() );
		$this->vars->setVar( 'file_height', $file->getHeight() );

		$mwProps = new MWFileProps( $this->mimeAnalyzer );
		$bits = $mwProps->getPropsFromPath( $file->getLocalRefPath(), true )['bits'];
		$this->vars->setVar( 'file_bits_per_channel', $bits );

		return $this;
	}

	/**
	 * @return $this
	 */
	private function addEditVarsForRow(): self {
		$title = Title::castFromPageReference( $this->rc->getPage() ) ?: Title::makeTitle( NS_SPECIAL, 'BadTitle' );
		$userIdentity = $this->rc->getPerformerIdentity();

		$this->addUserVars( $userIdentity, $this->rc )
			->addTitleVars( $title, 'page', $this->rc );

		$this->vars->setVar( 'action', 'edit' );
		$this->vars->setVar( 'summary', $this->rc->getAttribute( 'rc_comment' ) );

		$this->vars->setLazyLoadVar( 'new_wikitext', 'revision-text-by-id',
			[ 'revid' => $this->rc->getAttribute( 'rc_this_oldid' ), 'contextUser' => $this->contextUser ] );
		$this->vars->setLazyLoadVar( 'new_content_model', 'content-model-by-id',
			[ 'revid' => $this->rc->getAttribute( 'rc_this_oldid' ) ] );

		$parentId = $this->rc->getAttribute( 'rc_last_oldid' );
		if ( $parentId ) {
			$this->vars->setLazyLoadVar( 'old_wikitext', 'revision-text-by-id',
				[ 'revid' => $parentId, 'contextUser' => $this->contextUser ] );
			$this->vars->setLazyLoadVar( 'old_content_model', 'content-model-by-id',
				[ 'revid' => $parentId ] );
			$this->vars->setLazyLoadVar( 'page_last_edit_age', 'revision-age-by-id',
				[ 'revid' => $parentId, 'asof' => $this->rc->getAttribute( 'rc_timestamp' ) ] );
		} else {
			$this->vars->setVar( 'old_wikitext', '' );
			$this->vars->setVar( 'old_content_model', '' );
			$this->vars->setVar( 'page_last_edit_age', null );
		}

		$this->addEditVars(
			$this->wikiPageFactory->newFromTitle( $title ),
			$this->contextUser,
			false
		);

		return $this;
	}
}
PK       ! m:
  
  .  VariableGenerator/VariableGeneratorFactory.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\VariableGenerator;

use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Extension\AbuseFilter\TextExtractor;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Page\WikiPageFactory;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use RecentChange;
use RepoGroup;
use Wikimedia\Mime\MimeAnalyzer;

class VariableGeneratorFactory {
	public const SERVICE_NAME = 'AbuseFilterVariableGeneratorFactory';

	/** @var AbuseFilterHookRunner */
	private $hookRunner;
	/** @var TextExtractor */
	private $textExtractor;
	/** @var MimeAnalyzer */
	private $mimeAnalyzer;
	/** @var RepoGroup */
	private $repoGroup;
	/** @var WikiPageFactory */
	private $wikiPageFactory;
	/** @var UserFactory */
	private $userFactory;

	/**
	 * @param AbuseFilterHookRunner $hookRunner
	 * @param TextExtractor $textExtractor
	 * @param MimeAnalyzer $mimeAnalyzer
	 * @param RepoGroup $repoGroup
	 * @param WikiPageFactory $wikiPageFactory
	 * @param UserFactory $userFactory
	 */
	public function __construct(
		AbuseFilterHookRunner $hookRunner,
		TextExtractor $textExtractor,
		MimeAnalyzer $mimeAnalyzer,
		RepoGroup $repoGroup,
		WikiPageFactory $wikiPageFactory,
		UserFactory $userFactory
	) {
		$this->hookRunner = $hookRunner;
		$this->textExtractor = $textExtractor;
		$this->mimeAnalyzer = $mimeAnalyzer;
		$this->repoGroup = $repoGroup;
		$this->wikiPageFactory = $wikiPageFactory;
		$this->userFactory = $userFactory;
	}

	/**
	 * @param VariableHolder|null $holder
	 * @return VariableGenerator
	 */
	public function newGenerator( ?VariableHolder $holder = null ): VariableGenerator {
		return new VariableGenerator( $this->hookRunner, $this->userFactory, $holder );
	}

	/**
	 * @param User $user
	 * @param Title $title
	 * @param VariableHolder|null $holder
	 * @return RunVariableGenerator
	 */
	public function newRunGenerator( User $user, Title $title, ?VariableHolder $holder = null ): RunVariableGenerator {
		return new RunVariableGenerator(
			$this->hookRunner,
			$this->userFactory,
			$this->textExtractor,
			$this->mimeAnalyzer,
			$this->wikiPageFactory,
			$user,
			$title,
			$holder
		);
	}

	/**
	 * @param RecentChange $rc
	 * @param User $contextUser
	 * @param VariableHolder|null $holder
	 * @return RCVariableGenerator
	 */
	public function newRCGenerator(
		RecentChange $rc,
		User $contextUser,
		?VariableHolder $holder = null
	): RCVariableGenerator {
		return new RCVariableGenerator(
			$this->hookRunner,
			$this->userFactory,
			$this->mimeAnalyzer,
			$this->repoGroup,
			$this->wikiPageFactory,
			$rc,
			$contextUser,
			$holder
		);
	}
}
PK       ! K~!  ~!  '  VariableGenerator/VariableGenerator.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\VariableGenerator;

use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Storage\PreparedUpdate;
use MediaWiki\Title\Title;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentity;
use MediaWiki\Utils\MWTimestamp;
use RecentChange;
use WikiPage;

/**
 * Class used to generate variables, for instance related to a given user or title.
 */
class VariableGenerator {
	/**
	 * @var VariableHolder
	 */
	protected $vars;

	/** @var AbuseFilterHookRunner */
	protected $hookRunner;
	/** @var UserFactory */
	protected $userFactory;

	/**
	 * @param AbuseFilterHookRunner $hookRunner
	 * @param UserFactory $userFactory
	 * @param VariableHolder|null $vars
	 */
	public function __construct(
		AbuseFilterHookRunner $hookRunner,
		UserFactory $userFactory,
		?VariableHolder $vars = null
	) {
		$this->hookRunner = $hookRunner;
		$this->userFactory = $userFactory;
		$this->vars = $vars ?? new VariableHolder();
	}

	/**
	 * @return VariableHolder
	 */
	public function getVariableHolder(): VariableHolder {
		return $this->vars;
	}

	/**
	 * Computes all variables unrelated to title and user. In general, these variables may be known
	 * even without an ongoing action.
	 *
	 * @param RecentChange|null $rc If the variables should be generated for an RC entry,
	 *   this is the entry. Null if it's for the current action being filtered.
	 * @return $this For chaining
	 */
	public function addGenericVars( ?RecentChange $rc = null ): self {
		$timestamp = $rc
			? MWTimestamp::convert( TS_UNIX, $rc->getAttribute( 'rc_timestamp' ) )
			: wfTimestamp( TS_UNIX );
		$this->vars->setVar( 'timestamp', $timestamp );
		// These are lazy-loaded just to reduce the amount of preset variables, but they
		// shouldn't be expensive.
		$this->vars->setLazyLoadVar( 'wiki_name', 'get-wiki-name', [] );
		$this->vars->setLazyLoadVar( 'wiki_language', 'get-wiki-language', [] );

		$this->hookRunner->onAbuseFilter_generateGenericVars( $this->vars, $rc );
		return $this;
	}

	/**
	 * @param UserIdentity $userIdentity
	 * @param RecentChange|null $rc If the variables should be generated for an RC entry,
	 *   this is the entry. Null if it's for the current action being filtered.
	 * @return $this For chaining
	 */
	public function addUserVars( UserIdentity $userIdentity, ?RecentChange $rc = null ): self {
		$asOf = $rc ? $rc->getAttribute( 'rc_timestamp' ) : wfTimestampNow();
		$user = $this->userFactory->newFromUserIdentity( $userIdentity );

		$this->vars->setLazyLoadVar(
			'user_editcount',
			'user-editcount',
			[ 'user-identity' => $userIdentity ]
		);

		$this->vars->setVar( 'user_name', $user->getName() );

		$this->vars->setLazyLoadVar(
			'user_unnamed_ip',
			'user-unnamed-ip',
			[
				'user' => $user,
				'rc' => $rc,
			]
		);

		$this->vars->setLazyLoadVar(
			'user_type',
			'user-type',
			[ 'user-identity' => $userIdentity ]
		);

		$this->vars->setLazyLoadVar(
			'user_emailconfirm',
			'user-emailconfirm',
			[ 'user' => $user ]
		);

		$this->vars->setLazyLoadVar(
			'user_age',
			'user-age',
			[ 'user' => $user, 'asof' => $asOf ]
		);

		$this->vars->setLazyLoadVar(
			'user_groups',
			'user-groups',
			[ 'user-identity' => $userIdentity ]
		);

		$this->vars->setLazyLoadVar(
			'user_rights',
			'user-rights',
			[ 'user-identity' => $userIdentity ]
		);

		$this->vars->setLazyLoadVar(
			'user_blocked',
			'user-block',
			[ 'user' => $user ]
		);

		$this->hookRunner->onAbuseFilter_generateUserVars( $this->vars, $user, $rc );

		return $this;
	}

	/**
	 * @param Title $title
	 * @param string $prefix
	 * @param RecentChange|null $rc If the variables should be generated for an RC entry,
	 *   this is the entry. Null if it's for the current action being filtered.
	 * @return $this For chaining
	 */
	public function addTitleVars(
		Title $title,
		string $prefix,
		?RecentChange $rc = null
	): self {
		if ( $rc && $rc->getAttribute( 'rc_type' ) == RC_NEW ) {
			$this->vars->setVar( $prefix . '_id', 0 );
		} else {
			$this->vars->setVar( $prefix . '_id', $title->getArticleID() );
		}
		$this->vars->setVar( $prefix . '_namespace', $title->getNamespace() );
		$this->vars->setVar( $prefix . '_title', $title->getText() );
		$this->vars->setVar( $prefix . '_prefixedtitle', $title->getPrefixedText() );

		// We only support the default values in $wgRestrictionTypes. Custom restrictions wouldn't
		// have i18n messages. If a restriction is not enabled we'll just return the empty array.
		$types = [ 'edit', 'move', 'create', 'upload' ];
		foreach ( $types as $action ) {
			$this->vars->setLazyLoadVar(
				"{$prefix}_restrictions_$action",
				'get-page-restrictions',
				[ 'title' => $title, 'action' => $action ]
			);
		}

		$asOf = $rc ? $rc->getAttribute( 'rc_timestamp' ) : wfTimestampNow();

		// TODO: add 'asof' to this as well
		$this->vars->setLazyLoadVar(
			"{$prefix}_recent_contributors",
			'load-recent-authors',
			[ 'title' => $title ]
		);

		$this->vars->setLazyLoadVar(
			"{$prefix}_age",
			'page-age',
			[ 'title' => $title, 'asof' => $asOf ]
		);

		$this->vars->setLazyLoadVar(
			"{$prefix}_first_contributor",
			'load-first-author',
			[ 'title' => $title ]
		);

		$this->hookRunner->onAbuseFilter_generateTitleVars( $this->vars, $title, $prefix, $rc );

		return $this;
	}

	public function addDerivedEditVars(): self {
		$this->vars->setLazyLoadVar( 'edit_diff', 'diff',
			[ 'oldtext-var' => 'old_wikitext', 'newtext-var' => 'new_wikitext' ] );
		$this->vars->setLazyLoadVar( 'edit_diff_pst', 'diff',
			[ 'oldtext-var' => 'old_wikitext', 'newtext-var' => 'new_pst' ] );
		$this->vars->setLazyLoadVar( 'new_size', 'length', [ 'length-var' => 'new_wikitext' ] );
		$this->vars->setLazyLoadVar( 'old_size', 'length', [ 'length-var' => 'old_wikitext' ] );
		$this->vars->setLazyLoadVar( 'edit_delta', 'subtract-int',
			[ 'val1-var' => 'new_size', 'val2-var' => 'old_size' ] );

		// Some more specific/useful details about the changes.
		$this->vars->setLazyLoadVar( 'added_lines', 'diff-split',
			[ 'diff-var' => 'edit_diff', 'line-prefix' => '+' ] );
		$this->vars->setLazyLoadVar( 'removed_lines', 'diff-split',
			[ 'diff-var' => 'edit_diff', 'line-prefix' => '-' ] );
		$this->vars->setLazyLoadVar( 'added_lines_pst', 'diff-split',
			[ 'diff-var' => 'edit_diff_pst', 'line-prefix' => '+' ] );

		// Links
		$this->vars->setLazyLoadVar( 'added_links', 'array-diff',
			[ 'base-var' => 'all_links', 'minus-var' => 'old_links' ] );
		$this->vars->setLazyLoadVar( 'removed_links', 'array-diff',
			[ 'base-var' => 'old_links', 'minus-var' => 'all_links' ] );

		// Text
		$this->vars->setLazyLoadVar( 'new_text', 'strip-html',
			[ 'html-var' => 'new_html' ] );

		return $this;
	}

	/**
	 * @param WikiPage $page
	 * @param UserIdentity $userIdentity The current user
	 * @param bool $forFilter Whether the variables should be computed for an ongoing action
	 *   being filtered
	 * @param PreparedUpdate|null $update
	 * @return $this For chaining
	 */
	public function addEditVars(
		WikiPage $page,
		UserIdentity $userIdentity,
		bool $forFilter = true,
		?PreparedUpdate $update = null
	): self {
		$this->addDerivedEditVars();

		if ( $forFilter && $update ) {
			$this->vars->setLazyLoadVar( 'all_links', 'links-from-update',
				[ 'update' => $update ] );
		} else {
			$this->vars->setLazyLoadVar( 'all_links', 'links-from-wikitext',
				[
					'text-var' => 'new_wikitext',
					'article' => $page,
					'forFilter' => $forFilter,
					'contextUserIdentity' => $userIdentity
				] );
		}

		if ( $forFilter ) {
			$this->vars->setLazyLoadVar( 'old_links', 'links-from-database',
				[ 'article' => $page ] );
		} else {
			$this->vars->setLazyLoadVar( 'old_links', 'links-from-wikitext-or-database',
				[
					'article' => $page,
					'text-var' => 'old_wikitext',
					'contextUserIdentity' => $userIdentity
				] );
		}

		// TODO: the following should use PreparedUpdate, too
		$this->vars->setLazyLoadVar( 'new_pst', 'parse-wikitext',
			[
				'wikitext-var' => 'new_wikitext',
				'article' => $page,
				'pst' => true,
				'contextUserIdentity' => $userIdentity
			] );

		if ( $forFilter && $update ) {
			$this->vars->setLazyLoadVar( 'new_html', 'html-from-update',
				[ 'update' => $update ] );
		} else {
			$this->vars->setLazyLoadVar( 'new_html', 'parse-wikitext',
				[
					'wikitext-var' => 'new_wikitext',
					'article' => $page,
					'contextUserIdentity' => $userIdentity
				] );
		}

		return $this;
	}
}
PK       ! n,  ,  *  VariableGenerator/RunVariableGenerator.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\VariableGenerator;

use LogicException;
use MediaWiki\Content\Content;
use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Extension\AbuseFilter\TextExtractor;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Page\WikiPageFactory;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use MWFileProps;
use UploadBase;
use Wikimedia\Assert\PreconditionException;
use Wikimedia\Mime\MimeAnalyzer;
use WikiPage;

/**
 * This class contains the logic used to create variable holders before filtering
 * an action.
 */
class RunVariableGenerator extends VariableGenerator {
	/**
	 * @var User
	 */
	private $user;

	/**
	 * @var Title
	 */
	private $title;

	/** @var TextExtractor */
	private $textExtractor;
	/** @var MimeAnalyzer */
	private $mimeAnalyzer;
	/** @var WikiPageFactory */
	private $wikiPageFactory;

	/**
	 * @param AbuseFilterHookRunner $hookRunner
	 * @param UserFactory $userFactory
	 * @param TextExtractor $textExtractor
	 * @param MimeAnalyzer $mimeAnalyzer
	 * @param WikiPageFactory $wikiPageFactory
	 * @param User $user
	 * @param Title $title
	 * @param VariableHolder|null $vars
	 */
	public function __construct(
		AbuseFilterHookRunner $hookRunner,
		UserFactory $userFactory,
		TextExtractor $textExtractor,
		MimeAnalyzer $mimeAnalyzer,
		WikiPageFactory $wikiPageFactory,
		User $user,
		Title $title,
		?VariableHolder $vars = null
	) {
		parent::__construct( $hookRunner, $userFactory, $vars );
		$this->textExtractor = $textExtractor;
		$this->mimeAnalyzer = $mimeAnalyzer;
		$this->wikiPageFactory = $wikiPageFactory;
		$this->user = $user;
		$this->title = $title;
	}

	/**
	 * Get variables for pre-filtering an edit during stash
	 *
	 * @param Content $content
	 * @param string $summary
	 * @param string $slot
	 * @param WikiPage $page
	 * @return VariableHolder|null
	 */
	public function getStashEditVars(
		Content $content,
		string $summary,
		$slot,
		WikiPage $page
	): ?VariableHolder {
		$filterText = $this->getEditTextForFiltering( $page, $content, $slot );
		if ( $filterText === null ) {
			return null;
		}
		[ $oldContent, $oldAfText, $text ] = $filterText;
		return $this->newVariableHolderForEdit(
			$page, $summary, $content, $text, $oldAfText, $oldContent
		);
	}

	/**
	 * Get the text of an edit to be used for filtering
	 * @todo Full support for multi-slots
	 *
	 * @param WikiPage $page
	 * @param Content $content
	 * @param string $slot
	 * @return array|null
	 */
	private function getEditTextForFiltering( WikiPage $page, Content $content, $slot ): ?array {
		$oldRevRecord = $page->getRevisionRecord();
		if ( !$oldRevRecord ) {
			return null;
		}

		$oldContent = $oldRevRecord->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
		if ( !$oldContent ) {
			// @codeCoverageIgnoreStart
			throw new LogicException( 'Content cannot be null' );
			// @codeCoverageIgnoreEnd
		}
		$oldAfText = $this->textExtractor->revisionToString( $oldRevRecord, $this->user );

		// XXX: Recreate what the new revision will probably be so we can get the full AF
		// text for all slots
		$newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevRecord );
		$newRevision->setContent( $slot, $content );
		$text = $this->textExtractor->revisionToString( $newRevision, $this->user );

		// Don't trigger for null edits. Compare Content objects if available, but check the
		// stringified contents as well, e.g. for line endings normalization (T240115).
		// Don't treat content model change as null edit though.
		if (
			$content->equals( $oldContent ) ||
			( $oldContent->getModel() === $content->getModel() && strcmp( $oldAfText, $text ) === 0 )
		) {
			return null;
		}

		return [ $oldContent, $oldAfText, $text ];
	}

	/**
	 * @param WikiPage $page
	 * @param string $summary
	 * @param Content $newcontent
	 * @param string $text
	 * @param string $oldtext
	 * @param Content|null $oldcontent
	 * @return VariableHolder
	 */
	private function newVariableHolderForEdit(
		WikiPage $page,
		string $summary,
		Content $newcontent,
		string $text,
		string $oldtext,
		?Content $oldcontent = null
	): VariableHolder {
		$this->addUserVars( $this->user )
			->addTitleVars( $this->title, 'page' );
		$this->vars->setVar( 'action', 'edit' );
		$this->vars->setVar( 'summary', $summary );
		$this->setLastEditAge( $page->getRevisionRecord(), 'page' );

		if ( $oldcontent instanceof Content ) {
			$oldmodel = $oldcontent->getModel();
		} else {
			$oldmodel = '';
			$oldtext = '';
		}
		$this->vars->setVar( 'old_content_model', $oldmodel );
		$this->vars->setVar( 'new_content_model', $newcontent->getModel() );
		$this->vars->setVar( 'old_wikitext', $oldtext );
		$this->vars->setVar( 'new_wikitext', $text );

		try {
			$update = $page->getCurrentUpdate();
			$update->getParserOutputForMetaData();
		} catch ( PreconditionException | LogicException $exception ) {
			// Temporary workaround until this becomes
			// a hook parameter
			$update = null;
		}
		$this->addEditVars( $page, $this->user, true, $update );

		return $this->vars;
	}

	/**
	 * Get variables for filtering an edit.
	 *
	 * @param Content $content
	 * @param string $summary
	 * @param string $slot
	 * @param WikiPage $page
	 * @return VariableHolder|null
	 */
	public function getEditVars(
		Content $content,
		string $summary,
		$slot,
		WikiPage $page
	): ?VariableHolder {
		if ( $this->title->exists() ) {
			$filterText = $this->getEditTextForFiltering( $page, $content, $slot );
			if ( $filterText === null ) {
				return null;
			}
			[ $oldContent, $oldAfText, $text ] = $filterText;
		} else {
			// Optimization
			$oldContent = null;
			$oldAfText = '';
			$text = $this->textExtractor->contentToString( $content );
		}

		return $this->newVariableHolderForEdit(
			$page, $summary, $content, $text, $oldAfText, $oldContent
		);
	}

	/**
	 * @param RevisionRecord|Title|null $from
	 * @param string $prefix
	 */
	private function setLastEditAge( $from, string $prefix ): void {
		$varName = "{$prefix}_last_edit_age";
		if ( $from instanceof RevisionRecord ) {
			$this->vars->setVar(
				$varName,
				(int)wfTimestamp( TS_UNIX ) - (int)wfTimestamp( TS_UNIX, $from->getTimestamp() )
			);
		} elseif ( $from instanceof Title ) {
			$this->vars->setLazyLoadVar(
				$varName,
				'revision-age-by-title',
				[ 'title' => $from, 'asof' => wfTimestampNow() ]
			);
		} else {
			$this->vars->setVar( $varName, null );
		}
	}

	/**
	 * Get variables used to filter a move.
	 *
	 * @param Title $newTitle
	 * @param string $reason
	 * @return VariableHolder
	 */
	public function getMoveVars(
		Title $newTitle,
		string $reason
	): VariableHolder {
		$this->addUserVars( $this->user )
			->addTitleVars( $this->title, 'moved_from' )
			->addTitleVars( $newTitle, 'moved_to' );

		$this->vars->setVar( 'summary', $reason );
		$this->vars->setVar( 'action', 'move' );
		$this->setLastEditAge( $this->title, 'moved_from' );
		$this->setLastEditAge( $newTitle, 'moved_to' );
		// TODO: add old_wikitext etc. (T320347)
		return $this->vars;
	}

	/**
	 * Get variables for filtering a deletion.
	 *
	 * @param string $reason
	 * @return VariableHolder
	 */
	public function getDeleteVars(
		string $reason
	): VariableHolder {
		$this->addUserVars( $this->user )
			->addTitleVars( $this->title, 'page' );

		$this->vars->setVar( 'summary', $reason );
		$this->vars->setVar( 'action', 'delete' );
		// FIXME: this is an unnecessary round-trip, we could obtain WikiPage from
		// the hook and call WikiPage::getRevisionRecord, but then ProofreadPage tests fail
		$this->setLastEditAge( $this->title, 'page' );
		// TODO: add old_wikitext etc. (T173663)
		return $this->vars;
	}

	/**
	 * Get variables for filtering an upload.
	 *
	 * @param string $action
	 * @param UploadBase $upload
	 * @param string|null $summary
	 * @param string|null $text
	 * @param array|null $props
	 * @return VariableHolder|null
	 */
	public function getUploadVars(
		string $action,
		UploadBase $upload,
		?string $summary,
		?string $text,
		?array $props
	): ?VariableHolder {
		if ( !$props ) {
			$props = ( new MWFileProps( $this->mimeAnalyzer ) )->getPropsFromPath(
				$upload->getTempPath(),
				true
			);
		}

		$this->addUserVars( $this->user )
			->addTitleVars( $this->title, 'page' );
		$this->vars->setVar( 'action', $action );

		// We use the hexadecimal version of the file sha1.
		// Use UploadBase::getTempFileSha1Base36 so that we don't have to calculate the sha1 sum again
		$sha1 = \Wikimedia\base_convert( $upload->getTempFileSha1Base36(), 36, 16, 40 );

		// This is the same as AbuseFilterRowVariableGenerator::addUploadVars, but from a different source
		$this->vars->setVar( 'file_sha1', $sha1 );
		$this->vars->setVar( 'file_size', $upload->getFileSize() );

		$this->vars->setVar( 'file_mime', $props['mime'] );
		$this->vars->setVar( 'file_mediatype', $this->mimeAnalyzer->getMediaType( null, $props['mime'] ) );
		$this->vars->setVar( 'file_width', $props['width'] );
		$this->vars->setVar( 'file_height', $props['height'] );
		$this->vars->setVar( 'file_bits_per_channel', $props['bits'] );

		// We only have the upload comment and page text when using the UploadVerifyUpload hook
		if ( $summary !== null && $text !== null ) {
			// This block is adapted from self::getEditTextForFiltering()
			$page = $this->wikiPageFactory->newFromTitle( $this->title );
			if ( $this->title->exists() ) {
				$revRec = $page->getRevisionRecord();
				if ( !$revRec ) {
					return null;
				}

				$this->setLastEditAge( $revRec, 'page' );
				$oldcontent = $revRec->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
				'@phan-var Content $oldcontent';
				$oldtext = $this->textExtractor->contentToString( $oldcontent );

				// Page text is ignored for uploads when the page already exists
				$text = $oldtext;
			} else {
				$oldtext = '';
				$this->setLastEditAge( null, 'page' );
			}

			// Load vars for filters to check
			$this->vars->setVar( 'summary', $summary );
			$this->vars->setVar( 'old_wikitext', $oldtext );
			$this->vars->setVar( 'new_wikitext', $text );
			// TODO: set old_content_model and new_content_model vars, use them
			$this->addEditVars( $page, $this->user, true );
		}
		return $this->vars;
	}

	/**
	 * Get variables for filtering an account creation
	 *
	 * @param User $createdUser This is the user being created, not the creator (which is $this->user)
	 * @param bool $autocreate
	 * @return VariableHolder
	 */
	public function getAccountCreationVars(
		User $createdUser,
		bool $autocreate
	): VariableHolder {
		// generateUserVars records $this->user->getName() which would be the IP for unregistered users
		if ( $this->user->isRegistered() ) {
			$this->addUserVars( $this->user );
		} else {
			// Set the user_type for IP users, so that filters can distinguish between account
			// creations from temporary accounts and those from IP addresses.
			$this->vars->setLazyLoadVar(
				'user_type',
				'user-type',
				[ 'user-identity' => $this->user ]
			);
		}

		$this->vars->setVar( 'action', $autocreate ? 'autocreateaccount' : 'createaccount' );
		$this->vars->setVar( 'accountname', $createdUser->getName() );
		return $this->vars;
	}
}
PK       !  .0I  I    EditBox/EditBoxBuilder.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\EditBox;

use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\KeywordsManager;
use MediaWiki\Html\Html;
use MediaWiki\Output\OutputPage;
use MediaWiki\Permissions\Authority;
use MessageLocalizer;
use OOUI\ButtonWidget;
use OOUI\DropdownInputWidget;
use OOUI\FieldLayout;
use OOUI\FieldsetLayout;
use OOUI\Widget;

/**
 * Base class for classes responsible for building filter edit boxes
 */
abstract class EditBoxBuilder {
	/** @var AbuseFilterPermissionManager */
	protected $afPermManager;

	/** @var KeywordsManager */
	protected $keywordsManager;

	/** @var MessageLocalizer */
	protected $localizer;

	/** @var Authority */
	protected $authority;

	/** @var OutputPage */
	protected $output;

	/**
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param KeywordsManager $keywordsManager
	 * @param MessageLocalizer $messageLocalizer
	 * @param Authority $authority
	 * @param OutputPage $output
	 */
	public function __construct(
		AbuseFilterPermissionManager $afPermManager,
		KeywordsManager $keywordsManager,
		MessageLocalizer $messageLocalizer,
		Authority $authority,
		OutputPage $output
	) {
		$this->afPermManager = $afPermManager;
		$this->keywordsManager = $keywordsManager;
		$this->localizer = $messageLocalizer;
		$this->authority = $authority;
		$this->output = $output;
	}

	/**
	 * @param string $rules
	 * @param bool $addResultDiv
	 * @param bool $externalForm
	 * @param bool $needsModifyRights
	 * @param-taint $rules none
	 * @return string
	 */
	public function buildEditBox(
		string $rules,
		bool $addResultDiv = true,
		bool $externalForm = false,
		bool $needsModifyRights = true
	): string {
		$this->output->addModules( 'ext.abuseFilter.edit' );
		$this->output->enableOOUI();

		$isUserAllowed = $needsModifyRights ?
			$this->afPermManager->canEdit( $this->authority ) :
			$this->afPermManager->canUseTestTools( $this->authority );
		if ( !$isUserAllowed ) {
			$addResultDiv = false;
		}

		$output = $this->getEditBox( $rules, $isUserAllowed, $externalForm );

		if ( $isUserAllowed ) {
			$dropdown = $this->getSuggestionsDropdown();

			$formElements = [
				new FieldLayout( $dropdown ),
				new FieldLayout( $this->getEditorControls() )
			];

			$fieldSet = new FieldsetLayout( [
				'items' => $formElements,
				'classes' => [ 'mw-abusefilter-edit-buttons', 'mw-abusefilter-javascript-tools' ]
			] );

			$output .= $fieldSet;
		}

		if ( $addResultDiv ) {
			$output .= Html::element(
				'div',
				[ 'id' => 'mw-abusefilter-syntaxresult', 'style' => 'display: none;' ]
			);
		}

		return $output;
	}

	/**
	 * @return DropdownInputWidget
	 */
	private function getSuggestionsDropdown(): DropdownInputWidget {
		$rawDropdown = $this->keywordsManager->getBuilderValues();

		// The array needs to be rearranged to be understood by OOUI. It comes with the format
		// [ group-msg-key => [ text-to-add => text-msg-key ] ] and we need it as
		// [ group-msg => [ text-msg => text-to-add ] ]
		// Also, the 'other' element must be the first one.
		$dropdownOptions = [ $this->localizer->msg( 'abusefilter-edit-builder-select' )->text() => 'other' ];
		foreach ( $rawDropdown as $group => $values ) {
			// Give grep a chance to find the usages:
			// abusefilter-edit-builder-group-op-arithmetic
			// abusefilter-edit-builder-group-op-comparison
			// abusefilter-edit-builder-group-op-bool
			// abusefilter-edit-builder-group-misc
			// abusefilter-edit-builder-group-funcs
			// abusefilter-edit-builder-group-vars
			$localisedGroup = $this->localizer->msg( "abusefilter-edit-builder-group-$group" )->text();
			$dropdownOptions[ $localisedGroup ] = array_flip( $values );
			$newKeys = array_map(
				function ( $key ) use ( $group, $dropdownOptions, $localisedGroup ) {
					// Force all operators and functions to be always shown as left to right text
					// with the help of control characters:
					// * 202A is LEFT-TO-RIGHT EMBEDDING (LRE)
					// * 202C is POP DIRECTIONAL FORMATTING (PDF)
					// This has to be done with control characters because
					// markup cannot be used within <option> elements.
					$operatorExample = "\u{202A}" .
						$dropdownOptions[ $localisedGroup ][ $key ] .
						"\u{202C}";
					return $this->localizer->msg(
						"abusefilter-edit-builder-$group-$key",
						$operatorExample
					)->text();
				},
				array_keys( $dropdownOptions[ $localisedGroup ] )
			);
			$dropdownOptions[ $localisedGroup ] = array_combine(
				$newKeys,
				$dropdownOptions[ $localisedGroup ]
			);
		}

		$dropdownList = Html::listDropdownOptionsOoui( $dropdownOptions );
		return new DropdownInputWidget( [
			'name' => 'wpFilterBuilder',
			'inputId' => 'wpFilterBuilder',
			'options' => $dropdownList
		] );
	}

	/**
	 * Get an additional widget that "controls" the editor, and is placed next to it
	 * Precondition: the user has full rights.
	 *
	 * @return Widget
	 */
	protected function getEditorControls(): Widget {
		return new ButtonWidget(
			[
				'label' => $this->localizer->msg( 'abusefilter-edit-check' )->text(),
				'id' => 'mw-abusefilter-syntaxcheck'
			]
		);
	}

	/**
	 * Generate the HTML for the actual edit box
	 *
	 * @param string $rules
	 * @param bool $isUserAllowed
	 * @param bool $externalForm
	 * @return string
	 */
	abstract protected function getEditBox( string $rules, bool $isUserAllowed, bool $externalForm ): string;

}
PK       !       EditBox/PlainEditBoxBuilder.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\EditBox;

use MediaWiki\Html\Html;

/**
 * Class responsible for building a plain text filter edit box
 */
class PlainEditBoxBuilder extends EditBoxBuilder {
	/**
	 * @inheritDoc
	 */
	public function getEditBox( string $rules, bool $isUserAllowed, bool $externalForm ): string {
		$rules = rtrim( $rules ) . "\n";
		$editorAttribs = [
			'name' => 'wpFilterRules',
			'id' => 'wpFilterRules',
			// Rules are in English
			'dir' => 'ltr',
			'cols' => 40,
			'rows' => 15,
		];
		if ( !$isUserAllowed ) {
			$editorAttribs['readonly'] = 'readonly';
		}
		if ( $externalForm ) {
			$editorAttribs['form'] = 'wpFilterForm';
		}
		return Html::element( 'textarea', $editorAttribs, $rules );
	}

}
PK       !       EditBox/EditBoxField.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\EditBox;

use MediaWiki\HTMLForm\HTMLFormField;

/**
 * This class is used to easily wrap a filter editor box inside an HTMLForm. For now it's just a transparent
 * wrapper around the given HTML string. In the future, some of the actual logic might be moved here.
 * @unstable
 */
class EditBoxField extends HTMLFormField {
	/** @var string */
	private $html;

	/**
	 * @param array $params
	 */
	public function __construct( array $params ) {
		parent::__construct( $params );
		$this->html = $params['html'];
	}

	/**
	 * @inheritDoc
	 */
	public function getInputHTML( $value ): string {
		return $this->html;
	}
}
PK       ! l5W
  W
  !  EditBox/EditBoxBuilderFactory.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\EditBox;

use LogicException;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\KeywordsManager;
use MediaWiki\Output\OutputPage;
use MediaWiki\Permissions\Authority;
use MessageLocalizer;

/**
 * Factory for EditBoxBuilder objects
 */
class EditBoxBuilderFactory {

	public const SERVICE_NAME = 'AbuseFilterEditBoxBuilderFactory';

	/** @var AbuseFilterPermissionManager */
	private $afPermManager;

	/** @var KeywordsManager */
	private $keywordsManager;

	/** @var bool */
	private $isCodeEditorLoaded;

	/**
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param KeywordsManager $keywordsManager
	 * @param bool $isCodeEditorLoaded
	 */
	public function __construct(
		AbuseFilterPermissionManager $afPermManager,
		KeywordsManager $keywordsManager,
		bool $isCodeEditorLoaded
	) {
		$this->afPermManager = $afPermManager;
		$this->keywordsManager = $keywordsManager;
		$this->isCodeEditorLoaded = $isCodeEditorLoaded;
	}

	/**
	 * Returns a builder, preferring the Ace version if available
	 * @param MessageLocalizer $messageLocalizer
	 * @param Authority $authority
	 * @param OutputPage $output
	 * @return EditBoxBuilder
	 */
	public function newEditBoxBuilder(
		MessageLocalizer $messageLocalizer,
		Authority $authority,
		OutputPage $output
	): EditBoxBuilder {
		return $this->isCodeEditorLoaded
			? $this->newAceBoxBuilder( $messageLocalizer, $authority, $output )
			: $this->newPlainBoxBuilder( $messageLocalizer, $authority, $output );
	}

	/**
	 * @param MessageLocalizer $messageLocalizer
	 * @param Authority $authority
	 * @param OutputPage $output
	 * @return PlainEditBoxBuilder
	 */
	public function newPlainBoxBuilder(
		MessageLocalizer $messageLocalizer,
		Authority $authority,
		OutputPage $output
	): PlainEditBoxBuilder {
		return new PlainEditBoxBuilder(
			$this->afPermManager,
			$this->keywordsManager,
			$messageLocalizer,
			$authority,
			$output
		);
	}

	/**
	 * @param MessageLocalizer $messageLocalizer
	 * @param Authority $authority
	 * @param OutputPage $output
	 * @return AceEditBoxBuilder
	 */
	public function newAceBoxBuilder(
		MessageLocalizer $messageLocalizer,
		Authority $authority,
		OutputPage $output
	): AceEditBoxBuilder {
		if ( !$this->isCodeEditorLoaded ) {
			throw new LogicException( 'Cannot create Ace box without CodeEditor' );
		}
		return new AceEditBoxBuilder(
			$this->afPermManager,
			$this->keywordsManager,
			$messageLocalizer,
			$authority,
			$output,
			$this->newPlainBoxBuilder(
				$messageLocalizer,
				$authority,
				$output
			)
		);
	}

}
PK       ! V      EditBox/AceEditBoxBuilder.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\EditBox;

use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\KeywordsManager;
use MediaWiki\Extension\AbuseFilter\Parser\AbuseFilterTokenizer;
use MediaWiki\Extension\AbuseFilter\Parser\FilterEvaluator;
use MediaWiki\Html\Html;
use MediaWiki\Output\OutputPage;
use MediaWiki\Permissions\Authority;
use MessageLocalizer;
use OOUI\ButtonWidget;
use OOUI\HorizontalLayout;
use OOUI\Widget;

/**
 * Class responsible for building filter edit boxes with both the Ace and the plain version
 */
class AceEditBoxBuilder extends EditBoxBuilder {

	/** @var PlainEditBoxBuilder */
	private $plainBuilder;

	/**
	 * @inheritDoc
	 * @param PlainEditBoxBuilder $plainBuilder
	 */
	public function __construct(
		AbuseFilterPermissionManager $afPermManager,
		KeywordsManager $keywordsManager,
		MessageLocalizer $messageLocalizer,
		Authority $authority,
		OutputPage $output,
		PlainEditBoxBuilder $plainBuilder
	) {
		parent::__construct( $afPermManager, $keywordsManager, $messageLocalizer, $authority, $output );
		$this->plainBuilder = $plainBuilder;
	}

	/**
	 * @inheritDoc
	 */
	protected function getEditBox( string $rules, bool $isUserAllowed, bool $externalForm ): string {
		$rules = rtrim( $rules ) . "\n";

		$attribs = [
			// Rules are in English
			'dir' => 'ltr',
			'name' => 'wpAceFilterEditor',
			'id' => 'wpAceFilterEditor',
			'class' => 'mw-abusefilter-editor'
		];
		$rulesContainer = Html::element( 'div', $attribs, $rules );
		$editorConfig = $this->getAceConfig( $isUserAllowed );
		$this->output->addJsConfigVars( 'aceConfig', $editorConfig );
		return $rulesContainer . $this->plainBuilder->getEditBox( $rules, $isUserAllowed, $externalForm );
	}

	/**
	 * @inheritDoc
	 */
	protected function getEditorControls(): Widget {
		$base = parent::getEditorControls();
		$switchEditor = new ButtonWidget(
			[
				'label' => $this->localizer->msg( 'abusefilter-edit-switch-editor' )->text(),
				'id' => 'mw-abusefilter-switcheditor'
			]
		);
		return new Widget( [
			'content' => new HorizontalLayout( [
				'items' => [ $switchEditor, $base ]
			] )
		] );
	}

	/**
	 * Extract values for syntax highlight
	 *
	 * @param bool $canEdit
	 * @return array
	 */
	private function getAceConfig( bool $canEdit ): array {
		$values = $this->keywordsManager->getBuilderValues();
		$deprecatedVars = $this->keywordsManager->getDeprecatedVariables();

		$builderVariables = implode( '|', array_keys( $values['vars'] ) );
		$builderFunctions = implode( '|', array_keys( FilterEvaluator::FUNCTIONS ) );
		// AbuseFilterTokenizer::KEYWORDS also includes constants (true, false and null),
		// but Ace redefines these constants afterwards so this will not be an issue
		$builderKeywords = implode( '|', AbuseFilterTokenizer::KEYWORDS );
		// Extract operators from tokenizer like we do in AbuseFilterParserTest
		$operators = implode( '|', array_map( static function ( $op ) {
			return preg_quote( $op, '/' );
		}, AbuseFilterTokenizer::OPERATORS ) );
		$deprecatedVariables = implode( '|', array_keys( $deprecatedVars ) );
		$disabledVariables = implode( '|', array_keys( $this->keywordsManager->getDisabledVariables() ) );

		return [
			'variables' => $builderVariables,
			'functions' => $builderFunctions,
			'keywords' => $builderKeywords,
			'operators' => $operators,
			'deprecated' => $deprecatedVariables,
			'disabled' => $disabledVariables,
			'aceReadOnly' => !$canEdit
		];
	}
}
PK       ! a(	  	    Api/UnblockAutopromote.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Api;

use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiMain;
use MediaWiki\Extension\AbuseFilter\BlockAutopromoteStore;
use MediaWiki\ParamValidator\TypeDef\UserDef;
use Wikimedia\ParamValidator\ParamValidator;

class UnblockAutopromote extends ApiBase {

	/** @var BlockAutopromoteStore */
	private $afBlockAutopromoteStore;

	/**
	 * @param ApiMain $main
	 * @param string $action
	 * @param BlockAutopromoteStore $afBlockAutopromoteStore
	 */
	public function __construct(
		ApiMain $main,
		$action,
		BlockAutopromoteStore $afBlockAutopromoteStore
	) {
		parent::__construct( $main, $action );
		$this->afBlockAutopromoteStore = $afBlockAutopromoteStore;
	}

	/**
	 * @inheritDoc
	 */
	public function execute() {
		$this->checkUserRightsAny( 'abusefilter-modify' );

		$params = $this->extractRequestParams();
		$target = $params['user'];

		$block = $this->getAuthority()->getBlock();
		if ( $block && $block->isSitewide() ) {
			$this->dieBlocked( $block );
		}

		$msg = $this->msg( 'abusefilter-tools-restoreautopromote' )->inContentLanguage()->text();
		$res = $this->afBlockAutopromoteStore->unblockAutopromote( $target, $this->getUser(), $msg );

		if ( !$res ) {
			$this->dieWithError( [ 'abusefilter-reautoconfirm-none', $target->getName() ], 'notsuspended' );
		}

		$finalResult = [ 'user' => $target->getName() ];
		$this->getResult()->addValue( null, $this->getModuleName(), $finalResult );
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function mustBePosted() {
		return true;
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function isWriteMode() {
		return true;
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function getAllowedParams() {
		return [
			'user' => [
				ParamValidator::PARAM_TYPE => 'user',
				ParamValidator::PARAM_REQUIRED => true,
				UserDef::PARAM_RETURN_OBJECT => true,
				UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name' ],
			],
			'token' => null,
		];
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function needsToken() {
		return 'csrf';
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	protected function getExamplesMessages() {
		return [
			'action=abusefilterunblockautopromote&user=Example&token=123ABC'
				=> 'apihelp-abusefilterunblockautopromote-example-1',
		];
	}
}
PK       ! fС
  
    Api/CheckSyntax.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Api;

use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiMain;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleException;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use Wikimedia\ParamValidator\ParamValidator;

class CheckSyntax extends ApiBase {

	/** @var RuleCheckerFactory */
	private $ruleCheckerFactory;

	/** @var AbuseFilterPermissionManager */
	private $afPermManager;

	/**
	 * @param ApiMain $main
	 * @param string $action
	 * @param RuleCheckerFactory $ruleCheckerFactory
	 * @param AbuseFilterPermissionManager $afPermManager
	 */
	public function __construct(
		ApiMain $main,
		$action,
		RuleCheckerFactory $ruleCheckerFactory,
		AbuseFilterPermissionManager $afPermManager
	) {
		parent::__construct( $main, $action );
		$this->ruleCheckerFactory = $ruleCheckerFactory;
		$this->afPermManager = $afPermManager;
	}

	/**
	 * @inheritDoc
	 */
	public function execute() {
		// "Anti-DoS"
		if ( !$this->afPermManager->canUseTestTools( $this->getAuthority() )
			&& !$this->afPermManager->canEdit( $this->getAuthority() )
		) {
			$this->dieWithError( 'apierror-abusefilter-cantcheck', 'permissiondenied' );
		}

		$params = $this->extractRequestParams();
		$result = $this->ruleCheckerFactory->newRuleChecker()->checkSyntax( $params['filter'] );

		$r = [];
		$warnings = [];
		foreach ( $result->getWarnings() as $warning ) {
			$warnings[] = [
				'message' => $this->msg( $warning->getMessageObj() )->text(),
				'character' => $warning->getPosition()
			];
		}
		if ( $warnings ) {
			$r['warnings'] = $warnings;
		}

		if ( $result->isValid() ) {
			// Everything went better than expected :)
			$r['status'] = 'ok';
		} else {
			// TODO: Improve the type here.
			/** @var UserVisibleException $excep */
			$excep = $result->getException();
			'@phan-var UserVisibleException $excep';
			$r = [
				'status' => 'error',
				'message' => $this->msg( $excep->getMessageObj() )->text(),
				'character' => $excep->getPosition(),
			];
		}

		$this->getResult()->addValue( null, $this->getModuleName(), $r );
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function getAllowedParams() {
		return [
			'filter' => [
				ParamValidator::PARAM_REQUIRED => true,
			],
		];
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	protected function getExamplesMessages() {
		return [
			'action=abusefilterchecksyntax&filter="foo"'
				=> 'apihelp-abusefilterchecksyntax-example-1',
			'action=abusefilterchecksyntax&filter="bar"%20bad_variable'
				=> 'apihelp-abusefilterchecksyntax-example-2',
		];
	}
}
PK       ! u      Api/EvalExpression.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Api;

use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiResult;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesFormatter;
use MediaWiki\Status\Status;
use Wikimedia\ParamValidator\ParamValidator;

class EvalExpression extends ApiBase {

	/** @var RuleCheckerFactory */
	private $ruleCheckerFactory;

	/** @var AbuseFilterPermissionManager */
	private $afPermManager;

	/** @var VariableGeneratorFactory */
	private $afVariableGeneratorFactory;

	/**
	 * @param ApiMain $main
	 * @param string $action
	 * @param RuleCheckerFactory $ruleCheckerFactory
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param VariableGeneratorFactory $afVariableGeneratorFactory
	 */
	public function __construct(
		ApiMain $main,
		$action,
		RuleCheckerFactory $ruleCheckerFactory,
		AbuseFilterPermissionManager $afPermManager,
		VariableGeneratorFactory $afVariableGeneratorFactory
	) {
		parent::__construct( $main, $action );
		$this->ruleCheckerFactory = $ruleCheckerFactory;
		$this->afPermManager = $afPermManager;
		$this->afVariableGeneratorFactory = $afVariableGeneratorFactory;
	}

	/**
	 * @inheritDoc
	 */
	public function execute() {
		// "Anti-DoS"
		if ( !$this->afPermManager->canUseTestTools( $this->getAuthority() ) ) {
			$this->dieWithError( 'apierror-abusefilter-canteval', 'permissiondenied' );
		}

		$params = $this->extractRequestParams();

		$status = $this->evaluateExpression( $params['expression'] );
		if ( !$status->isGood() ) {
			$this->dieStatus( $status );
		} else {
			$res = $status->getValue();
			$res = $params['prettyprint'] ? VariablesFormatter::formatVar( $res ) : $res;
			$this->getResult()->addValue(
				null,
				$this->getModuleName(),
				ApiResult::addMetadataToResultVars( [ 'result' => $res ] )
			);
		}
	}

	/**
	 * @param string $expr
	 * @return Status
	 */
	private function evaluateExpression( string $expr ): Status {
		$ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
		if ( !$ruleChecker->checkSyntax( $expr )->isValid() ) {
			return Status::newFatal( 'abusefilter-tools-syntax-error' );
		}

		// Generic vars are the only ones available
		$generator = $this->afVariableGeneratorFactory->newGenerator();
		$vars = $generator->addGenericVars()->getVariableHolder();
		$ruleChecker->setVariables( $vars );

		return Status::newGood( $ruleChecker->evaluateExpression( $expr ) );
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function getAllowedParams() {
		return [
			'expression' => [
				ParamValidator::PARAM_REQUIRED => true,
			],
			'prettyprint' => [
				ParamValidator::PARAM_TYPE => 'boolean'
			]
		];
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	protected function getExamplesMessages() {
		return [
			'action=abusefilterevalexpression&expression=lcase("FOO")'
				=> 'apihelp-abusefilterevalexpression-example-1',
			'action=abusefilterevalexpression&expression=lcase("FOO")&prettyprint=1'
				=> 'apihelp-abusefilterevalexpression-example-2',
		];
	}
}
PK       ! pR      Api/AbuseLogPrivateDetails.phpnu Iw        <?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
 */

namespace MediaWiki\Extension\AbuseFilter\Api;

use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiMain;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseLog;
use Wikimedia\ParamValidator\ParamValidator;

/**
 * API module to allow accessing private details (the user's IP) from AbuseLog entries
 *
 * @ingroup API
 * @ingroup Extensions
 */
class AbuseLogPrivateDetails extends ApiBase {

	/** @var AbuseFilterPermissionManager */
	private $afPermManager;

	/**
	 * @param ApiMain $main
	 * @param string $action
	 * @param AbuseFilterPermissionManager $afPermManager
	 */
	public function __construct(
		ApiMain $main,
		$action,
		AbuseFilterPermissionManager $afPermManager
	) {
		parent::__construct( $main, $action );
		$this->afPermManager = $afPermManager;
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function mustBePosted() {
		return true;
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function isWriteMode() {
		return true;
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function needsToken() {
		return 'csrf';
	}

	/**
	 * @inheritDoc
	 */
	public function execute() {
		$user = $this->getUser();

		if ( !$this->afPermManager->canSeePrivateDetails( $user ) ) {
			$this->dieWithError( 'abusefilter-log-cannot-see-privatedetails' );
		}
		$params = $this->extractRequestParams();

		if ( !SpecialAbuseLog::checkPrivateDetailsAccessReason( $params['reason'] ) ) {
			// Double check, in case we add some extra validation
			$this->dieWithError( 'abusefilter-noreason' );
		}
		$status = SpecialAbuseLog::getPrivateDetailsRow( $user, $params['logid'] );
		if ( !$status->isGood() ) {
			$this->dieStatus( $status );
		}
		$row = $status->getValue();
		// Log accessing private details
		if ( $this->getConfig()->get( 'AbuseFilterLogPrivateDetailsAccess' ) ) {
			SpecialAbuseLog::addPrivateDetailsAccessLogEntry(
				$params['logid'],
				$params['reason'],
				$user
			);
		}

		$result = [
			'log-id' => $params['logid'],
			'user' => $row->afl_user_text,
			'filter-id' => (int)$row->af_id,
			'filter-description' => $row->af_public_comments,
			'ip-address' => $row->afl_ip !== '' ? $row->afl_ip : null
		];
		$this->getResult()->addValue( null, $this->getModuleName(), $result );
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function getAllowedParams() {
		return [
			'logid' => [
				ParamValidator::PARAM_TYPE => 'integer'
			],
			'reason' => [
				ParamValidator::PARAM_TYPE => 'string',
				ParamValidator::PARAM_REQUIRED => $this->getConfig()->get( 'AbuseFilterPrivateDetailsForceReason' ),
				ParamValidator::PARAM_DEFAULT => '',
			]
		];
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	protected function getExamplesMessages() {
		return [
			'action=abuselogprivatedetails&logid=1&reason=example&token=ABC123'
				=> 'apihelp-abuselogprivatedetails-example-1'
		];
	}
}
PK       ! >  >    Api/QueryAbuseLog.phpnu Iw        <?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
 */

namespace MediaWiki\Extension\AbuseFilter\Api;

use InvalidArgumentException;
use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiQuery;
use MediaWiki\Api\ApiQueryBase;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\AbuseLoggerFactory;
use MediaWiki\Extension\AbuseFilter\CentralDBNotAvailableException;
use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;
use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseLog;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Title\Title;
use MediaWiki\User\UserFactory;
use MediaWiki\Utils\MWTimestamp;
use Wikimedia\IPUtils;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\TypeDef\IntegerDef;

/**
 * Query module to list abuse log entries.
 *
 * @copyright 2009 Alex Z. <mrzmanwiki AT gmail DOT com>
 * Based mostly on code by Bryan Tong Minh and Roan Kattouw
 *
 * @ingroup API
 * @ingroup Extensions
 */
class QueryAbuseLog extends ApiQueryBase {

	/** @var FilterLookup */
	private $afFilterLookup;

	/** @var AbuseFilterPermissionManager */
	private $afPermManager;

	/** @var VariablesBlobStore */
	private $afVariablesBlobStore;

	/** @var VariablesManager */
	private $afVariablesManager;

	/** @var UserFactory */
	private $userFactory;

	private AbuseLoggerFactory $abuseLoggerFactory;

	/**
	 * @param ApiQuery $query
	 * @param string $moduleName
	 * @param FilterLookup $afFilterLookup
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param VariablesBlobStore $afVariablesBlobStore
	 * @param VariablesManager $afVariablesManager
	 * @param UserFactory $userFactory
	 * @param AbuseLoggerFactory $abuseLoggerFactory
	 */
	public function __construct(
		ApiQuery $query,
		$moduleName,
		FilterLookup $afFilterLookup,
		AbuseFilterPermissionManager $afPermManager,
		VariablesBlobStore $afVariablesBlobStore,
		VariablesManager $afVariablesManager,
		UserFactory $userFactory,
		AbuseLoggerFactory $abuseLoggerFactory
	) {
		parent::__construct( $query, $moduleName, 'afl' );
		$this->afFilterLookup = $afFilterLookup;
		$this->afPermManager = $afPermManager;
		$this->afVariablesBlobStore = $afVariablesBlobStore;
		$this->afVariablesManager = $afVariablesManager;
		$this->userFactory = $userFactory;
		$this->abuseLoggerFactory = $abuseLoggerFactory;
	}

	/**
	 * @inheritDoc
	 */
	public function execute() {
		$lookup = $this->afFilterLookup;

		// Same check as in SpecialAbuseLog
		$this->checkUserRightsAny( 'abusefilter-log' );

		$performer = $this->getAuthority();
		$params = $this->extractRequestParams();

		$prop = array_fill_keys( $params['prop'], true );
		$fld_ids = isset( $prop['ids'] );
		$fld_filter = isset( $prop['filter'] );
		$fld_user = isset( $prop['user'] );
		$fld_title = isset( $prop['title'] );
		$fld_action = isset( $prop['action'] );
		$fld_details = isset( $prop['details'] );
		$fld_result = isset( $prop['result'] );
		$fld_timestamp = isset( $prop['timestamp'] );
		$fld_hidden = isset( $prop['hidden'] );
		$fld_revid = isset( $prop['revid'] );
		$isCentral = $this->getConfig()->get( 'AbuseFilterIsCentral' );
		$fld_wiki = $isCentral && isset( $prop['wiki'] );

		if ( $fld_details ) {
			$this->checkUserRightsAny( 'abusefilter-log-detail' );
		}

		$canViewPrivate = $this->afPermManager->canViewPrivateFiltersLogs( $performer );
		$canViewProtected = $this->afPermManager->canViewProtectedVariables( $performer );
		$canViewProtectedValues = $this->afPermManager->canViewProtectedVariableValues( $performer );

		// Map of [ [ id, global ], ... ]
		$searchFilters = [];
		// Match permissions for viewing events on private filters to SpecialAbuseLog (bug 42814)
		// @todo Avoid code duplication with SpecialAbuseLog::showList, make it so that, if hidden
		// filters are specified, we only filter them out instead of failing.
		if ( $params['filter'] ) {
			if ( !is_array( $params['filter'] ) ) {
				$params['filter'] = [ $params['filter'] ];
			}
			$foundInvalid = false;
			foreach ( $params['filter'] as $filter ) {
				try {
					$searchFilters[] = GlobalNameUtils::splitGlobalName( $filter );
				} catch ( InvalidArgumentException $e ) {
					$foundInvalid = true;
					continue;
				}
			}

			if ( !$canViewPrivate || !$canViewProtected || !$canViewProtectedValues ) {
				foreach ( $searchFilters as [ $filterID, $global ] ) {
					try {
						$privacyLevel = $lookup->getFilter( $filterID, $global )->getPrivacyLevel();
					} catch ( CentralDBNotAvailableException $_ ) {
						// Conservatively assume it's hidden and protected, like in AbuseLogPager::doFormatRow
						$privacyLevel = Flags::FILTER_HIDDEN | Flags::FILTER_USES_PROTECTED_VARS;
					} catch ( FilterNotFoundException $_ ) {
						$privacyLevel = Flags::FILTER_PUBLIC;
						$foundInvalid = true;
					}
					if ( !$canViewPrivate && ( Flags::FILTER_HIDDEN & $privacyLevel ) ) {
						$this->dieWithError(
							[ 'apierror-permissiondenied', $this->msg( 'action-abusefilter-log-private' ) ]
						);
					}
					if ( !$canViewProtected && ( Flags::FILTER_USES_PROTECTED_VARS & $privacyLevel ) ) {
						$this->dieWithError(
							[ 'apierror-permissiondenied', $this->msg( 'action-abusefilter-log-protected' ) ]
						);
					}
					if ( !$canViewProtectedValues && ( Flags::FILTER_USES_PROTECTED_VARS & $privacyLevel ) ) {
						$this->dieWithError(
							[ 'apierror-permissiondenied', $this->msg( 'action-abusefilter-log-protected-access' ) ]
						);
					}
				}
			}

			if ( $foundInvalid ) {
				// @todo Tell what the invalid IDs are
				$this->addWarning( 'abusefilter-log-invalid-filter' );
			}
		}

		$result = $this->getResult();

		$this->addTables( 'abuse_filter_log' );
		$this->addFields( 'afl_timestamp' );
		$this->addFields( 'afl_rev_id' );
		$this->addFields( 'afl_deleted' );
		$this->addFields( 'afl_filter_id' );
		$this->addFields( 'afl_global' );
		$this->addFields( 'afl_ip' );
		$this->addFieldsIf( 'afl_id', $fld_ids );
		$this->addFieldsIf( 'afl_user_text', $fld_user );
		$this->addFieldsIf( [ 'afl_namespace', 'afl_title' ], $fld_title );
		$this->addFieldsIf( 'afl_action', $fld_action );
		$this->addFieldsIf( 'afl_var_dump', $fld_details );
		$this->addFieldsIf( 'afl_actions', $fld_result );
		$this->addFieldsIf( 'afl_wiki', $fld_wiki );

		if ( $fld_filter ) {
			$this->addTables( 'abuse_filter' );
			$this->addFields( 'af_public_comments' );

			$this->addJoinConds( [
				'abuse_filter' => [
					'LEFT JOIN',
					[
						'af_id=afl_filter_id',
						'afl_global' => 0
					]
				]
			] );
		}

		$this->addOption( 'LIMIT', $params['limit'] + 1 );

		$this->addWhereIf( [ 'afl_id' => $params['logid'] ], isset( $params['logid'] ) );

		$this->addWhereRange( 'afl_timestamp', $params['dir'], $params['start'], $params['end'] );

		if ( isset( $params['user'] ) ) {
			$u = $this->userFactory->newFromName( $params['user'] );
			if ( $u ) {
				// Username normalisation
				$params['user'] = $u->getName();
				$userId = $u->getId();
			} elseif ( IPUtils::isIPAddress( $params['user'] ) ) {
				// It's an IP, sanitize it
				$params['user'] = IPUtils::sanitizeIP( $params['user'] );
				$userId = 0;
			}

			if ( isset( $userId ) ) {
				// Only add the WHERE for user in case it's either a valid user
				// (but not necessary an existing one) or an IP.
				$this->addWhere(
					[
						'afl_user' => $userId,
						'afl_user_text' => $params['user']
					]
				);
			}
		}

		$this->addWhereIf( [ 'afl_deleted' => 0 ], !$this->afPermManager->canSeeHiddenLogEntries( $performer ) );

		if ( $searchFilters ) {
			// @todo Avoid code duplication with SpecialAbuseLog::showList
			$filterConds = [ 'local' => [], 'global' => [] ];
			foreach ( $searchFilters as $filter ) {
				$isGlobal = $filter[1];
				$key = $isGlobal ? 'global' : 'local';
				$filterConds[$key][] = $filter[0];
			}
			$dbr = $this->getDB();
			$conds = [];
			if ( $filterConds['local'] ) {
				$conds[] = $dbr->andExpr( [
					'afl_global' => 0,
					// @phan-suppress-previous-line PhanTypeMismatchArgument Array is non-empty
					'afl_filter_id' => $filterConds['local'],
				] );
			}
			if ( $filterConds['global'] ) {
				$conds[] = $dbr->andExpr( [
					'afl_global' => 1,
					// @phan-suppress-previous-line PhanTypeMismatchArgument Array is non-empty
					'afl_filter_id' => $filterConds['global'],
				] );
			}
			$this->addWhere( $dbr->orExpr( $conds ) );
		}

		if ( isset( $params['wiki'] ) ) {
			// 'wiki' won't be set if $wgAbuseFilterIsCentral = false
			$this->addWhereIf( [ 'afl_wiki' => $params['wiki'] ], $isCentral );
		}

		$title = $params['title'];
		if ( $title !== null ) {
			$titleObj = Title::newFromText( $title );
			if ( $titleObj === null ) {
				$this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $title ) ] );
			}
			$this->addWhereFld( 'afl_namespace', $titleObj->getNamespace() );
			$this->addWhereFld( 'afl_title', $titleObj->getDBkey() );
		}
		$res = $this->select( __METHOD__ );

		$count = 0;
		foreach ( $res as $row ) {
			if ( ++$count > $params['limit'] ) {
				// We've had enough
				$ts = new MWTimestamp( $row->afl_timestamp );
				$this->setContinueEnumParameter( 'start', $ts->getTimestamp( TS_ISO_8601 ) );
				break;
			}
			$visibility = SpecialAbuseLog::getEntryVisibilityForUser( $row, $performer, $this->afPermManager );
			if ( $visibility !== SpecialAbuseLog::VISIBILITY_VISIBLE ) {
				continue;
			}

			$filterID = $row->afl_filter_id;
			$global = $row->afl_global;
			$fullName = GlobalNameUtils::buildGlobalName( $filterID, $global );
			$privacyLevel = $lookup->getFilter( $filterID, $global )->getPrivacyLevel();
			$canSeeDetails = $this->afPermManager->canSeeLogDetailsForFilter( $performer, $privacyLevel );

			$entry = [];
			if ( $fld_ids ) {
				$entry['id'] = intval( $row->afl_id );
				$entry['filter_id'] = $canSeeDetails ? $fullName : '';
			}
			if ( $fld_filter ) {
				if ( $global ) {
					$entry['filter'] = $lookup->getFilter( $filterID, true )->getName();
				} else {
					$entry['filter'] = $row->af_public_comments;
				}
			}
			if ( $fld_user ) {
				$entry['user'] = $row->afl_user_text;
			}
			if ( $fld_wiki ) {
				$entry['wiki'] = $row->afl_wiki;
			}
			if ( $fld_title ) {
				$title = Title::makeTitle( $row->afl_namespace, $row->afl_title );
				ApiQueryBase::addTitleInfo( $entry, $title );
			}
			if ( $fld_action ) {
				$entry['action'] = $row->afl_action;
			}
			if ( $fld_result ) {
				$entry['result'] = $row->afl_actions;
			}
			if ( $fld_revid && $row->afl_rev_id !== null ) {
				$entry['revid'] = $canSeeDetails ? (int)$row->afl_rev_id : '';
			}
			if ( $fld_timestamp ) {
				$ts = new MWTimestamp( $row->afl_timestamp );
				$entry['timestamp'] = $ts->getTimestamp( TS_ISO_8601 );
			}
			if ( $fld_details ) {
				$entry['details'] = [];
				if ( $canSeeDetails ) {
					$vars = $this->afVariablesBlobStore->loadVarDump( $row );
					$varManager = $this->afVariablesManager;
					$entry['details'] = $varManager->exportAllVars( $vars );

					$usedProtectedVars = $this->afPermManager
						->getUsedProtectedVariables( array_keys( $entry['details'] ) );
					if ( $usedProtectedVars ) {
						// Unset the variable if the user can't see protected variables
						// Additionally, a protected variable is considered used if the key exists
						// but since it can have a null value, check isset before logging access
						$shouldLog = false;
						foreach ( $usedProtectedVars as $protectedVariable ) {
							if ( isset( $entry['details'][$protectedVariable] ) ) {
								if ( $canViewProtectedValues ) {
									$shouldLog = true;
								} else {
									$entry['details'][$protectedVariable] = '';
								}
							}
						}

						if ( $shouldLog ) {
							// user_name or accountname should always exist -- just in case
							// if it doesn't, unset the protected variables since they shouldn't be accessed if
							// the access isn't logged
							if ( isset( $entry['details']['user_name'] ) ||
								isset( $entry['details']['accountname'] )
							) {
								$logger = $this->abuseLoggerFactory->getProtectedVarsAccessLogger();
								$logger->logViewProtectedVariableValue(
									$performer->getUser(),
									$entry['details']['user_name'] ?? $entry['details']['accountname']
								);
							} else {
								foreach ( $usedProtectedVars as $protectedVariable ) {
									if ( isset( $entry['details'][$protectedVariable] ) ) {
										$entry['details'][$protectedVariable] = '';
									}
								}
							}

						}
					}
				}
			}

			if ( $fld_hidden ) {
				$entry['hidden'] = (bool)$row->afl_deleted;
			}

			if ( $entry ) {
				$fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $entry );
				if ( !$fit ) {
					$ts = new MWTimestamp( $row->afl_timestamp );
					$this->setContinueEnumParameter( 'start', $ts->getTimestamp( TS_ISO_8601 ) );
					break;
				}
			}
		}
		$result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'item' );
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function getAllowedParams() {
		$params = [
			'logid' => [
				ParamValidator::PARAM_TYPE => 'integer'
			],
			'start' => [
				ParamValidator::PARAM_TYPE => 'timestamp'
			],
			'end' => [
				ParamValidator::PARAM_TYPE => 'timestamp'
			],
			'dir' => [
				ParamValidator::PARAM_TYPE => [
					'newer',
					'older'
				],
				ParamValidator::PARAM_DEFAULT => 'older',
				ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
			],
			'user' => null,
			'title' => null,
			'filter' => [
				ParamValidator::PARAM_TYPE => 'string',
				ParamValidator::PARAM_ISMULTI => true,
				ApiBase::PARAM_HELP_MSG => [
					'apihelp-query+abuselog-param-filter',
					GlobalNameUtils::GLOBAL_FILTER_PREFIX
				]
			],
			'limit' => [
				ParamValidator::PARAM_DEFAULT => 10,
				ParamValidator::PARAM_TYPE => 'limit',
				IntegerDef::PARAM_MIN => 1,
				IntegerDef::PARAM_MAX => ApiBase::LIMIT_BIG1,
				IntegerDef::PARAM_MAX2 => ApiBase::LIMIT_BIG2
			],
			'prop' => [
				ParamValidator::PARAM_DEFAULT => 'ids|user|title|action|result|timestamp|hidden|revid',
				ParamValidator::PARAM_TYPE => [
					'ids',
					'filter',
					'user',
					'title',
					'action',
					'details',
					'result',
					'timestamp',
					'hidden',
					'revid',
				],
				ParamValidator::PARAM_ISMULTI => true
			]
		];
		if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
			$params['wiki'] = [
				ParamValidator::PARAM_TYPE => 'string',
			];
			$params['prop'][ParamValidator::PARAM_DEFAULT] .= '|wiki';
			$params['prop'][ParamValidator::PARAM_TYPE][] = 'wiki';
			$params['filter'][ApiBase::PARAM_HELP_MSG] = 'apihelp-query+abuselog-param-filter-central';
		}
		return $params;
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	protected function getExamplesMessages() {
		return [
			'action=query&list=abuselog'
				=> 'apihelp-query+abuselog-example-1',
			'action=query&list=abuselog&afltitle=API'
				=> 'apihelp-query+abuselog-example-2',
		];
	}
}
PK       ! >b      Api/CheckMatch.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Api;

use LogEventsList;
use LogicException;
use LogPage;
use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiResult;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseLog;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
use MediaWiki\Json\FormatJson;
use MediaWiki\Revision\RevisionRecord;
use RecentChange;
use Wikimedia\ParamValidator\ParamValidator;

class CheckMatch extends ApiBase {

	/** @var RuleCheckerFactory */
	private $ruleCheckerFactory;

	/** @var AbuseFilterPermissionManager */
	private $afPermManager;

	/** @var VariablesBlobStore */
	private $afVariablesBlobStore;

	/** @var VariableGeneratorFactory */
	private $afVariableGeneratorFactory;

	/**
	 * @param ApiMain $main
	 * @param string $action
	 * @param RuleCheckerFactory $ruleCheckerFactory
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param VariablesBlobStore $afVariablesBlobStore
	 * @param VariableGeneratorFactory $afVariableGeneratorFactory
	 */
	public function __construct(
		ApiMain $main,
		$action,
		RuleCheckerFactory $ruleCheckerFactory,
		AbuseFilterPermissionManager $afPermManager,
		VariablesBlobStore $afVariablesBlobStore,
		VariableGeneratorFactory $afVariableGeneratorFactory
	) {
		parent::__construct( $main, $action );
		$this->ruleCheckerFactory = $ruleCheckerFactory;
		$this->afPermManager = $afPermManager;
		$this->afVariablesBlobStore = $afVariablesBlobStore;
		$this->afVariableGeneratorFactory = $afVariableGeneratorFactory;
	}

	/**
	 * @inheritDoc
	 */
	public function execute() {
		$performer = $this->getAuthority();
		$params = $this->extractRequestParams();
		$this->requireOnlyOneParameter( $params, 'vars', 'rcid', 'logid' );

		// "Anti-DoS"
		if ( !$this->afPermManager->canUseTestTools( $performer ) ) {
			$this->dieWithError( 'apierror-abusefilter-canttest', 'permissiondenied' );
		}

		$vars = null;
		if ( $params['vars'] ) {
			$pairs = FormatJson::decode( $params['vars'], true );
			$vars = VariableHolder::newFromArray( $pairs );
		} elseif ( $params['rcid'] ) {
			$rc = RecentChange::newFromId( $params['rcid'] );

			if ( !$rc ) {
				$this->dieWithError( [ 'apierror-nosuchrcid', $params['rcid'] ] );
			}

			$type = (int)$rc->getAttribute( 'rc_type' );
			$deletedValue = $rc->getAttribute( 'rc_deleted' );
			if (
				(
					$type === RC_LOG &&
					!LogEventsList::userCanBitfield(
						$deletedValue,
						LogPage::SUPPRESSED_ACTION | LogPage::SUPPRESSED_USER,
						$performer
					)
				) || (
					$type !== RC_LOG &&
					!RevisionRecord::userCanBitfield( $deletedValue, RevisionRecord::SUPPRESSED_ALL, $performer )
				)
			) {
				// T223654 - Same check as in AbuseFilterChangesList
				$this->dieWithError( 'apierror-permissiondenied-generic', 'deletedrc' );
			}

			$varGenerator = $this->afVariableGeneratorFactory->newRCGenerator( $rc, $this->getUser() );
			$vars = $varGenerator->getVars();
		} elseif ( $params['logid'] ) {
			$row = $this->getDB()->newSelectQueryBuilder()
				->select( '*' )
				->from( 'abuse_filter_log' )
				->where( [ 'afl_id' => $params['logid'] ] )
				->caller( __METHOD__ )
				->fetchRow();

			if ( !$row ) {
				$this->dieWithError( [ 'apierror-abusefilter-nosuchlogid', $params['logid'] ], 'nosuchlogid' );
			}

			// TODO: Replace with dependency injection once security patch is uploaded publicly.
			$afFilterLookup = AbuseFilterServices::getFilterLookup();
			$privacyLevel = $afFilterLookup->getFilter( $row->afl_filter_id, $row->afl_global )
				->getPrivacyLevel();
			$canSeeDetails = $this->afPermManager->canSeeLogDetailsForFilter( $performer, $privacyLevel );
			if ( !$canSeeDetails ) {
				$this->dieWithError( 'apierror-permissiondenied-generic', 'cannotseedetails' );
			}

			$visibility = SpecialAbuseLog::getEntryVisibilityForUser( $row, $performer, $this->afPermManager );
			if ( $visibility !== SpecialAbuseLog::VISIBILITY_VISIBLE ) {
				// T223654 - Same check as in SpecialAbuseLog. Both the visibility of the AbuseLog entry
				// and the corresponding revision are checked.
				$this->dieWithError( 'apierror-permissiondenied-generic', 'deletedabuselog' );
			}

			$vars = $this->afVariablesBlobStore->loadVarDump( $row );
		}
		if ( $vars === null ) {
			// @codeCoverageIgnoreStart
			throw new LogicException( 'Impossible.' );
			// @codeCoverageIgnoreEnd
		}

		$ruleChecker = $this->ruleCheckerFactory->newRuleChecker( $vars );
		if ( !$ruleChecker->checkSyntax( $params['filter'] )->isValid() ) {
			$this->dieWithError( 'apierror-abusefilter-badsyntax', 'badsyntax' );
		}

		$result = [
			ApiResult::META_BC_BOOLS => [ 'result' ],
			'result' => $ruleChecker->checkConditions( $params['filter'] )->getResult(),
		];

		$this->getResult()->addValue(
			null,
			$this->getModuleName(),
			$result
		);
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function getAllowedParams() {
		return [
			'filter' => [
				ParamValidator::PARAM_REQUIRED => true,
			],
			'vars' => null,
			'rcid' => [
				ParamValidator::PARAM_TYPE => 'integer'
			],
			'logid' => [
				ParamValidator::PARAM_TYPE => 'integer'
			],
		];
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	protected function getExamplesMessages() {
		return [
			'action=abusefiltercheckmatch&filter=!("autoconfirmed"%20in%20user_groups)&rcid=15'
				=> 'apihelp-abusefiltercheckmatch-example-1',
		];
	}
}
PK       ! &s!  !    Api/QueryAbuseFilters.phpnu Iw        <?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
 */

namespace MediaWiki\Extension\AbuseFilter\Api;

use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiQuery;
use MediaWiki\Api\ApiQueryBase;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Extension\AbuseFilter\FilterUtils;
use MediaWiki\Utils\MWTimestamp;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\TypeDef\IntegerDef;

/**
 * Query module to list abuse filter details.
 *
 * @copyright 2009 Alex Z. <mrzmanwiki AT gmail DOT com>
 * Based mostly on code by Bryan Tong Minh and Roan Kattouw
 *
 * @ingroup API
 * @ingroup Extensions
 */
class QueryAbuseFilters extends ApiQueryBase {

	/** @var AbuseFilterPermissionManager */
	private $afPermManager;

	/**
	 * @param ApiQuery $query
	 * @param string $moduleName
	 * @param AbuseFilterPermissionManager $afPermManager
	 */
	public function __construct(
		ApiQuery $query,
		$moduleName,
		AbuseFilterPermissionManager $afPermManager
	) {
		parent::__construct( $query, $moduleName, 'abf' );
		$this->afPermManager = $afPermManager;
	}

	/**
	 * @inheritDoc
	 */
	public function execute() {
		$this->checkUserRightsAny( 'abusefilter-view' );

		$params = $this->extractRequestParams();

		$prop = array_fill_keys( $params['prop'], true );
		$fld_id = isset( $prop['id'] );
		$fld_desc = isset( $prop['description'] );
		$fld_pattern = isset( $prop['pattern'] );
		$fld_actions = isset( $prop['actions'] );
		$fld_hits = isset( $prop['hits'] );
		$fld_comments = isset( $prop['comments'] );
		$fld_user = isset( $prop['lasteditor'] );
		$fld_time = isset( $prop['lastedittime'] );
		$fld_status = isset( $prop['status'] );
		$fld_private = isset( $prop['private'] );
		$fld_protected = isset( $prop['protected'] );

		$result = $this->getResult();

		$this->addTables( 'abuse_filter' );

		$this->addFields( 'af_id' );
		$this->addFields( 'af_hidden' );
		$this->addFieldsIf( 'af_hit_count', $fld_hits );
		$this->addFieldsIf( 'af_enabled', $fld_status );
		$this->addFieldsIf( 'af_deleted', $fld_status );
		$this->addFieldsIf( 'af_public_comments', $fld_desc );
		$this->addFieldsIf( 'af_pattern', $fld_pattern );
		$this->addFieldsIf( 'af_actions', $fld_actions );
		$this->addFieldsIf( 'af_comments', $fld_comments );
		if ( $fld_user ) {
			$this->addTables( 'actor' );
			$this->addFields( [ 'af_user_text' => 'actor_name' ] );
			$this->addJoinConds( [ 'actor' => [ 'JOIN', 'actor_id = af_actor' ] ] );
		}
		$this->addFieldsIf( 'af_timestamp', $fld_time );

		$this->addOption( 'LIMIT', $params['limit'] + 1 );

		$this->addWhereRange( 'af_id', $params['dir'], $params['startid'], $params['endid'] );

		if ( $params['show'] !== null ) {
			$show = array_fill_keys( $params['show'], true );

			/* Check for conflicting parameters. */
			if ( ( isset( $show['enabled'] ) && isset( $show['!enabled'] ) )
				|| ( isset( $show['deleted'] ) && isset( $show['!deleted'] ) )
				|| ( isset( $show['private'] ) && isset( $show['!private'] ) )
			) {
				$this->dieWithError( 'apierror-show' );
			}

			$dbr = $this->getDb();
			$this->addWhereIf( $dbr->expr( 'af_enabled', '=', 0 ), isset( $show['!enabled'] ) );
			$this->addWhereIf( $dbr->expr( 'af_enabled', '!=', 0 ), isset( $show['enabled'] ) );
			$this->addWhereIf( $dbr->expr( 'af_deleted', '=', 0 ), isset( $show['!deleted'] ) );
			$this->addWhereIf( $dbr->expr( 'af_deleted', '!=', 0 ), isset( $show['deleted'] ) );
			$this->addWhereIf(
				$dbr->bitAnd( 'af_hidden', Flags::FILTER_HIDDEN ) . ' = 0',
				isset( $show['!private'] )
			);
			$this->addWhereIf(
				$dbr->bitAnd( 'af_hidden', Flags::FILTER_HIDDEN ) . ' != 0',
				isset( $show['private'] )
			);
			$this->addWhereIf(
				$dbr->bitAnd( 'af_hidden', Flags::FILTER_USES_PROTECTED_VARS ) . ' != 0',
				isset( $show['!protected'] )
			);
			$this->addWhereIf(
				$dbr->bitAnd( 'af_hidden', Flags::FILTER_USES_PROTECTED_VARS ) . ' = 0',
				isset( $show['!protected'] )
			);
		}

		$res = $this->select( __METHOD__ );

		$showhidden = $this->afPermManager->canViewPrivateFilters( $this->getAuthority() );
		$showProtected = $this->afPermManager->canViewProtectedVariables( $this->getAuthority() );

		$count = 0;
		foreach ( $res as $row ) {
			$filterId = intval( $row->af_id );
			if ( ++$count > $params['limit'] ) {
				// We've had enough
				$this->setContinueEnumParameter( 'startid', $filterId );
				break;
			}
			$entry = [];
			if ( $fld_id ) {
				$entry['id'] = $filterId;
			}
			if ( $fld_desc ) {
				$entry['description'] = $row->af_public_comments;
			}
			if (
				$fld_pattern &&
				( !FilterUtils::isHidden( $row->af_hidden ) || $showhidden ) &&
				( !FilterUtils::isProtected( $row->af_hidden ) || $showProtected )
			) {
				$entry['pattern'] = $row->af_pattern;
			}
			if ( $fld_actions ) {
				$entry['actions'] = $row->af_actions;
			}
			if ( $fld_hits ) {
				$entry['hits'] = intval( $row->af_hit_count );
			}
			if (
				$fld_comments &&
				( !FilterUtils::isHidden( $row->af_hidden ) || $showhidden ) &&
				( !FilterUtils::isProtected( $row->af_hidden ) || $showProtected )
			) {
				$entry['comments'] = $row->af_comments;
			}
			if ( $fld_user ) {
				$entry['lasteditor'] = $row->af_user_text;
			}
			if ( $fld_time ) {
				$ts = new MWTimestamp( $row->af_timestamp );
				$entry['lastedittime'] = $ts->getTimestamp( TS_ISO_8601 );
			}
			if ( $fld_private && FilterUtils::isHidden( $row->af_hidden ) ) {
				$entry['private'] = '';
			}
			if ( $fld_protected && FilterUtils::isProtected( $row->af_hidden ) ) {
				$entry['protected'] = '';
			}
			if ( $fld_status ) {
				if ( $row->af_enabled ) {
					$entry['enabled'] = '';
				}
				if ( $row->af_deleted ) {
					$entry['deleted'] = '';
				}
			}
			if ( $entry ) {
				$fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $entry );
				if ( !$fit ) {
					$this->setContinueEnumParameter( 'startid', $filterId );
					break;
				}
			}
		}
		$result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'filter' );
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function getAllowedParams() {
		return [
			'startid' => [
				ParamValidator::PARAM_TYPE => 'integer'
			],
			'endid' => [
				ParamValidator::PARAM_TYPE => 'integer',
			],
			'dir' => [
				ParamValidator::PARAM_TYPE => [
					'older',
					'newer'
				],
				ParamValidator::PARAM_DEFAULT => 'newer',
				ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
			],
			'show' => [
				ParamValidator::PARAM_ISMULTI => true,
				ParamValidator::PARAM_TYPE => [
					'enabled',
					'!enabled',
					'deleted',
					'!deleted',
					'private',
					'!private',
					'protected',
					'!protected',
				],
			],
			'limit' => [
				ParamValidator::PARAM_DEFAULT => 10,
				ParamValidator::PARAM_TYPE => 'limit',
				IntegerDef::PARAM_MIN => 1,
				IntegerDef::PARAM_MAX => ApiBase::LIMIT_BIG1,
				IntegerDef::PARAM_MAX2 => ApiBase::LIMIT_BIG2
			],
			'prop' => [
				ParamValidator::PARAM_DEFAULT => 'id|description|actions|status',
				ParamValidator::PARAM_TYPE => [
					'id',
					'description',
					'pattern',
					'actions',
					'hits',
					'comments',
					'lasteditor',
					'lastedittime',
					'status',
					'private',
					'protected',
				],
				ParamValidator::PARAM_ISMULTI => true
			]
		];
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	protected function getExamplesMessages() {
		return [
			'action=query&list=abusefilters&abfshow=enabled|!private'
				=> 'apihelp-query+abusefilters-example-1',
			'action=query&list=abusefilters&abfprop=id|description|pattern'
				=> 'apihelp-query+abusefilters-example-2',
		];
	}
}
PK       !  Sr  r    FilterRunnerFactory.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagger;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesExecutorFactory;
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\Extension\AbuseFilter\Watcher\EmergencyWatcher;
use MediaWiki\Extension\AbuseFilter\Watcher\UpdateHitCountWatcher;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use Psr\Log\LoggerInterface;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\Stats\IBufferingStatsdDataFactory;
use Wikimedia\Stats\NullStatsdDataFactory;

class FilterRunnerFactory {
	public const SERVICE_NAME = 'AbuseFilterFilterRunnerFactory';

	/** @var AbuseFilterHookRunner */
	private $hookRunner;
	/** @var FilterProfiler */
	private $filterProfiler;
	/** @var ChangeTagger */
	private $changeTagger;
	/** @var FilterLookup */
	private $filterLookup;
	/** @var RuleCheckerFactory */
	private $ruleCheckerFactory;
	/** @var ConsequencesExecutorFactory */
	private $consExecutorFactory;
	/** @var AbuseLoggerFactory */
	private $abuseLoggerFactory;
	/** @var VariablesManager */
	private $varManager;
	/** @var VariableGeneratorFactory */
	private $varGeneratorFactory;
	/** @var EmergencyCache */
	private $emergencyCache;
	/** @var UpdateHitCountWatcher */
	private $updateHitCountWatcher;
	/** @var EmergencyWatcher */
	private $emergencyWatcher;
	/** @var BagOStuff */
	private $localCache;
	/** @var LoggerInterface */
	private $logger;
	/** @var LoggerInterface */
	private $editStashLogger;
	/** @var IBufferingStatsdDataFactory */
	private $statsdDataFactory;
	/** @var ServiceOptions */
	private $options;

	/**
	 * @param AbuseFilterHookRunner $hookRunner
	 * @param FilterProfiler $filterProfiler
	 * @param ChangeTagger $changeTagger
	 * @param FilterLookup $filterLookup
	 * @param RuleCheckerFactory $ruleCheckerFactory
	 * @param ConsequencesExecutorFactory $consExecutorFactory
	 * @param AbuseLoggerFactory $abuseLoggerFactory
	 * @param VariablesManager $varManager
	 * @param VariableGeneratorFactory $varGeneratorFactory
	 * @param EmergencyCache $emergencyCache
	 * @param UpdateHitCountWatcher $updateHitCountWatcher
	 * @param EmergencyWatcher $emergencyWatcher
	 * @param BagOStuff $localCache
	 * @param LoggerInterface $logger
	 * @param LoggerInterface $editStashLogger
	 * @param IBufferingStatsdDataFactory $statsdDataFactory
	 * @param ServiceOptions $options
	 */
	public function __construct(
		AbuseFilterHookRunner $hookRunner,
		FilterProfiler $filterProfiler,
		ChangeTagger $changeTagger,
		FilterLookup $filterLookup,
		RuleCheckerFactory $ruleCheckerFactory,
		ConsequencesExecutorFactory $consExecutorFactory,
		AbuseLoggerFactory $abuseLoggerFactory,
		VariablesManager $varManager,
		VariableGeneratorFactory $varGeneratorFactory,
		EmergencyCache $emergencyCache,
		UpdateHitCountWatcher $updateHitCountWatcher,
		EmergencyWatcher $emergencyWatcher,
		BagOStuff $localCache,
		LoggerInterface $logger,
		LoggerInterface $editStashLogger,
		IBufferingStatsdDataFactory $statsdDataFactory,
		ServiceOptions $options
	) {
		$this->hookRunner = $hookRunner;
		$this->filterProfiler = $filterProfiler;
		$this->changeTagger = $changeTagger;
		$this->filterLookup = $filterLookup;
		$this->ruleCheckerFactory = $ruleCheckerFactory;
		$this->consExecutorFactory = $consExecutorFactory;
		$this->abuseLoggerFactory = $abuseLoggerFactory;
		$this->varManager = $varManager;
		$this->varGeneratorFactory = $varGeneratorFactory;
		$this->emergencyCache = $emergencyCache;
		$this->updateHitCountWatcher = $updateHitCountWatcher;
		$this->emergencyWatcher = $emergencyWatcher;
		$this->localCache = $localCache;
		$this->logger = $logger;
		$this->editStashLogger = $editStashLogger;
		$this->statsdDataFactory = $statsdDataFactory;
		$this->options = $options;
	}

	/**
	 * @param User $user
	 * @param Title $title
	 * @param VariableHolder $vars
	 * @param string $group
	 * @return FilterRunner
	 */
	public function newRunner(
		User $user,
		Title $title,
		VariableHolder $vars,
		string $group
	): FilterRunner {
		// TODO Add a method to this class taking these as params? Add a hook for custom watchers
		$watchers = [ $this->updateHitCountWatcher, $this->emergencyWatcher ];
		return new FilterRunner(
			$this->hookRunner,
			$this->filterProfiler,
			$this->changeTagger,
			$this->filterLookup,
			$this->ruleCheckerFactory,
			$this->consExecutorFactory,
			$this->abuseLoggerFactory,
			$this->varManager,
			$this->varGeneratorFactory,
			$this->emergencyCache,
			$watchers,
			new EditStashCache(
				$this->localCache,
				// Bots do not use edit stashing, so avoid distorting the stats
				$user->isBot() ? new NullStatsdDataFactory() : $this->statsdDataFactory,
				$this->varManager,
				$this->editStashLogger,
				$title,
				$group
			),
			$this->logger,
			$this->options,
			$user,
			$title,
			$vars,
			$group
		);
	}
}
PK       ! RNS(  S(    AbuseLogger.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use InvalidArgumentException;
use ManualLogEntry;
use MediaWiki\CheckUser\Hooks;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentityValue;
use Profiler;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\LBFactory;
use Wikimedia\ScopedCallback;

class AbuseLogger {
	public const CONSTRUCTOR_OPTIONS = [
		'AbuseFilterLogIP',
		'AbuseFilterNotifications',
		'AbuseFilterNotificationsPrivate',
	];

	/** @var Title */
	private $title;
	/** @var User */
	private $user;
	/** @var VariableHolder */
	private $vars;
	/** @var string */
	private $action;

	/** @var CentralDBManager */
	private $centralDBManager;
	/** @var FilterLookup */
	private $filterLookup;
	/** @var VariablesBlobStore */
	private $varBlobStore;
	/** @var VariablesManager */
	private $varManager;
	/** @var EditRevUpdater */
	private $editRevUpdater;
	/** @var LBFactory */
	private $lbFactory;
	/** @var ServiceOptions */
	private $options;
	/** @var string */
	private $wikiID;
	/** @var string */
	private $requestIP;

	/**
	 * @param CentralDBManager $centralDBManager
	 * @param FilterLookup $filterLookup
	 * @param VariablesBlobStore $varBlobStore
	 * @param VariablesManager $varManager
	 * @param EditRevUpdater $editRevUpdater
	 * @param LBFactory $lbFactory
	 * @param ServiceOptions $options
	 * @param string $wikiID
	 * @param string $requestIP
	 * @param Title $title
	 * @param User $user
	 * @param VariableHolder $vars
	 */
	public function __construct(
		CentralDBManager $centralDBManager,
		FilterLookup $filterLookup,
		VariablesBlobStore $varBlobStore,
		VariablesManager $varManager,
		EditRevUpdater $editRevUpdater,
		LBFactory $lbFactory,
		ServiceOptions $options,
		string $wikiID,
		string $requestIP,
		Title $title,
		User $user,
		VariableHolder $vars
	) {
		if ( !$vars->varIsSet( 'action' ) ) {
			throw new InvalidArgumentException( "The 'action' variable is not set." );
		}
		$this->centralDBManager = $centralDBManager;
		$this->filterLookup = $filterLookup;
		$this->varBlobStore = $varBlobStore;
		$this->varManager = $varManager;
		$this->editRevUpdater = $editRevUpdater;
		$this->lbFactory = $lbFactory;
		$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
		$this->options = $options;
		$this->wikiID = $wikiID;
		$this->requestIP = $requestIP;
		$this->title = $title;
		$this->user = $user;
		$this->vars = $vars;
		$this->action = $vars->getComputedVariable( 'action' )->toString();
	}

	/**
	 * Create and publish log entries for taken actions
	 *
	 * @param array[] $actionsTaken
	 * @return array Shape is [ 'local' => int[], 'global' => int[] ], IDs of logged filters
	 * @phan-return array{local:int[],global:int[]}
	 */
	public function addLogEntries( array $actionsTaken ): array {
		$dbw = $this->lbFactory->getPrimaryDatabase();
		$logTemplate = $this->buildLogTemplate();
		$centralLogTemplate = [
			'afl_wiki' => $this->wikiID,
		];

		$logRows = [];
		$centralLogRows = [];
		$loggedLocalFilters = [];
		$loggedGlobalFilters = [];

		foreach ( $actionsTaken as $filter => $actions ) {
			[ $filterID, $global ] = GlobalNameUtils::splitGlobalName( $filter );
			$thisLog = $logTemplate;
			$thisLog['afl_filter_id'] = $filterID;
			$thisLog['afl_global'] = (int)$global;
			$thisLog['afl_actions'] = implode( ',', $actions );

			// Don't log if we were only throttling.
			// TODO This check should be removed or rewritten using Consequence objects
			if ( $thisLog['afl_actions'] !== 'throttle' ) {
				$logRows[] = $thisLog;
				// Global logging
				if ( $global ) {
					$centralLog = $thisLog + $centralLogTemplate;
					$centralLog['afl_filter_id'] = $filterID;
					$centralLog['afl_global'] = 0;
					$centralLog['afl_title'] = $this->title->getPrefixedText();
					$centralLog['afl_namespace'] = 0;

					$centralLogRows[] = $centralLog;
					$loggedGlobalFilters[] = $filterID;
				} else {
					$loggedLocalFilters[] = $filterID;
				}
			}
		}

		if ( !count( $logRows ) ) {
			return [ 'local' => [], 'global' => [] ];
		}

		$localLogIDs = $this->insertLocalLogEntries( $logRows, $dbw );

		$globalLogIDs = [];
		if ( count( $loggedGlobalFilters ) ) {
			$fdb = $this->centralDBManager->getConnection( DB_PRIMARY );
			$globalLogIDs = $this->insertGlobalLogEntries( $centralLogRows, $fdb );
		}

		$this->editRevUpdater->setLogIdsForTarget(
			$this->title,
			[ 'local' => $localLogIDs, 'global' => $globalLogIDs ]
		);

		return [ 'local' => $loggedLocalFilters, 'global' => $loggedGlobalFilters ];
	}

	/**
	 * Creates a template to use for logging taken actions
	 *
	 * @return array
	 */
	private function buildLogTemplate(): array {
		// If $this->user isn't safe to load (e.g. a failure during
		// AbortAutoAccount), create a dummy anonymous user instead.
		$user = $this->user->isSafeToLoad() ? $this->user : new User;
		// Create a template
		$logTemplate = [
			'afl_user' => $user->getId(),
			'afl_user_text' => $user->getName(),
			'afl_timestamp' => $this->lbFactory->getReplicaDatabase()->timestamp(),
			'afl_namespace' => $this->title->getNamespace(),
			'afl_title' => $this->title->getDBkey(),
			'afl_action' => $this->action,
			'afl_ip' => $this->options->get( 'AbuseFilterLogIP' ) ? $this->requestIP : ''
		];
		// Hack to avoid revealing IPs of people creating accounts
		if ( ( $this->action === 'createaccount' || $this->action === 'autocreateaccount' ) && !$user->getId() ) {
			$logTemplate['afl_user_text'] = $this->vars->getComputedVariable( 'accountname' )->toString();
		}
		return $logTemplate;
	}

	/**
	 * @param array $data
	 * @return ManualLogEntry
	 */
	private function newLocalLogEntryFromData( array $data ): ManualLogEntry {
		// Give grep a chance to find the usages:
		// logentry-abusefilter-hit
		$entry = new ManualLogEntry( 'abusefilter', 'hit' );
		$user = new UserIdentityValue( $data['afl_user'], $data['afl_user_text'] );
		$entry->setPerformer( $user );
		$entry->setTarget( $this->title );
		$filterName = GlobalNameUtils::buildGlobalName(
			$data['afl_filter_id'],
			$data['afl_global'] === 1
		);
		// Additional info
		$entry->setParameters( [
			'action' => $data['afl_action'],
			'filter' => $filterName,
			'actions' => $data['afl_actions'],
			'log' => $data['afl_id'],
		] );
		return $entry;
	}

	/**
	 * @param array[] $logRows
	 * @param IDatabase $dbw
	 * @return int[]
	 */
	private function insertLocalLogEntries( array $logRows, IDatabase $dbw ): array {
		$varDump = $this->varBlobStore->storeVarDump( $this->vars );

		$loggedIDs = [];
		foreach ( $logRows as $data ) {
			$data['afl_var_dump'] = $varDump;
			$dbw->newInsertQueryBuilder()
				->insertInto( 'abuse_filter_log' )
				->row( $data )
				->caller( __METHOD__ )
				->execute();
			$loggedIDs[] = $data['afl_id'] = $dbw->insertId();

			// Send data to CheckUser if installed and we
			// aren't already sending a notification to recentchanges
			if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' )
				&& strpos( $this->options->get( 'AbuseFilterNotifications' ), 'rc' ) === false
			) {
				$entry = $this->newLocalLogEntryFromData( $data );
				$user = $entry->getPerformerIdentity();
				// Invert the hack from ::buildLogTemplate because CheckUser attempts
				// to assign an actor id to the non-existing user
				if (
					( $this->action === 'createaccount' || $this->action === 'autocreateaccount' )
					&& !$user->getId()
				) {
					$entry->setPerformer( new UserIdentityValue( 0, $this->requestIP ) );
				}
				$rc = $entry->getRecentChange();
				// We need to send the entries on POSTSEND to ensure that the user definitely exists, as a temporary
				// account being created by this edit may not exist until after AbuseFilter processes the edit.
				DeferredUpdates::addCallableUpdate( static function () use ( $rc ) {
					// Silence the TransactionProfiler warnings for performing write queries (T359648).
					$trxProfiler = Profiler::instance()->getTransactionProfiler();
					$scope = $trxProfiler->silenceForScope( $trxProfiler::EXPECTATION_REPLICAS_ONLY );
					Hooks::updateCheckUserData( $rc );
					ScopedCallback::consume( $scope );
				} );
			}

			if ( $this->options->get( 'AbuseFilterNotifications' ) !== false ) {
				$filterID = $data['afl_filter_id'];
				$global = $data['afl_global'];
				if (
					!$this->options->get( 'AbuseFilterNotificationsPrivate' ) &&
					$this->filterLookup->getFilter( $filterID, $global )->isHidden()
				) {
					continue;
				}
				$entry = $this->newLocalLogEntryFromData( $data );
				$this->publishEntry( $dbw, $entry );
			}
		}
		return $loggedIDs;
	}

	/**
	 * @param array[] $centralLogRows
	 * @param IDatabase $fdb
	 * @return int[]
	 */
	private function insertGlobalLogEntries( array $centralLogRows, IDatabase $fdb ): array {
		$this->varManager->computeDBVars( $this->vars );
		$globalVarDump = $this->varBlobStore->storeVarDump( $this->vars, true );
		foreach ( $centralLogRows as $index => $data ) {
			$centralLogRows[$index]['afl_var_dump'] = $globalVarDump;
		}

		$loggedIDs = [];
		foreach ( $centralLogRows as $row ) {
			$fdb->newInsertQueryBuilder()
				->insertInto( 'abuse_filter_log' )
				->row( $row )
				->caller( __METHOD__ )
				->execute();
			$loggedIDs[] = $fdb->insertId();
		}
		return $loggedIDs;
	}

	/**
	 * Like ManualLogEntry::publish, but doesn't require an ID (which we don't have) and skips the
	 * tagging part
	 *
	 * @param IDatabase $dbw To cancel the callback if the log insertion fails
	 * @param ManualLogEntry $entry
	 */
	private function publishEntry( IDatabase $dbw, ManualLogEntry $entry ): void {
		DeferredUpdates::addCallableUpdate(
			function () use ( $entry ) {
				$rc = $entry->getRecentChange();
				$to = $this->options->get( 'AbuseFilterNotifications' );

				if ( $to === 'rc' || $to === 'rcandudp' ) {
					$rc->save( $rc::SEND_NONE );
				}
				if ( $to === 'udp' || $to === 'rcandudp' ) {
					$rc->notifyRCFeeds();
				}
			},
			DeferredUpdates::POSTSEND,
			$dbw
		);
	}

}
PK       ! Ov      FilterUtils.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Extension\AbuseFilter\Filter\Flags;

/**
 * @internal
 */
class FilterUtils {
	/**
	 * Given a bitmask of privacy levels, return if the hidden flag is set
	 *
	 * @param int $privacyLevel
	 * @return bool
	 */
	public static function isHidden( int $privacyLevel ) {
		return (bool)( Flags::FILTER_HIDDEN & $privacyLevel );
	}

	/**
	 * Given a bitmask, return if the protected flag is set
	 *
	 * @param int $privacyLevel
	 * @return bool
	 */
	public static function isProtected( int $privacyLevel ) {
		return (bool)( Flags::FILTER_USES_PROTECTED_VARS & $privacyLevel );
	}
}
PK       ! ikӠ      BlockAutopromoteStore.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use ManualLogEntry;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\UserIdentity;
use Psr\Log\LoggerInterface;
use Wikimedia\ObjectCache\BagOStuff;

/**
 * Class responsible for storing and retrieving blockautopromote status
 */
class BlockAutopromoteStore {

	public const SERVICE_NAME = 'AbuseFilterBlockAutopromoteStore';

	/**
	 * @var BagOStuff
	 */
	private $store;

	/**
	 * @var LoggerInterface
	 */
	private $logger;

	/** @var FilterUser */
	private $filterUser;

	/**
	 * @param BagOStuff $store
	 * @param LoggerInterface $logger
	 * @param FilterUser $filterUser
	 */
	public function __construct( BagOStuff $store, LoggerInterface $logger, FilterUser $filterUser ) {
		$this->store = $store;
		$this->logger = $logger;
		$this->filterUser = $filterUser;
	}

	/**
	 * Gets the autopromotion block status for the given user
	 *
	 * @param UserIdentity $target
	 * @return int
	 */
	public function getAutoPromoteBlockStatus( UserIdentity $target ): int {
		return (int)$this->store->get( $this->getAutoPromoteBlockKey( $target ) );
	}

	/**
	 * Blocks autopromotion for the given user
	 *
	 * @param UserIdentity $target
	 * @param string $msg The message to show in the log
	 * @param int $duration Duration for which autopromotion is blocked, in seconds
	 * @return bool True on success, false on failure
	 */
	public function blockAutoPromote( UserIdentity $target, string $msg, int $duration ): bool {
		if ( !$this->store->set(
			$this->getAutoPromoteBlockKey( $target ),
			1,
			$duration
		) ) {
			// Failed to set key
			$this->logger->warning(
				'Failed to block autopromotion for {target}. Error: {error}',
				[
					'target' => $target->getName(),
					'error' => $this->store->getLastError(),
				]
			);
			return false;
		}

		$logEntry = new ManualLogEntry( 'rights', 'blockautopromote' );
		$logEntry->setPerformer( $this->filterUser->getUserIdentity() );
		$logEntry->setTarget( new TitleValue( NS_USER, $target->getName() ) );

		$logEntry->setParameters( [
			'7::duration' => $duration,
			// These parameters are unused in our message, but some parts of the code check for them
			'4::oldgroups' => [],
			'5::newgroups' => []
		] );
		$logEntry->setComment( $msg );
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
			// FIXME Remove this check once ManualLogEntry is servicified (T253717)
			// @codeCoverageIgnoreStart
			$logEntry->publish( $logEntry->insert() );
			// @codeCoverageIgnoreEnd
		}

		return true;
	}

	/**
	 * Unblocks autopromotion for the given user
	 *
	 * @param UserIdentity $target
	 * @param UserIdentity $performer
	 * @param string $msg The message to show in the log
	 * @return bool True on success, false on failure
	 */
	public function unblockAutopromote( UserIdentity $target, UserIdentity $performer, string $msg ): bool {
		// Immediately expire (delete) the key, failing if it does not exist
		$expireAt = time() - BagOStuff::TTL_HOUR;
		if ( !$this->store->changeTTL(
			$this->getAutoPromoteBlockKey( $target ),
			$expireAt
		) ) {
			// Key did not exist to begin with; nothing to do
			return false;
		}

		$logEntry = new ManualLogEntry( 'rights', 'restoreautopromote' );
		$logEntry->setTarget( new TitleValue( NS_USER, $target->getName() ) );
		$logEntry->setComment( $msg );
		// These parameters are unused in our message, but some parts of the code check for them
		$logEntry->setParameters( [
			'4::oldgroups' => [],
			'5::newgroups' => []
		] );
		$logEntry->setPerformer( $performer );
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
			// FIXME Remove this check once ManualLogEntry is servicified (T253717)
			// @codeCoverageIgnoreStart
			$logEntry->publish( $logEntry->insert() );
			// @codeCoverageIgnoreEnd
		}

		return true;
	}

	/**
	 * @param UserIdentity $target
	 * @return string
	 */
	private function getAutoPromoteBlockKey( UserIdentity $target ): string {
		return $this->store->makeKey( 'abusefilter', 'block-autopromote', $target->getId() );
	}
}
PK       ! 2  2    FilterRunner.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use InvalidArgumentException;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagger;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesExecutorFactory;
use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter;
use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Extension\AbuseFilter\Parser\FilterEvaluator;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
use MediaWiki\Extension\AbuseFilter\Variables\LazyVariableComputer;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Extension\AbuseFilter\Watcher\Watcher;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use Psr\Log\LoggerInterface;

/**
 * This class contains the logic for executing abuse filters and their actions. The entry points are
 * run() and runForStash(). Note that run() can only be executed once on a given instance.
 * @internal Not stable yet
 */
class FilterRunner {
	public const CONSTRUCTOR_OPTIONS = [
		'AbuseFilterValidGroups',
		'AbuseFilterCentralDB',
		'AbuseFilterIsCentral',
		'AbuseFilterConditionLimit',
	];

	/** @var AbuseFilterHookRunner */
	private $hookRunner;
	/** @var FilterProfiler */
	private $filterProfiler;
	/** @var ChangeTagger */
	private $changeTagger;
	/** @var FilterLookup */
	private $filterLookup;
	/** @var RuleCheckerFactory */
	private $ruleCheckerFactory;
	/** @var ConsequencesExecutorFactory */
	private $consExecutorFactory;
	/** @var AbuseLoggerFactory */
	private $abuseLoggerFactory;
	/** @var EmergencyCache */
	private $emergencyCache;
	/** @var Watcher[] */
	private $watchers;
	/** @var EditStashCache */
	private $stashCache;
	/** @var LoggerInterface */
	private $logger;
	/** @var VariablesManager */
	private $varManager;
	/** @var VariableGeneratorFactory */
	private $varGeneratorFactory;
	/** @var ServiceOptions */
	private $options;

	/**
	 * @var FilterEvaluator
	 */
	private $ruleChecker;

	/**
	 * @var User The user who performed the action being filtered
	 */
	private $user;
	/**
	 * @var Title The title where the action being filtered was performed
	 */
	private $title;
	/**
	 * @var VariableHolder The variables for the current action
	 */
	private $vars;
	/**
	 * @var string The group of filters to check (as defined in $wgAbuseFilterValidGroups)
	 */
	private $group;
	/**
	 * @var string The action we're filtering
	 */
	private $action;

	/**
	 * @param AbuseFilterHookRunner $hookRunner
	 * @param FilterProfiler $filterProfiler
	 * @param ChangeTagger $changeTagger
	 * @param FilterLookup $filterLookup
	 * @param RuleCheckerFactory $ruleCheckerFactory
	 * @param ConsequencesExecutorFactory $consExecutorFactory
	 * @param AbuseLoggerFactory $abuseLoggerFactory
	 * @param VariablesManager $varManager
	 * @param VariableGeneratorFactory $varGeneratorFactory
	 * @param EmergencyCache $emergencyCache
	 * @param Watcher[] $watchers
	 * @param EditStashCache $stashCache
	 * @param LoggerInterface $logger
	 * @param ServiceOptions $options
	 * @param User $user
	 * @param Title $title
	 * @param VariableHolder $vars
	 * @param string $group
	 * @throws InvalidArgumentException If $group is invalid or the 'action' variable is unset
	 */
	public function __construct(
		AbuseFilterHookRunner $hookRunner,
		FilterProfiler $filterProfiler,
		ChangeTagger $changeTagger,
		FilterLookup $filterLookup,
		RuleCheckerFactory $ruleCheckerFactory,
		ConsequencesExecutorFactory $consExecutorFactory,
		AbuseLoggerFactory $abuseLoggerFactory,
		VariablesManager $varManager,
		VariableGeneratorFactory $varGeneratorFactory,
		EmergencyCache $emergencyCache,
		array $watchers,
		EditStashCache $stashCache,
		LoggerInterface $logger,
		ServiceOptions $options,
		User $user,
		Title $title,
		VariableHolder $vars,
		string $group
	) {
		$this->hookRunner = $hookRunner;
		$this->filterProfiler = $filterProfiler;
		$this->changeTagger = $changeTagger;
		$this->filterLookup = $filterLookup;
		$this->ruleCheckerFactory = $ruleCheckerFactory;
		$this->consExecutorFactory = $consExecutorFactory;
		$this->abuseLoggerFactory = $abuseLoggerFactory;
		$this->varManager = $varManager;
		$this->varGeneratorFactory = $varGeneratorFactory;
		$this->emergencyCache = $emergencyCache;
		$this->watchers = $watchers;
		$this->stashCache = $stashCache;
		$this->logger = $logger;

		$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
		if ( !in_array( $group, $options->get( 'AbuseFilterValidGroups' ), true ) ) {
			throw new InvalidArgumentException( "Group $group is not a valid group" );
		}
		$this->options = $options;

		if ( !$vars->varIsSet( 'action' ) ) {
			throw new InvalidArgumentException( "The 'action' variable is not set." );
		}
		$this->user = $user;
		$this->title = $title;
		$this->vars = $vars;
		$this->group = $group;
		$this->action = $vars->getComputedVariable( 'action' )->toString();
	}

	/**
	 * Inits variables and parser right before running
	 */
	private function init() {
		// Add vars from extensions
		$this->hookRunner->onAbuseFilter_filterAction(
			$this->vars,
			$this->title
		);
		$this->hookRunner->onAbuseFilterAlterVariables(
			$this->vars,
			$this->title,
			$this->user
		);
		$generator = $this->varGeneratorFactory->newGenerator( $this->vars );
		$this->vars = $generator->addGenericVars()->getVariableHolder();
		$this->ruleChecker = $this->ruleCheckerFactory->newRuleChecker( $this->vars );
	}

	/**
	 * The main entry point of this class. This method runs all filters and takes their consequences.
	 *
	 * @param bool $allowStash Whether we are allowed to check the cache to see if there's a cached
	 *  result of a previous execution for the same edit.
	 * @return Status Good if no action has been taken, a fatal otherwise.
	 */
	public function run( $allowStash = true ): Status {
		$this->init();

		$skipReasons = [];
		$shouldFilter = $this->hookRunner->onAbuseFilterShouldFilterAction(
			$this->vars, $this->title, $this->user, $skipReasons
		);
		if ( !$shouldFilter ) {
			$this->logger->info(
				'Skipping action {action}. Reasons provided: {reasons}',
				[ 'action' => $this->action, 'reasons' => implode( ', ', $skipReasons ) ]
			);
			return Status::newGood();
		}

		$useStash = $allowStash && $this->action === 'edit';

		$runnerData = null;
		if ( $useStash ) {
			$cacheData = $this->stashCache->seek( $this->vars );
			if ( $cacheData !== false ) {
				// Use cached vars (T176291) and profiling data (T191430)
				$this->vars = VariableHolder::newFromArray( $cacheData['vars'] );
				$runnerData = RunnerData::fromArray( $cacheData['data'] );
			}
		}

		$runnerData ??= $this->checkAllFiltersInternal();

		DeferredUpdates::addCallableUpdate( function () use ( $runnerData ) {
			$this->profileExecution( $runnerData );
			$this->updateEmergencyCache( $runnerData->getMatchesMap() );
		} );

		// TODO: inject the action specifier to avoid this
		$accountname = $this->varManager->getVar(
			$this->vars,
			'accountname',
			VariablesManager::GET_BC
		)->toNative();
		$spec = new ActionSpecifier(
			$this->action,
			$this->title,
			$this->user,
			$this->user->getRequest()->getIP(),
			$accountname
		);

		// Tag the action if the condition limit was hit
		if ( $runnerData->getTotalConditions() > $this->options->get( 'AbuseFilterConditionLimit' ) ) {
			$this->changeTagger->addConditionsLimitTag( $spec );
		}

		$matchedFilters = $runnerData->getMatchedFilters();

		if ( count( $matchedFilters ) === 0 ) {
			return Status::newGood();
		}

		$executor = $this->consExecutorFactory->newExecutor( $spec, $this->vars );
		$status = $executor->executeFilterActions( $matchedFilters );
		$actionsTaken = $status->getValue();

		// Note, it's important that we create an AbuseLogger now, after all lazy-loaded variables
		// requested by active filters have been computed
		$abuseLogger = $this->abuseLoggerFactory->newLogger( $this->title, $this->user, $this->vars );
		[
			'local' => $loggedLocalFilters,
			'global' => $loggedGlobalFilters
		] = $abuseLogger->addLogEntries( $actionsTaken );

		foreach ( $this->watchers as $watcher ) {
			$watcher->run( $loggedLocalFilters, $loggedGlobalFilters, $this->group );
		}

		return $status;
	}

	/**
	 * Similar to run(), but runs in "stash" mode, which means filters are executed, no actions are
	 *  taken, and the result is saved in cache to be later reused. This can only be used for edits,
	 *  and not doing so will throw.
	 *
	 * @throws InvalidArgumentException
	 * @return Status Always a good status, since we're only saving data.
	 */
	public function runForStash(): Status {
		if ( $this->action !== 'edit' ) {
			throw new InvalidArgumentException(
				__METHOD__ . " can only be called for edits, called for action {$this->action}."
			);
		}

		$this->init();

		$skipReasons = [];
		$shouldFilter = $this->hookRunner->onAbuseFilterShouldFilterAction(
			$this->vars, $this->title, $this->user, $skipReasons
		);
		if ( !$shouldFilter ) {
			// Don't log it yet
			return Status::newGood();
		}

		// XXX: We need a copy here because the cache key is computed
		// from the variables, but some variables can be loaded lazily
		// which would store the data with a key distinct from that
		// computed by seek() in ::run().
		// TODO: Find better way to generate the cache key.
		$origVars = clone $this->vars;

		$runnerData = $this->checkAllFiltersInternal();
		// Save the filter stash result and do nothing further
		$cacheData = [
			'vars' => $this->varManager->dumpAllVars( $this->vars ),
			'data' => $runnerData->toArray(),
		];

		$this->stashCache->store( $origVars, $cacheData );

		return Status::newGood();
	}

	/**
	 * Run all filters and return information about matches and profiling
	 *
	 * @return RunnerData
	 */
	private function checkAllFiltersInternal(): RunnerData {
		// Ensure there's no extra time leftover
		LazyVariableComputer::$profilingExtraTime = 0;

		$data = new RunnerData();

		foreach ( $this->filterLookup->getAllActiveFiltersInGroup( $this->group, false ) as $filter ) {
			[ $status, $timeTaken ] = $this->checkFilter( $filter );
			$data->record( $filter->getID(), false, $status, $timeTaken );
		}

		if ( $this->options->get( 'AbuseFilterCentralDB' ) && !$this->options->get( 'AbuseFilterIsCentral' ) ) {
			foreach ( $this->filterLookup->getAllActiveFiltersInGroup( $this->group, true ) as $filter ) {
				[ $status, $timeTaken ] = $this->checkFilter( $filter, true );
				$data->record( $filter->getID(), true, $status, $timeTaken );
			}
		}

		return $data;
	}

	/**
	 * Returns an associative array of filters which were tripped
	 *
	 * @internal BC method
	 * @return bool[] Map of (filter ID => bool)
	 */
	public function checkAllFilters(): array {
		$this->init();
		return $this->checkAllFiltersInternal()->getMatchesMap();
	}

	/**
	 * Check the conditions of a single filter, and profile it
	 *
	 * @param ExistingFilter $filter
	 * @param bool $global
	 * @return array [ status, time taken ]
	 * @phan-return array{0:\MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerStatus,1:float}
	 */
	private function checkFilter( ExistingFilter $filter, bool $global = false ): array {
		$filterName = GlobalNameUtils::buildGlobalName( $filter->getID(), $global );

		$startTime = microtime( true );
		$origExtraTime = LazyVariableComputer::$profilingExtraTime;

		$status = $this->ruleChecker->checkConditions( $filter->getRules(), $filterName );

		$actualExtra = LazyVariableComputer::$profilingExtraTime - $origExtraTime;
		$timeTaken = 1000 * ( microtime( true ) - $startTime - $actualExtra );

		return [ $status, $timeTaken ];
	}

	/**
	 * @param RunnerData $data
	 */
	private function profileExecution( RunnerData $data ) {
		$allFilters = $data->getAllFilters();
		$matchedFilters = $data->getMatchedFilters();
		$this->filterProfiler->recordRuntimeProfilingResult(
			count( $allFilters ),
			$data->getTotalConditions(),
			$data->getTotalRuntime()
		);
		$this->filterProfiler->recordPerFilterProfiling( $this->title, $data->getProfilingData() );
		$this->filterProfiler->recordStats(
			$this->group,
			$data->getTotalConditions(),
			$data->getTotalRuntime(),
			(bool)$matchedFilters
		);
	}

	/**
	 * @param bool[] $matches
	 */
	private function updateEmergencyCache( array $matches ): void {
		$filters = $this->emergencyCache->getFiltersToCheckInGroup( $this->group );
		foreach ( $filters as $filter ) {
			if ( array_key_exists( "$filter", $matches ) ) {
				$this->emergencyCache->incrementForFilter( $filter, $matches["$filter"] );
			}
		}
	}
}
PK       ! B      FilterImporter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use LogicException;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
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\Json\FormatJson;

/**
 * This class allows encoding filters to (and decoding from) a string format that can be used
 * to export them to another wiki.
 *
 * @internal
 * @note Callers should NOT rely on the output format, as it may vary
 */
class FilterImporter {
	public const SERVICE_NAME = 'AbuseFilterFilterImporter';

	public const CONSTRUCTOR_OPTIONS = [
		'AbuseFilterValidGroups',
		'AbuseFilterIsCentral',
	];

	private const TEMPLATE_KEYS = [
		'rules',
		'name',
		'comments',
		'group',
		'actions',
		'enabled',
		'deleted',
		'privacylevel',
		'global'
	];

	/** @var ServiceOptions */
	private $options;

	/** @var ConsequencesRegistry */
	private $consequencesRegistry;

	/**
	 * @param ServiceOptions $options
	 * @param ConsequencesRegistry $consequencesRegistry
	 */
	public function __construct( ServiceOptions $options, ConsequencesRegistry $consequencesRegistry ) {
		$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
		$this->options = $options;
		$this->consequencesRegistry = $consequencesRegistry;
	}

	/**
	 * @param Filter $filter
	 * @param array $actions
	 * @return string
	 */
	public function encodeData( Filter $filter, array $actions ): string {
		$data = [
			'rules' => $filter->getRules(),
			'name' => $filter->getName(),
			'comments' => $filter->getComments(),
			'group' => $filter->getGroup(),
			'actions' => $filter->getActions(),
			'enabled' => $filter->isEnabled(),
			'deleted' => $filter->isDeleted(),
			'privacylevel' => $filter->getPrivacyLevel(),
			'global' => $filter->isGlobal()
		];
		// @codeCoverageIgnoreStart
		if ( array_keys( $data ) !== self::TEMPLATE_KEYS ) {
			// Sanity
			throw new LogicException( 'Bad keys' );
		}
		// @codeCoverageIgnoreEnd
		return FormatJson::encode( [ 'data' => $data, 'actions' => $actions ] );
	}

	/**
	 * @param string $rawData
	 * @return Filter
	 * @throws InvalidImportDataException
	 */
	public function decodeData( string $rawData ): Filter {
		$validGroups = $this->options->get( 'AbuseFilterValidGroups' );
		$globalFiltersEnabled = $this->options->get( 'AbuseFilterIsCentral' );

		$data = FormatJson::decode( $rawData );
		if ( !$this->isValidImportData( $data ) ) {
			throw new InvalidImportDataException( $rawData );
		}
		[ 'data' => $filterData, 'actions' => $actions ] = wfObjectToArray( $data );

		return new MutableFilter(
			new Specs(
				$filterData['rules'],
				$filterData['comments'],
				$filterData['name'],
				array_keys( $actions ),
				// Keep the group only if it exists on this wiki
				in_array( $filterData['group'], $validGroups, true ) ? $filterData['group'] : 'default'
			),
			new Flags(
				(bool)$filterData['enabled'],
				(bool)$filterData['deleted'],
				(int)$filterData['privacylevel'],
				// And also make it global only if global filters are enabled here
				$filterData['global'] && $globalFiltersEnabled
			),
			$actions,
			new LastEditInfo(
				0,
				'',
				''
			)
		);
	}

	/**
	 * Note: this doesn't check if parameters are valid etc., but only if the shape of the object is right.
	 *
	 * @param mixed $data Already decoded
	 * @return bool
	 */
	private function isValidImportData( $data ): bool {
		if ( !is_object( $data ) ) {
			return false;
		}

		$arr = get_object_vars( $data );

		$expectedKeys = [ 'data' => true, 'actions' => true ];
		if ( count( $arr ) !== count( $expectedKeys ) || array_diff_key( $arr, $expectedKeys ) ) {
			return false;
		}

		if ( !is_object( $arr['data'] ) || !( is_object( $arr['actions'] ) || $arr['actions'] === [] ) ) {
			return false;
		}

		if ( array_keys( get_object_vars( $arr['data'] ) ) !== self::TEMPLATE_KEYS ) {
			return false;
		}

		$allActions = $this->consequencesRegistry->getAllActionNames();
		foreach ( $arr['actions'] as $action => $params ) {
			if ( !in_array( $action, $allActions, true ) || !is_array( $params ) ) {
				return false;
			}
		}

		return true;
	}
}
PK       ! Ӳ    !  ChangeTags/ChangeTagValidator.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\ChangeTags;

use ChangeTags;
use MediaWiki\Status\Status;

/**
 * Service for testing whether filters can tag edits and other changes
 * with a specific tag
 * @todo Use DI when available in core (T245964)
 */
class ChangeTagValidator {

	public const SERVICE_NAME = 'AbuseFilterChangeTagValidator';

	/** @var ChangeTagsManager */
	private $changeTagsManager;

	/**
	 * @param ChangeTagsManager $changeTagsManager
	 */
	public function __construct( ChangeTagsManager $changeTagsManager ) {
		$this->changeTagsManager = $changeTagsManager;
	}

	/**
	 * Check whether a filter is allowed to use a tag
	 *
	 * @param string $tag Tag name
	 * @return Status
	 */
	public function validateTag( string $tag ): Status {
		$tagNameStatus = ChangeTags::isTagNameValid( $tag );

		if ( !$tagNameStatus->isGood() ) {
			return $tagNameStatus;
		}

		$canAddStatus = ChangeTags::canAddTagsAccompanyingChange( [ $tag ] );

		if ( $canAddStatus->isGood() ) {
			return $canAddStatus;
		}

		if ( $tag === $this->changeTagsManager->getCondsLimitTag() ) {
			return Status::newFatal( 'abusefilter-tag-reserved' );
		}

		// note: these are both local and global tags
		$alreadyDefinedTags = $this->changeTagsManager->getTagsDefinedByFilters();
		if ( in_array( $tag, $alreadyDefinedTags, true ) ) {
			return Status::newGood();
		}

		// note: this check is only done for the local wiki
		// global filters could interfere with existing tags on remote wikis
		$canCreateTagStatus = ChangeTags::canCreateTag( $tag );
		if ( $canCreateTagStatus->isGood() ) {
			return $canCreateTagStatus;
		}

		return Status::newFatal( 'abusefilter-edit-bad-tags' );
	}
}
PK       ! 6i       ChangeTags/ChangeTagsManager.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\ChangeTags;

use MediaWiki\ChangeTags\ChangeTagsStore;
use MediaWiki\Extension\AbuseFilter\CentralDBManager;
use MediaWiki\Extension\AbuseFilter\CentralDBNotAvailableException;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\IReadableDatabase;
use Wikimedia\Rdbms\LBFactory;

/**
 * Database wrapper class which aids registering and reserving change tags
 * used by relevant abuse filters
 * @todo Consider verbose constants instead of boolean?
 */
class ChangeTagsManager {

	public const SERVICE_NAME = 'AbuseFilterChangeTagsManager';
	private const CONDS_LIMIT_TAG = 'abusefilter-condition-limit';

	private ChangeTagsStore $changeTagsStore;
	private LBFactory $lbFactory;
	private WANObjectCache $cache;
	private CentralDBManager $centralDBManager;

	/**
	 * @param ChangeTagsStore $changeTagsStore
	 * @param LBFactory $lbFactory
	 * @param WANObjectCache $cache
	 * @param CentralDBManager $centralDBManager
	 */
	public function __construct(
		ChangeTagsStore $changeTagsStore,
		LBFactory $lbFactory,
		WANObjectCache $cache,
		CentralDBManager $centralDBManager
	) {
		$this->changeTagsStore = $changeTagsStore;
		$this->lbFactory = $lbFactory;
		$this->cache = $cache;
		$this->centralDBManager = $centralDBManager;
	}

	/**
	 * Purge all cache related to tags, both within AbuseFilter and in core
	 */
	public function purgeTagCache(): void {
		// xxx: this doesn't purge all existing caches, see T266105
		$this->changeTagsStore->purgeTagCacheAll();
		$this->cache->delete( $this->getCacheKeyForStatus( true ) );
		$this->cache->delete( $this->getCacheKeyForStatus( false ) );
	}

	/**
	 * Return tags used by any active (enabled) filter, both local and global.
	 * @return string[]
	 */
	public function getTagsDefinedByActiveFilters(): array {
		return $this->loadTags( true );
	}

	/**
	 * Return tags used by any filter that is not deleted, both local and global.
	 * @return string[]
	 */
	public function getTagsDefinedByFilters(): array {
		return $this->loadTags( false );
	}

	/**
	 * @param IReadableDatabase $dbr
	 * @param bool $enabled
	 * @param bool $global
	 * @return string[]
	 */
	private function loadTagsFromDb( IReadableDatabase $dbr, bool $enabled, bool $global = false ): array {
		// This is a pretty awful hack.
		$where = [
			'afa_consequence' => 'tag',
			'af_deleted' => 0
		];
		if ( $enabled ) {
			$where['af_enabled'] = 1;
		}
		if ( $global ) {
			$where['af_global'] = 1;
		}

		$res = $dbr->newSelectQueryBuilder()
			->select( 'afa_parameters' )
			->from( 'abuse_filter_action' )
			->join( 'abuse_filter', null, 'afa_filter=af_id' )
			->where( $where )
			->caller( __METHOD__ )
			->fetchResultSet();

		$tags = [];
		foreach ( $res as $row ) {
			$tags = array_merge(
				$row->afa_parameters !== '' ? explode( "\n", $row->afa_parameters ) : [],
				$tags
			);
		}
		return $tags;
	}

	/**
	 * @param bool $enabled
	 * @return string[]
	 */
	private function loadTags( bool $enabled ): array {
		return $this->cache->getWithSetCallback(
			$this->getCacheKeyForStatus( $enabled ),
			WANObjectCache::TTL_MINUTE,
			function ( $oldValue, &$ttl, array &$setOpts ) use ( $enabled ) {
				$dbr = $this->lbFactory->getReplicaDatabase();
				try {
					$globalDbr = $this->centralDBManager->getConnection( DB_REPLICA );
				} catch ( CentralDBNotAvailableException $_ ) {
					$globalDbr = null;
				}

				if ( $globalDbr !== null ) {
					// Account for any snapshot/replica DB lag
					$setOpts += Database::getCacheSetOptions( $dbr, $globalDbr );
					$tags = array_merge(
						$this->loadTagsFromDb( $dbr, $enabled ),
						$this->loadTagsFromDb( $globalDbr, $enabled, true )
					);
				} else {
					$setOpts += Database::getCacheSetOptions( $dbr );
					$tags = $this->loadTagsFromDb( $dbr, $enabled );
				}

				return array_unique( $tags );
			}
		);
	}

	/**
	 * @param bool $enabled
	 * @return string
	 */
	private function getCacheKeyForStatus( bool $enabled ): string {
		return $this->cache->makeKey( 'abusefilter-fetch-all-tags', (int)$enabled );
	}

	/**
	 * Get the tag identifier used to indicate a change has exceeded the condition limit
	 * @return string
	 */
	public function getCondsLimitTag(): string {
		return self::CONDS_LIMIT_TAG;
	}
}
PK       ! @
      ChangeTags/ChangeTagger.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\ChangeTags;

use MediaWiki\Extension\AbuseFilter\ActionSpecifier;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\UserIdentityValue;
use RecentChange;

/**
 * Class that collects change tags to be later applied
 * @internal This interface should be improved and is not ready for external use
 */
class ChangeTagger {
	public const SERVICE_NAME = 'AbuseFilterChangeTagger';

	/** @var array (Persistent) map of (action ID => string[]) */
	private static $tagsToSet = [];

	/**
	 * @var ChangeTagsManager
	 */
	private $changeTagsManager;

	/**
	 * @param ChangeTagsManager $changeTagsManager
	 */
	public function __construct( ChangeTagsManager $changeTagsManager ) {
		$this->changeTagsManager = $changeTagsManager;
	}

	/**
	 * Clear any buffered tag
	 */
	public function clearBuffer(): void {
		self::$tagsToSet = [];
	}

	/**
	 * @param ActionSpecifier $specifier
	 */
	public function addConditionsLimitTag( ActionSpecifier $specifier ): void {
		$this->addTags( $specifier, [ $this->changeTagsManager->getCondsLimitTag() ] );
	}

	/**
	 * @param ActionSpecifier $specifier
	 * @param array $tags
	 */
	public function addTags( ActionSpecifier $specifier, array $tags ): void {
		$id = $this->getActionID( $specifier );
		$this->bufferTagsToSetByAction( [ $id => $tags ] );
	}

	/**
	 * @param string[][] $tagsByAction Map of (string => string[])
	 */
	private function bufferTagsToSetByAction( array $tagsByAction ): void {
		foreach ( $tagsByAction as $actionID => $tags ) {
			self::$tagsToSet[ $actionID ] = array_unique(
				array_merge( self::$tagsToSet[ $actionID ] ?? [], $tags )
			);
		}
	}

	/**
	 * @param string $id
	 * @param bool $clear
	 * @return array
	 */
	private function getTagsForID( string $id, bool $clear = true ): array {
		$val = self::$tagsToSet[$id] ?? [];
		if ( $clear ) {
			unset( self::$tagsToSet[$id] );
		}
		return $val;
	}

	/**
	 * @param RecentChange $recentChange
	 * @param bool $clear
	 * @return array
	 */
	public function getTagsForRecentChange( RecentChange $recentChange, bool $clear = true ): array {
		$id = $this->getIDFromRecentChange( $recentChange );
		return $this->getTagsForID( $id, $clear );
	}

	/**
	 * @param RecentChange $recentChange
	 * @return string
	 */
	private function getIDFromRecentChange( RecentChange $recentChange ): string {
		$title = new TitleValue(
			$recentChange->getAttribute( 'rc_namespace' ),
			$recentChange->getAttribute( 'rc_title' )
		);

		$logType = $recentChange->getAttribute( 'rc_log_type' ) ?: 'edit';
		if ( $logType === 'newusers' ) {
			// XXX: as of 1.43, the following is never true
			$action = $recentChange->getAttribute( 'rc_log_action' ) === 'autocreate' ?
				'autocreateaccount' :
				'createaccount';
		} else {
			$action = $logType;
		}
		$user = new UserIdentityValue(
			$recentChange->getAttribute( 'rc_user' ),
			$recentChange->getAttribute( 'rc_user_text' )
		);
		$specifier = new ActionSpecifier(
			$action,
			$title,
			$user,
			$recentChange->getAttribute( 'rc_ip' ) ?? '',
			$user->getName()
		);
		return $this->getActionID( $specifier );
	}

	/**
	 * Get a unique identifier for the given action
	 *
	 * @param ActionSpecifier $specifier
	 * @return string
	 */
	private function getActionID( ActionSpecifier $specifier ): string {
		$username = $specifier->getUser()->getName();
		$title = $specifier->getTitle();
		if ( strpos( $specifier->getAction(), 'createaccount' ) !== false ) {
			// TODO Move this to ActionSpecifier?
			$username = $specifier->getAccountName();
			'@phan-var string $username';
			$title = new TitleValue( NS_USER, $username );
		}

		// Use a character that's not allowed in titles and usernames
		$glue = '|';
		return implode(
			$glue,
			[
				$title->getNamespace() . ':' . $title->getText(),
				$username,
				$specifier->getAction()
			]
		);
	}
}
PK       ! 00  0  "  Special/AbuseFilterSpecialPage.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Special;

use HtmlArmor;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Html\Html;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\TitleValue;

/**
 * Parent class for AbuseFilter special pages.
 */
abstract class AbuseFilterSpecialPage extends SpecialPage {

	/** @var AbuseFilterPermissionManager */
	protected $afPermissionManager;

	/**
	 * @param string $name
	 * @param string $restriction
	 * @param AbuseFilterPermissionManager $afPermissionManager
	 */
	public function __construct(
		$name,
		$restriction,
		AbuseFilterPermissionManager $afPermissionManager
	) {
		parent::__construct( $name, $restriction );
		$this->afPermissionManager = $afPermissionManager;
	}

	/**
	 * @inheritDoc
	 */
	public function getShortDescription( string $path = '' ): string {
		switch ( $path ) {
			case 'AbuseFilter':
				return $this->msg( 'abusefilter-topnav-home' )->text();
			case 'AbuseFilter/history':
				return $this->msg( 'abusefilter-topnav-recentchanges' )->text();
			case 'AbuseFilter/examine':
				return $this->msg( 'abusefilter-topnav-examine' )->text();
			case 'AbuseFilter/test':
				return $this->msg( 'abusefilter-topnav-test' )->text();
			case 'AbuseFilter/tools':
				return $this->msg( 'abusefilter-topnav-tools' )->text();
			default:
				return parent::getShortDescription( $path );
		}
	}

	/**
	 * Get topbar navigation links definitions
	 *
	 * @return array
	 */
	private function getNavigationLinksInternal(): array {
		$performer = $this->getAuthority();

		$linkDefs = [
			'home' => 'AbuseFilter',
			'recentchanges' => 'AbuseFilter/history',
			'examine' => 'AbuseFilter/examine',
		];

		if ( $this->afPermissionManager->canViewAbuseLog( $performer ) ) {
			$linkDefs += [
				'log' => 'AbuseLog'
			];
		}

		if ( $this->afPermissionManager->canUseTestTools( $performer ) ) {
			$linkDefs += [
				'test' => 'AbuseFilter/test',
				'tools' => 'AbuseFilter/tools'
			];
		}

		return $linkDefs;
	}

	/**
	 * Return an array of strings representing page titles that are discoverable to end users via UI.
	 *
	 * @inheritDoc
	 */
	public function getAssociatedNavigationLinks(): array {
		$links = $this->getNavigationLinksInternal();
		return array_map( static function ( $name ) {
			return 'Special:' . $name;
		}, array_values( $links ) );
	}

	/**
	 * Add topbar navigation links
	 *
	 * @param string $pageType
	 */
	protected function addNavigationLinks( $pageType ) {
		// If the current skin supports sub menus nothing to do here.
		if ( $this->getSkin()->supportsMenu( 'associated-pages' ) ) {
			return;
		}
		$linkDefs = $this->getNavigationLinksInternal();
		$links = [];
		foreach ( $linkDefs as $name => $page ) {
			// Give grep a chance to find the usages:
			// abusefilter-topnav-home, abusefilter-topnav-recentchanges, abusefilter-topnav-test,
			// abusefilter-topnav-log, abusefilter-topnav-tools, abusefilter-topnav-examine
			$msgName = "abusefilter-topnav-$name";

			$msg = $this->msg( $msgName )->parse();

			if ( $name === $pageType ) {
				$links[] = Html::rawElement( 'strong', [], $msg );
			} else {
				$links[] = $this->getLinkRenderer()->makeLink(
					new TitleValue( NS_SPECIAL, $page ),
					new HtmlArmor( $msg )
				);
			}
		}

		$linkStr = $this->msg( 'parentheses' )
			->rawParams( $this->getLanguage()->pipeList( $links ) )
			->escaped();
		$linkStr = $this->msg( 'abusefilter-topnav' )->parse() . " $linkStr";

		$linkStr = Html::rawElement( 'div', [ 'class' => 'mw-abusefilter-navigation' ], $linkStr );

		$this->getOutput()->setSubtitle( $linkStr );
	}
}
PK       ! ӑ  ӑ    Special/SpecialAbuseLog.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Special;

use DifferenceEngine;
use InvalidArgumentException;
use ManualLogEntry;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
use MediaWiki\Extension\AbuseFilter\AbuseLoggerFactory;
use MediaWiki\Extension\AbuseFilter\CentralDBNotAvailableException;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Extension\AbuseFilter\FilterUtils;
use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;
use MediaWiki\Extension\AbuseFilter\Pager\AbuseLogPager;
use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
use MediaWiki\Extension\AbuseFilter\Variables\UnsetVariableException;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesFormatter;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Extension\AbuseFilter\View\HideAbuseLog;
use MediaWiki\Html\Html;
use MediaWiki\Html\ListToggle;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Linker\Linker;
use MediaWiki\MediaWikiServices;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityLookup;
use MediaWiki\WikiMap\WikiMap;
use OOUI\ButtonInputWidget;
use stdClass;
use Wikimedia\Rdbms\IExpression;
use Wikimedia\Rdbms\LBFactory;
use Wikimedia\Rdbms\LikeValue;

class SpecialAbuseLog extends AbuseFilterSpecialPage {
	public const PAGE_NAME = 'AbuseLog';

	/** Visible entry */
	public const VISIBILITY_VISIBLE = 'visible';
	/** Explicitly hidden entry */
	public const VISIBILITY_HIDDEN = 'hidden';
	/** Visible entry but the associated revision is hidden */
	public const VISIBILITY_HIDDEN_IMPLICIT = 'implicit';

	/**
	 * @var string|null The user whose AbuseLog entries are being searched
	 */
	private $mSearchUser;

	/**
	 * @var string The start time of the search period
	 */
	private $mSearchPeriodStart;

	/**
	 * @var string The end time of the search period
	 */
	private $mSearchPeriodEnd;

	/**
	 * @var string The page of which AbuseLog entries are being searched
	 */
	private $mSearchTitle;

	/**
	 * @var string The action performed by the user
	 */
	private $mSearchAction;

	/**
	 * @var string The action taken by AbuseFilter
	 */
	private $mSearchActionTaken;

	/**
	 * @var string The wiki name where we're performing the search
	 */
	private $mSearchWiki;

	/**
	 * @var string|null The filter IDs we're looking for. Either a single one, or a pipe-separated list
	 */
	private $mSearchFilter;

	/**
	 * @var string The visibility of entries we're interested in
	 */
	private $mSearchEntries;

	/**
	 * @var string The impact of the user action, i.e. if the change has been saved
	 */
	private $mSearchImpact;

	/** @var string|null The filter group to search, as defined in $wgAbuseFilterValidGroups */
	private $mSearchGroup;

	/** @var LBFactory */
	private $lbFactory;

	/** @var LinkBatchFactory */
	private $linkBatchFactory;

	/** @var PermissionManager */
	private $permissionManager;

	/** @var UserIdentityLookup */
	private $userIdentityLookup;

	/** @var ConsequencesRegistry */
	private $consequencesRegistry;

	/** @var VariablesBlobStore */
	private $varBlobStore;

	/** @var SpecsFormatter */
	private $specsFormatter;

	/** @var VariablesFormatter */
	private $variablesFormatter;

	/** @var VariablesManager */
	private $varManager;

	private AbuseLoggerFactory $abuseLoggerFactory;

	/**
	 * @param LBFactory $lbFactory
	 * @param LinkBatchFactory $linkBatchFactory
	 * @param PermissionManager $permissionManager
	 * @param UserIdentityLookup $userIdentityLookup
	 * @param AbuseFilterPermissionManager $afPermissionManager
	 * @param ConsequencesRegistry $consequencesRegistry
	 * @param VariablesBlobStore $varBlobStore
	 * @param SpecsFormatter $specsFormatter
	 * @param VariablesFormatter $variablesFormatter
	 * @param VariablesManager $varManager
	 * @param AbuseLoggerFactory $abuseLoggerFactory
	 */
	public function __construct(
		LBFactory $lbFactory,
		LinkBatchFactory $linkBatchFactory,
		PermissionManager $permissionManager,
		UserIdentityLookup $userIdentityLookup,
		AbuseFilterPermissionManager $afPermissionManager,
		ConsequencesRegistry $consequencesRegistry,
		VariablesBlobStore $varBlobStore,
		SpecsFormatter $specsFormatter,
		VariablesFormatter $variablesFormatter,
		VariablesManager $varManager,
		AbuseLoggerFactory $abuseLoggerFactory
	) {
		parent::__construct( self::PAGE_NAME, 'abusefilter-log', $afPermissionManager );
		$this->lbFactory = $lbFactory;
		$this->linkBatchFactory = $linkBatchFactory;
		$this->permissionManager = $permissionManager;
		$this->userIdentityLookup = $userIdentityLookup;
		$this->consequencesRegistry = $consequencesRegistry;
		$this->varBlobStore = $varBlobStore;
		$this->specsFormatter = $specsFormatter;
		$this->specsFormatter->setMessageLocalizer( $this );
		$this->variablesFormatter = $variablesFormatter;
		$this->variablesFormatter->setMessageLocalizer( $this );
		$this->varManager = $varManager;
		$this->abuseLoggerFactory = $abuseLoggerFactory;
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function doesWrites() {
		return true;
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	protected function getGroupName() {
		return 'changes';
	}

	/**
	 * Main routine
	 *
	 * $parameter string is converted into the $args array, which can come in
	 * three shapes:
	 *
	 * An array of size 2: only if the URL is like Special:AbuseLog/private/id
	 * where id is the log identifier. In this case, the private details of the
	 * log (e.g. IP address) will be shown.
	 *
	 * An array of size 1: either the URL is like Special:AbuseLog/id where
	 * the id is log identifier, in which case the details of the log except for
	 * private bits (e.g. IP address) are shown, or Special:AbuseLog/hide for hiding entries,
	 * or the URL is incomplete as in Special:AbuseLog/private (without specifying id),
	 * in which case a warning is shown to the user
	 *
	 * An array of size 0 when URL is like Special:AbuseLog or an array of size
	 * 1 when the URL is like Special:AbuseFilter/ (i.e. without anything after
	 * the slash). Otherwise, the abuse logs are shown as a list, with a search form above the list.
	 *
	 * @param string|null $parameter URL parameters
	 */
	public function execute( $parameter ) {
		$out = $this->getOutput();

		$this->addNavigationLinks( 'log' );

		$this->setHeaders();
		$this->addHelpLink( 'Extension:AbuseFilter' );
		$this->loadParameters();

		$out->disableClientCache();

		$out->addModuleStyles( 'ext.abuseFilter' );

		$this->checkPermissions();

		$args = $parameter !== null ? explode( '/', $parameter ) : [];

		if ( count( $args ) === 2 && $args[0] === 'private' ) {
			$this->showPrivateDetails( (int)$args[1] );
		} elseif ( count( $args ) === 1 && $args[0] !== '' ) {
			if ( $args[0] === 'private' ) {
				$out->addWikiMsg( 'abusefilter-invalid-request-noid' );
			} elseif ( $args[0] === 'hide' ) {
				$this->showHideView();
			} else {
				$this->showDetails( $args[0] );
			}
		} else {
			$this->outputHeader( 'abusefilter-log-summary' );
			$this->searchForm();
			$this->showList();
		}
	}

	/**
	 * @inheritDoc
	 */
	public function getShortDescription( string $path = '' ): string {
		return $this->msg( 'abusefilter-topnav-log' )->text();
	}

	/**
	 * Loads parameters from request
	 */
	public function loadParameters() {
		$request = $this->getRequest();

		$searchUsername = trim( $request->getText( 'wpSearchUser' ) );
		$userTitle = Title::newFromText( $searchUsername, NS_USER );
		$this->mSearchUser = $userTitle ? $userTitle->getText() : null;
		if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
			$this->mSearchWiki = $request->getText( 'wpSearchWiki' );
		}

		$this->mSearchPeriodStart = $request->getText( 'wpSearchPeriodStart' );
		$this->mSearchPeriodEnd = $request->getText( 'wpSearchPeriodEnd' );
		$this->mSearchTitle = $request->getText( 'wpSearchTitle' );

		$this->mSearchFilter = null;
		$this->mSearchGroup = null;
		if ( $this->afPermissionManager->canSeeLogDetails( $this->getAuthority() ) ) {
			$this->mSearchFilter = $request->getText( 'wpSearchFilter' );
			if ( count( $this->getConfig()->get( 'AbuseFilterValidGroups' ) ) > 1 ) {
				$this->mSearchGroup = $request->getText( 'wpSearchGroup' );
			}
		}

		$this->mSearchAction = $request->getText( 'wpSearchAction' );
		$this->mSearchActionTaken = $request->getText( 'wpSearchActionTaken' );
		$this->mSearchEntries = $request->getText( 'wpSearchEntries' );
		$this->mSearchImpact = $request->getText( 'wpSearchImpact' );
	}

	/**
	 * @return string[]
	 */
	private function getAllFilterableActions() {
		return [
			'edit',
			'move',
			'upload',
			'stashupload',
			'delete',
			'createaccount',
			'autocreateaccount',
		];
	}

	/**
	 * Builds the search form
	 */
	public function searchForm() {
		$performer = $this->getAuthority();
		$formDescriptor = [
			'SearchUser' => [
				'label-message' => 'abusefilter-log-search-user',
				'type' => 'user',
				'ipallowed' => true,
				'default' => $this->mSearchUser,
			],
			'SearchPeriodStart' => [
				'label-message' => 'abusefilter-test-period-start',
				'type' => 'datetime',
				'default' => $this->mSearchPeriodStart
			],
			'SearchPeriodEnd' => [
				'label-message' => 'abusefilter-test-period-end',
				'type' => 'datetime',
				'default' => $this->mSearchPeriodEnd
			],
			'SearchTitle' => [
				'label-message' => 'abusefilter-log-search-title',
				'type' => 'title',
				'interwiki' => false,
				'default' => $this->mSearchTitle,
				'required' => false
			],
			'SearchImpact' => [
				'label-message' => 'abusefilter-log-search-impact',
				'type' => 'select',
				'options' => [
					$this->msg( 'abusefilter-log-search-impact-all' )->text() => 0,
					$this->msg( 'abusefilter-log-search-impact-saved' )->text() => 1,
					$this->msg( 'abusefilter-log-search-impact-not-saved' )->text() => 2,
				],
			],
		];
		$filterableActions = $this->getAllFilterableActions();
		$actions = array_combine( $filterableActions, $filterableActions );
		ksort( $actions );
		$actions = array_merge(
			[ $this->msg( 'abusefilter-log-search-action-any' )->text() => 'any' ],
			$actions,
			[ $this->msg( 'abusefilter-log-search-action-other' )->text() => 'other' ]
		);
		$formDescriptor['SearchAction'] = [
			'label-message' => 'abusefilter-log-search-action-label',
			'type' => 'select',
			'options' => $actions,
			'default' => 'any',
		];
		$options = [];
		foreach ( $this->consequencesRegistry->getAllActionNames() as $action ) {
			$key = $this->specsFormatter->getActionDisplay( $action );
			$options[$key] = $action;
		}
		ksort( $options );
		$options = array_merge(
			[ $this->msg( 'abusefilter-log-search-action-taken-any' )->text() => '' ],
			$options,
			[ $this->msg( 'abusefilter-log-noactions-filter' )->text() => 'noactions' ]
		);
		$formDescriptor['SearchActionTaken'] = [
			'label-message' => 'abusefilter-log-search-action-taken-label',
			'type' => 'select',
			'options' => $options,
		];
		if ( $this->afPermissionManager->canSeeHiddenLogEntries( $performer ) ) {
			$formDescriptor['SearchEntries'] = [
				'type' => 'select',
				'label-message' => 'abusefilter-log-search-entries-label',
				'options' => [
					$this->msg( 'abusefilter-log-search-entries-all' )->text() => 0,
					$this->msg( 'abusefilter-log-search-entries-hidden' )->text() => 1,
					$this->msg( 'abusefilter-log-search-entries-visible' )->text() => 2,
				],
			];
		}

		if ( $this->afPermissionManager->canSeeLogDetails( $performer ) ) {
			$groups = $this->getConfig()->get( 'AbuseFilterValidGroups' );
			if ( count( $groups ) > 1 ) {
				$options = array_merge(
					[ $this->msg( 'abusefilter-log-search-group-any' )->text() => 0 ],
					array_combine( $groups, $groups )
				);
				$formDescriptor['SearchGroup'] = [
					'label-message' => 'abusefilter-log-search-group',
					'type' => 'select',
					'options' => $options
				];
			}
			$helpmsg = $this->getConfig()->get( 'AbuseFilterIsCentral' )
				? $this->msg( 'abusefilter-log-search-filter-help-central' )->escaped()
				: $this->msg( 'abusefilter-log-search-filter-help' )
					->params( GlobalNameUtils::GLOBAL_FILTER_PREFIX )->escaped();
			$formDescriptor['SearchFilter'] = [
				'label-message' => 'abusefilter-log-search-filter',
				'type' => 'text',
				'default' => $this->mSearchFilter,
				'help' => $helpmsg
			];
		}
		if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
			// @todo Add free form input for wiki name. Would be nice to generate
			// a select with unique names in the db at some point.
			$formDescriptor['SearchWiki'] = [
				'label-message' => 'abusefilter-log-search-wiki',
				'type' => 'text',
				'default' => $this->mSearchWiki,
			];
		}

		HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
			->setWrapperLegendMsg( 'abusefilter-log-search' )
			->setSubmitTextMsg( 'abusefilter-log-search-submit' )
			->setMethod( 'get' )
			->setCollapsibleOptions( true )
			->prepareForm()
			->displayForm( false );
	}

	private function showHideView() {
		$view = new HideAbuseLog(
			$this->lbFactory,
			$this->afPermissionManager,
			$this->getContext(),
			$this->getLinkRenderer(),
			self::PAGE_NAME
		);
		$view->show();
	}

	/**
	 * Shows the results list
	 */
	public function showList() {
		$out = $this->getOutput();
		$performer = $this->getAuthority();

		// Generate conditions list.
		$conds = [];

		if ( $this->mSearchUser !== null ) {
			$searchedUser = $this->userIdentityLookup->getUserIdentityByName( $this->mSearchUser );

			if ( !$searchedUser ) {
				$conds['afl_user'] = 0;
				$conds['afl_user_text'] = $this->mSearchUser;
			} else {
				$conds['afl_user'] = $searchedUser->getId();
				$conds['afl_user_text'] = $searchedUser->getName();
			}
		}

		$dbr = $this->lbFactory->getReplicaDatabase();
		if ( $this->mSearchPeriodStart ) {
			$conds[] = $dbr->expr( 'afl_timestamp', '>=',
				$dbr->timestamp( strtotime( $this->mSearchPeriodStart ) ) );
		}

		if ( $this->mSearchPeriodEnd ) {
			$conds[] = $dbr->expr( 'afl_timestamp', '<=',
				$dbr->timestamp( strtotime( $this->mSearchPeriodEnd ) ) );
		}

		if ( $this->mSearchWiki ) {
			if ( $this->mSearchWiki === WikiMap::getCurrentWikiDbDomain()->getId() ) {
				$conds['afl_wiki'] = null;
			} else {
				$conds['afl_wiki'] = $this->mSearchWiki;
			}
		}

		$groupFilters = [];
		if ( $this->mSearchGroup ) {
			$groupFilters = $dbr->newSelectQueryBuilder()
				->select( 'af_id' )
				->from( 'abuse_filter' )
				->where( [ 'af_group' => $this->mSearchGroup ] )
				->caller( __METHOD__ )
				->fetchFieldValues();
		}

		$searchFilters = [];
		if ( $this->mSearchFilter ) {
			$rawFilters = array_map( 'trim', explode( '|', $this->mSearchFilter ) );
			// Map of [ [ id, global ], ... ]
			$filtersList = [];
			$foundInvalid = false;
			foreach ( $rawFilters as $filter ) {
				try {
					$filtersList[] = GlobalNameUtils::splitGlobalName( $filter );
				} catch ( InvalidArgumentException $e ) {
					$foundInvalid = true;
					continue;
				}
			}

			// if a filter is hidden, users who can't view private filters should
			// not be able to find log entries generated by it.
			if ( !$this->afPermissionManager->canViewPrivateFiltersLogs( $performer ) ) {
				$searchedForPrivate = false;
				foreach ( $filtersList as $index => $filterData ) {
					try {
						$filter = AbuseFilterServices::getFilterLookup()->getFilter( ...$filterData );
					} catch ( FilterNotFoundException $_ ) {
						unset( $filtersList[$index] );
						$foundInvalid = true;
						continue;
					}
					if ( $filter->isHidden() ) {
						unset( $filtersList[$index] );
						$searchedForPrivate = true;
					}
				}
				if ( $searchedForPrivate ) {
					$out->addWikiMsg( 'abusefilter-log-private-not-included' );
				}
			}

			// if a filter is protected, users who can't view protected filters should
			// not be able to find log entries generated by it.
			if ( !$this->afPermissionManager->canViewProtectedVariables( $performer ) ) {
				$searchedForProtected = false;
				foreach ( $filtersList as $index => $filterData ) {
					try {
						$filter = AbuseFilterServices::getFilterLookup()->getFilter( ...$filterData );
					} catch ( FilterNotFoundException $_ ) {
						unset( $filtersList[$index] );
						$foundInvalid = true;
						continue;
					}
					if ( $filter->isProtected() ) {
						unset( $filtersList[$index] );
						$searchedForProtected = true;
					}
				}
				if ( $searchedForProtected ) {
					$out->addWikiMsg( 'abusefilter-log-protected-not-included' );
				}
			}

			if ( $foundInvalid ) {
				// @todo Tell what the invalid IDs are
				$out->addHTML(
					Html::rawElement(
						'p',
						[],
						Html::warningBox( $this->msg( 'abusefilter-log-invalid-filter' )->escaped() )
					)
				);
			}

			foreach ( $filtersList as $filterData ) {
				$searchFilters[] = GlobalNameUtils::buildGlobalName( ...$filterData );
			}
		}

		$searchIDs = null;
		if ( $this->mSearchGroup && !$this->mSearchFilter ) {
			$searchIDs = $groupFilters;
		} elseif ( !$this->mSearchGroup && $this->mSearchFilter ) {
			$searchIDs = $searchFilters;
		} elseif ( $this->mSearchGroup && $this->mSearchFilter ) {
			$searchIDs = array_intersect( $groupFilters, $searchFilters );
		}

		if ( $searchIDs !== null ) {
			if ( !count( $searchIDs ) ) {
				$out->addWikiMsg( 'abusefilter-log-noresults' );
				return;
			}

			$filterConds = [ 'local' => [], 'global' => [] ];
			foreach ( $searchIDs as $filter ) {
				[ $filterID, $isGlobal ] = GlobalNameUtils::splitGlobalName( $filter );
				$key = $isGlobal ? 'global' : 'local';
				$filterConds[$key][] = $filterID;
			}
			$filterWhere = [];
			if ( $filterConds['local'] ) {
				$filterWhere[] = $dbr->andExpr( [
					'afl_global' => 0,
					// @phan-suppress-previous-line PhanTypeMismatchArgument Array is non-empty
					'afl_filter_id' => $filterConds['local'],
				] );
			}
			if ( $filterConds['global'] ) {
				$filterWhere[] = $dbr->andExpr( [
					'afl_global' => 1,
					// @phan-suppress-previous-line PhanTypeMismatchArgument Array is non-empty
					'afl_filter_id' => $filterConds['global'],
				] );
			}
			$conds[] = $dbr->orExpr( $filterWhere );
		}

		$searchTitle = Title::newFromText( $this->mSearchTitle );
		if ( $searchTitle ) {
			$conds['afl_namespace'] = $searchTitle->getNamespace();
			$conds['afl_title'] = $searchTitle->getDBkey();
		}

		if ( $this->afPermissionManager->canSeeHiddenLogEntries( $performer ) ) {
			if ( $this->mSearchEntries === '1' ) {
				$conds['afl_deleted'] = 1;
			} elseif ( $this->mSearchEntries === '2' ) {
				$conds['afl_deleted'] = 0;
			}
		}

		if ( $this->mSearchImpact === '1' ) {
			$conds[] = $dbr->expr( 'afl_rev_id', '!=', null );
		} elseif ( $this->mSearchImpact === '2' ) {
			$conds[] = $dbr->expr( 'afl_rev_id', '=', null );
		}

		if ( $this->mSearchActionTaken ) {
			if ( in_array( $this->mSearchActionTaken, $this->consequencesRegistry->getAllActionNames() ) ) {
				$conds[] = $dbr->expr( 'afl_actions', '=', $this->mSearchActionTaken )
					->or( 'afl_actions', IExpression::LIKE, new LikeValue(
						$this->mSearchActionTaken, ',', $dbr->anyString()
					) )
					->or( 'afl_actions', IExpression::LIKE, new LikeValue(
						$dbr->anyString(), ',', $this->mSearchActionTaken
					) )
					->or( 'afl_actions', IExpression::LIKE, new LikeValue(
						$dbr->anyString(),
						',', $this->mSearchActionTaken, ',',
						$dbr->anyString()
					) );
			} elseif ( $this->mSearchActionTaken === 'noactions' ) {
				$conds['afl_actions'] = '';
			}
		}

		if ( $this->mSearchAction ) {
			$filterableActions = $this->getAllFilterableActions();
			if ( in_array( $this->mSearchAction, $filterableActions ) ) {
				$conds['afl_action'] = $this->mSearchAction;
			} elseif ( $this->mSearchAction === 'other' ) {
				$conds[] = $dbr->expr( 'afl_action', '!=', $filterableActions );
			}
		}

		$pager = new AbuseLogPager(
			$this->getContext(),
			$this->getLinkRenderer(),
			$conds,
			$this->linkBatchFactory,
			$this->permissionManager,
			$this->afPermissionManager,
			$this->getName()
		);
		$pager->doQuery();
		$result = $pager->getResult();

		$form = Html::rawElement(
			'form',
			[
				'method' => 'GET',
				'action' => $this->getPageTitle( 'hide' )->getLocalURL()
			],
			$this->getDeleteButton() . $this->getListToggle() .
				Html::rawElement( 'ul', [ 'class' => 'plainlinks' ], $pager->getBody() ) .
				$this->getListToggle() . $this->getDeleteButton()
		);

		if ( $result && $result->numRows() !== 0 ) {
			$out->addModuleStyles( 'mediawiki.interface.helpers.styles' );
			$out->addHTML( $pager->getNavigationBar() . $form . $pager->getNavigationBar() );
		} else {
			$out->addWikiMsg( 'abusefilter-log-noresults' );
		}
	}

	/**
	 * Returns the HTML for a button to hide selected entries
	 *
	 * @return string|ButtonInputWidget
	 */
	private function getDeleteButton() {
		if ( !$this->afPermissionManager->canHideAbuseLog( $this->getAuthority() ) ) {
			return '';
		}
		return new ButtonInputWidget( [
			'label' => $this->msg( 'abusefilter-log-hide-entries' )->text(),
			'type' => 'submit'
		] );
	}

	/**
	 * Get the All / Invert / None options provided by
	 * ToggleList.php to mass select the checkboxes.
	 *
	 * @return string
	 */
	private function getListToggle() {
		if ( !$this->afPermissionManager->canHideAbuseLog( $this->getUser() ) ) {
			return '';
		}
		return ( new ListToggle( $this->getOutput() ) )->getHtml();
	}

	/**
	 * @param string|int $id
	 * @suppress SecurityCheck-SQLInjection
	 */
	public function showDetails( $id ) {
		$out = $this->getOutput();
		$performer = $this->getAuthority();

		$pager = new AbuseLogPager(
			$this->getContext(),
			$this->getLinkRenderer(),
			[],
			$this->linkBatchFactory,
			$this->permissionManager,
			$this->afPermissionManager,
			$this->getName()
		);

		[
			'tables' => $tables,
			'fields' => $fields,
			'join_conds' => $join_conds,
		] = $pager->getQueryInfo();

		$dbr = $this->lbFactory->getReplicaDatabase();
		$row = $dbr->newSelectQueryBuilder()
			->tables( $tables )
			->fields( $fields )
			->where( [ 'afl_id' => $id ] )
			->caller( __METHOD__ )
			->joinConds( $join_conds )
			->fetchRow();

		$error = null;
		$privacyLevel = Flags::FILTER_PUBLIC;
		if ( !$row ) {
			$error = 'abusefilter-log-nonexistent';
		} else {
			$filterID = $row->afl_filter_id;
			$global = $row->afl_global;

			$privacyLevel = $row->af_hidden;
			if ( $global ) {
				try {
					$privacyLevel = AbuseFilterServices::getFilterLookup()->getFilter( $filterID, $global )
						->getPrivacyLevel();
				} catch ( CentralDBNotAvailableException $_ ) {
					// Conservatively assume that it's hidden and protected, like in AbuseLogPager::doFormatRow
					$privacyLevel = Flags::FILTER_HIDDEN | Flags::FILTER_USES_PROTECTED_VARS;
				}
			}

			if ( !$this->afPermissionManager->canSeeLogDetailsForFilter( $performer, $privacyLevel ) ) {
				$error = 'abusefilter-log-cannot-see-details';
			} else {
				$visibility = self::getEntryVisibilityForUser( $row, $performer, $this->afPermissionManager );
				if ( $visibility === self::VISIBILITY_HIDDEN ) {
					$error = 'abusefilter-log-details-hidden';
				} elseif ( $visibility === self::VISIBILITY_HIDDEN_IMPLICIT ) {
					$error = 'abusefilter-log-details-hidden-implicit';
				}
			}

			// Only show the preference error if another error isn't already set
			// as this error shouldn't take precedence over a view permission error
			if (
				FilterUtils::isProtected( $privacyLevel ) &&
				!$this->afPermissionManager->canViewProtectedVariableValues( $performer ) &&
				!$error
			) {
				$error = 'abusefilter-examine-protected-vars-permission';
			}
		}

		if ( $error ) {
			$out->addWikiMsg( $error );
			return;
		}

		$output = Html::element(
			'legend',
			[],
			$this->msg( 'abusefilter-log-details-legend' )
				->params( $this->getLanguage()->formatNumNoSeparators( $id ) )
				->text()
		);
		$output .= Html::rawElement( 'p', [], $pager->doFormatRow( $row, false ) );

		// Load data
		$vars = $this->varBlobStore->loadVarDump( $row );
		$varsArray = $this->varManager->dumpAllVars( $vars, true );
		$shouldLogProtectedVarAccess = false;

		// If a non-protected filter and a protected filter have overlapping conditions,
		// it's possible for a hit to contain a protected variable and for that variable
		// to be dumped and displayed on a detail page that wouldn't be considered
		// protected (because it caught on the public filter).
		// We shouldn't block access to the details of an otherwise public filter hit so
		// instead only check for access to the protected variables and redact them if the user
		// shouldn't see them.
		$userAuthority = $this->getAuthority();
		$canViewProtectedVars = $this->afPermissionManager->canViewProtectedVariableValues( $userAuthority );
		foreach ( $this->afPermissionManager->getProtectedVariables() as $protectedVariable ) {
			if ( isset( $varsArray[$protectedVariable] ) ) {
				if ( !$canViewProtectedVars ) {
					$varsArray[$protectedVariable] = '';
				} else {
					// Protected variables in protected filters logs access in the general permission check
					// Log access to non-protected filters that happen to expose protected variables here
					if ( !FilterUtils::isProtected( $privacyLevel ) ) {
						$shouldLogProtectedVarAccess = true;
					}
				}
			}
		}
		$vars = VariableHolder::newFromArray( $varsArray );

		// Log if protected variables are accessed
		if (
			FilterUtils::isProtected( $privacyLevel ) &&
			$canViewProtectedVars
		) {
			$shouldLogProtectedVarAccess = true;
		}

		if ( $shouldLogProtectedVarAccess ) {
			$logger = $this->abuseLoggerFactory->getProtectedVarsAccessLogger();
			$logger->logViewProtectedVariableValue(
				$userAuthority->getUser(),
				$varsArray['user_name'] ?? $varsArray['accountname']
			);
		}

		$out->addJsConfigVars( 'wgAbuseFilterVariables', $varsArray );
		$out->addModuleStyles( 'mediawiki.interface.helpers.styles' );

		// Diff, if available
		if ( $row->afl_action === 'edit' ) {
			// Guard for exception because these variables may be unset in case of data corruption (T264513)
			// No need to lazy-load as these come from a DB dump.
			try {
				$old_wikitext = $vars->getComputedVariable( 'old_wikitext' )->toString();
			} catch ( UnsetVariableException $_ ) {
				$old_wikitext = '';
			}
			try {
				$new_wikitext = $vars->getComputedVariable( 'new_wikitext' )->toString();
			} catch ( UnsetVariableException $_ ) {
				$new_wikitext = '';
			}

			$diffEngine = new DifferenceEngine( $this->getContext() );

			$diffEngine->showDiffStyle();

			$formattedDiff = $diffEngine->addHeader(
				$diffEngine->generateTextDiffBody( $old_wikitext, $new_wikitext ),
				'', ''
			);

			$output .=
				Html::rawElement(
					'h3',
					[],
					$this->msg( 'abusefilter-log-details-diff' )->parse()
				);

			$output .= $formattedDiff;
		}

		$output .= Html::element( 'h3', [], $this->msg( 'abusefilter-log-details-vars' )->text() );

		// Build a table.
		$output .= $this->variablesFormatter->buildVarDumpTable( $vars );

		if ( $this->afPermissionManager->canSeePrivateDetails( $performer ) ) {
			$formDescriptor = [
				'Reason' => [
					'label-message' => 'abusefilter-view-privatedetails-reason',
					'type' => 'text',
					'size' => 45,
				],
			];

			$htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
			$htmlForm->setTitle( $this->getPageTitle( 'private/' . $id ) )
				->setWrapperLegendMsg( 'abusefilter-view-privatedetails-legend' )
				->setSubmitTextMsg( 'abusefilter-view-privatedetails-submit' )
				->prepareForm();

			$output .= $htmlForm->getHTML( false );
		}

		$out->addHTML( Html::rawElement( 'fieldset', [], $output ) );
	}

	/**
	 * Helper function to select a row with private details and some more context
	 * for an AbuseLog entry.
	 * @todo Create a service for this
	 *
	 * @param Authority $authority The user who's trying to view the row
	 * @param int $id The ID of the log entry
	 * @return Status A status object with the requested row stored in the value property,
	 *  or an error and no row.
	 */
	public static function getPrivateDetailsRow( Authority $authority, $id ) {
		$afPermissionManager = AbuseFilterServices::getPermissionManager();
		$dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();

		$row = $dbr->newSelectQueryBuilder()
			->select( [ 'afl_id', 'afl_user_text', 'afl_filter_id', 'afl_global', 'afl_timestamp', 'afl_ip',
				'af_id', 'af_public_comments', 'af_hidden' ] )
			->from( 'abuse_filter_log' )
			->leftJoin( 'abuse_filter', null, [ 'af_id=afl_filter_id', 'afl_global' => 0 ] )
			->where( [ 'afl_id' => $id ] )
			->caller( __METHOD__ )
			->fetchRow();

		$status = Status::newGood();
		if ( !$row ) {
			$status->fatal( 'abusefilter-log-nonexistent' );
			return $status;
		}

		$filterID = $row->afl_filter_id;
		$global = $row->afl_global;

		if ( $global ) {
			$lookup = AbuseFilterServices::getFilterLookup();
			$privacyLevel = $lookup->getFilter( $filterID, $global )->getPrivacyLevel();
		} else {
			$privacyLevel = $row->af_hidden;
		}

		if ( !$afPermissionManager->canSeeLogDetailsForFilter( $authority, $privacyLevel ) ) {
			$status->fatal( 'abusefilter-log-cannot-see-details' );
			return $status;
		}
		$status->setResult( true, $row );
		return $status;
	}

	/**
	 * Builds an HTML table with the private details for a given abuseLog entry.
	 *
	 * @param stdClass $row The row, as returned by self::getPrivateDetailsRow()
	 * @return string The HTML output
	 */
	private function buildPrivateDetailsTable( $row ) {
		$output = '';

		// Log ID
		$linkRenderer = $this->getLinkRenderer();
		$output .=
			Html::rawElement( 'tr', [],
				Html::element( 'td',
					[ 'style' => 'width: 30%;' ],
					$this->msg( 'abusefilter-log-details-id' )->text()
				) .
				Html::rawElement( 'td', [], $linkRenderer->makeKnownLink(
					$this->getPageTitle( $row->afl_id ),
					$this->getLanguage()->formatNumNoSeparators( $row->afl_id )
				) )
			);

		// Timestamp
		$output .=
			Html::rawElement( 'tr', [],
				Html::element( 'td',
					[ 'style' => 'width: 30%;' ],
					$this->msg( 'abusefilter-edit-builder-vars-timestamp-expanded' )->text()
				) .
				Html::element( 'td',
					[],
					$this->getLanguage()->userTimeAndDate( $row->afl_timestamp, $this->getUser() )
				)
			);

		// User
		$output .=
			Html::rawElement( 'tr', [],
				Html::element( 'td',
					[ 'style' => 'width: 30%;' ],
					$this->msg( 'abusefilter-edit-builder-vars-user-name' )->text()
				) .
				Html::element( 'td',
					[],
					$row->afl_user_text
				)
			);

		// Filter ID
		$output .=
			Html::rawElement( 'tr', [],
				Html::element( 'td',
					[ 'style' => 'width: 30%;' ],
					$this->msg( 'abusefilter-list-id' )->text()
				) .
				Html::rawElement( 'td', [], $linkRenderer->makeKnownLink(
					SpecialPage::getTitleFor( 'AbuseFilter', $row->af_id ),
					$this->getLanguage()->formatNum( $row->af_id )
				) )
			);

		// Filter description
		$output .=
			Html::rawElement( 'tr', [],
				Html::element( 'td',
					[ 'style' => 'width: 30%;' ],
					$this->msg( 'abusefilter-list-public' )->text()
				) .
				Html::element( 'td',
					[],
					$row->af_public_comments
				)
			);

		// IP address
		if ( $row->afl_ip !== '' ) {
			if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) &&
				$this->permissionManager->userHasRight( $this->getUser(), 'checkuser' )
			) {
				$CULink = '&nbsp;&middot;&nbsp;' . $linkRenderer->makeKnownLink(
					SpecialPage::getTitleFor(
						'CheckUser',
						$row->afl_ip
					),
					$this->msg( 'abusefilter-log-details-checkuser' )->text()
				);
			} else {
				$CULink = '';
			}
			$output .=
				Html::rawElement( 'tr', [],
					Html::element( 'td',
						[ 'style' => 'width: 30%;' ],
						$this->msg( 'abusefilter-log-details-ip' )->text()
					) .
					Html::rawElement(
						'td',
						[],
						self::getUserLinks( 0, $row->afl_ip ) . $CULink
					)
				);
		} else {
			$output .=
				Html::rawElement( 'tr', [],
					Html::element( 'td',
						[ 'style' => 'width: 30%;' ],
						$this->msg( 'abusefilter-log-details-ip' )->text()
					) .
					Html::element(
						'td',
						[],
						$this->msg( 'abusefilter-log-ip-not-available' )->text()
					)
				);
		}

		return Html::rawElement( 'fieldset', [],
			Html::element( 'legend', [],
				$this->msg( 'abusefilter-log-details-privatedetails' )->text()
			) .
			Html::rawElement( 'table',
				[
					'class' => 'wikitable mw-abuselog-private',
					'style' => 'width: 80%;'
				],
				Html::rawElement( 'thead', [],
					Html::rawElement( 'tr', [],
						Html::element( 'th', [],
							$this->msg( 'abusefilter-log-details-var' )->text()
						) .
						Html::element( 'th', [],
							$this->msg( 'abusefilter-log-details-val' )->text()
						)
					)
				) .
				Html::rawElement( 'tbody', [], $output )
			)
		);
	}

	/**
	 * @param int $id
	 * @return void
	 */
	public function showPrivateDetails( $id ) {
		$out = $this->getOutput();
		$user = $this->getUser();

		if ( !$this->afPermissionManager->canSeePrivateDetails( $user ) ) {
			$out->addWikiMsg( 'abusefilter-log-cannot-see-privatedetails' );

			return;
		}
		$request = $this->getRequest();

		// Make sure it is a valid request
		$token = $request->getVal( 'wpEditToken' );
		if ( !$request->wasPosted() || !$user->matchEditToken( $token ) ) {
			$out->addHTML(
				Html::rawElement(
					'p',
					[],
					Html::errorBox( $this->msg( 'abusefilter-invalid-request' )->params( $id )->parse() )
				)
			);

			return;
		}

		$reason = $request->getText( 'wpReason' );
		if ( !self::checkPrivateDetailsAccessReason( $reason ) ) {
			$out->addWikiMsg( 'abusefilter-noreason' );
			$this->showDetails( $id );
			return;
		}

		$status = self::getPrivateDetailsRow( $user, $id );
		if ( !$status->isGood() ) {
			$out->addWikiMsg( $status->getMessages()[0] );
			return;
		}
		$row = $status->getValue();

		// Log accessing private details
		if ( $this->getConfig()->get( 'AbuseFilterLogPrivateDetailsAccess' ) ) {
			self::addPrivateDetailsAccessLogEntry( $id, $reason, $user );
		}

		// Show private details (IP).
		$table = $this->buildPrivateDetailsTable( $row );
		$out->addHTML( $table );
	}

	/**
	 * If specifying a reason for viewing private details of abuse log is required
	 * then it makes sure that a reason is provided.
	 *
	 * @param string $reason
	 * @return bool
	 */
	public static function checkPrivateDetailsAccessReason( $reason ) {
		global $wgAbuseFilterPrivateDetailsForceReason;
		return ( !$wgAbuseFilterPrivateDetailsForceReason || strlen( $reason ) > 0 );
	}

	/**
	 * @param int $logID int The ID of the AbuseFilter log that was accessed
	 * @param string $reason The reason provided for accessing private details
	 * @param UserIdentity $userIdentity The user who accessed the private details
	 * @return void
	 */
	public static function addPrivateDetailsAccessLogEntry( $logID, $reason, UserIdentity $userIdentity ) {
		$target = self::getTitleFor( self::PAGE_NAME, (string)$logID );

		$logEntry = new ManualLogEntry( 'abusefilterprivatedetails', 'access' );
		$logEntry->setPerformer( $userIdentity );
		$logEntry->setTarget( $target );
		$logEntry->setParameters( [
			'4::logid' => $logID,
		] );
		$logEntry->setComment( $reason );

		$logEntry->insert();
	}

	/**
	 * @param int $userId
	 * @param string $userName
	 * @return string
	 */
	public static function getUserLinks( $userId, $userName ) {
		static $cache = [];

		if ( !isset( $cache[$userName][$userId] ) ) {
			$cache[$userName][$userId] = Linker::userLink( $userId, $userName ) .
				Linker::userToolLinks( $userId, $userName, true );
		}

		return $cache[$userName][$userId];
	}

	/**
	 * @param stdClass $row
	 * @param Authority $authority
	 * @param AbuseFilterPermissionManager $afPermissionManager
	 * @return string One of the self::VISIBILITY_* constants
	 */
	public static function getEntryVisibilityForUser(
		stdClass $row,
		Authority $authority,
		AbuseFilterPermissionManager $afPermissionManager
	): string {
		if ( $row->afl_deleted && !$afPermissionManager->canSeeHiddenLogEntries( $authority ) ) {
			return self::VISIBILITY_HIDDEN;
		}
		if ( !$row->afl_rev_id ) {
			return self::VISIBILITY_VISIBLE;
		}
		$revRec = MediaWikiServices::getInstance()
			->getRevisionLookup()
			->getRevisionById( (int)$row->afl_rev_id );
		if ( !$revRec || $revRec->getVisibility() === 0 ) {
			return self::VISIBILITY_VISIBLE;
		}
		return $revRec->audienceCan( RevisionRecord::SUPPRESSED_ALL, RevisionRecord::FOR_THIS_USER, $authority )
			? self::VISIBILITY_VISIBLE
			: self::VISIBILITY_HIDDEN_IMPLICIT;
	}
}
PK       ! R*(  (  "  Special/BlockedExternalDomains.phpnu Iw        <?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
 */
namespace MediaWiki\Extension\AbuseFilter\Special;

use ErrorPageError;
use MediaWiki\Extension\AbuseFilter\BlockedDomainStorage;
use MediaWiki\Html\Html;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\TitleValue;
use PermissionsError;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * List and manage blocked external domains
 *
 * @ingroup SpecialPage
 */
class BlockedExternalDomains extends SpecialPage {
	private BlockedDomainStorage $blockedDomainStorage;
	private WANObjectCache $wanCache;

	public function __construct(
		BlockedDomainStorage $blockedDomainStorage,
		WANObjectCache $wanCache
	) {
		parent::__construct( 'BlockedExternalDomains' );
		$this->blockedDomainStorage = $blockedDomainStorage;
		$this->wanCache = $wanCache;
	}

	/** @inheritDoc */
	public function execute( $par ) {
		if ( !$this->getConfig()->get( 'AbuseFilterEnableBlockedExternalDomain' ) ) {
			throw new ErrorPageError( 'abusefilter-disabled', 'disabledspecialpage-disabled' );
		}
		$this->setHeaders();
		$this->outputHeader();
		$this->addHelpLink( 'Manual:BlockedExternalDomains' );

		$request = $this->getRequest();
		switch ( $par ) {
			case 'remove':
				$this->showRemoveForm( $request->getVal( 'domain' ) );
				break;
			case 'add':
				$this->showAddForm( $request->getVal( 'domain' ) );
				break;
			default:
				$this->showList();
				break;
		}
	}

	private function showList() {
		$out = $this->getOutput();
		$out->setPageTitleMsg( $this->msg( 'abusefilter-blocked-domains-title' ) );
		$out->wrapWikiMsg( "$1", 'abusefilter-blocked-domains-intro' );

		// Direct editing of this page is blocked via EditPermissionHandler
		$userCanManage = $this->getAuthority()->isAllowed( 'abusefilter-modify-blocked-external-domains' );

		// Show form to add a blocked domain
		if ( $userCanManage ) {
			$fields = [
				'Domain' => [
					'type' => 'text',
					'label' => $this->msg( 'abusefilter-blocked-domains-domain' )->plain(),
					'required' => true,
				],
				'Notes' => [
					'type' => 'text',
					'maxlength' => 255,
					'label' => $this->msg( 'abusefilter-blocked-domains-notes' )->plain(),
					'size' => 250,
				],
			];

			HTMLForm::factory( 'ooui', $fields, $this->getContext() )
				->setAction( $this->getPageTitle( 'add' )->getLocalURL() )
				->setWrapperLegendMsg( 'abusefilter-blocked-domains-add-heading' )
				->setHeaderHtml( $this->msg( 'abusefilter-blocked-domains-add-explanation' )->parseAsBlock() )
				->setSubmitCallback( [ $this, 'processAddForm' ] )
				->setSubmitTextMsg( 'abusefilter-blocked-domains-add-submit' )
				->show();

			if ( $out->getRedirect() !== '' ) {
				return;
			}
		}

		$res = $this->blockedDomainStorage->loadConfig( IDBAccessObject::READ_LATEST );
		if ( !$res->isGood() ) {
			return;
		}

		$content = Html::element( 'th', [], $this->msg( 'abusefilter-blocked-domains-domain-header' )->text() ) .
			Html::element( 'th', [], $this->msg( 'abusefilter-blocked-domains-notes-header' )->text() );
		if ( $userCanManage ) {
			$content .= Html::element(
				'th',
				[],
				$this->msg( 'abusefilter-blocked-domains-addedby-header' )->text()
			);
			$content .= Html::element(
				'th',
				[ 'class' => 'unsortable' ],
				$this->msg( 'abusefilter-blocked-domains-actions-header' )->text()
			);
		}
		$thead = Html::rawElement( 'tr', [], $content );

		// Parsing each row is expensive, put it behind WAN cache
		// with md5 checksum, we make sure changes to the domain list
		// invalidate the cache
		$cacheKey = $this->wanCache->makeKey(
			'abuse-filter-special-blocked-external-domains-rows',
			md5( json_encode( $res->getValue() ) ),
			(int)$userCanManage
		);
		$tbody = $this->wanCache->getWithSetCallback(
			$cacheKey,
			WANObjectCache::TTL_DAY,
			function () use ( $res, $userCanManage ) {
				$tbody = '';
				foreach ( $res->getValue() as $domain ) {
					$tbody .= $this->doDomainRow( $domain, $userCanManage );
				}
				return $tbody;
			}
		);

		$out->addModuleStyles( [ 'jquery.tablesorter.styles', 'mediawiki.pager.styles' ] );
		$out->addModules( 'jquery.tablesorter' );
		$out->addHTML( Html::rawElement(
			'table',
			[ 'class' => 'mw-datatable sortable' ],
			Html::rawElement( 'thead', [], $thead ) .
			Html::rawElement( 'tbody', [], $tbody )
		) );
	}

	/**
	 * Show the row in the table
	 *
	 * @param array $domain domain data
	 * @param bool $showManageActions whether to add manage actions
	 * @return string HTML for the row
	 */
	private function doDomainRow( $domain, $showManageActions ) {
		$newRow = Html::rawElement( 'td', [], Html::element( 'code', [], $domain['domain'] ) );

		$newRow .= Html::rawElement( 'td', [], $this->getOutput()->parseInlineAsInterface( $domain['notes'] ) );

		if ( $showManageActions ) {
			if ( isset( $domain['addedBy'] ) ) {
				$addedBy = $this->getLinkRenderer()->makeLink(
					new TitleValue( 3, $domain['addedBy'] ),
					$domain['addedBy']
				);
			} else {
				$addedBy = '';
			}
			$newRow .= Html::rawElement( 'td', [], $addedBy );

			$actionLink = $this->getLinkRenderer()->makeKnownLink(
				$this->getPageTitle( 'remove' ),
				$this->msg( 'abusefilter-blocked-domains-remove' )->text(),
				[],
				[ 'domain' => $domain['domain'] ]
			);
			$newRow .= Html::rawElement( 'td', [], $actionLink );
		}

		return Html::rawElement( 'tr', [], $newRow ) . "\n";
	}

	/**
	 * Show form for removing a domain from the blocked list
	 *
	 * @param string $domain
	 * @return void
	 */
	private function showRemoveForm( $domain ) {
		if ( !$this->getAuthority()->isAllowed( 'editsitejson' ) ) {
			throw new PermissionsError( 'editsitejson' );
		}

		$out = $this->getOutput();
		$out->setPageTitleMsg( $this->msg( 'abusefilter-blocked-domains-remove-title' ) );
		$out->addBacklinkSubtitle( $this->getPageTitle() );

		$preText = $this->msg( 'abusefilter-blocked-domains-remove-explanation-initial', $domain )->parseAsBlock();

		$fields = [
			'Domain' => [
				'type' => 'text',
				'label' => $this->msg( 'abusefilter-blocked-domains-domain' )->plain(),
				'required' => true,
				'default' => $domain,
			],
			'Notes' => [
				'type' => 'text',
				'maxlength' => 255,
				'label' => $this->msg( 'abusefilter-blocked-domains-notes' )->plain(),
				'size' => 250,
			],
		];

		HTMLForm::factory( 'ooui', $fields, $this->getContext() )
			->setAction( $this->getPageTitle( 'remove' )->getLocalURL() )
			->setSubmitCallback( function ( $data, $form ) {
				return $this->processRemoveForm( $data, $form );
			} )
			->setSubmitTextMsg( 'abusefilter-blocked-domains-remove-submit' )
			->setSubmitDestructive()
			->addPreHtml( $preText )
			->show();
	}

	/**
	 * Process the form for removing a domain from the blocked list
	 *
	 * @param array $data request data
	 * @param HTMLForm $form
	 * @return bool whether the action was successful or not
	 */
	public function processRemoveForm( array $data, HTMLForm $form ) {
		$out = $form->getContext()->getOutput();
		$domain = $this->blockedDomainStorage->validateDomain( $data['Domain'] );
		if ( $domain === false ) {
			$out->wrapWikiTextAsInterface( 'error', 'Invalid URL' );
			return false;
		}

		$rev = $this->blockedDomainStorage->removeDomain(
			$domain,
			$data['Notes'] ?? '',
			$this->getUser()
		);

		if ( !$rev ) {
			$out->wrapWikiTextAsInterface( 'error', 'Save failed' );
			return false;
		}

		$out->redirect( $this->getPageTitle()->getLocalURL() );
		return true;
	}

	/**
	 * Show form for adding a domain to the blocked list
	 *
	 * @param string $domain
	 * @return void
	 */
	private function showAddForm( $domain ) {
		if ( !$this->getAuthority()->isAllowed( 'editsitejson' ) ) {
			throw new PermissionsError( 'editsitejson' );
		}

		$out = $this->getOutput();
		$out->setPageTitleMsg( $this->msg( "abusefilter-blocked-domains-add-heading" ) );
		$out->addBacklinkSubtitle( $this->getPageTitle() );

		$preText = $this->msg( "abusefilter-blocked-domains-add-explanation", $domain )->parseAsBlock();

		$fields = [
			'Domain' => [
				'type' => 'text',
				'label' => $this->msg( 'abusefilter-blocked-domains-domain' )->plain(),
				'required' => true,
				'default' => $domain,
			],
			'Notes' => [
				'type' => 'text',
				'maxlength' => 255,
				'label' => $this->msg( 'abusefilter-blocked-domains-notes' )->plain(),
				'size' => 250,
			],
		];

		HTMLForm::factory( 'ooui', $fields, $this->getContext() )
			->setAction( $this->getPageTitle( 'add' )->getLocalURL() )
			->setSubmitCallback( function ( $data, $form ) {
				return $this->processAddForm( $data, $form );
			} )
			->setSubmitTextMsg( "abusefilter-blocked-domains-add-submit" )
			->addPreHtml( $preText )
			->show();
	}

	/**
	 * Process the form for adding a domain to the blocked list
	 *
	 * @param array $data request data
	 * @param HTMLForm $form
	 * @return bool whether the action was successful or not
	 */
	private function processAddForm( array $data, HTMLForm $form ) {
		$out = $form->getContext()->getOutput();

		$domain = $this->blockedDomainStorage->validateDomain( $data['Domain'] );
		if ( $domain === false ) {
			$out->wrapWikiTextAsInterface( 'error', 'Invalid URL' );
			return false;
		}
		$rev = $this->blockedDomainStorage->addDomain(
			$domain,
			$data['Notes'] ?? '',
			$this->getUser()
		);

		if ( !$rev ) {
			$out->wrapWikiTextAsInterface( 'error', 'Save failed' );
			return false;
		}

		$out->redirect( $this->getPageTitle()->getLocalURL() );
		return true;
	}

	/** @inheritDoc */
	protected function getGroupName() {
		return 'spam';
	}

	/** @inheritDoc */
	public function isListed() {
		return $this->getConfig()->get( 'AbuseFilterEnableBlockedExternalDomain' );
	}
}
PK       ! aF        Special/SpecialAbuseFilter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Special;

use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\AbuseLoggerFactory;
use MediaWiki\Extension\AbuseFilter\CentralDBManager;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesFactory;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxBuilderFactory;
use MediaWiki\Extension\AbuseFilter\FilterImporter;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use MediaWiki\Extension\AbuseFilter\FilterProfiler;
use MediaWiki\Extension\AbuseFilter\FilterStore;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesFormatter;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Extension\AbuseFilter\View\AbuseFilterView;
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\Html\Html;
use MediaWiki\Title\Title;
use Wikimedia\ObjectFactory\ObjectFactory;

class SpecialAbuseFilter extends AbuseFilterSpecialPage {

	private const PAGE_NAME = 'AbuseFilter';

	/**
	 * @var ObjectFactory
	 */
	private $objectFactory;

	private const SERVICES_PER_VIEW = [
		AbuseFilterViewDiff::class => [
			AbuseFilterPermissionManager::SERVICE_NAME,
			SpecsFormatter::SERVICE_NAME,
			FilterLookup::SERVICE_NAME,
		],
		AbuseFilterViewEdit::class => [
			'DBLoadBalancerFactory',
			'PermissionManager',
			AbuseFilterPermissionManager::SERVICE_NAME,
			FilterProfiler::SERVICE_NAME,
			FilterLookup::SERVICE_NAME,
			FilterImporter::SERVICE_NAME,
			FilterStore::SERVICE_NAME,
			EditBoxBuilderFactory::SERVICE_NAME,
			ConsequencesRegistry::SERVICE_NAME,
			SpecsFormatter::SERVICE_NAME,
		],
		AbuseFilterViewExamine::class => [
			'DBLoadBalancerFactory',
			AbuseFilterPermissionManager::SERVICE_NAME,
			FilterLookup::SERVICE_NAME,
			EditBoxBuilderFactory::SERVICE_NAME,
			VariablesBlobStore::SERVICE_NAME,
			VariablesFormatter::SERVICE_NAME,
			VariablesManager::SERVICE_NAME,
			VariableGeneratorFactory::SERVICE_NAME,
			AbuseLoggerFactory::SERVICE_NAME
		],
		AbuseFilterViewHistory::class => [
			'UserNameUtils',
			'LinkBatchFactory',
			AbuseFilterPermissionManager::SERVICE_NAME,
			FilterLookup::SERVICE_NAME,
			SpecsFormatter::SERVICE_NAME,
		],
		AbuseFilterViewImport::class => [
			AbuseFilterPermissionManager::SERVICE_NAME,
		],
		AbuseFilterViewList::class => [
			'LinkBatchFactory',
			'ConnectionProvider',
			AbuseFilterPermissionManager::SERVICE_NAME,
			FilterProfiler::SERVICE_NAME,
			SpecsFormatter::SERVICE_NAME,
			CentralDBManager::SERVICE_NAME,
		],
		AbuseFilterViewRevert::class => [
			'DBLoadBalancerFactory',
			'UserFactory',
			AbuseFilterPermissionManager::SERVICE_NAME,
			FilterLookup::SERVICE_NAME,
			ConsequencesFactory::SERVICE_NAME,
			VariablesBlobStore::SERVICE_NAME,
			SpecsFormatter::SERVICE_NAME,
		],
		AbuseFilterViewTestBatch::class => [
			'DBLoadBalancerFactory',
			AbuseFilterPermissionManager::SERVICE_NAME,
			EditBoxBuilderFactory::SERVICE_NAME,
			RuleCheckerFactory::SERVICE_NAME,
			VariableGeneratorFactory::SERVICE_NAME,
		],
		AbuseFilterViewTools::class => [
			AbuseFilterPermissionManager::SERVICE_NAME,
			EditBoxBuilderFactory::SERVICE_NAME,
		],
	];

	/**
	 * @param AbuseFilterPermissionManager $afPermissionManager
	 * @param ObjectFactory $objectFactory
	 */
	public function __construct(
		AbuseFilterPermissionManager $afPermissionManager,
		ObjectFactory $objectFactory
	) {
		parent::__construct( self::PAGE_NAME, 'abusefilter-view', $afPermissionManager );
		$this->objectFactory = $objectFactory;
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function doesWrites() {
		return true;
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	protected function getGroupName() {
		return 'wiki';
	}

	/**
	 * @param string|null $subpage
	 */
	public function execute( $subpage ) {
		$out = $this->getOutput();
		$request = $this->getRequest();

		$out->addModuleStyles( 'ext.abuseFilter' );

		$this->setHeaders();
		$this->addHelpLink( 'Extension:AbuseFilter' );

		$this->checkPermissions();

		if ( $request->getVal( 'result' ) === 'success' ) {
			$out->setSubtitle( $this->msg( 'abusefilter-edit-done-subtitle' ) );
			$changedFilter = intval( $request->getVal( 'changedfilter' ) );
			$changeId = intval( $request->getVal( 'changeid' ) );
			$out->addHTML( Html::successBox(
				$this->msg(
					'abusefilter-edit-done',
					$changedFilter,
					$changeId,
					$this->getLanguage()->formatNum( $changedFilter )
				)->parse()
			) );
		}

		[ $view, $pageType, $params ] = $this->getViewClassAndPageType( $subpage );

		// Links at the top
		$this->addNavigationLinks( $pageType );

		$view = $this->instantiateView( $view, $params );
		$view->show();
	}

	/**
	 * Instantiate the view class
	 *
	 * @suppress PhanTypeInvalidCallableArraySize
	 *
	 * @param class-string<AbuseFilterView> $viewClass
	 * @param array $params
	 * @return AbuseFilterView
	 */
	public function instantiateView( string $viewClass, array $params ): AbuseFilterView {
		return $this->objectFactory->createObject( [
			'class' => $viewClass,
			'services' => self::SERVICES_PER_VIEW[$viewClass],
			'args' => [ $this->getContext(), $this->getLinkRenderer(), self::PAGE_NAME, $params ]
		] );
	}

	/**
	 * Determine the view class to instantiate
	 *
	 * @param string|null $subpage
	 * @return array A tuple of three elements:
	 *      - a subclass of AbuseFilterView
	 *      - type of page for addNavigationLinks
	 *      - array of parameters for the class
	 * @phan-return array{0:class-string,1:string,2:array}
	 */
	public function getViewClassAndPageType( $subpage ): array {
		// Filter by removing blanks.
		$params = array_values( array_filter(
			explode( '/', $subpage ?: '' ),
			static function ( $value ) {
				return $value !== '';
			}
		) );

		if ( $subpage === 'tools' ) {
			return [ AbuseFilterViewTools::class, 'tools', [] ];
		}

		if ( $subpage === 'import' ) {
			return [ AbuseFilterViewImport::class, 'import', [] ];
		}

		if ( is_numeric( $subpage ) || $subpage === 'new' ) {
			return [
				AbuseFilterViewEdit::class,
				'edit',
				[ 'filter' => is_numeric( $subpage ) ? (int)$subpage : null ]
			];
		}

		if ( $params ) {
			if ( count( $params ) === 2 && $params[0] === 'revert' && is_numeric( $params[1] ) ) {
				$params[1] = (int)$params[1];
				return [ AbuseFilterViewRevert::class, 'revert', $params ];
			}

			if ( $params[0] === 'test' ) {
				return [ AbuseFilterViewTestBatch::class, 'test', $params ];
			}

			if ( $params[0] === 'examine' ) {
				return [ AbuseFilterViewExamine::class, 'examine', $params ];
			}

			if ( $params[0] === 'history' || $params[0] === 'log' ) {
				if ( count( $params ) <= 2 ) {
					$params = isset( $params[1] ) ? [ 'filter' => (int)$params[1] ] : [];
					return [ AbuseFilterViewHistory::class, 'recentchanges', $params ];
				}
				if ( count( $params ) === 4 && $params[2] === 'item' ) {
					return [
						AbuseFilterViewEdit::class,
						'',
						[ 'filter' => (int)$params[1], 'history' => (int)$params[3] ]
					];
				}
				if ( count( $params ) === 5 && $params[2] === 'diff' ) {
					// Special:AbuseFilter/history/<filter>/diff/<oldid>/<newid>
					return [ AbuseFilterViewDiff::class, '', $params ];
				}
			}
		}

		return [ AbuseFilterViewList::class, 'home', [] ];
	}

	/**
	 * Static variant to get the associated Title.
	 *
	 * @param string|int $subpage
	 * @return Title
	 */
	public static function getTitleForSubpage( $subpage ): Title {
		return self::getTitleFor( self::PAGE_NAME, $subpage );
	}
}
PK       ! w7       AbuseFilterPermissionManager.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Extension\AbuseFilter\Filter\AbstractFilter;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Permissions\Authority;
use MediaWiki\User\Options\UserOptionsLookup;

/**
 * This class simplifies the interactions between the AbuseFilter code and Authority, knowing
 * what rights are required to perform AF-related actions.
 */
class AbuseFilterPermissionManager {
	public const SERVICE_NAME = 'AbuseFilterPermissionManager';

	public const CONSTRUCTOR_OPTIONS = [
		'AbuseFilterProtectedVariables',
	];

	/**
	 * @var string[] Protected variables defined in config via AbuseFilterProtectedVariables
	 */
	private $protectedVariables;

	private UserOptionsLookup $userOptionsLookup;

	/**
	 * @param ServiceOptions $options
	 * @param UserOptionsLookup $userOptionsLookup
	 */
	public function __construct(
		ServiceOptions $options,
		UserOptionsLookup $userOptionsLookup
	) {
		$this->protectedVariables = $options->get( 'AbuseFilterProtectedVariables' );
		$this->userOptionsLookup = $userOptionsLookup;
	}

	/**
	 * @param Authority $performer
	 * @return bool
	 */
	public function canEdit( Authority $performer ): bool {
		$block = $performer->getBlock();
		return (
			!( $block && $block->isSitewide() ) &&
			$performer->isAllowed( 'abusefilter-modify' )
		);
	}

	/**
	 * @param Authority $performer
	 * @return bool
	 */
	public function canEditGlobal( Authority $performer ): bool {
		return $performer->isAllowed( 'abusefilter-modify-global' );
	}

	/**
	 * Whether the user can edit the given filter.
	 *
	 * @param Authority $performer
	 * @param AbstractFilter $filter
	 * @return bool
	 */
	public function canEditFilter( Authority $performer, AbstractFilter $filter ): bool {
		return (
			$this->canEdit( $performer ) &&
			!( $filter->isGlobal() && !$this->canEditGlobal( $performer ) )
		);
	}

	/**
	 * Whether the user can edit a filter with restricted actions enabled.
	 *
	 * @param Authority $performer
	 * @return bool
	 */
	public function canEditFilterWithRestrictedActions( Authority $performer ): bool {
		return $performer->isAllowed( 'abusefilter-modify-restricted' );
	}

	/**
	 * @param Authority $performer
	 * @return bool
	 */
	public function canViewPrivateFilters( Authority $performer ): bool {
		$block = $performer->getBlock();
		return (
			!( $block && $block->isSitewide() ) &&
			$performer->isAllowedAny(
				'abusefilter-modify',
				'abusefilter-view-private'
			)
		);
	}

	/**
	 * @param Authority $performer
	 * @return bool
	 */
	public function canViewProtectedVariables( Authority $performer ) {
		$block = $performer->getBlock();
		return (
			!( $block && $block->isSitewide() ) &&
			$performer->isAllowed( 'abusefilter-access-protected-vars' )
		);
	}

	/**
	 * @param Authority $performer
	 * @return bool
	 */
	public function canViewProtectedVariableValues( Authority $performer ) {
		return (
			$this->canViewProtectedVariables( $performer ) &&
			$this->userOptionsLookup->getOption(
				$performer->getUser(),
				'abusefilter-protected-vars-view-agreement'
			)
		);
	}

	/**
	 * Return all used protected variables from an array of variables. Ignore user permissions.
	 *
	 * @param string[] $usedVariables
	 * @return string[]
	 */
	public function getUsedProtectedVariables( array $usedVariables ): array {
		return array_intersect( $usedVariables, $this->protectedVariables );
	}

	/**
	 * Check if the filter uses variables that the user is not allowed to use (i.e., variables that are protected, if
	 * the user can't view protected variables), and return them.
	 *
	 * @param Authority $performer
	 * @param string[] $usedVariables
	 * @return string[]
	 */
	public function getForbiddenVariables( Authority $performer, array $usedVariables ): array {
		$usedProtectedVariables = array_intersect( $usedVariables, $this->protectedVariables );
		// All good if protected variables aren't used, or the user can view them.
		if ( count( $usedProtectedVariables ) === 0 || $this->canViewProtectedVariables( $performer ) ) {
			return [];
		}
		return $usedProtectedVariables;
	}

	/**
	 * Return an array of protected variables (originally defined in configuration)
	 *
	 * @return string[]
	 */
	public function getProtectedVariables() {
		return $this->protectedVariables;
	}

	/**
	 * @param Authority $performer
	 * @return bool
	 */
	public function canViewPrivateFiltersLogs( Authority $performer ): bool {
		return $this->canViewPrivateFilters( $performer ) ||
			$performer->isAllowed( 'abusefilter-log-private' );
	}

	/**
	 * @param Authority $performer
	 * @return bool
	 */
	public function canViewAbuseLog( Authority $performer ): bool {
		return $performer->isAllowed( 'abusefilter-log' );
	}

	/**
	 * @param Authority $performer
	 * @return bool
	 */
	public function canHideAbuseLog( Authority $performer ): bool {
		return $performer->isAllowed( 'abusefilter-hide-log' );
	}

	/**
	 * @param Authority $performer
	 * @return bool
	 */
	public function canRevertFilterActions( Authority $performer ): bool {
		return $performer->isAllowed( 'abusefilter-revert' );
	}

	/**
	 * @param Authority $performer
	 * @param int $privacyLevel Bitmask of privacy flags
	 * @todo Take a Filter parameter
	 * @return bool
	 */
	public function canSeeLogDetailsForFilter( Authority $performer, int $privacyLevel ): bool {
		if ( !$this->canSeeLogDetails( $performer ) ) {
			return false;
		}

		if ( $privacyLevel === Flags::FILTER_PUBLIC ) {
			return true;
		}
		if ( FilterUtils::isHidden( $privacyLevel ) && !$this->canViewPrivateFiltersLogs( $performer ) ) {
			return false;
		}
		if ( FilterUtils::isProtected( $privacyLevel ) && !$this->canViewProtectedVariables( $performer ) ) {
			return false;
		}

		return true;
	}

	/**
	 * @param Authority $performer
	 * @return bool
	 */
	public function canSeeLogDetails( Authority $performer ): bool {
		return $performer->isAllowed( 'abusefilter-log-detail' );
	}

	/**
	 * @param Authority $performer
	 * @return bool
	 */
	public function canSeePrivateDetails( Authority $performer ): bool {
		return $performer->isAllowed( 'abusefilter-privatedetails' );
	}

	/**
	 * @param Authority $performer
	 * @return bool
	 */
	public function canSeeHiddenLogEntries( Authority $performer ): bool {
		return $performer->isAllowed( 'abusefilter-hidden-log' );
	}

	/**
	 * @param Authority $performer
	 * @return bool
	 */
	public function canUseTestTools( Authority $performer ): bool {
		// TODO: make independent
		return $this->canViewPrivateFilters( $performer );
	}

}
PK       ! B       Variables/VariablesFormatter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Variables;

use MediaWiki\Extension\AbuseFilter\KeywordsManager;
use MediaWiki\Html\Html;
use MessageLocalizer;

/**
 * Pretty-prints the content of a VariableHolder for use e.g. in AbuseLog hit details
 */
class VariablesFormatter {
	public const SERVICE_NAME = 'AbuseFilterVariablesFormatter';

	/** @var KeywordsManager */
	private $keywordsManager;
	/** @var VariablesManager */
	private $varManager;
	/** @var MessageLocalizer */
	private $messageLocalizer;

	/**
	 * @param KeywordsManager $keywordsManager
	 * @param VariablesManager $variablesManager
	 * @param MessageLocalizer $messageLocalizer
	 */
	public function __construct(
		KeywordsManager $keywordsManager,
		VariablesManager $variablesManager,
		MessageLocalizer $messageLocalizer
	) {
		$this->keywordsManager = $keywordsManager;
		$this->varManager = $variablesManager;
		$this->messageLocalizer = $messageLocalizer;
	}

	/**
	 * @param MessageLocalizer $messageLocalizer
	 */
	public function setMessageLocalizer( MessageLocalizer $messageLocalizer ): void {
		$this->messageLocalizer = $messageLocalizer;
	}

	/**
	 * @param VariableHolder $varHolder
	 * @return string
	 */
	public function buildVarDumpTable( VariableHolder $varHolder ): string {
		$vars = $this->varManager->exportAllVars( $varHolder );

		$output = '';

		// Now, build the body of the table.
		foreach ( $vars as $key => $value ) {
			$key = strtolower( $key );

			$varMsgKey = $this->keywordsManager->getMessageKeyForVar( $key );
			if ( $varMsgKey ) {
				$varMsg = $this->messageLocalizer->msg( $varMsgKey );
				$arg = Html::element( 'code', [], $key );
				if ( str_contains( $varMsg->plain(), '$1' ) ) {
					$keyDisplay = $varMsg->params( $arg )->parse();
				} else {
					// workaround due to 1904cf8 (temporary?)
					$keyDisplay = $varMsg->parse() . ' '
						. $this->messageLocalizer->msg( 'parentheses' )->rawParams( $arg )->escaped();
				}
			} else {
				$keyDisplay = Html::element( 'code', [], $key );
			}

			$value = Html::element(
				'div',
				[ 'class' => 'mw-abuselog-var-value' ],
				self::formatVar( $value )
			);

			$trow =
				Html::rawElement( 'td', [ 'class' => 'mw-abuselog-var' ], $keyDisplay ) .
				Html::rawElement( 'td', [ 'class' => 'mw-abuselog-var-value' ], $value );
			$output .=
				Html::rawElement( 'tr',
					[ 'class' => "mw-abuselog-details-$key mw-abuselog-value" ], $trow
				) . "\n";
		}

		return Html::rawElement( 'table', [ 'class' => 'mw-abuselog-details' ],
			Html::rawElement( 'thead', [],
				Html::rawElement( 'tr', [],
					Html::element( 'th', [],
						$this->messageLocalizer->msg( 'abusefilter-log-details-var' )->text()
					) .
					Html::element( 'th', [],
						$this->messageLocalizer->msg( 'abusefilter-log-details-val' )->text()
					)
				)
			) .
			Html::rawElement( 'tbody', [], $output )
		);
	}

	/**
	 * @param mixed $var
	 * @param string $indent
	 * @return string
	 */
	public static function formatVar( $var, string $indent = '' ): string {
		if ( $var === [] ) {
			return '[]';
		} elseif ( is_array( $var ) ) {
			$ret = '[';
			$indent .= "\t";
			foreach ( $var as $key => $val ) {
				$ret .= "\n$indent" . self::formatVar( $key, $indent ) .
					' => ' . self::formatVar( $val, $indent ) . ',';
			}
			// Strip trailing commas
			return substr( $ret, 0, -1 ) . "\n" . substr( $indent, 0, -1 ) . ']';
		} elseif ( is_string( $var ) ) {
			// Don't escape the string (specifically backslashes) to avoid displaying wrong stuff
			return "'$var'";
		} elseif ( $var === null ) {
			return 'null';
		} elseif ( is_float( $var ) ) {
			// Don't let float precision produce weirdness
			return (string)$var;
		}
		return var_export( $var, true );
	}
}
PK       ! $\       Variables/LazyLoadedVariable.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Variables;

class LazyLoadedVariable {
	/**
	 * @var string The method used to compute the variable
	 */
	private $method;
	/**
	 * @var array Parameters to be used with the specified method
	 */
	private $parameters;

	/**
	 * @param string $method
	 * @param array $parameters
	 */
	public function __construct( string $method, array $parameters ) {
		$this->method = $method;
		$this->parameters = $parameters;
	}

	/**
	 * @return string
	 */
	public function getMethod(): string {
		return $this->method;
	}

	/**
	 * @return array
	 */
	public function getParameters(): array {
		return $this->parameters;
	}
}
PK       ! Pr  r    Variables/VariablesManager.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Variables;

use LogicException;
use MediaWiki\Extension\AbuseFilter\KeywordsManager;
use MediaWiki\Extension\AbuseFilter\Parser\AFPData;

/**
 * Service that allows manipulating a VariableHolder
 */
class VariablesManager {
	public const SERVICE_NAME = 'AbuseFilterVariablesManager';
	/**
	 * Used in self::getVar() to determine what to do if the requested variable is missing. See
	 * the docs of that method for an explanation.
	 */
	public const GET_LAX = 0;
	public const GET_STRICT = 1;
	public const GET_BC = 2;

	/** @var KeywordsManager */
	private $keywordsManager;
	/** @var LazyVariableComputer */
	private $lazyComputer;

	/**
	 * @param KeywordsManager $keywordsManager
	 * @param LazyVariableComputer $lazyComputer
	 */
	public function __construct(
		KeywordsManager $keywordsManager,
		LazyVariableComputer $lazyComputer
	) {
		$this->keywordsManager = $keywordsManager;
		$this->lazyComputer = $lazyComputer;
	}

	/**
	 * Checks whether any deprecated variable is stored with the old name, and replaces it with
	 * the new name. This should normally only happen when a DB dump is retrieved from the DB.
	 *
	 * @param VariableHolder $holder
	 */
	public function translateDeprecatedVars( VariableHolder $holder ): void {
		$deprecatedVars = $this->keywordsManager->getDeprecatedVariables();
		foreach ( $holder->getVars() as $name => $value ) {
			if ( array_key_exists( $name, $deprecatedVars ) ) {
				$holder->setVar( $deprecatedVars[$name], $value );
				$holder->removeVar( $name );
			}
		}
	}

	/**
	 * Get a variable from the current object
	 *
	 * @param VariableHolder $holder
	 * @param string $varName The variable name
	 * @param int $mode One of the self::GET_* constants, determines how to behave when the variable is unset:
	 *  - GET_STRICT -> Throw UnsetVariableException
	 *  - GET_LAX -> Return a DUNDEFINED AFPData
	 *  - GET_BC -> Return a DNULL AFPData (this should only be used for BC, see T230256)
	 * @return AFPData
	 */
	public function getVar(
		VariableHolder $holder,
		string $varName,
		$mode = self::GET_STRICT
	): AFPData {
		$varName = strtolower( $varName );
		if ( $holder->varIsSet( $varName ) ) {
			/** @var LazyLoadedVariable|AFPData $variable */
			$variable = $holder->getVarThrow( $varName );
			if ( $variable instanceof LazyLoadedVariable ) {
				$getVarCB = function ( string $varName ) use ( $holder ): AFPData {
					return $this->getVar( $holder, $varName );
				};
				$value = $this->lazyComputer->compute( $variable, $holder, $getVarCB );
				$holder->setVar( $varName, $value );
				return $value;
			} elseif ( $variable instanceof AFPData ) {
				return $variable;
			} else {
				// @codeCoverageIgnoreStart
				throw new \UnexpectedValueException(
					"Variable $varName has unexpected type " . get_debug_type( $variable )
				);
				// @codeCoverageIgnoreEnd
			}
		}

		// The variable is not set.
		switch ( $mode ) {
			case self::GET_STRICT:
				throw new UnsetVariableException( $varName );
			case self::GET_LAX:
				return new AFPData( AFPData::DUNDEFINED );
			case self::GET_BC:
				// Old behaviour, which can sometimes lead to unexpected results (e.g.
				// `edit_delta < -5000` will match any non-edit action).
				return new AFPData( AFPData::DNULL );
			default:
				// @codeCoverageIgnoreStart
				throw new LogicException( "Mode '$mode' not recognized." );
				// @codeCoverageIgnoreEnd
		}
	}

	/**
	 * Dump all variables stored in the holder in their native types.
	 * If you want a not yet set variable to be included in the results you can
	 * either set $compute to an array with the name of the variable or set
	 * $compute to true to compute all not yet set variables.
	 *
	 * @param VariableHolder $holder
	 * @param array|bool $compute Variables we should compute if not yet set
	 * @param bool $includeUserVars Include user set variables
	 * @return array
	 */
	public function dumpAllVars(
		VariableHolder $holder,
		$compute = [],
		bool $includeUserVars = false
	): array {
		$coreVariables = [];

		if ( !$includeUserVars ) {
			// Compile a list of all variables set by the extension to be able
			// to filter user set ones by name
			$activeVariables = array_keys( $this->keywordsManager->getVarsMappings() );
			$deprecatedVariables = array_keys( $this->keywordsManager->getDeprecatedVariables() );
			$disabledVariables = array_keys( $this->keywordsManager->getDisabledVariables() );
			$coreVariables = array_merge( $activeVariables, $deprecatedVariables, $disabledVariables );
			$coreVariables = array_map( 'strtolower', $coreVariables );
		}

		$exported = [];
		foreach ( array_keys( $holder->getVars() ) as $varName ) {
			$computeThis = ( is_array( $compute ) && in_array( $varName, $compute ) ) || $compute === true;
			if (
				( $includeUserVars || in_array( strtolower( $varName ), $coreVariables ) ) &&
				// Only include variables set in the extension in case $includeUserVars is false
				( $computeThis || $holder->getVarThrow( $varName ) instanceof AFPData )
			) {
				$exported[$varName] = $this->getVar( $holder, $varName )->toNative();
			}
		}

		return $exported;
	}

	/**
	 * Compute all vars which need DB access. Useful for vars which are going to be saved
	 * cross-wiki or used for offline analysis.
	 *
	 * @param VariableHolder $holder
	 */
	public function computeDBVars( VariableHolder $holder ): void {
		static $dbTypes = [
			'links-from-database',
			'links-from-update',
			'links-from-wikitext-or-database',
			'load-recent-authors',
			'page-age',
			'revision-age-by-id',
			'revision-age-by-title',
			'previous-revision-age',
			'get-page-restrictions',
			'user-editcount',
			'user-emailconfirm',
			'user-groups',
			'user-rights',
			'user-age',
			'user-block',
			'revision-text-by-id',
			'content-model-by-id',
		];

		/** @var LazyLoadedVariable[] $missingVars */
		$missingVars = array_filter( $holder->getVars(), static function ( $el ) {
			return ( $el instanceof LazyLoadedVariable );
		} );
		foreach ( $missingVars as $name => $var ) {
			if ( in_array( $var->getMethod(), $dbTypes ) ) {
				$holder->setVar( $name, $this->getVar( $holder, $name ) );
			}
		}
	}

	/**
	 * Export all variables stored in this object with their native (PHP) types.
	 *
	 * @param VariableHolder $holder
	 * @return array
	 */
	public function exportAllVars( VariableHolder $holder ): array {
		$exported = [];
		foreach ( array_keys( $holder->getVars() ) as $varName ) {
			$exported[ $varName ] = $this->getVar( $holder, $varName )->toNative();
		}

		return $exported;
	}

	/**
	 * Export all non-lazy variables stored in this object as string
	 *
	 * @param VariableHolder $holder
	 * @return string[]
	 */
	public function exportNonLazyVars( VariableHolder $holder ): array {
		$exported = [];
		foreach ( $holder->getVars() as $varName => $data ) {
			if ( !( $data instanceof LazyLoadedVariable ) ) {
				$exported[$varName] = $holder->getComputedVariable( $varName )->toString();
			}
		}

		return $exported;
	}
}
PK       ! h+EP  EP  "  Variables/LazyVariableComputer.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Variables;

use MediaWiki\Content\ContentHandler;
use MediaWiki\Content\TextContent;
use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Extension\AbuseFilter\Parser\AFPData;
use MediaWiki\Extension\AbuseFilter\TextExtractor;
use MediaWiki\ExternalLinks\ExternalLinksLookup;
use MediaWiki\ExternalLinks\LinkFilter;
use MediaWiki\Language\Language;
use MediaWiki\Parser\ParserFactory;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Permissions\RestrictionStore;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\PreparedUpdate;
use MediaWiki\Title\Title;
use MediaWiki\User\ExternalUserNames;
use MediaWiki\User\User;
use MediaWiki\User\UserEditTracker;
use MediaWiki\User\UserGroupManager;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityUtils;
use Psr\Log\LoggerInterface;
use stdClass;
use StringUtils;
use UnexpectedValueException;
use Wikimedia\Diff\Diff;
use Wikimedia\Diff\UnifiedDiffFormatter;
use Wikimedia\IPUtils;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\LBFactory;
use Wikimedia\Rdbms\SelectQueryBuilder;
use WikiPage;

/**
 * Service used to compute lazy-loaded variable.
 * @internal
 */
class LazyVariableComputer {
	public const SERVICE_NAME = 'AbuseFilterLazyVariableComputer';

	/**
	 * @var float The amount of time to subtract from profiling
	 * @todo This is a hack
	 */
	public static $profilingExtraTime = 0;

	/** @var TextExtractor */
	private $textExtractor;

	/** @var AbuseFilterHookRunner */
	private $hookRunner;

	/** @var LoggerInterface */
	private $logger;

	/** @var LBFactory */
	private $lbFactory;

	/** @var WANObjectCache */
	private $wanCache;

	/** @var RevisionLookup */
	private $revisionLookup;

	/** @var RevisionStore */
	private $revisionStore;

	/** @var Language */
	private $contentLanguage;

	/** @var ParserFactory */
	private $parserFactory;

	/** @var UserEditTracker */
	private $userEditTracker;

	/** @var UserGroupManager */
	private $userGroupManager;

	/** @var PermissionManager */
	private $permissionManager;

	/** @var RestrictionStore */
	private $restrictionStore;

	/** @var UserIdentityUtils */
	private $userIdentityUtils;

	/** @var string */
	private $wikiID;

	/**
	 * @param TextExtractor $textExtractor
	 * @param AbuseFilterHookRunner $hookRunner
	 * @param LoggerInterface $logger
	 * @param LBFactory $lbFactory
	 * @param WANObjectCache $wanCache
	 * @param RevisionLookup $revisionLookup
	 * @param RevisionStore $revisionStore
	 * @param Language $contentLanguage
	 * @param ParserFactory $parserFactory
	 * @param UserEditTracker $userEditTracker
	 * @param UserGroupManager $userGroupManager
	 * @param PermissionManager $permissionManager
	 * @param RestrictionStore $restrictionStore
	 * @param UserIdentityUtils $userIdentityUtils
	 * @param string $wikiID
	 */
	public function __construct(
		TextExtractor $textExtractor,
		AbuseFilterHookRunner $hookRunner,
		LoggerInterface $logger,
		LBFactory $lbFactory,
		WANObjectCache $wanCache,
		RevisionLookup $revisionLookup,
		RevisionStore $revisionStore,
		Language $contentLanguage,
		ParserFactory $parserFactory,
		UserEditTracker $userEditTracker,
		UserGroupManager $userGroupManager,
		PermissionManager $permissionManager,
		RestrictionStore $restrictionStore,
		UserIdentityUtils $userIdentityUtils,
		string $wikiID
	) {
		$this->textExtractor = $textExtractor;
		$this->hookRunner = $hookRunner;
		$this->logger = $logger;
		$this->lbFactory = $lbFactory;
		$this->wanCache = $wanCache;
		$this->revisionLookup = $revisionLookup;
		$this->revisionStore = $revisionStore;
		$this->contentLanguage = $contentLanguage;
		$this->parserFactory = $parserFactory;
		$this->userEditTracker = $userEditTracker;
		$this->userGroupManager = $userGroupManager;
		$this->permissionManager = $permissionManager;
		$this->restrictionStore = $restrictionStore;
		$this->userIdentityUtils = $userIdentityUtils;
		$this->wikiID = $wikiID;
	}

	/**
	 * XXX: $getVarCB is a hack to hide the cyclic dependency with VariablesManager. See T261069 for possible
	 * solutions. This might also be merged into VariablesManager, but it would bring a ton of dependencies.
	 * @todo Should we remove $vars parameter (check hooks)?
	 *
	 * @param LazyLoadedVariable $var
	 * @param VariableHolder $vars
	 * @param callable $getVarCB
	 * @phan-param callable(string $name):AFPData $getVarCB
	 * @return AFPData
	 */
	public function compute( LazyLoadedVariable $var, VariableHolder $vars, callable $getVarCB ) {
		$parameters = $var->getParameters();
		$varMethod = $var->getMethod();
		$result = null;

		if ( !$this->hookRunner->onAbuseFilter_interceptVariable(
			$varMethod,
			$vars,
			$parameters,
			$result
		) ) {
			return $result instanceof AFPData
				? $result : AFPData::newFromPHPVar( $result );
		}

		switch ( $varMethod ) {
			case 'diff':
				$text1Var = $parameters['oldtext-var'];
				$text2Var = $parameters['newtext-var'];
				$text1 = $getVarCB( $text1Var )->toString();
				$text2 = $getVarCB( $text2Var )->toString();
				// T74329: if there's no text, don't return an array with the empty string
				$text1 = $text1 === '' ? [] : explode( "\n", $text1 );
				$text2 = $text2 === '' ? [] : explode( "\n", $text2 );
				$diffs = new Diff( $text1, $text2 );
				$format = new UnifiedDiffFormatter();
				$result = $format->format( $diffs );
				break;
			case 'diff-split':
				$diff = $getVarCB( $parameters['diff-var'] )->toString();
				$line_prefix = $parameters['line-prefix'];
				$diff_lines = explode( "\n", $diff );
				$result = [];
				foreach ( $diff_lines as $line ) {
					if ( ( $line[0] ?? '' ) === $line_prefix ) {
						$result[] = substr( $line, 1 );
					}
				}
				break;
			case 'array-diff':
				$baseVar = $parameters['base-var'];
				$minusVar = $parameters['minus-var'];

				$baseArray = $getVarCB( $baseVar )->toNative();
				$minusArray = $getVarCB( $minusVar )->toNative();

				$result = array_diff( $baseArray, $minusArray );
				break;
			case 'links-from-wikitext':
				// This should ONLY be used when sharing a parse operation with the edit.

				/** @var WikiPage $article */
				$article = $parameters['article'];
				if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) {
					// Shared with the edit, don't count it in profiling
					$startTime = microtime( true );
					$textVar = $parameters['text-var'];

					$new_text = $getVarCB( $textVar )->toString();
					$content = ContentHandler::makeContent( $new_text, $article->getTitle() );
					$editInfo = $article->prepareContentForEdit(
						$content,
						null,
						$parameters['contextUserIdentity']
					);
					$result = LinkFilter::getIndexedUrlsNonReversed(
						array_keys( $editInfo->output->getExternalLinks() )
					);
					self::$profilingExtraTime += ( microtime( true ) - $startTime );
					break;
				}
			// Otherwise fall back to database
			case 'links-from-wikitext-or-database':
				// TODO: use Content object instead, if available!
				/** @var WikiPage $article */
				$article ??= $parameters['article'];

				// this inference is ugly, but the name isn't accessible from here
				// and we only want this for debugging
				$textVar = $parameters['text-var'];
				$varName = str_starts_with( $textVar, 'old_' ) ? 'old_links' : 'all_links';
				if ( $parameters['forFilter'] ?? false ) {
					$this->logger->debug( "Loading $varName from DB" );
					$links = $this->getLinksFromDB( $article );
				} elseif ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) {
					$this->logger->debug( "Loading $varName from Parser" );

					$wikitext = $getVarCB( $textVar )->toString();
					$editInfo = $this->parseNonEditWikitext(
						$wikitext,
						$article,
						$parameters['contextUserIdentity']
					);
					$links = LinkFilter::getIndexedUrlsNonReversed(
						array_keys( $editInfo->output->getExternalLinks() )
					);
				} else {
					// TODO: Get links from Content object. But we don't have the content object.
					// And for non-text content, $wikitext is usually not going to be a valid
					// serialization, but rather some dummy text for filtering.
					$links = [];
				}

				$result = $links;
				break;
			case 'links-from-update':
				/** @var PreparedUpdate $update */
				$update = $parameters['update'];
				// Shared with the edit, don't count it in profiling
				$startTime = microtime( true );
				$result = LinkFilter::getIndexedUrlsNonReversed(
					array_keys( $update->getParserOutputForMetaData()->getExternalLinks() )
				);
				self::$profilingExtraTime += ( microtime( true ) - $startTime );
				break;
			case 'links-from-database':
				/** @var WikiPage $article */
				$article = $parameters['article'];
				$this->logger->debug( 'Loading old_links from DB' );
				$result = $this->getLinksFromDB( $article );
				break;
			case 'parse-wikitext':
				// Should ONLY be used when sharing a parse operation with the edit.
				// TODO: use Content object instead, if available!
				/* @var WikiPage $article */
				$article = $parameters['article'];
				if ( $article->getContentModel() === CONTENT_MODEL_WIKITEXT ) {
					// Shared with the edit, don't count it in profiling
					$startTime = microtime( true );
					$textVar = $parameters['wikitext-var'];

					$new_text = $getVarCB( $textVar )->toString();
					$content = ContentHandler::makeContent( $new_text, $article->getTitle() );
					$editInfo = $article->prepareContentForEdit(
						$content,
						null,
						$parameters['contextUserIdentity']
					);
					if ( isset( $parameters['pst'] ) && $parameters['pst'] ) {
						$result = $editInfo->pstContent->serialize( $editInfo->format );
					} else {
						// Note: as of core change r727361, the PP limit comments (which we don't want to be here)
						// are already excluded.
						$result = $editInfo->getOutput()->getText();
					}
					self::$profilingExtraTime += ( microtime( true ) - $startTime );
				} else {
					$result = '';
				}
				break;
			case 'html-from-update':
				/** @var PreparedUpdate $update */
				$update = $parameters['update'];
				// Shared with the edit, don't count it in profiling
				$startTime = microtime( true );
				$result = $update->getCanonicalParserOutput()->getText();
				self::$profilingExtraTime += ( microtime( true ) - $startTime );
				break;
			case 'strip-html':
				$htmlVar = $parameters['html-var'];
				$html = $getVarCB( $htmlVar )->toString();
				$stripped = StringUtils::delimiterReplace( '<', '>', '', $html );
				// We strip extra spaces to the right because the stripping above
				// could leave a lot of whitespace.
				// @fixme Find a better way to do this.
				$result = TextContent::normalizeLineEndings( $stripped );
				break;
			case 'load-recent-authors':
				$result = $this->getLastPageAuthors( $parameters['title'] );
				break;
			case 'load-first-author':
				$revision = $this->revisionLookup->getFirstRevision( $parameters['title'] );
				if ( $revision ) {
					// TODO T233241
					$user = $revision->getUser();
					$result = $user === null ? '' : $user->getName();
				} else {
					$result = '';
				}
				break;
			case 'get-page-restrictions':
				$action = $parameters['action'];
				/** @var Title $title */
				$title = $parameters['title'];
				$result = $this->restrictionStore->getRestrictions( $title, $action );
				break;
			case 'user-unnamed-ip':
				$user = $parameters['user'];
				$result = null;

				// Don't return an IP for past events (eg. revisions, logs)
				// This could leak IPs to users who don't have IP viewing rights
				if ( !$parameters['rc'] &&
					// Reveal IPs for:
					// - temporary accounts: temporary account names will replace the IP in the `user_name`
					//   variable. This variable restores this access.
					// - logged-out users: This supports the transition to the use of temporary accounts
					//   so that filter maintainers on pre-transition wikis can migrate `user_name` to `user_unnamed_ip`
					//   where necessary and see no disruption on transition.
					//
					// This variable should only ever be exposed for these use cases and shouldn't be extended
					// to registered accounts, as that would leak account PII to users without the right to see
					// that information
					( $this->userIdentityUtils->isTemp( $user ) || IPUtils::isIPAddress( $user->getName() ) ) ) {
					$result = $user->getRequest()->getIP();
				}
				break;
			case 'user-type':
				/** @var UserIdentity $userIdentity */
				$userIdentity = $parameters['user-identity'];
				if ( $this->userIdentityUtils->isNamed( $userIdentity ) ) {
					$result = 'named';
				} elseif ( $this->userIdentityUtils->isTemp( $userIdentity ) ) {
					$result = 'temp';
				} elseif ( IPUtils::isIPAddress( $userIdentity->getName() ) ) {
					$result = 'ip';
				} elseif ( ExternalUserNames::isExternal( $userIdentity->getName() ) ) {
					$result = 'external';
				} else {
					$result = 'unknown';
				}
				break;
			case 'user-editcount':
				/** @var UserIdentity $userIdentity */
				$userIdentity = $parameters['user-identity'];
				$result = $this->userEditTracker->getUserEditCount( $userIdentity );
				break;
			case 'user-emailconfirm':
				/** @var User $user */
				$user = $parameters['user'];
				$result = $user->getEmailAuthenticationTimestamp();
				break;
			case 'user-groups':
				/** @var UserIdentity $userIdentity */
				$userIdentity = $parameters['user-identity'];
				$result = $this->userGroupManager->getUserEffectiveGroups( $userIdentity );
				break;
			case 'user-rights':
				/** @var UserIdentity $userIdentity */
				$userIdentity = $parameters['user-identity'];
				$result = $this->permissionManager->getUserPermissions( $userIdentity );
				break;
			case 'user-block':
				// @todo Support partial blocks?
				/** @var User $user */
				$user = $parameters['user'];
				$result = (bool)$user->getBlock();
				break;
			case 'user-age':
				/** @var User $user */
				$user = $parameters['user'];
				$asOf = $parameters['asof'];

				if ( !$user->isRegistered() ) {
					$result = 0;
				} else {
					// HACK: If there's no registration date, assume 2008-01-15, Wikipedia Day
					// in the year before the new user log was created. See T243469.
					$registration = $user->getRegistration() ?? "20080115000000";
					$result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $registration );
				}
				break;
			case 'page-age':
				/** @var Title $title */
				$title = $parameters['title'];

				$firstRev = $this->revisionLookup->getFirstRevision( $title );
				$firstRevisionTime = $firstRev ? $firstRev->getTimestamp() : null;
				if ( !$firstRevisionTime ) {
					$result = 0;
					break;
				}

				$asOf = $parameters['asof'];
				$result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $firstRevisionTime );
				break;
			case 'revision-age-by-id':
				$timestamp = $this->revisionLookup->getTimestampFromId( $parameters['revid'] );
				if ( !$timestamp ) {
					$result = null;
					break;
				}
				$asOf = $parameters['asof'];
				$result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $timestamp );
				break;
			case 'revision-age-by-title':
				/** @var Title $title */
				$title = $parameters['title'];
				$revRec = $this->revisionLookup->getRevisionByTitle( $title );
				if ( !$revRec ) {
					$result = null;
					break;
				}
				$asOf = $parameters['asof'];
				$result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $revRec->getTimestamp() );
				break;
			case 'previous-revision-age':
				$revRec = $this->revisionLookup->getRevisionById( $parameters['revid'] );
				if ( !$revRec ) {
					$result = null;
					break;
				}
				$prev = $this->revisionLookup->getPreviousRevision( $revRec );
				if ( !$prev ) {
					$result = null;
					break;
				}
				$asOf = $parameters['asof'] ?? $revRec->getTimestamp();
				$result = (int)wfTimestamp( TS_UNIX, $asOf ) - (int)wfTimestamp( TS_UNIX, $prev->getTimestamp() );
				break;
			case 'length':
				$s = $getVarCB( $parameters['length-var'] )->toString();
				$result = strlen( $s );
				break;
			case 'subtract-int':
				$v1 = $getVarCB( $parameters['val1-var'] )->toInt();
				$v2 = $getVarCB( $parameters['val2-var'] )->toInt();
				$result = $v1 - $v2;
				break;
			case 'content-model-by-id':
				$revRec = $this->revisionLookup->getRevisionById( $parameters['revid'] );
				$result = $this->getContentModelFromRevision( $revRec );
				break;
			case 'revision-text-by-id':
				$revRec = $this->revisionLookup->getRevisionById( $parameters['revid'] );
				$result = $this->textExtractor->revisionToString( $revRec, $parameters['contextUser'] );
				break;
			case 'get-wiki-name':
				$result = $this->wikiID;
				break;
			case 'get-wiki-language':
				$result = $this->contentLanguage->getCode();
				break;
			default:
				if ( $this->hookRunner->onAbuseFilter_computeVariable(
					$varMethod,
					$vars,
					$parameters,
					$result
				) ) {
					throw new UnexpectedValueException( 'Unknown variable compute type ' . $varMethod );
				}
		}

		return $result instanceof AFPData ? $result : AFPData::newFromPHPVar( $result );
	}

	/**
	 * @param WikiPage $article
	 * @return array
	 */
	private function getLinksFromDB( WikiPage $article ) {
		$id = $article->getId();
		if ( !$id ) {
			return [];
		}

		return ExternalLinksLookup::getExternalLinksForPage(
			$id,
			$this->lbFactory->getReplicaDatabase(),
			__METHOD__
		);
	}

	/**
	 * @todo Move to MW core (T272050)
	 * @param Title $title
	 * @return string[] Usernames of the last 10 (unique) authors from $title
	 */
	private function getLastPageAuthors( Title $title ) {
		if ( !$title->exists() ) {
			return [];
		}

		$fname = __METHOD__;

		return $this->wanCache->getWithSetCallback(
			$this->wanCache->makeKey( 'last-10-authors', 'revision', $title->getLatestRevID() ),
			WANObjectCache::TTL_MINUTE,
			function ( $oldValue, &$ttl, array &$setOpts ) use ( $title, $fname ) {
				$dbr = $this->lbFactory->getReplicaDatabase();

				$setOpts += Database::getCacheSetOptions( $dbr );
				// Get the last 100 edit authors with a trivial query (avoid T116557)
				$revQuery = $this->revisionStore->getQueryInfo();
				$revAuthors = $dbr->newSelectQueryBuilder()
					->tables( $revQuery['tables'] )
					->field( $revQuery['fields']['rev_user_text'] )
					->where( [
						'rev_page' => $title->getArticleID(),
						// TODO Should deleted names be counted in the 10 authors? If yes, this check should
						// be moved inside the foreach
						'rev_deleted' => 0
					] )
					->caller( $fname )
					// Some pages have < 10 authors but many revisions (e.g. bot pages)
					->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder::SORT_DESC )
					->limit( 100 )
					// Force index per T116557
					->useIndex( [ 'revision' => 'rev_page_timestamp' ] )
					->joinConds( $revQuery['joins'] )
					->fetchFieldValues();
				// Get the last 10 distinct authors within this set of edits
				$users = [];
				foreach ( $revAuthors as $author ) {
					$users[$author] = 1;
					if ( count( $users ) >= 10 ) {
						break;
					}
				}

				return array_keys( $users );
			}
		);
	}

	/**
	 * @param ?RevisionRecord $revision
	 * @return string
	 */
	private function getContentModelFromRevision( ?RevisionRecord $revision ): string {
		// this is consistent with what is done on various places in RunVariableGenerator
		// and RCVariableGenerator
		if ( $revision !== null ) {
			$content = $revision->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
			return $content->getModel();
		}
		return '';
	}

	/**
	 * It's like WikiPage::prepareContentForEdit, but not for editing (old wikitext usually)
	 *
	 * @param string $wikitext
	 * @param WikiPage $article
	 * @param UserIdentity $userIdentity Context user
	 *
	 * @return stdClass
	 */
	private function parseNonEditWikitext( $wikitext, WikiPage $article, UserIdentity $userIdentity ) {
		static $cache = [];

		$cacheKey = md5( $wikitext ) . ':' . $article->getTitle()->getPrefixedText();

		if ( !isset( $cache[$cacheKey] ) ) {
			$options = ParserOptions::newFromUser( $userIdentity );
			$cache[$cacheKey] = (object)[
				'output' => $this->parserFactory->getInstance()->parse( $wikitext, $article->getTitle(), $options )
			];
		}

		return $cache[$cacheKey];
	}
}
PK       ! &ς       Variables/VariablesBlobStore.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Variables;

use InvalidArgumentException;
use MediaWiki\Json\FormatJson;
use MediaWiki\Storage\BlobAccessException;
use MediaWiki\Storage\BlobStore;
use MediaWiki\Storage\BlobStoreFactory;
use stdClass;

/**
 * This service is used to store and load var dumps to a BlobStore
 */
class VariablesBlobStore {
	public const SERVICE_NAME = 'AbuseFilterVariablesBlobStore';

	/** @var VariablesManager */
	private $varManager;

	/** @var BlobStoreFactory */
	private $blobStoreFactory;

	/** @var BlobStore */
	private $blobStore;

	/** @var string|null */
	private $centralDB;

	/**
	 * @param VariablesManager $varManager
	 * @param BlobStoreFactory $blobStoreFactory
	 * @param BlobStore $blobStore
	 * @param string|null $centralDB
	 */
	public function __construct(
		VariablesManager $varManager,
		BlobStoreFactory $blobStoreFactory,
		BlobStore $blobStore,
		?string $centralDB
	) {
		$this->varManager = $varManager;
		$this->blobStoreFactory = $blobStoreFactory;
		$this->blobStore = $blobStore;
		$this->centralDB = $centralDB;
	}

	/**
	 * Store a var dump to a BlobStore.
	 *
	 * @param VariableHolder $varsHolder
	 * @param bool $global
	 *
	 * @return string Address of the record
	 */
	public function storeVarDump( VariableHolder $varsHolder, $global = false ) {
		// Get all variables yet set and compute old and new wikitext if not yet done
		// as those are needed for the diff view on top of the abuse log pages
		$vars = $this->varManager->dumpAllVars( $varsHolder, [ 'old_wikitext', 'new_wikitext' ] );

		// if user_unnamed_ip exists it can't be saved, as var dump blobs are stored in an append-only
		// database and stored IPs eventually need to be cleared.
		// Set the value to something safe here, as by now it's been used in the filter and if
		// logs later need it, it can be reconstructed from afl_ip.
		if ( isset( $vars[ 'user_unnamed_ip' ] ) && $vars[ 'user_unnamed_ip' ] ) {
			$vars[ 'user_unnamed_ip' ] = true;
		}

		// Vars is an array with native PHP data types (non-objects) now
		$text = FormatJson::encode( $vars );

		$dbDomain = $global ? $this->centralDB : false;
		$blobStore = $this->blobStoreFactory->newBlobStore( $dbDomain );

		$hints = [
			BlobStore::DESIGNATION_HINT => 'AbuseFilter',
			BlobStore::MODEL_HINT => 'AbuseFilter',
		];
		return $blobStore->storeBlob( $text, $hints );
	}

	/**
	 * Retrieve a var dump from a BlobStore.
	 *
	 * The entire $row is passed through but only the following columns are actually required:
	 * - afl_var_dump: the main variable store to load
	 * - afl_ip: the IP value to use if necessary
	 *
	 * @param stdClass $row
	 *
	 * @return VariableHolder
	 */
	public function loadVarDump( stdClass $row ): VariableHolder {
		if ( !isset( $row->afl_var_dump ) || !isset( $row->afl_ip ) ) {
			throw new InvalidArgumentException( 'Both afl_var_dump and afl_ip must be set' );
		}

		try {
			$varDump = $row->afl_var_dump;
			$blob = $this->blobStore->getBlob( $varDump );
		} catch ( BlobAccessException $ex ) {
			return new VariableHolder;
		}

		$vars = FormatJson::decode( $blob, true );
		$obj = VariableHolder::newFromArray( $vars );
		$this->varManager->translateDeprecatedVars( $obj );

		// If user_unnamed_ip was set when afl_var_dump was saved, it was saved as a visibility boolean
		// and needs to be translated back into an IP
		// user_unnamed_ip uses afl_ip instead of saving the value because afl_ip gets purged and the blob
		// that contains user_unnamed_ip can't be modified
		if (
			$this->varManager->getVar( $obj, 'user_unnamed_ip', $this->varManager::GET_LAX )->toNative()
		) {
			$obj->setVar( 'user_unnamed_ip', $row->afl_ip );
		}

		return $obj;
	}
}
PK       ! ;o  o    Variables/VariableHolder.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Variables;

use MediaWiki\Extension\AbuseFilter\Parser\AFPData;

/**
 * Mutable value object that holds a list of variables
 */
class VariableHolder {
	/**
	 * @var (AFPData|LazyLoadedVariable)[]
	 */
	private $mVars = [];

	/**
	 * Utility function to translate an array with shape [ varname => value ] into a self instance
	 *
	 * @param array $vars
	 * @return VariableHolder
	 */
	public static function newFromArray( array $vars ): VariableHolder {
		$ret = new self();
		foreach ( $vars as $var => $value ) {
			$ret->setVar( $var, $value );
		}
		return $ret;
	}

	/**
	 * @param string $variable
	 * @param mixed $datum
	 */
	public function setVar( string $variable, $datum ): void {
		$variable = strtolower( $variable );
		if ( !( $datum instanceof AFPData || $datum instanceof LazyLoadedVariable ) ) {
			$datum = AFPData::newFromPHPVar( $datum );
		}

		$this->mVars[$variable] = $datum;
	}

	/**
	 * Get all variables stored in this object
	 *
	 * @return (AFPData|LazyLoadedVariable)[]
	 */
	public function getVars(): array {
		return $this->mVars;
	}

	/**
	 * @param string $variable
	 * @param string $method
	 * @param array $parameters
	 */
	public function setLazyLoadVar( string $variable, string $method, array $parameters ): void {
		$placeholder = new LazyLoadedVariable( $method, $parameters );
		$this->setVar( $variable, $placeholder );
	}

	/**
	 * Get a variable from the current object, or throw if not set
	 *
	 * @param string $varName The variable name
	 * @return AFPData|LazyLoadedVariable
	 */
	public function getVarThrow( string $varName ) {
		$varName = strtolower( $varName );
		if ( !$this->varIsSet( $varName ) ) {
			throw new UnsetVariableException( $varName );
		}
		return $this->mVars[$varName];
	}

	/**
	 * A stronger version of self::getVarThrow that also asserts that the variable was computed
	 * @param string $varName
	 * @return AFPData
	 * @codeCoverageIgnore
	 */
	public function getComputedVariable( string $varName ): AFPData {
		return $this->getVarThrow( $varName );
	}

	/**
	 * Merge any number of holders given as arguments into this holder.
	 *
	 * @param VariableHolder ...$holders
	 */
	public function addHolders( VariableHolder ...$holders ): void {
		foreach ( $holders as $addHolder ) {
			$this->mVars = array_merge( $this->mVars, $addHolder->mVars );
		}
	}

	/**
	 * @param string $var
	 * @return bool
	 */
	public function varIsSet( string $var ): bool {
		return array_key_exists( $var, $this->mVars );
	}

	/**
	 * @param string $varName
	 */
	public function removeVar( string $varName ): void {
		unset( $this->mVars[$varName] );
	}
}

// @deprecated Since 1.36. Kept for BC with the UpdateVarDumps script, see T331861. The alias can be removed
// once we no longer support updating from a MW version where that script may run.
class_alias( VariableHolder::class, 'AbuseFilterVariableHolder' );
PK       ! _@  @  $  Variables/UnsetVariableException.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Variables;

use RuntimeException;

/**
 * @codeCoverageIgnore
 */
class UnsetVariableException extends RuntimeException {
	/**
	 * @param string $varName
	 */
	public function __construct( string $varName ) {
		parent::__construct( "Variable $varName is not set" );
	}
}
PK       ! *ȗu	  	    EchoNotifier.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter;
use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseFilter;
use MediaWiki\Extension\Notifications\Model\Event;
use MediaWiki\Title\Title;

/**
 * Helper service for EmergencyWatcher to notify filter maintainers of throttled filters
 * @todo DI not possible due to Echo
 */
class EchoNotifier {
	public const SERVICE_NAME = 'AbuseFilterEchoNotifier';
	public const EVENT_TYPE = 'throttled-filter';

	/** @var FilterLookup */
	private $filterLookup;
	/** @var ConsequencesRegistry */
	private $consequencesRegistry;
	/** @var bool */
	private $isEchoLoaded;

	/**
	 * @param FilterLookup $filterLookup
	 * @param ConsequencesRegistry $consequencesRegistry
	 * @param bool $isEchoLoaded
	 */
	public function __construct(
		FilterLookup $filterLookup,
		ConsequencesRegistry $consequencesRegistry,
		bool $isEchoLoaded
	) {
		$this->filterLookup = $filterLookup;
		$this->consequencesRegistry = $consequencesRegistry;
		$this->isEchoLoaded = $isEchoLoaded;
	}

	/**
	 * @param int $filter
	 * @return Title
	 */
	private function getTitleForFilter( int $filter ): Title {
		return SpecialAbuseFilter::getTitleForSubpage( (string)$filter );
	}

	/**
	 * @param int $filter
	 * @return ExistingFilter
	 */
	private function getFilterObject( int $filter ): ExistingFilter {
		return $this->filterLookup->getFilter( $filter, false );
	}

	/**
	 * @internal
	 * @param int $filter
	 * @return array
	 */
	public function getDataForEvent( int $filter ): array {
		$filterObj = $this->getFilterObject( $filter );
		$throttledActionNames = array_intersect(
			$filterObj->getActionsNames(),
			$this->consequencesRegistry->getDangerousActionNames()
		);
		return [
			'type' => self::EVENT_TYPE,
			'title' => $this->getTitleForFilter( $filter ),
			'extra' => [
				'user' => $filterObj->getUserID(),
				'throttled-actions' => $throttledActionNames,
			],
		];
	}

	/**
	 * Send notification about a filter being throttled
	 *
	 * @param int $filter
	 * @return Event|false
	 */
	public function notifyForFilter( int $filter ) {
		if ( $this->isEchoLoaded ) {
			return Event::create( $this->getDataForEvent( $filter ) );
		}
		return false;
	}

}
PK       ! >      "  CentralDBNotAvailableException.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use RuntimeException;

class CentralDBNotAvailableException extends RuntimeException {
}
PK       ! xy  y    ActionSpecifier.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use InvalidArgumentException;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\User\UserIdentity;

/**
 * Plain value object that univocally represents an action being filtered
 * @todo Add constants for possible actions?
 * @todo Add the timestamp
 */
class ActionSpecifier {
	/** @var string */
	private $action;
	/** @var LinkTarget */
	private $title;
	/** @var UserIdentity */
	private $user;
	/** @var string */
	private $requestIP;
	/** @var string|null */
	private $accountName;

	/**
	 * @param string $action Action being filtered (e.g. 'edit' or 'createaccount')
	 * @param LinkTarget $title Where the current action is executed. This is the user page
	 *   for account creations.
	 * @param UserIdentity $user
	 * @param string $requestIP
	 * @param string|null $accountName Required iff the action is an account creation
	 */
	public function __construct(
		string $action, LinkTarget $title, UserIdentity $user, string $requestIP, ?string $accountName
	) {
		if ( $accountName === null && strpos( $action, 'createaccount' ) !== false ) {
			throw new InvalidArgumentException( '$accountName required for account creations' );
		}
		$this->action = $action;
		$this->title = $title;
		$this->user = $user;
		$this->requestIP = $requestIP;
		$this->accountName = $accountName;
	}

	/**
	 * @return string
	 */
	public function getAction(): string {
		return $this->action;
	}

	/**
	 * @return LinkTarget
	 */
	public function getTitle(): LinkTarget {
		return $this->title;
	}

	/**
	 * @return UserIdentity
	 */
	public function getUser(): UserIdentity {
		return $this->user;
	}

	/**
	 * @return string
	 * @note It may be an empty string for less recent changes.
	 */
	public function getIP(): string {
		return $this->requestIP;
	}

	/**
	 * @return string|null
	 */
	public function getAccountName(): ?string {
		return $this->accountName;
	}
}
PK       ! em      GlobalNameUtils.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use InvalidArgumentException;

/**
 * @internal
 */
class GlobalNameUtils {
	/** @var string The prefix to use for global filters */
	public const GLOBAL_FILTER_PREFIX = 'global-';

	/**
	 * Given a filter ID and a boolean indicating whether it's global, build a string like
	 * "<GLOBAL_FILTER_PREFIX>$ID". Note that, with global = false, $id is casted to string.
	 * This reverses self::splitGlobalName.
	 *
	 * @param int $id The filter ID
	 * @param bool $global Whether the filter is global
	 * @return string
	 * @todo Calling this method should be avoided wherever possible
	 */
	public static function buildGlobalName( int $id, bool $global = true ): string {
		$prefix = $global ? self::GLOBAL_FILTER_PREFIX : '';
		return "$prefix$id";
	}

	/**
	 * Utility function to split "<GLOBAL_FILTER_PREFIX>$index" to an array [ $id, $global ], where
	 * $id is $index casted to int, and $global is a boolean: true if the filter is global,
	 * false otherwise (i.e. if the $filter === $index). Note that the $index
	 * is always casted to int. Passing anything which isn't an integer-like value or a string
	 * in the shape "<GLOBAL_FILTER_PREFIX>integer" will throw.
	 * This reverses self::buildGlobalName
	 *
	 * @param string|int $filter
	 * @return array
	 * @phan-return array{0:int,1:bool}
	 * @throws InvalidArgumentException
	 */
	public static function splitGlobalName( $filter ): array {
		if ( preg_match( '/^' . self::GLOBAL_FILTER_PREFIX . '\d+$/', $filter ) === 1 ) {
			$id = intval( substr( $filter, strlen( self::GLOBAL_FILTER_PREFIX ) ) );
			return [ $id, true ];
		} elseif ( is_numeric( $filter ) ) {
			return [ (int)$filter, false ];
		} else {
			throw new InvalidArgumentException( "Invalid filter name: $filter" );
		}
	}
}
PK       ! r2&      RunnerData.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use LogicException;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerStatus;

/**
 * Mutable value class storing and accumulating information about filter matches and runtime
 */
class RunnerData {

	/**
	 * @var array<string,RuleCheckerStatus>
	 */
	private $matchedFilters;

	/**
	 * @var array[]
	 * @phan-var array<string,array{time:float,conds:int,result:bool}>
	 */
	private $profilingData;

	/** @var float */
	private $totalRuntime;

	/** @var int */
	private $totalConditions;

	/**
	 * @param RuleCheckerStatus[] $matchedFilters
	 * @param array[] $profilingData
	 * @param float $totalRuntime
	 * @param int $totalConditions
	 */
	public function __construct(
		array $matchedFilters = [],
		array $profilingData = [],
		float $totalRuntime = 0.0,
		int $totalConditions = 0
	) {
		$this->matchedFilters = $matchedFilters;
		$this->profilingData = $profilingData;
		$this->totalRuntime = $totalRuntime;
		$this->totalConditions = $totalConditions;
	}

	/**
	 * Record (memorize) data from a filter run
	 *
	 * @param int $filterID
	 * @param bool $global
	 * @param RuleCheckerStatus $status
	 * @param float $timeTaken
	 */
	public function record( int $filterID, bool $global, RuleCheckerStatus $status, float $timeTaken ): void {
		$key = GlobalNameUtils::buildGlobalName( $filterID, $global );
		if ( array_key_exists( $key, $this->matchedFilters ) ) {
			throw new LogicException( "Filter '$key' has already been recorded" );
		}
		$this->matchedFilters[$key] = $status;
		$this->profilingData[$key] = [
			'time' => $timeTaken,
			'conds' => $status->getCondsUsed(),
			'result' => $status->getResult()
		];
		$this->totalRuntime += $timeTaken;
		$this->totalConditions += $status->getCondsUsed();
	}

	/**
	 * Get information about filter matches in backwards compatible format
	 * @return bool[]
	 * @phan-return array<string,bool>
	 */
	public function getMatchesMap(): array {
		return array_map(
			static function ( $status ) {
				return $status->getResult();
			},
			$this->matchedFilters
		);
	}

	/**
	 * @return string[]
	 */
	public function getAllFilters(): array {
		return array_keys( $this->matchedFilters );
	}

	/**
	 * @return string[]
	 */
	public function getMatchedFilters(): array {
		return array_keys( array_filter( $this->getMatchesMap() ) );
	}

	/**
	 * @return array[]
	 */
	public function getProfilingData(): array {
		return $this->profilingData;
	}

	/**
	 * @return float
	 */
	public function getTotalRuntime(): float {
		return $this->totalRuntime;
	}

	/**
	 * @return int
	 */
	public function getTotalConditions(): int {
		return $this->totalConditions;
	}

	/**
	 * Serialize data for edit stash
	 * @return array
	 * @phan-return array{matches:array<string,array>,runtime:float,condCount:int,profiling:array}
	 */
	public function toArray(): array {
		return [
			'matches' => array_map(
				static function ( $status ) {
					return $status->toArray();
				},
				$this->matchedFilters
			),
			'profiling' => $this->profilingData,
			'condCount' => $this->totalConditions,
			'runtime' => $this->totalRuntime,
		];
	}

	/**
	 * Deserialize data from edit stash
	 * @param array $value
	 * @return self
	 */
	public static function fromArray( array $value ): self {
		return new self(
			array_map( [ RuleCheckerStatus::class, 'fromArray' ], $value['matches'] ),
			$value['profiling'],
			$value['runtime'],
			$value['condCount']
		);
	}

}
PK       ! k%  %    FilterStore.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use ManualLogEntry;
use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagsManager;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
use MediaWiki\Extension\AbuseFilter\Filter\Filter;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseFilter;
use MediaWiki\Permissions\Authority;
use MediaWiki\Status\Status;
use MediaWiki\User\ActorNormalization;
use MediaWiki\User\UserIdentity;
use Wikimedia\Rdbms\LBFactory;

/**
 * @internal
 */
class FilterStore {
	public const SERVICE_NAME = 'AbuseFilterFilterStore';

	/** @var ConsequencesRegistry */
	private $consequencesRegistry;

	/** @var LBFactory */
	private $lbFactory;

	/** @var ActorNormalization */
	private $actorNormalization;

	/** @var FilterProfiler */
	private $filterProfiler;

	/** @var FilterLookup */
	private $filterLookup;

	/** @var ChangeTagsManager */
	private $tagsManager;

	/** @var FilterValidator */
	private $filterValidator;

	/** @var FilterCompare */
	private $filterCompare;

	/** @var EmergencyCache */
	private $emergencyCache;

	/**
	 * @param ConsequencesRegistry $consequencesRegistry
	 * @param LBFactory $lbFactory
	 * @param ActorNormalization $actorNormalization
	 * @param FilterProfiler $filterProfiler
	 * @param FilterLookup $filterLookup
	 * @param ChangeTagsManager $tagsManager
	 * @param FilterValidator $filterValidator
	 * @param FilterCompare $filterCompare
	 * @param EmergencyCache $emergencyCache
	 */
	public function __construct(
		ConsequencesRegistry $consequencesRegistry,
		LBFactory $lbFactory,
		ActorNormalization $actorNormalization,
		FilterProfiler $filterProfiler,
		FilterLookup $filterLookup,
		ChangeTagsManager $tagsManager,
		FilterValidator $filterValidator,
		FilterCompare $filterCompare,
		EmergencyCache $emergencyCache
	) {
		$this->consequencesRegistry = $consequencesRegistry;
		$this->lbFactory = $lbFactory;
		$this->actorNormalization = $actorNormalization;
		$this->filterProfiler = $filterProfiler;
		$this->filterLookup = $filterLookup;
		$this->tagsManager = $tagsManager;
		$this->filterValidator = $filterValidator;
		$this->filterCompare = $filterCompare;
		$this->emergencyCache = $emergencyCache;
	}

	/**
	 * Checks whether user input for the filter editing form is valid and if so saves the filter.
	 * Returns a Status object which can be:
	 *  - Good with [ new_filter_id, history_id ] as value if the filter was successfully saved
	 *  - Good with value = false if everything went fine but the filter is unchanged
	 *  - OK with errors if a validation error occurred
	 *  - Fatal in case of a permission-related error
	 *
	 * @param Authority $performer
	 * @param int|null $filterId
	 * @param Filter $newFilter
	 * @param Filter $originalFilter
	 * @return Status
	 */
	public function saveFilter(
		Authority $performer,
		?int $filterId,
		Filter $newFilter,
		Filter $originalFilter
	): Status {
		$validationStatus = $this->filterValidator->checkAll( $newFilter, $originalFilter, $performer );
		if ( !$validationStatus->isGood() ) {
			return $validationStatus;
		}

		// Check for non-changes
		$differences = $this->filterCompare->compareVersions( $newFilter, $originalFilter );
		if ( !$differences ) {
			return Status::newGood( false );
		}

		// Everything went fine, so let's save the filter
		$wasGlobal = $originalFilter->isGlobal();
		[ $newID, $historyID ] = $this->doSaveFilter(
			$performer->getUser(), $newFilter, $originalFilter, $differences, $filterId, $wasGlobal );
		return Status::newGood( [ $newID, $historyID ] );
	}

	/**
	 * Saves new filter's info to DB
	 *
	 * @param UserIdentity $userIdentity
	 * @param Filter $newFilter
	 * @param Filter $originalFilter
	 * @param array $differences
	 * @param int|null $filterId
	 * @param bool $wasGlobal
	 * @return int[] first element is new ID, second is history ID
	 */
	private function doSaveFilter(
		UserIdentity $userIdentity,
		Filter $newFilter,
		Filter $originalFilter,
		array $differences,
		?int $filterId,
		bool $wasGlobal
	): array {
		$dbw = $this->lbFactory->getPrimaryDatabase();
		$newRow = $this->filterToDatabaseRow( $newFilter, $originalFilter );

		// Set last modifier.
		$newRow['af_timestamp'] = $dbw->timestamp();
		$newRow['af_actor'] = $this->actorNormalization->acquireActorId( $userIdentity, $dbw );

		$isNew = $filterId === null;

		// Preserve the old throttled status (if any) only if disabling the filter.
		// TODO: It might make more sense to check what was actually changed
		$newRow['af_throttled'] = ( $newRow['af_throttled'] ?? false ) && !$newRow['af_enabled'];
		// This is null when creating a new filter, but the DB field is NOT NULL
		$newRow['af_hit_count'] ??= 0;
		$rowForInsert = array_diff_key( $newRow, [ 'af_id' => true ] );

		$dbw->startAtomic( __METHOD__ );
		if ( $filterId === null ) {
			$dbw->newInsertQueryBuilder()
				->insertInto( 'abuse_filter' )
				->row( $rowForInsert )
				->caller( __METHOD__ )
				->execute();
			$filterId = $dbw->insertId();
		} else {
			$dbw->newUpdateQueryBuilder()
				->update( 'abuse_filter' )
				->set( $rowForInsert )
				->where( [ 'af_id' => $filterId ] )
				->caller( __METHOD__ )
				->execute();
		}
		$newRow['af_id'] = $filterId;

		$actions = $newFilter->getActions();
		$actionsRows = [];
		foreach ( $this->consequencesRegistry->getAllEnabledActionNames() as $action ) {
			if ( !isset( $actions[$action] ) ) {
				continue;
			}

			$parameters = $actions[$action];
			if ( $action === 'throttle' && $parameters[0] === null ) {
				// FIXME: Do we really need to keep the filter ID inside throttle parameters?
				// We'd save space, keep things simpler and avoid this hack. Note: if removing
				// it, a maintenance script will be necessary to clean up the table.
				$parameters[0] = $filterId;
			}

			$actionsRows[] = [
				'afa_filter' => $filterId,
				'afa_consequence' => $action,
				'afa_parameters' => implode( "\n", $parameters ),
			];
		}

		// Create a history row
		$afhRow = [];

		foreach ( AbuseFilter::HISTORY_MAPPINGS as $afCol => $afhCol ) {
			// Some fields are expected to be missing during actor migration
			if ( isset( $newRow[$afCol] ) ) {
				$afhRow[$afhCol] = $newRow[$afCol];
			}
		}

		$afhRow['afh_actions'] = serialize( $actions );

		$afhRow['afh_changed_fields'] = implode( ',', $differences );

		$flags = [];
		if ( FilterUtils::isHidden( $newRow['af_hidden'] ) ) {
			$flags[] = 'hidden';
		}
		if ( FilterUtils::isProtected( $newRow['af_hidden'] ) ) {
			$flags[] = 'protected';
		}
		if ( $newRow['af_enabled'] ) {
			$flags[] = 'enabled';
		}
		if ( $newRow['af_deleted'] ) {
			$flags[] = 'deleted';
		}
		if ( $newRow['af_global'] ) {
			$flags[] = 'global';
		}

		$afhRow['afh_flags'] = implode( ',', $flags );

		$afhRow['afh_filter'] = $filterId;

		// Do the update
		$dbw->newInsertQueryBuilder()
			->insertInto( 'abuse_filter_history' )
			->row( $afhRow )
			->caller( __METHOD__ )
			->execute();
		$historyID = $dbw->insertId();
		if ( !$isNew ) {
			$dbw->newDeleteQueryBuilder()
				->deleteFrom( 'abuse_filter_action' )
				->where( [ 'afa_filter' => $filterId ] )
				->caller( __METHOD__ )
				->execute();
		}
		if ( $actionsRows ) {
			$dbw->newInsertQueryBuilder()
				->insertInto( 'abuse_filter_action' )
				->rows( $actionsRows )
				->caller( __METHOD__ )
				->execute();
		}

		$dbw->endAtomic( __METHOD__ );

		// Invalidate cache if this was a global rule
		if ( $wasGlobal || $newRow['af_global'] ) {
			$this->filterLookup->purgeGroupWANCache( $newRow['af_group'] );
		}

		// Logging
		$logEntry = new ManualLogEntry( 'abusefilter', $isNew ? 'create' : 'modify' );
		$logEntry->setPerformer( $userIdentity );
		$logEntry->setTarget( SpecialAbuseFilter::getTitleForSubpage( (string)$filterId ) );
		$logEntry->setParameters( [
			'historyId' => $historyID,
			'newId' => $filterId
		] );
		$logid = $logEntry->insert( $dbw );
		$logEntry->publish( $logid );

		// Purge the tag list cache so the fetchAllTags hook applies tag changes
		if ( isset( $actions['tag'] ) ) {
			$this->tagsManager->purgeTagCache();
		}

		$this->filterProfiler->resetFilterProfile( $filterId );
		if ( $newRow['af_enabled'] ) {
			$this->emergencyCache->setNewForFilter( $filterId, $newRow['af_group'] );
		}
		return [ $filterId, $historyID ];
	}

	/**
	 * @todo Perhaps add validation to ensure no null values remained.
	 * @note For simplicity, data about the last editor are omitted.
	 * @param Filter $filter
	 * @return array
	 */
	private function filterToDatabaseRow( Filter $filter, Filter $originalFilter ): array {
		// T67807: integer 1's & 0's might be better understood than booleans

		// If the filter is already protected, it must remain protected even if
		// the current filter doesn't use a protected variable anymore
		$privacyLevel = $filter->getPrivacyLevel();
		if ( $originalFilter->isProtected() ) {
			$privacyLevel |= Flags::FILTER_USES_PROTECTED_VARS;
		}

		return [
			'af_id' => $filter->getID(),
			'af_pattern' => $filter->getRules(),
			'af_public_comments' => $filter->getName(),
			'af_comments' => $filter->getComments(),
			'af_group' => $filter->getGroup(),
			'af_actions' => implode( ',', $filter->getActionsNames() ),
			'af_enabled' => (int)$filter->isEnabled(),
			'af_deleted' => (int)$filter->isDeleted(),
			'af_hidden' => $privacyLevel,
			'af_global' => (int)$filter->isGlobal(),
			'af_timestamp' => $filter->getTimestamp(),
			'af_hit_count' => $filter->getHitCount(),
			'af_throttled' => (int)$filter->isThrottled(),
		];
	}
}
PK       ! iy&b    (  AbuseFilterPreAuthenticationProvider.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Auth\AbstractPreAuthenticationProvider;
use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use StatusValue;
use Wikimedia\Stats\IBufferingStatsdDataFactory;

/**
 * AuthenticationProvider used to filter account creations. This runs after normal preauth providers
 * to keep the log cleaner.
 */
class AbuseFilterPreAuthenticationProvider extends AbstractPreAuthenticationProvider {
	/** @var VariableGeneratorFactory */
	private $variableGeneratorFactory;
	/** @var FilterRunnerFactory */
	private $filterRunnerFactory;
	/** @var IBufferingStatsdDataFactory */
	private $statsd;
	/** @var UserFactory */
	private $userFactory;

	/**
	 * @param VariableGeneratorFactory $variableGeneratorFactory
	 * @param FilterRunnerFactory $filterRunnerFactory
	 * @param IBufferingStatsdDataFactory $statsd
	 * @param UserFactory $userFactory
	 */
	public function __construct(
		VariableGeneratorFactory $variableGeneratorFactory,
		FilterRunnerFactory $filterRunnerFactory,
		IBufferingStatsdDataFactory $statsd,
		UserFactory $userFactory
	) {
		$this->variableGeneratorFactory = $variableGeneratorFactory;
		$this->filterRunnerFactory = $filterRunnerFactory;
		$this->statsd = $statsd;
		$this->userFactory = $userFactory;
	}

	/**
	 * @param User $user
	 * @param User $creator
	 * @param AuthenticationRequest[] $reqs
	 * @return StatusValue
	 */
	public function testForAccountCreation( $user, $creator, array $reqs ): StatusValue {
		return $this->testUser( $user, $creator, false );
	}

	/**
	 * @param User $user
	 * @param bool|string $autocreate
	 * @param array $options
	 * @return StatusValue
	 */
	public function testUserForCreation( $user, $autocreate, array $options = [] ): StatusValue {
		// if this is not an autocreation, testForAccountCreation already handled it
		if ( $autocreate && !( $options['canAlwaysAutocreate'] ?? false ) ) {
			// Make sure to use an anon as the creator, see T272244
			return $this->testUser( $user, $this->userFactory->newAnonymous(), true );
		}
		return StatusValue::newGood();
	}

	/**
	 * @param User $user The user being created or autocreated
	 * @param User $creator The user who caused $user to be created (can be anonymous)
	 * @param bool $autocreate Is this an autocreation?
	 * @return StatusValue
	 */
	private function testUser( $user, $creator, $autocreate ): StatusValue {
		$startTime = microtime( true );
		if ( $user->getName() === wfMessage( 'abusefilter-blocker' )->inContentLanguage()->text() ) {
			return StatusValue::newFatal( 'abusefilter-accountreserved' );
		}

		$title = SpecialPage::getTitleFor( 'Userlogin' );
		$builder = $this->variableGeneratorFactory->newRunGenerator( $creator, $title );
		$vars = $builder->getAccountCreationVars( $user, $autocreate );

		// pass creator in explicitly to prevent recording the current user on autocreation - T135360
		$runner = $this->filterRunnerFactory->newRunner( $creator, $title, $vars, 'default' );
		$status = $runner->run();

		$this->statsd->timing( 'timing.createaccountAbuseFilter', microtime( true ) - $startTime );

		return $status->getStatusValue();
	}
}
PK       ! ĤF  F    KeywordsManager.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;

/**
 * This service can be used to manage the list of keywords recognized by the Parser
 */
class KeywordsManager {
	public const SERVICE_NAME = 'AbuseFilterKeywordsManager';

	/**
	 * Operators and functions that can be used in AbuseFilter code.
	 * They are shown in the dropdown in the filter editor.
	 * Keys of translatable messages with their descriptions are
	 * based on keys of this array.
	 * When editing this list or the messages, keep the order
	 * consistent in both lists.
	 *
	 * @var array
	 */
	private const BUILDER_VALUES = [
		'op-arithmetic' => [
			// Generates abusefilter-edit-builder-op-arithmetic-addition
			'+' => 'addition',
			// Generates abusefilter-edit-builder-op-arithmetic-subtraction
			'-' => 'subtraction',
			// Generates abusefilter-edit-builder-op-arithmetic-multiplication
			'*' => 'multiplication',
			// Generates abusefilter-edit-builder-op-arithmetic-divide
			'/' => 'divide',
			// Generates abusefilter-edit-builder-op-arithmetic-modulo
			'%' => 'modulo',
			// Generates abusefilter-edit-builder-op-arithmetic-pow
			'**' => 'pow'
		],
		'op-comparison' => [
			// Generates abusefilter-edit-builder-op-comparison-equal
			'==' => 'equal',
			// Generates abusefilter-edit-builder-op-comparison-equal-strict
			'===' => 'equal-strict',
			// Generates abusefilter-edit-builder-op-comparison-notequal
			'!=' => 'notequal',
			// Generates abusefilter-edit-builder-op-comparison-notequal-strict
			'!==' => 'notequal-strict',
			// Generates abusefilter-edit-builder-op-comparison-lt
			'<' => 'lt',
			// Generates abusefilter-edit-builder-op-comparison-gt
			'>' => 'gt',
			// Generates abusefilter-edit-builder-op-comparison-lte
			'<=' => 'lte',
			// Generates abusefilter-edit-builder-op-comparison-gte
			'>=' => 'gte'
		],
		'op-bool' => [
			// Generates abusefilter-edit-builder-op-bool-not
			'!' => 'not',
			// Generates abusefilter-edit-builder-op-bool-and
			'&' => 'and',
			// Generates abusefilter-edit-builder-op-bool-or
			'|' => 'or',
			// Generates abusefilter-edit-builder-op-bool-xor
			'^' => 'xor'
		],
		'misc' => [
			// Generates abusefilter-edit-builder-misc-in
			'in' => 'in',
			// Generates abusefilter-edit-builder-misc-contains
			'contains' => 'contains',
			// Generates abusefilter-edit-builder-misc-like
			'like' => 'like',
			// Generates abusefilter-edit-builder-misc-stringlit
			'""' => 'stringlit',
			// Generates abusefilter-edit-builder-misc-rlike
			'rlike' => 'rlike',
			// Generates abusefilter-edit-builder-misc-irlike
			'irlike' => 'irlike',
			// Generates abusefilter-edit-builder-misc-tern
			'cond ? iftrue : iffalse' => 'tern',
			// Generates abusefilter-edit-builder-misc-cond
			'if cond then iftrue else iffalse end' => 'cond',
			// Generates abusefilter-edit-builder-misc-cond-short
			'if cond then iftrue end' => 'cond-short',
		],
		'funcs' => [
			// Generates abusefilter-edit-builder-funcs-length
			'length(string)' => 'length',
			// Generates abusefilter-edit-builder-funcs-lcase
			'lcase(string)' => 'lcase',
			// Generates abusefilter-edit-builder-funcs-ucase
			'ucase(string)' => 'ucase',
			// Generates abusefilter-edit-builder-funcs-ccnorm
			'ccnorm(string)' => 'ccnorm',
			// Generates abusefilter-edit-builder-funcs-ccnorm-contains-any
			'ccnorm_contains_any(haystack,needle1,needle2,..)' => 'ccnorm-contains-any',
			// Generates abusefilter-edit-builder-funcs-ccnorm-contains-all
			'ccnorm_contains_all(haystack,needle1,needle2,..)' => 'ccnorm-contains-all',
			// Generates abusefilter-edit-builder-funcs-rmdoubles
			'rmdoubles(string)' => 'rmdoubles',
			// Generates abusefilter-edit-builder-funcs-specialratio
			'specialratio(string)' => 'specialratio',
			// Generates abusefilter-edit-builder-funcs-norm
			'norm(string)' => 'norm',
			// Generates abusefilter-edit-builder-funcs-count
			'count(needle,haystack)' => 'count',
			// Generates abusefilter-edit-builder-funcs-rcount
			'rcount(needle,haystack)' => 'rcount',
			// Generates abusefilter-edit-builder-funcs-get_matches
			'get_matches(needle,haystack)' => 'get_matches',
			// Generates abusefilter-edit-builder-funcs-rmwhitespace
			'rmwhitespace(text)' => 'rmwhitespace',
			// Generates abusefilter-edit-builder-funcs-rmspecials
			'rmspecials(text)' => 'rmspecials',
			// Generates abusefilter-edit-builder-funcs-ip_in_range
			'ip_in_range(ip, range)' => 'ip_in_range',
			// Generates abusefilter-edit-builder-funcs-ip_in_ranges
			'ip_in_ranges(ip, range1, range2, ...)' => 'ip_in_ranges',
			// Generates abusefilter-edit-builder-funcs-contains-any
			'contains_any(haystack,needle1,needle2,...)' => 'contains-any',
			// Generates abusefilter-edit-builder-funcs-contains-all
			'contains_all(haystack,needle1,needle2,...)' => 'contains-all',
			// Generates abusefilter-edit-builder-funcs-equals-to-any
			'equals_to_any(haystack,needle1,needle2,...)' => 'equals-to-any',
			// Generates abusefilter-edit-builder-funcs-substr
			'substr(subject, offset, length)' => 'substr',
			// Generates abusefilter-edit-builder-funcs-strpos
			'strpos(haystack, needle)' => 'strpos',
			// Generates abusefilter-edit-builder-funcs-str_replace
			'str_replace(subject, search, replace)' => 'str_replace',
			// Generates abusefilter-edit-builder-funcs-str_replace_regexp
			'str_replace_regexp(subject, search, replace)' => 'str_replace_regexp',
			// Generates abusefilter-edit-builder-funcs-rescape
			'rescape(string)' => 'rescape',
			// Generates abusefilter-edit-builder-funcs-set_var
			'set_var(var,value)' => 'set_var',
			// Generates abusefilter-edit-builder-funcs-sanitize
			'sanitize(string)' => 'sanitize',
		],
		'vars' => [
			// Generates abusefilter-edit-builder-vars-timestamp
			'timestamp' => 'timestamp',
			// Generates abusefilter-edit-builder-vars-accountname
			'accountname' => 'accountname',
			// Generates abusefilter-edit-builder-vars-action
			'action' => 'action',
			// Generates abusefilter-edit-builder-vars-addedlines
			'added_lines' => 'addedlines',
			// Generates abusefilter-edit-builder-vars-delta
			'edit_delta' => 'delta',
			// Generates abusefilter-edit-builder-vars-diff
			'edit_diff' => 'diff',
			// Generates abusefilter-edit-builder-vars-newsize
			'new_size' => 'newsize',
			// Generates abusefilter-edit-builder-vars-oldsize
			'old_size' => 'oldsize',
			// Generates abusefilter-edit-builder-vars-new-content-model
			'new_content_model' => 'new-content-model',
			// Generates abusefilter-edit-builder-vars-old-content-model
			'old_content_model' => 'old-content-model',
			// Generates abusefilter-edit-builder-vars-removedlines
			'removed_lines' => 'removedlines',
			// Generates abusefilter-edit-builder-vars-summary
			'summary' => 'summary',
			// Generates abusefilter-edit-builder-vars-page-id
			'page_id' => 'page-id',
			// Generates abusefilter-edit-builder-vars-page-ns
			'page_namespace' => 'page-ns',
			// Generates abusefilter-edit-builder-vars-page-title
			'page_title' => 'page-title',
			// Generates abusefilter-edit-builder-vars-page-prefixedtitle
			'page_prefixedtitle' => 'page-prefixedtitle',
			// Generates abusefilter-edit-builder-vars-page-age
			'page_age' => 'page-age',
			// Generates abusefilter-edit-builder-vars-page-last-edit-age
			'page_last_edit_age' => 'page-last-edit-age',
			// Generates abusefilter-edit-builder-vars-movedfrom-id
			'moved_from_id' => 'movedfrom-id',
			// Generates abusefilter-edit-builder-vars-movedfrom-ns
			'moved_from_namespace' => 'movedfrom-ns',
			// Generates abusefilter-edit-builder-vars-movedfrom-title
			'moved_from_title' => 'movedfrom-title',
			// Generates abusefilter-edit-builder-vars-movedfrom-prefixedtitle
			'moved_from_prefixedtitle' => 'movedfrom-prefixedtitle',
			// Generates abusefilter-edit-builder-vars-movedfrom-age
			'moved_from_age' => 'movedfrom-age',
			// Generates abusefilter-edit-builder-vars-movedfrom-last-edit-age
			'moved_from_last_edit_age' => 'movedfrom-last-edit-age',
			// Generates abusefilter-edit-builder-vars-movedto-id
			'moved_to_id' => 'movedto-id',
			// Generates abusefilter-edit-builder-vars-movedto-ns
			'moved_to_namespace' => 'movedto-ns',
			// Generates abusefilter-edit-builder-vars-movedto-title
			'moved_to_title' => 'movedto-title',
			// Generates abusefilter-edit-builder-vars-movedto-prefixedtitle
			'moved_to_prefixedtitle' => 'movedto-prefixedtitle',
			// Generates abusefilter-edit-builder-vars-movedto-age
			'moved_to_age' => 'movedto-age',
			// Generates abusefilter-edit-builder-vars-movedto-last-edit-age
			'moved_to_last_edit_age' => 'movedto-last-edit-age',
			// Generates abusefilter-edit-builder-vars-user-editcount
			'user_editcount' => 'user-editcount',
			// Generates abusefilter-edit-builder-vars-user-age
			'user_age' => 'user-age',
			// Generates abusefilter-edit-builder-vars-user-unnamed-ip
			'user_unnamed_ip' => 'user-unnamed-ip',
			// Generates abusefilter-edit-builder-vars-user-name
			'user_name' => 'user-name',
			// Generates abusefilter-edit-builder-vars-user-type
			'user_type' => 'user-type',
			// Generates abusefilter-edit-builder-vars-user-groups
			'user_groups' => 'user-groups',
			// Generates abusefilter-edit-builder-vars-user-rights
			'user_rights' => 'user-rights',
			// Generates abusefilter-edit-builder-vars-user-blocked
			'user_blocked' => 'user-blocked',
			// Generates abusefilter-edit-builder-vars-user-emailconfirm
			'user_emailconfirm' => 'user-emailconfirm',
			// Generates abusefilter-edit-builder-vars-old-wikitext
			'old_wikitext' => 'old-wikitext',
			// Generates abusefilter-edit-builder-vars-new-wikitext
			'new_wikitext' => 'new-wikitext',
			// Generates abusefilter-edit-builder-vars-added-links
			'added_links' => 'added-links',
			// Generates abusefilter-edit-builder-vars-removed-links
			'removed_links' => 'removed-links',
			// Generates abusefilter-edit-builder-vars-all-links
			'all_links' => 'all-links',
			// Generates abusefilter-edit-builder-vars-new-pst
			'new_pst' => 'new-pst',
			// Generates abusefilter-edit-builder-vars-diff-pst
			'edit_diff_pst' => 'diff-pst',
			// Generates abusefilter-edit-builder-vars-addedlines-pst
			'added_lines_pst' => 'addedlines-pst',
			// Generates abusefilter-edit-builder-vars-new-text
			'new_text' => 'new-text',
			// Generates abusefilter-edit-builder-vars-new-html
			'new_html' => 'new-html',
			// Generates abusefilter-edit-builder-vars-restrictions-edit
			'page_restrictions_edit' => 'restrictions-edit',
			// Generates abusefilter-edit-builder-vars-restrictions-move
			'page_restrictions_move' => 'restrictions-move',
			// Generates abusefilter-edit-builder-vars-restrictions-create
			'page_restrictions_create' => 'restrictions-create',
			// Generates abusefilter-edit-builder-vars-restrictions-upload
			'page_restrictions_upload' => 'restrictions-upload',
			// Generates abusefilter-edit-builder-vars-recent-contributors
			'page_recent_contributors' => 'recent-contributors',
			// Generates abusefilter-edit-builder-vars-first-contributor
			'page_first_contributor' => 'first-contributor',
			// Generates abusefilter-edit-builder-vars-movedfrom-restrictions-edit
			'moved_from_restrictions_edit' => 'movedfrom-restrictions-edit',
			// Generates abusefilter-edit-builder-vars-movedfrom-restrictions-move
			'moved_from_restrictions_move' => 'movedfrom-restrictions-move',
			// Generates abusefilter-edit-builder-vars-movedfrom-restrictions-create
			'moved_from_restrictions_create' => 'movedfrom-restrictions-create',
			// Generates abusefilter-edit-builder-vars-movedfrom-restrictions-upload
			'moved_from_restrictions_upload' => 'movedfrom-restrictions-upload',
			// Generates abusefilter-edit-builder-vars-movedfrom-recent-contributors
			'moved_from_recent_contributors' => 'movedfrom-recent-contributors',
			// Generates abusefilter-edit-builder-vars-movedfrom-first-contributor
			'moved_from_first_contributor' => 'movedfrom-first-contributor',
			// Generates abusefilter-edit-builder-vars-movedto-restrictions-edit
			'moved_to_restrictions_edit' => 'movedto-restrictions-edit',
			// Generates abusefilter-edit-builder-vars-movedto-restrictions-move
			'moved_to_restrictions_move' => 'movedto-restrictions-move',
			// Generates abusefilter-edit-builder-vars-movedto-restrictions-create
			'moved_to_restrictions_create' => 'movedto-restrictions-create',
			// Generates abusefilter-edit-builder-vars-movedto-restrictions-upload
			'moved_to_restrictions_upload' => 'movedto-restrictions-upload',
			// Generates abusefilter-edit-builder-vars-movedto-recent-contributors
			'moved_to_recent_contributors' => 'movedto-recent-contributors',
			// Generates abusefilter-edit-builder-vars-movedto-first-contributor
			'moved_to_first_contributor' => 'movedto-first-contributor',
			// Generates abusefilter-edit-builder-vars-old-links
			'old_links' => 'old-links',
			// Generates abusefilter-edit-builder-vars-file-sha1
			'file_sha1' => 'file-sha1',
			// Generates abusefilter-edit-builder-vars-file-size
			'file_size' => 'file-size',
			// Generates abusefilter-edit-builder-vars-file-mime
			'file_mime' => 'file-mime',
			// Generates abusefilter-edit-builder-vars-file-mediatype
			'file_mediatype' => 'file-mediatype',
			// Generates abusefilter-edit-builder-vars-file-width
			'file_width' => 'file-width',
			// Generates abusefilter-edit-builder-vars-file-height
			'file_height' => 'file-height',
			// Generates abusefilter-edit-builder-vars-file-bits-per-channel
			'file_bits_per_channel' => 'file-bits-per-channel',
			// Generates abusefilter-edit-builder-vars-wiki-name
			'wiki_name' => 'wiki-name',
			// Generates abusefilter-edit-builder-vars-wiki-language
			'wiki_language' => 'wiki-language',
		],
	];

	/**
	 * Old vars which aren't in use anymore.
	 * The translatable messages that are based
	 * on them are not shown in the filter editor,
	 * but may still be shown in the log descriptions of
	 * filter actions that were taken by filters
	 * that used them.
	 *
	 * @var array
	 */
	private const DISABLED_VARS = [
		// Generates abusefilter-edit-builder-vars-old-text
		'old_text' => 'old-text',
		// Generates abusefilter-edit-builder-vars-old-html
		'old_html' => 'old-html',
		// Generates abusefilter-edit-builder-vars-minor-edit
		'minor_edit' => 'minor-edit'
	];

	private const DEPRECATED_VARS = [
		'article_text' => 'page_title',
		'article_prefixedtext' => 'page_prefixedtitle',
		'article_namespace' => 'page_namespace',
		'article_articleid' => 'page_id',
		'article_restrictions_edit' => 'page_restrictions_edit',
		'article_restrictions_move' => 'page_restrictions_move',
		'article_restrictions_create' => 'page_restrictions_create',
		'article_restrictions_upload' => 'page_restrictions_upload',
		'article_recent_contributors' => 'page_recent_contributors',
		'article_first_contributor' => 'page_first_contributor',
		'moved_from_text' => 'moved_from_title',
		'moved_from_prefixedtext' => 'moved_from_prefixedtitle',
		'moved_from_articleid' => 'moved_from_id',
		'moved_to_text' => 'moved_to_title',
		'moved_to_prefixedtext' => 'moved_to_prefixedtitle',
		'moved_to_articleid' => 'moved_to_id',
	];

	/** @var string[][] Final list of builder values */
	private $builderValues;

	/** @var string[] Final list of deprecated vars */
	private $deprecatedVars;

	/** @var AbuseFilterHookRunner */
	private $hookRunner;

	/**
	 * @param AbuseFilterHookRunner $hookRunner
	 */
	public function __construct( AbuseFilterHookRunner $hookRunner ) {
		$this->hookRunner = $hookRunner;
	}

	/**
	 * @return array
	 */
	public function getDisabledVariables(): array {
		return self::DISABLED_VARS;
	}

	/**
	 * @return array
	 */
	public function getDeprecatedVariables(): array {
		if ( $this->deprecatedVars === null ) {
			$this->deprecatedVars = self::DEPRECATED_VARS;
			$this->hookRunner->onAbuseFilter_deprecatedVariables( $this->deprecatedVars );
		}
		return $this->deprecatedVars;
	}

	/**
	 * @return array
	 */
	public function getBuilderValues(): array {
		if ( $this->builderValues === null ) {
			$this->builderValues = self::BUILDER_VALUES;
			$this->hookRunner->onAbuseFilter_builder( $this->builderValues );
		}
		return $this->builderValues;
	}

	/**
	 * @param string $name
	 * @return bool
	 */
	public function isVarDisabled( string $name ): bool {
		return array_key_exists( $name, self::DISABLED_VARS );
	}

	/**
	 * @param string $name
	 * @return bool
	 */
	public function isVarDeprecated( string $name ): bool {
		return array_key_exists( $name, $this->getDeprecatedVariables() );
	}

	/**
	 * @param string $name
	 * @return bool
	 */
	public function isVarInUse( string $name ): bool {
		return array_key_exists( $name, $this->getVarsMappings() );
	}

	/**
	 * Check whether the given name corresponds to a known variable.
	 * @param string $name
	 * @return bool
	 */
	public function varExists( string $name ): bool {
		return $this->isVarInUse( $name ) ||
			$this->isVarDisabled( $name ) ||
			$this->isVarDeprecated( $name );
	}

	/**
	 * Get the message for a builtin variable; takes deprecated variables into account.
	 * Returns null for non-builtin variables.
	 *
	 * @param string $var
	 * @return string|null
	 */
	public function getMessageKeyForVar( string $var ): ?string {
		if ( !$this->varExists( $var ) ) {
			return null;
		}
		if ( $this->isVarDeprecated( $var ) ) {
			$var = $this->getDeprecatedVariables()[$var];
		}

		$key = self::DISABLED_VARS[$var] ??
			$this->getVarsMappings()[$var];
		return "abusefilter-edit-builder-vars-$key";
	}

	/**
	 * @return array
	 */
	public function getVarsMappings(): array {
		return $this->getBuilderValues()['vars'];
	}

	/**
	 * Get a list of core variables, i.e. variables defined in AbuseFilter (ignores hooks).
	 * You usually want to use getVarsMappings(), not this one.
	 * @return string[]
	 */
	public function getCoreVariables(): array {
		return array_keys( self::BUILDER_VALUES['vars'] );
	}
}
PK       ! ]?T  T    InvalidImportDataException.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use InvalidArgumentException;

/**
 * @codeCoverageIgnore
 */
class InvalidImportDataException extends InvalidArgumentException {
	/**
	 * @param string $data That is not valid
	 */
	public function __construct( string $data ) {
		parent::__construct( "Invalid import data: $data" );
	}
}
PK       ! ;U    .  LogFormatter/AbuseFilterModifyLogFormatter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\LogFormatter;

use LogFormatter;
use MediaWiki\Message\Message;
use MediaWiki\SpecialPage\SpecialPage;

class AbuseFilterModifyLogFormatter extends LogFormatter {

	/**
	 * @return string
	 */
	protected function getMessageKey() {
		$subtype = $this->entry->getSubtype();
		// Messages that can be used here:
		// * abusefilter-logentry-create
		// * abusefilter-logentry-modify
		return "abusefilter-logentry-$subtype";
	}

	/**
	 * @return array
	 */
	protected function extractParameters() {
		$parameters = $this->entry->getParameters();
		if ( $this->entry->isLegacy() ) {
			[ $historyId, $filterId ] = $parameters;
		} else {
			$historyId = $parameters['historyId'];
			$filterId = $parameters['newId'];
		}

		$detailsTitle = SpecialPage::getTitleFor(
			'AbuseFilter',
			"history/$filterId/diff/prev/$historyId"
		);

		$params = [];
		$params[3] = Message::rawParam(
			$this->makePageLink(
				$this->entry->getTarget(),
				[],
				$this->msg( 'abusefilter-log-detailedentry-local' )
					->numParams( $filterId )->escaped()
			)
		);
		$params[4] = Message::rawParam(
			$this->makePageLink(
				$detailsTitle,
				[],
				$this->msg( 'abusefilter-log-detailslink' )->escaped()
			)
		);

		return $params;
	}

}
PK       ! ۪<6  6  0  LogFormatter/ProtectedVarsAccessLogFormatter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\LogFormatter;

use LogEntry;
use LogFormatter;
use MediaWiki\Extension\AbuseFilter\ProtectedVarsAccessLogger;
use MediaWiki\Linker\Linker;
use MediaWiki\Message\Message;
use MediaWiki\User\UserFactory;

class ProtectedVarsAccessLogFormatter extends LogFormatter {

	private UserFactory $userFactory;

	public function __construct(
		LogEntry $entry,
		UserFactory $userFactory
	) {
		parent::__construct( $entry );
		$this->userFactory = $userFactory;
	}

	/**
	 * @inheritDoc
	 */
	protected function getMessageParameters() {
		$params = parent::getMessageParameters();

		// Replace temporary user page link with contributions page link.
		// Don't use LogFormatter::makeUserLink, because that adds tools links.
		if ( $this->entry->getSubtype() === ProtectedVarsAccessLogger::ACTION_VIEW_PROTECTED_VARIABLE_VALUE ) {
			$tempUserName = $this->entry->getTarget()->getText();
			$params[2] = Message::rawParam(
				Linker::userLink( 0, $this->userFactory->newUnsavedTempUser( $tempUserName ) )
			);
		}

		return $params;
	}
}
PK       ! 9}    8  LogFormatter/AbuseFilterBlockedDomainHitLogFormatter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\LogFormatter;

use LogFormatter;
use MediaWiki\Message\Message;

class AbuseFilterBlockedDomainHitLogFormatter extends LogFormatter {
	/**
	 * @return array
	 * @suppress SecurityCheck-DoubleEscaped Known taint-check bug
	 */
	protected function getMessageParameters() {
		$params = parent::getMessageParameters();
		$params[3] = Message::rawParam( htmlspecialchars( $params[3] ) );
		return $params;
	}

}
PK       ! mz  z  0  LogFormatter/AbuseFilterSuppressLogFormatter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\LogFormatter;

use LogFormatter;

class AbuseFilterSuppressLogFormatter extends LogFormatter {

	/**
	 * @return string
	 */
	protected function getMessageKey() {
		if ( $this->entry->getSubtype() === 'unhide-afl' ) {
			return 'abusefilter-log-entry-unsuppress';
		} else {
			return 'abusefilter-log-entry-suppress';
		}
	}

}
PK       ! Hݹ    %  LogFormatter/AbuseLogHitFormatter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\LogFormatter;

use LogEntry;
use LogFormatter;
use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
use MediaWiki\Message\Message;
use MediaWiki\SpecialPage\SpecialPage;

/**
 * This class formats abuse log notifications.
 *
 * Uses logentry-abusefilter-hit
 */
class AbuseLogHitFormatter extends LogFormatter {

	private SpecsFormatter $specsFormatter;

	public function __construct(
		LogEntry $entry,
		SpecsFormatter $specsFormatter
	) {
		parent::__construct( $entry );
		$this->specsFormatter = $specsFormatter;
	}

	/**
	 * @return array
	 */
	protected function getMessageParameters() {
		$entry = $this->entry->getParameters();
		$linkRenderer = $this->getLinkRenderer();
		$params = parent::getMessageParameters();

		$filter_title = SpecialPage::getTitleFor( 'AbuseFilter', $entry['filter'] );
		$filter_caption = $this->msg( 'abusefilter-log-detailedentry-local' )
			->params( $entry['filter'] )
			->text();
		$log_title = SpecialPage::getTitleFor( 'AbuseLog', $entry['log'] );
		$log_caption = $this->msg( 'abusefilter-log-detailslink' )->text();

		$params[4] = $entry['action'];

		if ( $this->plaintext ) {
			$params[3] = '[[' . $filter_title->getPrefixedText() . '|' . $filter_caption . ']]';
			$params[8] = '[[' . $log_title->getPrefixedText() . '|' . $log_caption . ']]';
		} else {
			$params[3] = Message::rawParam( $linkRenderer->makeLink(
				$filter_title,
				$filter_caption
			) );
			$params[8] = Message::rawParam( $linkRenderer->makeLink(
				$log_title,
				$log_caption
			) );
		}

		$actions_takenRaw = $entry['actions'];
		if ( !strlen( trim( $actions_takenRaw ) ) ) {
			$actions_taken = $this->msg( 'abusefilter-log-noactions' );
		} else {
			$actions = explode( ',', $actions_takenRaw );
			$displayActions = [];

			$this->specsFormatter->setMessageLocalizer( $this->context );
			foreach ( $actions as $action ) {
				$displayActions[] = $this->specsFormatter->getActionDisplay( $action );
			}
			$actions_taken = $this->context->getLanguage()->commaList( $displayActions );
		}
		$params[5] = Message::rawParam( $actions_taken );

		// Bad things happen if the numbers are not in correct order
		ksort( $params );

		return $params;
	}
}
PK       ! @    .  LogFormatter/AbuseFilterRightsLogFormatter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\LogFormatter;

use LogFormatter;

class AbuseFilterRightsLogFormatter extends LogFormatter {

	/**
	 * This method is identical to the parent, but it's redeclared to give grep a chance
	 * to find the messages.
	 * @inheritDoc
	 */
	protected function getMessageKey() {
		$subtype = $this->entry->getSubtype();
		// Messages that can be used here:
		// * logentry-rights-blockautopromote
		// * logentry-rights-restoreautopromote
		return "logentry-rights-$subtype";
	}

	/**
	 * @inheritDoc
	 */
	protected function extractParameters() {
		$ret = [];
		$ret[3] = $this->entry->getTarget()->getText();
		if ( $this->entry->getSubtype() === 'blockautopromote' ) {
			$parameters = $this->entry->getParameters();
			$duration = $parameters['7::duration'];
			$ret[4] = $this->context->getLanguage()->formatDuration( $duration );
		}
		return $ret;
	}

	/**
	 * @inheritDoc
	 */
	protected function getMessageParameters() {
		$params = parent::getMessageParameters();
		// remove "User:" prefix
		$params[2] = $this->formatParameterValue( 'user-link', $this->entry->getTarget()->getText() );
		return $params;
	}

}
PK       ! G3      AbuseFilterChangesList.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use HtmlArmor;
use LogFormatter;
use MediaWiki\Context\IContextSource;
use MediaWiki\Linker\Linker;
use MediaWiki\MediaWikiServices;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\TitleValue;
use OldChangesList;
use RecentChange;

class AbuseFilterChangesList extends OldChangesList {

	/**
	 * @var string
	 */
	private $testFilter;

	/**
	 * @param IContextSource $context
	 * @param string $testFilter
	 */
	public function __construct( IContextSource $context, $testFilter ) {
		parent::__construct( $context );
		$this->testFilter = $testFilter;
	}

	/**
	 * @param string &$s
	 * @param RecentChange &$rc
	 * @param string[] &$classes
	 */
	public function insertExtra( &$s, &$rc, &$classes ) {
		if ( (int)$rc->getAttribute( 'rc_deleted' ) !== 0 ) {
			$s .= ' ' . $this->msg( 'abusefilter-log-hidden-implicit' )->parse();
			if ( !$this->userCan( $rc, RevisionRecord::SUPPRESSED_ALL ) ) {
				// Remember to keep this in sync with the CheckMatch API
				return;
			}
		}

		$examineParams = [];
		if ( $this->testFilter && strlen( $this->testFilter ) < 2000 ) {
			// Since this is GETed, don't send it if it's too long to prevent broken URLs 2000 is taken from
			// https://stackoverflow.com/questions/417142/what-is-the-maximum-length-of-a-url-
			// in-different-browsers/417184#417184
			$examineParams['testfilter'] = $this->testFilter;
		}

		$title = SpecialPage::getTitleFor( 'AbuseFilter', 'examine/' . $rc->getAttribute( 'rc_id' ) );
		$examineLink = $this->linkRenderer->makeLink(
			$title,
			new HtmlArmor( $this->msg( 'abusefilter-changeslist-examine' )->parse() ),
			[],
			$examineParams
		);

		$s .= ' ' . $this->msg( 'parentheses' )->rawParams( $examineLink )->escaped();

		// Add CSS classes for match and not match
		if ( isset( $rc->filterResult ) ) {
			$class = $rc->filterResult ?
				'mw-abusefilter-changeslist-match' :
				'mw-abusefilter-changeslist-nomatch';

			$classes[] = $class;
		}
	}

	/**
	 * Overridden as a hacky workaround for T273387. Yuck!
	 * @inheritDoc
	 */
	public function recentChangesLine( &$rc, $watched = false, $linenumber = null ) {
		$par = parent::recentChangesLine( $rc, $watched, $linenumber );
		if ( $par === false || $par === '' ) {
			return $par;
		}
		$ret = preg_replace( '/<\/li>$/', '', $par );
		if ( $rc->getAttribute( 'rc_source' ) === 'flow' ) {
			$classes = [];
			$this->insertExtra( $ret, $rc, $classes );
		}
		return $ret . '</li>';
	}

	/**
	 * Insert links to user page, user talk page and eventually a blocking link.
	 *   Like the parent, but don't hide details if user can see them.
	 *
	 * @param string &$s HTML to update
	 * @param RecentChange &$rc
	 */
	public function insertUserRelatedLinks( &$s, &$rc ) {
		$links = $this->getLanguage()->getDirMark() . Linker::userLink( $rc->getAttribute( 'rc_user' ),
				$rc->getAttribute( 'rc_user_text' ) ) .
				Linker::userToolLinks( $rc->getAttribute( 'rc_user' ), $rc->getAttribute( 'rc_user_text' ) );

		if ( $this->isDeleted( $rc, RevisionRecord::DELETED_USER ) ) {
			if ( $this->userCan( $rc, RevisionRecord::DELETED_USER ) ) {
				$s .= ' <span class="history-deleted">' . $links . '</span>';
			} else {
				$s .= ' <span class="history-deleted">' .
					$this->msg( 'rev-deleted-user' )->escaped() . '</span>';
			}
		} else {
			$s .= $links;
		}
	}

	/**
	 * Insert a formatted comment. Like the parent, but don't hide details if user can see them.
	 * @param RecentChange $rc
	 * @return string
	 */
	public function insertComment( $rc ) {
		if ( $this->isDeleted( $rc, RevisionRecord::DELETED_COMMENT ) ) {
			if ( $this->userCan( $rc, RevisionRecord::DELETED_COMMENT ) ) {
				return ' <span class="history-deleted">' .
					MediaWikiServices::getInstance()->getCommentFormatter()
						->formatBlock(
							$rc->getAttribute( 'rc_comment' ),
							TitleValue::castPageToLinkTarget( $rc->getPage() )
						) . '</span>';
			} else {
				return ' <span class="history-deleted">' .
					$this->msg( 'rev-deleted-comment' )->escaped() . '</span>';
			}
		} else {
			return MediaWikiServices::getInstance()->getCommentFormatter()
				->formatBlock( $rc->getAttribute( 'rc_comment' ), TitleValue::castPageToLinkTarget( $rc->getPage() ) );
		}
	}

	/**
	 * Insert a formatted action. The same as parent, but with a different audience in LogFormatter
	 *
	 * @param RecentChange $rc
	 * @return string
	 */
	public function insertLogEntry( $rc ) {
		$formatter = MediaWikiServices::getInstance()->getLogFormatterFactory()->newFromRow( $rc->getAttributes() );
		$formatter->setContext( $this->getContext() );
		$formatter->setAudience( LogFormatter::FOR_THIS_USER );
		$formatter->setShowUserToolLinks( true );
		$mark = $this->getLanguage()->getDirMark();
		return $formatter->getActionText() . " $mark" . $formatter->getComment();
	}

	/**
	 * @param string &$s
	 * @param RecentChange &$rc
	 */
	public function insertRollback( &$s, &$rc ) {
		// Kill rollback links.
	}
}
PK       ! .D      CentralDBManager.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use Wikimedia\Rdbms\DBError;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\LBFactory;

class CentralDBManager {
	public const SERVICE_NAME = 'AbuseFilterCentralDBManager';

	/** @var LBFactory */
	private $loadBalancerFactory;
	/** @var string|false */
	private $dbName;
	/** @var bool */
	private $filterIsCentral;

	/**
	 * @param LBFactory $loadBalancerFactory
	 * @param string|false|null $dbName
	 * @param bool $filterIsCentral
	 */
	public function __construct( LBFactory $loadBalancerFactory, $dbName, bool $filterIsCentral ) {
		$this->loadBalancerFactory = $loadBalancerFactory;
		// Use false to agree with LoadBalancer
		$this->dbName = $dbName ?: false;
		$this->filterIsCentral = $filterIsCentral;
	}

	/**
	 * @param int $index DB_PRIMARY/DB_REPLICA
	 * @return IDatabase
	 * @throws DBError
	 * @throws CentralDBNotAvailableException
	 */
	public function getConnection( int $index ): IDatabase {
		if ( !is_string( $this->dbName ) ) {
			throw new CentralDBNotAvailableException( '$wgAbuseFilterCentralDB is not configured' );
		}

		return $this->loadBalancerFactory
			->getMainLB( $this->dbName )
			->getConnection( $index, [], $this->dbName );
	}

	/**
	 * @return string
	 * @throws CentralDBNotAvailableException
	 */
	public function getCentralDBName(): string {
		if ( !is_string( $this->dbName ) ) {
			throw new CentralDBNotAvailableException( '$wgAbuseFilterCentralDB is not configured' );
		}
		return $this->dbName;
	}

	/**
	 * Whether this database is the central one.
	 * @todo Deprecate the config in favour of just checking whether the current DB is the same
	 *  as $wgAbuseFilterCentralDB.
	 * @return bool
	 */
	public function filterIsCentral(): bool {
		return $this->filterIsCentral;
	}
}
PK       ! $s[      Watcher/EmergencyWatcher.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Watcher;

use InvalidArgumentException;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Deferred\AutoCommitUpdate;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Extension\AbuseFilter\EchoNotifier;
use MediaWiki\Extension\AbuseFilter\EmergencyCache;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\LBFactory;

/**
 * Service for monitoring filters with restricted actions and preventing them
 * from executing destructive actions ("throttling")
 *
 * @todo We should log throttling somewhere
 */
class EmergencyWatcher implements Watcher {
	public const SERVICE_NAME = 'AbuseFilterEmergencyWatcher';

	public const CONSTRUCTOR_OPTIONS = [
		'AbuseFilterEmergencyDisableAge',
		'AbuseFilterEmergencyDisableCount',
		'AbuseFilterEmergencyDisableThreshold',
	];

	/** @var EmergencyCache */
	private $cache;

	/** @var LBFactory */
	private $lbFactory;

	/** @var FilterLookup */
	private $filterLookup;

	/** @var EchoNotifier */
	private $notifier;

	/** @var ServiceOptions */
	private $options;

	/**
	 * @param EmergencyCache $cache
	 * @param LBFactory $lbFactory
	 * @param FilterLookup $filterLookup
	 * @param EchoNotifier $notifier
	 * @param ServiceOptions $options
	 */
	public function __construct(
		EmergencyCache $cache,
		LBFactory $lbFactory,
		FilterLookup $filterLookup,
		EchoNotifier $notifier,
		ServiceOptions $options
	) {
		$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
		$this->cache = $cache;
		$this->lbFactory = $lbFactory;
		$this->filterLookup = $filterLookup;
		$this->notifier = $notifier;
		$this->options = $options;
	}

	/**
	 * Determine which filters must be throttled, i.e. their potentially dangerous
	 *  actions must be disabled.
	 *
	 * @param int[] $filters The filters to check
	 * @param string $group Group the filters belong to
	 * @return int[] Array of filters to be throttled
	 */
	public function getFiltersToThrottle( array $filters, string $group ): array {
		$filters = array_intersect(
			$filters,
			$this->cache->getFiltersToCheckInGroup( $group )
		);
		if ( $filters === [] ) {
			return [];
		}

		$threshold = $this->getEmergencyValue( 'threshold', $group );
		$hitCountLimit = $this->getEmergencyValue( 'count', $group );
		$maxAge = $this->getEmergencyValue( 'age', $group );

		$time = (int)wfTimestamp( TS_UNIX );

		$throttleFilters = [];
		foreach ( $filters as $filter ) {
			$filterObj = $this->filterLookup->getFilter( $filter, false );
			// TODO: consider removing the filter from the group key
			// after throttling
			if ( $filterObj->isThrottled() ) {
				continue;
			}

			$filterAge = (int)wfTimestamp( TS_UNIX, $filterObj->getTimestamp() );
			$exemptTime = $filterAge + $maxAge;

			// Optimize for the common case when filters are well-established
			// This check somewhat duplicates the role of cache entry's TTL
			// and could as well be removed
			if ( $exemptTime <= $time ) {
				continue;
			}

			// TODO: this value might be stale, there is no guarantee the match
			// has actually been recorded now
			$cacheValue = $this->cache->getForFilter( $filter );
			if ( $cacheValue === false ) {
				continue;
			}

			[ 'total' => $totalActions, 'matches' => $matchCount ] = $cacheValue;

			if ( $matchCount > $hitCountLimit && ( $matchCount / $totalActions ) > $threshold ) {
				// More than AbuseFilterEmergencyDisableCount matches, constituting more than
				// AbuseFilterEmergencyDisableThreshold (a fraction) of last few edits.
				// Disable it.
				$throttleFilters[] = $filter;
			}
		}

		return $throttleFilters;
	}

	/**
	 * Determine which a filters must be throttled and apply the throttling
	 *
	 * @inheritDoc
	 */
	public function run( array $localFilters, array $globalFilters, string $group ): void {
		$throttleFilters = $this->getFiltersToThrottle( $localFilters, $group );
		if ( !$throttleFilters ) {
			return;
		}

		DeferredUpdates::addUpdate(
			new AutoCommitUpdate(
				$this->lbFactory->getPrimaryDatabase(),
				__METHOD__,
				static function ( IDatabase $dbw, $fname ) use ( $throttleFilters ) {
					$dbw->newUpdateQueryBuilder()
						->update( 'abuse_filter' )
						->set( [ 'af_throttled' => 1 ] )
						->where( [ 'af_id' => $throttleFilters ] )
						->caller( $fname )
						->execute();
				}
			)
		);
		DeferredUpdates::addCallableUpdate( function () use ( $throttleFilters ) {
			foreach ( $throttleFilters as $filter ) {
				$this->notifier->notifyForFilter( $filter );
			}
		} );
	}

	/**
	 * @param string $type The value to get, either "threshold", "count" or "age"
	 * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups)
	 * @return mixed
	 */
	private function getEmergencyValue( string $type, string $group ) {
		switch ( $type ) {
			case 'threshold':
				$opt = 'AbuseFilterEmergencyDisableThreshold';
				break;
			case 'count':
				$opt = 'AbuseFilterEmergencyDisableCount';
				break;
			case 'age':
				$opt = 'AbuseFilterEmergencyDisableAge';
				break;
			default:
				// @codeCoverageIgnoreStart
				throw new InvalidArgumentException( '$type must be either "threshold", "count" or "age"' );
				// @codeCoverageIgnoreEnd
		}

		$value = $this->options->get( $opt );
		return $value[$group] ?? $value['default'];
	}
}
PK       ! 0       Watcher/Watcher.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Watcher;

/**
 * Classes inheriting this interface can be used to execute some actions after all filter have been checked.
 */
interface Watcher {
	/**
	 * @param int[] $localFilters The local filters that matched the action
	 * @param int[] $globalFilters The global filters that matched the action
	 * @param string $group
	 */
	public function run( array $localFilters, array $globalFilters, string $group ): void;
}
PK       ! e    !  Watcher/UpdateHitCountWatcher.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Watcher;

use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Extension\AbuseFilter\CentralDBManager;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\LBFactory;

/**
 * Watcher that updates hit counts of filters
 */
class UpdateHitCountWatcher implements Watcher {
	public const SERVICE_NAME = 'AbuseFilterUpdateHitCountWatcher';

	/** @var LBFactory */
	private $lbFactory;

	/** @var CentralDBManager */
	private $centralDBManager;

	/**
	 * @param LBFactory $lbFactory
	 * @param CentralDBManager $centralDBManager
	 */
	public function __construct(
		LBFactory $lbFactory,
		CentralDBManager $centralDBManager
	) {
		$this->lbFactory = $lbFactory;
		$this->centralDBManager = $centralDBManager;
	}

	/**
	 * @inheritDoc
	 */
	public function run( array $localFilters, array $globalFilters, string $group ): void {
		// Run in a DeferredUpdate to avoid primary database queries on raw/view requests (T274455)
		DeferredUpdates::addCallableUpdate( function () use ( $localFilters, $globalFilters ) {
			if ( $localFilters ) {
				$this->updateHitCounts( $this->lbFactory->getPrimaryDatabase(), $localFilters );
			}

			if ( $globalFilters ) {
				$fdb = $this->centralDBManager->getConnection( DB_PRIMARY );
				$this->updateHitCounts( $fdb, $globalFilters );
			}
		} );
	}

	/**
	 * @param IDatabase $dbw
	 * @param array $loggedFilters
	 */
	private function updateHitCounts( IDatabase $dbw, array $loggedFilters ): void {
		$dbw->newUpdateQueryBuilder()
			->update( 'abuse_filter' )
			->set( [ 'af_hit_count=af_hit_count+1' ] )
			->where( [ 'af_id' => $loggedFilters ] )
			->caller( __METHOD__ )
			->execute();
	}
}
PK       ! 3R  R    AbuseLoggerFactory.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Title\Title;
use MediaWiki\User\ActorStore;
use MediaWiki\User\User;
use Psr\Log\LoggerInterface;
use Wikimedia\Rdbms\LBFactory;

class AbuseLoggerFactory {
	public const SERVICE_NAME = 'AbuseFilterAbuseLoggerFactory';

	/**
	 * The default amount of time after which a duplicate log entry can be inserted. 24 hours (in
	 * seconds).
	 *
	 * @var int
	 */
	private const DEFAULT_DEBOUNCE_DELAY = 24 * 60 * 60;

	/** @var CentralDBManager */
	private $centralDBManager;
	/** @var FilterLookup */
	private $filterLookup;
	/** @var VariablesBlobStore */
	private $varBlobStore;
	/** @var VariablesManager */
	private $varManager;
	/** @var EditRevUpdater */
	private $editRevUpdater;
	/** @var LBFactory */
	private $lbFactory;
	/** @var ActorStore */
	private $actorStore;
	/** @var ServiceOptions */
	private $options;
	/** @var string */
	private $wikiID;
	/** @var string */
	private $requestIP;
	/** @var LoggerInterface */
	private $logger;

	/**
	 * @param CentralDBManager $centralDBManager
	 * @param FilterLookup $filterLookup
	 * @param VariablesBlobStore $varBlobStore
	 * @param VariablesManager $varManager
	 * @param EditRevUpdater $editRevUpdater
	 * @param LBFactory $lbFactory
	 * @param ActorStore $actorStore
	 * @param ServiceOptions $options
	 * @param string $wikiID
	 * @param string $requestIP
	 * @param LoggerInterface $logger
	 */
	public function __construct(
		CentralDBManager $centralDBManager,
		FilterLookup $filterLookup,
		VariablesBlobStore $varBlobStore,
		VariablesManager $varManager,
		EditRevUpdater $editRevUpdater,
		LBFactory $lbFactory,
		ActorStore $actorStore,
		ServiceOptions $options,
		string $wikiID,
		string $requestIP,
		LoggerInterface $logger
	) {
		$this->centralDBManager = $centralDBManager;
		$this->filterLookup = $filterLookup;
		$this->varBlobStore = $varBlobStore;
		$this->varManager = $varManager;
		$this->editRevUpdater = $editRevUpdater;
		$this->lbFactory = $lbFactory;
		$this->actorStore = $actorStore;
		$this->options = $options;
		$this->wikiID = $wikiID;
		$this->requestIP = $requestIP;
		$this->logger = $logger;
	}

	/**
	 * @param int $delay
	 * @return ProtectedVarsAccessLogger
	 */
	public function getProtectedVarsAccessLogger(
		int $delay = self::DEFAULT_DEBOUNCE_DELAY
	) {
		return new ProtectedVarsAccessLogger(
			$this->logger,
			$this->lbFactory,
			$this->actorStore,
			$delay
		);
	}

	/**
	 * @param Title $title
	 * @param User $user
	 * @param VariableHolder $vars
	 * @return AbuseLogger
	 */
	public function newLogger(
		Title $title,
		User $user,
		VariableHolder $vars
	): AbuseLogger {
		return new AbuseLogger(
			$this->centralDBManager,
			$this->filterLookup,
			$this->varBlobStore,
			$this->varManager,
			$this->editRevUpdater,
			$this->lbFactory,
			$this->options,
			$this->wikiID,
			$this->requestIP,
			$title,
			$user,
			$vars
		);
	}
}
PK       ! .        FilterProfiler.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Title\Title;
use Psr\Log\LoggerInterface;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\Stats\IBufferingStatsdDataFactory;
use Wikimedia\WRStats\LocalEntityKey;
use Wikimedia\WRStats\WRStatsFactory;

/**
 * This class is used to create, store, and retrieve profiling information for single filters and
 * groups of filters.
 *
 * @internal
 */
class FilterProfiler {
	public const SERVICE_NAME = 'AbuseFilterFilterProfiler';

	public const CONSTRUCTOR_OPTIONS = [
		'AbuseFilterConditionLimit',
		'AbuseFilterSlowFilterRuntimeLimit',
	];

	/**
	 * How long to keep profiling data in cache (in seconds)
	 */
	private const STATS_STORAGE_PERIOD = BagOStuff::TTL_DAY;

	/** The stats time bucket size */
	private const STATS_TIME_STEP = self::STATS_STORAGE_PERIOD / 12;

	/** The WRStats spec common to all metrics */
	private const STATS_TEMPLATE = [
		'sequences' => [ [
			'timeStep' => self::STATS_TIME_STEP,
			'expiry' => self::STATS_STORAGE_PERIOD,
		] ],
	];

	private const KEY_PREFIX = 'abusefilter-profile';

	/** @var WRStatsFactory */
	private $statsFactory;

	/** @var ServiceOptions */
	private $options;

	/** @var string */
	private $localWikiID;

	/** @var IBufferingStatsdDataFactory */
	private $statsd;

	/** @var LoggerInterface */
	private $logger;

	/** @var array */
	private $statsSpecs;

	/**
	 * @param WRStatsFactory $statsFactory
	 * @param ServiceOptions $options
	 * @param string $localWikiID
	 * @param IBufferingStatsdDataFactory $statsd
	 * @param LoggerInterface $logger
	 */
	public function __construct(
		WRStatsFactory $statsFactory,
		ServiceOptions $options,
		string $localWikiID,
		IBufferingStatsdDataFactory $statsd,
		LoggerInterface $logger
	) {
		$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
		$this->statsFactory = $statsFactory;
		$this->options = $options;
		$this->localWikiID = $localWikiID;
		$this->statsd = $statsd;
		$this->logger = $logger;
		$this->statsSpecs = [
			'count' => self::STATS_TEMPLATE,
			'total' => self::STATS_TEMPLATE,
			'overflow' => self::STATS_TEMPLATE,
			'matches' => self::STATS_TEMPLATE,
			'total-time' => [ 'resolution' => 1e-3 ] + self::STATS_TEMPLATE,
			'total-cond' => self::STATS_TEMPLATE
		];
	}

	/**
	 * @param int $filter
	 */
	public function resetFilterProfile( int $filter ): void {
		$writer = $this->statsFactory->createWriter(
			$this->statsSpecs,
			self::KEY_PREFIX
		);
		$writer->resetAll( [ $this->filterProfileKey( $filter ) ] );
	}

	/**
	 * Retrieve per-filter statistics.
	 *
	 * @param int $filter
	 * @return array See self::NULL_FILTER_PROFILE for the returned array structure
	 * @phan-return array{count:int,matches:int,total-time:float,total-cond:int}
	 */
	public function getFilterProfile( int $filter ): array {
		$reader = $this->statsFactory->createReader(
			$this->statsSpecs,
			self::KEY_PREFIX
		);
		return $reader->total( $reader->getRates(
			[ 'count', 'matches', 'total-time', 'total-cond' ],
			$this->filterProfileKey( $filter ),
			$reader->latest( self::STATS_STORAGE_PERIOD )
		) );
	}

	/**
	 * Retrieve per-group statistics.
	 *
	 * @param string $group
	 * @return array See self::NULL_GROUP_PROFILE for the returned array structure
	 * @phan-return array{total:int,overflow:int,total-time:float,total-cond:int,matches:int}
	 */
	public function getGroupProfile( string $group ): array {
		$reader = $this->statsFactory->createReader(
			$this->statsSpecs,
			self::KEY_PREFIX
		);
		return $reader->total( $reader->getRates(
			[ 'total', 'overflow', 'total-time', 'total-cond', 'matches' ],
			$this->filterProfileGroupKey( $group ),
			$reader->latest( self::STATS_STORAGE_PERIOD )
		) );
	}

	/**
	 * Record per-filter profiling data
	 *
	 * @param int $filter
	 * @param float $time Time taken, in milliseconds
	 * @param int $conds
	 * @param bool $matched
	 */
	private function recordProfilingResult( int $filter, float $time, int $conds, bool $matched ): void {
		$key = $this->filterProfileKey( $filter );
		$writer = $this->statsFactory->createWriter(
			$this->statsSpecs,
			self::KEY_PREFIX
		);
		$writer->incr( 'count', $key );
		if ( $matched ) {
			$writer->incr( 'matches', $key );
		}
		$writer->incr( 'total-time', $key, $time );
		$writer->incr( 'total-cond', $key, $conds );
		$writer->flush();
	}

	/**
	 * Update global statistics
	 *
	 * @param string $group
	 * @param int $condsUsed The amount of used conditions
	 * @param float $totalTime Time taken, in milliseconds
	 * @param bool $anyMatch Whether at least one filter matched the action
	 */
	public function recordStats( string $group, int $condsUsed, float $totalTime, bool $anyMatch ): void {
		$writer = $this->statsFactory->createWriter(
			$this->statsSpecs,
			self::KEY_PREFIX
		);
		$key = $this->filterProfileGroupKey( $group );

		$writer->incr( 'total', $key );
		$writer->incr( 'total-time', $key, $totalTime );
		$writer->incr( 'total-cond', $key, $condsUsed );

		// Increment overflow counter, if our condition limit overflowed
		if ( $condsUsed > $this->options->get( 'AbuseFilterConditionLimit' ) ) {
			$writer->incr( 'overflow', $key );
		}

		// Increment counter by 1 if there was at least one match
		if ( $anyMatch ) {
			$writer->incr( 'matches', $key );
		}
		$writer->flush();
	}

	/**
	 * Record runtime profiling data for all filters together
	 *
	 * @param int $totalFilters
	 * @param int $totalConditions
	 * @param float $runtime
	 * @codeCoverageIgnore
	 */
	public function recordRuntimeProfilingResult( int $totalFilters, int $totalConditions, float $runtime ): void {
		$keyPrefix = 'abusefilter.runtime-profile.' . $this->localWikiID . '.';

		$this->statsd->timing( $keyPrefix . 'runtime', $runtime );
		$this->statsd->timing( $keyPrefix . 'total_filters', $totalFilters );
		$this->statsd->timing( $keyPrefix . 'total_conditions', $totalConditions );
	}

	/**
	 * Record per-filter profiling, for all filters
	 *
	 * @param Title $title
	 * @param array $data Profiling data
	 * @phan-param array<string,array{time:float,conds:int,result:bool}> $data
	 */
	public function recordPerFilterProfiling( Title $title, array $data ): void {
		$slowFilterThreshold = $this->options->get( 'AbuseFilterSlowFilterRuntimeLimit' );

		foreach ( $data as $filterName => $params ) {
			[ $filterID, $global ] = GlobalNameUtils::splitGlobalName( $filterName );
			// @todo Maybe add a parameter to recordProfilingResult to record global filters
			// data separately (in the foreign wiki)
			if ( !$global ) {
				$this->recordProfilingResult(
					$filterID,
					$params['time'],
					$params['conds'],
					$params['result']
				);
			}

			if ( $params['time'] > $slowFilterThreshold ) {
				$this->recordSlowFilter(
					$title,
					$filterName,
					$params['time'],
					$params['conds'],
					$params['result'],
					$global
				);
			}
		}
	}

	/**
	 * Logs slow filter's runtime data for later analysis
	 *
	 * @param Title $title
	 * @param string $filterId
	 * @param float $runtime
	 * @param int $totalConditions
	 * @param bool $matched
	 * @param bool $global
	 */
	private function recordSlowFilter(
		Title $title,
		string $filterId,
		float $runtime,
		int $totalConditions,
		bool $matched,
		bool $global
	): void {
		$this->logger->info(
			'Edit filter {filter_id} on {wiki} is taking longer than expected',
			[
				'wiki' => $this->localWikiID,
				'filter_id' => $filterId,
				'title' => $title->getPrefixedText(),
				'runtime' => $runtime,
				'matched' => $matched,
				'total_conditions' => $totalConditions,
				'global' => $global
			]
		);
	}

	/**
	 * Get the WRStats entity key used to store per-filter profiling data.
	 *
	 * @param int $filter
	 * @return LocalEntityKey
	 */
	private function filterProfileKey( int $filter ): LocalEntityKey {
		return new LocalEntityKey( [ 'filter', (string)$filter ] );
	}

	/**
	 * WRStats entity key used to store overall profiling data for rule groups
	 *
	 * @param string $group
	 * @return LocalEntityKey
	 */
	private function filterProfileGroupKey( string $group ): LocalEntityKey {
		return new LocalEntityKey( [ 'group', $group ] );
	}
}
PK       !       FilterCompare.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
use MediaWiki\Extension\AbuseFilter\Filter\Filter;

/**
 * This service allows comparing two versions of a filter.
 * @todo We might want to expand this to cover the use case of ViewDiff
 * @internal
 */
class FilterCompare {
	public const SERVICE_NAME = 'AbuseFilterFilterCompare';

	/** @var ConsequencesRegistry */
	private $consequencesRegistry;

	/**
	 * @param ConsequencesRegistry $consequencesRegistry
	 */
	public function __construct( ConsequencesRegistry $consequencesRegistry ) {
		$this->consequencesRegistry = $consequencesRegistry;
	}

	/**
	 * @param Filter $firstFilter
	 * @param Filter $secondFilter
	 * @return array Fields that are different
	 */
	public function compareVersions( Filter $firstFilter, Filter $secondFilter ): array {
		// TODO: Avoid DB references here, re-add when saving the filter
		$methods = [
			'af_public_comments' => 'getName',
			'af_pattern' => 'getRules',
			'af_comments' => 'getComments',
			'af_deleted' => 'isDeleted',
			'af_enabled' => 'isEnabled',
			'af_hidden' => 'getPrivacyLevel',
			'af_global' => 'isGlobal',
			'af_group' => 'getGroup',
		];

		$differences = [];

		foreach ( $methods as $field => $method ) {
			if ( $firstFilter->$method() !== $secondFilter->$method() ) {
				$differences[] = $field;
			}
		}

		$firstActions = $firstFilter->getActions();
		$secondActions = $secondFilter->getActions();
		foreach ( $this->consequencesRegistry->getAllEnabledActionNames() as $action ) {
			if ( !isset( $firstActions[$action] ) && !isset( $secondActions[$action] ) ) {
				// They're both unset
			} elseif ( isset( $firstActions[$action] ) && isset( $secondActions[$action] ) ) {
				// They're both set. Double check needed, e.g. per T180194
				if ( array_diff( $firstActions[$action], $secondActions[$action] ) ||
					array_diff( $secondActions[$action], $firstActions[$action] ) ) {
					// Different parameters
					$differences[] = 'actions';
				}
			} else {
				// One's unset, one's set.
				$differences[] = 'actions';
			}
		}

		return array_unique( $differences );
	}
}
PK       ! x
  
    TextExtractor.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Content\Content;
use MediaWiki\Content\TextContent;
use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\RevisionRecord;

/**
 * This service provides an interface to convert RevisionRecord and Content objects to some text
 * suitable for running abuse filters.
 *
 * @internal No external code should rely on this representation
 */
class TextExtractor {
	public const SERVICE_NAME = 'AbuseFilterTextExtractor';

	/** @var AbuseFilterHookRunner */
	private $hookRunner;

	/**
	 * @param AbuseFilterHookRunner $hookRunner
	 */
	public function __construct( AbuseFilterHookRunner $hookRunner ) {
		$this->hookRunner = $hookRunner;
	}

	/**
	 * Look up some text of a revision from its revision id
	 *
	 * Note that this is really *some* text, we do not make *any* guarantee
	 * that this text will be even close to what the user actually sees, or
	 * that the form is fit for any intended purpose.
	 *
	 * Note also that if the revision for any reason is not an Revision
	 * the function returns with an empty string.
	 *
	 * For now, this returns all the revision's slots, concatenated together.
	 * In future, this will be replaced by a better solution. See T208769 for
	 * discussion.
	 *
	 * @param RevisionRecord|null $revision a valid revision
	 * @param Authority $performer to check for privileged access
	 * @return string the content of the revision as some kind of string,
	 *        or an empty string if it can not be found
	 * @return-taint none
	 */
	public function revisionToString( ?RevisionRecord $revision, Authority $performer ): string {
		if ( !$revision ) {
			return '';
		}

		$strings = [];

		foreach ( $revision->getSlotRoles() as $role ) {
			$content = $revision->getContent( $role, RevisionRecord::FOR_THIS_USER, $performer );
			if ( $content === null ) {
				continue;
			}
			$strings[$role] = $this->contentToString( $content );
		}

		return implode( "\n\n", $strings );
	}

	/**
	 * Converts the given Content object to a string.
	 *
	 * This uses TextContent::getText() if $content is an instance of TextContent,
	 * or Content::getTextForSearchIndex() otherwise.
	 *
	 * The hook AbuseFilterContentToString can be used to override this
	 * behavior.
	 *
	 * @param Content $content
	 *
	 * @return string a suitable string representation of the content.
	 */
	public function contentToString( Content $content ): string {
		$text = null;

		if ( $this->hookRunner->onAbuseFilter_contentToString(
			$content,
			$text
		) ) {
			$text = $content instanceof TextContent
				? $content->getText()
				: $content->getTextForSearchIndex();
		}

		// T22310
		return TextContent::normalizeLineEndings( (string)$text );
	}
}
PK       ! f$      FilterUser.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Permissions\Authority;
use MediaWiki\User\User;
use MediaWiki\User\UserGroupManager;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserNameUtils;
use MessageLocalizer;
use Psr\Log\LoggerInterface;

class FilterUser {
	public const SERVICE_NAME = 'AbuseFilterFilterUser';

	private MessageLocalizer $messageLocalizer;
	private UserGroupManager $userGroupManager;
	private UserNameUtils $userNameUtils;
	private LoggerInterface $logger;

	/**
	 * @param MessageLocalizer $messageLocalizer
	 * @param UserGroupManager $userGroupManager
	 * @param UserNameUtils $userNameUtils
	 * @param LoggerInterface $logger
	 */
	public function __construct(
		MessageLocalizer $messageLocalizer,
		UserGroupManager $userGroupManager,
		UserNameUtils $userNameUtils,
		LoggerInterface $logger
	) {
		$this->messageLocalizer = $messageLocalizer;
		$this->userGroupManager = $userGroupManager;
		$this->userNameUtils = $userNameUtils;
		$this->logger = $logger;
	}

	/**
	 * @return Authority
	 */
	public function getAuthority(): Authority {
		return $this->getUser();
	}

	/**
	 * @return UserIdentity
	 */
	public function getUserIdentity(): UserIdentity {
		return $this->getUser();
	}

	/**
	 * Compares the given $user to see if they are the same as the FilterUser.
	 *
	 * @return bool
	 */
	public function isSameUserAs( UserIdentity $user ): bool {
		// Checking the usernames are equal is enough, as this is what is done by
		// User::equals and UserIdentityValue::equals.
		return $user->getName() === $this->getFilterUserName();
	}

	/**
	 * @todo Stop using the User class when User::newSystemUser is refactored.
	 * @return User
	 */
	private function getUser(): User {
		$user = User::newSystemUser( $this->getFilterUserName(), [ 'steal' => true ] );
		'@phan-var User $user';

		// Promote user to 'sysop' so it doesn't look
		// like an unprivileged account is blocking users
		if ( !in_array( 'sysop', $this->userGroupManager->getUserGroups( $user ) ) ) {
			$this->userGroupManager->addUserToGroup( $user, 'sysop' );
		}

		return $user;
	}

	/**
	 * Gets the username for the FilterUser.
	 *
	 * @return string
	 */
	private function getFilterUserName(): string {
		$username = $this->messageLocalizer->msg( 'abusefilter-blocker' )->inContentLanguage()->text();
		if ( !$this->userNameUtils->getCanonical( $username ) ) {
			// User name is invalid. Don't throw because this is a system message, easy
			// to change and make wrong either by mistake or intentionally to break the site.
			$this->logger->warning(
				'The AbuseFilter user\'s name is invalid. Please change it in ' .
				'MediaWiki:abusefilter-blocker'
			);
			// Use the default name to avoid breaking other stuff. This should have no harm,
			// aside from blocks temporarily attributed to another user.
			// Don't use the database in case the English onwiki message is broken, T284364
			$username = $this->messageLocalizer->msg( 'abusefilter-blocker' )
				->inLanguage( 'en' )
				->useDatabase( false )
				->text();
		}
		return $username;
	}
}
PK       ! >7      EmergencyCache.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use Wikimedia\ObjectCache\BagOStuff;

/**
 * Helper class for EmergencyWatcher. Wrapper around cache which tracks hits of recently
 * modified filters.
 */
class EmergencyCache {

	public const SERVICE_NAME = 'AbuseFilterEmergencyCache';

	/** @var BagOStuff */
	private $stash;

	/** @var int[] */
	private $ttlPerGroup;

	/**
	 * @param BagOStuff $stash
	 * @param int[] $ttlPerGroup
	 */
	public function __construct( BagOStuff $stash, array $ttlPerGroup ) {
		$this->stash = $stash;
		$this->ttlPerGroup = $ttlPerGroup;
	}

	/**
	 * Get recently modified filters in the group. Thanks to this, performance can be improved,
	 * because only a small subset of filters will need an update.
	 *
	 * @param string $group
	 * @return int[]
	 */
	public function getFiltersToCheckInGroup( string $group ): array {
		$filterToExpiry = $this->stash->get( $this->createGroupKey( $group ) );
		if ( $filterToExpiry === false ) {
			return [];
		}
		$time = (int)round( $this->stash->getCurrentTime() );
		return array_keys( array_filter(
			$filterToExpiry,
			static function ( $exp ) use ( $time ) {
				return $exp > $time;
			}
		) );
	}

	/**
	 * Create a new entry in cache for a filter and update the entry for the group.
	 * This method is usually called after the filter has been updated.
	 *
	 * @param int $filter
	 * @param string $group
	 * @return bool
	 */
	public function setNewForFilter( int $filter, string $group ): bool {
		$ttl = $this->ttlPerGroup[$group] ?? $this->ttlPerGroup['default'];
		$expiry = (int)round( $this->stash->getCurrentTime() + $ttl );
		$this->stash->merge(
			$this->createGroupKey( $group ),
			static function ( $cache, $key, $value ) use ( $filter, $expiry ) {
				if ( $value === false ) {
					$value = [];
				}
				// note that some filters may have already had their keys expired
				// we are currently filtering them out in getFiltersToCheckInGroup
				// but if necessary, it can be done here
				$value[$filter] = $expiry;
				return $value;
			},
			$expiry
		);
		return $this->stash->set(
			$this->createFilterKey( $filter ),
			[ 'total' => 0, 'matches' => 0, 'expiry' => $expiry ],
			$expiry
		);
	}

	/**
	 * Increase the filter's 'total' value by one and possibly also the 'matched' value.
	 *
	 * @param int $filter
	 * @param bool $matched Whether the filter matched the action
	 * @return bool
	 */
	public function incrementForFilter( int $filter, bool $matched ): bool {
		return $this->stash->merge(
			$this->createFilterKey( $filter ),
			static function ( $cache, $key, $value, &$expiry ) use ( $matched ) {
				if ( $value === false ) {
					return false;
				}
				$value['total']++;
				if ( $matched ) {
					$value['matches']++;
				}
				// enforce the prior TTL
				$expiry = $value['expiry'];
				return $value;
			}
		);
	}

	/**
	 * Get the cache entry for the filter. Returns false when the key has already expired.
	 * Otherwise it returns the entry formatted as [ 'total' => number of actions,
	 * 'matches' => number of hits ] (since the last filter modification).
	 *
	 * @param int $filter
	 * @return array|false
	 */
	public function getForFilter( int $filter ) {
		$value = $this->stash->get( $this->createFilterKey( $filter ) );
		if ( $value !== false ) {
			unset( $value['expiry'] );
		}
		return $value;
	}

	/**
	 * @param string $group
	 * @return string
	 */
	private function createGroupKey( string $group ): string {
		return $this->stash->makeKey( 'abusefilter', 'emergency', 'group', $group );
	}

	/**
	 * @param int $filter
	 * @return string
	 */
	private function createFilterKey( int $filter ): string {
		return $this->stash->makeKey( 'abusefilter', 'emergency', 'filter', $filter );
	}

}
PK       ! f    !  TableDiffFormatterFullContext.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use Wikimedia\Diff\Diff;
use Wikimedia\Diff\TableDiffFormatter;

/**
 * Like TableDiffFormatter, but will always render the full context (even for empty diffs).
 *
 * @todo Consider moving to MW core (as a separate class, or as an option to TableDiffFormatter)
 *
 * @internal
 */
class TableDiffFormatterFullContext extends TableDiffFormatter {
	/**
	 * Format a diff.
	 *
	 * @param Diff $diff
	 * @return string The formatted output.
	 */
	public function format( $diff ) {
		$xlen = $ylen = 0;

		// Calculate the length of the left and the right side
		foreach ( $diff->edits as $edit ) {
			if ( $edit->orig ) {
				$xlen += count( $edit->orig );
			}
			if ( $edit->closing ) {
				$ylen += count( $edit->closing );
			}
		}

		// Just render the diff with no preprocessing
		$this->startDiff();
		$this->block( 1, $xlen, 1, $ylen, $diff->edits );
		return $this->endDiff();
	}
}
PK       ! #8[  [    EditStashCache.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use InvalidArgumentException;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Linker\LinkTarget;
use Psr\Log\LoggerInterface;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\Stats\IBufferingStatsdDataFactory;

/**
 * Wrapper around cache for storing and retrieving data from edit stash
 */
class EditStashCache {

	private const CACHE_VERSION = 'v5';

	/** @var BagOStuff */
	private $cache;

	/** @var IBufferingStatsdDataFactory */
	private $statsdDataFactory;

	/** @var VariablesManager */
	private $variablesManager;

	/** @var LoggerInterface */
	private $logger;

	/** @var LinkTarget */
	private $target;

	/** @var string */
	private $group;

	/**
	 * @param BagOStuff $cache
	 * @param IBufferingStatsdDataFactory $statsdDataFactory
	 * @param VariablesManager $variablesManager
	 * @param LoggerInterface $logger
	 * @param LinkTarget $target
	 * @param string $group
	 */
	public function __construct(
		BagOStuff $cache,
		IBufferingStatsdDataFactory $statsdDataFactory,
		VariablesManager $variablesManager,
		LoggerInterface $logger,
		LinkTarget $target,
		string $group
	) {
		$this->cache = $cache;
		$this->statsdDataFactory = $statsdDataFactory;
		$this->variablesManager = $variablesManager;
		$this->logger = $logger;
		$this->target = $target;
		$this->group = $group;
	}

	/**
	 * @param VariableHolder $vars For creating the key
	 * @param array $data Data to store
	 */
	public function store( VariableHolder $vars, array $data ): void {
		$key = $this->getStashKey( $vars );
		$this->cache->set( $key, $data, BagOStuff::TTL_MINUTE );
		$this->logCache( 'store', $key );
	}

	/**
	 * Search the cache to find data for a previous execution done for the current edit.
	 *
	 * @param VariableHolder $vars For creating the key
	 * @return false|array False on cache miss, the array with data otherwise
	 */
	public function seek( VariableHolder $vars ) {
		$key = $this->getStashKey( $vars );
		$value = $this->cache->get( $key );
		$status = $value !== false ? 'hit' : 'miss';
		$this->logCache( $status, $key );
		return $value;
	}

	/**
	 * Log cache operations related to stashed edits, i.e. store, hit and miss
	 *
	 * @param string $type Either 'store', 'hit' or 'miss'
	 * @param string $key The cache key used
	 * @throws InvalidArgumentException
	 */
	private function logCache( string $type, string $key ): void {
		if ( !in_array( $type, [ 'store', 'hit', 'miss' ] ) ) {
			// @codeCoverageIgnoreStart
			throw new InvalidArgumentException( '$type must be either "store", "hit" or "miss"' );
			// @codeCoverageIgnoreEnd
		}
		$this->logger->debug(
			__METHOD__ . ": cache {logtype} for '{target}' (key {key}).",
			[ 'logtype' => $type, 'target' => $this->target, 'key' => $key ]
		);
		$this->statsdDataFactory->increment( "abusefilter.check-stash.$type" );
	}

	/**
	 * Get the stash key for the current variables
	 *
	 * @param VariableHolder $vars
	 * @return string
	 */
	private function getStashKey( VariableHolder $vars ): string {
		$inputVars = $this->variablesManager->exportNonLazyVars( $vars );
		// Exclude noisy fields that have superficial changes
		$excludedVars = [
			'old_html' => true,
			'new_html' => true,
			'user_age' => true,
			'timestamp' => true,
			'page_age' => true,
			'page_last_edit_age' => true,
		];

		$inputVars = array_diff_key( $inputVars, $excludedVars );
		ksort( $inputVars );
		$hash = md5( serialize( $inputVars ) );

		return $this->cache->makeKey(
			'abusefilter',
			'check-stash',
			$this->group,
			$hash,
			self::CACHE_VERSION
		);
	}

}
PK       ! p4  4  %  Consequences/ConsequencesExecutor.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences;

use MediaWiki\Block\BlockUser;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Extension\AbuseFilter\ActionSpecifier;
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\Block;
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\Consequence;
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\ConsequencesDisablerConsequence;
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\HookAborterConsequence;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Message\Message;
use MediaWiki\Status\Status;
use MediaWiki\User\UserIdentityUtils;
use Psr\Log\LoggerInterface;

class ConsequencesExecutor {
	public const CONSTRUCTOR_OPTIONS = [
		'AbuseFilterLocallyDisabledGlobalActions',
		'AbuseFilterBlockDuration',
		'AbuseFilterAnonBlockDuration',
		'AbuseFilterBlockAutopromoteDuration',
	];

	/** @var ConsequencesLookup */
	private $consLookup;
	/** @var ConsequencesFactory */
	private $consFactory;
	/** @var ConsequencesRegistry */
	private $consRegistry;
	/** @var FilterLookup */
	private $filterLookup;
	/** @var LoggerInterface */
	private $logger;
	/** @var UserIdentityUtils */
	private $userIdentityUtils;
	/** @var ServiceOptions */
	private $options;
	/** @var ActionSpecifier */
	private $specifier;
	/** @var VariableHolder */
	private $vars;

	/**
	 * @param ConsequencesLookup $consLookup
	 * @param ConsequencesFactory $consFactory
	 * @param ConsequencesRegistry $consRegistry
	 * @param FilterLookup $filterLookup
	 * @param LoggerInterface $logger
	 * @param UserIdentityUtils $userIdentityUtils
	 * @param ServiceOptions $options
	 * @param ActionSpecifier $specifier
	 * @param VariableHolder $vars
	 */
	public function __construct(
		ConsequencesLookup $consLookup,
		ConsequencesFactory $consFactory,
		ConsequencesRegistry $consRegistry,
		FilterLookup $filterLookup,
		LoggerInterface $logger,
		UserIdentityUtils $userIdentityUtils,
		ServiceOptions $options,
		ActionSpecifier $specifier,
		VariableHolder $vars
	) {
		$this->consLookup = $consLookup;
		$this->consFactory = $consFactory;
		$this->consRegistry = $consRegistry;
		$this->filterLookup = $filterLookup;
		$this->logger = $logger;
		$this->userIdentityUtils = $userIdentityUtils;
		$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
		$this->options = $options;
		$this->specifier = $specifier;
		$this->vars = $vars;
	}

	/**
	 * Executes a set of actions.
	 *
	 * @param string[] $filters
	 * @return Status returns the operation's status. $status->isOK() will return true if
	 *         there were no actions taken, false otherwise. $status->getValue() will return
	 *         an array listing the actions taken. $status->getMessages() will provide
	 *         the errors and warnings to be shown to the user to explain the actions.
	 */
	public function executeFilterActions( array $filters ): Status {
		$actionsToTake = $this->getActualConsequencesToExecute( $filters );
		$actionsTaken = array_fill_keys( $filters, [] );

		$messages = [];
		foreach ( $actionsToTake as $filter => $actions ) {
			foreach ( $actions as $action => $info ) {
				[ $executed, $newMsg ] = $this->takeConsequenceAction( $info );

				if ( $newMsg !== null ) {
					$messages[] = $newMsg;
				}
				if ( $executed ) {
					$actionsTaken[$filter][] = $action;
				}
			}
		}

		return $this->buildStatus( $actionsTaken, $messages );
	}

	/**
	 * @param string[] $filters
	 * @return Consequence[][]
	 * @internal
	 */
	public function getActualConsequencesToExecute( array $filters ): array {
		$rawConsParamsByFilter = $this->consLookup->getConsequencesForFilters( $filters );
		$consParamsByFilter = $this->replaceLegacyParameters( $rawConsParamsByFilter );
		$specializedConsParams = $this->specializeParameters( $consParamsByFilter );
		$allowedConsParams = $this->removeForbiddenConsequences( $specializedConsParams );

		$consequences = $this->replaceArraysWithConsequences( $allowedConsParams );
		$actualConsequences = $this->applyConsequenceDisablers( $consequences );
		$deduplicatedConsequences = $this->deduplicateConsequences( $actualConsequences );
		return $this->removeRedundantConsequences( $deduplicatedConsequences );
	}

	/**
	 * Update parameters for all consequences, making sure that they match the currently expected format
	 * (e.g., 'block' didn't use to have expiries).
	 *
	 * @param array[] $consParams
	 * @return array[]
	 */
	private function replaceLegacyParameters( array $consParams ): array {
		$registeredBlockDuration = $this->options->get( 'AbuseFilterBlockDuration' );
		$anonBlockDuration = $this->options->get( 'AbuseFilterAnonBlockDuration' ) ?? $registeredBlockDuration;
		foreach ( $consParams as $filter => $actions ) {
			foreach ( $actions as $name => $parameters ) {
				if ( $name === 'block' && count( $parameters ) !== 3 ) {
					// Old type with fixed expiry
					$blockTalk = in_array( 'blocktalk', $parameters, true );

					$consParams[$filter][$name] = [
						$blockTalk ? 'blocktalk' : 'noTalkBlockSet',
						$anonBlockDuration,
						$registeredBlockDuration
					];
				}
			}
		}

		return $consParams;
	}

	/**
	 * For every consequence, keep only the parameters that are relevant for this specific action being filtered.
	 * For instance, choose between anon expiry and registered expiry for blocks.
	 *
	 * @param array[] $consParams
	 * @return array[]
	 */
	private function specializeParameters( array $consParams ): array {
		$user = $this->specifier->getUser();
		$isNamed = $this->userIdentityUtils->isNamed( $user );
		foreach ( $consParams as $filter => $actions ) {
			foreach ( $actions as $name => $parameters ) {
				if ( $name === 'block' ) {
					$consParams[$filter][$name] = [
						'expiry' => $isNamed ? $parameters[2] : $parameters[1],
						'blocktalk' => $parameters[0] === 'blocktalk'
					];
				}
			}
		}

		return $consParams;
	}

	/**
	 * Removes any consequence that cannot be executed. For instance, remove locally disabled
	 * consequences for global filters.
	 *
	 * @param array[] $consParams
	 * @return array[]
	 */
	private function removeForbiddenConsequences( array $consParams ): array {
		$locallyDisabledActions = $this->options->get( 'AbuseFilterLocallyDisabledGlobalActions' );
		foreach ( $consParams as $filter => $actions ) {
			$isGlobalFilter = GlobalNameUtils::splitGlobalName( $filter )[1];
			if ( $isGlobalFilter ) {
				$consParams[$filter] = array_diff_key(
					$actions,
					array_filter( $locallyDisabledActions )
				);
			}
		}

		return $consParams;
	}

	/**
	 * Converts all consequence specifiers to Consequence objects.
	 *
	 * @param array[] $actionsByFilter
	 * @return Consequence[][]
	 */
	private function replaceArraysWithConsequences( array $actionsByFilter ): array {
		$ret = [];
		foreach ( $actionsByFilter as $filter => $actions ) {
			$ret[$filter] = [];
			foreach ( $actions as $name => $parameters ) {
				$cons = $this->actionsParamsToConsequence( $name, $parameters, $filter );
				if ( $cons !== null ) {
					$ret[$filter][$name] = $cons;
				}
			}
		}

		return $ret;
	}

	/**
	 * Pre-check any consequences-disabler consequence and remove any further actions prevented by them. Specifically:
	 * - For every filter with "throttle" enabled, remove other actions if the throttle counter hasn't been reached
	 * - For every filter with "warn" enabled, remove other actions if the warning hasn't been shown
	 *
	 * @param Consequence[][] $consequencesByFilter
	 * @return Consequence[][]
	 */
	private function applyConsequenceDisablers( array $consequencesByFilter ): array {
		foreach ( $consequencesByFilter as $filter => $actions ) {
			/** @var ConsequencesDisablerConsequence[] $consequenceDisablers */
			$consequenceDisablers = array_filter( $actions, static function ( $el ) {
				return $el instanceof ConsequencesDisablerConsequence;
			} );
			'@phan-var ConsequencesDisablerConsequence[] $consequenceDisablers';
			uasort(
				$consequenceDisablers,
				static function ( ConsequencesDisablerConsequence $x, ConsequencesDisablerConsequence $y ) {
					return $x->getSort() - $y->getSort();
				}
			);
			foreach ( $consequenceDisablers as $name => $consequence ) {
				if ( $consequence->shouldDisableOtherConsequences() ) {
					$consequencesByFilter[$filter] = [ $name => $consequence ];
					continue 2;
				}
			}
		}

		return $consequencesByFilter;
	}

	/**
	 * Removes duplicated consequences. For instance, this only keeps the longest of all blocks.
	 *
	 * @param Consequence[][] $consByFilter
	 * @return Consequence[][]
	 */
	private function deduplicateConsequences( array $consByFilter ): array {
		// Keep track of the longest block
		$maxBlock = [ 'id' => null, 'expiry' => -1, 'cons' => null ];

		foreach ( $consByFilter as $filter => $actions ) {
			foreach ( $actions as $name => $cons ) {
				if ( $name === 'block' ) {
					/** @var Block $cons */
					'@phan-var Block $cons';
					$expiry = $cons->getExpiry();
					$parsedExpiry = BlockUser::parseExpiryInput( $expiry );
					if (
						$maxBlock['expiry'] === -1 ||
						$parsedExpiry > BlockUser::parseExpiryInput( $maxBlock['expiry'] )
					) {
						$maxBlock = [
							'id' => $filter,
							'expiry' => $expiry,
							'cons' => $cons
						];
					}
					// We'll re-add it later
					unset( $consByFilter[$filter]['block'] );
				}
			}
		}

		if ( $maxBlock['id'] !== null ) {
			$consByFilter[$maxBlock['id']]['block'] = $maxBlock['cons'];
		}

		return $consByFilter;
	}

	/**
	 * Remove redundant consequences, e.g., remove "disallow" if a dangerous action will be executed
	 * TODO: Is this wanted, especially now that we have custom disallow messages?
	 *
	 * @param Consequence[][] $consByFilter
	 * @return Consequence[][]
	 */
	private function removeRedundantConsequences( array $consByFilter ): array {
		$dangerousActions = $this->consRegistry->getDangerousActionNames();

		foreach ( $consByFilter as $filter => $actions ) {
			// Don't show the disallow message if a blocking action is executed
			if (
				isset( $actions['disallow'] ) &&
				array_intersect( array_keys( $actions ), $dangerousActions )
			) {
				unset( $consByFilter[$filter]['disallow'] );
			}
		}

		return $consByFilter;
	}

	/**
	 * @param string $actionName
	 * @param array $rawParams
	 * @param int|string $filter
	 * @return Consequence|null
	 */
	private function actionsParamsToConsequence( string $actionName, array $rawParams, $filter ): ?Consequence {
		[ $filterID, $isGlobalFilter ] = GlobalNameUtils::splitGlobalName( $filter );
		$filterObj = $this->filterLookup->getFilter( $filterID, $isGlobalFilter );

		$baseConsParams = new Parameters(
			$filterObj,
			$isGlobalFilter,
			$this->specifier
		);

		switch ( $actionName ) {
			case 'throttle':
				$throttleId = array_shift( $rawParams );
				[ $rateCount, $ratePeriod ] = explode( ',', array_shift( $rawParams ) );

				$throttleParams = [
					'id' => $throttleId,
					'count' => (int)$rateCount,
					'period' => (int)$ratePeriod,
					'groups' => $rawParams,
					'global' => $isGlobalFilter
				];
				return $this->consFactory->newThrottle( $baseConsParams, $throttleParams );
			case 'warn':
				return $this->consFactory->newWarn( $baseConsParams, $rawParams[0] ?? 'abusefilter-warning' );
			case 'disallow':
				return $this->consFactory->newDisallow( $baseConsParams, $rawParams[0] ?? 'abusefilter-disallowed' );
			case 'rangeblock':
				return $this->consFactory->newRangeBlock( $baseConsParams, '1 week' );
			case 'degroup':
				return $this->consFactory->newDegroup( $baseConsParams, $this->vars );
			case 'blockautopromote':
				$duration = $this->options->get( 'AbuseFilterBlockAutopromoteDuration' ) * 86400;
				return $this->consFactory->newBlockAutopromote( $baseConsParams, $duration );
			case 'block':
				return $this->consFactory->newBlock(
					$baseConsParams,
					$rawParams['expiry'],
					$rawParams['blocktalk']
				);
			case 'tag':
				return $this->consFactory->newTag( $baseConsParams, $rawParams );
			default:
				if ( array_key_exists( $actionName, $this->consRegistry->getCustomActions() ) ) {
					$callback = $this->consRegistry->getCustomActions()[$actionName];
					return $callback( $baseConsParams, $rawParams );
				} else {
					$this->logger->warning( "Unrecognised action $actionName" );
					return null;
				}
		}
	}

	/**
	 * @param Consequence $consequence
	 * @return array [ executed (bool), message (?Message) ]
	 * @phan-return array{0:bool, 1:?Message}
	 */
	private function takeConsequenceAction( Consequence $consequence ): array {
		$res = $consequence->execute();
		if ( $res && $consequence instanceof HookAborterConsequence ) {
			$message = Message::newFromSpecifier( $consequence->getMessage() );
		}

		return [ $res, $message ?? null ];
	}

	/**
	 * Constructs a Status object as returned by executeFilterActions() from the list of
	 * actions taken and the corresponding list of messages.
	 *
	 * @param array[] $actionsTaken associative array mapping each filter to the list if
	 *                actions taken because of that filter.
	 * @param Message[] $messages a list of Message objects
	 *
	 * @return Status
	 */
	private function buildStatus( array $actionsTaken, array $messages ): Status {
		$status = Status::newGood( $actionsTaken );

		foreach ( $messages as $msg ) {
			$status->fatal( $msg );
		}

		return $status;
	}
}
PK       ! 0*    #  Consequences/ConsequencesLookup.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences;

use MediaWiki\Extension\AbuseFilter\CentralDBManager;
use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;
use Psr\Log\LoggerInterface;
use Wikimedia\Rdbms\IReadableDatabase;
use Wikimedia\Rdbms\LBFactory;

/**
 * Class for retrieving actions and parameters from the database
 * @todo Can we better integrate this with FilterLookup?
 */
class ConsequencesLookup {
	public const SERVICE_NAME = 'AbuseFilterConsequencesLookup';

	/** @var LBFactory */
	private $lbFactory;
	/** @var CentralDBManager */
	private $centralDBManager;
	/** @var ConsequencesRegistry */
	private $consequencesRegistry;
	/** @var LoggerInterface */
	private $logger;

	/**
	 * @param LBFactory $lbFactory
	 * @param CentralDBManager $centralDBManager
	 * @param ConsequencesRegistry $consequencesRegistry
	 * @param LoggerInterface $logger
	 */
	public function __construct(
		LBFactory $lbFactory,
		CentralDBManager $centralDBManager,
		ConsequencesRegistry $consequencesRegistry,
		LoggerInterface $logger
	) {
		$this->lbFactory = $lbFactory;
		$this->centralDBManager = $centralDBManager;
		$this->consequencesRegistry = $consequencesRegistry;
		$this->logger = $logger;
	}

	/**
	 * @param array<int|string> $filters
	 * @return array[][]
	 */
	public function getConsequencesForFilters( array $filters ): array {
		$globalFilters = [];
		$localFilters = [];

		foreach ( $filters as $filter ) {
			[ $filterID, $global ] = GlobalNameUtils::splitGlobalName( $filter );

			if ( $global ) {
				$globalFilters[] = $filterID;
			} else {
				$localFilters[] = (int)$filter;
			}
		}

		// Load local filter info
		$dbr = $this->lbFactory->getReplicaDatabase();
		// Retrieve the consequences.
		$consequences = [];

		if ( count( $localFilters ) ) {
			$consequences = $this->loadConsequencesFromDB( $dbr, $localFilters );
		}

		if ( count( $globalFilters ) ) {
			$consequences += $this->loadConsequencesFromDB(
				$this->centralDBManager->getConnection( DB_REPLICA ),
				$globalFilters,
				GlobalNameUtils::GLOBAL_FILTER_PREFIX
			);
		}

		return $consequences;
	}

	/**
	 * @param IReadableDatabase $dbr
	 * @param int[] $filters
	 * @param string $prefix
	 * @return array[][]
	 */
	private function loadConsequencesFromDB( IReadableDatabase $dbr, array $filters, string $prefix = '' ): array {
		$actionsByFilter = [];
		foreach ( $filters as $filter ) {
			$actionsByFilter[$prefix . $filter] = [];
		}

		$res = $dbr->newSelectQueryBuilder()
			->select( '*' )
			->from( 'abuse_filter_action' )
			->leftJoin( 'abuse_filter', null, 'afa_filter=af_id' )
			->where( [ 'af_id' => $filters ] )
			->caller( __METHOD__ )
			->fetchResultSet();

		$dangerousActions = $this->consequencesRegistry->getDangerousActionNames();
		// Categorise consequences by filter.
		foreach ( $res as $row ) {
			if ( $row->af_throttled
				&& in_array( $row->afa_consequence, $dangerousActions )
			) {
				// Don't do the action, just log
				$this->logger->info(
					'Filter {filter_id} is throttled, skipping action: {action}',
					[
						'filter_id' => $row->af_id,
						'action' => $row->afa_consequence
					]
				);
			} elseif ( $row->afa_filter !== $row->af_id ) {
				// We probably got a NULL, as it's a LEFT JOIN. Don't add it.
				continue;
			} else {
				$actionsByFilter[$prefix . $row->afa_filter][$row->afa_consequence] =
					$row->afa_parameters !== '' ? explode( "\n", $row->afa_parameters ) : [];
			}
		}

		return $actionsByFilter;
	}

}
PK       ! xw    '  Consequences/Consequence/RangeBlock.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence;

use MediaWiki\Block\BlockUserFactory;
use MediaWiki\Extension\AbuseFilter\Consequences\Parameters;
use MediaWiki\Extension\AbuseFilter\FilterUser;
use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;
use MessageLocalizer;
use Psr\Log\LoggerInterface;
use Wikimedia\IPUtils;

/**
 * Consequence that blocks an IP range (retrieved from the current request for both anons and registered users).
 */
class RangeBlock extends BlockingConsequence {
	/** @var int[] */
	private $rangeBlockSize;
	/** @var int[] */
	private $blockCIDRLimit;

	/**
	 * @param Parameters $parameters
	 * @param string $expiry
	 * @param BlockUserFactory $blockUserFactory
	 * @param FilterUser $filterUser
	 * @param MessageLocalizer $messageLocalizer
	 * @param LoggerInterface $logger
	 * @param array $rangeBlockSize
	 * @param array $blockCIDRLimit
	 */
	public function __construct(
		Parameters $parameters,
		string $expiry,
		BlockUserFactory $blockUserFactory,
		FilterUser $filterUser,
		MessageLocalizer $messageLocalizer,
		LoggerInterface $logger,
		array $rangeBlockSize,
		array $blockCIDRLimit
	) {
		parent::__construct( $parameters, $expiry, $blockUserFactory, $filterUser, $messageLocalizer, $logger );
		$this->rangeBlockSize = $rangeBlockSize;
		$this->blockCIDRLimit = $blockCIDRLimit;
	}

	/**
	 * @inheritDoc
	 */
	public function execute(): bool {
		$requestIP = $this->parameters->getActionSpecifier()->getIP();
		$type = IPUtils::isIPv6( $requestIP ) ? 'IPv6' : 'IPv4';
		$CIDRsize = max( $this->rangeBlockSize[$type], $this->blockCIDRLimit[$type] );
		$blockCIDR = $requestIP . '/' . $CIDRsize;

		$target = IPUtils::sanitizeRange( $blockCIDR );
		$status = $this->doBlockInternal(
			$this->parameters->getFilter()->getName(),
			$this->parameters->getFilter()->getID(),
			$target,
			$this->expiry,
			$autoblock = false,
			$preventTalk = false
		);
		return $status->isOK();
	}

	/**
	 * @inheritDoc
	 */
	public function getMessage(): array {
		$filter = $this->parameters->getFilter();
		return [
			'abusefilter-blocked-display',
			$filter->getName(),
			GlobalNameUtils::buildGlobalName( $filter->getID(), $this->parameters->getIsGlobalFilter() )
		];
	}
}
PK       ! by    $  Consequences/Consequence/Degroup.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence;

use ManualLogEntry;
use MediaWiki\Extension\AbuseFilter\Consequences\Parameters;
use MediaWiki\Extension\AbuseFilter\FilterUser;
use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;
use MediaWiki\Extension\AbuseFilter\Variables\LazyLoadedVariable;
use MediaWiki\Extension\AbuseFilter\Variables\UnsetVariableException;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\UserGroupManager;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityUtils;
use MessageLocalizer;

/**
 * Consequence that removes all user groups from a user.
 */
class Degroup extends Consequence implements HookAborterConsequence, ReversibleConsequence {
	/**
	 * @var VariableHolder
	 * @todo This dependency is subpar
	 */
	private $vars;

	/** @var UserGroupManager */
	private $userGroupManager;

	/** @var UserIdentityUtils */
	private $userIdentityUtils;

	/** @var FilterUser */
	private $filterUser;

	/** @var MessageLocalizer */
	private $messageLocalizer;

	/**
	 * @param Parameters $params
	 * @param VariableHolder $vars
	 * @param UserGroupManager $userGroupManager
	 * @param UserIdentityUtils $userIdentityUtils
	 * @param FilterUser $filterUser
	 * @param MessageLocalizer $messageLocalizer
	 */
	public function __construct(
		Parameters $params,
		VariableHolder $vars,
		UserGroupManager $userGroupManager,
		UserIdentityUtils $userIdentityUtils,
		FilterUser $filterUser,
		MessageLocalizer $messageLocalizer
	) {
		parent::__construct( $params );
		$this->vars = $vars;
		$this->userGroupManager = $userGroupManager;
		$this->userIdentityUtils = $userIdentityUtils;
		$this->filterUser = $filterUser;
		$this->messageLocalizer = $messageLocalizer;
	}

	/**
	 * @inheritDoc
	 */
	public function execute(): bool {
		$user = $this->parameters->getUser();

		if ( !$this->userIdentityUtils->isNamed( $user ) ) {
			return false;
		}

		// Pull the groups from the VariableHolder, so that they will always be computed.
		// This allow us to pull the groups from the VariableHolder to undo the degroup
		// via Special:AbuseFilter/revert.
		try {
			// No point in triggering a lazy-load, instead we compute it here if necessary
			$groupsVar = $this->vars->getVarThrow( 'user_groups' );
		} catch ( UnsetVariableException $_ ) {
			$groupsVar = null;
		}
		if ( $groupsVar === null || $groupsVar instanceof LazyLoadedVariable ) {
			// The variable is unset or not computed. Compute it and update the holder so we can use it for reverts
			$groups = $this->userGroupManager->getUserEffectiveGroups( $user );
			$this->vars->setVar( 'user_groups', $groups );
		} else {
			$groups = $groupsVar->toNative();
		}

		$implicitGroups = $this->userGroupManager->listAllImplicitGroups();
		$removeGroups = array_diff( $groups, $implicitGroups );
		if ( !count( $removeGroups ) ) {
			return false;
		}

		foreach ( $removeGroups as $group ) {
			$this->userGroupManager->removeUserFromGroup( $user, $group );
		}

		// TODO Core should provide a logging method
		$logEntry = new ManualLogEntry( 'rights', 'rights' );
		$logEntry->setPerformer( $this->filterUser->getUserIdentity() );
		$logEntry->setTarget( new TitleValue( NS_USER, $user->getName() ) );
		$logEntry->setComment(
			$this->messageLocalizer->msg(
				'abusefilter-degroupreason',
				$this->parameters->getFilter()->getName(),
				$this->parameters->getFilter()->getID()
			)->inContentLanguage()->text()
		);
		$logEntry->setParameters( [
			'4::oldgroups' => $removeGroups,
			'5::newgroups' => []
		] );
		$logEntry->publish( $logEntry->insert() );
		return true;
	}

	/**
	 * @inheritDoc
	 */
	public function revert( UserIdentity $performer, string $reason ): bool {
		$user = $this->parameters->getUser();
		$currentGroups = $this->userGroupManager->getUserGroups( $user );
		// Pull the user's original groups from the vars. This is guaranteed to be set, because we
		// enforce it when performing a degroup.
		$removedGroups = $this->vars->getComputedVariable( 'user_groups' )->toNative();
		$removedGroups = array_diff(
			$removedGroups,
			$this->userGroupManager->listAllImplicitGroups(),
			$currentGroups
		);

		$addedGroups = [];
		foreach ( $removedGroups as $group ) {
			// TODO An addUserToGroups method with bulk updates would be nice
			if ( $this->userGroupManager->addUserToGroup( $user, $group ) ) {
				$addedGroups[] = $group;
			}
		}

		// Don't log if no groups were added.
		if ( !$addedGroups ) {
			return false;
		}

		// TODO Core should provide a logging method
		$logEntry = new ManualLogEntry( 'rights', 'rights' );
		$logEntry->setTarget( new TitleValue( NS_USER, $user->getName() ) );
		$logEntry->setPerformer( $performer );
		$logEntry->setComment( $reason );
		$logEntry->setParameters( [
			'4::oldgroups' => $currentGroups,
			'5::newgroups' => array_merge( $currentGroups, $addedGroups )
		] );
		$logEntry->publish( $logEntry->insert() );

		return true;
	}

	/**
	 * @inheritDoc
	 */
	public function getMessage(): array {
		$filter = $this->parameters->getFilter();
		return [
			'abusefilter-degrouped',
			$filter->getName(),
			GlobalNameUtils::buildGlobalName( $filter->getID(), $this->parameters->getIsGlobalFilter() )
		];
	}
}
PK       !     2  Consequences/Consequence/ReversibleConsequence.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence;

use MediaWiki\User\UserIdentity;

/**
 * Interface for consequences which can be reverted
 */
interface ReversibleConsequence {

	/**
	 * @param UserIdentity $performer
	 * @param string $reason
	 * @return bool Whether the revert was successful
	 */
	public function revert( UserIdentity $performer, string $reason ): bool;

}
PK       ! ^i    %  Consequences/Consequence/Throttle.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence;

use InvalidArgumentException;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequenceNotPrecheckedException;
use MediaWiki\Extension\AbuseFilter\Consequences\Parameters;
use MediaWiki\Title\Title;
use MediaWiki\User\UserEditTracker;
use MediaWiki\User\UserFactory;
use Psr\Log\LoggerInterface;
use Wikimedia\IPUtils;
use Wikimedia\ObjectCache\BagOStuff;

/**
 * Consequence that delays executing other actions until certain conditions are met
 */
class Throttle extends Consequence implements ConsequencesDisablerConsequence {
	/** @var array */
	private $throttleParams;
	/** @var BagOStuff */
	private $mainStash;
	/** @var UserEditTracker */
	private $userEditTracker;
	/** @var UserFactory */
	private $userFactory;
	/** @var LoggerInterface */
	private $logger;
	/** @var bool */
	private $filterIsCentral;
	/** @var string|null */
	private $centralDB;

	/** @var bool|null */
	private $hitThrottle;

	private const IPV4_RANGE = '16';
	private const IPV6_RANGE = '64';

	/**
	 * @param Parameters $parameters
	 * @param array $throttleParams
	 * @phan-param array{groups:string[],id:int|string,count:int,period:int} $throttleParams
	 * @param BagOStuff $mainStash
	 * @param UserEditTracker $userEditTracker
	 * @param UserFactory $userFactory
	 * @param LoggerInterface $logger
	 * @param bool $filterIsCentral
	 * @param string|null $centralDB
	 */
	public function __construct(
		Parameters $parameters,
		array $throttleParams,
		BagOStuff $mainStash,
		UserEditTracker $userEditTracker,
		UserFactory $userFactory,
		LoggerInterface $logger,
		bool $filterIsCentral,
		?string $centralDB
	) {
		parent::__construct( $parameters );
		$this->throttleParams = $throttleParams;
		$this->mainStash = $mainStash;
		$this->userEditTracker = $userEditTracker;
		$this->userFactory = $userFactory;
		$this->logger = $logger;
		$this->filterIsCentral = $filterIsCentral;
		$this->centralDB = $centralDB;
	}

	/**
	 * @return bool Whether the throttling took place (i.e. the limit was NOT hit)
	 * @throws ConsequenceNotPrecheckedException
	 */
	public function execute(): bool {
		if ( $this->hitThrottle === null ) {
			throw new ConsequenceNotPrecheckedException();
		}
		foreach ( $this->throttleParams['groups'] as $throttleType ) {
			$this->setThrottled( $throttleType );
		}
		return !$this->hitThrottle;
	}

	/**
	 * @inheritDoc
	 */
	public function shouldDisableOtherConsequences(): bool {
		$this->hitThrottle = false;
		foreach ( $this->throttleParams['groups'] as $throttleType ) {
			$this->hitThrottle = $this->isThrottled( $throttleType ) || $this->hitThrottle;
		}
		return !$this->hitThrottle;
	}

	/**
	 * @inheritDoc
	 */
	public function getSort(): int {
		return 0;
	}

	/**
	 * Determines whether the throttle has been hit with the given parameters
	 * @note If caching is disabled, get() will return false, so the throttle count will never be reached (if >0).
	 *  This means that filters with 'throttle' enabled won't ever trigger any consequence.
	 *
	 * @param string $types
	 * @return bool
	 */
	private function isThrottled( string $types ): bool {
		$key = $this->throttleKey( $types );
		$newCount = (int)$this->mainStash->get( $key ) + 1;

		$this->logger->debug(
			'New value is {newCount} for throttle key {key}. Maximum is {rateCount}.',
			[
				'newCount' => $newCount,
				'key' => $key,
				'rateCount' => $this->throttleParams['count'],
			]
		);

		return $newCount > $this->throttleParams['count'];
	}

	/**
	 * Updates the throttle status with the given parameters
	 *
	 * @param string $types
	 */
	private function setThrottled( string $types ): void {
		$key = $this->throttleKey( $types );
		$this->logger->debug(
			'Increasing throttle key {key}',
			[ 'key' => $key ]
		);
		$this->mainStash->incrWithInit( $key, $this->throttleParams['period'] );
	}

	/**
	 * @param string $type
	 * @return string
	 */
	private function throttleKey( string $type ): string {
		$types = explode( ',', $type );

		$identifiers = [];

		foreach ( $types as $subtype ) {
			$identifiers[] = $this->throttleIdentifier( $subtype );
		}

		$identifier = sha1( implode( ':', $identifiers ) );

		if ( $this->parameters->getIsGlobalFilter() && !$this->filterIsCentral ) {
			return $this->mainStash->makeGlobalKey(
				'abusefilter', 'throttle', $this->centralDB, $this->throttleParams['id'], $identifier
			);
		}

		return $this->mainStash->makeKey( 'abusefilter', 'throttle', $this->throttleParams['id'], $identifier );
	}

	/**
	 * @param string $type
	 * @return string
	 */
	private function throttleIdentifier( string $type ): string {
		$user = $this->parameters->getUser();
		switch ( $type ) {
			case 'ip':
				$identifier = $this->parameters->getActionSpecifier()->getIP();
				break;
			case 'user':
				// NOTE: This is always 0 for anons. Is this good/wanted?
				$identifier = $user->getId();
				break;
			case 'range':
				$requestIP = $this->parameters->getActionSpecifier()->getIP();
				$range = IPUtils::isIPv6( $requestIP ) ? self::IPV6_RANGE : self::IPV4_RANGE;
				$identifier = IPUtils::sanitizeRange( "{$requestIP}/$range" );
				break;
			case 'creationdate':
				// TODO Inject a proper service, not UserFactory, once getRegistration is moved away from User
				$reg = (int)$this->userFactory->newFromUserIdentity( $user )->getRegistration();
				$identifier = $reg - ( $reg % 86400 );
				break;
			case 'editcount':
				// Hack for detecting different single-purpose accounts.
				$identifier = $this->userEditTracker->getUserEditCount( $user ) ?: 0;
				break;
			case 'site':
				$identifier = 1;
				break;
			case 'page':
				$title = Title::castFromLinkTarget( $this->parameters->getTarget() );
				// TODO Replace with something from LinkTarget, e.g. namespace + text
				$identifier = $title->getPrefixedText();
				break;
			default:
				// Should never happen
				throw new InvalidArgumentException( "Invalid throttle type $type." );
		}

		return "$type-$identifier";
	}
}
PK       ! rg    (  Consequences/Consequence/Consequence.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence;

use MediaWiki\Extension\AbuseFilter\Consequences\Parameters;

/**
 * Base command-style class for consequences.
 */
abstract class Consequence {
	/** @var Parameters */
	protected $parameters;

	/**
	 * @param Parameters $parameters
	 */
	public function __construct( Parameters $parameters ) {
		$this->parameters = $parameters;
	}

	/**
	 * @return bool A generic success indicator, subclasses can be more specific
	 */
	abstract public function execute(): bool;
}
PK       ! A    %  Consequences/Consequence/Disallow.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence;

use MediaWiki\Extension\AbuseFilter\Consequences\Parameters;
use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;

/**
 * Consequence that simply disallows the ongoing action.
 */
class Disallow extends Consequence implements HookAborterConsequence {
	/** @var string */
	private $message;

	/**
	 * @param Parameters $parameters
	 * @param string $message
	 */
	public function __construct( Parameters $parameters, string $message ) {
		parent::__construct( $parameters );
		$this->message = $message;
	}

	/**
	 * @inheritDoc
	 */
	public function execute(): bool {
		return true;
	}

	/**
	 * @inheritDoc
	 */
	public function getMessage(): array {
		$filter = $this->parameters->getFilter();
		return [
			$this->message,
			$filter->getName(),
			GlobalNameUtils::buildGlobalName( $filter->getID(), $this->parameters->getIsGlobalFilter() )
		];
	}
}
PK       ! _  _  <  Consequences/Consequence/ConsequencesDisablerConsequence.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence;

/**
 * Interface for consequences that are checked first, and can disable every other consequence (including
 * other ConsequencesDisabler consequences) if needed.
 */
interface ConsequencesDisablerConsequence {
	/**
	 * Returns whether other consequences should be disabled. This may depend on Consequence::execute().
	 * ConsequenceNotPrecheckedException can be used to assert that execute() was called.
	 * @return bool
	 */
	public function shouldDisableOtherConsequences(): bool;

	/**
	 * Returns an arbitrary integer representing the sorting importance of this consequence. Consequences
	 * with lower numbers are executed first.
	 * @note If two consequences have the same importance, their final order is nondeterministic
	 * @return int
	 */
	public function getSort(): int;
}
PK       ! 
  
  0  Consequences/Consequence/BlockingConsequence.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence;

use LogPage;
use MediaWiki\Block\BlockUserFactory;
use MediaWiki\Extension\AbuseFilter\Consequences\Parameters;
use MediaWiki\Extension\AbuseFilter\FilterUser;
use MediaWiki\Status\Status;
use MessageLocalizer;
use Psr\Log\LoggerInterface;
use Wikimedia\IPUtils;

/**
 * Base class for consequences that block a user
 */
abstract class BlockingConsequence extends Consequence implements HookAborterConsequence {
	/** @var BlockUserFactory */
	private $blockUserFactory;

	/** @var FilterUser */
	protected $filterUser;

	/** @var MessageLocalizer */
	private $messageLocalizer;

	/** @var LoggerInterface */
	private $logger;

	/** @var string Expiry of the block */
	protected $expiry;

	/**
	 * @param Parameters $params
	 * @param string $expiry
	 * @param BlockUserFactory $blockUserFactory
	 * @param FilterUser $filterUser
	 * @param MessageLocalizer $messageLocalizer
	 * @param LoggerInterface $logger
	 */
	public function __construct(
		Parameters $params,
		string $expiry,
		BlockUserFactory $blockUserFactory,
		FilterUser $filterUser,
		MessageLocalizer $messageLocalizer,
		LoggerInterface $logger
	) {
		parent::__construct( $params );
		$this->expiry = $expiry;
		$this->blockUserFactory = $blockUserFactory;
		$this->filterUser = $filterUser;
		$this->messageLocalizer = $messageLocalizer;
		$this->logger = $logger;
	}

	/**
	 * Perform a block by the AbuseFilter system user
	 * @param string $ruleDesc
	 * @param int|string $ruleNumber
	 * @param string $target
	 * @param string $expiry
	 * @param bool $isAutoBlock
	 * @param bool $preventEditOwnUserTalk
	 * @return Status
	 */
	protected function doBlockInternal(
		string $ruleDesc,
		$ruleNumber,
		string $target,
		string $expiry,
		bool $isAutoBlock,
		bool $preventEditOwnUserTalk
	): Status {
		$reason = $this->messageLocalizer->msg(
			'abusefilter-blockreason',
			$ruleDesc,
			$ruleNumber
		)->inContentLanguage()->text();

		$blockUser = $this->blockUserFactory->newBlockUser(
			$target,
			$this->filterUser->getAuthority(),
			$expiry,
			$reason,
			[
				'isHardBlock' => false,
				'isAutoblocking' => $isAutoBlock,
				'isCreateAccountBlocked' => true,
				'isUserTalkEditBlocked' => $preventEditOwnUserTalk
			]
		);
		if (
			strpos( $this->parameters->getAction(), 'createaccount' ) !== false &&
			IPUtils::isIPAddress( $target )
		) {
			$blockUser->setLogDeletionFlags( LogPage::SUPPRESSED_ACTION );
		}
		$status = $blockUser->placeBlockUnsafe();
		if ( !$status->isGood() ) {
			$this->logger->warning(
				'AbuseFilter block to {block_target} failed: {errors}',
				[ 'block_target' => $target, 'errors' => $status->__toString() ]
			);
		}
		return $status;
	}
}
PK       ! G}  }  3  Consequences/Consequence/HookAborterConsequence.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence;

/**
 * Interface for consequences that can abort an action hook with an error message
 */
interface HookAborterConsequence {
	/**
	 * Return a message specifier that will be used to fail the hook
	 * @return array First element is the key, then the parameters
	 */
	public function getMessage(): array;
}
PK       ! \  \  "  Consequences/Consequence/Block.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence;

use ManualLogEntry;
use MediaWiki\Block\BlockUserFactory;
use MediaWiki\Block\DatabaseBlockStore;
use MediaWiki\Extension\AbuseFilter\Consequences\Parameters;
use MediaWiki\Extension\AbuseFilter\FilterUser;
use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\UserIdentity;
use MessageLocalizer;
use Psr\Log\LoggerInterface;

/**
 * Consequence that blocks a single user.
 */
class Block extends BlockingConsequence implements ReversibleConsequence {

	private bool $preventsTalkEdit;
	private DatabaseBlockStore $databaseBlockStore;

	/**
	 * @param Parameters $params
	 * @param string $expiry
	 * @param bool $preventTalkEdit
	 * @param BlockUserFactory $blockUserFactory
	 * @param DatabaseBlockStore $databaseBlockStore
	 * @param FilterUser $filterUser
	 * @param MessageLocalizer $messageLocalizer
	 * @param LoggerInterface $logger
	 */
	public function __construct(
		Parameters $params,
		string $expiry,
		bool $preventTalkEdit,
		BlockUserFactory $blockUserFactory,
		DatabaseBlockStore $databaseBlockStore,
		FilterUser $filterUser,
		MessageLocalizer $messageLocalizer,
		LoggerInterface $logger
	) {
		parent::__construct( $params, $expiry, $blockUserFactory, $filterUser, $messageLocalizer, $logger );
		$this->databaseBlockStore = $databaseBlockStore;
		$this->preventsTalkEdit = $preventTalkEdit;
	}

	/**
	 * @inheritDoc
	 */
	public function execute(): bool {
		$status = $this->doBlockInternal(
			$this->parameters->getFilter()->getName(),
			$this->parameters->getFilter()->getID(),
			$this->parameters->getUser()->getName(),
			$this->expiry,
			$autoblock = true,
			$this->preventsTalkEdit
		);
		// TODO: Should we reblock in case of partial blocks? At that point we could return
		// the status of doBlockInternal
		return defined( 'MW_PHPUNIT_TEST' ) ? $status->isOK() : true;
	}

	/**
	 * @inheritDoc
	 * @todo This could use UnblockUser, but we need to check if the block was performed by the AF user
	 */
	public function revert( UserIdentity $performer, string $reason ): bool {
		$block = $this->databaseBlockStore->newFromTarget( $this->parameters->getUser()->getName() );
		if ( !( $block && $block->getBy() === $this->filterUser->getUserIdentity()->getId() ) ) {
			// Not blocked by abuse filter
			return false;
		}
		if ( !$this->databaseBlockStore->deleteBlock( $block ) ) {
			return false;
		}
		$logEntry = new ManualLogEntry( 'block', 'unblock' );
		$logEntry->setTarget( new TitleValue( NS_USER, $this->parameters->getUser()->getName() ) );
		$logEntry->setComment( $reason );
		$logEntry->setPerformer( $performer );
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
			// This has a bazillion of static dependencies all around the place, and a nightmare to deal with in tests
			// TODO: Remove this check once T253717 is resolved
			// @codeCoverageIgnoreStart
			$logEntry->publish( $logEntry->insert() );
			// @codeCoverageIgnoreEnd
		}
		return true;
	}

	/**
	 * @inheritDoc
	 */
	public function getMessage(): array {
		$filter = $this->parameters->getFilter();
		return [
			'abusefilter-blocked-display',
			$filter->getName(),
			GlobalNameUtils::buildGlobalName( $filter->getID(), $this->parameters->getIsGlobalFilter() )
		];
	}

	/**
	 * @return string
	 * @internal
	 */
	public function getExpiry(): string {
		return $this->expiry;
	}
}
PK       ! u?  ?     Consequences/Consequence/Tag.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence;

use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagger;
use MediaWiki\Extension\AbuseFilter\Consequences\Parameters;

/**
 * Consequence that adds change tags once the edit is saved
 */
class Tag extends Consequence {
	/** @var string[] */
	private $tags;
	/** @var ChangeTagger */
	private $tagger;

	/**
	 * @param Parameters $parameters
	 * @param string[] $tags
	 * @param ChangeTagger $tagger
	 */
	public function __construct( Parameters $parameters, array $tags, ChangeTagger $tagger ) {
		parent::__construct( $parameters );
		$this->tags = $tags;
		$this->tagger = $tagger;
	}

	/**
	 * @inheritDoc
	 */
	public function execute(): bool {
		$this->tagger->addTags( $this->parameters->getActionSpecifier(), $this->tags );
		return true;
	}
}
PK       ! 'L/  /  !  Consequences/Consequence/Warn.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence;

use MediaWiki\Extension\AbuseFilter\Consequences\ConsequenceNotPrecheckedException;
use MediaWiki\Extension\AbuseFilter\Consequences\Parameters;
use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;
use MediaWiki\Session\Session;

/**
 * Consequence that warns the user once, allowing the action on the second attempt.
 */
class Warn extends Consequence implements HookAborterConsequence, ConsequencesDisablerConsequence {
	/** @var Session */
	private $session;
	/** @var string */
	private $message;
	/** @var bool|null */
	private $shouldWarn;

	/**
	 * @param Parameters $parameters
	 * @param string $message
	 * @param Session $session
	 */
	public function __construct( Parameters $parameters, string $message, Session $session ) {
		parent::__construct( $parameters );
		$this->message = $message;
		$this->session = $session;
	}

	/**
	 * @return bool Whether the user should be warned (i.e. this is the first attempt)
	 * @throws ConsequenceNotPrecheckedException
	 */
	public function execute(): bool {
		if ( $this->shouldWarn === null ) {
			throw new ConsequenceNotPrecheckedException();
		}
		$this->setWarn();
		return $this->shouldWarn;
	}

	/**
	 * @inheritDoc
	 */
	public function shouldDisableOtherConsequences(): bool {
		$this->shouldWarn = $this->shouldBeWarned();
		return $this->shouldWarn;
	}

	/**
	 * @inheritDoc
	 */
	public function getSort(): int {
		return 5;
	}

	/**
	 * @inheritDoc
	 */
	public function getMessage(): array {
		$filter = $this->parameters->getFilter();
		return [
			$this->message,
			$filter->getName(),
			GlobalNameUtils::buildGlobalName( $filter->getID(), $this->parameters->getIsGlobalFilter() )
		];
	}

	/**
	 * @return bool
	 */
	private function shouldBeWarned(): bool {
		// Make sure the session is started prior to using it
		$this->session->persist();
		$warnKey = $this->getWarnKey();
		return ( !isset( $this->session[$warnKey] ) || !$this->session[$warnKey] );
	}

	/**
	 * Sets the parameters needed to warn the user, *without* checking if the user should be warned.
	 */
	private function setWarn(): void {
		$warnKey = $this->getWarnKey();
		$this->session[$warnKey] = $this->shouldWarn;
	}

	/**
	 * Generate a unique key to determine whether the user has already been warned.
	 * We'll warn again if one of these changes: session, page, triggered filter, or action
	 * @return string
	 */
	private function getWarnKey(): string {
		$globalFilterName = GlobalNameUtils::buildGlobalName(
			$this->parameters->getFilter()->getID(),
			$this->parameters->getIsGlobalFilter()
		);
		$target = $this->parameters->getTarget();
		$titleText = $target->getNamespace() . ':' . $target->getText();
		return 'abusefilter-warned-' . md5( $titleText ) .
			'-' . $globalFilterName . '-' . $this->parameters->getAction();
	}
}
PK       ! |K_^	  ^	  -  Consequences/Consequence/BlockAutopromote.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences\Consequence;

use MediaWiki\Extension\AbuseFilter\BlockAutopromoteStore;
use MediaWiki\Extension\AbuseFilter\Consequences\Parameters;
use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityUtils;
use MessageLocalizer;

/**
 * Consequence that blocks/delays autopromotion of a registered user.
 */
class BlockAutopromote extends Consequence implements HookAborterConsequence, ReversibleConsequence {
	/** @var int */
	private $duration;
	/** @var BlockAutopromoteStore */
	private $blockAutopromoteStore;
	/** @var MessageLocalizer */
	private $messageLocalizer;
	/** @var UserIdentityUtils */
	private $userIdentityUtils;

	/**
	 * @param Parameters $params
	 * @param int $duration
	 * @param BlockAutopromoteStore $blockAutopromoteStore
	 * @param MessageLocalizer $messageLocalizer
	 * @param UserIdentityUtils $userIdentityUtils
	 */
	public function __construct(
		Parameters $params,
		int $duration,
		BlockAutopromoteStore $blockAutopromoteStore,
		MessageLocalizer $messageLocalizer,
		UserIdentityUtils $userIdentityUtils
	) {
		parent::__construct( $params );
		$this->duration = $duration;
		$this->blockAutopromoteStore = $blockAutopromoteStore;
		$this->messageLocalizer = $messageLocalizer;
		$this->userIdentityUtils = $userIdentityUtils;
	}

	/**
	 * @inheritDoc
	 */
	public function execute(): bool {
		$target = $this->parameters->getUser();
		if ( !$this->userIdentityUtils->isNamed( $target ) ) {
			return false;
		}

		return $this->blockAutopromoteStore->blockAutoPromote(
			$target,
			$this->messageLocalizer->msg(
				'abusefilter-blockautopromotereason',
				$this->parameters->getFilter()->getName(),
				$this->parameters->getFilter()->getID()
			)->inContentLanguage()->text(),
			$this->duration
		);
	}

	/**
	 * @inheritDoc
	 */
	public function revert( UserIdentity $performer, string $reason ): bool {
		return $this->blockAutopromoteStore->unblockAutopromote(
			$this->parameters->getUser(),
			$performer,
			$reason
		);
	}

	/**
	 * @inheritDoc
	 */
	public function getMessage(): array {
		$filter = $this->parameters->getFilter();
		return [
			'abusefilter-autopromote-blocked',
			$filter->getName(),
			GlobalNameUtils::buildGlobalName( $filter->getID(), $this->parameters->getIsGlobalFilter() ),
			$this->duration
		];
	}
}
PK       ! #      Consequences/Parameters.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences;

use MediaWiki\Extension\AbuseFilter\ActionSpecifier;
use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\User\UserIdentity;

/**
 * Immutable value object that provides "base" parameters to Consequence objects
 */
class Parameters {
	/** @var ExistingFilter */
	private $filter;

	/** @var bool */
	private $isGlobalFilter;

	/** @var ActionSpecifier */
	private $specifier;

	/**
	 * @param ExistingFilter $filter
	 * @param bool $isGlobalFilter
	 * @param ActionSpecifier $specifier
	 */
	public function __construct(
		ExistingFilter $filter,
		bool $isGlobalFilter,
		ActionSpecifier $specifier
	) {
		$this->filter = $filter;
		$this->isGlobalFilter = $isGlobalFilter;
		$this->specifier = $specifier;
	}

	/**
	 * @return ExistingFilter
	 */
	public function getFilter(): ExistingFilter {
		return $this->filter;
	}

	/**
	 * @return bool
	 */
	public function getIsGlobalFilter(): bool {
		return $this->isGlobalFilter;
	}

	/**
	 * @return ActionSpecifier
	 */
	public function getActionSpecifier(): ActionSpecifier {
		return $this->specifier;
	}

	/**
	 * @return UserIdentity
	 */
	public function getUser(): UserIdentity {
		return $this->specifier->getUser();
	}

	/**
	 * @return LinkTarget
	 */
	public function getTarget(): LinkTarget {
		return $this->specifier->getTitle();
	}

	/**
	 * @return string
	 */
	public function getAction(): string {
		return $this->specifier->getAction();
	}
}
PK       ! 7]0  0  %  Consequences/ConsequencesRegistry.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences;

// phpcs:ignore MediaWiki.Classes.UnusedUseStatement.UnusedUse
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\Consequence;
use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use RuntimeException;

class ConsequencesRegistry {
	public const SERVICE_NAME = 'AbuseFilterConsequencesRegistry';

	private const DANGEROUS_ACTIONS = [
		'block',
		'blockautopromote',
		'degroup',
		'rangeblock'
	];

	/** @var AbuseFilterHookRunner */
	private $hookRunner;
	/** @var bool[] */
	private $configActions;

	/** @var string[]|null */
	private $dangerousActionsCache;
	/** @var callable[]|null */
	private $customActionsCache;

	/**
	 * @param AbuseFilterHookRunner $hookRunner
	 * @param bool[] $configActions
	 */
	public function __construct(
		AbuseFilterHookRunner $hookRunner,
		array $configActions
	) {
		$this->hookRunner = $hookRunner;
		$this->configActions = $configActions;
	}

	/**
	 * Get an array of actions which harm the user.
	 *
	 * @return string[]
	 */
	public function getDangerousActionNames(): array {
		if ( $this->dangerousActionsCache === null ) {
			$extActions = [];
			$this->hookRunner->onAbuseFilterGetDangerousActions( $extActions );
			$this->dangerousActionsCache = array_unique(
				array_merge( $extActions, self::DANGEROUS_ACTIONS )
			);
		}
		return $this->dangerousActionsCache;
	}

	/**
	 * @return string[]
	 */
	public function getAllActionNames(): array {
		return array_unique(
			array_merge(
				array_keys( $this->configActions ),
				array_keys( $this->getCustomActions() )
			)
		);
	}

	/**
	 * @return callable[]
	 * @phan-return array<string,callable(Parameters,array):Consequence>
	 */
	public function getCustomActions(): array {
		if ( $this->customActionsCache === null ) {
			$this->customActionsCache = [];
			$this->hookRunner->onAbuseFilterCustomActions( $this->customActionsCache );
			$this->validateCustomActions();
		}
		return $this->customActionsCache;
	}

	/**
	 * Ensure that extensions aren't putting crap in this array, since we can't enforce types on closures otherwise
	 */
	private function validateCustomActions(): void {
		foreach ( $this->customActionsCache as $name => $cb ) {
			if ( !is_string( $name ) ) {
				throw new RuntimeException( 'Custom actions keys should be strings!' );
			}
			// Validating parameters and return value will happen later at runtime.
			if ( !is_callable( $cb ) ) {
				throw new RuntimeException( 'Custom actions values should be callables!' );
			}
		}
	}

	/**
	 * @return string[]
	 */
	public function getAllEnabledActionNames(): array {
		$disabledActions = array_keys( array_filter(
			$this->configActions,
			static function ( $el ) {
				return $el === false;
			}
		) );
		return array_values( array_diff( $this->getAllActionNames(), $disabledActions ) );
	}
}
PK       ! ׮8  8  $  Consequences/ConsequencesFactory.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences;

use MediaWiki\Block\BlockUserFactory;
use MediaWiki\Block\DatabaseBlockStore;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Extension\AbuseFilter\BlockAutopromoteStore;
use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagger;
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\Block;
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\BlockAutopromote;
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\Degroup;
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\Disallow;
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\RangeBlock;
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\Tag;
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\Throttle;
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\Warn;
use MediaWiki\Extension\AbuseFilter\FilterUser;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Session\Session;
use MediaWiki\Session\SessionManager;
use MediaWiki\User\UserEditTracker;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserGroupManager;
use MediaWiki\User\UserIdentityUtils;
use MessageLocalizer;
use Psr\Log\LoggerInterface;
use Wikimedia\ObjectCache\BagOStuff;

class ConsequencesFactory {
	public const SERVICE_NAME = 'AbuseFilterConsequencesFactory';

	public const CONSTRUCTOR_OPTIONS = [
		'AbuseFilterCentralDB',
		'AbuseFilterIsCentral',
		'AbuseFilterRangeBlockSize',
		'BlockCIDRLimit',
	];

	/** @var ServiceOptions */
	private $options;

	/** @var LoggerInterface */
	private $logger;

	/** @var BlockUserFactory */
	private $blockUserFactory;

	/** @var DatabaseBlockStore */
	private $databaseBlockStore;

	/** @var UserGroupManager */
	private $userGroupManager;

	/** @var BagOStuff */
	private $mainStash;

	/** @var ChangeTagger */
	private $changeTagger;

	/** @var BlockAutopromoteStore */
	private $blockAutopromoteStore;

	/** @var FilterUser */
	private $filterUser;

	/** @var MessageLocalizer */
	private $messageLocalizer;

	/** @var UserEditTracker */
	private $userEditTracker;

	/** @var UserFactory */
	private $userFactory;

	/** @var UserIdentityUtils */
	private $userIdentityUtils;

	/** @var ?Session */
	private $session;

	/**
	 * @todo This might drag in unwanted dependencies. The alternative is to use ObjectFactory, but that's harder
	 *   to understand for humans and static analysis tools, so do that only if the dependencies list starts growing.
	 * @param ServiceOptions $options
	 * @param LoggerInterface $logger
	 * @param BlockUserFactory $blockUserFactory
	 * @param DatabaseBlockStore $databaseBlockStore
	 * @param UserGroupManager $userGroupManager
	 * @param BagOStuff $mainStash
	 * @param ChangeTagger $changeTagger
	 * @param BlockAutopromoteStore $blockAutopromoteStore
	 * @param FilterUser $filterUser
	 * @param MessageLocalizer $messageLocalizer
	 * @param UserEditTracker $userEditTracker
	 * @param UserFactory $userFactory
	 * @param UserIdentityUtils $userIdentityUtils
	 */
	public function __construct(
		ServiceOptions $options,
		LoggerInterface $logger,
		BlockUserFactory $blockUserFactory,
		DatabaseBlockStore $databaseBlockStore,
		UserGroupManager $userGroupManager,
		BagOStuff $mainStash,
		ChangeTagger $changeTagger,
		BlockAutopromoteStore $blockAutopromoteStore,
		FilterUser $filterUser,
		MessageLocalizer $messageLocalizer,
		UserEditTracker $userEditTracker,
		UserFactory $userFactory,
		UserIdentityUtils $userIdentityUtils
	) {
		$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
		$this->options = $options;
		$this->logger = $logger;
		$this->blockUserFactory = $blockUserFactory;
		$this->databaseBlockStore = $databaseBlockStore;
		$this->userGroupManager = $userGroupManager;
		$this->mainStash = $mainStash;
		$this->changeTagger = $changeTagger;
		$this->blockAutopromoteStore = $blockAutopromoteStore;
		$this->filterUser = $filterUser;
		$this->messageLocalizer = $messageLocalizer;
		$this->userEditTracker = $userEditTracker;
		$this->userFactory = $userFactory;
		$this->userIdentityUtils = $userIdentityUtils;
	}

	// Each class has its factory method for better type inference and static analysis

	/**
	 * @param Parameters $params
	 * @param string $expiry
	 * @param bool $preventsTalk
	 * @return Block
	 */
	public function newBlock( Parameters $params, string $expiry, bool $preventsTalk ): Block {
		return new Block(
			$params,
			$expiry,
			$preventsTalk,
			$this->blockUserFactory,
			$this->databaseBlockStore,
			$this->filterUser,
			$this->messageLocalizer,
			$this->logger
		);
	}

	/**
	 * @param Parameters $params
	 * @param string $expiry
	 * @return RangeBlock
	 */
	public function newRangeBlock( Parameters $params, string $expiry ): RangeBlock {
		return new RangeBlock(
			$params,
			$expiry,
			$this->blockUserFactory,
			$this->filterUser,
			$this->messageLocalizer,
			$this->logger,
			$this->options->get( 'AbuseFilterRangeBlockSize' ),
			$this->options->get( 'BlockCIDRLimit' )
		);
	}

	/**
	 * @param Parameters $params
	 * @param VariableHolder $vars
	 * @return Degroup
	 */
	public function newDegroup( Parameters $params, VariableHolder $vars ): Degroup {
		return new Degroup(
			$params,
			$vars,
			$this->userGroupManager,
			$this->userIdentityUtils,
			$this->filterUser,
			$this->messageLocalizer
		);
	}

	/**
	 * @param Parameters $params
	 * @param int $duration
	 * @return BlockAutopromote
	 */
	public function newBlockAutopromote( Parameters $params, int $duration ): BlockAutopromote {
		return new BlockAutopromote( $params, $duration, $this->blockAutopromoteStore, $this->messageLocalizer,
			$this->userIdentityUtils );
	}

	/**
	 * @param Parameters $params
	 * @param array $throttleParams
	 * @phan-param array{id:int|string,count:int,period:int,groups:string[]} $throttleParams
	 * @return Throttle
	 */
	public function newThrottle( Parameters $params, array $throttleParams ): Throttle {
		return new Throttle(
			$params,
			$throttleParams,
			$this->mainStash,
			$this->userEditTracker,
			$this->userFactory,
			$this->logger,
			$this->options->get( 'AbuseFilterIsCentral' ),
			$this->options->get( 'AbuseFilterCentralDB' )
		);
	}

	/**
	 * @param Parameters $params
	 * @param string $message
	 * @return Warn
	 */
	public function newWarn( Parameters $params, string $message ): Warn {
		return new Warn( $params, $message, $this->getSession() );
	}

	/**
	 * @param Parameters $params
	 * @param string $message
	 * @return Disallow
	 */
	public function newDisallow( Parameters $params, string $message ): Disallow {
		return new Disallow( $params, $message );
	}

	/**
	 * @param Parameters $params
	 * @param string[] $tags
	 * @return Tag
	 */
	public function newTag( Parameters $params, array $tags ): Tag {
		return new Tag( $params, $tags, $this->changeTagger );
	}

	/**
	 * @param Session $session
	 * @return void
	 */
	public function setSession( Session $session ): void {
		$this->session = $session;
	}

	/**
	 * @return Session
	 */
	private function getSession(): Session {
		if ( $this->session === null ) {
			$this->session = SessionManager::getGlobalSession();
		}

		return $this->session;
	}
}
PK       ! t  t  ,  Consequences/ConsequencesExecutorFactory.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Extension\AbuseFilter\ActionSpecifier;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\User\UserIdentityUtils;
use Psr\Log\LoggerInterface;

class ConsequencesExecutorFactory {
	public const SERVICE_NAME = 'AbuseFilterConsequencesExecutorFactory';

	/** @var ConsequencesLookup */
	private $consLookup;
	/** @var ConsequencesFactory */
	private $consFactory;
	/** @var ConsequencesRegistry */
	private $consRegistry;
	/** @var FilterLookup */
	private $filterLookup;
	/** @var LoggerInterface */
	private $logger;
	/** @var UserIdentityUtils */
	private $userIdentityUtils;
	/** @var ServiceOptions */
	private $options;

	/**
	 * @param ConsequencesLookup $consLookup
	 * @param ConsequencesFactory $consFactory
	 * @param ConsequencesRegistry $consRegistry
	 * @param FilterLookup $filterLookup
	 * @param LoggerInterface $logger
	 * @param UserIdentityUtils $userIdentityUtils
	 * @param ServiceOptions $options
	 */
	public function __construct(
		ConsequencesLookup $consLookup,
		ConsequencesFactory $consFactory,
		ConsequencesRegistry $consRegistry,
		FilterLookup $filterLookup,
		LoggerInterface $logger,
		UserIdentityUtils $userIdentityUtils,
		ServiceOptions $options
	) {
		$this->consLookup = $consLookup;
		$this->consFactory = $consFactory;
		$this->consRegistry = $consRegistry;
		$this->filterLookup = $filterLookup;
		$this->logger = $logger;
		$this->userIdentityUtils = $userIdentityUtils;
		$options->assertRequiredOptions( ConsequencesExecutor::CONSTRUCTOR_OPTIONS );
		$this->options = $options;
	}

	/**
	 * @param ActionSpecifier $specifier
	 * @param VariableHolder $vars
	 * @return ConsequencesExecutor
	 */
	public function newExecutor( ActionSpecifier $specifier, VariableHolder $vars ): ConsequencesExecutor {
		return new ConsequencesExecutor(
			$this->consLookup,
			$this->consFactory,
			$this->consRegistry,
			$this->filterLookup,
			$this->logger,
			$this->userIdentityUtils,
			$this->options,
			$specifier,
			$vars
		);
	}
}
PK       ! zRw  w  2  Consequences/ConsequenceNotPrecheckedException.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Consequences;

use RuntimeException;

/**
 * @codeCoverageIgnore
 */
class ConsequenceNotPrecheckedException extends RuntimeException {
	public function __construct() {
		parent::__construct(
			'Consequences that can disable other consequences should ' .
				'use shouldDisableOtherConsequences() before execute()'
		);
	}
}
PK       ! ̘<      BlockedDomainFilter.phpnu Iw        <?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
 */
namespace MediaWiki\Extension\AbuseFilter;

use LogPage;
use ManualLogEntry;
use MediaWiki\CheckUser\Hooks as CUHooks;
use MediaWiki\Extension\AbuseFilter\Variables\UnsetVariableException;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Message\Message;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use MediaWiki\User\User;

/**
 * Filters blocked domains
 *
 * @ingroup SpecialPage
 */
class BlockedDomainFilter {
	public const SERVICE_NAME = 'AbuseFilterBlockedDomainFilter';
	private VariablesManager $variablesManager;
	private BlockedDomainStorage $blockedDomainStorage;

	/**
	 * @param VariablesManager $variablesManager
	 * @param BlockedDomainStorage $blockedDomainStorage
	 */
	public function __construct(
		VariablesManager $variablesManager,
		BlockedDomainStorage $blockedDomainStorage
	) {
		$this->variablesManager = $variablesManager;
		$this->blockedDomainStorage = $blockedDomainStorage;
	}

	/**
	 * @param VariableHolder $vars variables by the action
	 * @param User $user User that tried to add the domain, used for logging
	 * @param Title $title Title of the page that was attempted on, used for logging
	 * @return Status Error status if it's a match, good status if not
	 */
	public function filter( VariableHolder $vars, User $user, Title $title ) {
		global $wgAbuseFilterEnableBlockedExternalDomain;
		$status = Status::newGood();
		if ( !$wgAbuseFilterEnableBlockedExternalDomain ) {
			return $status;
		}
		try {
			$urls = $this->variablesManager->getVar( $vars, 'added_links', VariablesManager::GET_STRICT );
		} catch ( UnsetVariableException $_ ) {
			return $status;
		}

		$addedDomains = [];
		foreach ( $urls->toArray() as $addedUrl ) {
			$parsedHost = parse_url( (string)$addedUrl->getData(), PHP_URL_HOST );
			if ( !is_string( $parsedHost ) ) {
				continue;
			}
			// Given that we block subdomains of blocked domains too
			// pretend that all the higher-level domains are added as well
			// so for foo.bar.com, you will have three domains to check:
			// foo.bar.com, bar.com, and com
			// This saves string search in the large list of blocked domains
			// making it much faster.
			$domainString = '';
			$domainPieces = array_reverse( explode( '.', strtolower( $parsedHost ) ) );
			foreach ( $domainPieces as $domainPiece ) {
				if ( !$domainString ) {
					$domainString = $domainPiece;
				} else {
					$domainString = $domainPiece . '.' . $domainString;
				}
				// It should be a map, benchmark at https://phabricator.wikimedia.org/P48956
				$addedDomains[$domainString] = true;
			}
		}
		if ( !$addedDomains ) {
			return $status;
		}
		$blockedDomains = $this->blockedDomainStorage->loadComputed();
		$blockedDomainsAdded = array_intersect_key( $addedDomains, $blockedDomains );
		if ( !$blockedDomainsAdded ) {
			return $status;
		}
		$blockedDomainsAdded = array_keys( $blockedDomainsAdded );
		$error = Message::newFromSpecifier( 'abusefilter-blocked-domains-attempted' );
		$error->params( Message::listParam( $blockedDomainsAdded ) );
		$status->fatal( $error );
		$this->logFilterHit(
			$user,
			$title,
			implode( ' ', $blockedDomainsAdded )
		);
		return $status;
	}

	/**
	 * Logs the filter hit to Special:Log
	 *
	 * @param User $user
	 * @param Title $title
	 * @param string $blockedDomain The blocked domain the user attempted to add
	 */
	private function logFilterHit( User $user, Title $title, string $blockedDomain ) {
		$logEntry = new ManualLogEntry( 'abusefilterblockeddomainhit', 'hit' );
		$logEntry->setPerformer( $user );
		$logEntry->setTarget( $title );
		$logEntry->setParameters( [ '4::blocked' => $blockedDomain ] );
		$logid = $logEntry->insert();
		$log = new LogPage( 'abusefilterblockeddomainhit' );
		if ( $log->isRestricted() ) {
			// Make sure checkusers can see this action if the log is restricted
			// (which is the default)
			if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) ) {
				$rc = $logEntry->getRecentChange( $logid );
				CUHooks::updateCheckUserData( $rc );
			}
		} else {
			// If the log is unrestricted, publish normally to RC,
			// which will also update checkuser
			$logEntry->publish( $logid, "rc" );
		}
	}
}
PK       ! u    ,  Hooks/AbuseFilterGetDangerousActionsHook.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

interface AbuseFilterGetDangerousActionsHook {
	/**
	 * Hook runner for the `AbuseFilterGetDangerousActions` hook
	 *
	 * Allows specifying custom consequences which can harm the user and prevent
	 * the edit from being saved.
	 *
	 * @param string[] &$actions The dangerous actions
	 */
	public function onAbuseFilterGetDangerousActions( array &$actions ): void;
}
PK       ! P    4  Hooks/AbuseFilterGenerateVarsForRecentChangeHook.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

use MediaWiki\Extension\AbuseFilter\VariableGenerator\RCVariableGenerator;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\User\User;
use RecentChange;

interface AbuseFilterGenerateVarsForRecentChangeHook {
	/**
	 * Hook runner for the `AbuseFilterGenerateVarsForRecentChange` hook
	 *
	 * Hook that allows extensions to generate variables from a RecentChange row with a non-standard model.
	 * The hooks `AbuseFilterGenerate(Title|User|Generic)Hook` should be used for computing single variables
	 * in standard RC rows.
	 *
	 * @param RCVariableGenerator $generator
	 * @param RecentChange $rc
	 * @param VariableHolder $vars
	 * @param User $contextUser
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onAbuseFilterGenerateVarsForRecentChange(
		RCVariableGenerator $generator,
		RecentChange $rc,
		VariableHolder $vars,
		User $contextUser
	);
}
PK       ! Eȭ    '  Hooks/AbuseFilterAlterVariablesHook.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Title\Title;
use MediaWiki\User\User;

interface AbuseFilterAlterVariablesHook {
	/**
	 * Hook runner for the `AbuseFilterAlterVariables` hook
	 *
	 * Allows overwriting of abusefilter variables just before they're
	 * checked against filters. Note that you may specify custom variables in a saner way using other hooks:
	 * AbuseFilter-generateTitleVars, AbuseFilter-generateUserVars and AbuseFilter-generateGenericVars.
	 *
	 * @param VariableHolder &$vars
	 * @param Title $title Title object target of the action
	 * @param User $user User object performer of the action
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onAbuseFilterAlterVariables(
		VariableHolder &$vars,
		Title $title,
		User $user
	);
}
PK       ! Lm  m  +  Hooks/AbuseFilterShouldFilterActionHook.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Title\Title;
use MediaWiki\User\User;

interface AbuseFilterShouldFilterActionHook {
	/**
	 * Hook runner for the `AbuseFilterShouldFilterAction` hook
	 *
	 * Called before filtering an action. If the current action should not be filtered,
	 * return false and add a useful reason to $skipReasons.
	 *
	 * @param VariableHolder $vars
	 * @param Title $title Title object target of the action
	 * @param User $user User object performer of the action
	 * @param array &$skipReasons Array of reasons why the action should be skipped
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onAbuseFilterShouldFilterAction(
		VariableHolder $vars,
		Title $title,
		User $user,
		array &$skipReasons
	);
}
PK       ! `E  E  ,  Hooks/AbuseFilterDeprecatedVariablesHook.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

interface AbuseFilterDeprecatedVariablesHook {
	/**
	 * Hook runner for the `AbuseFilter-deprecatedVariables` hook
	 *
	 * Allows adding deprecated variables. If a filter uses an old variable, the parser
	 * will automatically translate it to the new one.
	 *
	 * @param array &$deprecatedVariables deprecated variables, syntax: [ 'old_name' => 'new_name' ]
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onAbuseFilter_deprecatedVariables( array &$deprecatedVariables );
}
PK       !     &  Hooks/AbuseFilterCustomActionsHook.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

// phpcs:ignore MediaWiki.Classes.UnusedUseStatement.UnusedUse
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\Consequence;
// phpcs:ignore MediaWiki.Classes.UnusedUseStatement.UnusedUse
use MediaWiki\Extension\AbuseFilter\Consequences\Parameters;

interface AbuseFilterCustomActionsHook {
	/**
	 * Hook runner for the `AbuseFilterCustomActions` hook
	 *
	 * Allows specifying custom actions. Callers should append to $actions, using the action name as (string) key,
	 * and the value should be a callable with the signature documented below.
	 *
	 * @param callable[] &$actions
	 * @phan-param array<string,callable(Parameters,array):Consequence> &$actions
	 */
	public function onAbuseFilterCustomActions( array &$actions );
}
PK       ! Tj7    (  Hooks/AbuseFilterContentToStringHook.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

use MediaWiki\Content\Content;

interface AbuseFilterContentToStringHook {
	/**
	 * Hook runner for the `AbuseFilter-contentToString` hook
	 *
	 * Called when converting a Content object to a string to which
	 * filters can be applied. If the hook function returns true, Content::getTextForSearchIndex()
	 * will be used for non-text content.
	 *
	 * @param Content $content
	 * @param ?string &$text Set this to the desired text
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onAbuseFilter_contentToString(
		Content $content,
		?string &$text
	);
}
PK       ! Qf      Hooks/AbuseFilterHookRunner.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

use MediaWiki\Content\Content;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\RCVariableGenerator;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use RecentChange;

/**
 * Handle running AbuseFilter's hooks
 * @author DannyS712
 */
class AbuseFilterHookRunner implements
	AbuseFilterAlterVariablesHook,
	AbuseFilterBuilderHook,
	AbuseFilterComputeVariableHook,
	AbuseFilterContentToStringHook,
	AbuseFilterCustomActionsHook,
	AbuseFilterDeprecatedVariablesHook,
	AbuseFilterFilterActionHook,
	AbuseFilterGenerateGenericVarsHook,
	AbuseFilterGenerateTitleVarsHook,
	AbuseFilterGenerateUserVarsHook,
	AbuseFilterGenerateVarsForRecentChangeHook,
	AbuseFilterInterceptVariableHook,
	AbuseFilterShouldFilterActionHook,
	AbuseFilterGetDangerousActionsHook
{
	public const SERVICE_NAME = 'AbuseFilterHookRunner';

	/** @var HookContainer */
	private $hookContainer;

	/**
	 * @param HookContainer $hookContainer
	 */
	public function __construct( HookContainer $hookContainer ) {
		$this->hookContainer = $hookContainer;
	}

	/**
	 * @inheritDoc
	 */
	public function onAbuseFilter_builder( array &$realValues ) {
		return $this->hookContainer->run(
			'AbuseFilter-builder',
			[ &$realValues ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onAbuseFilter_deprecatedVariables( array &$deprecatedVariables ) {
		return $this->hookContainer->run(
			'AbuseFilter-deprecatedVariables',
			[ &$deprecatedVariables ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onAbuseFilter_computeVariable(
		string $method,
		VariableHolder $vars,
		array $parameters,
		?string &$result
	) {
		return $this->hookContainer->run(
			'AbuseFilter-computeVariable',
			[ $method, $vars, $parameters, &$result ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onAbuseFilter_contentToString(
		Content $content,
		?string &$text
	) {
		return $this->hookContainer->run(
			'AbuseFilter-contentToString',
			[ $content, &$text ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onAbuseFilter_filterAction(
		VariableHolder &$vars,
		Title $title
	) {
		return $this->hookContainer->run(
			'AbuseFilter-filterAction',
			[ &$vars, $title ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onAbuseFilterAlterVariables(
		VariableHolder &$vars,
		Title $title,
		User $user
	) {
		return $this->hookContainer->run(
			'AbuseFilterAlterVariables',
			[ &$vars, $title, $user ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onAbuseFilter_generateTitleVars(
		VariableHolder $vars,
		Title $title,
		string $prefix,
		?RecentChange $rc
	) {
		return $this->hookContainer->run(
			'AbuseFilter-generateTitleVars',
			[ $vars, $title, $prefix, $rc ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onAbuseFilter_generateUserVars(
		VariableHolder $vars,
		User $user,
		?RecentChange $rc
	) {
		return $this->hookContainer->run(
			'AbuseFilter-generateUserVars',
			[ $vars, $user, $rc ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onAbuseFilter_generateGenericVars(
		VariableHolder $vars,
		?RecentChange $rc
	) {
		return $this->hookContainer->run(
			'AbuseFilter-generateGenericVars',
			[ $vars, $rc ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onAbuseFilterGenerateVarsForRecentChange(
		RCVariableGenerator $generator,
		RecentChange $rc,
		VariableHolder $vars,
		User $contextUser
	) {
		return $this->hookContainer->run(
			'AbuseFilterGenerateVarsForRecentChange',
			[ $generator, $rc, $vars, $contextUser ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onAbuseFilter_interceptVariable(
		string $method,
		VariableHolder $vars,
		array $parameters,
		&$result
	) {
		return $this->hookContainer->run(
			'AbuseFilter-interceptVariable',
			[ $method, $vars, $parameters, &$result ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onAbuseFilterShouldFilterAction(
		VariableHolder $vars,
		Title $title,
		User $user,
		array &$skipReasons
	) {
		return $this->hookContainer->run(
			'AbuseFilterShouldFilterAction',
			[ $vars, $title, $user, &$skipReasons ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onAbuseFilterGetDangerousActions( array &$actions ): void {
		$this->hookContainer->run(
			'AbuseFilterGetDangerousActions',
			[ &$actions ],
			[ 'abortable' => false ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onAbuseFilterCustomActions( array &$actions ): void {
		$this->hookContainer->run(
			'AbuseFilterCustomActions',
			[ &$actions ],
			[ 'abortable' => false ]
		);
	}
}
PK       ! Z`[I  I  %  Hooks/AbuseFilterFilterActionHook.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Title\Title;

interface AbuseFilterFilterActionHook {
	/**
	 * Hook runner for the `AbuseFilter-filterAction` hook
	 *
	 * DEPRECATED! Use AbuseFilterAlterVariables instead.
	 *
	 * Allows overwriting of abusefilter variables in FilterRunner::init just before they're
	 * checked against filters. Note that you may specify custom variables in a saner way using other hooks:
	 * AbuseFilter-generateTitleVars, AbuseFilter-generateUserVars and AbuseFilter-generateGenericVars.
	 *
	 * @param VariableHolder &$vars
	 * @param Title $title
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onAbuseFilter_filterAction(
		VariableHolder &$vars,
		Title $title
	);
}
PK       ! '4  4  *  Hooks/AbuseFilterGenerateTitleVarsHook.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Title\Title;
use RecentChange;

interface AbuseFilterGenerateTitleVarsHook {
	/**
	 * Hook runner for the `AbuseFilter-generateTitleVars` hook
	 *
	 * Allows altering the variables generated for a title
	 *
	 * @param VariableHolder $vars
	 * @param Title $title
	 * @param string $prefix Variable name prefix
	 * @param ?RecentChange $rc If the variables should be generated for an RC entry,
	 *     this is the entry. Null if it's for the current action being filtered.
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onAbuseFilter_generateTitleVars(
		VariableHolder $vars,
		Title $title,
		string $prefix,
		?RecentChange $rc
	);
}
PK       ! 4    )  Hooks/AbuseFilterGenerateUserVarsHook.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\User\User;
use RecentChange;

interface AbuseFilterGenerateUserVarsHook {
	/**
	 * Hook runner for the `AbuseFilter-generateUserVars` hook
	 *
	 * Allows altering the variables generated for a specific user
	 *
	 * @param VariableHolder $vars
	 * @param User $user
	 * @param ?RecentChange $rc If the variables should be generated for an RC entry,
	 *     this is the entry. Null if it's for the current action being filtered.
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onAbuseFilter_generateUserVars(
		VariableHolder $vars,
		User $user,
		?RecentChange $rc
	);
}
PK       ! d>T  T  (  Hooks/AbuseFilterComputeVariableHook.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;

interface AbuseFilterComputeVariableHook {
	/**
	 * Hook runner for the `AbuseFilter-computeVariable` hook
	 *
	 * Like AbuseFilter-interceptVariable but called if the requested method wasn't found.
	 * Return false to indicate that the method is known to the hook and was computed successfully.
	 *
	 * @param string $method Method to generate the variable
	 * @param VariableHolder $vars
	 * @param array $parameters Parameters with data to compute the value
	 * @param ?string &$result Result of the computation
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onAbuseFilter_computeVariable(
		string $method,
		VariableHolder $vars,
		array $parameters,
		?string &$result
	);
}
PK       ! rg6  6  *  Hooks/AbuseFilterInterceptVariableHook.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;

interface AbuseFilterInterceptVariableHook {
	/**
	 * Hook runner for the `AbuseFilter-interceptVariable` hook
	 *
	 * Called before a lazy-loaded variable is computed to be able to set
	 * it before the core code runs. Return false to make the function return right after.
	 *
	 * @param string $method Method to generate the variable
	 * @param VariableHolder $vars
	 * @param array $parameters Parameters with data to compute the value
	 * @param mixed &$result Result of the computation
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onAbuseFilter_interceptVariable(
		string $method,
		VariableHolder $vars,
		array $parameters,
		&$result
	);
}
PK       ! :.    ,  Hooks/AbuseFilterGenerateGenericVarsHook.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use RecentChange;

interface AbuseFilterGenerateGenericVarsHook {
	/**
	 * Hook runner for the `AbuseFilter-generateGenericVars` hook
	 *
	 * Allows altering generic variables, i.e. independent from page and user
	 *
	 * @param VariableHolder $vars
	 * @param ?RecentChange $rc If the variables should be generated for an RC entry,
	 *     this is the entry. Null if it's for the current action being filtered.
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onAbuseFilter_generateGenericVars(
		VariableHolder $vars,
		?RecentChange $rc
	);
}
PK       ! "Ztz       Hooks/AbuseFilterBuilderHook.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks;

interface AbuseFilterBuilderHook {
	/**
	 * Hook runner for the `AbuseFilter-builder` hook
	 *
	 * Allows overwriting of the builder values, i.e. names and descriptions of
	 * the AbuseFilter language like variables.
	 *
	 * @param array &$realValues Builder values
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onAbuseFilter_builder( array &$realValues );
}
PK       ! r    $  Hooks/Handlers/ChangeTagsHandler.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagsManager;

class ChangeTagsHandler implements
	\MediaWiki\ChangeTags\Hook\ListDefinedTagsHook,
	\MediaWiki\ChangeTags\Hook\ChangeTagsListActiveHook
{

	/** @var ChangeTagsManager */
	private $changeTagsManager;

	/**
	 * @param ChangeTagsManager $changeTagsManager
	 */
	public function __construct( ChangeTagsManager $changeTagsManager ) {
		$this->changeTagsManager = $changeTagsManager;
	}

	/**
	 * @param string[] &$tags
	 */
	public function onListDefinedTags( &$tags ) {
		$tags = array_merge(
			$tags,
			$this->changeTagsManager->getTagsDefinedByFilters(),
			[ $this->changeTagsManager->getCondsLimitTag() ]
		);
	}

	/**
	 * @param string[] &$tags
	 */
	public function onChangeTagsListActive( &$tags ) {
		$tags = array_merge(
			$tags,
			$this->changeTagsManager->getTagsDefinedByActiveFilters(),
			[ $this->changeTagsManager->getCondsLimitTag() ]
		);
	}
}
PK       !     #  Hooks/Handlers/ToolLinksHandler.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

use MediaWiki\Context\IContextSource;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseLog;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use Wikimedia\IPUtils;

class ToolLinksHandler implements
	\MediaWiki\Hook\ContributionsToolLinksHook,
	\MediaWiki\Hook\HistoryPageToolLinksHook,
	\MediaWiki\Hook\UndeletePageToolLinksHook
{

	/** @var AbuseFilterPermissionManager */
	private $afPermManager;

	/**
	 * ToolLinksHandler constructor.
	 * @param AbuseFilterPermissionManager $afPermManager
	 */
	public function __construct( AbuseFilterPermissionManager $afPermManager ) {
		$this->afPermManager = $afPermManager;
	}

	/**
	 * @param int $id
	 * @param Title $nt
	 * @param array &$tools
	 * @param SpecialPage $sp for context
	 */
	public function onContributionsToolLinks( $id, Title $nt, array &$tools, SpecialPage $sp ) {
		$username = $nt->getText();
		if ( $this->afPermManager->canViewAbuseLog( $sp->getAuthority() )
			&& !IPUtils::isValidRange( $username )
		) {
			$linkRenderer = $sp->getLinkRenderer();
			$tools['abuselog'] = $linkRenderer->makeLink(
				$this->getSpecialPageTitle(),
				$sp->msg( 'abusefilter-log-linkoncontribs' )->text(),
				[ 'title' => $sp->msg( 'abusefilter-log-linkoncontribs-text',
					$username )->text(), 'class' => 'mw-contributions-link-abuse-log' ],
				[ 'wpSearchUser' => $username ]
			);
		}
	}

	/**
	 * @param IContextSource $context
	 * @param LinkRenderer $linkRenderer
	 * @param string[] &$links
	 */
	public function onHistoryPageToolLinks( IContextSource $context, LinkRenderer $linkRenderer, array &$links ) {
		if ( $this->afPermManager->canViewAbuseLog( $context->getAuthority() ) ) {
			$links[] = $linkRenderer->makeLink(
				$this->getSpecialPageTitle(),
				$context->msg( 'abusefilter-log-linkonhistory' )->text(),
				[ 'title' => $context->msg( 'abusefilter-log-linkonhistory-text' )->text() ],
				[ 'wpSearchTitle' => $context->getTitle()->getPrefixedText() ]
			);
		}
	}

	/**
	 * @param IContextSource $context
	 * @param LinkRenderer $linkRenderer
	 * @param string[] &$links
	 */
	public function onUndeletePageToolLinks( IContextSource $context, LinkRenderer $linkRenderer, array &$links ) {
		$show = $this->afPermManager->canViewAbuseLog( $context->getAuthority() );
		$action = $context->getRequest()->getVal( 'action', 'view' );

		// For 'history action', the link would be added by HistoryPageToolLinks hook.
		if ( $show && $action !== 'history' ) {
			$links[] = $linkRenderer->makeLink(
				$this->getSpecialPageTitle(),
				$context->msg( 'abusefilter-log-linkonundelete' )->text(),
				[ 'title' => $context->msg( 'abusefilter-log-linkonundelete-text' )->text() ],
				[ 'wpSearchTitle' => $context->getTitle()->getPrefixedText() ]
			);
		}
	}

	/**
	 * @codeCoverageIgnore Helper for tests
	 * @return LinkTarget
	 */
	private function getSpecialPageTitle(): LinkTarget {
		return defined( 'MW_PHPUNIT_TEST' )
			? new TitleValue( NS_SPECIAL, SpecialAbuseLog::PAGE_NAME )
			: SpecialPage::getTitleFor( SpecialAbuseLog::PAGE_NAME );
	}
}
PK       ! gS    #  Hooks/Handlers/UserMergeHandler.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

use MediaWiki\Extension\UserMerge\Hooks\AccountFieldsHook;

class UserMergeHandler implements AccountFieldsHook {

	/**
	 * Tables that Extension:UserMerge needs to update
	 *
	 * @param array[] &$updateFields
	 */
	public function onUserMergeAccountFields( array &$updateFields ) {
		$updateFields[] = [
			'abuse_filter',
			'batchKey' => 'af_id',
			'actorId' => 'af_actor',
			'actorStage' => SCHEMA_COMPAT_NEW,
		];
		$updateFields[] = [
			'abuse_filter_log',
			'afl_user',
			'afl_user_text',
			'batchKey' => 'afl_id',
		];
		$updateFields[] = [
			'abuse_filter_history',
			'batchKey' => 'afh_id',
			'actorId' => 'afh_actor',
			'actorStage' => SCHEMA_COMPAT_NEW,
		];
	}

}
PK       ! u2ڢ      Hooks/Handlers/EchoHandler.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

use MediaWiki\Extension\AbuseFilter\EchoNotifier;
use MediaWiki\Extension\AbuseFilter\ThrottleFilterPresentationModel;
use MediaWiki\Extension\Notifications\AttributeManager;
use MediaWiki\Extension\Notifications\Hooks\BeforeCreateEchoEventHook;
use MediaWiki\Extension\Notifications\UserLocator;

class EchoHandler implements BeforeCreateEchoEventHook {

	/**
	 * @param array &$notifications
	 * @param array &$notificationCategories
	 * @param array &$icons
	 */
	public function onBeforeCreateEchoEvent(
		array &$notifications,
		array &$notificationCategories,
		array &$icons
	) {
		$notifications[ EchoNotifier::EVENT_TYPE ] = [
			'category' => 'system',
			'section' => 'alert',
			'group' => 'negative',
			'presentation-model' => ThrottleFilterPresentationModel::class,
			AttributeManager::ATTR_LOCATORS => [
				[
					[ UserLocator::class, 'locateFromEventExtra' ],
					[ 'user' ]
				]
			],
		];
	}

}
PK       ! o}    *  Hooks/Handlers/RecentChangeSaveHandler.phpnu Iw        <?php
// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagger;
use MediaWiki\Hook\RecentChange_saveHook;

class RecentChangeSaveHandler implements RecentChange_saveHook {
	/** @var ChangeTagger */
	private $changeTagger;

	/**
	 * @param ChangeTagger $changeTagger
	 */
	public function __construct( ChangeTagger $changeTagger ) {
		$this->changeTagger = $changeTagger;
	}

	/**
	 * @inheritDoc
	 */
	public function onRecentChange_save( $recentChange ) {
		$tags = $this->changeTagger->getTagsForRecentChange( $recentChange );
		if ( $tags ) {
			$recentChange->addTags( $tags );
		}
	}
}
PK       ! )    #  Hooks/Handlers/CheckUserHandler.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

use MediaWiki\CheckUser\Hook\CheckUserInsertChangesRowHook;
use MediaWiki\CheckUser\Hook\CheckUserInsertLogEventRowHook;
use MediaWiki\CheckUser\Hook\CheckUserInsertPrivateEventRowHook;
use MediaWiki\Extension\AbuseFilter\FilterUser;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityUtils;
use RecentChange;

class CheckUserHandler implements
	CheckUserInsertChangesRowHook,
	CheckUserInsertPrivateEventRowHook,
	CheckUserInsertLogEventRowHook
{

	/** @var FilterUser */
	private $filterUser;

	/** @var UserIdentityUtils */
	private $userIdentityUtils;

	/**
	 * @param FilterUser $filterUser
	 * @param UserIdentityUtils $userIdentityUtils
	 */
	public function __construct(
		FilterUser $filterUser,
		UserIdentityUtils $userIdentityUtils
	) {
		$this->filterUser = $filterUser;
		$this->userIdentityUtils = $userIdentityUtils;
	}

	/**
	 * Any edits by the filter user should always be marked as by the software
	 * using IP 127.0.0.1, no XFF and no UA.
	 *
	 * @inheritDoc
	 */
	public function onCheckUserInsertChangesRow(
		string &$ip, &$xff, array &$row, UserIdentity $user, ?RecentChange $rc
	) {
		if (
			$this->userIdentityUtils->isNamed( $user ) &&
			$this->filterUser->isSameUserAs( $user )
		) {
			$ip = '127.0.0.1';
			$xff = false;
			$row['cuc_agent'] = '';
		}
	}

	/**
	 * Any log actions by the filter user should always be marked as by the software
	 * using IP 127.0.0.1, no XFF and no UA.
	 *
	 * @inheritDoc
	 */
	public function onCheckUserInsertLogEventRow(
		string &$ip, &$xff, array &$row, UserIdentity $user, int $id, ?RecentChange $rc
	) {
		if (
			$this->userIdentityUtils->isNamed( $user ) &&
			$this->filterUser->isSameUserAs( $user )
		) {
			$ip = '127.0.0.1';
			$xff = false;
			$row['cule_agent'] = '';
		}
	}

	/**
	 * Any log actions by the filter user should always be marked as by the software
	 * using IP 127.0.0.1, no XFF and no UA.
	 *
	 * @inheritDoc
	 */
	public function onCheckUserInsertPrivateEventRow(
		string &$ip, &$xff, array &$row, UserIdentity $user, ?RecentChange $rc
	) {
		if (
			$this->userIdentityUtils->isNamed( $user ) &&
			$this->filterUser->isSameUserAs( $user )
		) {
			$ip = '127.0.0.1';
			$xff = false;
			$row['cupe_agent'] = '';
		}
	}
}
PK       ! Q+  Q+  )  Hooks/Handlers/FilteredActionsHandler.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

use MediaWiki\Api\ApiMessage;
use MediaWiki\Content\Content;
use MediaWiki\Context\IContextSource;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Extension\AbuseFilter\BlockedDomainFilter;
use MediaWiki\Extension\AbuseFilter\EditRevUpdater;
use MediaWiki\Extension\AbuseFilter\FilterRunnerFactory;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
use MediaWiki\Hook\EditFilterMergedContentHook;
use MediaWiki\Hook\TitleMoveHook;
use MediaWiki\Hook\UploadStashFileHook;
use MediaWiki\Hook\UploadVerifyUploadHook;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\Page\Hook\ArticleDeleteHook;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Status\Status;
use MediaWiki\Storage\Hook\ParserOutputStashForEditHook;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use UploadBase;
use Wikimedia\Stats\IBufferingStatsdDataFactory;
use WikiPage;

/**
 * Handler for actions that can be filtered
 */
class FilteredActionsHandler implements
	EditFilterMergedContentHook,
	TitleMoveHook,
	ArticleDeleteHook,
	UploadVerifyUploadHook,
	UploadStashFileHook,
	ParserOutputStashForEditHook
{
	/** @var IBufferingStatsdDataFactory */
	private $statsDataFactory;
	/** @var FilterRunnerFactory */
	private $filterRunnerFactory;
	/** @var VariableGeneratorFactory */
	private $variableGeneratorFactory;
	/** @var EditRevUpdater */
	private $editRevUpdater;
	private PermissionManager $permissionManager;
	private BlockedDomainFilter $blockedDomainFilter;

	/**
	 * @param IBufferingStatsdDataFactory $statsDataFactory
	 * @param FilterRunnerFactory $filterRunnerFactory
	 * @param VariableGeneratorFactory $variableGeneratorFactory
	 * @param EditRevUpdater $editRevUpdater
	 * @param BlockedDomainFilter $blockedDomainFilter
	 * @param PermissionManager $permissionManager
	 */
	public function __construct(
		IBufferingStatsdDataFactory $statsDataFactory,
		FilterRunnerFactory $filterRunnerFactory,
		VariableGeneratorFactory $variableGeneratorFactory,
		EditRevUpdater $editRevUpdater,
		BlockedDomainFilter $blockedDomainFilter,
		PermissionManager $permissionManager
	) {
		$this->statsDataFactory = $statsDataFactory;
		$this->filterRunnerFactory = $filterRunnerFactory;
		$this->variableGeneratorFactory = $variableGeneratorFactory;
		$this->editRevUpdater = $editRevUpdater;
		$this->blockedDomainFilter = $blockedDomainFilter;
		$this->permissionManager = $permissionManager;
	}

	/**
	 * @inheritDoc
	 * @param string $slot Slot role for the content, added by Wikibase (T288885)
	 */
	public function onEditFilterMergedContent(
		IContextSource $context,
		Content $content,
		Status $status,
		$summary,
		User $user,
		$minoredit,
		string $slot = SlotRecord::MAIN
	) {
		$startTime = microtime( true );
		if ( !$status->isOK() ) {
			// Investigate what happens if we skip filtering here (T211680)
			LoggerFactory::getInstance( 'AbuseFilter' )->info(
				'Status is already not OK',
				[ 'status' => (string)$status ]
			);
		}

		$this->filterEdit( $context, $user, $content, $summary, $slot, $status );

		$this->statsDataFactory->timing( 'timing.editAbuseFilter', microtime( true ) - $startTime );

		return $status->isOK();
	}

	/**
	 * Implementation for EditFilterMergedContent hook.
	 *
	 * @param IContextSource $context the context of the edit
	 * @param User $user
	 * @param Content $content the new Content generated by the edit
	 * @param string $summary Edit summary for page
	 * @param string $slot slot role for the content
	 * @param Status $status
	 */
	private function filterEdit(
		IContextSource $context,
		User $user,
		Content $content,
		string $summary,
		string $slot,
		Status $status
	): void {
		$this->editRevUpdater->clearLastEditPage();

		$title = $context->getTitle();
		$logger = LoggerFactory::getInstance( 'AbuseFilter' );
		if ( $title === null ) {
			// T144265: This *should* never happen.
			$logger->warning( __METHOD__ . ' received a null title.' );
			return;
		}
		if ( !$title->canExist() ) {
			// This also should be handled in EditPage or whoever is calling the hook.
			$logger->warning( __METHOD__ . ' received a Title that cannot exist.' );
			// Note that if the title cannot exist, there's no much point in filtering the edit anyway
			return;
		}

		$page = $context->getWikiPage();

		$builder = $this->variableGeneratorFactory->newRunGenerator( $user, $title );
		$vars = $builder->getEditVars( $content, $summary, $slot, $page );
		if ( $vars === null ) {
			// We don't have to filter the edit
			return;
		}
		$runner = $this->filterRunnerFactory->newRunner( $user, $title, $vars, 'default' );
		$filterResult = $runner->run();
		if ( !$filterResult->isOK() ) {
			// Produce a useful error message for API edits
			$filterResultApi = self::getApiStatus( $filterResult );
			$status->merge( $filterResultApi );
			return;
		}

		$this->editRevUpdater->setLastEditPage( $page );

		if ( $this->permissionManager->userHasRight( $user, 'abusefilter-bypass-blocked-external-domains' ) ) {
			return;
		}
		$blockedDomainFilterResult = $this->blockedDomainFilter->filter( $vars, $user, $title );
		if ( !$blockedDomainFilterResult->isOK() ) {
			$status->merge( $blockedDomainFilterResult );
		}
	}

	/**
	 * @param Status $status Error message details
	 * @return Status Status containing the same error messages with extra data for the API
	 */
	private static function getApiStatus( Status $status ): Status {
		$allActionsTaken = $status->getValue();
		$statusForApi = Status::newGood();

		foreach ( $status->getMessages() as $msg ) {
			[ $filterDescription, $filter ] = $msg->getParams();
			$actionsTaken = $allActionsTaken[ $filter ];

			$code = ( $actionsTaken === [ 'warn' ] ) ? 'abusefilter-warning' : 'abusefilter-disallowed';
			$data = [
				'abusefilter' => [
					'id' => $filter,
					'description' => $filterDescription,
					'actions' => $actionsTaken,
				],
			];

			$message = ApiMessage::create( $msg, $code, $data );
			$statusForApi->fatal( $message );
		}

		return $statusForApi;
	}

	/**
	 * @inheritDoc
	 */
	public function onTitleMove( Title $old, Title $nt, User $user, $reason, Status &$status ) {
		$builder = $this->variableGeneratorFactory->newRunGenerator( $user, $old );
		$vars = $builder->getMoveVars( $nt, $reason );
		$runner = $this->filterRunnerFactory->newRunner( $user, $old, $vars, 'default' );
		$result = $runner->run();
		$status->merge( $result );
	}

	/**
	 * @inheritDoc
	 */
	public function onArticleDelete( WikiPage $wikiPage, User $user, &$reason, &$error, Status &$status, $suppress ) {
		if ( $suppress ) {
			// Don't filter suppressions, T71617
			return true;
		}
		$builder = $this->variableGeneratorFactory->newRunGenerator( $user, $wikiPage->getTitle() );
		$vars = $builder->getDeleteVars( $reason );
		$runner = $this->filterRunnerFactory->newRunner( $user, $wikiPage->getTitle(), $vars, 'default' );
		$filterResult = $runner->run();

		$status->merge( $filterResult );
		$error = $filterResult->isOK() ? '' : $filterResult->getHTML();

		return $filterResult->isOK();
	}

	/**
	 * @inheritDoc
	 */
	public function onUploadVerifyUpload(
		UploadBase $upload,
		User $user,
		?array $props,
		$comment,
		$pageText,
		&$error
	) {
		return $this->filterUpload( 'upload', $upload, $user, $props, $comment, $pageText, $error );
	}

	/**
	 * Filter an upload to stash. If a filter doesn't need to check the page contents or
	 * upload comment, it can use `action='stashupload'` to provide better experience to e.g.
	 * UploadWizard (rejecting files immediately, rather than after the user adds the details).
	 *
	 * @inheritDoc
	 */
	public function onUploadStashFile( UploadBase $upload, User $user, ?array $props, &$error ) {
		return $this->filterUpload( 'stashupload', $upload, $user, $props, null, null, $error );
	}

	/**
	 * Implementation for UploadStashFile and UploadVerifyUpload hooks.
	 *
	 * @param string $action 'upload' or 'stashupload'
	 * @param UploadBase $upload
	 * @param User $user User performing the action
	 * @param array|null $props File properties, as returned by MWFileProps::getPropsFromPath().
	 * @param string|null $summary Upload log comment (also used as edit summary)
	 * @param string|null $text File description page text (only used for new uploads)
	 * @param array|ApiMessage &$error
	 * @return bool
	 */
	private function filterUpload(
		string $action,
		UploadBase $upload,
		User $user,
		?array $props,
		?string $summary,
		?string $text,
		&$error
	): bool {
		$title = $upload->getTitle();
		if ( $title === null ) {
			// T144265: This could happen for 'stashupload' if the specified title is invalid.
			// Let UploadBase warn the user about that, and we'll filter later.
			$logger = LoggerFactory::getInstance( 'AbuseFilter' );
			$logger->warning( __METHOD__ . " received a null title. Action: $action." );
			return true;
		}

		$builder = $this->variableGeneratorFactory->newRunGenerator( $user, $title );
		$vars = $builder->getUploadVars( $action, $upload, $summary, $text, $props );
		if ( $vars === null ) {
			return true;
		}
		$runner = $this->filterRunnerFactory->newRunner( $user, $title, $vars, 'default' );
		$filterResult = $runner->run();

		if ( !$filterResult->isOK() ) {
			// Produce a useful error message for API edits
			$filterResultApi = self::getApiStatus( $filterResult );
			// @todo Return all errors instead of only the first one
			$error = $filterResultApi->getMessages()[0];
		} else {
			if ( $this->permissionManager->userHasRight( $user, 'abusefilter-bypass-blocked-external-domains' ) ) {
				return true;
			}
			$blockedDomainFilterResult = $this->blockedDomainFilter->filter( $vars, $user, $title );
			if ( !$blockedDomainFilterResult->isOK() ) {
				$error = $blockedDomainFilterResult->getMessages()[0];
				return $blockedDomainFilterResult->isOK();
			}
		}

		return $filterResult->isOK();
	}

	/**
	 * @inheritDoc
	 */
	public function onParserOutputStashForEdit( $page, $content, $output, $summary, $user ) {
		// XXX: This makes the assumption that this method is only ever called for the main slot.
		// Which right now holds true, but any more fancy MCR stuff will likely break here...
		$slot = SlotRecord::MAIN;

		// Cache any resulting filter matches.
		// Do this outside the synchronous stash lock to avoid any chance of slowdown.
		DeferredUpdates::addCallableUpdate(
			function () use (
				$user,
				$page,
				$summary,
				$content,
				$slot
			) {
				$startTime = microtime( true );
				$generator = $this->variableGeneratorFactory->newRunGenerator( $user, $page->getTitle() );
				$vars = $generator->getStashEditVars( $content, $summary, $slot, $page );
				if ( !$vars ) {
					return;
				}
				$runner = $this->filterRunnerFactory->newRunner( $user, $page->getTitle(), $vars, 'default' );
				$runner->runForStash();
				$totalTime = microtime( true ) - $startTime;
				$this->statsDataFactory->timing( 'timing.stashAbuseFilter', $totalTime );
			},
			DeferredUpdates::PRESEND
		);
	}
}
PK       ! 	    %  Hooks/Handlers/PreferencesHandler.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

use MediaWiki\Extension\AbuseFilter\AbuseLoggerFactory;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Preferences\Hook\GetPreferencesHook;
use MediaWiki\User\Options\Hook\SaveUserOptionsHook;
use MediaWiki\User\UserIdentity;

class PreferencesHandler implements GetPreferencesHook, SaveUserOptionsHook {
	private PermissionManager $permissionManager;

	private AbuseLoggerFactory $abuseLoggerFactory;

	public function __construct(
		PermissionManager $permissionManager,
		AbuseLoggerFactory $abuseLoggerFactory
	) {
		$this->permissionManager = $permissionManager;
		$this->abuseLoggerFactory = $abuseLoggerFactory;
	}

	/** @inheritDoc */
	public function onGetPreferences( $user, &$preferences ): void {
		if ( !$this->permissionManager->userHasRight( $user, 'abusefilter-access-protected-vars' ) ) {
			return;
		}

		$preferences['abusefilter-protected-vars-view-agreement'] = [
			'type' => 'toggle',
			'label-message' => 'abusefilter-preference-protected-vars-view-agreement',
			'section' => 'personal/abusefilter',
			'noglobal' => true,
		];
	}

	/**
	 * @param UserIdentity $user
	 * @param array &$modifiedOptions
	 * @param array $originalOptions
	 */
	public function onSaveUserOptions( UserIdentity $user, array &$modifiedOptions, array $originalOptions ) {
		$wasEnabled = !empty( $originalOptions['abusefilter-protected-vars-view-agreement'] );
		$wasDisabled = !$wasEnabled;

		$willEnable = !empty( $modifiedOptions['abusefilter-protected-vars-view-agreement'] );
		$willDisable = isset( $modifiedOptions['abusefilter-protected-vars-view-agreement'] ) &&
			!$modifiedOptions['abusefilter-protected-vars-view-agreement'];

		if (
			( $wasEnabled && $willDisable ) ||
			( $wasDisabled && $willEnable )
		) {
			$logger = $this->abuseLoggerFactory->getProtectedVarsAccessLogger();
			if ( $willEnable ) {
				$logger->logAccessEnabled( $user );
			} else {
				$logger->logAccessDisabled( $user );
			}
		}
	}
}
PK       ! /      +  Hooks/Handlers/AutoPromoteGroupsHandler.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

use MediaWiki\Extension\AbuseFilter\BlockAutopromoteStore;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
use MediaWiki\User\Hook\GetAutoPromoteGroupsHook;
use MediaWiki\User\UserIdentity;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;

class AutoPromoteGroupsHandler implements GetAutoPromoteGroupsHook {

	/** @var BagOStuff */
	private $cache;

	/** @var ConsequencesRegistry */
	private $consequencesRegistry;

	/** @var BlockAutopromoteStore */
	private $blockAutopromoteStore;

	/**
	 * @param ConsequencesRegistry $consequencesRegistry
	 * @param BlockAutopromoteStore $blockAutopromoteStore
	 * @param BagOStuff|null $cache
	 */
	public function __construct(
		ConsequencesRegistry $consequencesRegistry,
		BlockAutopromoteStore $blockAutopromoteStore,
		?BagOStuff $cache = null
	) {
		$this->cache = $cache ?? new HashBagOStuff();
		$this->consequencesRegistry = $consequencesRegistry;
		$this->blockAutopromoteStore = $blockAutopromoteStore;
	}

	/**
	 * @param UserIdentity $user
	 * @param string[] &$promote
	 */
	public function onGetAutoPromoteGroups( $user, &$promote ): void {
		if (
			in_array( 'blockautopromote', $this->consequencesRegistry->getAllEnabledActionNames() )
			&& $promote
		) {
			// Proxy the blockautopromote data to a faster backend, using an appropriate key
			$quickCacheKey = $this->cache->makeKey(
				'abusefilter',
				'blockautopromote',
				'quick',
				$user->getId()
			);
			$blocked = (bool)$this->cache->getWithSetCallback(
				$quickCacheKey,
				BagOStuff::TTL_PROC_LONG,
				function () use ( $user ) {
					return $this->blockAutopromoteStore->getAutoPromoteBlockStatus( $user );
				}
			);

			if ( $blocked ) {
				$promote = [];
			}
		}
	}
}
PK       ! 0Er    %  Hooks/Handlers/ConfirmEditHandler.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

use MediaWiki\Content\Content;
use MediaWiki\Context\IContextSource;
use MediaWiki\Extension\ConfirmEdit\Hooks;
use MediaWiki\Hook\EditFilterMergedContentHook;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\Status\Status;
use MediaWiki\User\User;

/**
 * Integration with Extension:ConfirmEdit, if loaded.
 */
class ConfirmEditHandler implements EditFilterMergedContentHook {

	/** @inheritDoc */
	public function onEditFilterMergedContent(
		IContextSource $context, Content $content, Status $status, $summary, User $user, $minoredit
	) {
		if ( !ExtensionRegistry::getInstance()->isLoaded( 'ConfirmEdit' ) ) {
			return true;
		}
		$simpleCaptcha = Hooks::getInstance();
		// In WMF production, AbuseFilter is loaded after ConfirmEdit. That means,
		// Extension:ConfirmEdit's EditFilterMergedContent hook has already run, and that hook
		// is responsible for deciding whether to show a CAPTCHA via the SimpleCaptcha::confirmEditMerged
		// method.
		// Here, we look to see if:
		// 1. CaptchaConsequence in AbuseFilter modified the global SimpleCaptcha instance to say that
		//    we should force showing a Captcha
		// 2. that the Captcha hasn't yet been solved
		// 3. ConfirmEdit's EditFilterMergedContent handler has already run (ConfirmEdit was loaded
		//    ahead of AbuseFilter via wfLoadExtension())
		// If all conditions are true, we invoke SimpleCaptcha's ConfirmEditMerged method, which
		// will run in a narrower scope (not invoking ConfirmEdit's onConfirmEditTriggersCaptcha hook,
		// for example), and will just make sure that the status is modified to present a CAPTCHA to
		// the user.
		if ( $simpleCaptcha->shouldForceShowCaptcha() &&
			!$simpleCaptcha->isCaptchaSolved() &&
			$simpleCaptcha->editFilterMergedContentHandlerAlreadyInvoked() ) {
			return $simpleCaptcha->confirmEditMerged(
				$context,
				$content,
				$status,
				$summary,
				$user,
				$minoredit
			);
		}
		return true;
	}

}
PK       ! n  n  "  Hooks/Handlers/PageSaveHandler.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

use MediaWiki\Extension\AbuseFilter\EditRevUpdater;
use MediaWiki\Storage\Hook\PageSaveCompleteHook;

class PageSaveHandler implements PageSaveCompleteHook {
	/** @var EditRevUpdater */
	private $revUpdater;

	/**
	 * @param EditRevUpdater $revUpdater
	 */
	public function __construct( EditRevUpdater $revUpdater ) {
		$this->revUpdater = $revUpdater;
	}

	/**
	 * @inheritDoc
	 */
	public function onPageSaveComplete( $wikiPage, $user, $summary, $flags, $revisionRecord, $editResult ) {
		$this->revUpdater->updateRev( $wikiPage, $revisionRecord );
	}
}
PK       ! ʞ2Դ    '  Hooks/Handlers/SchemaChangesHandler.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

use MediaWiki\Context\RequestContext;
use MediaWiki\Extension\AbuseFilter\Maintenance\MigrateActorsAF;
use MediaWiki\Extension\AbuseFilter\Maintenance\UpdateVarDumps;
use MediaWiki\Installer\DatabaseUpdater;
use MediaWiki\Installer\Hook\LoadExtensionSchemaUpdatesHook;
use MediaWiki\MediaWikiServices;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserGroupManager;
use MessageLocalizer;

class SchemaChangesHandler implements LoadExtensionSchemaUpdatesHook {
	/** @var MessageLocalizer */
	private $messageLocalizer;
	/** @var UserGroupManager */
	private $userGroupManager;
	/** @var UserFactory */
	private $userFactory;

	/**
	 * @param MessageLocalizer $messageLocalizer
	 * @param UserGroupManager $userGroupManager
	 * @param UserFactory $userFactory
	 */
	public function __construct(
		MessageLocalizer $messageLocalizer,
		UserGroupManager $userGroupManager,
		UserFactory $userFactory
	) {
		$this->messageLocalizer = $messageLocalizer;
		$this->userGroupManager = $userGroupManager;
		$this->userFactory = $userFactory;
	}

	/**
	 * @note The hook doesn't allow injecting services!
	 * @codeCoverageIgnore
	 * @return self
	 */
	public static function newFromGlobalState(): self {
		return new self(
			// @todo Use a proper MessageLocalizer once available (T247127)
			RequestContext::getMain(),
			MediaWikiServices::getInstance()->getUserGroupManager(),
			MediaWikiServices::getInstance()->getUserFactory()
		);
	}

	/**
	 * @codeCoverageIgnore This is tested by installing or updating MediaWiki
	 * @param DatabaseUpdater $updater
	 */
	public function onLoadExtensionSchemaUpdates( $updater ) {
		$dbType = $updater->getDB()->getType();
		$dir = __DIR__ . "/../../../db_patches";

		$updater->addExtensionTable(
			'abuse_filter',
			"$dir/$dbType/tables-generated.sql"
		);

		if ( $dbType === 'mysql' || $dbType === 'sqlite' ) {
			if ( $dbType === 'mysql' ) {
				// 1.37
				$updater->renameExtensionIndex(
					'abuse_filter_log',
					'ip_timestamp',
					'afl_ip_timestamp',
					"$dir/mysql/patch-rename-indexes.sql",
					true
				);

				// 1.38
				// This one has its own files because apparently, sometimes this particular index can already
				// have the correct name (T291725)
				$updater->renameExtensionIndex(
					'abuse_filter_log',
					'wiki_timestamp',
					'afl_wiki_timestamp',
					"$dir/mysql/patch-rename-wiki-timestamp-index.sql",
					true
				);

				// 1.38
				// This one is also separate to avoid interferences with the afl_filter field removal below.
				$updater->renameExtensionIndex(
					'abuse_filter_log',
					'filter_timestamp',
					'afl_filter_timestamp',
					"$dir/mysql/patch-rename-filter_timestamp-index.sql",
					true
				);
			}
			// 1.38
			$updater->dropExtensionField(
				'abuse_filter_log',
				'afl_filter',
				"$dir/$dbType/patch-remove-afl_filter.sql"
			);
		} elseif ( $dbType === 'postgres' ) {
			$updater->addExtensionUpdate( [
				'dropPgIndex', 'abuse_filter_log', 'abuse_filter_log_timestamp'
			] );
			$updater->addExtensionUpdate( [
				'dropPgField', 'abuse_filter_log', 'afl_filter'
			] );
			$updater->addExtensionUpdate( [
				'dropDefault', 'abuse_filter_log', 'afl_filter_id'
			] );
			$updater->addExtensionUpdate( [
				'dropDefault', 'abuse_filter_log', 'afl_global'
			] );
			$updater->addExtensionUpdate( [
				'renameIndex', 'abuse_filter', 'abuse_filter_user', 'af_user'
			] );
			$updater->addExtensionUpdate( [
				'renameIndex', 'abuse_filter', 'abuse_filter_group_enabled_id', 'af_group_enabled'
			] );
			$updater->addExtensionUpdate( [
				'renameIndex', 'abuse_filter_action', 'abuse_filter_action_consequence', 'afa_consequence'
			] );
			$updater->addExtensionUpdate( [
				'renameIndex', 'abuse_filter_log', 'abuse_filter_log_filter_timestamp_full', 'afl_filter_timestamp_full'
			] );
			$updater->addExtensionUpdate( [
				'renameIndex', 'abuse_filter_log', 'abuse_filter_log_user_timestamp', 'afl_user_timestamp'
			] );
			$updater->addExtensionUpdate( [
				'renameIndex', 'abuse_filter_log', 'abuse_filter_log_timestamp', 'afl_timestamp'
			] );
			$updater->addExtensionUpdate( [
				'renameIndex', 'abuse_filter_log', 'abuse_filter_log_page_timestamp', 'afl_page_timestamp'
			] );
			$updater->addExtensionUpdate( [
				'renameIndex', 'abuse_filter_log', 'abuse_filter_log_ip_timestamp', 'afl_ip_timestamp'
			] );
			$updater->addExtensionUpdate( [
				'renameIndex', 'abuse_filter_log', 'abuse_filter_log_rev_id', 'afl_rev_id'
			] );
			$updater->addExtensionUpdate( [
				'renameIndex', 'abuse_filter_log', 'abuse_filter_log_wiki_timestamp', 'afl_wiki_timestamp'
			] );
			$updater->addExtensionUpdate( [
				'renameIndex', 'abuse_filter_history', 'abuse_filter_history_filter', 'afh_filter'
			] );
			$updater->addExtensionUpdate( [
				'renameIndex', 'abuse_filter_history', 'abuse_filter_history_user', 'afh_user'
			] );
			$updater->addExtensionUpdate( [
				'renameIndex', 'abuse_filter_history', 'abuse_filter_history_user_text', 'afh_user_text'
			] );
			$updater->addExtensionUpdate( [
				'renameIndex', 'abuse_filter_history', 'abuse_filter_history_timestamp', 'afh_timestamp'
			] );
			$updater->addExtensionUpdate( [
				'changeNullableField', ' abuse_filter_history', 'afh_public_comments', 'NULL', true
			] );
			$updater->addExtensionUpdate( [
				'changeNullableField', ' abuse_filter_history', 'afh_actions', 'NULL', true
			] );
		}

		// 1.41
		$updater->addExtensionUpdate( [
			'addField', 'abuse_filter', 'af_actor',
			"$dir/$dbType/patch-add-af_actor.sql", true
		] );

		// 1.41
		$updater->addExtensionUpdate( [
			'addField', 'abuse_filter_history', 'afh_actor',
			"$dir/$dbType/patch-add-afh_actor.sql", true
		] );

		// 1.43
		$updater->addExtensionUpdate( [
			'runMaintenance',
			MigrateActorsAF::class,
		] );

		// 1.43
		$updater->addExtensionUpdate( [
			'dropField', 'abuse_filter', 'af_user',
			"$dir/$dbType/patch-drop-af_user.sql", true
		] );

		// 1.43
		$updater->addExtensionUpdate( [
			'dropField', 'abuse_filter_history', 'afh_user',
			"$dir/$dbType/patch-drop-afh_user.sql", true
		] );

		$updater->addExtensionUpdate( [ [ $this, 'createAbuseFilterUser' ] ] );
		// 1.35
		$updater->addPostDatabaseUpdateMaintenance( UpdateVarDumps::class );
	}

	/**
	 * Updater callback to create the AbuseFilter user after the user tables have been updated.
	 * @param DatabaseUpdater $updater
	 * @return bool
	 */
	public function createAbuseFilterUser( DatabaseUpdater $updater ): bool {
		$username = $this->messageLocalizer->msg( 'abusefilter-blocker' )->inContentLanguage()->text();
		$user = $this->userFactory->newFromName( $username );

		if ( $user && !$updater->updateRowExists( 'create abusefilter-blocker-user' ) ) {
			$user = User::newSystemUser( $username, [ 'steal' => true ] );
			$updater->insertUpdateRow( 'create abusefilter-blocker-user' );
			// Promote user so it doesn't look too crazy.
			$this->userGroupManager->addUserToGroup( $user, 'sysop' );
			return true;
		}
		return false;
	}
}
PK       ! 崄    '  Hooks/Handlers/RegistrationCallback.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

/**
 * This class runs a callback when the extension is registered, right after configuration has been
 * loaded (not really a hook, but almost).
 * @codeCoverageIgnore Mainly deprecation warnings and other things that can be tested by running the updater
 */
class RegistrationCallback {

	public static function onRegistration(): void {
		global $wgAbuseFilterProfile,
			$wgAbuseFilterProfiling, $wgAbuseFilterPrivateLog, $wgAbuseFilterForceSummary,
			$wgGroupPermissions, $wgAbuseFilterRestrictions, $wgAbuseFilterDisallowGlobalLocalBlocks,
			$wgAbuseFilterActionRestrictions, $wgAbuseFilterLocallyDisabledGlobalActions;

		// @todo Remove this in a future release (added in 1.33)
		if ( isset( $wgAbuseFilterProfile ) || isset( $wgAbuseFilterProfiling ) ) {
			wfWarn( '$wgAbuseFilterProfile and $wgAbuseFilterProfiling have been removed and ' .
				'profiling is now enabled by default.' );
		}

		if ( isset( $wgAbuseFilterPrivateLog ) ) {
			global $wgAbuseFilterLogPrivateDetailsAccess;
			$wgAbuseFilterLogPrivateDetailsAccess = $wgAbuseFilterPrivateLog;
			wfWarn( '$wgAbuseFilterPrivateLog has been renamed to $wgAbuseFilterLogPrivateDetailsAccess. ' .
				'Please make the change in your settings; the format is identical.'
			);
		}
		if ( isset( $wgAbuseFilterForceSummary ) ) {
			global $wgAbuseFilterPrivateDetailsForceReason;
			$wgAbuseFilterPrivateDetailsForceReason = $wgAbuseFilterForceSummary;
			wfWarn( '$wgAbuseFilterForceSummary has been renamed to ' .
				'$wgAbuseFilterPrivateDetailsForceReason. Please make the change in your settings; ' .
				'the format is identical.'
			);
		}

		$found = false;
		foreach ( $wgGroupPermissions as &$perms ) {
			if ( array_key_exists( 'abusefilter-private', $perms ) ) {
				$perms['abusefilter-privatedetails'] = $perms[ 'abusefilter-private' ];
				unset( $perms[ 'abusefilter-private' ] );
				$found = true;
			}
			if ( array_key_exists( 'abusefilter-private-log', $perms ) ) {
				$perms['abusefilter-privatedetails-log'] = $perms[ 'abusefilter-private-log' ];
				unset( $perms[ 'abusefilter-private-log' ] );
				$found = true;
			}
		}
		unset( $perms );

		if ( $found ) {
			wfWarn( 'The group permissions "abusefilter-private-log" and "abusefilter-private" have ' .
				'been renamed, respectively, to "abusefilter-privatedetails-log" and ' .
				'"abusefilter-privatedetails". Please update the names in your settings.'
			);
		}

		// @todo Remove this in a future release (added in 1.36)
		if ( isset( $wgAbuseFilterDisallowGlobalLocalBlocks ) ) {
			wfWarn( '$wgAbuseFilterDisallowGlobalLocalBlocks has been removed and replaced by ' .
				'$wgAbuseFilterLocallyDisabledGlobalActions. You can now specify which actions to disable. ' .
				'If you had set the former to true, you should set to true all of the actions in ' .
				'$wgAbuseFilterRestrictions (if you were manually setting the variable) or ' .
				'ConsequencesRegistry::DANGEROUS_ACTIONS. ' .
				'If you had set it to false (or left the default), just remove it from your wiki settings.'
			);
			if ( $wgAbuseFilterDisallowGlobalLocalBlocks === true ) {
				$wgAbuseFilterLocallyDisabledGlobalActions = [
					'throttle' => false,
					'warn' => false,
					'disallow' => false,
					'blockautopromote' => true,
					'block' => true,
					'rangeblock' => true,
					'degroup' => true,
					'tag' => false
				];
			}
		}

		// @todo Remove this in a future release (added in 1.36)
		if ( isset( $wgAbuseFilterRestrictions ) ) {
			wfWarn( '$wgAbuseFilterRestrictions has been renamed to $wgAbuseFilterActionRestrictions.' );
			$wgAbuseFilterActionRestrictions = $wgAbuseFilterRestrictions;
		}
	}

}
PK       ! H    (  Hooks/Handlers/EditPermissionHandler.phpnu Iw        <?php
// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

namespace MediaWiki\Extension\AbuseFilter\Hooks\Handlers;

use MediaWiki\Content\Hook\JsonValidateSaveHook;
use MediaWiki\Content\JsonContent;
use MediaWiki\Extension\AbuseFilter\BlockedDomainStorage;
use MediaWiki\MediaWikiServices;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Permissions\Hook\GetUserPermissionsErrorsHook;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\User;
use StatusValue;
use Wikimedia\Message\MessageSpecifier;

/**
 * This hook handler is for very simple checks, rather than the much more advanced ones
 * undertaken by the FilteredActionsHandler.
 */
class EditPermissionHandler implements GetUserPermissionsErrorsHook, JsonValidateSaveHook {

	/** @var string[] */
	private const JSON_OBJECT_FIELDS = [
		'domain',
		'notes'
	];

	private const JSON_OPTIONAL_FIELDS = [ 'addedBy' ];

	/**
	 * @see https://www.mediawiki.org/wiki/Manual:Hooks/getUserPermissionsErrors
	 *
	 * @param Title $title
	 * @param User $user
	 * @param string $action
	 * @param array|string|MessageSpecifier &$result
	 * @return bool|void
	 */
	public function onGetUserPermissionsErrors( $title, $user, $action, &$result ) {
		$services = MediaWikiServices::getInstance();

		// Only do anything if we're enabled on this wiki.
		if ( !$services->getMainConfig()->get( 'AbuseFilterEnableBlockedExternalDomain' ) ) {
			return;
		}

		// Ignore all actions and pages except MediaWiki: edits (and creates)
		// to the page we care about
		if (
			!( $action == 'create' || $action == 'edit' ) ||
			!$title->inNamespace( NS_MEDIAWIKI ) ||
			$title->getDBkey() !== BlockedDomainStorage::TARGET_PAGE
		) {
			return;
		}

		if ( $services->getPermissionManager()->userHasRight( $user, 'editinterface' ) ) {
			return;
		}

		// Prohibit direct actions on our page.
		$result = [ 'abusefilter-blocked-domains-cannot-edit-directly', BlockedDomainStorage::TARGET_PAGE ];
		return false;
	}

	/**
	 * @param JsonContent $content
	 * @param PageIdentity $pageIdentity
	 * @param StatusValue $status
	 * @return bool|void
	 */
	public function onJsonValidateSave( JsonContent $content, PageIdentity $pageIdentity, StatusValue $status ) {
		$services = MediaWikiServices::getInstance();

		// Only do anything if we're enabled on this wiki.
		if ( !$services->getMainConfig()->get( 'AbuseFilterEnableBlockedExternalDomain' ) ) {
			return;
		}

		$title = TitleValue::newFromPage( $pageIdentity );
		if ( !$title->inNamespace( NS_MEDIAWIKI ) || $title->getText() !== BlockedDomainStorage::TARGET_PAGE ) {
			return;
		}
		$data = $content->getData()->getValue();

		if ( !is_array( $data ) ) {
			$status->fatal( 'abusefilter-blocked-domains-json-error' );
			return;
		}

		$isValid = true;
		$entryNumber = 0;
		foreach ( $data as $element ) {
			$entryNumber++;
			// Check if each element is an object with all known fields, allow optional fields but no other fields
			if ( is_object( $element ) && count( get_object_vars( $element ) ) >= count( self::JSON_OBJECT_FIELDS ) ) {
				foreach ( self::JSON_OBJECT_FIELDS as $field ) {
					if ( !property_exists( $element, $field ) || !is_string( $element->{$field} ) ) {
						$isValid = false;
						break 2;
					}
				}

				foreach ( self::JSON_OPTIONAL_FIELDS as $field ) {
					if ( property_exists( $element, $field ) && !is_string( $element->{$field} ) ) {
						$isValid = false;
						break 2;
					}
				}
				foreach ( $element as $field => $value ) {
					if (
						!in_array( $field, array_merge( self::JSON_OPTIONAL_FIELDS, self::JSON_OBJECT_FIELDS ) )
					) {
						$isValid = false;
						break;
					}
				}
			} else {
				$isValid = false;
				break;
			}
		}

		if ( !$isValid ) {
			$status->fatal( 'abusefilter-blocked-domains-invalid-entry', $entryNumber );
		}
	}

}
PK       ! nVA  A    FilterLookup.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Extension\AbuseFilter\Filter\ClosestFilterVersionNotFoundException;
use MediaWiki\Extension\AbuseFilter\Filter\ExistingFilter;
use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException;
use MediaWiki\Extension\AbuseFilter\Filter\FilterVersionNotFoundException;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Extension\AbuseFilter\Filter\HistoryFilter;
use MediaWiki\Extension\AbuseFilter\Filter\LastEditInfo;
use MediaWiki\Extension\AbuseFilter\Filter\Specs;
use RuntimeException;
use stdClass;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Rdbms\IDBAccessObject;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Rdbms\IReadableDatabase;
use Wikimedia\Rdbms\SelectQueryBuilder;

/**
 * This class provides read access to the filters stored in the database.
 *
 * @todo Cache exceptions
 */
class FilterLookup implements IDBAccessObject {
	public const SERVICE_NAME = 'AbuseFilterFilterLookup';

	// Used in getClosestVersion
	public const DIR_PREV = 'prev';
	public const DIR_NEXT = 'next';

	/**
	 * @var ExistingFilter[] Individual filters cache. Keys can be integer IDs, or global names
	 */
	private $cache = [];

	/**
	 * @var ExistingFilter[][][] Cache of all active filters in each group. This is not related to
	 * the individual cache, and is replicated in WAN cache. The structure is
	 * [ local|global => [ group => [ ID => filter ] ] ]
	 * where the cache for each group has the same format as $this->cache
	 * Note that the keys are also in the form 'global-ID' for filters in 'global', although redundant.
	 */
	private $groupCache = [ 'local' => [], 'global' => [] ];

	/** @var HistoryFilter[] */
	private $historyCache = [];

	/** @var int[] */
	private $firstVersionCache = [];

	/** @var int[] */
	private $lastVersionCache = [];

	/**
	 * @var int[][] [ filter => [ historyID => [ prev, next ] ] ]
	 * @phan-var array<int,array<int,array{prev?:int,next?:int}>>
	 */
	private $closestVersionsCache = [];

	/** @var ILoadBalancer */
	private $loadBalancer;

	/** @var WANObjectCache */
	private $wanCache;

	/** @var CentralDBManager */
	private $centralDBManager;

	/**
	 * @var bool Flag used in PHPUnit tests to "hide" local filters when testing global ones, so that we can use the
	 * local database pretending it's not local.
	 */
	private bool $localFiltersHiddenForTest = false;

	/**
	 * @param ILoadBalancer $loadBalancer
	 * @param WANObjectCache $cache
	 * @param CentralDBManager $centralDBManager
	 */
	public function __construct(
		ILoadBalancer $loadBalancer,
		WANObjectCache $cache,
		CentralDBManager $centralDBManager
	) {
		$this->loadBalancer = $loadBalancer;
		$this->wanCache = $cache;
		$this->centralDBManager = $centralDBManager;
	}

	/**
	 * @param int $filterID
	 * @param bool $global
	 * @param int $flags One of the IDBAccessObject::READ_* constants
	 * @return ExistingFilter
	 * @throws FilterNotFoundException if the filter doesn't exist
	 * @throws CentralDBNotAvailableException
	 */
	public function getFilter(
		int $filterID, bool $global, int $flags = IDBAccessObject::READ_NORMAL
	): ExistingFilter {
		$cacheKey = $this->getCacheKey( $filterID, $global );
		if ( $flags !== IDBAccessObject::READ_NORMAL || !isset( $this->cache[$cacheKey] ) ) {
			$dbr = ( $flags & IDBAccessObject::READ_LATEST )
				? $this->getDBConnection( DB_PRIMARY, $global )
				: $this->getDBConnection( DB_REPLICA, $global );
			$row = $this->getAbuseFilterQueryBuilder( $dbr )
				->where( [ 'af_id' => $filterID ] )
				->recency( $flags )
				->caller( __METHOD__ )->fetchRow();

			if ( !$row ) {
				throw new FilterNotFoundException( $filterID, $global );
			}
			$fname = __METHOD__;
			$getActionsCB = function () use ( $dbr, $fname, $row ): array {
				return $this->getActionsFromDB( $dbr, $fname, $row->af_id );
			};
			$this->cache[$cacheKey] = $this->filterFromRow( $row, $getActionsCB );
		}

		return $this->cache[$cacheKey];
	}

	/**
	 * Get all filters that are active (and not deleted) and in the given group
	 * @param string $group
	 * @param bool $global
	 * @param int $flags
	 * @return ExistingFilter[]
	 * @throws CentralDBNotAvailableException
	 */
	public function getAllActiveFiltersInGroup(
		string $group, bool $global, int $flags = IDBAccessObject::READ_NORMAL
	): array {
		$domainKey = $global ? 'global' : 'local';
		if ( $flags !== IDBAccessObject::READ_NORMAL || !isset( $this->groupCache[$domainKey][$group] ) ) {
			if ( $global ) {
				$globalRulesKey = $this->getGlobalRulesKey( $group );
				$ret = $this->wanCache->getWithSetCallback(
					$globalRulesKey,
					WANObjectCache::TTL_WEEK,
					function () use ( $group, $global, $flags ) {
						return $this->getAllActiveFiltersInGroupFromDB( $group, $global, $flags );
					},
					[
						'checkKeys' => [ $globalRulesKey ],
						'lockTSE' => 300,
						'version' => 3
					]
				);
			} else {
				$ret = $this->getAllActiveFiltersInGroupFromDB( $group, $global, $flags );
			}

			$this->groupCache[$domainKey][$group] = [];
			foreach ( $ret as $key => $filter ) {
				$this->groupCache[$domainKey][$group][$key] = $filter;
				$this->cache[$key] = $filter;
			}
		}
		return $this->groupCache[$domainKey][$group];
	}

	/**
	 * @param string $group
	 * @param bool $global
	 * @param int $flags
	 * @return ExistingFilter[]
	 */
	private function getAllActiveFiltersInGroupFromDB( string $group, bool $global, int $flags ): array {
		if ( $this->localFiltersHiddenForTest && !$global ) {
			return [];
		}
		$dbr = ( $flags & IDBAccessObject::READ_LATEST )
			? $this->getDBConnection( DB_PRIMARY, $global )
			: $this->getDBConnection( DB_REPLICA, $global );
		$queryBuilder = $this->getAbuseFilterQueryBuilder( $dbr )
			->where( [ 'af_enabled' => 1, 'af_deleted' => 0, 'af_group' => $group ] )
			->recency( $flags );

		if ( $global ) {
			$queryBuilder->andWhere( [ 'af_global' => 1 ] );
		}

		// Note, excluding individually cached filter now wouldn't help much, so take it as
		// an occasion to refresh the cache later
		$rows = $queryBuilder->caller( __METHOD__ )->fetchResultSet();

		$fname = __METHOD__;
		$ret = [];
		foreach ( $rows as $row ) {
			$filterKey = $this->getCacheKey( $row->af_id, $global );
			$getActionsCB = function () use ( $dbr, $fname, $row ): array {
				return $this->getActionsFromDB( $dbr, $fname, $row->af_id );
			};
			$ret[$filterKey] = $this->filterFromRow(
				$row,
				// Don't pass a closure if global, as this is going to be serialized when caching
				$global ? $getActionsCB() : $getActionsCB
			);
		}
		return $ret;
	}

	/**
	 * @param int $dbIndex
	 * @param bool $global
	 * @return IReadableDatabase
	 * @throws CentralDBNotAvailableException
	 */
	private function getDBConnection( int $dbIndex, bool $global ): IReadableDatabase {
		if ( $global ) {
			return $this->centralDBManager->getConnection( $dbIndex );
		} else {
			return $this->loadBalancer->getConnection( $dbIndex );
		}
	}

	/**
	 * @param IReadableDatabase $db
	 * @param string $fname
	 * @param int $id
	 * @return array
	 */
	private function getActionsFromDB( IReadableDatabase $db, string $fname, int $id ): array {
		$res = $db->newSelectQueryBuilder()
			->select( [ 'afa_consequence', 'afa_parameters' ] )
			->from( 'abuse_filter_action' )
			->where( [ 'afa_filter' => $id ] )
			->caller( $fname )
			->fetchResultSet();

		$actions = [];
		foreach ( $res as $actionRow ) {
			$actions[$actionRow->afa_consequence] = $actionRow->afa_parameters !== ''
				? explode( "\n", $actionRow->afa_parameters )
				: [];
		}
		return $actions;
	}

	/**
	 * Get an old version of the given (local) filter, with its actions
	 *
	 * @param int $version Unique identifier of the version
	 * @param int $flags
	 * @return HistoryFilter
	 * @throws FilterVersionNotFoundException if the version doesn't exist
	 */
	public function getFilterVersion(
		int $version,
		int $flags = IDBAccessObject::READ_NORMAL
	): HistoryFilter {
		if ( $flags !== IDBAccessObject::READ_NORMAL || !isset( $this->historyCache[$version] ) ) {
			$dbr = ( $flags & IDBAccessObject::READ_LATEST )
				? $this->loadBalancer->getConnection( DB_PRIMARY )
				: $this->loadBalancer->getConnection( DB_REPLICA );
			$row = $this->getAbuseFilterHistoryQueryBuilder( $dbr )
				->where( [ 'afh_id' => $version ] )
				->recency( $flags )
				->caller( __METHOD__ )->fetchRow();
			if ( !$row ) {
				throw new FilterVersionNotFoundException( $version );
			}
			$this->historyCache[$version] = $this->filterFromHistoryRow( $row );
		}

		return $this->historyCache[$version];
	}

	/**
	 * @param int $filterID
	 * @return HistoryFilter
	 * @throws FilterNotFoundException If the filter doesn't exist
	 */
	public function getLastHistoryVersion( int $filterID ): HistoryFilter {
		if ( !isset( $this->lastVersionCache[$filterID] ) ) {
			$dbr = $this->loadBalancer->getConnection( DB_REPLICA );
			$row = $this->getAbuseFilterHistoryQueryBuilder( $dbr )
				->where( [ 'afh_filter' => $filterID ] )
				->orderBy( 'afh_id', SelectQueryBuilder::SORT_DESC )
				->caller( __METHOD__ )->fetchRow();
			if ( !$row ) {
				throw new FilterNotFoundException( $filterID, false );
			}
			$filterObj = $this->filterFromHistoryRow( $row );
			$this->lastVersionCache[$filterID] = $filterObj->getHistoryID();
			$this->historyCache[$filterObj->getHistoryID()] = $filterObj;
		}
		return $this->historyCache[ $this->lastVersionCache[$filterID] ];
	}

	/**
	 * @param int $historyID
	 * @param int $filterID
	 * @param string $direction self::DIR_PREV or self::DIR_NEXT
	 * @return HistoryFilter
	 * @throws ClosestFilterVersionNotFoundException
	 */
	public function getClosestVersion( int $historyID, int $filterID, string $direction ): HistoryFilter {
		if ( !isset( $this->closestVersionsCache[$filterID][$historyID][$direction] ) ) {
			$comparison = $direction === self::DIR_PREV ? '<' : '>';
			$order = $direction === self::DIR_PREV ? 'DESC' : 'ASC';
			$dbr = $this->loadBalancer->getConnection( DB_REPLICA );
			$row = $this->getAbuseFilterHistoryQueryBuilder( $dbr )
				->where( [ 'afh_filter' => $filterID ] )
				->andWhere( $dbr->expr( 'afh_id', $comparison, $historyID ) )
				->orderBy( 'afh_timestamp', $order )
				->caller( __METHOD__ )->fetchRow();
			if ( !$row ) {
				throw new ClosestFilterVersionNotFoundException( $filterID, $historyID );
			}
			$filterObj = $this->filterFromHistoryRow( $row );
			$this->closestVersionsCache[$filterID][$historyID][$direction] = $filterObj->getHistoryID();
			$this->historyCache[$filterObj->getHistoryID()] = $filterObj;
		}
		$histID = $this->closestVersionsCache[$filterID][$historyID][$direction];
		return $this->historyCache[$histID];
	}

	/**
	 * Get the history ID of the first change to a given filter
	 *
	 * @param int $filterID
	 * @return int
	 * @throws FilterNotFoundException
	 */
	public function getFirstFilterVersionID( int $filterID ): int {
		if ( !isset( $this->firstVersionCache[$filterID] ) ) {
			$dbr = $this->loadBalancer->getConnection( DB_REPLICA );
			$historyID = $dbr->newSelectQueryBuilder()
				->select( 'MIN(afh_id)' )
				->from( 'abuse_filter_history' )
				->where( [ 'afh_filter' => $filterID ] )
				->caller( __METHOD__ )
				->fetchField();
			if ( $historyID === false ) {
				throw new FilterNotFoundException( $filterID, false );
			}
			$this->firstVersionCache[$filterID] = (int)$historyID;
		}

		return $this->firstVersionCache[$filterID];
	}

	/**
	 * Resets the internal cache of Filter objects
	 */
	public function clearLocalCache(): void {
		$this->cache = [];
		$this->groupCache = [ 'local' => [], 'global' => [] ];
		$this->historyCache = [];
		$this->firstVersionCache = [];
		$this->lastVersionCache = [];
		$this->closestVersionsCache = [];
	}

	/**
	 * Purge the shared cache of global filters in the given group.
	 * @note This doesn't purge the local cache
	 * @param string $group
	 */
	public function purgeGroupWANCache( string $group ): void {
		$this->wanCache->touchCheckKey( $this->getGlobalRulesKey( $group ) );
	}

	/**
	 * @param string $group The filter's group (as defined in $wgAbuseFilterValidGroups)
	 * @return string
	 */
	private function getGlobalRulesKey( string $group ): string {
		if ( !$this->centralDBManager->filterIsCentral() ) {
			return $this->wanCache->makeGlobalKey(
				'abusefilter',
				'rules',
				$this->centralDBManager->getCentralDBName(),
				$group
			);
		}

		return $this->wanCache->makeKey( 'abusefilter', 'rules', $group );
	}

	/**
	 * @param array $flags
	 * @return int
	 */
	private function getPrivacyLevelFromFlags( $flags ): int {
		$hidden = in_array( 'hidden', $flags, true ) ?
			Flags::FILTER_HIDDEN :
			0;
		$protected = in_array( 'protected', $flags, true ) ?
			Flags::FILTER_USES_PROTECTED_VARS :
			0;
		return $hidden | $protected;
	}

	/**
	 * Note: this is private because no external caller should access DB rows directly.
	 * @param stdClass $row
	 * @return HistoryFilter
	 */
	private function filterFromHistoryRow( stdClass $row ): HistoryFilter {
		$actionsRaw = unserialize( $row->afh_actions );
		$actions = is_array( $actionsRaw ) ? $actionsRaw : [];
		$flags = $row->afh_flags ? explode( ',', $row->afh_flags ) : [];
		return new HistoryFilter(
			new Specs(
				trim( $row->afh_pattern ),
				$row->afh_comments,
				// FIXME: Make the DB field NOT NULL (T263324)
				(string)$row->afh_public_comments,
				array_keys( $actions ),
				// FIXME Make the field NOT NULL and add default (T263324)
				$row->afh_group ?? 'default'
			),
			new Flags(
				in_array( 'enabled', $flags, true ),
				in_array( 'deleted', $flags, true ),
				$this->getPrivacyLevelFromFlags( $flags ),
				in_array( 'global', $flags, true )
			),
			$actions,
			new LastEditInfo(
				(int)$row->afh_user,
				$row->afh_user_text,
				$row->afh_timestamp
			),
			(int)$row->afh_filter,
			$row->afh_id
		);
	}

	/**
	 * Note: this is private because no external caller should access DB rows directly.
	 * @param stdClass $row
	 * @param array[]|callable $actions
	 * @return ExistingFilter
	 */
	private function filterFromRow( stdClass $row, $actions ): ExistingFilter {
		return new ExistingFilter(
			new Specs(
				trim( $row->af_pattern ),
				// FIXME: Make the DB fields for these NOT NULL (T263324)
				(string)$row->af_comments,
				(string)$row->af_public_comments,
				$row->af_actions !== '' ? explode( ',', $row->af_actions ) : [],
				$row->af_group
			),
			new Flags(
				(bool)$row->af_enabled,
				(bool)$row->af_deleted,
				(int)$row->af_hidden,
				(bool)$row->af_global
			),
			$actions,
			new LastEditInfo(
				(int)$row->af_user,
				$row->af_user_text,
				$row->af_timestamp
			),
			(int)$row->af_id,
			isset( $row->af_hit_count ) ? (int)$row->af_hit_count : null,
			isset( $row->af_throttled ) ? (bool)$row->af_throttled : null
		);
	}

	private function getAbuseFilterQueryBuilder( IReadableDatabase $dbr ): SelectQueryBuilder {
		return $dbr->newSelectQueryBuilder()
			->select( [
				'af_id',
				'af_pattern',
				'af_timestamp',
				'af_enabled',
				'af_comments',
				'af_public_comments',
				'af_hidden',
				'af_hit_count',
				'af_throttled',
				'af_deleted',
				'af_actions',
				'af_global',
				'af_group',
				'af_user' => 'actor_af_user.actor_user',
				'af_user_text' => 'actor_af_user.actor_name',
				'af_actor' => 'af_actor'
			] )
			->from( 'abuse_filter' )
			->join( 'actor', 'actor_af_user', 'actor_af_user.actor_id = af_actor' );
	}

	private function getAbuseFilterHistoryQueryBuilder( IReadableDatabase $dbr ): SelectQueryBuilder {
		return $dbr->newSelectQueryBuilder()
			->select( [
				'afh_id',
				'afh_pattern',
				'afh_timestamp',
				'afh_filter',
				'afh_comments',
				'afh_public_comments',
				'afh_flags',
				'afh_actions',
				'afh_group',
				'afh_user' => 'actor_afh_user.actor_user',
				'afh_user_text' => 'actor_afh_user.actor_name',
				'afh_actor' => 'afh_actor'
			] )
			->from( 'abuse_filter_history' )
			->join( 'actor', 'actor_afh_user', 'actor_afh_user.actor_id = afh_actor' );
	}

	/**
	 * @param int $filterID
	 * @param bool $global
	 * @return string
	 */
	private function getCacheKey( int $filterID, bool $global ): string {
		return GlobalNameUtils::buildGlobalName( $filterID, $global );
	}

	/**
	 * "Hides" local filters when testing global ones, so that we can use the
	 * local database pretending it's not local.
	 * @codeCoverageIgnore
	 */
	public function hideLocalFiltersForTesting(): void {
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
			throw new RuntimeException( 'Can only be called in tests' );
		}
		$this->localFiltersHiddenForTest = true;
	}
}
PK       ! A%+e#  e#    BlockedDomainStorage.phpnu Iw        <?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
 */
namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Api\ApiRawMessage;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\JsonContent;
use MediaWiki\Json\FormatJson;
use MediaWiki\Message\Message;
use MediaWiki\Page\WikiPageFactory;
use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentity;
use MediaWiki\Utils\UrlUtils;
use RecentChange;
use StatusValue;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\Rdbms\DBAccessObjectUtils;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * Hold and update information about blocked external domains
 *
 * @ingroup SpecialPage
 */
class BlockedDomainStorage implements IDBAccessObject {
	public const SERVICE_NAME = 'AbuseFilterBlockedDomainStorage';
	public const TARGET_PAGE = 'BlockedExternalDomains.json';

	private RevisionLookup $revisionLookup;
	private BagOStuff $cache;
	private UserFactory $userFactory;
	private WikiPageFactory $wikiPageFactory;
	private UrlUtils $urlUtils;

	/**
	 * @param BagOStuff $cache Local-server caching
	 * @param RevisionLookup $revisionLookup
	 * @param UserFactory $userFactory
	 * @param WikiPageFactory $wikiPageFactory
	 * @param UrlUtils $urlUtils
	 */
	public function __construct(
		BagOStuff $cache,
		RevisionLookup $revisionLookup,
		UserFactory $userFactory,
		WikiPageFactory $wikiPageFactory,
		UrlUtils $urlUtils
	) {
		$this->cache = $cache;
		$this->revisionLookup = $revisionLookup;
		$this->userFactory = $userFactory;
		$this->wikiPageFactory = $wikiPageFactory;
		$this->urlUtils = $urlUtils;
	}

	/**
	 * @return string
	 */
	private function makeCacheKey() {
		return $this->cache->makeKey( 'abusefilter-blocked-domains' );
	}

	/**
	 * Load the configuration page, with optional local-server caching.
	 *
	 * @param int $flags bit field, see IDBAccessObject::READ_XXX
	 * @return StatusValue The content of the configuration page (as JSON
	 *   data in PHP-native format), or a StatusValue on error.
	 */
	public function loadConfig( int $flags = 0 ): StatusValue {
		if ( DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST ) ) {
			return $this->fetchConfig( $flags );
		}

		// Load configuration from APCU
		return $this->cache->getWithSetCallback(
			$this->makeCacheKey(),
			BagOStuff::TTL_MINUTE * 5,
			function ( &$ttl ) use ( $flags ) {
				$result = $this->fetchConfig( $flags );
				if ( !$result->isGood() ) {
					// error should not be cached
					$ttl = BagOStuff::TTL_UNCACHEABLE;
				}
				return $result;
			}
		);
	}

	/**
	 * Load the computed domain blocklist
	 *
	 * @return array<string,true> Flipped for performance reasons
	 */
	public function loadComputed(): array {
		return $this->cache->getWithSetCallback(
			$this->cache->makeKey( 'abusefilter-blocked-domains-computed' ),
			BagOStuff::TTL_MINUTE * 5,
			function () {
				$status = $this->loadConfig();
				if ( !$status->isGood() ) {
					return [];
				}
				$computedDomains = [];
				foreach ( $status->getValue() as $domain ) {
					if ( !( $domain['domain'] ?? null ) ) {
						continue;
					}
					$validatedDomain = $this->validateDomain( $domain['domain'] );
					if ( $validatedDomain ) {
						// It should be a map, benchmark at https://phabricator.wikimedia.org/P48956
						$computedDomains[$validatedDomain] = true;
					}
				}
				return $computedDomains;
			}
		);
	}

	/**
	 * Validate an input domain
	 *
	 * @param string|null $domain Domain such as foo.wikipedia.org
	 * @return string|false Parsed domain, or false otherwise
	 */
	public function validateDomain( $domain ) {
		if ( !$domain ) {
			return false;
		}

		$domain = trim( $domain );
		if ( !str_contains( $domain, '//' ) ) {
			$domain = 'https://' . $domain;
		}

		$parsedUrl = $this->urlUtils->parse( $domain );
		// Parse url returns a valid URL for "foo"
		if ( !$parsedUrl || !str_contains( $parsedUrl['host'], '.' ) ) {
			return false;
		}
		return $parsedUrl['host'];
	}

	/**
	 * Fetch the contents of the configuration page, without caching.
	 *
	 * The result is not validated with a config validator.
	 *
	 * @param int $flags bit field, see IDBAccessObject::READ_XXX; do NOT pass READ_UNCACHED
	 * @return StatusValue Status object, with the configuration (as JSON data) on success.
	 */
	private function fetchConfig( int $flags ): StatusValue {
		$revision = $this->revisionLookup->getRevisionByTitle( $this->getBlockedDomainPage(), 0, $flags );
		if ( !$revision ) {
			// The configuration page does not exist. Pretend it does not configure anything
			// specific (failure mode and empty-page behaviors are equal).
			return StatusValue::newGood( [] );
		}
		$content = $revision->getContent( SlotRecord::MAIN );
		if ( !$content instanceof JsonContent ) {
			return StatusValue::newFatal( new ApiRawMessage(
				'The configuration title has no content or is not JSON content.',
				'newcomer-tasks-configuration-loader-content-error' ) );
		}

		return FormatJson::parse( $content->getText(), FormatJson::FORCE_ASSOC );
	}

	/**
	 * This doesn't do validation.
	 *
	 * @param string $domain domain to be blocked
	 * @param string $notes User provided notes
	 * @param Authority|UserIdentity $user Performer
	 *
	 * @return RevisionRecord|null Null on failure
	 */
	public function addDomain( string $domain, string $notes, $user ): ?RevisionRecord {
		$content = $this->fetchLatestConfig();
		if ( $content === null ) {
			return null;
		}
		$content[] = [ 'domain' => $domain, 'notes' => $notes, 'addedBy' => $user->getName() ];
		$comment = Message::newFromSpecifier( 'abusefilter-blocked-domains-domain-added-comment' )
			->params( $domain, $notes )
			->plain();
		return $this->saveContent( $content, $user, $comment );
	}

	/**
	 * This doesn't do validation
	 *
	 * @param string $domain domain to be removed from the blocked list
	 * @param string $notes User provided notes
	 * @param Authority|UserIdentity $user Performer
	 *
	 * @return RevisionRecord|null Null on failure
	 */
	public function removeDomain( string $domain, string $notes, $user ): ?RevisionRecord {
		$content = $this->fetchLatestConfig();
		if ( $content === null ) {
			return null;
		}
		foreach ( $content as $key => $value ) {
			if ( ( $value['domain'] ?? '' ) == $domain ) {
				unset( $content[$key] );
			}
		}
		$comment = Message::newFromSpecifier( 'abusefilter-blocked-domains-domain-removed-comment' )
			->params( $domain, $notes )
			->plain();
		return $this->saveContent( array_values( $content ), $user, $comment );
	}

	/**
	 * @return array[]|null Empty array when the page doesn't exist, null on failure
	 */
	private function fetchLatestConfig(): ?array {
		$configPage = $this->getBlockedDomainPage();
		$revision = $this->revisionLookup->getRevisionByTitle( $configPage, 0, IDBAccessObject::READ_LATEST );
		if ( !$revision ) {
			return [];
		}

		$revContent = $revision->getContent( SlotRecord::MAIN );
		if ( $revContent instanceof JsonContent ) {
			$status = FormatJson::parse( $revContent->getText(), FormatJson::FORCE_ASSOC );
			if ( $status->isOK() ) {
				return $status->getValue();
			}
		}

		return null;
	}

	/**
	 * Save the provided content into the page
	 *
	 * @param array[] $content To be turned into JSON
	 * @param Authority|UserIdentity $user Performer
	 * @param string $comment Save comment
	 *
	 * @return RevisionRecord|null
	 */
	private function saveContent( array $content, $user, string $comment ): ?RevisionRecord {
		$configPage = $this->getBlockedDomainPage();
		$page = $this->wikiPageFactory->newFromLinkTarget( $configPage );
		$updater = $page->newPageUpdater( $user );
		$updater->setContent( SlotRecord::MAIN, new JsonContent( FormatJson::encode( $content ) ) );

		if ( $this->userFactory->newFromUserIdentity( $user )->isAllowed( 'autopatrol' ) ) {
			$updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
		}

		return $updater->saveRevision(
			CommentStoreComment::newUnsavedComment( $comment )
		);
	}

	/**
	 * @return TitleValue TitleValue of the config JSON page
	 */
	private function getBlockedDomainPage(): TitleValue {
		return new TitleValue( NS_MEDIAWIKI, self::TARGET_PAGE );
	}
}
PK       ! Me&      EditRevUpdater.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use InvalidArgumentException;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\RevisionRecord;
use Wikimedia\Rdbms\LBFactory;
use WikiPage;

/**
 * This service allows "linking" the edit filter hook and the page save hook
 */
class EditRevUpdater {
	public const SERVICE_NAME = 'AbuseFilterEditRevUpdater';

	/** @var CentralDBManager */
	private $centralDBManager;
	/** @var RevisionLookup */
	private $revisionLookup;
	/** @var LBFactory */
	private $lbFactory;
	/** @var string */
	private $wikiID;

	/** @var WikiPage|null */
	private $wikiPage;
	/**
	 * @var int[][][] IDs of logged filters like [ page title => [ 'local' => [ids], 'global' => [ids] ] ].
	 * @phan-var array<string,array{local:int[],global:int[]}>
	 */
	private $logIds = [];

	/**
	 * @param CentralDBManager $centralDBManager
	 * @param RevisionLookup $revisionLookup
	 * @param LBFactory $lbFactory
	 * @param string $wikiID
	 */
	public function __construct(
		CentralDBManager $centralDBManager,
		RevisionLookup $revisionLookup,
		LBFactory $lbFactory,
		string $wikiID
	) {
		$this->centralDBManager = $centralDBManager;
		$this->revisionLookup = $revisionLookup;
		$this->lbFactory = $lbFactory;
		$this->wikiID = $wikiID;
	}

	/**
	 * Set the WikiPage object used for the ongoing edit
	 *
	 * @param WikiPage $page
	 */
	public function setLastEditPage( WikiPage $page ): void {
		$this->wikiPage = $page;
	}

	/**
	 * Clear the WikiPage object used for the ongoing edit
	 */
	public function clearLastEditPage(): void {
		$this->wikiPage = null;
	}

	/**
	 * @param LinkTarget $target
	 * @param int[][] $logIds
	 * @phan-param array{local:int[],global:int[]} $logIds
	 */
	public function setLogIdsForTarget( LinkTarget $target, array $logIds ): void {
		if ( count( $logIds ) !== 2 || array_diff( array_keys( $logIds ), [ 'local', 'global' ] ) ) {
			throw new InvalidArgumentException( 'Wrong keys; got: ' . implode( ', ', array_keys( $logIds ) ) );
		}
		$key = $this->getCacheKey( $target );
		$this->logIds[$key] = $logIds;
	}

	/**
	 * @param WikiPage $wikiPage
	 * @param RevisionRecord $revisionRecord
	 * @return bool Whether the DB was updated
	 */
	public function updateRev( WikiPage $wikiPage, RevisionRecord $revisionRecord ): bool {
		$key = $this->getCacheKey( $wikiPage->getTitle() );
		if ( !isset( $this->logIds[ $key ] ) || $wikiPage !== $this->wikiPage ) {
			// This isn't the edit $this->logIds was set for
			$this->logIds = [];
			return false;
		}

		// Ignore null edit.
		$parentRevId = $revisionRecord->getParentId();
		if ( $parentRevId !== null ) {
			$parentRev = $this->revisionLookup->getRevisionById( $parentRevId );
			if ( $parentRev && $revisionRecord->hasSameContent( $parentRev ) ) {
				$this->logIds = [];
				return false;
			}
		}

		$this->clearLastEditPage();

		$ret = false;
		$logs = $this->logIds[ $key ];
		if ( $logs[ 'local' ] ) {
			$dbw = $this->lbFactory->getPrimaryDatabase();
			$dbw->newUpdateQueryBuilder()
				->update( 'abuse_filter_log' )
				->set( [ 'afl_rev_id' => $revisionRecord->getId() ] )
				->where( [ 'afl_id' => $logs['local'] ] )
				->caller( __METHOD__ )
				->execute();
			$ret = true;
		}

		if ( $logs[ 'global' ] ) {
			$fdb = $this->centralDBManager->getConnection( DB_PRIMARY );
			$fdb->newUpdateQueryBuilder()
				->update( 'abuse_filter_log' )
				->set( [ 'afl_rev_id' => $revisionRecord->getId() ] )
				->where( [ 'afl_id' => $logs['global'], 'afl_wiki' => $this->wikiID ] )
				->caller( __METHOD__ )
				->execute();
			$ret = true;
		}
		return $ret;
	}

	/**
	 * @param LinkTarget $target
	 * @return string
	 */
	private function getCacheKey( LinkTarget $target ): string {
		return $target->getNamespace() . '|' . $target->getText();
	}
}
PK       ! ڜ"1  1    FilterValidator.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagValidator;
use MediaWiki\Extension\AbuseFilter\Filter\AbstractFilter;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleException;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use MediaWiki\Message\Message;
use MediaWiki\Permissions\Authority;
use MediaWiki\Status\Status;

/**
 * This class validates filters, e.g. before saving.
 */
class FilterValidator {
	public const SERVICE_NAME = 'AbuseFilterFilterValidator';

	public const CONSTRUCTOR_OPTIONS = [
		'AbuseFilterValidGroups',
		'AbuseFilterActionRestrictions',
		'AbuseFilterProtectedVariables',
	];

	/** @var ChangeTagValidator */
	private $changeTagValidator;

	/** @var RuleCheckerFactory */
	private $ruleCheckerFactory;

	/** @var AbuseFilterPermissionManager */
	private $permManager;

	/** @var string[] */
	private $restrictedActions;

	/** @var string[] */
	private $validGroups;

	/**
	 * @var string[] Protected variables defined in config via AbuseFilterProtectedVariables
	 */
	private $protectedVariables;

	/**
	 * @param ChangeTagValidator $changeTagValidator
	 * @param RuleCheckerFactory $ruleCheckerFactory
	 * @param AbuseFilterPermissionManager $permManager
	 * @param ServiceOptions $options
	 */
	public function __construct(
		ChangeTagValidator $changeTagValidator,
		RuleCheckerFactory $ruleCheckerFactory,
		AbuseFilterPermissionManager $permManager,
		ServiceOptions $options
	) {
		$this->changeTagValidator = $changeTagValidator;
		$this->ruleCheckerFactory = $ruleCheckerFactory;
		$this->permManager = $permManager;
		$this->restrictedActions = array_keys( array_filter( $options->get( 'AbuseFilterActionRestrictions' ) ) );
		$this->validGroups = $options->get( 'AbuseFilterValidGroups' );
		$this->protectedVariables = $options->get( 'AbuseFilterProtectedVariables' );
	}

	/**
	 * @param AbstractFilter $newFilter
	 * @param AbstractFilter $originalFilter
	 * @param Authority $performer
	 * @return Status
	 */
	public function checkAll(
		AbstractFilter $newFilter, AbstractFilter $originalFilter, Authority $performer
	): Status {
		// TODO We might consider not bailing at the first error, so we can show all errors at the first attempt

		$syntaxStatus = $this->checkValidSyntax( $newFilter );
		if ( !$syntaxStatus->isGood() ) {
			return $syntaxStatus;
		}

		$requiredFieldsStatus = $this->checkRequiredFields( $newFilter );
		if ( !$requiredFieldsStatus->isGood() ) {
			return $requiredFieldsStatus;
		}

		$conflictStatus = $this->checkConflictingFields( $newFilter );
		if ( !$conflictStatus->isGood() ) {
			return $conflictStatus;
		}

		$actions = $newFilter->getActions();
		if ( isset( $actions['tag'] ) ) {
			$validTagsStatus = $this->checkAllTags( $actions['tag'] );
			if ( !$validTagsStatus->isGood() ) {
				return $validTagsStatus;
			}
		}

		$messagesStatus = $this->checkEmptyMessages( $newFilter );
		if ( !$messagesStatus->isGood() ) {
			return $messagesStatus;
		}

		if ( isset( $actions['throttle'] ) ) {
			$throttleStatus = $this->checkThrottleParameters( $actions['throttle'] );
			if ( !$throttleStatus->isGood() ) {
				return $throttleStatus;
			}
		}

		$protectedVarsPermissionStatus = $this->checkCanViewProtectedVariables( $performer, $newFilter );
		if ( !$protectedVarsPermissionStatus->isGood() ) {
			return $protectedVarsPermissionStatus;
		}

		$protectedVarsStatus = $this->checkProtectedVariables( $newFilter, $originalFilter );
		if ( !$protectedVarsStatus->isGood() ) {
			return $protectedVarsStatus;
		}

		$globalPermStatus = $this->checkGlobalFilterEditPermission( $performer, $newFilter, $originalFilter );
		if ( !$globalPermStatus->isGood() ) {
			return $globalPermStatus;
		}

		$globalFilterMsgStatus = $this->checkMessagesOnGlobalFilters( $newFilter );
		if ( !$globalFilterMsgStatus->isGood() ) {
			return $globalFilterMsgStatus;
		}

		$restrictedActionsStatus = $this->checkRestrictedActions( $performer, $newFilter, $originalFilter );
		if ( !$restrictedActionsStatus->isGood() ) {
			return $restrictedActionsStatus;
		}

		$filterGroupStatus = $this->checkGroup( $newFilter );
		if ( !$filterGroupStatus->isGood() ) {
			return $filterGroupStatus;
		}

		return Status::newGood();
	}

	/**
	 * @param AbstractFilter $filter
	 * @return Status
	 */
	public function checkValidSyntax( AbstractFilter $filter ): Status {
		$ret = Status::newGood();
		$ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
		$syntaxStatus = $ruleChecker->checkSyntax( $filter->getRules() );
		if ( !$syntaxStatus->isValid() ) {
			$excep = $syntaxStatus->getException();
			$errMsg = $excep instanceof UserVisibleException
				? $excep->getMessageObj()
				: $excep->getMessage();
			$ret->error( 'abusefilter-edit-badsyntax', $errMsg );
		}
		return $ret;
	}

	/**
	 * @param AbstractFilter $filter
	 * @return Status
	 */
	public function checkRequiredFields( AbstractFilter $filter ): Status {
		$ret = Status::newGood();
		$missing = [];
		if ( $filter->getRules() === '' ) {
			$missing[] = new Message( 'abusefilter-edit-field-conditions' );
		}
		if ( trim( $filter->getName() ) === '' ) {
			$missing[] = new Message( 'abusefilter-edit-field-description' );
		}
		if ( count( $missing ) !== 0 ) {
			$ret->error(
				'abusefilter-edit-missingfields',
				Message::listParam( $missing, 'comma' )
			);
		}
		return $ret;
	}

	/**
	 * @param AbstractFilter $filter
	 * @return Status
	 */
	public function checkConflictingFields( AbstractFilter $filter ): Status {
		$ret = Status::newGood();
		// Don't allow setting as deleted an active filter
		if ( $filter->isEnabled() && $filter->isDeleted() ) {
			$ret->error( 'abusefilter-edit-deleting-enabled' );
		}
		return $ret;
	}

	/**
	 * @param string[] $tags
	 * @return Status
	 */
	public function checkAllTags( array $tags ): Status {
		$ret = Status::newGood();
		if ( count( $tags ) === 0 ) {
			$ret->error( 'tags-create-no-name' );
			return $ret;
		}
		foreach ( $tags as $tag ) {
			$curStatus = $this->changeTagValidator->validateTag( $tag );

			if ( !$curStatus->isGood() ) {
				// TODO Consider merging
				return $curStatus;
			}
		}
		return $ret;
	}

	/**
	 * @todo Consider merging with checkRequiredFields
	 * @param AbstractFilter $filter
	 * @return Status
	 */
	public function checkEmptyMessages( AbstractFilter $filter ): Status {
		$ret = Status::newGood();
		$actions = $filter->getActions();
		// TODO: Check and report both together
		if ( isset( $actions['warn'] ) && $actions['warn'][0] === '' ) {
			$ret->error( 'abusefilter-edit-invalid-warn-message' );
		} elseif ( isset( $actions['disallow'] ) && $actions['disallow'][0] === '' ) {
			$ret->error( 'abusefilter-edit-invalid-disallow-message' );
		}
		return $ret;
	}

	/**
	 * Validate throttle parameters
	 *
	 * @param array $params Throttle parameters
	 * @return Status
	 */
	public function checkThrottleParameters( array $params ): Status {
		[ $throttleCount, $throttlePeriod ] = explode( ',', $params[1], 2 );
		$throttleGroups = array_slice( $params, 2 );
		$validGroups = [
			'ip',
			'user',
			'range',
			'creationdate',
			'editcount',
			'site',
			'page'
		];

		$ret = Status::newGood();
		if ( preg_match( '/^[1-9][0-9]*$/', $throttleCount ) === 0 ) {
			$ret->error( 'abusefilter-edit-invalid-throttlecount' );
		} elseif ( preg_match( '/^[1-9][0-9]*$/', $throttlePeriod ) === 0 ) {
			$ret->error( 'abusefilter-edit-invalid-throttleperiod' );
		} elseif ( !$throttleGroups ) {
			$ret->error( 'abusefilter-edit-empty-throttlegroups' );
		} else {
			$valid = true;
			// Groups should be unique in three ways: no direct duplicates like 'user' and 'user',
			// no duplicated subgroups, not even shuffled ('ip,user' and 'user,ip') and no duplicates
			// within subgroups ('user,ip,user')
			$uniqueGroups = [];
			$uniqueSubGroups = true;
			// Every group should be valid, and subgroups should have valid groups inside
			foreach ( $throttleGroups as $group ) {
				if ( strpos( $group, ',' ) !== false ) {
					$subGroups = explode( ',', $group );
					// @phan-suppress-next-line PhanPossiblyUndeclaredVariable
					if ( $subGroups !== array_unique( $subGroups ) ) {
						$uniqueSubGroups = false;
						break;
					}
					foreach ( $subGroups as $subGroup ) {
						if ( !in_array( $subGroup, $validGroups ) ) {
							$valid = false;
							break 2;
						}
					}
					sort( $subGroups );
					$uniqueGroups[] = implode( ',', $subGroups );
				} else {
					if ( !in_array( $group, $validGroups ) ) {
						$valid = false;
						break;
					}
					$uniqueGroups[] = $group;
				}
			}

			if ( !$valid ) {
				$ret->error( 'abusefilter-edit-invalid-throttlegroups' );
			} elseif ( !$uniqueSubGroups || $uniqueGroups !== array_unique( $uniqueGroups ) ) {
				$ret->error( 'abusefilter-edit-duplicated-throttlegroups' );
			}
		}

		return $ret;
	}

	/**
	 * @param Authority $performer
	 * @param AbstractFilter $newFilter
	 * @param AbstractFilter $originalFilter
	 * @return Status
	 */
	public function checkGlobalFilterEditPermission(
		Authority $performer,
		AbstractFilter $newFilter,
		AbstractFilter $originalFilter
	): Status {
		if (
			!$this->permManager->canEditFilter( $performer, $newFilter ) ||
			!$this->permManager->canEditFilter( $performer, $originalFilter )
		) {
			return Status::newFatal( 'abusefilter-edit-notallowed-global' );
		}
		return Status::newGood();
	}

	/**
	 * @param AbstractFilter $filter
	 * @return Status
	 */
	public function checkMessagesOnGlobalFilters( AbstractFilter $filter ): Status {
		$ret = Status::newGood();
		$actions = $filter->getActions();
		if (
			$filter->isGlobal() && (
				( isset( $actions['warn'] ) && $actions['warn'][0] !== 'abusefilter-warning' ) ||
				( isset( $actions['disallow'] ) && $actions['disallow'][0] !== 'abusefilter-disallowed' )
			)
		) {
			$ret->error( 'abusefilter-edit-notallowed-global-custom-msg' );
		}
		return $ret;
	}

	/**
	 * @param Authority $performer
	 * @param AbstractFilter $newFilter
	 * @param AbstractFilter $originalFilter
	 * @return Status
	 */
	public function checkRestrictedActions(
		Authority $performer,
		AbstractFilter $newFilter,
		AbstractFilter $originalFilter
	): Status {
		$ret = Status::newGood();
		$allEnabledActions = $newFilter->getActions() + $originalFilter->getActions();
		if (
			array_intersect_key( array_fill_keys( $this->restrictedActions, true ), $allEnabledActions )
			&& !$this->permManager->canEditFilterWithRestrictedActions( $performer )
		) {
			$ret->error( 'abusefilter-edit-restricted' );
		}
		return $ret;
	}

	/**
	 * @param AbstractFilter $filter
	 * @param ?AbstractFilter $originalFilter
	 * @return Status
	 */
	public function checkProtectedVariables( AbstractFilter $filter, ?AbstractFilter $originalFilter = null ): Status {
		$ret = Status::newGood();

		// If an original filter is passed through, check if it's already protected and bypass this check
		// if so.
		// T364485 introduces a UX that disables the checkbox for already protected filters and
		// therefore $filter will always fail the isProtected check but because it's already protected,
		// FilterStore->filterToDatabaseRow() will ensure it stays protected
		if ( $originalFilter && $originalFilter->isProtected() ) {
			return $ret;
		}

		$ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
		$usedVariables = $ruleChecker->getUsedVars( $filter->getRules() );
		$usedProtectedVariables = array_intersect( $usedVariables, $this->protectedVariables );

		if (
			count( $usedProtectedVariables ) > 0 &&
			!$filter->isProtected()
		) {
			$ret->error(
				'abusefilter-edit-protected-variable-not-protected',
				Message::listParam( $usedProtectedVariables )
			);
		}

		return $ret;
	}

	/**
	 * @param Authority $performer
	 * @param AbstractFilter $filter
	 * @return Status
	 */
	public function checkCanViewProtectedVariables( Authority $performer, AbstractFilter $filter ): Status {
		$ret = Status::newGood();
		$ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
		$usedVars = $ruleChecker->getUsedVars( $filter->getRules() );
		$forbiddenVariables = $this->permManager->getForbiddenVariables( $performer, $usedVars );
		if ( $forbiddenVariables ) {
			$ret->error( 'abusefilter-edit-protected-variable', Message::listParam( $forbiddenVariables ) );
		}
		return $ret;
	}

	/**
	 * @param AbstractFilter $filter
	 * @return Status
	 */
	public function checkGroup( AbstractFilter $filter ): Status {
		$ret = Status::newGood();
		$group = $filter->getGroup();
		if ( !in_array( $group, $this->validGroups, true ) ) {
			$ret->error( 'abusefilter-edit-invalid-group' );
		}
		return $ret;
	}
}
PK       ! ߯qI  I    Parser/AFPTreeParser.phpnu Iw        <?php

/**
 * A version of the abuse filter parser that separates parsing the filter and
 * evaluating it into different passes, allowing the parse tree to be cached.
 *
 * @file
 * @phan-file-suppress PhanPossiblyInfiniteRecursionSameParams Recursion controlled by class props
 */

namespace MediaWiki\Extension\AbuseFilter\Parser;

use MediaWiki\Extension\AbuseFilter\KeywordsManager;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleException;
use Psr\Log\LoggerInterface;
use Wikimedia\Stats\IBufferingStatsdDataFactory;

/**
 * A parser that transforms the text of the filter into a parse tree.
 */
class AFPTreeParser {
	/**
	 * @var array[] Contains the AFPTokens for the code being parsed
	 * @phan-var array<int,array{0:AFPToken,1:int}>
	 */
	private $mTokens;
	/**
	 * @var AFPToken The current token
	 */
	private $mCur;
	/** @var int The position of the current token */
	private $mPos;

	/**
	 * @var string|null The ID of the filter being parsed, if available. Can also be "global-$ID"
	 */
	private $mFilter;

	public const CACHE_VERSION = 2;

	/**
	 * @var LoggerInterface Used for debugging
	 */
	private $logger;

	/**
	 * @var IBufferingStatsdDataFactory
	 */
	private $statsd;

	/** @var KeywordsManager */
	private $keywordsManager;

	/**
	 * @param LoggerInterface $logger Used for debugging
	 * @param IBufferingStatsdDataFactory $statsd
	 * @param KeywordsManager $keywordsManager
	 */
	public function __construct(
		LoggerInterface $logger,
		IBufferingStatsdDataFactory $statsd,
		KeywordsManager $keywordsManager
	) {
		$this->logger = $logger;
		$this->statsd = $statsd;
		$this->keywordsManager = $keywordsManager;
		$this->resetState();
	}

	/**
	 * @param string $filter
	 */
	public function setFilter( $filter ) {
		$this->mFilter = $filter;
	}

	/**
	 * Resets the state
	 */
	private function resetState() {
		$this->mTokens = [];
		$this->mPos = 0;
		$this->mFilter = null;
	}

	/**
	 * Advances the parser to the next token in the filter code.
	 */
	private function move() {
		[ $this->mCur, $this->mPos ] = $this->mTokens[$this->mPos];
	}

	/**
	 * Get the next token. This is similar to move() but doesn't change class members,
	 *   allowing to look ahead without rolling back the state.
	 *
	 * @return AFPToken
	 */
	private function getNextToken() {
		return $this->mTokens[$this->mPos][0];
	}

	/**
	 * getState() function allows parser state to be rollbacked to several tokens
	 * back.
	 *
	 * @return AFPParserState
	 */
	private function getState() {
		return new AFPParserState( $this->mCur, $this->mPos );
	}

	/**
	 * setState() function allows parser state to be rollbacked to several tokens
	 * back.
	 *
	 * @param AFPParserState $state
	 */
	private function setState( AFPParserState $state ) {
		$this->mCur = $state->token;
		$this->mPos = $state->pos;
	}

	/**
	 * Parse the supplied filter source code into a tree.
	 *
	 * @param array[] $tokens
	 * @phan-param array<int,array{0:AFPToken,1:int}> $tokens
	 * @return AFPSyntaxTree
	 * @throws UserVisibleException
	 */
	public function parse( array $tokens ): AFPSyntaxTree {
		$this->mTokens = $tokens;
		$this->mPos = 0;

		return $this->buildSyntaxTree();
	}

	/**
	 * @return AFPSyntaxTree
	 */
	private function buildSyntaxTree(): AFPSyntaxTree {
		$startTime = microtime( true );
		$root = $this->doLevelEntry();
		$this->statsd->timing( 'abusefilter_cachingParser_buildtree', microtime( true ) - $startTime );
		return new AFPSyntaxTree( $root );
	}

	/* Levels */

	/**
	 * Handles unexpected characters after the expression.
	 * @return AFPTreeNode|null Null only if no statements
	 * @throws UserVisibleException
	 */
	private function doLevelEntry() {
		$result = $this->doLevelSemicolon();

		if ( $this->mCur->type !== AFPToken::TNONE ) {
			throw new UserVisibleException(
				'unexpectedatend',
				$this->mPos, [ $this->mCur->type ]
			);
		}

		return $result;
	}

	/**
	 * Handles the semicolon operator.
	 *
	 * @return AFPTreeNode|null
	 */
	private function doLevelSemicolon() {
		$statements = [];

		do {
			$this->move();
			$position = $this->mPos;

			if (
				$this->mCur->type === AFPToken::TNONE ||
				( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value == ')' )
			) {
				// Handle special cases which the other parser handled in doLevelAtom
				break;
			}

			// Allow empty statements.
			if ( $this->mCur->type === AFPToken::TSTATEMENTSEPARATOR ) {
				continue;
			}

			$statements[] = $this->doLevelSet();
			$position = $this->mPos;
		} while ( $this->mCur->type === AFPToken::TSTATEMENTSEPARATOR );

		// Flatten the tree if possible.
		if ( count( $statements ) === 0 ) {
			return null;
		} elseif ( count( $statements ) === 1 ) {
			return $statements[0];
		} else {
			return new AFPTreeNode( AFPTreeNode::SEMICOLON, $statements, $position );
		}
	}

	/**
	 * Handles variable assignment.
	 *
	 * @return AFPTreeNode
	 * @throws UserVisibleException
	 */
	private function doLevelSet() {
		if ( $this->mCur->type === AFPToken::TID ) {
			$varname = (string)$this->mCur->value;

			// Speculatively parse the assignment statement assuming it can
			// potentially be an assignment, but roll back if it isn't.
			// @todo Use $this->getNextToken for clearer code
			$initialState = $this->getState();
			$this->move();

			if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':=' ) {
				$position = $this->mPos;
				$this->move();
				$value = $this->doLevelSet();

				return new AFPTreeNode( AFPTreeNode::ASSIGNMENT, [ $varname, $value ], $position );
			}

			if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === '[' ) {
				$this->move();

				if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) {
					$index = 'append';
				} else {
					// Parse index offset.
					$this->setState( $initialState );
					$this->move();
					$index = $this->doLevelSemicolon();
					if ( !( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) ) {
						throw new UserVisibleException( 'expectednotfound', $this->mPos,
							[ ']', $this->mCur->type, $this->mCur->value ] );
					}
				}

				$this->move();
				if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':=' ) {
					$position = $this->mPos;
					$this->move();
					$value = $this->doLevelSet();
					if ( $index === 'append' ) {
						return new AFPTreeNode(
							AFPTreeNode::ARRAY_APPEND, [ $varname, $value ], $position );
					} else {
						return new AFPTreeNode(
							AFPTreeNode::INDEX_ASSIGNMENT,
							[ $varname, $index, $value ],
							$position
						);
					}
				}
			}

			// If we reached this point, we did not find an assignment.  Roll back
			// and assume this was just a literal.
			$this->setState( $initialState );
		}

		return $this->doLevelConditions();
	}

	/**
	 * Handles ternary operator and if-then-else-end.
	 *
	 * @return AFPTreeNode
	 * @throws UserVisibleException
	 */
	private function doLevelConditions() {
		if ( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'if' ) {
			$position = $this->mPos;
			$this->move();
			$condition = $this->doLevelBoolOps();

			if ( !( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'then' ) ) {
				throw new UserVisibleException( 'expectednotfound',
					$this->mPos,
					[
						'then',
						$this->mCur->type,
						$this->mCur->value
					]
				);
			}
			$this->move();

			$valueIfTrue = $this->doLevelConditions();

			if ( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'else' ) {
				$this->move();
				$valueIfFalse = $this->doLevelConditions();
			} else {
				$valueIfFalse = null;
			}

			if ( !( $this->mCur->type === AFPToken::TKEYWORD && $this->mCur->value === 'end' ) ) {
				throw new UserVisibleException( 'expectednotfound',
					$this->mPos,
					[
						'end',
						$this->mCur->type,
						$this->mCur->value
					]
				);
			}
			$this->move();

			return new AFPTreeNode(
				AFPTreeNode::CONDITIONAL,
				[ $condition, $valueIfTrue, $valueIfFalse ],
				$position
			);
		}

		$condition = $this->doLevelBoolOps();
		if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '?' ) {
			$position = $this->mPos;
			$this->move();

			$valueIfTrue = $this->doLevelConditions();
			if ( !( $this->mCur->type === AFPToken::TOP && $this->mCur->value === ':' ) ) {
				throw new UserVisibleException( 'expectednotfound',
					$this->mPos,
					[
						':',
						$this->mCur->type,
						$this->mCur->value
					]
				);
			}
			$this->move();

			$valueIfFalse = $this->doLevelConditions();
			return new AFPTreeNode(
				AFPTreeNode::CONDITIONAL,
				[ $condition, $valueIfTrue, $valueIfFalse ],
				$position
			);
		}

		return $condition;
	}

	/**
	 * Handles logic operators.
	 *
	 * @return AFPTreeNode
	 */
	private function doLevelBoolOps() {
		$leftOperand = $this->doLevelCompares();
		$ops = [ '&', '|', '^' ];
		while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
			$op = $this->mCur->value;
			$position = $this->mPos;
			$this->move();

			$rightOperand = $this->doLevelCompares();

			$leftOperand = new AFPTreeNode(
				AFPTreeNode::LOGIC,
				[ $op, $leftOperand, $rightOperand ],
				$position
			);
		}
		return $leftOperand;
	}

	/**
	 * Handles comparison operators.
	 *
	 * @return AFPTreeNode
	 */
	private function doLevelCompares() {
		$leftOperand = $this->doLevelSumRels();
		$equalityOps = [ '==', '===', '!=', '!==', '=' ];
		$orderOps = [ '<', '>', '<=', '>=' ];
		// Only allow either a single operation, or a combination of a single equalityOps and a single
		// orderOps. This resembles what PHP does, and allows `a < b == c` while rejecting `a < b < c`
		$allowedOps = array_merge( $equalityOps, $orderOps );
		while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $allowedOps ) ) {
			$op = $this->mCur->value;
			$allowedOps = in_array( $op, $equalityOps ) ?
				array_diff( $allowedOps, $equalityOps ) :
				array_diff( $allowedOps, $orderOps );
			$position = $this->mPos;
			$this->move();
			$rightOperand = $this->doLevelSumRels();
			$leftOperand = new AFPTreeNode(
				AFPTreeNode::COMPARE,
				[ $op, $leftOperand, $rightOperand ],
				$position
			);
		}
		return $leftOperand;
	}

	/**
	 * Handle addition and subtraction.
	 *
	 * @return AFPTreeNode
	 */
	private function doLevelSumRels() {
		$leftOperand = $this->doLevelMulRels();
		$ops = [ '+', '-' ];
		while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
			$op = $this->mCur->value;
			$position = $this->mPos;
			$this->move();
			$rightOperand = $this->doLevelMulRels();
			$leftOperand = new AFPTreeNode(
				AFPTreeNode::SUM_REL,
				[ $op, $leftOperand, $rightOperand ],
				$position
			);
		}
		return $leftOperand;
	}

	/**
	 * Handles multiplication and division.
	 *
	 * @return AFPTreeNode
	 */
	private function doLevelMulRels() {
		$leftOperand = $this->doLevelPow();
		$ops = [ '*', '/', '%' ];
		while ( $this->mCur->type === AFPToken::TOP && in_array( $this->mCur->value, $ops ) ) {
			$op = $this->mCur->value;
			$position = $this->mPos;
			$this->move();
			$rightOperand = $this->doLevelPow();
			$leftOperand = new AFPTreeNode(
				AFPTreeNode::MUL_REL,
				[ $op, $leftOperand, $rightOperand ],
				$position
			);
		}
		return $leftOperand;
	}

	/**
	 * Handles exponentiation.
	 *
	 * @return AFPTreeNode
	 */
	private function doLevelPow() {
		$base = $this->doLevelBoolInvert();
		while ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '**' ) {
			$position = $this->mPos;
			$this->move();
			$exponent = $this->doLevelBoolInvert();
			$base = new AFPTreeNode( AFPTreeNode::POW, [ $base, $exponent ], $position );
		}
		return $base;
	}

	/**
	 * Handles boolean inversion.
	 *
	 * @return AFPTreeNode
	 */
	private function doLevelBoolInvert() {
		if ( $this->mCur->type === AFPToken::TOP && $this->mCur->value === '!' ) {
			$position = $this->mPos;
			$this->move();
			$argument = $this->doLevelKeywordOperators();
			return new AFPTreeNode( AFPTreeNode::BOOL_INVERT, [ $argument ], $position );
		}

		return $this->doLevelKeywordOperators();
	}

	/**
	 * Handles keyword operators.
	 *
	 * @return AFPTreeNode
	 */
	private function doLevelKeywordOperators() {
		$leftOperand = $this->doLevelUnarys();
		$keyword = strtolower( $this->mCur->value );
		if ( $this->mCur->type === AFPToken::TKEYWORD &&
			isset( FilterEvaluator::KEYWORDS[$keyword] )
		) {
			$position = $this->mPos;
			$this->move();
			$rightOperand = $this->doLevelUnarys();

			return new AFPTreeNode(
				AFPTreeNode::KEYWORD_OPERATOR,
				[ $keyword, $leftOperand, $rightOperand ],
				$position
			);
		}

		return $leftOperand;
	}

	/**
	 * Handles unary operators.
	 *
	 * @return AFPTreeNode
	 */
	private function doLevelUnarys() {
		$op = $this->mCur->value;
		if ( $this->mCur->type === AFPToken::TOP && ( $op === "+" || $op === "-" ) ) {
			$position = $this->mPos;
			$this->move();
			$argument = $this->doLevelArrayElements();
			return new AFPTreeNode( AFPTreeNode::UNARY, [ $op, $argument ], $position );
		}
		return $this->doLevelArrayElements();
	}

	/**
	 * Handles accessing an array element by an offset.
	 *
	 * @return AFPTreeNode
	 * @throws UserVisibleException
	 */
	private function doLevelArrayElements() {
		$array = $this->doLevelParenthesis();
		while ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === '[' ) {
			$position = $this->mPos;
			$index = $this->doLevelSemicolon();
			$array = new AFPTreeNode( AFPTreeNode::ARRAY_INDEX, [ $array, $index ], $position );

			if ( !( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) ) {
				throw new UserVisibleException( 'expectednotfound', $this->mPos,
					[ ']', $this->mCur->type, $this->mCur->value ] );
			}
			$this->move();
		}

		return $array;
	}

	/**
	 * Handles parenthesis.
	 *
	 * @return AFPTreeNode
	 * @throws UserVisibleException
	 */
	private function doLevelParenthesis() {
		if ( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value === '(' ) {
			$next = $this->getNextToken();
			if ( $next->type === AFPToken::TBRACE && $next->value === ')' ) {
				// Empty parentheses are never allowed
				throw new UserVisibleException(
					'unexpectedtoken',
					$this->mPos,
					[
						$this->mCur->type,
						$this->mCur->value
					]
				);
			}
			$result = $this->doLevelSemicolon();

			if ( !( $this->mCur->type === AFPToken::TBRACE && $this->mCur->value === ')' ) ) {
				throw new UserVisibleException(
					'expectednotfound',
					$this->mPos,
					[ ')', $this->mCur->type, $this->mCur->value ]
				);
			}
			$this->move();

			return $result;
		}

		return $this->doLevelFunction();
	}

	/**
	 * Handles function calls.
	 *
	 * @return AFPTreeNode
	 * @throws UserVisibleException
	 */
	private function doLevelFunction() {
		$next = $this->getNextToken();
		if ( $this->mCur->type === AFPToken::TID &&
			$next->type === AFPToken::TBRACE &&
			$next->value === '('
		) {
			$func = $this->mCur->value;
			$position = $this->mPos;
			$this->move();

			$args = [];
			$next = $this->getNextToken();
			if ( $next->type !== AFPToken::TBRACE || $next->value !== ')' ) {
				do {
					$thisArg = $this->doLevelSemicolon();
					if ( $thisArg !== null ) {
						$args[] = $thisArg;
					} elseif (
						array_key_exists( $func, FilterEvaluator::FUNC_ARG_COUNT ) &&
						FilterEvaluator::FUNC_ARG_COUNT[$func][1] !== INF
					) {
						// If this function exists and is not variadic, fail now. If it does not exist, we'll fail when
						// checking the call validity in SyntaxChecker (T387649). Trailing commas are allowed when
						// calling variadic functions.
						throw new UserVisibleException(
							'unexpectedtoken',
							$this->mPos,
							[
								$this->mCur->type,
								$this->mCur->value
							]
						);
					}
				} while ( $this->mCur->type === AFPToken::TCOMMA );
			} else {
				$this->move();
			}

			if ( $this->mCur->type !== AFPToken::TBRACE || $this->mCur->value !== ')' ) {
				throw new UserVisibleException( 'expectednotfound',
					$this->mPos,
					[
						')',
						$this->mCur->type,
						$this->mCur->value
					]
				);
			}
			$this->move();

			array_unshift( $args, $func );
			return new AFPTreeNode( AFPTreeNode::FUNCTION_CALL, $args, $position );
		}

		return $this->doLevelAtom();
	}

	/**
	 * Handle literals.
	 * @return AFPTreeNode
	 * @throws UserVisibleException
	 */
	private function doLevelAtom() {
		$tok = $this->mCur->value;
		switch ( $this->mCur->type ) {
			case AFPToken::TID:
				$this->checkLogDeprecatedVar( strtolower( $tok ) );
				// Fallthrough intended
			case AFPToken::TSTRING:
			case AFPToken::TFLOAT:
			case AFPToken::TINT:
				$result = new AFPTreeNode( AFPTreeNode::ATOM, $this->mCur, $this->mPos );
				break;
			case AFPToken::TKEYWORD:
				if ( in_array( $tok, [ "true", "false", "null" ] ) ) {
					$result = new AFPTreeNode( AFPTreeNode::ATOM, $this->mCur, $this->mPos );
					break;
				}

				throw new UserVisibleException(
					'unrecognisedkeyword',
					$this->mPos,
					[ $tok ]
				);
			/** @noinspection PhpMissingBreakStatementInspection */
			case AFPToken::TSQUAREBRACKET:
				if ( $this->mCur->value === '[' ) {
					$array = [];
					while ( true ) {
						$this->move();
						if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) {
							break;
						}

						$array[] = $this->doLevelSet();

						if ( $this->mCur->type === AFPToken::TSQUAREBRACKET && $this->mCur->value === ']' ) {
							break;
						}
						if ( $this->mCur->type !== AFPToken::TCOMMA ) {
							throw new UserVisibleException(
								'expectednotfound',
								$this->mPos,
								[ ', or ]', $this->mCur->type, $this->mCur->value ]
							);
						}
					}

					$result = new AFPTreeNode( AFPTreeNode::ARRAY_DEFINITION, $array, $this->mPos );
					break;
				}

			// Fallthrough expected
			default:
				throw new UserVisibleException(
					'unexpectedtoken',
					$this->mPos,
					[
						$this->mCur->type,
						$this->mCur->value
					]
				);
		}

		$this->move();
		// @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
		// @phan-suppress-next-line PhanTypeMismatchReturnNullable Until phan can understand the switch
		return $result;
	}

	/**
	 * Given a variable name, check if the variable is deprecated. If it is, log the use.
	 * Do that here, and not every time the AST is eval'ed. This means less logging, but more
	 * performance.
	 * @param string $varname
	 */
	private function checkLogDeprecatedVar( $varname ) {
		if ( $this->keywordsManager->isVarDeprecated( $varname ) ) {
			$this->logger->debug( "Deprecated variable $varname used in filter {$this->mFilter}." );
		}
	}
}
PK       ! X=aLI  LI    Parser/SyntaxChecker.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser;

use InvalidArgumentException;
use LogicException;
use MediaWiki\Extension\AbuseFilter\KeywordsManager;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\InternalException;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleException;
use MediaWiki\Message\Message;

/**
 * SyntaxChecker statically analyzes the code without actually running it.
 * Currently, it only checks for
 *
 * - unbound variables
 * - unused variables: note that a := 1; a := 1; a
 *	 is considered OK even though the first `a` seems unused
 *	 because the pattern "a := null; if ... then (a := ...) end; ..."
 *	 should not count first `a` as unused.
 * - assignment to built-in identifiers
 * - invalid function call (arity mismatch, non-valid function)
 * - first-order information of `set_var` and `set`
 *
 * Because it doesn't cover all checks that the current Check Syntax does,
 * it is currently complementary to the current Check Syntax.
 * In the future, it could subsume the current Check Syntax, and could be
 * extended to perform type checking or type inference.
 */
class SyntaxChecker {
	/**
	 * @var AFPTreeNode|null Root of the AST to check
	 */
	private $treeRoot;

	/** @var KeywordsManager */
	private $keywordsManager;

	public const MCONSERVATIVE = 'MODE_CONSERVATIVE';
	public const MLIBERAL = 'MODE_LIBERAL';
	public const DUMMYPOS = 0;
	public const CACHE_VERSION = 1;

	/**
	 * @var string The mode of checking. The value should be either
	 *
	 *	 - MLIBERAL: which guarantees that all user-defined variables
	 *	   will be bound, but incompatible with what the evaluator currently
	 *	   permits. E.g.,
	 *
	 *	   if true then (a := 1) else null end; a
	 *
	 *	   is rejected in this mode, even though `a` is in fact always bound.
	 *
	 *	 - MCONSERVATIVE which is compatible with what the evaluator
	 *	   currently permits, but could allow undefined variables to occur.
	 *	   E.g.,
	 *
	 *	   if false then (a := 1) else null end; a
	 *
	 *	   is accepted in this mode, even though `a` is in fact always unbound.
	 */
	private $mode;

	/**
	 * @var bool Whether we want to check for unused variables
	 */
	private $checkUnusedVars;

	/**
	 * @param AFPSyntaxTree $tree
	 * @param KeywordsManager $keywordsManager
	 * @param string $mode
	 * @param bool $checkUnusedVars
	 */
	public function __construct(
		AFPSyntaxTree $tree,
		KeywordsManager $keywordsManager,
		string $mode = self::MCONSERVATIVE,
		bool $checkUnusedVars = false
	) {
		$this->treeRoot = $tree->getRoot();
		$this->keywordsManager = $keywordsManager;
		$this->mode = $mode;
		$this->checkUnusedVars = $checkUnusedVars;
	}

	/**
	 * Start the static analysis
	 *
	 * @throws UserVisibleException
	 */
	public function start(): void {
		if ( !$this->treeRoot ) {
			return;
		}
		$bound = $this->check( $this->desugar( $this->treeRoot ), [] );
		$unused = array_keys( array_filter( $bound, static function ( $v ) {
			return !$v;
		} ) );
		if ( $this->checkUnusedVars && $unused ) {
			throw new UserVisibleException(
				'unusedvars',
				self::DUMMYPOS,
				[ Message::listParam( $unused, 'comma' ) ]
			);
		}
	}

	/**
	 * Remove syntactic sugar so that we don't need to deal with
	 * too many cases.
	 *
	 * This could benefit the evaluator as well, but for now, this is
	 * only used for static analysis.
	 *
	 * Postcondition:
	 *	 - The tree will not contain nodes of
	 *	   type ASSIGNMENT, LOGIC, COMPARE, SUM_REL, MUL_REL, POW,
	 *	   KEYWORD_OPERATOR, and ARRAY_INDEX
	 *	 - The tree may additionally contain a node of type BINOP.
	 *	 - The tree should not have set_var function application.
	 *	 - Conditionals will have both branches.
	 *
	 * @param AFPTreeNode $node
	 * @return AFPTreeNode
	 * @throws InternalException
	 */
	private function desugar( AFPTreeNode $node ): AFPTreeNode {
		switch ( $node->type ) {
			case AFPTreeNode::ATOM:
				return $node;

			case AFPTreeNode::FUNCTION_CALL:
				if ( $node->children[0] === 'set_var' ) {
					$node->children[0] = 'set';
				}
				return $this->newNodeMapExceptFirst( $node );

			case AFPTreeNode::ARRAY_INDEX:
				return $this->newNodeNamedBinop( $node, '[]' );

			case AFPTreeNode::POW:
				return $this->newNodeNamedBinop( $node, '**' );

			case AFPTreeNode::UNARY:
			case AFPTreeNode::INDEX_ASSIGNMENT:
			case AFPTreeNode::ARRAY_APPEND:
				return $this->newNodeMapExceptFirst( $node );

			case AFPTreeNode::BOOL_INVERT:
				/*
				 * @todo this should really be combined with UNARY,
				 * but let's wait to change the meaning of UNARY across
				 * the codebase together
				 */
				return $this->newNodeMapAll( $node );

			case AFPTreeNode::KEYWORD_OPERATOR:
			case AFPTreeNode::MUL_REL:
			case AFPTreeNode::SUM_REL:
			case AFPTreeNode::COMPARE:
				return $this->newNodeBinop( $node );

			case AFPTreeNode::LOGIC:
				$result = $this->newNodeBinop( $node );
				[ $op, $left, $right ] = $result->children;
				if ( $op === '&' || $op === '|' ) {
					return $this->desugarAndOr( $op, $left, $right, $node->position );
				} else {
					return $result;
				}

			case AFPTreeNode::ARRAY_DEFINITION:
			case AFPTreeNode::SEMICOLON:
				return $this->newNodeMapAll( $node );

			case AFPTreeNode::CONDITIONAL:
				if ( $node->children[2] === null ) {
					$node->children[2] = new AFPTreeNode(
						AFPTreeNode::ATOM,
						new AFPToken(
							AFPToken::TKEYWORD,
							"null",
							$node->position
						),
						$node->position
					);
				}
				return $this->newNodeMapAll( $node );

			case AFPTreeNode::ASSIGNMENT:
				[ $varname, $value ] = $node->children;

				return new AFPTreeNode(
					AFPTreeNode::FUNCTION_CALL,
					[
						"set",
						new AFPTreeNode(
							AFPTreeNode::ATOM,
							new AFPToken(
								AFPToken::TSTRING,
								$varname,
								$node->position
							),
							$node->position
						),
						$this->desugar( $value )
					],
					$node->position
				);

			default:
				// @codeCoverageIgnoreStart
				throw new InternalException( "Unknown node type passed: {$node->type}" );
				// @codeCoverageIgnoreEnd
		}
	}

	/**
	 * @param string $op
	 * @param AFPTreeNode $left
	 * @param AFPTreeNode $right
	 * @param int $position
	 * @return AFPTreeNode
	 */
	private function desugarAndOr(
		string $op,
		AFPTreeNode $left,
		AFPTreeNode $right,
		int $position
	): AFPTreeNode {
		$trueNode = new AFPTreeNode(
			AFPTreeNode::ATOM,
			new AFPToken(
				AFPToken::TKEYWORD,
				"true",
				$position
			),
			$position
		);
		$falseNode = new AFPTreeNode(
			AFPTreeNode::ATOM,
			new AFPToken(
				AFPToken::TKEYWORD,
				"false",
				$position
			),
			$position
		);
		$conditionalNode = new AFPTreeNode(
			AFPTreeNode::CONDITIONAL,
			[
				$right,
				$trueNode,
				$falseNode
			],
			$position
		);

		if ( $op === '&' ) {
			// <a> & <b> is supposed to be equivalent to
			// if <a> then (if <b> then true else false) else false end
			// See T237336 for why this is currently not the case.
			return new AFPTreeNode(
				AFPTreeNode::CONDITIONAL,
				[
					$left,
					$conditionalNode,
					$falseNode
				],
				$position
			);
		} elseif ( $op === '|' ) {
			// <a> | <b> is supposed to be equivalent to
			// if <a> then true else (if <b> then true else false) end
			// See T237336 for why this is currently not the case.
			return new AFPTreeNode(
				AFPTreeNode::CONDITIONAL,
				[
					$left,
					$trueNode,
					$conditionalNode
				],
				$position
			);
		} else {
			// @codeCoverageIgnoreStart
			throw new InternalException( "Unknown operator: {$op}" );
			// @codeCoverageIgnoreEnd
		}
	}

	/**
	 * Construct a new node with information based on the old node but
	 * with different children
	 *
	 * @param AFPTreeNode $node
	 * @param AFPTreeNode[]|string[]|AFPToken $children
	 * @return AFPTreeNode
	 */
	private function newNode( AFPTreeNode $node, $children ): AFPTreeNode {
		return new AFPTreeNode( $node->type, $children, $node->position );
	}

	/**
	 * Construct a new node with information based on the old node but
	 * with different type
	 *
	 * @param AFPTreeNode $node
	 * @param string $type
	 * @return AFPTreeNode
	 */
	private function newNodeReplaceType(
		AFPTreeNode $node,
		string $type
	): AFPTreeNode {
		return new AFPTreeNode( $type, $node->children, $node->position );
	}

	/**
	 * Recursively desugar on all children
	 *
	 * @param AFPTreeNode $node
	 * @return AFPTreeNode
	 */
	private function newNodeMapAll( AFPTreeNode $node ): AFPTreeNode {
		$children = $node->children;
		if ( !is_array( $children ) ) {
			// @codeCoverageIgnoreStart
			throw new LogicException(
				"Unexpected non-array children of an AFPTreeNode of type " .
				"{$node->type} at position {$node->position}"
			);
			// @codeCoverageIgnoreEnd
		}
		return $this->newNode( $node, array_map( [ $this, 'desugar' ], $children ) );
	}

	/**
	 * Recursively desugar on all children except the first one
	 *
	 * @param AFPTreeNode $node
	 * @return AFPTreeNode
	 */
	private function newNodeMapExceptFirst( AFPTreeNode $node ): AFPTreeNode {
		$items = [ $node->children[0] ];
		$args = array_slice( $node->children, 1 );
		foreach ( $args as $el ) {
			$items[] = $this->desugar( $el );
		}
		return $this->newNode( $node, $items );
	}

	/**
	 * Convert a node with an operation into a BINOP
	 *
	 * @param AFPTreeNode $node
	 * @return AFPTreeNode
	 */
	private function newNodeBinop( AFPTreeNode $node ): AFPTreeNode {
		return $this->newNodeReplaceType(
			$this->newNodeMapExceptFirst( $node ),
			AFPTreeNode::BINOP
		);
	}

	/**
	 * Convert a node without an operation into a BINOP with the specified operation
	 *
	 * @param AFPTreeNode $node
	 * @param string $op
	 * @return AFPTreeNode
	 */
	private function newNodeNamedBinop(
		AFPTreeNode $node,
		string $op
	): AFPTreeNode {
		$items = $this->newNodeMapAll( $node )->children;
		array_unshift( $items, $op );
		return $this->newNodeReplaceType(
			$this->newNode( $node, $items ),
			AFPTreeNode::BINOP
		);
	}

	/**
	 * - Statically compute what are bound after evaluating $node,
	 *	 provided that variables in $bound are already bound.
	 * - Similarly compute for each bound variable after evaluating $node
	 *	 whether it is used provided that we already have $bound
	 *	 that contains necessary information.
	 * - Ensure function application's validity.
	 * - Ensure that the first argument of set is a literal string.
	 * - Ensure that all assignment is not done on built-in identifier.
	 *
	 * Precondition:
	 *	 - The tree $node should be desugared and normalized.
	 *
	 * Postcondition:
	 *	 - $node is guaranteed to have no unbound variables
	 *	   provided that variables in $bound are already bound
	 *	   (for the definition of unbound variable indicated by $this->mode)
	 *	 - All function applications should be valid and have correct arity.
	 *	 - The set function application's first argument should be
	 *	   a literal string.
	 *
	 * @param AFPTreeNode $node
	 * @param bool[] $bound Map of [ variable_name => used ]
	 * @return bool[] Map of [ variable_name => used ]
	 * @throws UserVisibleException
	 * @throws InternalException
	 */
	private function check( AFPTreeNode $node, array $bound ): array {
		switch ( $node->type ) {
			// phpcs:ignore PSR2.ControlStructures.SwitchDeclaration.TerminatingComment
			case AFPTreeNode::ATOM:
				$tok = $node->children;
				switch ( $tok->type ) {
					case AFPToken::TID:
						return $this->lookupVar(
							$tok->value,
							$tok->pos,
							$bound
						);

					case AFPToken::TSTRING:
					case AFPToken::TFLOAT:
					case AFPToken::TINT:
					case AFPToken::TKEYWORD:
						return $bound;

					default:
						// @codeCoverageIgnoreStart
						throw new InternalException( "Unknown token {$tok->type} provided in the ATOM node" );
						// @codeCoverageIgnoreEnd
				}
			case AFPTreeNode::ARRAY_DEFINITION:
				// @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach children is array here
				foreach ( $node->children as $el ) {
					$bound = $this->check( $el, $bound );
				}
				return $bound;

			case AFPTreeNode::FUNCTION_CALL:
				$fname = $node->children[0];
				$args = array_slice( $node->children, 1 );
				if ( !array_key_exists( $fname, FilterEvaluator::FUNCTIONS ) ) {
					throw new UserVisibleException(
						'unknownfunction',
						$node->position,
						[ $fname ]
					);
				}
				$this->checkArgCount( $args, $fname, $node->position );

				if ( $fname === 'set' ) {
					// arity is checked, so we know $args[0] and $args[1] exist
					$tok = $args[0]->children;

					if (
						!( $tok instanceof AFPToken ) ||
						$tok->type !== AFPToken::TSTRING
					) {
						throw new UserVisibleException(
							'variablevariable',
							$node->position,
							[]
						);
					}

					$bound = $this->check( $args[1], $bound );
					// set the variable as unused
					return $this->assignVar(
						$tok->value,
						$tok->pos,
						$bound
					);
				} else {
					foreach ( $args as $arg ) {
						$bound = $this->check( $arg, $bound );
					}
					return $bound;
				}

			case AFPTreeNode::BINOP:
				[ , $left, $right ] = $node->children;
				return $this->check( $right, $this->check( $left, $bound ) );

			case AFPTreeNode::UNARY:
				[ , $argument ] = $node->children;
				return $this->check( $argument, $bound );

			case AFPTreeNode::BOOL_INVERT:
				[ $argument ] = $node->children;
				return $this->check( $argument, $bound );
			// phpcs:ignore PSR2.ControlStructures.SwitchDeclaration.TerminatingComment
			case AFPTreeNode::CONDITIONAL:
				[ $condition, $exprIfTrue, $exprIfFalse ] = $node->children;
				$bound = $this->check( $condition, $bound );
				$boundLeft = $this->check( $exprIfTrue, $bound );
				$boundRight = $this->check( $exprIfFalse, $bound );
				switch ( $this->mode ) {
					case self::MCONSERVATIVE:
						return $this->mapUnion( $boundLeft, $boundRight );
					case self::MLIBERAL:
						return $this->mapIntersect( $boundLeft, $boundRight );
					default:
						// @codeCoverageIgnoreStart
						throw new LogicException( "Unknown mode: {$this->mode}" );
						// @codeCoverageIgnoreEnd
				}

			case AFPTreeNode::INDEX_ASSIGNMENT:
				[ $varName, $offset, $value ] = $node->children;

				// deal with unbound $varName
				$bound = $this->lookupVar( $varName, $node->position, $bound );
				$bound = $this->check( $offset, $bound );
				$bound = $this->check( $value, $bound );
				// deal with built-in $varName and set $varName as unused
				return $this->assignVar( $varName, $node->position, $bound );

			case AFPTreeNode::ARRAY_APPEND:
				[ $varName, $value ] = $node->children;

				// deal with unbound $varName
				$bound = $this->lookupVar( $varName, $node->position, $bound );
				$bound = $this->check( $value, $bound );
				// deal with built-in $varName and set $varName as unused
				return $this->assignVar( $varName, $node->position, $bound );

			case AFPTreeNode::SEMICOLON:
				// @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach children is array here
				foreach ( $node->children as $statement ) {
					$bound = $this->check( $statement, $bound );
				}
				return $bound;

			default:
				// @codeCoverageIgnoreStart
				throw new LogicException( "Unknown type: {$node->type}" );
				// @codeCoverageIgnoreEnd
		}
	}

	/**
	 * @param array $left
	 * @param array $right
	 * @return array
	 */
	private function mapUnion( array $left, array $right ): array {
		foreach ( $right as $key => $val ) {
			if ( array_key_exists( $key, $left ) ) {
				$left[ $key ] = $left[ $key ] || $val;
			} else {
				$left[ $key ] = $val;
			}
		}
		return $left;
	}

	/**
	 * @param array $left
	 * @param array $right
	 * @return array
	 */
	private function mapIntersect( array $left, array $right ): array {
		$keys = array_intersect_key( $left, $right );
		$result = [];
		foreach ( $keys as $key => $val ) {
			$result[ $key ] = $left[ $key ] || $right[ $key ];
		}
		return $result;
	}

	/**
	 * @param string $var
	 * @param int $pos
	 * @param array $bound
	 * @return array
	 */
	private function assignVar( string $var, int $pos, array $bound ): array {
		$var = strtolower( $var );
		if ( $this->isReservedIdentifier( $var ) ) {
			throw new UserVisibleException(
				'overridebuiltin',
				$pos,
				[ $var ]
			);
		}
		$bound[ $var ] = false;
		return $bound;
	}

	/**
	 * @param string $var
	 * @param int $pos
	 * @param array $bound
	 * @return array
	 */
	private function lookupVar( string $var, int $pos, array $bound ): array {
		$var = strtolower( $var );
		if ( array_key_exists( $var, $bound ) ) {
			// user-defined variable
			$bound[ $var ] = true;
			return $bound;
		} elseif ( $this->keywordsManager->isVarDisabled( $var ) ) {
			// disabled built-in variables
			throw new UserVisibleException(
				'disabledvar',
				$pos,
				[ $var ]
			);
		} elseif ( $this->keywordsManager->varExists( $var ) ) {
			// non-disabled built-in variables
			return $bound;
		} elseif ( $this->isReservedIdentifier( $var ) ) {
			// other built-in identifiers
			throw new UserVisibleException(
				'usebuiltin',
				$pos,
				[ $var ]
			);
		} else {
			// unbound variables
			throw new UserVisibleException(
				'unrecognisedvar',
				$pos,
				[ $var ]
			);
		}
	}

	/**
	 * Check that a built-in function has been provided the right amount of arguments
	 *
	 * @param array $args The arguments supplied to the function
	 * @param string $func The function name
	 * @param int $position
	 * @throws UserVisibleException
	 */
	private function checkArgCount( array $args, string $func, int $position ): void {
		if ( !array_key_exists( $func, FilterEvaluator::FUNC_ARG_COUNT ) ) {
			// @codeCoverageIgnoreStart
			throw new InvalidArgumentException( "$func is not a valid function." );
			// @codeCoverageIgnoreEnd
		}
		[ $min, $max ] = FilterEvaluator::FUNC_ARG_COUNT[ $func ];
		if ( count( $args ) < $min ) {
			throw new UserVisibleException(
				$min === 1 ? 'noparams' : 'notenoughargs',
				$position,
				[ $func, $min, count( $args ) ]
			);
		} elseif ( count( $args ) > $max ) {
			throw new UserVisibleException(
				'toomanyargs',
				$position,
				[ $func, $max, count( $args ) ]
			);
		}
	}

	/**
	 * Check whether the given name is a reserved identifier, e.g. the name of a built-in variable,
	 * function, or keyword.
	 *
	 * @param string $name
	 * @return bool
	 */
	private function isReservedIdentifier( string $name ): bool {
		return $this->keywordsManager->varExists( $name ) ||
			array_key_exists( $name, FilterEvaluator::FUNCTIONS ) ||
			// We need to check for true, false, if/then/else etc. because, even if they have a different
			// AFPToken type, they may be used inside set/set_var()
			in_array( $name, AbuseFilterTokenizer::KEYWORDS, true );
	}
}
PK       ! =Is  s    Parser/RuleCheckerFactory.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser;

use MediaWiki\Extension\AbuseFilter\KeywordsManager;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Language\Language;
use Psr\Log\LoggerInterface;
use Wikimedia\Equivset\Equivset;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\Stats\IBufferingStatsdDataFactory;

class RuleCheckerFactory {
	public const SERVICE_NAME = 'AbuseFilterRuleCheckerFactory';

	/** @var Language */
	private $contLang;

	/** @var BagOStuff */
	private $cache;

	/** @var LoggerInterface */
	private $logger;

	/** @var KeywordsManager */
	private $keywordsManager;

	/** @var VariablesManager */
	private $varManager;

	/** @var IBufferingStatsdDataFactory */
	private $statsdDataFactory;

	/** @var Equivset */
	private $equivset;

	/** @var int */
	private $conditionsLimit;

	/**
	 * @param Language $contLang
	 * @param BagOStuff $cache
	 * @param LoggerInterface $logger
	 * @param KeywordsManager $keywordsManager
	 * @param VariablesManager $varManager
	 * @param IBufferingStatsdDataFactory $statsdDataFactory
	 * @param Equivset $equivset
	 * @param int $conditionsLimit
	 */
	public function __construct(
		Language $contLang,
		BagOStuff $cache,
		LoggerInterface $logger,
		KeywordsManager $keywordsManager,
		VariablesManager $varManager,
		IBufferingStatsdDataFactory $statsdDataFactory,
		Equivset $equivset,
		int $conditionsLimit
	) {
		$this->contLang = $contLang;
		$this->cache = $cache;
		$this->logger = $logger;
		$this->keywordsManager = $keywordsManager;
		$this->varManager = $varManager;
		$this->statsdDataFactory = $statsdDataFactory;
		$this->equivset = $equivset;
		$this->conditionsLimit = $conditionsLimit;
	}

	/**
	 * @param VariableHolder|null $vars
	 * @return FilterEvaluator
	 */
	public function newRuleChecker( ?VariableHolder $vars = null ): FilterEvaluator {
		return new FilterEvaluator(
			$this->contLang,
			$this->cache,
			$this->logger,
			$this->keywordsManager,
			$this->varManager,
			$this->statsdDataFactory,
			$this->equivset,
			$this->conditionsLimit,
			$vars
		);
	}
}
PK       ! D
  
  )  Parser/Exception/UserVisibleException.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser\Exception;

use MediaWiki\Message\Message;

/**
 * Exceptions that we might conceivably want to report to ordinary users
 * (i.e. exceptions that don't represent bugs in the extension itself)
 */
class UserVisibleException extends ExceptionBase {
	/** @var string */
	public $mExceptionID;
	/** @var int */
	protected $mPosition;
	/** @var array */
	protected $mParams;

	/**
	 * @param string $exception_id
	 * @param int $position
	 * @param array $params
	 */
	public function __construct( $exception_id, $position, $params ) {
		$this->mExceptionID = $exception_id;
		$this->mPosition = $position;
		$this->mParams = $params;

		parent::__construct( $exception_id );
	}

	/**
	 * @return int
	 */
	public function getPosition(): int {
		return $this->mPosition;
	}

	/**
	 * Returns the error message for use in logs
	 *
	 * @return string
	 */
	public function getMessageForLogs(): string {
		return "ID: {$this->mExceptionID}; position: {$this->mPosition}; params: " . implode( ', ', $this->mParams );
	}

	/**
	 * @return Message
	 */
	public function getMessageObj(): Message {
		// Give grep a chance to find the usages:
		// abusefilter-exception-unexpectedatend, abusefilter-exception-expectednotfound
		// abusefilter-exception-unrecognisedkeyword, abusefilter-exception-unexpectedtoken
		// abusefilter-exception-unclosedstring, abusefilter-exception-invalidoperator
		// abusefilter-exception-unrecognisedtoken, abusefilter-exception-noparams
		// abusefilter-exception-dividebyzero, abusefilter-exception-unrecognisedvar
		// abusefilter-exception-notenoughargs, abusefilter-exception-regexfailure
		// abusefilter-exception-overridebuiltin, abusefilter-exception-outofbounds
		// abusefilter-exception-notarray, abusefilter-exception-unclosedcomment
		// abusefilter-exception-invalidiprange, abusefilter-exception-disabledvar
		// abusefilter-exception-variablevariable, abusefilter-exception-toomanyargs
		// abusefilter-exception-negativeoffset, abusefilter-exception-unusedvars
		// abusefilter-exception-unknownfunction, abusefilter-exception-usebuiltin
		return new Message(
			'abusefilter-exception-' . $this->mExceptionID,
			[ $this->mPosition, ...$this->mParams ]
		);
	}

	/**
	 * Serialize data for edit stash
	 * @return array
	 */
	public function toArray(): array {
		return [
			'class' => static::class,
			'exceptionID' => $this->mExceptionID,
			'position' => $this->mPosition,
			'params' => $this->mParams,
		];
	}

	/**
	 * Deserialize data from edit stash
	 * @param array $value
	 * @return static
	 */
	public static function fromArray( array $value ) {
		$cls = $value['class'];
		return new $cls( $value['exceptionID'], $value['position'], $value['params'] );
	}

}
PK       ! h	  	  '  Parser/Exception/UserVisibleWarning.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser\Exception;

use MediaWiki\Message\Message;

/**
 * A variant of user-visible exception that is not fatal.
 */
class UserVisibleWarning extends UserVisibleException {
	/**
	 * @return Message
	 */
	public function getMessageObj(): Message {
		// Give grep a chance to find the usages:
		// abusefilter-parser-warning-match-empty-regex
		return new Message(
			'abusefilter-parser-warning-' . $this->mExceptionID,
			[ $this->mPosition, ...$this->mParams ]
		);
	}
}
PK       ! Y.(  (  "  Parser/Exception/ExceptionBase.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser\Exception;

use Exception;

abstract class ExceptionBase extends Exception {

	/**
	 * Serialize data for edit stash
	 * @return array
	 */
	public function toArray(): array {
		return [
			'class' => static::class,
			'message' => $this->getMessage(),
		];
	}

	/**
	 * Deserialize data from edit stash
	 * @param array $value
	 * @return static
	 */
	public static function fromArray( array $value ) {
		[ 'class' => $cls, 'message' => $message ] = $value;
		return new $cls( $message );
	}

}
PK       ! E$/  /  ,  Parser/Exception/ConditionLimitException.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser\Exception;

/**
 * Exceptions thrown upon reaching the condition limit of the AbuseFilter parser.
 */
class ConditionLimitException extends ExceptionBase {
	public function __construct() {
		parent::__construct( 'Condition limit reached.' );
	}
}
PK       !  :    &  Parser/Exception/InternalException.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser\Exception;

/**
 * Exceptions from the AbuseFilter parser that should not be reported to the user, because they indicate
 * programming errors or unexpected situations.
 */
class InternalException extends ExceptionBase {
}
PK       ! lr  r    Parser/AFPSyntaxTree.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser;

/**
 * A class representing a whole AST generated by AFPTreeParser, holding AFPTreeNode's. This wrapper
 * could be expanded in the future. For now, it's mostly useful for typehints, and to have an
 * evalTree function in the evaluator.
 */
class AFPSyntaxTree {
	/**
	 * @var AFPTreeNode|null
	 */
	private $rootNode;

	/**
	 * @param AFPTreeNode|null $root
	 */
	public function __construct( ?AFPTreeNode $root = null ) {
		$this->rootNode = $root;
	}

	/**
	 * @return AFPTreeNode|null
	 */
	public function getRoot(): ?AFPTreeNode {
		return $this->rootNode;
	}
}
PK       ! `;  ;    Parser/AFPParserState.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser;

class AFPParserState {
	/** @var AFPToken */
	public $token;
	/** @var int */
	public $pos;

	/**
	 * @param AFPToken $token
	 * @param int $pos
	 */
	public function __construct( AFPToken $token, $pos ) {
		$this->token = $token;
		$this->pos = $pos;
	}
}
PK       ! @z      Parser/ParserStatus.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser;

use MediaWiki\Extension\AbuseFilter\Parser\Exception\ExceptionBase;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleWarning;

class ParserStatus {
	/** @var ExceptionBase|null */
	protected $excep;
	/** @var UserVisibleWarning[] */
	protected $warnings;
	/** @var int */
	protected $condsUsed;

	/**
	 * @param ExceptionBase|null $excep An exception thrown while parsing, or null if it parsed correctly
	 * @param UserVisibleWarning[] $warnings
	 * @param int $condsUsed
	 */
	public function __construct(
		?ExceptionBase $excep,
		array $warnings,
		int $condsUsed
	) {
		$this->excep = $excep;
		$this->warnings = $warnings;
		$this->condsUsed = $condsUsed;
	}

	/**
	 * @return ExceptionBase|null
	 */
	public function getException(): ?ExceptionBase {
		return $this->excep;
	}

	/**
	 * @return UserVisibleWarning[]
	 */
	public function getWarnings(): array {
		return $this->warnings;
	}

	/**
	 * @return int
	 */
	public function getCondsUsed(): int {
		return $this->condsUsed;
	}

	/**
	 * Whether the parsing/evaluation happened successfully.
	 * @return bool
	 */
	public function isValid(): bool {
		return !$this->excep;
	}
}
PK       ! (/      Parser/RuleCheckerStatus.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser;

use MediaWiki\Extension\AbuseFilter\Parser\Exception\ExceptionBase;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleWarning;

class RuleCheckerStatus extends ParserStatus {
	/** @var bool */
	private $result;
	/** @var bool */
	private $warmCache;

	/**
	 * @param bool $result Whether the rule matched
	 * @param bool $warmCache Whether we retrieved the AST from cache
	 * @param ExceptionBase|null $excep An exception thrown while parsing, or null if it parsed correctly
	 * @param UserVisibleWarning[] $warnings
	 * @param int $condsUsed
	 */
	public function __construct(
		bool $result,
		bool $warmCache,
		?ExceptionBase $excep,
		array $warnings,
		int $condsUsed
	) {
		parent::__construct( $excep, $warnings, $condsUsed );
		$this->result = $result;
		$this->warmCache = $warmCache;
	}

	/**
	 * @return bool
	 */
	public function getResult(): bool {
		return $this->result;
	}

	/**
	 * @return bool
	 */
	public function getWarmCache(): bool {
		return $this->warmCache;
	}

	/**
	 * Serialize data for edit stash
	 * @return array
	 */
	public function toArray(): array {
		return [
			'result' => $this->result,
			'warmCache' => $this->warmCache,
			'exception' => $this->excep ? $this->excep->toArray() : null,
			'warnings' => array_map(
				static function ( $warn ) {
					return $warn->toArray();
				},
				$this->warnings
			),
			'condsUsed' => $this->condsUsed,
		];
	}

	/**
	 * Deserialize data from edit stash
	 * @param array $value
	 * @return self
	 */
	public static function fromArray( array $value ): self {
		$excClass = $value['exception']['class'] ?? null;
		return new self(
			$value['result'],
			$value['warmCache'],
			$excClass !== null ? call_user_func( [ $excClass, 'fromArray' ], $value['exception'] ) : null,
			array_map( [ UserVisibleWarning::class, 'fromArray' ], $value['warnings'] ),
			$value['condsUsed']
		);
	}
}
PK       ! ܃_V  V    Parser/AFPTreeNode.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser;

use MediaWiki\Extension\AbuseFilter\Parser\Exception\InternalException;

/**
 * Represents a node of a parser tree.
 */
class AFPTreeNode {
	// Each of the constants below represents a node corresponding to a level
	// of the parser, from the top of the tree to the bottom.

	// ENTRY is always one-element and thus does not have its own node.

	// SEMICOLON is a many-children node, denoting that the nodes have to be
	// evaluated in order and the last value has to be returned.
	public const SEMICOLON = 'SEMICOLON';

	// ASSIGNMENT (formerly known as SET) is a node which is responsible for
	// assigning values to variables.  ASSIGNMENT is a (variable name [string],
	// value [tree node]) tuple, INDEX_ASSIGNMENT (which is used to assign
	// values at array offsets) is a (variable name [string], index [tree node],
	// value [tree node]) tuple, and ARRAY_APPEND has the form of (variable name
	// [string], value [tree node]).
	public const ASSIGNMENT = 'ASSIGNMENT';
	public const INDEX_ASSIGNMENT = 'INDEX_ASSIGNMENT';
	public const ARRAY_APPEND = 'ARRAY_APPEND';

	// CONDITIONAL represents both a ternary operator and an if-then-else-end
	// construct.  The format is (condition, evaluated-if-true, evaluated-in-false).
	// The first two are tree nodes, the last one can be a node, or null if there's no else.
	public const CONDITIONAL = 'CONDITIONAL';

	// LOGIC is a logic operator accepted by AFPData::boolOp.  The format is
	// (operation, left operand, right operand).
	public const LOGIC = 'LOGIC';

	// COMPARE is a comparison operator accepted by AFPData::boolOp.  The format is
	// (operation, left operand, right operand).
	public const COMPARE = 'COMPARE';

	// SUM_REL is either '+' or '-'.  The format is (operation, left operand,
	// right operand).
	public const SUM_REL = 'SUM_REL';

	// MUL_REL is a multiplication-related operation accepted by AFPData::mulRel.
	// The format is (operation, left operand, right operand).
	public const MUL_REL = 'MUL_REL';

	// POW is an exponentiation operator.  The format is (base, exponent).
	public const POW = 'POW';

	// BOOL_INVERT is a boolean inversion operator.  The format is (operand).
	public const BOOL_INVERT = 'BOOL_INVERT';

	// KEYWORD_OPERATOR is one of the binary keyword operators supported by the
	// filter language.  The format is (keyword, left operand, right operand).
	public const KEYWORD_OPERATOR = 'KEYWORD_OPERATOR';

	// UNARY is either unary minus or unary plus.  The format is (operator, operand).
	public const UNARY = 'UNARY';

	// ARRAY_INDEX is an operation of accessing an array by an offset.  The format
	// is (array, offset).
	public const ARRAY_INDEX = 'ARRAY_INDEX';

	// Since parenthesis only manipulate precedence of the operators, they are
	// not explicitly represented in the tree.

	// FUNCTION_CALL is an invocation of built-in function.  The format is a
	// tuple where the first element is a function name, and all subsequent
	// elements are the arguments.
	public const FUNCTION_CALL = 'FUNCTION_CALL';

	// ARRAY_DEFINITION is an array literal.  The $children field contains tree
	// nodes for the values of each of the array element used.
	public const ARRAY_DEFINITION = 'ARRAY_DEFINITION';

	// ATOM is a node representing a literal.  The only element of $children is a
	// token corresponding to the literal.
	public const ATOM = 'ATOM';

	// BINOP is a combination of LOGIC (^), COMPARE (<=, <, etc.),
	// SUM_REL (+, -), MUL_REL (*, /, %), POW (**),
	// KEYWORD_OPERATOR (like, rlike, etc.), and ARRAY_INDEX ([]).
	// The format is (operator, operand, operand).
	// Currently, it's only used in SyntaxChecker
	// & and | which is in LOGIC is not in BINOP because it affects
	// control flow.
	public const BINOP = 'BINOP';

	/** @var string Type of the node, one of the constants above */
	public $type;
	/**
	 * Parameters of the value. Typically it is an array of children nodes,
	 * which might be either strings (for parametrization of the node) or another
	 * node. In case of ATOM it's a parser token.
	 * @var AFPTreeNode[]|string[]|AFPToken
	 */
	public $children;

	/** @var int Position used for error reporting. */
	public $position;

	/**
	 * @param string $type
	 * @param (AFPTreeNode|null)[]|string[]|AFPToken $children
	 * @param int $position
	 */
	public function __construct( $type, $children, $position ) {
		$this->type = $type;
		$this->children = $children;
		$this->position = $position;
	}

	/**
	 * @return string
	 * @codeCoverageIgnore
	 */
	public function toDebugString() {
		return implode( "\n", $this->toDebugStringInner() );
	}

	/**
	 * @return array
	 * @codeCoverageIgnore
	 */
	private function toDebugStringInner() {
		if ( $this->type === self::ATOM ) {
			return [ "ATOM({$this->children->type} {$this->children->value})" ];
		}

		$align = static function ( $line ) {
			return '  ' . $line;
		};

		$lines = [ $this->type ];
		// @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach children is array here
		foreach ( $this->children as $subnode ) {
			if ( $subnode instanceof AFPTreeNode ) {
				$sublines = array_map( $align, $subnode->toDebugStringInner() );
			} elseif ( is_string( $subnode ) ) {
				$sublines = [ "  {$subnode}" ];
			} else {
				throw new InternalException( "Each node parameter has to be either a node or a string" );
			}

			$lines = array_merge( $lines, $sublines );
		}
		return $lines;
	}
}
PK       ! :P      Parser/AFPToken.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser;

/**
 * Abuse filter parser.
 * Copyright © Victor Vasiliev, 2008.
 * Based on ideas by Andrew Garrett
 * Distributed under GNU GPL v2 terms.
 *
 * Types of token:
 * * T_NONE - special-purpose token
 * * T_BRACE  - ( or )
 * * T_COMMA - ,
 * * T_OP - operator like + or ^
 * * T_NUMBER - number
 * * T_STRING - string, in "" or ''
 * * T_KEYWORD - keyword
 * * T_ID - identifier
 * * T_STATEMENT_SEPARATOR - ;
 * * T_SQUARE_BRACKETS - [ or ]
 *
 * Levels of parsing:
 * * Entry - catches unexpected characters
 * * Semicolon - ;
 * * Set - :=
 * * Conditionals (IF) - if-then-else-end, cond ? a :b
 * * BoolOps (BO) - &, |, ^
 * * CompOps (CO) - ==, !=, ===, !==, >, <, >=, <=
 * * SumRel (SR) - +, -
 * * MulRel (MR) - *, /, %
 * * Pow (P) - **
 * * BoolNeg (BN) - ! operation
 * * SpecialOperators (SO) - in and like
 * * Unarys (U) - plus and minus in cases like -5 or -(2 * +2)
 * * ArrayElement (AE) - array[number]
 * * Braces (B) - ( and )
 * * Functions (F)
 * * Atom (A) - return value
 */
class AFPToken {
	public const TNONE = 'T_NONE';
	public const TID = 'T_ID';
	public const TKEYWORD = 'T_KEYWORD';
	public const TSTRING = 'T_STRING';
	public const TINT = 'T_INT';
	public const TFLOAT = 'T_FLOAT';
	public const TOP = 'T_OP';
	public const TBRACE = 'T_BRACE';
	public const TSQUAREBRACKET = 'T_SQUARE_BRACKET';
	public const TCOMMA = 'T_COMMA';
	public const TSTATEMENTSEPARATOR = 'T_STATEMENT_SEPARATOR';

	/**
	 * @var string One of the T* constant from this class
	 */
	public $type;
	/**
	 * @var mixed|null The actual value of the token
	 */
	public $value;
	/**
	 * @var int The code offset where this token is found
	 */
	public $pos;

	/**
	 * @param string $type
	 * @param mixed|null $value
	 * @param int $pos
	 */
	public function __construct( $type = self::TNONE, $value = null, $pos = 0 ) {
		$this->type = $type;
		$this->value = $value;
		$this->pos = $pos;
	}
}
PK       !  ֪  ֪    Parser/FilterEvaluator.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser;

use Exception;
use InvalidArgumentException;
use MediaWiki\Extension\AbuseFilter\KeywordsManager;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\ConditionLimitException;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\ExceptionBase;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\InternalException;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleException;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleWarning;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Language\Language;
use MediaWiki\Parser\Sanitizer;
use Psr\Log\LoggerInterface;
use Wikimedia\Equivset\Equivset;
use Wikimedia\IPUtils;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\Stats\IBufferingStatsdDataFactory;

/**
 * This class evaluates an AST generated by the filter parser.
 *
 * @todo Override checkSyntax and make it only try to build the AST. That would mean faster results,
 *   and no need to mess with DUNDEFINED and the like. However, we must first try to reduce the
 *   amount of runtime-only exceptions, and try to detect them in the AFPTreeParser instead.
 *   Otherwise, people may be able to save a broken filter without the syntax check reporting that.
 */
class FilterEvaluator {
	private const CACHE_VERSION = 1;

	public const FUNCTIONS = [
		'lcase' => 'funcLc',
		'ucase' => 'funcUc',
		'length' => 'funcLen',
		'string' => 'castString',
		'int' => 'castInt',
		'float' => 'castFloat',
		'bool' => 'castBool',
		'norm' => 'funcNorm',
		'ccnorm' => 'funcCCNorm',
		'ccnorm_contains_any' => 'funcCCNormContainsAny',
		'ccnorm_contains_all' => 'funcCCNormContainsAll',
		'specialratio' => 'funcSpecialRatio',
		'rmspecials' => 'funcRMSpecials',
		'rmdoubles' => 'funcRMDoubles',
		'rmwhitespace' => 'funcRMWhitespace',
		'count' => 'funcCount',
		'rcount' => 'funcRCount',
		'get_matches' => 'funcGetMatches',
		'ip_in_range' => 'funcIPInRange',
		'ip_in_ranges' => 'funcIPInRanges',
		'contains_any' => 'funcContainsAny',
		'contains_all' => 'funcContainsAll',
		'equals_to_any' => 'funcEqualsToAny',
		'substr' => 'funcSubstr',
		'strlen' => 'funcLen',
		'strpos' => 'funcStrPos',
		'str_replace' => 'funcStrReplace',
		'str_replace_regexp' => 'funcStrReplaceRegexp',
		'rescape' => 'funcStrRegexEscape',
		'set' => 'funcSetVar',
		'set_var' => 'funcSetVar',
		'sanitize' => 'funcSanitize',
	];

	/**
	 * The minimum and maximum amount of arguments required by each function.
	 * @var int[][]
	 */
	public const FUNC_ARG_COUNT = [
		'lcase' => [ 1, 1 ],
		'ucase' => [ 1, 1 ],
		'length' => [ 1, 1 ],
		'string' => [ 1, 1 ],
		'int' => [ 1, 1 ],
		'float' => [ 1, 1 ],
		'bool' => [ 1, 1 ],
		'norm' => [ 1, 1 ],
		'ccnorm' => [ 1, 1 ],
		'ccnorm_contains_any' => [ 2, INF ],
		'ccnorm_contains_all' => [ 2, INF ],
		'specialratio' => [ 1, 1 ],
		'rmspecials' => [ 1, 1 ],
		'rmdoubles' => [ 1, 1 ],
		'rmwhitespace' => [ 1, 1 ],
		'count' => [ 1, 2 ],
		'rcount' => [ 1, 2 ],
		'get_matches' => [ 2, 2 ],
		'ip_in_range' => [ 2, 2 ],
		'ip_in_ranges' => [ 2, INF ],
		'contains_any' => [ 2, INF ],
		'contains_all' => [ 2, INF ],
		'equals_to_any' => [ 2, INF ],
		'substr' => [ 2, 3 ],
		'strlen' => [ 1, 1 ],
		'strpos' => [ 2, 3 ],
		'str_replace' => [ 3, 3 ],
		'str_replace_regexp' => [ 3, 3 ],
		'rescape' => [ 1, 1 ],
		'set' => [ 2, 2 ],
		'set_var' => [ 2, 2 ],
		'sanitize' => [ 1, 1 ],
	];

	// Functions that affect parser state, and shouldn't be cached.
	private const ACTIVE_FUNCTIONS = [
		'funcSetVar',
	];

	public const KEYWORDS = [
		'in' => 'keywordIn',
		'like' => 'keywordLike',
		'matches' => 'keywordLike',
		'contains' => 'keywordContains',
		'rlike' => 'keywordRegex',
		'irlike' => 'keywordRegexInsensitive',
		'regex' => 'keywordRegex',
	];

	/**
	 * @var bool Are we allowed to use short-circuit evaluation?
	 */
	private $mAllowShort;

	/**
	 * @var VariableHolder
	 */
	private $mVariables;
	/**
	 * @var int The current amount of conditions being consumed
	 */
	private $mCondCount;
	/**
	 * @var bool Whether the condition limit is enabled.
	 */
	private $condLimitEnabled = true;
	/**
	 * @var string|null The ID of the filter being parsed, if available. Can also be "global-$ID"
	 */
	private $mFilter;
	/**
	 * @var bool Whether we can allow retrieving _builtin_ variables not included in $this->mVariables
	 */
	private $allowMissingVariables = false;

	/**
	 * @var BagOStuff Used to cache the AST and the tokens
	 */
	private $cache;
	/**
	 * @var bool Whether the AST was retrieved from cache
	 */
	private $fromCache = false;
	/**
	 * @var LoggerInterface Used for debugging
	 */
	private $logger;
	/**
	 * @var Language Content language, used for language-dependent functions
	 */
	private $contLang;
	/**
	 * @var IBufferingStatsdDataFactory
	 */
	private $statsd;

	/** @var KeywordsManager */
	private $keywordsManager;

	/** @var VariablesManager */
	private $varManager;

	/** @var int */
	private $conditionsLimit;

	/** @var UserVisibleWarning[] */
	private $warnings = [];

	/**
	 * @var array Cached results of functions
	 */
	private $funcCache = [];

	/**
	 * @var Equivset
	 */
	private $equivset;

	/**
	 * @var array AFPToken::TID values found during node evaluation
	 */
	private $usedVars = [];

	/**
	 * Create a new instance
	 *
	 * @param Language $contLang Content language, used for language-dependent function
	 * @param BagOStuff $cache Used to cache the AST and the tokens
	 * @param LoggerInterface $logger Used for debugging
	 * @param KeywordsManager $keywordsManager
	 * @param VariablesManager $varManager
	 * @param IBufferingStatsdDataFactory $statsdDataFactory
	 * @param Equivset $equivset
	 * @param int $conditionsLimit
	 * @param VariableHolder|null $vars
	 */
	public function __construct(
		Language $contLang,
		BagOStuff $cache,
		LoggerInterface $logger,
		KeywordsManager $keywordsManager,
		VariablesManager $varManager,
		IBufferingStatsdDataFactory $statsdDataFactory,
		Equivset $equivset,
		int $conditionsLimit,
		?VariableHolder $vars = null
	) {
		$this->contLang = $contLang;
		$this->cache = $cache;
		$this->logger = $logger;
		$this->statsd = $statsdDataFactory;
		$this->keywordsManager = $keywordsManager;
		$this->varManager = $varManager;
		$this->equivset = $equivset;
		$this->conditionsLimit = $conditionsLimit;
		$this->resetState();
		if ( $vars ) {
			$this->mVariables = $vars;
		}
	}

	/**
	 * For use in batch scripts and the like
	 *
	 * @param bool $enable True to enable the limit, false to disable it
	 */
	public function toggleConditionLimit( $enable ) {
		$this->condLimitEnabled = $enable;
	}

	/**
	 * @throws ConditionLimitException
	 */
	private function raiseCondCount() {
		$this->mCondCount++;
		if ( $this->condLimitEnabled && $this->mCondCount > $this->conditionsLimit ) {
			throw new ConditionLimitException();
		}
	}

	/**
	 * @param VariableHolder $vars
	 */
	public function setVariables( VariableHolder $vars ) {
		$this->mVariables = $vars;
	}

	/**
	 * Return the generated version of the parser for cache invalidation
	 * purposes.  Automatically tracks list of all functions and invalidates the
	 * cache if it is changed.
	 * @return string
	 */
	private static function getCacheVersion() {
		static $version = null;
		if ( $version !== null ) {
			return $version;
		}

		$versionKey = [
			self::CACHE_VERSION,
			AFPTreeParser::CACHE_VERSION,
			AbuseFilterTokenizer::CACHE_VERSION,
			SyntaxChecker::CACHE_VERSION,
			array_keys( self::FUNCTIONS ),
			array_keys( self::KEYWORDS ),
		];
		$version = hash( 'sha256', serialize( $versionKey ) );

		return $version;
	}

	/**
	 * Resets the state of the parser
	 */
	private function resetState() {
		$this->mVariables = new VariableHolder();
		$this->mCondCount = 0;
		$this->mAllowShort = true;
		$this->mFilter = null;
		$this->warnings = [];
		$this->usedVars = [];
	}

	/**
	 * Check the syntax of $filter, throwing an exception if invalid
	 * @param string $filter
	 * @return true When successful
	 * @throws UserVisibleException
	 */
	public function checkSyntaxThrow( string $filter ): bool {
		$this->allowMissingVariables = true;
		$origAS = $this->mAllowShort;
		try {
			$this->mAllowShort = false;
			$this->evalTree( $this->getTree( $filter ) );
		} finally {
			$this->mAllowShort = $origAS;
			$this->allowMissingVariables = false;
		}

		return true;
	}

	/**
	 * Check the syntax of $filter, without throwing
	 *
	 * @param string $filter
	 * @return ParserStatus
	 */
	public function checkSyntax( string $filter ): ParserStatus {
		$initialConds = $this->mCondCount;
		try {
			$this->checkSyntaxThrow( $filter );
		} catch ( UserVisibleException $excep ) {
		}

		return new ParserStatus(
			$excep ?? null,
			$this->warnings,
			$this->mCondCount - $initialConds
		);
	}

	/**
	 * This is the main entry point. It checks the given conditions and returns whether
	 * they match. Parser errors are always logged.
	 *
	 * @param string $conds
	 * @param string|null $filter The ID of the filter being parsed
	 * @return RuleCheckerStatus
	 */
	public function checkConditions( string $conds, $filter = null ): RuleCheckerStatus {
		$this->mFilter = $filter;
		$excep = null;
		$initialConds = $this->mCondCount;
		$startTime = microtime( true );
		try {
			$res = $this->parse( $conds );
		} catch ( ExceptionBase $excep ) {
			$res = false;
		}
		$this->statsd->timing( 'abusefilter_cachingParser_full', microtime( true ) - $startTime );
		$result = new RuleCheckerStatus(
			$res,
			$this->fromCache,
			$excep,
			$this->warnings,
			$this->mCondCount - $initialConds
		);

		if ( $excep !== null ) {
			if ( $excep instanceof UserVisibleException ) {
				$msg = $excep->getMessageForLogs();
			} else {
				$msg = $excep->getMessage();
			}

			$this->logger->warning(
				"AbuseFilter parser error: {parser_error}",
				[ 'parser_error' => $msg, 'broken_filter' => $filter ?: 'none' ]
			);
		}

		return $result;
	}

	/**
	 * @param string $code
	 * @return bool
	 */
	public function parse( $code ) {
		$res = $this->evalTree( $this->getTree( $code ) );
		return $res->getType() === AFPData::DUNDEFINED ? false : $res->toBool();
	}

	/**
	 * @param string $filter
	 * @return mixed
	 */
	public function evaluateExpression( $filter ) {
		return $this->evalTree( $this->getTree( $filter ) )->toNative();
	}

	/**
	 * @param string $code
	 * @return AFPSyntaxTree
	 */
	private function getTree( $code ): AFPSyntaxTree {
		$this->fromCache = true;
		return $this->cache->getWithSetCallback(
			$this->cache->makeGlobalKey(
				__CLASS__,
				self::getCacheVersion(),
				hash( 'sha256', $code )
			),
			BagOStuff::TTL_DAY,
			function () use ( $code ) {
				$this->fromCache = false;
				$tokenizer = new AbuseFilterTokenizer( $this->cache );
				$tokens = $tokenizer->getTokens( $code );
				$parser = new AFPTreeParser( $this->logger, $this->statsd, $this->keywordsManager );
				$parser->setFilter( $this->mFilter );
				$tree = $parser->parse( $tokens );
				$checker = new SyntaxChecker(
					$tree,
					$this->keywordsManager,
					SyntaxChecker::MCONSERVATIVE,
					false
				);
				$checker->start();
				return $tree;
			}
		);
	}

	/**
	 * @param AFPSyntaxTree $tree
	 * @return AFPData
	 */
	private function evalTree( AFPSyntaxTree $tree ): AFPData {
		$startTime = microtime( true );
		$root = $tree->getRoot();

		if ( !$root ) {
			return new AFPData( AFPData::DNULL );
		}

		$ret = $this->evalNode( $root );
		$this->statsd->timing( 'abusefilter_cachingParser_eval', microtime( true ) - $startTime );
		return $ret;
	}

	/**
	 * Parse a filter and return the variables used.
	 * All variables are AFPToken::TID and are found during the node stepthrough in evaluation
	 * and saved to self::usedVars to be returned to the caller in this function.
	 *
	 * @param string $filter
	 * @return string[]
	 */
	public function getUsedVars( string $filter ): array {
		$this->checkSyntax( $filter );
		return array_unique( $this->usedVars );
	}

	/**
	 * Evaluate the value of the specified AST node.
	 *
	 * @param AFPTreeNode $node The node to evaluate.
	 * @return AFPData|AFPTreeNode|string
	 * @throws ExceptionBase
	 * @throws UserVisibleException
	 */
	private function evalNode( AFPTreeNode $node ) {
		switch ( $node->type ) {
			case AFPTreeNode::ATOM:
				$tok = $node->children;
				switch ( $tok->type ) {
					case AFPToken::TID:
						return $this->getVarValue( strtolower( $tok->value ) );
					case AFPToken::TSTRING:
						return new AFPData( AFPData::DSTRING, $tok->value );
					case AFPToken::TFLOAT:
						return new AFPData( AFPData::DFLOAT, $tok->value );
					case AFPToken::TINT:
						return new AFPData( AFPData::DINT, $tok->value );
					/** @noinspection PhpMissingBreakStatementInspection */
					case AFPToken::TKEYWORD:
						switch ( $tok->value ) {
							case "true":
								return new AFPData( AFPData::DBOOL, true );
							case "false":
								return new AFPData( AFPData::DBOOL, false );
							case "null":
								return new AFPData( AFPData::DNULL );
						}
					// Fallthrough intended
					default:
						// @codeCoverageIgnoreStart
						throw new InternalException( "Unknown token provided in the ATOM node" );
						// @codeCoverageIgnoreEnd
				}
				// Unreachable line
			case AFPTreeNode::ARRAY_DEFINITION:
				$items = [];
				// Foreach is usually faster than array_map
				// @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach children is array here
				foreach ( $node->children as $el ) {
					$items[] = $this->evalNode( $el );
				}
				return new AFPData( AFPData::DARRAY, $items );

			case AFPTreeNode::FUNCTION_CALL:
				$functionName = $node->children[0];
				$args = array_slice( $node->children, 1 );

				$dataArgs = [];
				// Foreach is usually faster than array_map
				foreach ( $args as $arg ) {
					$dataArgs[] = $this->evalNode( $arg );
				}

				return $this->callFunc( $functionName, $dataArgs, $node->position );
			case AFPTreeNode::ARRAY_INDEX:
				[ $array, $offset ] = $node->children;

				$array = $this->evalNode( $array );
				// Note: we MUST evaluate the offset to ensure it is valid, regardless
				// of $array!
				$offset = $this->evalNode( $offset );
				// @todo If $array has no elements we could already throw an outofbounds. We don't
				// know what the index is, though.
				if ( $offset->getType() === AFPData::DUNDEFINED ) {
					return new AFPData( AFPData::DUNDEFINED );
				}
				$offset = $offset->toInt();

				if ( $array->getType() === AFPData::DUNDEFINED ) {
					return new AFPData( AFPData::DUNDEFINED );
				}

				if ( $array->getType() !== AFPData::DARRAY ) {
					throw new UserVisibleException( 'notarray', $node->position, [] );
				}

				$array = $array->toArray();
				if ( count( $array ) <= $offset ) {
					throw new UserVisibleException( 'outofbounds', $node->position,
						[ $offset, count( $array ) ] );
				} elseif ( $offset < 0 ) {
					throw new UserVisibleException( 'negativeindex', $node->position, [ $offset ] );
				}

				return $array[$offset];

			case AFPTreeNode::UNARY:
				[ $operation, $argument ] = $node->children;
				$argument = $this->evalNode( $argument );
				if ( $operation === '-' ) {
					return $argument->unaryMinus();
				}
				return $argument;

			case AFPTreeNode::KEYWORD_OPERATOR:
				[ $keyword, $leftOperand, $rightOperand ] = $node->children;
				$leftOperand = $this->evalNode( $leftOperand );
				$rightOperand = $this->evalNode( $rightOperand );

				return $this->callKeyword( $keyword, $leftOperand, $rightOperand, $node->position );
			case AFPTreeNode::BOOL_INVERT:
				[ $argument ] = $node->children;
				$argument = $this->evalNode( $argument );
				return $argument->boolInvert();

			case AFPTreeNode::POW:
				[ $base, $exponent ] = $node->children;
				$base = $this->evalNode( $base );
				$exponent = $this->evalNode( $exponent );
				return $base->pow( $exponent );

			case AFPTreeNode::MUL_REL:
				[ $op, $leftOperand, $rightOperand ] = $node->children;
				$leftOperand = $this->evalNode( $leftOperand );
				$rightOperand = $this->evalNode( $rightOperand );
				return $leftOperand->mulRel( $rightOperand, $op, $node->position );

			case AFPTreeNode::SUM_REL:
				[ $op, $leftOperand, $rightOperand ] = $node->children;
				$leftOperand = $this->evalNode( $leftOperand );
				$rightOperand = $this->evalNode( $rightOperand );
				switch ( $op ) {
					case '+':
						return $leftOperand->sum( $rightOperand );
					case '-':
						return $leftOperand->sub( $rightOperand );
					default:
						// @codeCoverageIgnoreStart
						throw new InternalException( "Unknown sum-related operator: {$op}" );
						// @codeCoverageIgnoreEnd
				}
				// Unreachable line
			case AFPTreeNode::COMPARE:
				[ $op, $leftOperand, $rightOperand ] = $node->children;
				$leftOperand = $this->evalNode( $leftOperand );
				$rightOperand = $this->evalNode( $rightOperand );
				$this->raiseCondCount();
				return $leftOperand->compareOp( $rightOperand, $op );

			case AFPTreeNode::LOGIC:
				[ $op, $leftOperand, $rightOperand ] = $node->children;
				$leftOperand = $this->evalNode( $leftOperand );
				$value = $leftOperand->getType() === AFPData::DUNDEFINED ? false : $leftOperand->toBool();
				// Short-circuit.
				if ( ( !$value && $op === '&' ) || ( $value && $op === '|' ) ) {
					if ( $rightOperand instanceof AFPTreeNode ) {
						$this->maybeDiscardNode( $rightOperand );
					}
					return $leftOperand;
				}
				$rightOperand = $this->evalNode( $rightOperand );
				return $leftOperand->boolOp( $rightOperand, $op );

			case AFPTreeNode::CONDITIONAL:
				[ $condition, $valueIfTrue, $valueIfFalse ] = $node->children;
				$condition = $this->evalNode( $condition );
				$isTrue = $condition->getType() === AFPData::DUNDEFINED ? false : $condition->toBool();
				if ( $isTrue ) {
					if ( $valueIfFalse !== null ) {
						$this->maybeDiscardNode( $valueIfFalse );
					}
					return $this->evalNode( $valueIfTrue );
				} else {
					$this->maybeDiscardNode( $valueIfTrue );
					return $valueIfFalse !== null
						? $this->evalNode( $valueIfFalse )
						// We assume null as default if the else is missing
						: new AFPData( AFPData::DNULL );
				}

			case AFPTreeNode::ASSIGNMENT:
				[ $varName, $value ] = $node->children;
				$value = $this->evalNode( $value );
				$this->setUserVariable( $varName, $value );
				return $value;

			case AFPTreeNode::INDEX_ASSIGNMENT:
				[ $varName, $offset, $value ] = $node->children;

				$array = $this->getVarValue( $varName );

				if ( $array->getType() !== AFPData::DARRAY && $array->getType() !== AFPData::DUNDEFINED ) {
					throw new UserVisibleException( 'notarray', $node->position, [] );
				}

				$offset = $this->evalNode( $offset );
				// @todo If $array has no elements we could already throw an outofbounds. We don't
				// know what the index is, though.

				if ( $array->getType() !== AFPData::DUNDEFINED ) {
					// If it's a DUNDEFINED, leave it as is
					if ( $offset->getType() !== AFPData::DUNDEFINED ) {
						$offset = $offset->toInt();
						$array = $array->toArray();
						if ( count( $array ) <= $offset ) {
							throw new UserVisibleException( 'outofbounds', $node->position,
								[ $offset, count( $array ) ] );
						} elseif ( $offset < 0 ) {
							throw new UserVisibleException( 'negativeindex', $node->position, [ $offset ] );
						}

						$value = $this->evalNode( $value );
						$array[$offset] = $value;
						$array = new AFPData( AFPData::DARRAY, $array );
					} else {
						$value = $this->evalNode( $value );
						$array = new AFPData( AFPData::DUNDEFINED );
					}
					$this->setUserVariable( $varName, $array );
				} else {
					$value = $this->evalNode( $value );
				}

				return $value;

			case AFPTreeNode::ARRAY_APPEND:
				[ $varName, $value ] = $node->children;

				$array = $this->getVarValue( $varName );
				$value = $this->evalNode( $value );
				if ( $array->getType() !== AFPData::DUNDEFINED ) {
					// If it's a DUNDEFINED, leave it as is
					if ( $array->getType() !== AFPData::DARRAY ) {
						throw new UserVisibleException( 'notarray', $node->position, [] );
					}

					$array = $array->toArray();
					$array[] = $value;
					$this->setUserVariable( $varName, new AFPData( AFPData::DARRAY, $array ) );
				}
				return $value;

			case AFPTreeNode::SEMICOLON:
				$lastValue = null;
				// @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach children is array here
				foreach ( $node->children as $statement ) {
					$lastValue = $this->evalNode( $statement );
				}

				// @phan-suppress-next-next-line PhanTypeMismatchReturnNullable Can never be null because
				// empty statements are discarded in AFPTreeParser
				return $lastValue;
			default:
				// @codeCoverageIgnoreStart
				throw new InternalException( "Unknown node type passed: {$node->type}" );
				// @codeCoverageIgnoreEnd
		}
	}

	/**
	 * Helper to call a built-in function.
	 *
	 * @param string $fname The name of the function as found in the filter code
	 * @param AFPData[] $args Arguments for the function
	 * @param int $position
	 * @return AFPData The return value of the function
	 * @throws InvalidArgumentException if given an invalid func
	 */
	private function callFunc( $fname, array $args, int $position ): AFPData {
		if ( !array_key_exists( $fname, self::FUNCTIONS ) ) {
			// @codeCoverageIgnoreStart
			throw new InvalidArgumentException( "$fname is not a valid function." );
			// @codeCoverageIgnoreEnd
		}

		$funcHandler = self::FUNCTIONS[$fname];
		$funcHash = md5( $funcHandler . serialize( $args ) );

		if ( isset( $this->funcCache[$funcHash] ) &&
			!in_array( $funcHandler, self::ACTIVE_FUNCTIONS )
		) {
			$result = $this->funcCache[$funcHash];
		} else {
			$this->raiseCondCount();

			// Any undefined argument should be special-cased by the function, but that would be too
			// much overhead. We also cannot skip calling the handler in case it's making further
			// validation (T234339). So temporarily replace the DUNDEFINED with a DNULL.
			// @todo This is subpar.
			$hasUndefinedArg = false;
			foreach ( $args as $i => $arg ) {
				if ( $arg->hasUndefined() ) {
					$args[$i] = $arg->cloneAsUndefinedReplacedWithNull();
					$hasUndefinedArg = true;
				}
			}
			if ( $hasUndefinedArg ) {
				// @phan-suppress-next-line PhanParamTooMany Not every function needs the position
				$this->$funcHandler( $args, $position );
				$result = new AFPData( AFPData::DUNDEFINED );
			} else {
				// @phan-suppress-next-line PhanParamTooMany Not every function needs the position
				$result = $this->$funcHandler( $args, $position );
			}
			$this->funcCache[$funcHash] = $result;
		}

		if ( count( $this->funcCache ) > 1000 ) {
			// @codeCoverageIgnoreStart
			$this->funcCache = [];
			// @codeCoverageIgnoreEnd
		}
		return $result;
	}

	/**
	 * Helper to invoke a built-in keyword. Note that this assumes that $kname is
	 * a valid keyword name.
	 *
	 * @param string $kname
	 * @param AFPData $lhs
	 * @param AFPData $rhs
	 * @param int $position
	 * @return AFPData
	 */
	private function callKeyword( $kname, AFPData $lhs, AFPData $rhs, int $position ): AFPData {
		$func = self::KEYWORDS[$kname];
		$this->raiseCondCount();

		$hasUndefinedOperand = false;
		if ( $lhs->hasUndefined() ) {
			$lhs = $lhs->cloneAsUndefinedReplacedWithNull();
			$hasUndefinedOperand = true;
		}
		if ( $rhs->hasUndefined() ) {
			$rhs = $rhs->cloneAsUndefinedReplacedWithNull();
			$hasUndefinedOperand = true;
		}
		if ( $hasUndefinedOperand ) {
			// We need to run the handler with bogus args, see the comment in self::callFunc (T234339)
			// @todo Likewise, this is subpar.
			// @phan-suppress-next-line PhanParamTooMany Not every function needs the position
			$this->$func( $lhs, $rhs, $position );
			$result = new AFPData( AFPData::DUNDEFINED );
		} else {
			// @phan-suppress-next-line PhanParamTooMany Not every function needs the position
			$result = $this->$func( $lhs, $rhs, $position );
		}
		return $result;
	}

	/**
	 * Check whether a variable exists, being either built-in or user-defined. Doesn't include
	 * disabled variables.
	 *
	 * @param string $varname
	 * @return bool
	 */
	private function varExists( $varname ) {
		return $this->keywordsManager->isVarInUse( $varname ) ||
			$this->mVariables->varIsSet( $varname );
	}

	/**
	 * @param string $var
	 * @return AFPData
	 * @throws UserVisibleException
	 */
	private function getVarValue( $var ) {
		$var = strtolower( $var );
		$deprecatedVars = $this->keywordsManager->getDeprecatedVariables();

		if ( array_key_exists( $var, $deprecatedVars ) ) {
			$var = $deprecatedVars[ $var ];
		}
		// With check syntax, all unbound variables will be caught
		// already. So we do not error unbound variables at runtime,
		// allowing it to result in DUNDEFINED.
		$allowMissingVariables = !$this->varExists( $var ) || $this->allowMissingVariables;

		array_push( $this->usedVars, $var );

		// It's a built-in, non-disabled variable (either set or unset), or a set custom variable
		$flags = $allowMissingVariables
			? VariablesManager::GET_LAX
			// TODO: This should be GET_STRICT, but that's going to be very hard (see T230256)
			: VariablesManager::GET_BC;
		return $this->varManager->getVar( $this->mVariables, $var, $flags );
	}

	/**
	 * @param string $name
	 * @param mixed $value
	 * @throws UserVisibleException
	 */
	private function setUserVariable( $name, $value ) {
		$this->mVariables->setVar( $name, $value );
	}

	// Built-in functions

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcLc( $args ) {
		$s = $args[0]->toString();

		return new AFPData( AFPData::DSTRING, $this->contLang->lc( $s ) );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcUc( $args ) {
		$s = $args[0]->toString();

		return new AFPData( AFPData::DSTRING, $this->contLang->uc( $s ) );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcLen( $args ) {
		if ( $args[0]->type === AFPData::DARRAY ) {
			// Don't use toString on arrays, but count
			$val = count( $args[0]->data );
		} else {
			$val = mb_strlen( $args[0]->toString(), 'utf-8' );
		}

		return new AFPData( AFPData::DINT, $val );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcSpecialRatio( $args ) {
		$s = $args[0]->toString();

		if ( !strlen( $s ) ) {
			return new AFPData( AFPData::DFLOAT, 0 );
		}

		$nospecials = $this->rmspecials( $s );

		$val = 1. - ( ( mb_strlen( $nospecials ) / mb_strlen( $s ) ) );

		return new AFPData( AFPData::DFLOAT, $val );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcCount( $args ) {
		if ( $args[0]->type === AFPData::DARRAY && count( $args ) === 1 ) {
			return new AFPData( AFPData::DINT, count( $args[0]->data ) );
		}

		if ( count( $args ) === 1 ) {
			$count = count( explode( ',', $args[0]->toString() ) );
		} else {
			$needle = $args[0]->toString();
			$haystack = $args[1]->toString();

			// T62203: Keep empty parameters from causing PHP warnings
			if ( $needle === '' ) {
				$count = 0;
			} else {
				$count = substr_count( $haystack, $needle );
			}
		}

		return new AFPData( AFPData::DINT, $count );
	}

	/**
	 * @param array $args
	 * @param int $position
	 * @return AFPData
	 * @throws UserVisibleException
	 */
	private function funcRCount( $args, int $position ) {
		if ( count( $args ) === 1 ) {
			$count = count( explode( ',', $args[0]->toString() ) );
		} else {
			$needle = $args[0]->toString();
			$haystack = $args[1]->toString();

			$needle = $this->mungeRegexp( $needle );

			$this->checkRegexMatchesEmpty( $args[0], $needle, $position );
			// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
			$count = @preg_match_all( $needle, $haystack );

			if ( $count === false ) {
				throw new UserVisibleException(
					'regexfailure',
					$position,
					[ $needle ]
				);
			}
		}

		return new AFPData( AFPData::DINT, $count );
	}

	/**
	 * Returns an array of matches of needle in the haystack, the first one for the whole regex,
	 * the other ones for every capturing group.
	 *
	 * @param array $args
	 * @param int $position
	 * @return AFPData An array of matches.
	 * @throws UserVisibleException
	 */
	private function funcGetMatches( $args, int $position ) {
		$needle = $args[0]->toString();
		$haystack = $args[1]->toString();

		// Count the amount of capturing groups in the submitted pattern.
		// This way we can return a fixed-dimension array, much easier to manage.
		// ToDo: Find a better way to do this.
		// First, strip away escaped parentheses
		$sanitized = preg_replace( '/((\\\\\\\\)*)\\\\\(/', '$1', $needle );

		// Then strip starting parentheses of non-capturing groups, including
		// atomics, lookaheads and so on, even if not every of them is supported.
		// Avoid stripping named capturing groups: (?P<name>), (?<name>) and (?'name')
		$sanitized = preg_replace( '/\(\?(?!P?<[a-zA-Z_][a-zA-Z0-9_]*>|\'[a-zA-Z_][a-zA-Z0-9_]*\')/', '', $sanitized );

		// And also strip "(*", used with backtracking verbs like (*FAIL)
		$sanitized = str_replace( '(*', '', $sanitized );

		// Finally create an array of falses with dimension = # of capturing groups + 1
		// (as there is also the 0 element, which contains the whole match)
		$groupscount = substr_count( $sanitized, '(' ) + 1;
		$falsy = array_fill( 0, $groupscount, false );

		$needle = $this->mungeRegexp( $needle );

		$this->checkRegexMatchesEmpty( $args[0], $needle, $position );
		// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
		$check = @preg_match( $needle, $haystack, $matches );

		if ( $check === false ) {
			throw new UserVisibleException(
				'regexfailure',
				$position,
				[ $needle ]
			);
		}

		// Named capturing groups add the capture twice: with a numeric key and with a string key.
		// AF doesn't provide associative arrays, thus we have to filter out the elements with string keys,
		// else AFPData::newFromPHPVar would erroneously insert them into the final array, with numeric keys.
		$matches = array_filter( $matches, 'is_int', ARRAY_FILTER_USE_KEY );

		// Returned array has non-empty positions identical to the ones returned
		// by the third parameter of a standard preg_match call ($matches in this case).
		// We want an union with falsy to return a fixed-dimension array.
		return AFPData::newFromPHPVar( $matches + $falsy );
	}

	/**
	 * @param array $args
	 * @param int $position
	 * @return AFPData
	 * @throws UserVisibleException
	 */
	private function funcIPInRange( $args, int $position ) {
		$ip = $args[0]->toString();
		$range = $args[1]->toString();

		if ( !IPUtils::isValidRange( $range ) && !IPUtils::isIPAddress( $range ) ) {
			throw new UserVisibleException(
				'invalidiprange',
				$position,
				[ $range ]
			);
		}

		$result = IPUtils::isInRange( $ip, $range );

		return new AFPData( AFPData::DBOOL, $result );
	}

	/**
	 * @param array $args
	 * @param int $position
	 * @return AFPData
	 * @throws UserVisibleException
	 */
	private function funcIPInRanges( $args, int $position ) {
		$ip = array_shift( $args )->toString();

		$strRanges = [];
		foreach ( $args as $range ) {
			$range = $range->toString();

			if ( !IPUtils::isValidRange( $range ) && !IPUtils::isIPAddress( $range ) ) {
				throw new UserVisibleException(
					'invalidiprange',
					$position,
					[ $range ]
				);
			}

			$strRanges[] = $range;
		}

		return new AFPData( AFPData::DBOOL, IPUtils::isInRanges( $ip, $strRanges ) );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcCCNorm( $args ) {
		$s = $args[0]->toString();

		$s = html_entity_decode( $s, ENT_QUOTES, 'UTF-8' );
		$s = $this->ccnorm( $s );

		return new AFPData( AFPData::DSTRING, $s );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcSanitize( $args ) {
		$s = $args[0]->toString();

		$s = html_entity_decode( $s, ENT_QUOTES, 'UTF-8' );
		$s = Sanitizer::decodeCharReferences( $s );

		return new AFPData( AFPData::DSTRING, $s );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcContainsAny( $args ) {
		$s = array_shift( $args );

		return new AFPData( AFPData::DBOOL, $this->contains( $s, $args, true ) );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcContainsAll( $args ) {
		$s = array_shift( $args );

		return new AFPData( AFPData::DBOOL, $this->contains( $s, $args, false, false ) );
	}

	/**
	 * Normalize and search a string for multiple substrings in OR mode
	 *
	 * @param array $args
	 * @return AFPData
	 */
	private function funcCCNormContainsAny( $args ) {
		$s = array_shift( $args );

		return new AFPData( AFPData::DBOOL, $this->contains( $s, $args, true, true ) );
	}

	/**
	 * Normalize and search a string for multiple substrings in AND mode
	 *
	 * @param array $args
	 * @return AFPData
	 */
	private function funcCCNormContainsAll( $args ) {
		$s = array_shift( $args );

		return new AFPData( AFPData::DBOOL, $this->contains( $s, $args, false, true ) );
	}

	/**
	 * Search for substrings in a string
	 *
	 * Use is_any to determine whether to use logic OR (true) or AND (false).
	 *
	 * Use normalize = true to make use of ccnorm and
	 * normalize both sides of the search.
	 *
	 * @param AFPData $string
	 * @param AFPData[] $values
	 * @param bool $is_any
	 * @param bool $normalize
	 *
	 * @return bool
	 */
	private function contains( $string, $values, $is_any = true, $normalize = false ) {
		$string = $string->toString();

		if ( $string === '' ) {
			return false;
		}

		if ( $normalize ) {
			$string = $this->ccnorm( $string );
		}

		foreach ( $values as $needle ) {
			$needle = $needle->toString();
			if ( $normalize ) {
				$needle = $this->ccnorm( $needle );
			}
			if ( $needle === '' ) {
				// T62203: Keep empty parameters from causing PHP warnings
				continue;
			}

			$is_found = strpos( $string, $needle ) !== false;
			if ( $is_found === $is_any ) {
				// If I'm here and it's ANY (OR) => something is found.
				// If I'm here and it's ALL (AND) => nothing is found.
				// In both cases, we've had enough.
				return $is_found;
			}
		}

		// If I'm here and it's ANY (OR) => nothing was found: return false ($is_any is true)
		// If I'm here and it's ALL (AND) => everything was found: return true ($is_any is false)
		return !$is_any;
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcEqualsToAny( $args ) {
		$s = array_shift( $args );

		return new AFPData( AFPData::DBOOL, self::equalsToAny( $s, $args ) );
	}

	/**
	 * Check if the given string is equals to any of the following strings
	 *
	 * @param AFPData $string
	 * @param AFPData[] $values
	 *
	 * @return bool
	 */
	private static function equalsToAny( $string, $values ) {
		foreach ( $values as $needle ) {
			if ( $string->equals( $needle, true ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * @param string $s
	 * @return string
	 */
	private function ccnorm( $s ): string {
		return $this->equivset->normalize( $s );
	}

	/**
	 * @param string $s
	 * @return array|string
	 */
	private function rmspecials( $s ) {
		// (T385452) Disable JIT for this call, as it breaks sometimes
		ini_set( 'pcre.jit', '0' );
		$res = preg_replace( '/[^\p{L}\p{N}\s]/u', '', $s );
		ini_restore( 'pcre.jit' );
		return $res;
	}

	/**
	 * @param string $s
	 * @return array|string
	 */
	private function rmdoubles( $s ) {
		// (T385452) Disable JIT for this call, as it breaks sometimes
		ini_set( 'pcre.jit', '0' );
		$res = preg_replace( '/(.)\1+/us', '\1', $s );
		ini_restore( 'pcre.jit' );
		return $res;
	}

	/**
	 * @param string $s
	 * @return array|string
	 */
	private function rmwhitespace( $s ) {
		return preg_replace( '/\s+/u', '', $s );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcRMSpecials( $args ) {
		$s = $args[0]->toString();

		return new AFPData( AFPData::DSTRING, $this->rmspecials( $s ) );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcRMWhitespace( $args ) {
		$s = $args[0]->toString();

		return new AFPData( AFPData::DSTRING, $this->rmwhitespace( $s ) );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcRMDoubles( $args ) {
		$s = $args[0]->toString();

		return new AFPData( AFPData::DSTRING, $this->rmdoubles( $s ) );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcNorm( $args ) {
		$s = $args[0]->toString();

		$s = $this->ccnorm( $s );
		$s = $this->rmdoubles( $s );
		$s = $this->rmspecials( $s );
		$s = $this->rmwhitespace( $s );

		return new AFPData( AFPData::DSTRING, $s );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcSubstr( $args ) {
		$s = $args[0]->toString();
		$offset = $args[1]->toInt();
		$length = isset( $args[2] ) ? $args[2]->toInt() : null;

		$result = mb_substr( $s, $offset, $length );

		return new AFPData( AFPData::DSTRING, $result );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcStrPos( $args ) {
		$haystack = $args[0]->toString();
		$needle = $args[1]->toString();
		$offset = isset( $args[2] ) ? $args[2]->toInt() : 0;

		// T62203: Keep empty parameters from causing PHP warnings
		if ( $needle === '' ) {
			return new AFPData( AFPData::DINT, -1 );
		}
		// Special handling for when the offset is not contained in $haystack. PHP can emit a warning
		// or throw an error depending on the version (T285978). TODO Should we also throw?
		if ( $offset > mb_strlen( $haystack ) ) {
			return new AFPData( AFPData::DINT, -1 );
		}
		$result = mb_strpos( $haystack, $needle, $offset );

		if ( $result === false ) {
			$result = -1;
		}

		return new AFPData( AFPData::DINT, $result );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcStrReplace( $args ) {
		$subject = $args[0]->toString();
		$search = $args[1]->toString();
		$replace = $args[2]->toString();

		return new AFPData( AFPData::DSTRING, str_replace( $search, $replace, $subject ) );
	}

	/**
	 * @param array $args
	 * @param int $position
	 * @return AFPData
	 */
	private function funcStrReplaceRegexp( $args, int $position ) {
		$subject = $args[0]->toString();
		$search = $args[1]->toString();
		$replace = $args[2]->toString();

		$this->checkRegexMatchesEmpty( $args[1], $search, $position );
		// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
		$result = @preg_replace(
			$this->mungeRegexp( $search ),
			$replace,
			$subject
		);

		if ( $result === null ) {
			throw new UserVisibleException(
				'regexfailure',
				$position,
				[ $search ]
			);
		}

		return new AFPData( AFPData::DSTRING, $result );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function funcStrRegexEscape( $args ) {
		$string = $args[0]->toString();

		// preg_quote does not need the second parameter, since rlike takes
		// care of the delimiter symbol itself
		return new AFPData( AFPData::DSTRING, preg_quote( $string ) );
	}

	/**
	 * @param array $args
	 * @return mixed
	 */
	private function funcSetVar( $args ) {
		$varName = $args[0]->toString();
		$value = $args[1];

		$this->setUserVariable( $varName, $value );

		return $value;
	}

	/**
	 * Checks if $a contains $b
	 *
	 * @param AFPData $a
	 * @param AFPData $b
	 * @return AFPData
	 */
	private function containmentKeyword( AFPData $a, AFPData $b ) {
		$a = $a->toString();
		$b = $b->toString();

		if ( $a === '' || $b === '' ) {
			return new AFPData( AFPData::DBOOL, false );
		}

		return new AFPData( AFPData::DBOOL, strpos( $a, $b ) !== false );
	}

	/**
	 * @param AFPData $a
	 * @param AFPData $b
	 * @return AFPData
	 */
	private function keywordIn( AFPData $a, AFPData $b ) {
		return $this->containmentKeyword( $b, $a );
	}

	/**
	 * @param AFPData $a
	 * @param AFPData $b
	 * @return AFPData
	 */
	private function keywordContains( AFPData $a, AFPData $b ) {
		return $this->containmentKeyword( $a, $b );
	}

	/**
	 * @param AFPData $str
	 * @param AFPData $pattern
	 * @return AFPData
	 */
	private function keywordLike( AFPData $str, AFPData $pattern ) {
		$str = $str->toString();
		$pattern = '#^' . strtr( preg_quote( $pattern->toString(), '#' ), AFPData::WILDCARD_MAP ) . '$#u';
		// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
		$result = @preg_match( $pattern, $str );

		return new AFPData( AFPData::DBOOL, (bool)$result );
	}

	/**
	 * @param AFPData $str
	 * @param AFPData $regex
	 * @param int $pos
	 * @param bool $insensitive
	 * @return AFPData
	 * @throws Exception
	 */
	private function keywordRegex( AFPData $str, AFPData $regex, $pos, $insensitive = false ) {
		$str = $str->toString();
		$pattern = $regex->toString();

		$pattern = $this->mungeRegexp( $pattern );

		if ( $insensitive ) {
			$pattern .= 'i';
		}

		$this->checkRegexMatchesEmpty( $regex, $pattern, $pos );
		// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
		$result = @preg_match( $pattern, $str );
		if ( $result === false ) {
			throw new UserVisibleException(
				'regexfailure',
				// Coverage bug
				// @codeCoverageIgnoreStart
				$pos,
				// @codeCoverageIgnoreEnd
				[ $pattern ]
			);
		}

		return new AFPData( AFPData::DBOOL, (bool)$result );
	}

	/**
	 * @param AFPData $str
	 * @param AFPData $regex
	 * @param int $pos
	 * @return AFPData
	 */
	private function keywordRegexInsensitive( AFPData $str, AFPData $regex, $pos ) {
		return $this->keywordRegex( $str, $regex, $pos, true );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function castString( $args ) {
		return AFPData::castTypes( $args[0], AFPData::DSTRING );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function castInt( $args ) {
		return AFPData::castTypes( $args[0], AFPData::DINT );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function castFloat( $args ) {
		return AFPData::castTypes( $args[0], AFPData::DFLOAT );
	}

	/**
	 * @param array $args
	 * @return AFPData
	 */
	private function castBool( $args ) {
		return AFPData::castTypes( $args[0], AFPData::DBOOL );
	}

	/**
	 * Given a node that we don't need to evaluate, decide what to do with it.
	 * The nodes passed in will usually be discarded by short-circuit
	 * evaluation. If we don't allow it, we fully evaluate the node.
	 *
	 * @param AFPTreeNode $node
	 */
	private function maybeDiscardNode( AFPTreeNode $node ) {
		if ( !$this->mAllowShort ) {
			$this->evalNode( $node );
		}
	}

	/**
	 * Given a regexp in the AF syntax, make it PCRE-compliant (i.e. we need to escape slashes, add
	 * delimiters and modifiers).
	 *
	 * @param string $rawRegexp
	 * @return string
	 */
	private function mungeRegexp( string $rawRegexp ): string {
		$needle = preg_replace( '!(\\\\\\\\)*(\\\\)?/!', '$1\/', $rawRegexp );
		return "/$needle/u";
	}

	/**
	 * Check whether the provided regex matches the empty string.
	 * @note This method can generate a PHP notice if the regex is invalid
	 *
	 * @param AFPData $regex TODO Can we avoid passing this in?
	 * @param string $pattern Already munged
	 * @param int $position
	 */
	private function checkRegexMatchesEmpty( AFPData $regex, string $pattern, int $position ): void {
		if ( $regex->getType() === AFPData::DUNDEFINED ) {
			// We can't tell, and toString() would return the empty string (T273809)
			return;
		}
		// @phan-suppress-next-next-line PhanParamSuspiciousOrder
		// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
		if ( @preg_match( $pattern, '' ) === 1 ) {
			$this->warnings[] = new UserVisibleWarning(
				'match-empty-regex',
				$position,
				[]
			);
		}
	}
}
PK       ! 툄4  4    Parser/AFPData.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser;

use InvalidArgumentException;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\InternalException;
use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleException;
use RuntimeException;

class AFPData {
	// Datatypes
	public const DINT = 'int';
	public const DSTRING = 'string';
	public const DNULL = 'null';
	public const DBOOL = 'bool';
	public const DFLOAT = 'float';
	public const DARRAY = 'array';
	// Special purpose type for non-initialized stuff
	public const DUNDEFINED = 'undefined';

	/**
	 * Translation table mapping shell-style wildcards to PCRE equivalents.
	 * Derived from <http://www.php.net/manual/en/function.fnmatch.php#100207>
	 * @internal
	 */
	public const WILDCARD_MAP = [
		'\*' => '.*',
		'\+' => '\+',
		'\-' => '\-',
		'\.' => '\.',
		'\?' => '.',
		'\[' => '[',
		'\[\!' => '[^',
		'\\' => '\\\\',
		'\]' => ']',
	];

	/**
	 * @var string One of the D* const from this class
	 * @internal Use $this->getType() instead
	 */
	public $type;
	/**
	 * @var mixed|null|AFPData[] The actual data contained in this object
	 * @internal Use $this->getData() instead
	 */
	public $data;

	/**
	 * @return string
	 */
	public function getType() {
		return $this->type;
	}

	/**
	 * @return AFPData[]|mixed|null
	 */
	public function getData() {
		return $this->data;
	}

	/**
	 * @param string $type
	 * @param AFPData[]|mixed|null $val
	 */
	public function __construct( $type, $val = null ) {
		if ( $type === self::DUNDEFINED && $val !== null ) {
			// Sanity
			throw new InvalidArgumentException( 'DUNDEFINED cannot have a non-null value' );
		}
		$this->type = $type;
		$this->data = $val;
	}

	/**
	 * @param mixed $var
	 * @return AFPData
	 * @throws InternalException
	 */
	public static function newFromPHPVar( $var ) {
		switch ( gettype( $var ) ) {
			case 'string':
				return new AFPData( self::DSTRING, $var );
			case 'integer':
				return new AFPData( self::DINT, $var );
			case 'double':
				return new AFPData( self::DFLOAT, $var );
			case 'boolean':
				return new AFPData( self::DBOOL, $var );
			case 'array':
				$result = [];
				foreach ( $var as $item ) {
					$result[] = self::newFromPHPVar( $item );
				}
				return new AFPData( self::DARRAY, $result );
			case 'NULL':
				return new AFPData( self::DNULL );
			default:
				throw new InternalException(
					'Data type ' . get_debug_type( $var ) . ' is not supported by AbuseFilter'
				);
		}
	}

	/**
	 * @param AFPData $orig
	 * @param string $target
	 * @return AFPData
	 */
	public static function castTypes( AFPData $orig, $target ) {
		if ( $orig->type === $target ) {
			return $orig;
		}
		if ( $orig->type === self::DUNDEFINED ) {
			// This case should be handled at a higher level, to avoid implicitly relying on what
			// this method will do for the specific case.
			throw new InternalException( 'Refusing to cast DUNDEFINED to something else' );
		}
		if ( $target === self::DNULL ) {
			// We don't expose any method to cast to null. And, actually, should we?
			return new AFPData( self::DNULL );
		}

		if ( $orig->type === self::DARRAY ) {
			if ( $target === self::DBOOL ) {
				return new AFPData( self::DBOOL, (bool)count( $orig->data ) );
			} elseif ( $target === self::DFLOAT ) {
				return new AFPData( self::DFLOAT, floatval( count( $orig->data ) ) );
			} elseif ( $target === self::DINT ) {
				return new AFPData( self::DINT, count( $orig->data ) );
			} elseif ( $target === self::DSTRING ) {
				$s = '';
				foreach ( $orig->data as $item ) {
					$s .= $item->toString() . "\n";
				}

				return new AFPData( self::DSTRING, $s );
			}
		}

		if ( $target === self::DBOOL ) {
			return new AFPData( self::DBOOL, (bool)$orig->data );
		} elseif ( $target === self::DFLOAT ) {
			return new AFPData( self::DFLOAT, floatval( $orig->data ) );
		} elseif ( $target === self::DINT ) {
			return new AFPData( self::DINT, intval( $orig->data ) );
		} elseif ( $target === self::DSTRING ) {
			return new AFPData( self::DSTRING, strval( $orig->data ) );
		} elseif ( $target === self::DARRAY ) {
			// We don't expose any method to cast to array
			return new AFPData( self::DARRAY, [ $orig ] );
		}
		throw new InternalException( 'Cannot cast ' . $orig->type . " to $target." );
	}

	/**
	 * @return AFPData
	 */
	public function boolInvert() {
		if ( $this->type === self::DUNDEFINED ) {
			return new AFPData( self::DUNDEFINED );
		}
		return new AFPData( self::DBOOL, !$this->toBool() );
	}

	/**
	 * @param AFPData $exponent
	 * @return AFPData
	 */
	public function pow( AFPData $exponent ) {
		if ( $this->type === self::DUNDEFINED || $exponent->type === self::DUNDEFINED ) {
			return new AFPData( self::DUNDEFINED );
		}
		$res = pow( $this->toNumber(), $exponent->toNumber() );
		$type = is_int( $res ) ? self::DINT : self::DFLOAT;

		return new AFPData( $type, $res );
	}

	/**
	 * @param AFPData $d2
	 * @param bool $strict whether to also check types
	 * @return bool
	 * @throws InternalException if $this or $d2 is a DUNDEFINED. This shouldn't happen, because this method
	 *  only returns a boolean, and thus the type of the result has already been decided and cannot
	 *  be changed to be a DUNDEFINED from here.
	 * @internal
	 */
	public function equals( AFPData $d2, $strict = false ) {
		if ( $this->type === self::DUNDEFINED || $d2->type === self::DUNDEFINED ) {
			throw new InternalException(
				__METHOD__ . " got a DUNDEFINED. This should be handled at a higher level"
			);
		} elseif ( $this->type !== self::DARRAY && $d2->type !== self::DARRAY ) {
			$typecheck = $this->type === $d2->type || !$strict;
			return $typecheck && $this->toString() === $d2->toString();
		} elseif ( $this->type === self::DARRAY && $d2->type === self::DARRAY ) {
			$data1 = $this->data;
			$data2 = $d2->data;
			if ( count( $data1 ) !== count( $data2 ) ) {
				return false;
			}
			$length = count( $data1 );
			for ( $i = 0; $i < $length; $i++ ) {
				// @phan-suppress-next-line PhanTypeArraySuspiciousNullable Array type
				if ( $data1[$i]->equals( $data2[$i], $strict ) === false ) {
					return false;
				}
			}
			return true;
		} else {
			// Trying to compare an array to something else
			if ( $strict ) {
				return false;
			}
			if ( $this->type === self::DARRAY && count( $this->data ) === 0 ) {
				return ( $d2->type === self::DBOOL && $d2->toBool() === false ) || $d2->type === self::DNULL;
			} elseif ( $d2->type === self::DARRAY && count( $d2->data ) === 0 ) {
				return ( $this->type === self::DBOOL && $this->toBool() === false ) ||
					$this->type === self::DNULL;
			} else {
				return false;
			}
		}
	}

	/**
	 * @return AFPData
	 */
	public function unaryMinus() {
		if ( $this->type === self::DUNDEFINED ) {
			return new AFPData( self::DUNDEFINED );
		} elseif ( $this->type === self::DINT ) {
			return new AFPData( $this->type, -$this->toInt() );
		} else {
			return new AFPData( $this->type, -$this->toFloat() );
		}
	}

	/**
	 * @param AFPData $b
	 * @param string $op
	 * @return AFPData
	 * @throws InternalException
	 */
	public function boolOp( AFPData $b, $op ) {
		$a = $this->type === self::DUNDEFINED ? false : $this->toBool();
		$b = $b->type === self::DUNDEFINED ? false : $b->toBool();

		if ( $op === '|' ) {
			return new AFPData( self::DBOOL, $a || $b );
		} elseif ( $op === '&' ) {
			return new AFPData( self::DBOOL, $a && $b );
		} elseif ( $op === '^' ) {
			return new AFPData( self::DBOOL, $a xor $b );
		}
		// Should never happen.
		// @codeCoverageIgnoreStart
		throw new InternalException( "Invalid boolean operation: {$op}" );
		// @codeCoverageIgnoreEnd
	}

	/**
	 * @param AFPData $b
	 * @param string $op
	 * @return AFPData
	 * @throws InternalException
	 */
	public function compareOp( AFPData $b, $op ) {
		if ( $this->type === self::DUNDEFINED || $b->type === self::DUNDEFINED ) {
			return new AFPData( self::DUNDEFINED );
		}
		if ( $op === '==' || $op === '=' ) {
			return new AFPData( self::DBOOL, $this->equals( $b ) );
		} elseif ( $op === '!=' ) {
			return new AFPData( self::DBOOL, !$this->equals( $b ) );
		} elseif ( $op === '===' ) {
			return new AFPData( self::DBOOL, $this->equals( $b, true ) );
		} elseif ( $op === '!==' ) {
			return new AFPData( self::DBOOL, !$this->equals( $b, true ) );
		}

		$a = $this->toString();
		$b = $b->toString();
		if ( $op === '>' ) {
			return new AFPData( self::DBOOL, $a > $b );
		} elseif ( $op === '<' ) {
			return new AFPData( self::DBOOL, $a < $b );
		} elseif ( $op === '>=' ) {
			return new AFPData( self::DBOOL, $a >= $b );
		} elseif ( $op === '<=' ) {
			return new AFPData( self::DBOOL, $a <= $b );
		}
		// Should never happen
		// @codeCoverageIgnoreStart
		throw new InternalException( "Invalid comparison operation: {$op}" );
		// @codeCoverageIgnoreEnd
	}

	/**
	 * @param AFPData $b
	 * @param string $op
	 * @param int $pos
	 * @return AFPData
	 * @throws UserVisibleException
	 * @throws InternalException
	 */
	public function mulRel( AFPData $b, $op, $pos ) {
		if ( $b->type === self::DUNDEFINED ) {
			// The LHS type is checked later, because we first need to ensure we're not
			// dividing or taking modulo by 0 (and that should throw regardless of whether
			// the LHS is undefined).
			return new AFPData( self::DUNDEFINED );
		}

		$b = $b->toNumber();

		if (
			( $op === '/' && (float)$b === 0.0 ) ||
			( $op === '%' && (int)$b === 0 )
		) {
			$lhs = $this->type === self::DUNDEFINED ? 0 : $this->toNumber();
			throw new UserVisibleException( 'dividebyzero', $pos, [ $lhs ] );
		}

		if ( $this->type === self::DUNDEFINED ) {
			return new AFPData( self::DUNDEFINED );
		}
		$a = $this->toNumber();

		if ( $op === '*' ) {
			$data = $a * $b;
		} elseif ( $op === '/' ) {
			$data = $a / $b;
		} elseif ( $op === '%' ) {
			$data = (int)$a % (int)$b;
		} else {
			// Should never happen
			// @codeCoverageIgnoreStart
			throw new InternalException( "Invalid multiplication-related operation: {$op}" );
			// @codeCoverageIgnoreEnd
		}

		$type = is_int( $data ) ? self::DINT : self::DFLOAT;

		return new AFPData( $type, $data );
	}

	/**
	 * @param AFPData $b
	 * @return AFPData
	 */
	public function sum( AFPData $b ) {
		if ( $this->type === self::DUNDEFINED || $b->type === self::DUNDEFINED ) {
			return new AFPData( self::DUNDEFINED );
		} elseif ( $this->type === self::DSTRING || $b->type === self::DSTRING ) {
			return new AFPData( self::DSTRING, $this->toString() . $b->toString() );
		} elseif ( $this->type === self::DARRAY && $b->type === self::DARRAY ) {
			return new AFPData( self::DARRAY, array_merge( $this->toArray(), $b->toArray() ) );
		} else {
			$res = $this->toNumber() + $b->toNumber();
			$type = is_int( $res ) ? self::DINT : self::DFLOAT;

			return new AFPData( $type, $res );
		}
	}

	/**
	 * @param AFPData $b
	 * @return AFPData
	 */
	public function sub( AFPData $b ) {
		if ( $this->type === self::DUNDEFINED || $b->type === self::DUNDEFINED ) {
			return new AFPData( self::DUNDEFINED );
		}
		$res = $this->toNumber() - $b->toNumber();
		$type = is_int( $res ) ? self::DINT : self::DFLOAT;

		return new AFPData( $type, $res );
	}

	/**
	 * Check whether this instance contains the DUNDEFINED type, recursively
	 * @return bool
	 */
	public function hasUndefined(): bool {
		if ( $this->type === self::DUNDEFINED ) {
			return true;
		}
		if ( $this->type === self::DARRAY ) {
			foreach ( $this->data as $el ) {
				if ( $el->hasUndefined() ) {
					return true;
				}
			}
		}
		return false;
	}

	/**
	 * Return a clone of this instance where DUNDEFINED is replaced with DNULL
	 * @return $this
	 */
	public function cloneAsUndefinedReplacedWithNull(): self {
		if ( $this->type === self::DUNDEFINED ) {
			return new self( self::DNULL );
		}
		if ( $this->type === self::DARRAY ) {
			$data = [];
			foreach ( $this->data as $el ) {
				$data[] = $el->cloneAsUndefinedReplacedWithNull();
			}
			return new self( self::DARRAY, $data );
		}
		return clone $this;
	}

	/** Convert shorteners */

	/**
	 * @throws RuntimeException
	 * @return mixed
	 */
	public function toNative() {
		switch ( $this->type ) {
			case self::DBOOL:
				return $this->toBool();
			case self::DSTRING:
				return $this->toString();
			case self::DFLOAT:
				return $this->toFloat();
			case self::DINT:
				return $this->toInt();
			case self::DARRAY:
				$input = $this->toArray();
				$output = [];
				foreach ( $input as $item ) {
					$output[] = $item->toNative();
				}

				return $output;
			case self::DNULL:
			case self::DUNDEFINED:
				return null;
			default:
				// @codeCoverageIgnoreStart
				throw new RuntimeException( "Unknown type" );
				// @codeCoverageIgnoreEnd
		}
	}

	/**
	 * @return bool
	 */
	public function toBool() {
		return self::castTypes( $this, self::DBOOL )->data;
	}

	/**
	 * @return string
	 */
	public function toString() {
		return self::castTypes( $this, self::DSTRING )->data;
	}

	/**
	 * @return float
	 */
	public function toFloat() {
		return self::castTypes( $this, self::DFLOAT )->data;
	}

	/**
	 * @return int
	 */
	public function toInt() {
		return self::castTypes( $this, self::DINT )->data;
	}

	/**
	 * @return int|float
	 */
	public function toNumber() {
		// Types that can be cast to int
		$intLikeTypes = [
			self::DINT,
			self::DBOOL,
			self::DNULL
		];
		return in_array( $this->type, $intLikeTypes, true ) ? $this->toInt() : $this->toFloat();
	}

	/**
	 * @return array
	 */
	public function toArray() {
		return self::castTypes( $this, self::DARRAY )->data;
	}
}
PK       ! _$      Parser/AbuseFilterTokenizer.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Parser;

use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleException;
use Wikimedia\ObjectCache\BagOStuff;

/**
 * Tokenizer for AbuseFilter rules.
 */
class AbuseFilterTokenizer {
	/** @var int Tokenizer cache version. Increment this when changing the syntax. */
	public const CACHE_VERSION = 4;
	private const COMMENT_START_RE = '/\s*\/\*/A';
	private const ID_SYMBOL_RE = '/[0-9A-Za-z_]+/A';
	public const OPERATOR_RE =
		'/(\!\=\=|\!\=|\!|\*\*|\*|\/|\+|\-|%|&|\||\^|\:\=|\?|\:|\<\=|\<|\>\=|\>|\=\=\=|\=\=|\=)/A';
	private const BASE = '0(?<base>[xbo])';
	private const DIGIT = '[0-9A-Fa-f]';
	private const DIGITS = self::DIGIT . '+' . '(?:\.\d*)?|\.\d+';
	private const RADIX_RE = '/(?:' . self::BASE . ')?(?<input>' . self::DIGITS . ')(?!\w)/Au';
	private const WHITESPACE = "\011\012\013\014\015\040";

	// Order is important. The punctuation-matching regex requires that
	// ** comes before *, etc. They are sorted to make it easy to spot
	// such errors.
	public const OPERATORS = [
		// Inequality
		'!==', '!=', '!',
		// Multiplication/exponentiation
		'**', '*',
		// Other arithmetic
		'/', '+', '-', '%',
		// Logic
		'&', '|', '^',
		// Setting
		':=',
		// Ternary
		'?', ':',
		// Less than
		'<=', '<',
		// Greater than
		'>=', '>',
		// Equality
		'===', '==', '=',
	];

	public const PUNCTUATION = [
		',' => AFPToken::TCOMMA,
		'(' => AFPToken::TBRACE,
		')' => AFPToken::TBRACE,
		'[' => AFPToken::TSQUAREBRACKET,
		']' => AFPToken::TSQUAREBRACKET,
		';' => AFPToken::TSTATEMENTSEPARATOR,
	];

	public const BASES = [
		'b' => 2,
		'x' => 16,
		'o' => 8
	];

	public const BASE_CHARS_RES = [
		2  => '/^[01]+$/',
		8  => '/^[0-7]+$/',
		16 => '/^[0-9A-Fa-f]+$/',
		10 => '/^[0-9.]+$/',
	];

	public const KEYWORDS = [
		'in', 'like', 'true', 'false', 'null', 'contains', 'matches',
		'rlike', 'irlike', 'regex', 'if', 'then', 'else', 'end',
	];

	/**
	 * @var BagOStuff
	 */
	private $cache;

	/**
	 * @param BagOStuff $cache
	 */
	public function __construct( BagOStuff $cache ) {
		$this->cache = $cache;
	}

	/**
	 * Get a cache key used to store the tokenized code
	 *
	 * @param string $code Not yet tokenized
	 * @return string
	 * @internal
	 */
	public function getCacheKey( $code ) {
		return $this->cache->makeGlobalKey( __CLASS__, self::CACHE_VERSION, crc32( $code ) );
	}

	/**
	 * Get the tokens for the given code.
	 *
	 * @param string $code
	 * @return array[]
	 * @phan-return array<int,array{0:AFPToken,1:int}>
	 */
	public function getTokens( string $code ): array {
		return $this->cache->getWithSetCallback(
			$this->getCacheKey( $code ),
			BagOStuff::TTL_DAY,
			function () use ( $code ) {
				return $this->tokenize( $code );
			}
		);
	}

	/**
	 * @param string $code
	 * @return array[]
	 * @phan-return array<int,array{0:AFPToken,1:int}>
	 */
	private function tokenize( string $code ): array {
		$tokens = [];
		$curPos = 0;

		do {
			$prevPos = $curPos;
			$token = $this->nextToken( $code, $curPos );
			$tokens[ $token->pos ] = [ $token, $curPos ];
		} while ( $curPos !== $prevPos );

		return $tokens;
	}

	/**
	 * @param string $code
	 * @param int &$offset
	 * @return AFPToken
	 * @throws UserVisibleException
	 */
	private function nextToken( $code, &$offset ) {
		$matches = [];
		$start = $offset;

		// Read past comments
		while ( preg_match( self::COMMENT_START_RE, $code, $matches, 0, $offset ) ) {
			if ( strpos( $code, '*/', $offset ) === false ) {
				throw new UserVisibleException(
					'unclosedcomment', $offset, [] );
			}
			$offset = strpos( $code, '*/', $offset ) + 2;
		}

		// Spaces
		$offset += strspn( $code, self::WHITESPACE, $offset );
		if ( $offset >= strlen( $code ) ) {
			return new AFPToken( AFPToken::TNONE, '', $start );
		}

		$chr = $code[$offset];

		// Punctuation
		if ( isset( self::PUNCTUATION[$chr] ) ) {
			$offset++;
			return new AFPToken( self::PUNCTUATION[$chr], $chr, $start );
		}

		// String literal
		if ( $chr === '"' || $chr === "'" ) {
			return self::readStringLiteral( $code, $offset, $start );
		}

		$matches = [];

		// Operators
		if ( preg_match( self::OPERATOR_RE, $code, $matches, 0, $offset ) ) {
			$token = $matches[0];
			$offset += strlen( $token );
			return new AFPToken( AFPToken::TOP, $token, $start );
		}

		// Numbers
		$matchesv2 = [];
		if ( preg_match( self::RADIX_RE, $code, $matchesv2, 0, $offset ) ) {
			$token = $matchesv2[0];
			$baseChar = $matchesv2['base'];
			$input = $matchesv2['input'];
			$base = $baseChar ? self::BASES[$baseChar] : 10;
			if ( preg_match( self::BASE_CHARS_RES[$base], $input ) ) {
				$num = $base !== 10 ? base_convert( $input, $base, 10 ) : $input;
				$offset += strlen( $token );
				return ( strpos( $input, '.' ) !== false )
					? new AFPToken( AFPToken::TFLOAT, floatval( $num ), $start )
					: new AFPToken( AFPToken::TINT, intval( $num ), $start );
			}
		}

		// IDs / Keywords

		if ( preg_match( self::ID_SYMBOL_RE, $code, $matches, 0, $offset ) ) {
			$token = $matches[0];
			$offset += strlen( $token );
			$type = in_array( $token, self::KEYWORDS )
				? AFPToken::TKEYWORD
				: AFPToken::TID;
			return new AFPToken( $type, $token, $start );
		}

		throw new UserVisibleException(
			'unrecognisedtoken', $start, [ substr( $code, $start ) ] );
	}

	/**
	 * @param string $code
	 * @param int &$offset
	 * @param int $start
	 * @return AFPToken
	 * @throws UserVisibleException
	 */
	private static function readStringLiteral( $code, &$offset, $start ) {
		$type = $code[$offset];
		$offset++;
		$length = strlen( $code );
		$token = '';
		while ( $offset < $length ) {
			if ( $code[$offset] === $type ) {
				$offset++;
				return new AFPToken( AFPToken::TSTRING, $token, $start );
			}

			// Performance: Use a PHP function (implemented in C)
			// to scan ahead.
			$addLength = strcspn( $code, $type . "\\", $offset );
			if ( $addLength ) {
				$token .= substr( $code, $offset, $addLength );
				$offset += $addLength;
			} elseif ( $code[$offset] === '\\' ) {
				if ( !isset( $code[$offset + 1] ) ) {
					// Unterminated escape sequence, hence unterminated string. (T390416)
					throw new UserVisibleException( 'unclosedstring', $offset + 1, [] );
				}

				switch ( $code[$offset + 1] ) {
					case '\\':
						$token .= '\\';
						break;
					case $type:
						$token .= $type;
						break;
					case 'n':
						$token .= "\n";
						break;
					case 'r':
						$token .= "\r";
						break;
					case 't':
						$token .= "\t";
						break;
					case 'x':
						$chr = substr( $code, $offset + 2, 2 );

						if ( preg_match( '/^[0-9A-Fa-f]{2}$/', $chr ) ) {
							$token .= chr( hexdec( $chr ) );
							// \xXX -- 2 done later
							$offset += 2;
						} else {
							$token .= '\\x';
						}
						break;
					default:
						$token .= "\\" . $code[$offset + 1];
				}

				$offset += 2;

			} else {
				// Should never happen
				// @codeCoverageIgnoreStart
				$token .= $code[$offset];
				$offset++;
				// @codeCoverageIgnoreEnd
			}
		}
		throw new UserVisibleException( 'unclosedstring', $offset, [] );
	}
}
PK       ! K1BJm/  m/    AbuseFilterServices.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagger;
use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagsManager;
use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagValidator;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesExecutorFactory;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesFactory;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesLookup;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxBuilderFactory;
use MediaWiki\Extension\AbuseFilter\Hooks\AbuseFilterHookRunner;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
use MediaWiki\Extension\AbuseFilter\Variables\LazyVariableComputer;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesFormatter;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Extension\AbuseFilter\Watcher\EmergencyWatcher;
use MediaWiki\Extension\AbuseFilter\Watcher\UpdateHitCountWatcher;
use MediaWiki\MediaWikiServices;
use Psr\Container\ContainerInterface;

class AbuseFilterServices {

	public static function getHookRunner( ?ContainerInterface $services = null ): AbuseFilterHookRunner {
		return ( $services ?? MediaWikiServices::getInstance() )->get( AbuseFilterHookRunner::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return KeywordsManager
	 */
	public static function getKeywordsManager( ?ContainerInterface $services = null ): KeywordsManager {
		return ( $services ?? MediaWikiServices::getInstance() )->get( KeywordsManager::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return FilterProfiler
	 */
	public static function getFilterProfiler( ?ContainerInterface $services = null ): FilterProfiler {
		return ( $services ?? MediaWikiServices::getInstance() )->get( FilterProfiler::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return AbuseFilterPermissionManager
	 */
	public static function getPermissionManager( ?ContainerInterface $services = null ): AbuseFilterPermissionManager {
		return ( $services ?? MediaWikiServices::getInstance() )->get( AbuseFilterPermissionManager::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return ChangeTagger
	 */
	public static function getChangeTagger( ?ContainerInterface $services = null ): ChangeTagger {
		return ( $services ?? MediaWikiServices::getInstance() )->get( ChangeTagger::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return ChangeTagsManager
	 */
	public static function getChangeTagsManager( ?ContainerInterface $services = null ): ChangeTagsManager {
		return ( $services ?? MediaWikiServices::getInstance() )->get( ChangeTagsManager::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return ChangeTagValidator
	 */
	public static function getChangeTagValidator( ?ContainerInterface $services = null ): ChangeTagValidator {
		return ( $services ?? MediaWikiServices::getInstance() )->get( ChangeTagValidator::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return BlockAutopromoteStore
	 */
	public static function getBlockAutopromoteStore( ?ContainerInterface $services = null ): BlockAutopromoteStore {
		return ( $services ?? MediaWikiServices::getInstance() )->get( BlockAutopromoteStore::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return FilterUser
	 */
	public static function getFilterUser( ?ContainerInterface $services = null ): FilterUser {
		return ( $services ?? MediaWikiServices::getInstance() )->get( FilterUser::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return CentralDBManager
	 */
	public static function getCentralDBManager( ?ContainerInterface $services = null ): CentralDBManager {
		return ( $services ?? MediaWikiServices::getInstance() )->get( CentralDBManager::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return RuleCheckerFactory
	 */
	public static function getRuleCheckerFactory( ?ContainerInterface $services = null ): RuleCheckerFactory {
		return ( $services ?? MediaWikiServices::getInstance() )->get( RuleCheckerFactory::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return FilterLookup
	 */
	public static function getFilterLookup( ?ContainerInterface $services = null ): FilterLookup {
		return ( $services ?? MediaWikiServices::getInstance() )->get( FilterLookup::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return EmergencyCache
	 */
	public static function getEmergencyCache( ?ContainerInterface $services = null ): EmergencyCache {
		return ( $services ?? MediaWikiServices::getInstance() )->get( EmergencyCache::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return EmergencyWatcher
	 */
	public static function getEmergencyWatcher( ?ContainerInterface $services = null ): EmergencyWatcher {
		return ( $services ?? MediaWikiServices::getInstance() )->get( EmergencyWatcher::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return EchoNotifier
	 */
	public static function getEchoNotifier( ?ContainerInterface $services = null ): EchoNotifier {
		return ( $services ?? MediaWikiServices::getInstance() )->get( EchoNotifier::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return FilterValidator
	 */
	public static function getFilterValidator( ?ContainerInterface $services = null ): FilterValidator {
		return ( $services ?? MediaWikiServices::getInstance() )->get( FilterValidator::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return FilterCompare
	 */
	public static function getFilterCompare( ?ContainerInterface $services = null ): FilterCompare {
		return ( $services ?? MediaWikiServices::getInstance() )->get( FilterCompare::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return FilterImporter
	 */
	public static function getFilterImporter( ?ContainerInterface $services = null ): FilterImporter {
		return ( $services ?? MediaWikiServices::getInstance() )->get( FilterImporter::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return FilterStore
	 */
	public static function getFilterStore( ?ContainerInterface $services = null ): FilterStore {
		return ( $services ?? MediaWikiServices::getInstance() )->get( FilterStore::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return ConsequencesFactory
	 */
	public static function getConsequencesFactory( ?ContainerInterface $services = null ): ConsequencesFactory {
		return ( $services ?? MediaWikiServices::getInstance() )->get( ConsequencesFactory::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return EditBoxBuilderFactory
	 */
	public static function getEditBoxBuilderFactory( ?ContainerInterface $services = null ): EditBoxBuilderFactory {
		return ( $services ?? MediaWikiServices::getInstance() )->get( EditBoxBuilderFactory::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return ConsequencesLookup
	 */
	public static function getConsequencesLookup( ?ContainerInterface $services = null ): ConsequencesLookup {
		return ( $services ?? MediaWikiServices::getInstance() )->get( ConsequencesLookup::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return ConsequencesRegistry
	 */
	public static function getConsequencesRegistry( ?ContainerInterface $services = null ): ConsequencesRegistry {
		return ( $services ?? MediaWikiServices::getInstance() )->get( ConsequencesRegistry::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return AbuseLoggerFactory
	 */
	public static function getAbuseLoggerFactory( ?ContainerInterface $services = null ): AbuseLoggerFactory {
		return ( $services ?? MediaWikiServices::getInstance() )->get( AbuseLoggerFactory::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return UpdateHitCountWatcher
	 */
	public static function getUpdateHitCountWatcher( ?ContainerInterface $services = null ): UpdateHitCountWatcher {
		return ( $services ?? MediaWikiServices::getInstance() )->get( UpdateHitCountWatcher::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return VariablesBlobStore
	 */
	public static function getVariablesBlobStore( ?ContainerInterface $services = null ): VariablesBlobStore {
		return ( $services ?? MediaWikiServices::getInstance() )->get( VariablesBlobStore::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return ConsequencesExecutorFactory
	 */
	public static function getConsequencesExecutorFactory(
		?ContainerInterface $services = null
	): ConsequencesExecutorFactory {
		return ( $services ?? MediaWikiServices::getInstance() )->get( ConsequencesExecutorFactory::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return FilterRunnerFactory
	 */
	public static function getFilterRunnerFactory( ?ContainerInterface $services = null ): FilterRunnerFactory {
		return ( $services ?? MediaWikiServices::getInstance() )->get( FilterRunnerFactory::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return SpecsFormatter
	 */
	public static function getSpecsFormatter( ?ContainerInterface $services = null ): SpecsFormatter {
		return ( $services ?? MediaWikiServices::getInstance() )->get( SpecsFormatter::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return VariablesFormatter
	 */
	public static function getVariablesFormatter( ?ContainerInterface $services = null ): VariablesFormatter {
		return ( $services ?? MediaWikiServices::getInstance() )->get( VariablesFormatter::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return LazyVariableComputer
	 */
	public static function getLazyVariableComputer( ?ContainerInterface $services = null ): LazyVariableComputer {
		return ( $services ?? MediaWikiServices::getInstance() )->get( LazyVariableComputer::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return TextExtractor
	 */
	public static function getTextExtractor( ?ContainerInterface $services = null ): TextExtractor {
		return ( $services ?? MediaWikiServices::getInstance() )->get( TextExtractor::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return VariablesManager
	 */
	public static function getVariablesManager( ?ContainerInterface $services = null ): VariablesManager {
		return ( $services ?? MediaWikiServices::getInstance() )->get( VariablesManager::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return VariableGeneratorFactory
	 */
	public static function getVariableGeneratorFactory(
		?ContainerInterface $services = null
	): VariableGeneratorFactory {
		return ( $services ?? MediaWikiServices::getInstance() )->get( VariableGeneratorFactory::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return EditRevUpdater
	 */
	public static function getEditRevUpdater( ?ContainerInterface $services = null ): EditRevUpdater {
		return ( $services ?? MediaWikiServices::getInstance() )->get( EditRevUpdater::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return BlockedDomainStorage
	 */
	public static function getBlockedDomainStorage( ?ContainerInterface $services = null ): BlockedDomainStorage {
		return ( $services ?? MediaWikiServices::getInstance() )->get( BlockedDomainStorage::SERVICE_NAME );
	}

	/**
	 * @param ContainerInterface|null $services
	 * @return BlockedDomainFilter
	 */
	public static function getBlockedDomainFilter( ?ContainerInterface $services = null ): BlockedDomainFilter {
		return ( $services ?? MediaWikiServices::getInstance() )->get( BlockedDomainFilter::SERVICE_NAME );
	}
}
PK       ! urHj  j     Pager/GlobalAbuseFilterPager.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Pager;

use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\CentralDBManager;
use MediaWiki\Extension\AbuseFilter\FilterUtils;
use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
use MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewList;
use MediaWiki\Linker\LinkRenderer;

/**
 * Class to build paginated filter list for wikis using global abuse filters
 */
class GlobalAbuseFilterPager extends AbuseFilterPager {

	/**
	 * @param AbuseFilterViewList $page
	 * @param LinkRenderer $linkRenderer
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param SpecsFormatter $specsFormatter
	 * @param CentralDBManager $centralDBManager
	 * @param array $conds
	 */
	public function __construct(
		AbuseFilterViewList $page,
		LinkRenderer $linkRenderer,
		AbuseFilterPermissionManager $afPermManager,
		SpecsFormatter $specsFormatter,
		CentralDBManager $centralDBManager,
		array $conds
	) {
		// Set database before parent constructor to avoid setting it there
		$this->mDb = $centralDBManager->getConnection( DB_REPLICA );
		parent::__construct( $page, $linkRenderer, null, $afPermManager, $specsFormatter, $conds, null, null );
	}

	/**
	 * @param string $name
	 * @param string|null $value
	 * @return string
	 */
	public function formatValue( $name, $value ) {
		$lang = $this->getLanguage();
		$row = $this->mCurrentRow;

		switch ( $name ) {
			case 'af_id':
				return $lang->formatNum( intval( $value ) );
			case 'af_public_comments':
				return $this->getOutput()->parseInlineAsInterface( $value );
			case 'af_enabled':
				$statuses = [];
				if ( $row->af_deleted ) {
					$statuses[] = $this->msg( 'abusefilter-deleted' )->parse();
				} elseif ( $row->af_enabled ) {
					$statuses[] = $this->msg( 'abusefilter-enabled' )->parse();
				} else {
					$statuses[] = $this->msg( 'abusefilter-disabled' )->parse();
				}
				if ( $row->af_global ) {
					$statuses[] = $this->msg( 'abusefilter-status-global' )->parse();
				}

				return $lang->commaList( $statuses );
			case 'af_hit_count':
				// If the rule is hidden or protected, don't show it, even to privileged local admins
				if ( FilterUtils::isHidden( $row->af_hidden ) || FilterUtils::isProtected( $row->af_hidden ) ) {
					return '';
				}
				return $this->msg( 'abusefilter-hitcount' )->numParams( $value )->parse();
			case 'af_timestamp':
				$user = $this->getUser();
				return $this->msg(
					'abusefilter-edit-lastmod-text',
					$lang->userTimeAndDate( $value, $user ),
					$row->af_user_text,
					$lang->userDate( $value, $user ),
					$lang->userTime( $value, $user ),
					$row->af_user_text
				)->parse();
			case 'af_group':
				// If this is global, local name probably doesn't exist, but try
				return $this->specsFormatter->nameGroup( $value );
			default:
				return parent::formatValue( $name, $value );
		}
	}
}
PK       ! 6+GW4  4    Pager/AbuseLogPager.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Pager;

use HtmlArmor;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Context\IContextSource;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\AbuseFilterServices;
use MediaWiki\Extension\AbuseFilter\CentralDBNotAvailableException;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseLog;
use MediaWiki\Html\Html;
use MediaWiki\Linker\Linker;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\MediaWikiServices;
use MediaWiki\Pager\ReverseChronologicalPager;
use MediaWiki\Parser\Sanitizer;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
use MediaWiki\WikiMap\WikiMap;
use stdClass;
use Wikimedia\Rdbms\IResultWrapper;

class AbuseLogPager extends ReverseChronologicalPager {
	/**
	 * @var array
	 */
	private $conds;

	/** @var LinkBatchFactory */
	private $linkBatchFactory;

	/** @var PermissionManager */
	private $permissionManager;

	/** @var AbuseFilterPermissionManager */
	private $afPermissionManager;

	/** @var string */
	private $basePageName;

	/**
	 * @var string[] Map of [ id => show|hide ], for entries that we're currently (un)hiding
	 */
	private $hideEntries;

	/**
	 * @param IContextSource $context
	 * @param LinkRenderer $linkRenderer
	 * @param array $conds
	 * @param LinkBatchFactory $linkBatchFactory
	 * @param PermissionManager $permManager
	 * @param AbuseFilterPermissionManager $afPermissionManager
	 * @param string $basePageName
	 * @param string[] $hideEntries
	 */
	public function __construct(
		IContextSource $context,
		LinkRenderer $linkRenderer,
		array $conds,
		LinkBatchFactory $linkBatchFactory,
		PermissionManager $permManager,
		AbuseFilterPermissionManager $afPermissionManager,
		string $basePageName,
		array $hideEntries = []
	) {
		parent::__construct( $context, $linkRenderer );
		$this->conds = $conds;
		$this->linkBatchFactory = $linkBatchFactory;
		$this->permissionManager = $permManager;
		$this->afPermissionManager = $afPermissionManager;
		$this->basePageName = $basePageName;
		$this->hideEntries = $hideEntries;
	}

	/**
	 * @param stdClass $row
	 * @return string
	 */
	public function formatRow( $row ) {
		return $this->doFormatRow( $row );
	}

	/**
	 * @param stdClass $row
	 * @param bool $isListItem
	 * @return string
	 */
	public function doFormatRow( stdClass $row, bool $isListItem = true ): string {
		$performer = $this->getAuthority();
		$visibility = SpecialAbuseLog::getEntryVisibilityForUser( $row, $performer, $this->afPermissionManager );

		if ( $visibility !== SpecialAbuseLog::VISIBILITY_VISIBLE ) {
			return '';
		}

		$linkRenderer = $this->getLinkRenderer();
		$diffLink = false;

		if ( !$row->afl_wiki ) {
			$title = Title::makeTitle( $row->afl_namespace, $row->afl_title );

			$pageLink = $linkRenderer->makeLink(
				$title,
				null,
				[],
				[ 'redirect' => 'no' ]
			);
			if ( $row->rev_id ) {
				$diffLink = $linkRenderer->makeKnownLink(
					$title,
					new HtmlArmor( $this->msg( 'abusefilter-log-diff' )->parse() ),
					[],
					[ 'diff' => 'prev', 'oldid' => $row->rev_id ]
				);
			} elseif (
				isset( $row->ar_timestamp ) && $row->ar_timestamp
				&& $this->canSeeUndeleteDiffForPage( $title )
			) {
				$diffLink = $linkRenderer->makeKnownLink(
					SpecialPage::getTitleFor( 'Undelete' ),
					new HtmlArmor( $this->msg( 'abusefilter-log-diff' )->parse() ),
					[],
					[
						'diff' => 'prev',
						'target' => $title->getPrefixedText(),
						'timestamp' => $row->ar_timestamp,
					]
				);
			}
		} else {
			$pageLink = WikiMap::makeForeignLink( $row->afl_wiki, $row->afl_title );

			if ( $row->afl_rev_id ) {
				$diffUrl = WikiMap::getForeignURL( $row->afl_wiki, $row->afl_title );
				$diffUrl = wfAppendQuery( $diffUrl,
					[ 'diff' => 'prev', 'oldid' => $row->afl_rev_id ] );

				$diffLink = Linker::makeExternalLink( $diffUrl,
					$this->msg( 'abusefilter-log-diff' )->text() );
			}
		}

		if ( !$row->afl_wiki ) {
			// Local user
			$userLink = SpecialAbuseLog::getUserLinks( $row->afl_user, $row->afl_user_text );
		} else {
			$userLink = WikiMap::foreignUserLink( $row->afl_wiki, $row->afl_user_text ) . ' ' .
				$this->msg( 'parentheses' )->params( WikiMap::getWikiName( $row->afl_wiki ) )->escaped();
		}

		$lang = $this->getLanguage();
		$timestamp = htmlspecialchars( $lang->userTimeAndDate( $row->afl_timestamp, $this->getUser() ) );

		$actions_takenRaw = $row->afl_actions;
		if ( !strlen( trim( $actions_takenRaw ) ) ) {
			$actions_taken = $this->msg( 'abusefilter-log-noactions' )->escaped();
		} else {
			$actions = explode( ',', $actions_takenRaw );
			$displayActions = [];

			$specsFormatter = AbuseFilterServices::getSpecsFormatter();
			$specsFormatter->setMessageLocalizer( $this->getContext() );
			foreach ( $actions as $action ) {
				$displayActions[] = $specsFormatter->getActionDisplay( $action );
			}
			$actions_taken = $lang->commaList( $displayActions );
		}

		$filterID = $row->afl_filter_id;
		$global = $row->afl_global;

		if ( $global ) {
			// Pull global filter description
			$lookup = AbuseFilterServices::getFilterLookup();
			try {
				$filterObj = $lookup->getFilter( $filterID, true );
				$globalDesc = $filterObj->getName();
				$escaped_comments = Sanitizer::escapeHtmlAllowEntities( $globalDesc );
				$privacyLevel = $filterObj->getPrivacyLevel();
			} catch ( CentralDBNotAvailableException $_ ) {
				$escaped_comments = $this->msg( 'abusefilter-log-description-not-available' )->escaped();
				// either hide all filters, including not hidden/protected, or show all, including hidden/protected
				// we choose the former
				$privacyLevel = Flags::FILTER_HIDDEN | Flags::FILTER_USES_PROTECTED_VARS;
			}
		} else {
			$escaped_comments = Sanitizer::escapeHtmlAllowEntities(
				$row->af_public_comments ?? '' );
			$privacyLevel = $row->af_hidden;
		}

		if ( $this->afPermissionManager->canSeeLogDetailsForFilter( $performer, $privacyLevel ) ) {
			$actionLinks = [];

			if ( $isListItem ) {
				$detailsLink = $linkRenderer->makeKnownLink(
					SpecialPage::getTitleFor( $this->basePageName, $row->afl_id ),
					$this->msg( 'abusefilter-log-detailslink' )->text()
				);
				$actionLinks[] = $detailsLink;
			}

			$examineTitle = SpecialPage::getTitleFor( 'AbuseFilter', 'examine/log/' . $row->afl_id );
			$examineLink = $linkRenderer->makeKnownLink(
				$examineTitle,
				new HtmlArmor( $this->msg( 'abusefilter-changeslist-examine' )->parse() )
			);
			$actionLinks[] = $examineLink;

			if ( $diffLink ) {
				$actionLinks[] = $diffLink;
			}

			if ( !$isListItem && $this->afPermissionManager->canHideAbuseLog( $performer ) ) {
				// Link for hiding a single entry from the details view
				$hideLink = $linkRenderer->makeKnownLink(
					SpecialPage::getTitleFor( $this->basePageName, 'hide' ),
					$this->msg( 'abusefilter-log-hidelink' )->text(),
					[],
					[ "hideids[$row->afl_id]" => 1 ]
				);

				$actionLinks[] = $hideLink;
			}

			if ( $global ) {
				$centralDb = $this->getConfig()->get( 'AbuseFilterCentralDB' );
				$linkMsg = $this->msg( 'abusefilter-log-detailedentry-global' )
					->numParams( $filterID );
				if ( $centralDb !== null ) {
					$globalURL = WikiMap::getForeignURL(
						$centralDb,
						'Special:AbuseFilter/' . $filterID
					);
					$filterLink = Linker::makeExternalLink( $globalURL, $linkMsg->text() );
				} else {
					$filterLink = $linkMsg->escaped();
				}
			} else {
				$title = SpecialPage::getTitleFor( 'AbuseFilter', (string)$filterID );
				$linkText = $this->msg( 'abusefilter-log-detailedentry-local' )
					->numParams( $filterID )->text();
				$filterLink = $linkRenderer->makeKnownLink( $title, $linkText );
			}
			$description = $this->msg( 'abusefilter-log-detailedentry-meta' )->rawParams(
				$timestamp,
				$userLink,
				$filterLink,
				htmlspecialchars( $row->afl_action ),
				$pageLink,
				$actions_taken,
				$escaped_comments,
				$lang->pipeList( $actionLinks )
			)->params( $row->afl_user_text )->parse();
		} else {
			if ( $diffLink ) {
				$msg = 'abusefilter-log-entry-withdiff';
			} else {
				$msg = 'abusefilter-log-entry';
			}
			$description = $this->msg( $msg )->rawParams(
				$timestamp,
				$userLink,
				htmlspecialchars( $row->afl_action ),
				$pageLink,
				$actions_taken,
				$escaped_comments,
				// Passing $7 to 'abusefilter-log-entry' will do nothing, as it's not used.
				$diffLink ?: ''
			)->params( $row->afl_user_text )->parse();
		}

		$attribs = [];
		if (
			$this->isHidingEntry( $row ) === true ||
			// If isHidingEntry is false, we've just unhidden the row
			( $this->isHidingEntry( $row ) === null && $row->afl_deleted )
		) {
			$attribs['class'] = 'mw-abusefilter-log-hidden-entry';
		}
		if ( self::entryHasAssociatedDeletedRev( $row ) ) {
			$description .= ' ' .
				$this->msg( 'abusefilter-log-hidden-implicit' )->parse();
		}

		if ( $isListItem && !$this->hideEntries && $this->afPermissionManager->canHideAbuseLog( $performer ) ) {
			// Checkbox for hiding multiple entries, single entries are handled above
			$description = Html::check( 'hideids[' . $row->afl_id . ']' ) . ' ' . $description;
		}

		if ( $isListItem ) {
			return Html::rawElement( 'li', $attribs, $description );
		} else {
			return Html::rawElement( 'span', $attribs, $description );
		}
	}

	/**
	 * Can this user see diffs generated by Special:Undelete for the page?
	 * @see MediaWiki\Specials\SpecialUndelete
	 * @param LinkTarget $page
	 *
	 * @return bool
	 */
	private function canSeeUndeleteDiffForPage( LinkTarget $page ): bool {
		if ( !$this->canSeeUndeleteDiffs() ) {
			return false;
		}

		foreach ( [ 'deletedtext', 'undelete' ] as $action ) {
			if ( $this->permissionManager->userCan(
				$action, $this->getUser(), $page, PermissionManager::RIGOR_QUICK
			) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Can this user see diffs generated by Special:Undelete?
	 * @see MediaWiki\Specials\SpecialUndelete
	 *
	 * @return bool
	 */
	private function canSeeUndeleteDiffs(): bool {
		if ( !$this->permissionManager->userHasRight( $this->getUser(), 'deletedhistory' ) ) {
			return false;
		}

		return $this->permissionManager->userHasAnyRight(
			$this->getUser(), 'deletedtext', 'undelete' );
	}

	/**
	 * @return array
	 */
	public function getQueryInfo() {
		$info = [
			'tables' => [ 'abuse_filter_log', 'abuse_filter', 'revision' ],
			'fields' => [
				'afl_id',
				'afl_global',
				'afl_filter_id',
				'afl_user',
				'afl_ip',
				'afl_user_text',
				'afl_action',
				'afl_actions',
				'afl_var_dump',
				'afl_timestamp',
				'afl_namespace',
				'afl_title',
				'afl_wiki',
				'afl_deleted',
				'afl_rev_id',
				'af_public_comments',
				'af_hidden',
				'rev_id',
			],
			'conds' => $this->conds,
			'join_conds' => [
				'abuse_filter' => [
					'LEFT JOIN',
					[ 'af_id=afl_filter_id', 'afl_global' => 0 ],
				],
				'revision' => [
					'LEFT JOIN',
					[
						'afl_wiki' => null,
						$this->mDb->expr( 'afl_rev_id', '!=', null ),
						'rev_id=afl_rev_id',
					]
				],
			],
		];

		if ( $this->canSeeUndeleteDiffs() ) {
			$info['tables'][] = 'archive';
			$info['fields'][] = 'ar_timestamp';
			$info['join_conds']['archive'] = [
				'LEFT JOIN',
				[
					'afl_wiki' => null,
					$this->mDb->expr( 'afl_rev_id', '!=', null ),
					'rev_id' => null,
					'ar_rev_id=afl_rev_id',
				]
			];
		}

		if ( !$this->afPermissionManager->canSeeHiddenLogEntries( $this->getAuthority() ) ) {
			$info['conds']['afl_deleted'] = 0;
		}

		return $info;
	}

	/**
	 * @param IResultWrapper $result
	 */
	protected function preprocessResults( $result ) {
		if ( $this->getNumRows() === 0 ) {
			return;
		}

		$lb = $this->linkBatchFactory->newLinkBatch();
		$lb->setCaller( __METHOD__ );
		foreach ( $result as $row ) {
			// Only for local wiki results
			if ( !$row->afl_wiki ) {
				$lb->add( $row->afl_namespace, $row->afl_title );
				$lb->add( NS_USER, $row->afl_user_text );
				$lb->add( NS_USER_TALK, $row->afl_user_text );
			}
		}
		$lb->execute();
		$result->seek( 0 );
	}

	/**
	 * @param stdClass $row
	 * @return bool
	 * @todo This should be moved elsewhere
	 */
	private static function entryHasAssociatedDeletedRev( stdClass $row ): bool {
		if ( !$row->afl_rev_id ) {
			return false;
		}
		$revision = MediaWikiServices::getInstance()
			->getRevisionLookup()
			->getRevisionById( $row->afl_rev_id );
		return $revision && $revision->getVisibility() !== 0;
	}

	/**
	 * Check whether the entry passed in is being currently hidden/unhidden.
	 * This is used to format the entries list shown when updating visibility, and is necessary because
	 * when we decide whether to display the entry as hidden the DB hasn't been updated yet.
	 *
	 * @param stdClass $row
	 * @return bool|null True if just hidden, false if just unhidden, null if untouched
	 */
	private function isHidingEntry( stdClass $row ): ?bool {
		if ( isset( $this->hideEntries[ $row->afl_id ] ) ) {
			return $this->hideEntries[ $row->afl_id ] === 'hide';
		}
		return null;
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function getIndexField() {
		return 'afl_timestamp';
	}
}
PK       ! ?-"  "  !  Pager/AbuseFilterHistoryPager.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Pager;

use HtmlArmor;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Context\IContextSource;
use MediaWiki\Extension\AbuseFilter\AbuseFilter;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseFilter;
use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
use MediaWiki\Html\Html;
use MediaWiki\Linker\Linker;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Pager\TablePager;
use MediaWiki\Title\Title;
use UnexpectedValueException;
use Wikimedia\Rdbms\IResultWrapper;

class AbuseFilterHistoryPager extends TablePager {

	/** @var LinkBatchFactory */
	private $linkBatchFactory;

	/** @var FilterLookup */
	private $filterLookup;

	/** @var SpecsFormatter */
	private $specsFormatter;

	/** @var int|null The filter ID */
	private $filter;

	/** @var string|null The user whose changes we're looking up for */
	private $user;

	/** @var bool */
	private $canViewPrivateFilters;

	/** @var bool */
	private $canViewProtectedVars;

	/**
	 * @param IContextSource $context
	 * @param LinkRenderer $linkRenderer
	 * @param LinkBatchFactory $linkBatchFactory
	 * @param FilterLookup $filterLookup
	 * @param SpecsFormatter $specsFormatter
	 * @param ?int $filter
	 * @param ?string $user User name
	 * @param bool $canViewPrivateFilters
	 * @param bool $canViewProtectedVars
	 */
	public function __construct(
		IContextSource $context,
		LinkRenderer $linkRenderer,
		LinkBatchFactory $linkBatchFactory,
		FilterLookup $filterLookup,
		SpecsFormatter $specsFormatter,
		?int $filter,
		?string $user,
		bool $canViewPrivateFilters = false,
		bool $canViewProtectedVars = false
	) {
		// needed by parent's constructor call
		$this->filter = $filter;
		parent::__construct( $context, $linkRenderer );
		$this->linkBatchFactory = $linkBatchFactory;
		$this->filterLookup = $filterLookup;
		$this->specsFormatter = $specsFormatter;
		$this->user = $user;
		$this->canViewPrivateFilters = $canViewPrivateFilters;
		$this->canViewProtectedVars = $canViewProtectedVars;
		$this->mDefaultDirection = true;
	}

	/**
	 * Note: this method is called by parent::__construct
	 * @return array
	 * @see MediaWiki\Pager\Pager::getFieldNames()
	 */
	public function getFieldNames() {
		static $headers = null;

		if ( $headers !== null ) {
			return $headers;
		}

		$headers = [
			'afh_timestamp' => 'abusefilter-history-timestamp',
			'afh_user_text' => 'abusefilter-history-user',
			'afh_public_comments' => 'abusefilter-history-public',
			'afh_flags' => 'abusefilter-history-flags',
			'afh_actions' => 'abusefilter-history-actions',
			'afh_id' => 'abusefilter-history-diff',
		];

		if ( !$this->filter ) {
			// awful hack
			$headers = [ 'afh_filter' => 'abusefilter-history-filterid' ] + $headers;
		}

		foreach ( $headers as &$msg ) {
			$msg = $this->msg( $msg )->text();
		}

		return $headers;
	}

	/**
	 * @param string $name
	 * @param string|null $value
	 * @return string
	 */
	public function formatValue( $name, $value ) {
		$lang = $this->getLanguage();
		$linkRenderer = $this->getLinkRenderer();

		$row = $this->mCurrentRow;

		switch ( $name ) {
			case 'afh_filter':
				$formatted = $linkRenderer->makeLink(
					SpecialAbuseFilter::getTitleForSubpage( $row->afh_filter ),
					$lang->formatNum( $row->afh_filter )
				);
				break;
			case 'afh_timestamp':
				$title = SpecialAbuseFilter::getTitleForSubpage(
					'history/' . $row->afh_filter . '/item/' . $row->afh_id );
				$formatted = $linkRenderer->makeLink(
					$title,
					$lang->userTimeAndDate( $row->afh_timestamp, $this->getUser() )
				);
				break;
			case 'afh_user_text':
				$formatted =
					Linker::userLink( $row->afh_user ?? 0, $row->afh_user_text ) . ' ' .
					Linker::userToolLinks( $row->afh_user ?? 0, $row->afh_user_text );
				break;
			case 'afh_public_comments':
				$formatted = htmlspecialchars( $value, ENT_QUOTES, 'UTF-8', false );
				break;
			case 'afh_flags':
				$formatted = $this->specsFormatter->formatFlags( $value, $lang );
				break;
			case 'afh_actions':
				$actions = unserialize( $value );

				$display_actions = '';

				foreach ( $actions as $action => $parameters ) {
					$displayAction = $this->specsFormatter->formatAction( $action, $parameters, $lang );
					$display_actions .= Html::rawElement( 'li', [], $displayAction );
				}
				$display_actions = Html::rawElement( 'ul', [], $display_actions );

				$formatted = $display_actions;
				break;
			case 'afh_id':
				// Set a link to a diff with the previous version if this isn't the first edit to the filter.
				// Like in AbuseFilterViewDiff, don't show it if:
				// - the user cannot see private filters and any of the versions is hidden
				// - the user cannot see protected variables and any of the versions is protected
				$formatted = '';
				if ( $this->filterLookup->getFirstFilterVersionID( $row->afh_filter ) !== (int)$value ) {
					// @todo Should we also hide actions?
					$prevFilter = $this->filterLookup->getClosestVersion(
						$row->afh_id, $row->afh_filter, FilterLookup::DIR_PREV );
					if (
							( $this->canViewPrivateFilters ||
							(
								!in_array( 'hidden', explode( ',', $row->afh_flags ) ) &&
								!$prevFilter->isHidden()
							)
						) &&
						(
							$this->canViewProtectedVars ||
							(
								!in_array( 'protected', explode( ',', $row->afh_flags ) ) &&
								!$prevFilter->isProtected()
							)
						)
					) {
						$title = SpecialAbuseFilter::getTitleForSubpage(
							'history/' . $row->afh_filter . "/diff/prev/$value" );
						$formatted = $linkRenderer->makeLink(
							$title,
							new HtmlArmor( $this->msg( 'abusefilter-history-diff' )->parse() )
						);
					}
				}
				break;
			default:
				throw new UnexpectedValueException( "Unknown row type $name!" );
		}

		return $formatted;
	}

	/**
	 * @return array
	 */
	public function getQueryInfo() {
		$info = [
			'tables' => [ 'abuse_filter_history', 'abuse_filter', 'actor' ],
			// All fields but afh_deleted on abuse_filter_history
			'fields' => [
				'afh_filter',
				'afh_timestamp',
				'afh_public_comments',
				'afh_user' => 'actor_user',
				'afh_user_text' => 'actor_name',
				'afh_flags',
				'afh_comments',
				'afh_actions',
				'afh_id',
				'afh_changed_fields',
				'afh_pattern',
				'af_hidden'
			],
			'conds' => [],
			'join_conds' => [
				'abuse_filter' =>
					[
						'LEFT JOIN',
						'afh_filter=af_id',
					],
				'actor' => [ 'JOIN', 'actor_id = afh_actor' ],
			]
		];

		if ( $this->user !== null ) {
			$info['conds']['actor_name'] = $this->user;
		}

		if ( $this->filter ) {
			$info['conds']['afh_filter'] = $this->filter;
		}

		if ( !$this->canViewPrivateFilters ) {
			// Hide data the user can't see.
			$info['conds'][] = $this->mDb->bitAnd( 'af_hidden', Flags::FILTER_HIDDEN ) . ' = 0';
		}

		if ( !$this->canViewProtectedVars ) {
			// Hide data the user can't see.
			$info['conds'][] = $this->mDb->bitAnd( 'af_hidden', Flags::FILTER_USES_PROTECTED_VARS ) . ' = 0';
		}

		return $info;
	}

	/**
	 * @param IResultWrapper $result
	 */
	protected function preprocessResults( $result ) {
		if ( $this->getNumRows() === 0 ) {
			return;
		}

		$lb = $this->linkBatchFactory->newLinkBatch();
		$lb->setCaller( __METHOD__ );
		foreach ( $result as $row ) {
			$lb->add( NS_USER, $row->afh_user_text );
			$lb->add( NS_USER_TALK, $row->afh_user_text );
		}
		$lb->execute();
		$result->seek( 0 );
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function getDefaultSort() {
		return 'afh_timestamp';
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function isFieldSortable( $field ) {
		return $field === 'afh_timestamp';
	}

	/**
	 * @param string $field
	 * @param string $value
	 * @return array
	 * @see TablePager::getCellAttrs
	 */
	public function getCellAttrs( $field, $value ) {
		$row = $this->mCurrentRow;
		$mappings = array_flip( AbuseFilter::HISTORY_MAPPINGS ) +
			[ 'afh_actions' => 'actions', 'afh_id' => 'id' ];
		$changed = explode( ',', $row->afh_changed_fields );

		$fieldChanged = false;
		if ( $field === 'afh_flags' ) {
			// The field is changed if any of these filters are in the $changed array.
			$filters = [ 'af_enabled', 'af_hidden', 'af_deleted', 'af_global' ];
			if ( count( array_intersect( $filters, $changed ) ) ) {
				$fieldChanged = true;
			}
		} elseif ( in_array( $mappings[$field], $changed ) ) {
			$fieldChanged = true;
		}

		$class = $fieldChanged ? ' mw-abusefilter-history-changed' : '';
		$attrs = parent::getCellAttrs( $field, $value );
		$attrs['class'] .= $class;
		return $attrs;
	}

	/**
	 * Title used for self-links.
	 *
	 * @return Title
	 */
	public function getTitle() {
		$subpage = $this->filter ? ( 'history/' . $this->filter ) : 'history';
		return SpecialAbuseFilter::getTitleForSubpage( $subpage );
	}
}
PK       ! 	2  	2    Pager/AbuseFilterPager.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Pager;

use LogicException;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\FilterUtils;
use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
use MediaWiki\Extension\AbuseFilter\View\AbuseFilterViewList;
use MediaWiki\Linker\Linker;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Pager\TablePager;
use MediaWiki\SpecialPage\SpecialPage;
use stdClass;
use UnexpectedValueException;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\Rdbms\IResultWrapper;

/**
 * Class to build paginated filter list
 */
class AbuseFilterPager extends TablePager {

	/**
	 * The unique sort fields for the sort options for unique paginate
	 */
	private const INDEX_FIELDS = [
		'af_id' => [ 'af_id' ],
		'af_enabled' => [ 'af_enabled', 'af_deleted', 'af_id' ],
		'af_timestamp' => [ 'af_timestamp', 'af_id' ],
		'af_hidden' => [ 'af_hidden', 'af_id' ],
		'af_group' => [ 'af_group', 'af_id' ],
		'af_hit_count' => [ 'af_hit_count', 'af_id' ],
		'af_public_comments' => [ 'af_public_comments', 'af_id' ],
	];

	/** @var ?LinkBatchFactory */
	private $linkBatchFactory;

	/** @var AbuseFilterPermissionManager */
	private $afPermManager;

	/** @var SpecsFormatter */
	protected $specsFormatter;

	/**
	 * @var AbuseFilterViewList The associated page
	 */
	private $mPage;
	/**
	 * @var array Query WHERE conditions
	 */
	private $conds;
	/**
	 * @var string|null The pattern being searched
	 */
	private $searchPattern;
	/**
	 * @var string|null The pattern search mode (LIKE, RLIKE or IRLIKE)
	 */
	private $searchMode;

	/**
	 * @param AbuseFilterViewList $page
	 * @param LinkRenderer $linkRenderer
	 * @param ?LinkBatchFactory $linkBatchFactory
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param SpecsFormatter $specsFormatter
	 * @param array $conds
	 * @param ?string $searchPattern Null if no pattern was specified
	 * @param ?string $searchMode
	 */
	public function __construct(
		AbuseFilterViewList $page,
		LinkRenderer $linkRenderer,
		?LinkBatchFactory $linkBatchFactory,
		AbuseFilterPermissionManager $afPermManager,
		SpecsFormatter $specsFormatter,
		array $conds,
		?string $searchPattern,
		?string $searchMode
	) {
		// needed by parent's constructor call
		$this->afPermManager = $afPermManager;
		$this->specsFormatter = $specsFormatter;
		parent::__construct( $page->getContext(), $linkRenderer );
		$this->mPage = $page;
		$this->linkBatchFactory = $linkBatchFactory;
		$this->conds = $conds;
		$this->searchPattern = $searchPattern;
		$this->searchMode = $searchMode;
	}

	/**
	 * @return array
	 */
	public function getQueryInfo() {
		return [
			'tables' => [ 'abuse_filter', 'actor' ],
			'fields' => [
				// All columns but af_comments
				'af_id',
				'af_enabled',
				'af_deleted',
				'af_pattern',
				'af_global',
				'af_public_comments',
				'af_user' => 'actor_user',
				'af_user_text' => 'actor_name',
				'af_hidden',
				'af_hit_count',
				'af_timestamp',
				'af_actions',
				'af_group',
				'af_throttled'
			],
			'conds' => $this->conds,
			'join_conds' => [
				'actor' => [ 'JOIN', 'actor_id = af_actor' ],
			]
		];
	}

	/**
	 * @param IResultWrapper $result
	 */
	protected function preprocessResults( $result ) {
		// LinkBatchFactory only provided and needed for local wiki results
		if ( $this->linkBatchFactory === null || $this->getNumRows() === 0 ) {
			return;
		}

		$lb = $this->linkBatchFactory->newLinkBatch();
		$lb->setCaller( __METHOD__ );
		foreach ( $result as $row ) {
			$lb->add( NS_USER, $row->af_user_text );
			$lb->add( NS_USER_TALK, $row->af_user_text );
		}
		$lb->execute();
		$result->seek( 0 );
	}

	/**
	 * @inheritDoc
	 * This is the same as the parent implementation if no search pattern was specified.
	 * Otherwise, it does a query with no limit and then slices the results à la ContribsPager.
	 */
	public function reallyDoQuery( $offset, $limit, $order ) {
		if ( $this->searchMode === null ) {
			return parent::reallyDoQuery( $offset, $limit, $order );
		}

		[ $tables, $fields, $conds, $fname, $options, $join_conds ] =
			$this->buildQueryInfo( $offset, $limit, $order );

		unset( $options['LIMIT'] );
		$res = $this->mDb->newSelectQueryBuilder()
			->tables( $tables )
			->fields( $fields )
			->conds( $conds )
			->caller( $fname )
			->options( $options )
			->joinConds( $join_conds )
			->fetchResultSet();

		$filtered = [];
		foreach ( $res as $row ) {
			if ( $this->matchesPattern( $row->af_pattern ) ) {
				$filtered[$row->af_id] = $row;
			}
		}

		// sort results and enforce limit like ContribsPager
		if ( $order === self::QUERY_ASCENDING ) {
			ksort( $filtered );
		} else {
			krsort( $filtered );
		}
		$filtered = array_slice( $filtered, 0, $limit );
		$filtered = array_values( $filtered );
		return new FakeResultWrapper( $filtered );
	}

	/**
	 * Check whether $subject matches the given $pattern.
	 *
	 * @param string $subject
	 * @return bool
	 * @throws LogicException
	 */
	private function matchesPattern( $subject ) {
		$pattern = $this->searchPattern;
		switch ( $this->searchMode ) {
			case 'RLIKE':
				return (bool)preg_match( "/$pattern/u", $subject );
			case 'IRLIKE':
				return (bool)preg_match( "/$pattern/ui", $subject );
			case 'LIKE':
				return mb_stripos( $subject, $pattern ) !== false;
			default:
				throw new LogicException( "Unknown search type {$this->searchMode}" );
		}
	}

	/**
	 * Note: this method is called by parent::__construct
	 * @return array
	 * @see MediaWiki\Pager\Pager::getFieldNames()
	 */
	public function getFieldNames() {
		$headers = [
			'af_id' => 'abusefilter-list-id',
			'af_public_comments' => 'abusefilter-list-public',
			'af_actions' => 'abusefilter-list-consequences',
			'af_enabled' => 'abusefilter-list-status',
			'af_timestamp' => 'abusefilter-list-lastmodified',
			'af_hidden' => 'abusefilter-list-visibility',
		];

		$performer = $this->getAuthority();
		if ( $this->afPermManager->canSeeLogDetails( $performer ) ) {
			$headers['af_hit_count'] = 'abusefilter-list-hitcount';
		}

		if (
				$this->afPermManager->canViewPrivateFilters( $performer ) &&
				$this->searchMode !== null
		) {
			// This is also excluded in the default view
			$headers['af_pattern'] = 'abusefilter-list-pattern';
		}

		if ( count( $this->getConfig()->get( 'AbuseFilterValidGroups' ) ) > 1 ) {
			$headers['af_group'] = 'abusefilter-list-group';
		}

		foreach ( $headers as &$msg ) {
			$msg = $this->msg( $msg )->text();
		}

		return $headers;
	}

	/**
	 * @param string $name
	 * @param string|null $value
	 * @return string
	 */
	public function formatValue( $name, $value ) {
		$lang = $this->getLanguage();
		$user = $this->getUser();
		$linkRenderer = $this->getLinkRenderer();
		$row = $this->mCurrentRow;

		switch ( $name ) {
			case 'af_id':
				return $linkRenderer->makeLink(
					SpecialPage::getTitleFor( 'AbuseFilter', $value ),
					$lang->formatNum( intval( $value ) )
				);
			case 'af_pattern':
				return $this->getHighlightedPattern( $row );
			case 'af_public_comments':
				return $linkRenderer->makeLink(
					SpecialPage::getTitleFor( 'AbuseFilter', $row->af_id ),
					$value
				);
			case 'af_actions':
				$actions = explode( ',', $value );
				$displayActions = [];
				foreach ( $actions as $action ) {
					$displayActions[] = $this->specsFormatter->getActionDisplay( $action );
				}
				return $lang->commaList( $displayActions );
			case 'af_enabled':
				$statuses = [];
				if ( $row->af_deleted ) {
					$statuses[] = $this->msg( 'abusefilter-deleted' )->parse();
				} elseif ( $row->af_enabled ) {
					$statuses[] = $this->msg( 'abusefilter-enabled' )->parse();
					if ( $row->af_throttled ) {
						$statuses[] = $this->msg( 'abusefilter-throttled' )->parse();
					}
				} else {
					$statuses[] = $this->msg( 'abusefilter-disabled' )->parse();
				}

				if ( $row->af_global && $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
					$statuses[] = $this->msg( 'abusefilter-status-global' )->parse();
				}

				return $lang->commaList( $statuses );
			case 'af_hidden':
				$flagMsgs = [];
				if ( FilterUtils::isHidden( (int)$value ) ) {
					$flagMsgs[] = $this->msg( 'abusefilter-hidden' )->parse();
				}
				if ( FilterUtils::isProtected( (int)$value ) ) {
					$flagMsgs[] = $this->msg( 'abusefilter-protected' )->parse();
				}
				if ( !$flagMsgs ) {
					return $this->msg( 'abusefilter-unhidden' )->parse();
				}
				return $lang->commaList( $flagMsgs );
			case 'af_hit_count':
				if ( $this->afPermManager->canSeeLogDetailsForFilter( $user, $row->af_hidden ) ) {
					$count_display = $this->msg( 'abusefilter-hitcount' )
						->numParams( $value )->text();
					$link = $linkRenderer->makeKnownLink(
						SpecialPage::getTitleFor( 'AbuseLog' ),
						$count_display,
						[],
						[ 'wpSearchFilter' => $row->af_id ]
					);
				} else {
					$link = "";
				}
				return $link;
			case 'af_timestamp':
				$userLink =
					Linker::userLink(
						$row->af_user ?? 0,
						$row->af_user_text
					) .
					Linker::userToolLinks(
						$row->af_user ?? 0,
						$row->af_user_text
					);

				return $this->msg( 'abusefilter-edit-lastmod-text' )
					->rawParams(
						$this->mPage->getLinkToLatestDiff(
							$row->af_id,
							$lang->userTimeAndDate( $value, $user )
						),
						$userLink,
						$this->mPage->getLinkToLatestDiff(
							$row->af_id,
							$lang->userDate( $value, $user )
						),
						$this->mPage->getLinkToLatestDiff(
							$row->af_id,
							$lang->userTime( $value, $user )
						)
					)->params(
						wfEscapeWikiText( $row->af_user_text )
					)->parse();
			case 'af_group':
				return $this->specsFormatter->nameGroup( $value );
			default:
				throw new UnexpectedValueException( "Unknown row type $name!" );
		}
	}

	/**
	 * Get the filter pattern with <b> elements surrounding the searched pattern
	 *
	 * @param stdClass $row
	 * @return string
	 */
	private function getHighlightedPattern( stdClass $row ) {
		if ( $this->searchMode === null ) {
			throw new LogicException( 'Cannot search without a mode.' );
		}
		$maxLen = 50;
		if ( $this->searchMode === 'LIKE' ) {
			$position = mb_stripos( $row->af_pattern, $this->searchPattern );
			$length = mb_strlen( $this->searchPattern );
		} else {
			$regex = '/' . $this->searchPattern . '/u';
			if ( $this->searchMode === 'IRLIKE' ) {
				$regex .= 'i';
			}

			$matches = [];
			// phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
			$check = @preg_match(
				$regex,
				$row->af_pattern,
				$matches
			);
			// This may happen in case of catastrophic backtracking, or regexps matching
			// the empty string.
			if ( $check === false || strlen( $matches[0] ) === 0 ) {
				return htmlspecialchars( mb_substr( $row->af_pattern, 0, 50 ) );
			}

			$length = mb_strlen( $matches[0] );
			$position = mb_strpos( $row->af_pattern, $matches[0] );
		}

		$remaining = $maxLen - $length;
		if ( $remaining <= 0 ) {
			$pattern = '<b>' .
				htmlspecialchars( mb_substr( $row->af_pattern, $position, $maxLen ) ) .
				'</b>';
		} else {
			// Center the snippet on the matched string
			$minoffset = max( $position - round( $remaining / 2 ), 0 );
			$pattern = mb_substr( $row->af_pattern, $minoffset, $maxLen );
			$pattern =
				htmlspecialchars( mb_substr( $pattern, 0, $position - $minoffset ) ) .
				'<b>' .
				htmlspecialchars( mb_substr( $pattern, $position - $minoffset, $length ) ) .
				'</b>' .
				htmlspecialchars( mb_substr(
						$pattern,
						$position - $minoffset + $length,
						$remaining - ( $position - $minoffset + $length )
					)
				);
		}
		return $pattern;
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function getDefaultSort() {
		return 'af_id';
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function getTableClass() {
		return parent::getTableClass() . ' mw-abusefilter-list-scrollable';
	}

	/**
	 * @param stdClass $row
	 * @return string
	 * @see TablePager::getRowClass()
	 */
	public function getRowClass( $row ) {
		if ( $row->af_enabled ) {
			return $row->af_throttled ? 'mw-abusefilter-list-throttled' : 'mw-abusefilter-list-enabled';
		} elseif ( $row->af_deleted ) {
			return 'mw-abusefilter-list-deleted';
		} else {
			return 'mw-abusefilter-list-disabled';
		}
	}

	/**
	 * @inheritDoc
	 */
	public function getIndexField() {
		return [ self::INDEX_FIELDS[$this->mSort] ];
	}

	/**
	 * @param string $field
	 *
	 * @return bool
	 */
	public function isFieldSortable( $field ) {
		if ( ( $field === 'af_hit_count' || $field === 'af_public_comments' )
			&& !$this->afPermManager->canSeeLogDetails( $this->getAuthority() )
		) {
			return false;
		}
		return isset( self::INDEX_FIELDS[$field] );
	}
}
PK       ! 	H@T  T  !  Pager/AbuseFilterExaminePager.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Pager;

use MediaWiki\Extension\AbuseFilter\AbuseFilterChangesList;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Pager\ReverseChronologicalPager;
use MediaWiki\Title\Title;
use RecentChange;
use stdClass;
use Wikimedia\Rdbms\IReadableDatabase;

class AbuseFilterExaminePager extends ReverseChronologicalPager {
	/**
	 * @var AbuseFilterChangesList Our changes list
	 */
	private $changesList;
	/**
	 * @var Title
	 */
	private $title;
	/**
	 * @var array Query conditions
	 */
	private $conds;
	/**
	 * @var int Line number of the row, see RecentChange::$counter
	 */
	private $rcCounter;

	/**
	 * @param AbuseFilterChangesList $changesList
	 * @param LinkRenderer $linkRenderer
	 * @param IReadableDatabase $dbr
	 * @param Title $title
	 * @param array $conds
	 */
	public function __construct(
		AbuseFilterChangesList $changesList,
		LinkRenderer $linkRenderer,
		IReadableDatabase $dbr,
		Title $title,
		array $conds
	) {
		// Set database before parent constructor to avoid setting it there
		$this->mDb = $dbr;
		parent::__construct( $changesList, $linkRenderer );
		$this->changesList = $changesList;
		$this->title = $title;
		$this->conds = $conds;
		$this->rcCounter = 1;
	}

	/**
	 * @return array
	 */
	public function getQueryInfo() {
		$rcQuery = RecentChange::getQueryInfo();
		return [
			'tables' => $rcQuery['tables'],
			'fields' => $rcQuery['fields'],
			'conds' => $this->conds,
			'join_conds' => $rcQuery['joins'],
		];
	}

	/**
	 * @param stdClass $row
	 * @return string
	 */
	public function formatRow( $row ) {
		$rc = RecentChange::newFromRow( $row );
		$rc->counter = $this->rcCounter++;
		return $this->changesList->recentChangesLine( $rc, false );
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @inheritDoc
	 */
	public function getIndexField() {
		return 'rc_id';
	}

	/**
	 * @codeCoverageIgnore Merely declarative
	 * @return Title
	 */
	public function getTitle() {
		return $this->title;
	}

	/**
	 * @return string
	 */
	public function getEmptyBody() {
		return $this->msg( 'abusefilter-examine-noresults' )->parseAsBlock();
	}
}
PK       ! 5d +  +    View/AbuseFilterViewTools.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\View;

use MediaWiki\Context\IContextSource;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxBuilderFactory;
use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxField;
use MediaWiki\Html\Html;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Linker\LinkRenderer;

class AbuseFilterViewTools extends AbuseFilterView {

	/**
	 * @var EditBoxBuilderFactory
	 */
	private $boxBuilderFactory;

	/**
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param EditBoxBuilderFactory $boxBuilderFactory
	 * @param IContextSource $context
	 * @param LinkRenderer $linkRenderer
	 * @param string $basePageName
	 * @param array $params
	 */
	public function __construct(
		AbuseFilterPermissionManager $afPermManager,
		EditBoxBuilderFactory $boxBuilderFactory,
		IContextSource $context,
		LinkRenderer $linkRenderer,
		string $basePageName,
		array $params
	) {
		parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
		$this->boxBuilderFactory = $boxBuilderFactory;
	}

	/**
	 * Shows the page
	 */
	public function show() {
		$out = $this->getOutput();
		$out->enableOOUI();
		$out->addHelpLink( 'Extension:AbuseFilter/Rules format' );
		$request = $this->getRequest();

		if ( !$this->afPermManager->canUseTestTools( $this->getAuthority() ) ) {
			// TODO: the message still refers to the old rights
			$out->addWikiMsg( 'abusefilter-mustviewprivateoredit' );
			return;
		}

		// Header
		$out->addWikiMsg( 'abusefilter-tools-text' );

		$boxBuilder = $this->boxBuilderFactory->newEditBoxBuilder( $this, $this->getAuthority(), $out );

		// Expression evaluator
		$formDesc = [
			'rules' => [
				'class' => EditBoxField::class,
				'html' => $boxBuilder->buildEditBox(
					$request->getText( 'wpFilterRules' ),
					true,
					false,
					false
				)
			]
		];

		HTMLForm::factory( 'ooui', $formDesc, $this->getContext() )
			->setMethod( 'GET' )
			->setWrapperLegendMsg( 'abusefilter-tools-expr' )
			->setSubmitTextMsg( 'abusefilter-tools-submitexpr' )
			->setSubmitID( 'mw-abusefilter-submitexpr' )
			->setFooterHtml( Html::element( 'pre', [ 'id' => 'mw-abusefilter-expr-result' ], ' ' ) )
			->prepareForm()
			->displayForm( false );

		$out->addModules( 'ext.abuseFilter.tools' );

		if ( $this->afPermManager->canEdit( $this->getAuthority() ) ) {
			// Hacky little box to re-enable autoconfirmed if it got disabled
			$formDescriptor = [
				'RestoreAutoconfirmed' => [
					'label-message' => 'abusefilter-tools-reautoconfirm-user',
					'type' => 'user',
					'name' => 'wpReAutoconfirmUser',
					'id' => 'reautoconfirm-user',
					'infusable' => true
				],
			];
			$htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
			$htmlForm->setWrapperLegendMsg( 'abusefilter-tools-reautoconfirm' )
				->setSubmitTextMsg( 'abusefilter-tools-reautoconfirm-submit' )
				->setSubmitName( 'wpReautoconfirmSubmit' )
				->setSubmitID( 'mw-abusefilter-reautoconfirmsubmit' )
				->prepareForm()
				->displayForm( false );
		}
	}
}
PK       ! Wz(  (  !  View/AbuseFilterViewTestBatch.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\View;

use LogEventsList;
use LogPage;
use MediaWiki\Context\IContextSource;
use MediaWiki\Extension\AbuseFilter\AbuseFilterChangesList;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxBuilderFactory;
use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxField;
use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
use MediaWiki\Html\Html;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Title\Title;
use RecentChange;
use Wikimedia\Rdbms\LBFactory;
use Wikimedia\Rdbms\SelectQueryBuilder;

class AbuseFilterViewTestBatch extends AbuseFilterView {
	/**
	 * @var int The limit of changes to test, hard coded for now
	 */
	private static $mChangeLimit = 100;

	/**
	 * @var LBFactory
	 */
	private $lbFactory;
	/**
	 * @var string The text of the rule to test changes against
	 */
	private $testPattern;
	/**
	 * @var EditBoxBuilderFactory
	 */
	private $boxBuilderFactory;
	/**
	 * @var RuleCheckerFactory
	 */
	private $ruleCheckerFactory;
	/**
	 * @var VariableGeneratorFactory
	 */
	private $varGeneratorFactory;

	/**
	 * @param LBFactory $lbFactory
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param EditBoxBuilderFactory $boxBuilderFactory
	 * @param RuleCheckerFactory $ruleCheckerFactory
	 * @param VariableGeneratorFactory $varGeneratorFactory
	 * @param IContextSource $context
	 * @param LinkRenderer $linkRenderer
	 * @param string $basePageName
	 * @param array $params
	 */
	public function __construct(
		LBFactory $lbFactory,
		AbuseFilterPermissionManager $afPermManager,
		EditBoxBuilderFactory $boxBuilderFactory,
		RuleCheckerFactory $ruleCheckerFactory,
		VariableGeneratorFactory $varGeneratorFactory,
		IContextSource $context,
		LinkRenderer $linkRenderer,
		string $basePageName,
		array $params
	) {
		parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
		$this->lbFactory = $lbFactory;
		$this->boxBuilderFactory = $boxBuilderFactory;
		$this->ruleCheckerFactory = $ruleCheckerFactory;
		$this->varGeneratorFactory = $varGeneratorFactory;
	}

	/**
	 * Shows the page
	 */
	public function show() {
		$out = $this->getOutput();

		if ( !$this->afPermManager->canUseTestTools( $this->getAuthority() ) ) {
			// TODO: the message still refers to the old rights
			$out->addWikiMsg( 'abusefilter-mustviewprivateoredit' );
			return;
		}

		$this->loadParameters();

		// Check if a loaded test pattern uses protected variables and if the user has the right
		// to view protected variables. If they don't and protected variables are present, unset
		// the test pattern to avoid leaking PII and notify the user.
		// This is done as early as possible so that a filter with PII the user cannot access is
		// never loaded.
		if ( $this->testPattern !== '' ) {
			$ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
			$usedVars = $ruleChecker->getUsedVars( $this->testPattern );
			if ( $this->afPermManager->getForbiddenVariables( $this->getAuthority(), $usedVars ) ) {
				$this->testPattern = '';
				$out->addHtml(
					Html::errorBox( $this->msg( 'abusefilter-test-protectedvarerr' )->parse() )
				);
			}
		}

		$out->setPageTitleMsg( $this->msg( 'abusefilter-test' ) );
		$out->addHelpLink( 'Extension:AbuseFilter/Rules format' );
		$out->addWikiMsg( 'abusefilter-test-intro', self::$mChangeLimit );
		$out->enableOOUI();

		$boxBuilder = $this->boxBuilderFactory->newEditBoxBuilder( $this, $this->getAuthority(), $out );

		$rulesFields = [
			'rules' => [
				'section' => 'abusefilter-test-rules-section',
				'class' => EditBoxField::class,
				'html' => $boxBuilder->buildEditBox(
					$this->testPattern,
					true,
					true,
					false
				) . $this->buildFilterLoader()
			]
		];

		$RCMaxAge = $this->getConfig()->get( 'RCMaxAge' );
		$min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge );
		$max = wfTimestampNow();

		$optionsFields = [
			'TestAction' => [
				'type' => 'select',
				'label-message' => 'abusefilter-test-action',
				'options-messages' => [
					'abusefilter-test-search-type-all' => '0',
					'abusefilter-test-search-type-edit' => 'edit',
					'abusefilter-test-search-type-move' => 'move',
					'abusefilter-test-search-type-delete' => 'delete',
					'abusefilter-test-search-type-createaccount' => 'createaccount',
					'abusefilter-test-search-type-upload' => 'upload'
				],
			],
			'TestUser' => [
				'type' => 'user',
				'exists' => true,
				'ipallowed' => true,
				'required' => false,
				'label-message' => 'abusefilter-test-user',
			],
			'ExcludeBots' => [
				'type' => 'check',
				'label-message' => 'abusefilter-test-nobots',
			],
			'TestPeriodStart' => [
				'type' => 'datetime',
				'label-message' => 'abusefilter-test-period-start',
				'min' => $min,
				'max' => $max,
			],
			'TestPeriodEnd' => [
				'type' => 'datetime',
				'label-message' => 'abusefilter-test-period-end',
				'min' => $min,
				'max' => $max,
			],
			'TestPage' => [
				'type' => 'title',
				'label-message' => 'abusefilter-test-page',
				'creatable' => true,
				'required' => false,
			],
			'ShowNegative' => [
				'type' => 'check',
				'label-message' => 'abusefilter-test-shownegative',
			],
		];
		array_walk( $optionsFields, static function ( &$el ) {
			$el['section'] = 'abusefilter-test-options-section';
		} );
		$allFields = array_merge( $rulesFields, $optionsFields );

		HTMLForm::factory( 'ooui', $allFields, $this->getContext() )
			->setTitle( $this->getTitle( 'test' ) )
			->setId( 'wpFilterForm' )
			->setWrapperLegendMsg( 'abusefilter-test-legend' )
			->setSubmitTextMsg( 'abusefilter-test-submit' )
			->setSubmitCallback( [ $this, 'doTest' ] )
			->showAlways();
	}

	/**
	 * Loads the revisions and checks the given syntax against them
	 * @param array $formData
	 * @param HTMLForm $form
	 * @return bool
	 */
	public function doTest( array $formData, HTMLForm $form ): bool {
		// Quick syntax check.
		$ruleChecker = $this->ruleCheckerFactory->newRuleChecker();

		if ( !$ruleChecker->checkSyntax( $this->testPattern )->isValid() ) {
			$form->addPreHtml(
				Html::errorBox( $this->msg( 'abusefilter-test-syntaxerr' )->parse() )
			);
			return true;
		}

		$dbr = $this->lbFactory->getReplicaDatabase();
		$rcQuery = RecentChange::getQueryInfo();
		$conds = [];

		// Normalise username
		$userTitle = Title::newFromText( $formData['TestUser'], NS_USER );
		$testUser = $userTitle ? $userTitle->getText() : '';
		if ( $testUser !== '' ) {
			$conds[$rcQuery['fields']['rc_user_text']] = $testUser;
		}

		$startTS = strtotime( $formData['TestPeriodStart'] );
		if ( $startTS ) {
			$conds[] = $dbr->expr( 'rc_timestamp', '>=', $dbr->timestamp( $startTS ) );
		}
		$endTS = strtotime( $formData['TestPeriodEnd'] );
		if ( $endTS ) {
			$conds[] = $dbr->expr( 'rc_timestamp', '<=', $dbr->timestamp( $endTS ) );
		}
		if ( $formData['TestPage'] !== '' ) {
			// The form validates the input for us, so this shouldn't throw.
			$title = Title::newFromTextThrow( $formData['TestPage'] );
			$conds['rc_namespace'] = $title->getNamespace();
			$conds['rc_title'] = $title->getDBkey();
		}

		if ( $formData['ExcludeBots'] ) {
			$conds['rc_bot'] = 0;
		}

		$action = $formData['TestAction'] !== '0' ? $formData['TestAction'] : false;
		$conds[] = $this->buildTestConditions( $dbr, $action );
		$conds = array_merge( $conds, $this->buildVisibilityConditions( $dbr, $this->getAuthority() ) );

		$res = $dbr->newSelectQueryBuilder()
			->tables( $rcQuery['tables'] )
			->fields( $rcQuery['fields'] )
			->conds( $conds )
			->caller( __METHOD__ )
			->limit( self::$mChangeLimit )
			->orderBy( 'rc_timestamp', SelectQueryBuilder::SORT_DESC )
			->joinConds( $rcQuery['joins'] )
			->fetchResultSet();

		// Get our ChangesList
		$changesList = new AbuseFilterChangesList( $this->getContext(), $this->testPattern );
		// Note, we're initializing some rows that will later be discarded. Hopefully this won't have any overhead.
		$changesList->initChangesListRows( $res );
		$output = $changesList->beginRecentChangesList();

		$counter = 1;

		$contextUser = $this->getUser();
		$ruleChecker->toggleConditionLimit( false );
		foreach ( $res as $row ) {
			$rc = RecentChange::newFromRow( $row );
			if ( !$formData['ShowNegative'] ) {
				$type = (int)$rc->getAttribute( 'rc_type' );
				$deletedValue = (int)$rc->getAttribute( 'rc_deleted' );
				if (
					(
						$type === RC_LOG &&
						!LogEventsList::userCanBitfield(
							$deletedValue,
							LogPage::SUPPRESSED_ACTION | LogPage::SUPPRESSED_USER,
							$contextUser
						)
					) || (
						$type !== RC_LOG &&
						!RevisionRecord::userCanBitfield( $deletedValue, RevisionRecord::SUPPRESSED_ALL, $contextUser )
					)
				) {
					// If the RC is deleted, the user can't see it, and we're only showing matches,
					// always skip this row. If ShowNegative is true, we can still show the row
					// because we won't tell whether it matches the given filter.
					continue;
				}
			}

			$varGenerator = $this->varGeneratorFactory->newRCGenerator( $rc, $contextUser );
			$vars = $varGenerator->getVars();

			if ( !$vars ) {
				continue;
			}

			$ruleChecker->setVariables( $vars );
			$result = $ruleChecker->checkConditions( $this->testPattern )->getResult();

			if ( $result || $formData['ShowNegative'] ) {
				// Stash result in RC item
				$rc->filterResult = $result;
				$rc->counter = $counter++;
				$output .= $changesList->recentChangesLine( $rc, false );
			}
		}

		$output .= $changesList->endRecentChangesList();

		$form->addPostHtml( $output );

		return true;
	}

	/**
	 * Loads parameters from request
	 */
	public function loadParameters() {
		$request = $this->getRequest();

		$this->testPattern = $request->getText( 'wpFilterRules' );

		if ( $this->testPattern === ''
			&& count( $this->mParams ) > 1
			&& is_numeric( $this->mParams[1] )
		) {
			$dbr = $this->lbFactory->getReplicaDatabase();
			$pattern = $dbr->newSelectQueryBuilder()
				->select( 'af_pattern' )
				->from( 'abuse_filter' )
				->where( [ 'af_id' => intval( $this->mParams[1] ) ] )
				->caller( __METHOD__ )
				->fetchField();
			if ( $pattern !== false ) {
				$this->testPattern = $pattern;
			}
		}
	}
}
PK       ! 7      View/AbuseFilterViewImport.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\View;

use MediaWiki\HTMLForm\HTMLForm;

class AbuseFilterViewImport extends AbuseFilterView {
	/**
	 * Shows the page
	 */
	public function show() {
		$out = $this->getOutput();
		if ( !$this->afPermManager->canEdit( $this->getAuthority() ) ) {
			$out->addWikiMsg( 'abusefilter-edit-notallowed' );
			return;
		}

		$out->addWikiMsg( 'abusefilter-import-intro' );

		$formDescriptor = [
			'ImportText' => [
				'type' => 'textarea',
				'required' => true
			]
		];
		HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
			->setTitle( $this->getTitle( 'new' ) )
			->setSubmitTextMsg( 'abusefilter-import-submit' )
			->show();
	}
}
PK       ! :d0+  0+    View/AbuseFilterViewDiff.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\View;

use DifferenceEngine;
use MediaWiki\Content\TextContent;
use MediaWiki\Context\IContextSource;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\Filter\ClosestFilterVersionNotFoundException;
use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException;
use MediaWiki\Extension\AbuseFilter\Filter\FilterVersionNotFoundException;
use MediaWiki\Extension\AbuseFilter\Filter\HistoryFilter;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
use MediaWiki\Extension\AbuseFilter\TableDiffFormatterFullContext;
use MediaWiki\Html\Html;
use MediaWiki\Linker\Linker;
use MediaWiki\Linker\LinkRenderer;
use OOUI;
use Wikimedia\Diff\Diff;

class AbuseFilterViewDiff extends AbuseFilterView {
	/**
	 * @var HistoryFilter|null The old version of the filter
	 */
	public $oldVersion;
	/**
	 * @var HistoryFilter|null The new version of the filter
	 */
	public $newVersion;
	/**
	 * @var int|null The history ID of the next version, if any
	 */
	public $nextHistoryId;
	/**
	 * @var int|null The ID of the filter
	 */
	private $filter;
	/**
	 * @var SpecsFormatter
	 */
	private $specsFormatter;
	/**
	 * @var FilterLookup
	 */
	private $filterLookup;

	/**
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param SpecsFormatter $specsFormatter
	 * @param FilterLookup $filterLookup
	 * @param IContextSource $context
	 * @param LinkRenderer $linkRenderer
	 * @param string $basePageName
	 * @param array $params
	 */
	public function __construct(
		AbuseFilterPermissionManager $afPermManager,
		SpecsFormatter $specsFormatter,
		FilterLookup $filterLookup,
		IContextSource $context,
		LinkRenderer $linkRenderer,
		string $basePageName,
		array $params
	) {
		parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
		$this->specsFormatter = $specsFormatter;
		$this->specsFormatter->setMessageLocalizer( $this->getContext() );
		$this->filterLookup = $filterLookup;
	}

	/**
	 * Shows the page
	 */
	public function show() {
		$show = $this->loadData();
		$out = $this->getOutput();
		$out->enableOOUI();
		$out->addModuleStyles( [ 'oojs-ui.styles.icons-movement' ] );

		$links = [];
		if ( $this->filter ) {
			$links['abusefilter-history-backedit'] = $this->getTitle( $this->filter )->getFullURL();
			$links['abusefilter-diff-backhistory'] = $this->getTitle( "history/$this->filter" )->getFullURL();
		}

		foreach ( $links as $msg => $href ) {
			$links[$msg] = new OOUI\ButtonWidget( [
				'label' => $this->msg( $msg )->text(),
				'href' => $href
			] );
		}

		$backlinks = new OOUI\HorizontalLayout( [ 'items' => array_values( $links ) ] );
		$out->addHTML( $backlinks );

		if ( $show ) {
			$out->addHTML( $this->formatDiff() );
			// Next and previous change links
			$buttons = [];
			$oldHistoryID = $this->oldVersion->getHistoryID();
			if ( $this->filterLookup->getFirstFilterVersionID( $this->filter ) !== $oldHistoryID ) {
				// Create a "previous change" link if this isn't the first change of the given filter
				$href = $this->getTitle( "history/$this->filter/diff/prev/$oldHistoryID" )->getFullURL();
				$buttons[] = new OOUI\ButtonWidget( [
					'label' => $this->msg( 'abusefilter-diff-prev' )->text(),
					'href' => $href,
					'icon' => 'previous'
				] );
			}

			if ( $this->nextHistoryId !== null ) {
				// Create a "next change" link if this isn't the last change of the given filter
				$href = $this->getTitle( "history/$this->filter/diff/prev/$this->nextHistoryId" )->getFullURL();
				$buttons[] = new OOUI\ButtonWidget( [
					'label' => $this->msg( 'abusefilter-diff-next' )->text(),
					'href' => $href,
					'icon' => 'next'
				] );
			}

			if ( count( $buttons ) > 0 ) {
				$buttons = new OOUI\HorizontalLayout( [
					'items' => $buttons,
					'classes' => [ 'mw-abusefilter-history-buttons' ]
				] );
				$out->addHTML( $buttons );
			}
		}
	}

	/**
	 * @return bool
	 */
	public function loadData() {
		$oldSpec = $this->mParams[3];
		$newSpec = $this->mParams[4];

		if ( !is_numeric( $this->mParams[1] ) ) {
			$this->getOutput()->addWikiMsg( 'abusefilter-diff-invalid' );
			return false;
		}
		$this->filter = (int)$this->mParams[1];

		$this->oldVersion = $this->loadSpec( $oldSpec, $newSpec );
		$this->newVersion = $this->loadSpec( $newSpec, $oldSpec );

		if ( $this->oldVersion === null || $this->newVersion === null ) {
			$this->getOutput()->addWikiMsg( 'abusefilter-diff-invalid' );
			return false;
		}

		if ( !$this->afPermManager->canViewPrivateFilters( $this->getAuthority() ) &&
			( $this->oldVersion->isHidden() || $this->newVersion->isHidden() )
		) {
			$this->getOutput()->addWikiMsg( 'abusefilter-history-error-hidden' );
			return false;
		}

		if ( !$this->afPermManager->canViewProtectedVariables( $this->getAuthority() ) &&
			( $this->oldVersion->isProtected() || $this->newVersion->isProtected() )
		) {
			$this->getOutput()->addWikiMsg( 'abusefilter-history-error-protected' );
			return false;
		}

		try {
			$this->nextHistoryId = $this->filterLookup->getClosestVersion(
				$this->newVersion->getHistoryID(),
				$this->filter,
				FilterLookup::DIR_NEXT
			)->getHistoryID();
		} catch ( ClosestFilterVersionNotFoundException $_ ) {
			$this->nextHistoryId = null;
		}

		return true;
	}

	/**
	 * @param string $spec
	 * @param string $otherSpec
	 * @return HistoryFilter|null
	 */
	public function loadSpec( $spec, $otherSpec ): ?HistoryFilter {
		static $dependentSpecs = [ 'prev', 'next' ];
		static $cache = [];

		if ( isset( $cache[$spec] ) ) {
			return $cache[$spec];
		}

		$filterObj = null;
		if ( ( $spec === 'prev' || $spec === 'next' ) && !in_array( $otherSpec, $dependentSpecs ) ) {
			$other = $this->loadSpec( $otherSpec, $spec );

			if ( !$other ) {
				return null;
			}

			$dir = $spec === 'prev' ? FilterLookup::DIR_PREV : FilterLookup::DIR_NEXT;
			try {
				$filterObj = $this->filterLookup->getClosestVersion( $other->getHistoryID(), $this->filter, $dir );
			} catch ( ClosestFilterVersionNotFoundException $_ ) {
				$t = $this->getTitle( "history/$this->filter/item/" . $other->getHistoryID() );
				$this->getOutput()->redirect( $t->getFullURL() );
				return null;
			}
		}

		if ( $filterObj === null ) {
			try {
				if ( is_numeric( $spec ) ) {
					$filterObj = $this->filterLookup->getFilterVersion( (int)$spec );
				} elseif ( $spec === 'cur' ) {
					$filterObj = $this->filterLookup->getLastHistoryVersion( $this->filter );
				}
			} catch ( FilterNotFoundException | FilterVersionNotFoundException $_ ) {
			}
		}

		$cache[$spec] = $filterObj;
		return $cache[$spec];
	}

	/**
	 * @param HistoryFilter $filterVersion
	 * @return string raw html for the <th> element
	 */
	private function getVersionHeading( HistoryFilter $filterVersion ) {
		$text = $this->getLanguage()->userTimeAndDate(
			$filterVersion->getTimestamp(),
			$this->getUser()
		);
		$history_id = $filterVersion->getHistoryID();
		$title = $this->getTitle( "history/$this->filter/item/$history_id" );

		$versionLink = $this->linkRenderer->makeLink( $title, $text );
		$userLink = Linker::userLink(
			$filterVersion->getUserID(),
			$filterVersion->getUserName()
		);
		return Html::rawElement(
			'th',
			[],
			$this->msg( 'abusefilter-diff-version' )
				->rawParams( $versionLink, $userLink )
				->params( $filterVersion->getUserName() )
				->parse()
		);
	}

	/**
	 * @return string
	 */
	public function formatDiff() {
		$oldVersion = $this->oldVersion;
		$newVersion = $this->newVersion;

		// headings
		$headings = Html::rawElement(
			'th',
			[],
			$this->msg( 'abusefilter-diff-item' )->parse()
		);
		$headings .= $this->getVersionHeading( $oldVersion );
		$headings .= $this->getVersionHeading( $newVersion );
		$headings = Html::rawElement( 'tr', [], $headings );

		$body = '';
		// Basic info
		$info = $this->getDiffRow( 'abusefilter-edit-description', $oldVersion->getName(), $newVersion->getName() );
		$info .= $this->getDiffRow(
			'abusefilter-edit-group',
			$this->specsFormatter->nameGroup( $oldVersion->getGroup() ),
			$this->specsFormatter->nameGroup( $newVersion->getGroup() )
		);
		$info .= $this->getDiffRow(
			'abusefilter-edit-flags',
			$this->specsFormatter->formatFilterFlags( $oldVersion, $this->getLanguage() ),
			$this->specsFormatter->formatFilterFlags( $newVersion, $this->getLanguage() )
		);

		$info .= $this->getDiffRow( 'abusefilter-edit-notes', $oldVersion->getComments(), $newVersion->getComments() );
		if ( $info !== '' ) {
			$body .= $this->getHeaderRow( 'abusefilter-diff-info' ) . $info;
		}

		$pattern = $this->getDiffRow( 'abusefilter-edit-rules', $oldVersion->getRules(), $newVersion->getRules() );
		if ( $pattern !== '' ) {
			$body .= $this->getHeaderRow( 'abusefilter-diff-pattern' ) . $pattern;
		}

		$actions = $this->getDiffRow(
			'abusefilter-edit-consequences',
			$this->stringifyActions( $oldVersion->getActions() ) ?: [ '' ],
			$this->stringifyActions( $newVersion->getActions() ) ?: [ '' ]
		);
		if ( $actions !== '' ) {
			$body .= $this->getHeaderRow( 'abusefilter-edit-consequences' ) . $actions;
		}

		$tableHead = Html::rawElement( 'thead', [], $headings );
		$tableBody = Html::rawElement( 'tbody', [], $body );
		$table = Html::rawElement(
			'table',
			[ 'class' => 'wikitable' ],
			$tableHead . $tableBody
		);

		return Html::rawElement( 'h2', [], $this->msg( 'abusefilter-diff-title' )->parse() ) .
			$table;
	}

	/**
	 * @param string[][] $actions
	 * @return string[]
	 */
	private function stringifyActions( array $actions ): array {
		$lines = [];

		ksort( $actions );
		$language = $this->getLanguage();
		foreach ( $actions as $action => $parameters ) {
			$lines[] = $this->specsFormatter->formatAction( $action, $parameters, $language );
		}

		return $lines;
	}

	/**
	 * @param string $key
	 * @return string
	 */
	public function getHeaderRow( $key ) {
		$msg = $this->msg( $key )->parse();
		return Html::rawElement(
			'tr',
			[ 'class' => 'mw-abusefilter-diff-header' ],
			Html::rawElement( 'th', [ 'colspan' => 3 ], $msg )
		);
	}

	/**
	 * @param string $key
	 * @param array|string $old
	 * @param array|string $new
	 * @return string
	 */
	public function getDiffRow( $key, $old, $new ) {
		if ( !is_array( $old ) ) {
			$old = explode( "\n", TextContent::normalizeLineEndings( $old ) );
		}
		if ( !is_array( $new ) ) {
			$new = explode( "\n", TextContent::normalizeLineEndings( $new ) );
		}

		if ( $old === $new ) {
			return '';
		}

		$diffEngine = new DifferenceEngine( $this->getContext() );

		$diffEngine->showDiffStyle();

		$diff = new Diff( $old, $new );
		$formatter = new TableDiffFormatterFullContext();
		$formattedDiff = $diffEngine->addHeader( $formatter->format( $diff ), '', '' );

		$heading = Html::rawElement( 'th', [], $this->msg( $key )->parse() );
		$bodyCell = Html::rawElement( 'td', [ 'colspan' => 2 ], $formattedDiff );
		return Html::rawElement(
			'tr',
			[],
			$heading . $bodyCell
		) . "\n";
	}
}
PK       ! MGgh  h    View/AbuseFilterViewHistory.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\View;

use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Context\IContextSource;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use MediaWiki\Extension\AbuseFilter\Pager\AbuseFilterHistoryPager;
use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Linker\Linker;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\User\UserNameUtils;
use OOUI;

class AbuseFilterViewHistory extends AbuseFilterView {

	/** @var int|null */
	private $filter;

	/** @var FilterLookup */
	private $filterLookup;

	/** @var SpecsFormatter */
	private $specsFormatter;

	/** @var UserNameUtils */
	private $userNameUtils;

	/** @var LinkBatchFactory */
	private $linkBatchFactory;

	/**
	 * @param UserNameUtils $userNameUtils
	 * @param LinkBatchFactory $linkBatchFactory
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param FilterLookup $filterLookup
	 * @param SpecsFormatter $specsFormatter
	 * @param IContextSource $context
	 * @param LinkRenderer $linkRenderer
	 * @param string $basePageName
	 * @param array $params
	 */
	public function __construct(
		UserNameUtils $userNameUtils,
		LinkBatchFactory $linkBatchFactory,
		AbuseFilterPermissionManager $afPermManager,
		FilterLookup $filterLookup,
		SpecsFormatter $specsFormatter,
		IContextSource $context,
		LinkRenderer $linkRenderer,
		string $basePageName,
		array $params
	) {
		parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
		$this->userNameUtils = $userNameUtils;
		$this->linkBatchFactory = $linkBatchFactory;
		$this->filterLookup = $filterLookup;
		$this->specsFormatter = $specsFormatter;
		$this->specsFormatter->setMessageLocalizer( $context );
		$this->filter = $this->mParams['filter'] ?? null;
	}

	/**
	 * Shows the page
	 */
	public function show() {
		$out = $this->getOutput();
		$out->enableOOUI();
		$filter = $this->getRequest()->getIntOrNull( 'filter' ) ?: $this->filter;
		$canViewPrivate = $this->afPermManager->canViewPrivateFilters( $this->getAuthority() );
		$canViewProtectedVars = $this->afPermManager->canViewProtectedVariables( $this->getAuthority() );

		if ( $filter ) {
			try {
				$filterObj = $this->filterLookup->getFilter( $filter, false );
			} catch ( FilterNotFoundException $_ ) {
				$filter = null;
			}
			if ( isset( $filterObj ) && $filterObj->isHidden() && !$canViewPrivate ) {
				$out->addWikiMsg( 'abusefilter-history-error-hidden' );
				return;
			}
			if ( isset( $filterObj ) && $filterObj->isProtected() && !$canViewProtectedVars ) {
				$out->addWikiMsg( 'abusefilter-history-error-protected' );
				return;
			}
		}

		if ( $filter ) {
			// Parse wikitext in this message to allow formatting of numero signs (T343994#9209383)
			$out->setPageTitle( $this->msg( 'abusefilter-history' )->numParams( $filter )->parse() );
		} else {
			$out->setPageTitleMsg( $this->msg( 'abusefilter-filter-log' ) );
		}

		// Useful links
		$links = [];
		if ( $filter ) {
			$links['abusefilter-history-backedit'] = $this->getTitle( $filter )->getFullURL();
		}

		foreach ( $links as $msg => $title ) {
			$links[$msg] =
				new OOUI\ButtonWidget( [
					'label' => $this->msg( $msg )->text(),
					'href' => $title
				] );
		}

		$backlinks =
			new OOUI\HorizontalLayout( [
				'items' => array_values( $links )
			] );
		$out->addHTML( $backlinks );

		// For user
		$user = $this->userNameUtils->getCanonical(
			$this->getRequest()->getText( 'user' ),
			UserNameUtils::RIGOR_VALID
		);
		if ( $user !== false ) {
			$out->addSubtitle(
				$this->msg( 'abusefilter-history-foruser' )
					// We don't really need to pass the real user ID
					->rawParams( Linker::userLink( 1, $user ) )
					// For GENDER
					->params( $user )
					->parse()
			);
		} else {
			$user = null;
		}

		$formDescriptor = [
			'user' => [
				'type' => 'user',
				'name' => 'user',
				'default' => $user,
				'size' => '45',
				'label-message' => 'abusefilter-history-select-user'
			],
			'filter' => [
				'type' => 'int',
				'name' => 'filter',
				'default' => $filter ?: '',
				'size' => '45',
				'label-message' => 'abusefilter-history-select-filter'
			],
		];

		$htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
		$htmlForm->setSubmitTextMsg( 'abusefilter-history-select-submit' )
			->setWrapperLegendMsg( 'abusefilter-history-select-legend' )
			->setTitle( $this->getTitle( 'history' ) )
			->setMethod( 'get' )
			->prepareForm()
			->displayForm( false );

		$pager = new AbuseFilterHistoryPager(
			$this->getContext(),
			$this->linkRenderer,
			$this->linkBatchFactory,
			$this->filterLookup,
			$this->specsFormatter,
			$filter,
			$user,
			$canViewPrivate,
			$canViewProtectedVars
		);

		$out->addParserOutputContent( $pager->getFullOutput() );
	}
}
PK       ! 8:82  82    View/AbuseFilterViewExamine.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\View;

use ChangesList;
use LogicException;
use MediaWiki\Context\IContextSource;
use MediaWiki\Extension\AbuseFilter\AbuseFilterChangesList;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\AbuseLoggerFactory;
use MediaWiki\Extension\AbuseFilter\CentralDBNotAvailableException;
use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxBuilderFactory;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use MediaWiki\Extension\AbuseFilter\FilterUtils;
use MediaWiki\Extension\AbuseFilter\Pager\AbuseFilterExaminePager;
use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseLog;
use MediaWiki\Extension\AbuseFilter\VariableGenerator\VariableGeneratorFactory;
use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesFormatter;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
use MediaWiki\Html\Html;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Title\Title;
use OOUI;
use RecentChange;
use Wikimedia\Rdbms\LBFactory;

class AbuseFilterViewExamine extends AbuseFilterView {
	/**
	 * @var string The rules of the filter we're examining
	 */
	private $testFilter;
	/**
	 * @var LBFactory
	 */
	private $lbFactory;
	/**
	 * @var FilterLookup
	 */
	private $filterLookup;
	/**
	 * @var EditBoxBuilderFactory
	 */
	private $boxBuilderFactory;
	/**
	 * @var VariablesBlobStore
	 */
	private $varBlobStore;
	/**
	 * @var VariablesFormatter
	 */
	private $variablesFormatter;
	/**
	 * @var VariablesManager
	 */
	private $varManager;
	/**
	 * @var VariableGeneratorFactory
	 */
	private $varGeneratorFactory;

	private AbuseLoggerFactory $abuseLoggerFactory;

	/**
	 * @param LBFactory $lbFactory
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param FilterLookup $filterLookup
	 * @param EditBoxBuilderFactory $boxBuilderFactory
	 * @param VariablesBlobStore $varBlobStore
	 * @param VariablesFormatter $variablesFormatter
	 * @param VariablesManager $varManager
	 * @param VariableGeneratorFactory $varGeneratorFactory
	 * @param AbuseLoggerFactory $abuseLoggerFactory
	 * @param IContextSource $context
	 * @param LinkRenderer $linkRenderer
	 * @param string $basePageName
	 * @param array $params
	 */
	public function __construct(
		LBFactory $lbFactory,
		AbuseFilterPermissionManager $afPermManager,
		FilterLookup $filterLookup,
		EditBoxBuilderFactory $boxBuilderFactory,
		VariablesBlobStore $varBlobStore,
		VariablesFormatter $variablesFormatter,
		VariablesManager $varManager,
		VariableGeneratorFactory $varGeneratorFactory,
		AbuseLoggerFactory $abuseLoggerFactory,
		IContextSource $context,
		LinkRenderer $linkRenderer,
		string $basePageName,
		array $params
	) {
		parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
		$this->lbFactory = $lbFactory;
		$this->filterLookup = $filterLookup;
		$this->boxBuilderFactory = $boxBuilderFactory;
		$this->varBlobStore = $varBlobStore;
		$this->variablesFormatter = $variablesFormatter;
		$this->variablesFormatter->setMessageLocalizer( $context );
		$this->varManager = $varManager;
		$this->varGeneratorFactory = $varGeneratorFactory;
		$this->abuseLoggerFactory = $abuseLoggerFactory;
	}

	/**
	 * Shows the page
	 */
	public function show() {
		$out = $this->getOutput();
		$out->setPageTitleMsg( $this->msg( 'abusefilter-examine' ) );
		$out->addHelpLink( 'Extension:AbuseFilter/Rules format' );
		if ( $this->afPermManager->canUseTestTools( $this->getAuthority() ) ) {
			$out->addWikiMsg( 'abusefilter-examine-intro' );
		} else {
			$out->addWikiMsg( 'abusefilter-examine-intro-examine-only' );
		}

		$this->testFilter = $this->getRequest()->getText( 'testfilter' );

		// Check if we've got a subpage
		if ( count( $this->mParams ) > 1 && is_numeric( $this->mParams[1] ) ) {
			$this->showExaminerForRC( $this->mParams[1] );
		} elseif ( count( $this->mParams ) > 2
			&& $this->mParams[1] === 'log'
			&& is_numeric( $this->mParams[2] )
		) {
			$this->showExaminerForLogEntry( $this->mParams[2] );
		} else {
			$this->showSearch();
		}
	}

	/**
	 * Shows the search form
	 */
	public function showSearch() {
		$RCMaxAge = $this->getConfig()->get( 'RCMaxAge' );
		$min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge );
		$max = wfTimestampNow();
		$formDescriptor = [
			'SearchUser' => [
				'label-message' => 'abusefilter-test-user',
				'type' => 'user',
				'ipallowed' => true,
			],
			'SearchPeriodStart' => [
				'label-message' => 'abusefilter-test-period-start',
				'type' => 'datetime',
				'min' => $min,
				'max' => $max,
			],
			'SearchPeriodEnd' => [
				'label-message' => 'abusefilter-test-period-end',
				'type' => 'datetime',
				'min' => $min,
				'max' => $max,
			],
		];
		HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
			->addHiddenField( 'testfilter', $this->testFilter )
			->setWrapperLegendMsg( 'abusefilter-examine-legend' )
			->setSubmitTextMsg( 'abusefilter-examine-submit' )
			->setSubmitCallback( [ $this, 'showResults' ] )
			->showAlways();
	}

	/**
	 * Show search results, called as submit callback by HTMLForm
	 * @param array $formData
	 * @param HTMLForm $form
	 * @return bool
	 */
	public function showResults( array $formData, HTMLForm $form ): bool {
		$changesList = new AbuseFilterChangesList( $this->getContext(), $this->testFilter );

		$dbr = $this->lbFactory->getReplicaDatabase();
		$conds = $this->buildVisibilityConditions( $dbr, $this->getAuthority() );
		$conds[] = $this->buildTestConditions( $dbr );

		// Normalise username
		$userTitle = Title::newFromText( $formData['SearchUser'], NS_USER );
		$userName = $userTitle ? $userTitle->getText() : '';

		if ( $userName !== '' ) {
			$rcQuery = RecentChange::getQueryInfo();
			$conds[$rcQuery['fields']['rc_user_text']] = $userName;
		}

		$startTS = strtotime( $formData['SearchPeriodStart'] );
		if ( $startTS ) {
			$conds[] = $dbr->expr( 'rc_timestamp', '>=', $dbr->timestamp( $startTS ) );
		}
		$endTS = strtotime( $formData['SearchPeriodEnd'] );
		if ( $endTS ) {
			$conds[] = $dbr->expr( 'rc_timestamp', '<=', $dbr->timestamp( $endTS ) );
		}
		$pager = new AbuseFilterExaminePager(
			$changesList,
			$this->linkRenderer,
			$dbr,
			$this->getTitle( 'examine' ),
			$conds
		);

		$output = $changesList->beginRecentChangesList()
			. $pager->getNavigationBar()
			. $pager->getBody()
			. $pager->getNavigationBar()
			. $changesList->endRecentChangesList();

		$form->addPostHtml( $output );
		return true;
	}

	/**
	 * @param int $rcid
	 */
	public function showExaminerForRC( $rcid ) {
		// Get data
		$rc = RecentChange::newFromId( $rcid );
		$out = $this->getOutput();
		if ( !$rc ) {
			$out->addWikiMsg( 'abusefilter-examine-notfound' );
			return;
		}

		if ( !ChangesList::userCan( $rc, RevisionRecord::SUPPRESSED_ALL ) ) {
			$out->addWikiMsg( 'abusefilter-log-details-hidden-implicit' );
			return;
		}

		$varGenerator = $this->varGeneratorFactory->newRCGenerator( $rc, $this->getUser() );
		$vars = $varGenerator->getVars() ?: new VariableHolder();
		$out->addJsConfigVars( [
			'wgAbuseFilterVariables' => $this->varManager->dumpAllVars( $vars, true ),
			'abuseFilterExamine' => [ 'type' => 'rc', 'id' => $rcid ]
		] );

		$this->showExaminer( $vars );
	}

	/**
	 * @param int $logid
	 */
	public function showExaminerForLogEntry( $logid ) {
		// Get data
		$dbr = $this->lbFactory->getReplicaDatabase();
		$performer = $this->getAuthority();
		$out = $this->getOutput();

		$row = $dbr->newSelectQueryBuilder()
			->select( [
				'afl_deleted',
				'afl_ip',
				'afl_var_dump',
				'afl_rev_id',
				'afl_filter_id',
				'afl_global'
			] )
			->from( 'abuse_filter_log' )
			->where( [ 'afl_id' => $logid ] )
			->caller( __METHOD__ )
			->fetchRow();

		if ( !$row ) {
			$out->addWikiMsg( 'abusefilter-examine-notfound' );
			return;
		}

		try {
			$privacyLevel = $this->filterLookup->getFilter( $row->afl_filter_id, $row->afl_global )->getPrivacyLevel();
		} catch ( CentralDBNotAvailableException $_ ) {
			// Conservatively assume that it's hidden and protected, like in AbuseLogPager::doFormatRow
			$privacyLevel = Flags::FILTER_HIDDEN | Flags::FILTER_USES_PROTECTED_VARS;
		}
		if ( !$this->afPermManager->canSeeLogDetailsForFilter( $performer, $privacyLevel ) ) {
			$out->addWikiMsg( 'abusefilter-log-cannot-see-details' );
			return;
		}

		$visibility = SpecialAbuseLog::getEntryVisibilityForUser( $row, $performer, $this->afPermManager );
		if ( $visibility !== SpecialAbuseLog::VISIBILITY_VISIBLE ) {
			if ( $visibility === SpecialAbuseLog::VISIBILITY_HIDDEN ) {
				$msg = 'abusefilter-log-details-hidden';
			} elseif ( $visibility === SpecialAbuseLog::VISIBILITY_HIDDEN_IMPLICIT ) {
				$msg = 'abusefilter-log-details-hidden-implicit';
			} else {
				throw new LogicException( "Unexpected visibility $visibility" );
			}
			$out->addWikiMsg( $msg );
			return;
		}

		$shouldLogProtectedVarAccess = false;

		// Logs that reveal the values of protected variables are gated behind:
		// 1. the `abusefilter-access-protected-vars` right
		// 2. agreement to the `abusefilter-protected-vars-view-agreement` preference
		$userAuthority = $this->getAuthority();
		$canViewProtectedVars = $this->afPermManager->canViewProtectedVariableValues( $userAuthority );
		if ( FilterUtils::isProtected( $privacyLevel ) ) {
			if ( !$canViewProtectedVars ) {
				$out->addWikiMsg( 'abusefilter-examine-protected-vars-permission' );
				return;
			} else {
				$shouldLogProtectedVarAccess = true;
			}
		}

		// If a non-protected filter and a protected filter have overlapping conditions,
		// it's possible for a hit to contain a protected variable and for that variable
		// to be dumped and displayed on a detail page that wouldn't be considered
		// protected (because it caught on the public filter).
		// We shouldn't block access to the details of an otherwise public filter hit so
		// instead only check for access to the protected variables and redact them if the
		// user shouldn't see them.
		$vars = $this->varBlobStore->loadVarDump( $row );
		$varsArray = $this->varManager->dumpAllVars( $vars, true );

		foreach ( $this->afPermManager->getProtectedVariables() as $protectedVariable ) {
			if ( isset( $varsArray[$protectedVariable] ) ) {
				if ( !$canViewProtectedVars ) {
					$varsArray[$protectedVariable] = '';
				} else {
					// Protected variable in protected filters logs access in the general permission check
					// Log access to non-protected filters that happen to expose protected variables here
					if ( !FilterUtils::isProtected( $privacyLevel ) ) {
						$shouldLogProtectedVarAccess = true;
					}
				}
			}
		}
		$vars = VariableHolder::newFromArray( $varsArray );

		if ( $shouldLogProtectedVarAccess ) {
			$logger = $this->abuseLoggerFactory->getProtectedVarsAccessLogger();
			$logger->logViewProtectedVariableValue(
				$userAuthority->getUser(),
				$varsArray['user_name'] ?? $varsArray['accountname']
			);
		}

		$out->addJsConfigVars( [
			'wgAbuseFilterVariables' => $varsArray,
			'abuseFilterExamine' => [ 'type' => 'log', 'id' => $logid ]
		] );
		$this->showExaminer( $vars );
	}

	/**
	 * @param VariableHolder|null $vars
	 */
	public function showExaminer( ?VariableHolder $vars ) {
		$output = $this->getOutput();
		$output->enableOOUI();

		if ( !$vars ) {
			$output->addWikiMsg( 'abusefilter-examine-incompatible' );
			return;
		}

		$html = '';

		$output->addModules( 'ext.abuseFilter.examine' );

		// Add test bit
		if ( $this->afPermManager->canUseTestTools( $this->getAuthority() ) ) {
			$boxBuilder = $this->boxBuilderFactory->newEditBoxBuilder(
				$this,
				$this->getAuthority(),
				$output
			);

			$tester = Html::rawElement( 'h2', [], $this->msg( 'abusefilter-examine-test' )->parse() );
			$tester .= $boxBuilder->buildEditBox( $this->testFilter, false, false, false );
			$tester .= $this->buildFilterLoader();
			$html .= Html::rawElement( 'div', [ 'id' => 'mw-abusefilter-examine-editor' ], $tester );
			$html .= Html::rawElement( 'p',
				[],
				new OOUI\ButtonInputWidget(
					[
						'label' => $this->msg( 'abusefilter-examine-test-button' )->text(),
						'id' => 'mw-abusefilter-examine-test',
						'flags' => [ 'primary', 'progressive' ]
					]
				) .
				Html::element( 'div',
					[
						'id' => 'mw-abusefilter-syntaxresult',
						'style' => 'display: none;'
					]
				)
			);
		}

		// Variable dump
		$html .= Html::rawElement(
			'h2',
			[],
			$this->msg( 'abusefilter-examine-vars' )->parse()
		);
		$html .= $this->variablesFormatter->buildVarDumpTable( $vars );

		$output->addHTML( $html );
	}

}
PK       ! ?R      View/HideAbuseLog.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\View;

use LogEventsList;
use LogPage;
use ManualLogEntry;
use MediaWiki\Context\IContextSource;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\Pager\AbuseLogPager;
use MediaWiki\Html\Html;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\MediaWikiServices;
use Wikimedia\Rdbms\LBFactory;

class HideAbuseLog extends AbuseFilterView {

	/** @var LBFactory */
	private $lbFactory;

	/** @var int[] */
	private $hideIDs;

	/**
	 * @param LBFactory $lbFactory
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param IContextSource $context
	 * @param LinkRenderer $linkRenderer
	 * @param string $basePageName
	 */
	public function __construct(
		LBFactory $lbFactory,
		AbuseFilterPermissionManager $afPermManager,
		IContextSource $context,
		LinkRenderer $linkRenderer,
		string $basePageName
	) {
		parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, [] );
		$this->lbFactory = $lbFactory;
		$this->hideIDs = array_keys( $this->getRequest()->getArray( 'hideids', [] ) );
	}

	/**
	 * Shows the page
	 */
	public function show(): void {
		$output = $this->getOutput();
		$output->enableOOUI();

		if ( !$this->afPermManager->canHideAbuseLog( $this->getAuthority() ) ) {
			$output->addWikiMsg( 'abusefilter-log-hide-forbidden' );
			return;
		}

		if ( !$this->hideIDs ) {
			$output->addWikiMsg( 'abusefilter-log-hide-no-selected' );
			return;
		}

		// TODO DI
		$pager = new AbuseLogPager(
			$this->getContext(),
			MediaWikiServices::getInstance()->getLinkRenderer(),
			[ 'afl_id' => $this->hideIDs ],
			MediaWikiServices::getInstance()->getLinkBatchFactory(),
			MediaWikiServices::getInstance()->getPermissionManager(),
			$this->afPermManager,
			$this->basePageName,
			array_fill_keys( $this->hideIDs, $this->getRequest()->getVal( 'wpshoworhide' ) )
		);
		$pager->doQuery();
		if ( $pager->getResult()->numRows() === 0 ) {
			$output->addWikiMsg( 'abusefilter-log-hide-no-selected' );
			return;
		}

		$output->addModuleStyles( 'mediawiki.interface.helpers.styles' );
		$output->wrapWikiMsg(
			"<strong>$1</strong>",
			[
				'abusefilter-log-hide-selected',
				$this->getLanguage()->formatNum( count( $this->hideIDs ) )
			]
		);
		$output->addHTML( Html::rawElement( 'ul', [ 'class' => 'plainlinks' ], $pager->getBody() ) );

		$hideReasonsOther = $this->msg( 'revdelete-reasonotherlist' )->text();
		$hideReasons = $this->msg( 'revdelete-reason-dropdown-suppress' )->inContentLanguage()->text();
		$hideReasons = Html::listDropdownOptions( $hideReasons, [ 'other' => $hideReasonsOther ] );

		$formInfo = [
			'showorhide' => [
				'type' => 'radio',
				'label-message' => 'abusefilter-log-hide-set-visibility',
				'options-messages' => [
					'abusefilter-log-hide-show' => 'show',
					'abusefilter-log-hide-hide' => 'hide'
				],
				'default' => 'hide',
				'flatlist' => true
			],
			'dropdownreason' => [
				'type' => 'select',
				'options' => $hideReasons,
				'label-message' => 'abusefilter-log-hide-reason'
			],
			'reason' => [
				'type' => 'text',
				'label-message' => 'abusefilter-log-hide-reason-other',
			],
		];

		$actionURL = $this->getTitle( 'hide' )->getLocalURL( [ 'hideids' => array_fill_keys( $this->hideIDs, 1 ) ] );
		HTMLForm::factory( 'ooui', $formInfo, $this->getContext() )
			->setAction( $actionURL )
			->setWrapperLegend( $this->msg( 'abusefilter-log-hide-legend' )->text() )
			->setSubmitCallback( [ $this, 'saveHideForm' ] )
			->showAlways();

		// Show suppress log for this entry. Hack: since every suppression is performed on a
		// totally different page (i.e. Special:AbuseLog/xxx), we use showLogExtract without
		// specifying a title and then adding it in conds.
		// This isn't shown if the request was posted because we update visibility in a DeferredUpdate, so it would
		// display outdated info that might confuse the user.
		// TODO Can we improve this somehow?
		if ( !$this->getRequest()->wasPosted() ) {
			$suppressLogPage = new LogPage( 'suppress' );
			$output->addHTML( "<h2>" . $suppressLogPage->getName()->escaped() . "</h2>\n" );
			$searchTitles = [];
			foreach ( $this->hideIDs as $id ) {
				$searchTitles[] = $this->getTitle( (string)$id )->getDBkey();
			}
			$conds = [ 'log_namespace' => NS_SPECIAL, 'log_title' => $searchTitles ];
			LogEventsList::showLogExtract( $output, 'suppress', '', '', [ 'conds' => $conds ] );
		}
	}

	/**
	 * Process the hide form after submission. This performs the actual visibility update. Used as callback by HTMLForm
	 *
	 * @param array $fields
	 * @return bool|array True on success, array of error message keys otherwise
	 */
	public function saveHideForm( array $fields ) {
		// Determine which rows actually have to be changed
		$dbw = $this->lbFactory->getPrimaryDatabase();
		$newValue = $fields['showorhide'] === 'hide' ? 1 : 0;
		$actualIDs = $dbw->newSelectQueryBuilder()
			->select( 'afl_id' )
			->from( 'abuse_filter_log' )
			->where( [
				'afl_id' => $this->hideIDs,
				$dbw->expr( 'afl_deleted', '!=', $newValue ),
			] )
			->caller( __METHOD__ )
			->fetchFieldValues();
		if ( !count( $actualIDs ) ) {
			return [ 'abusefilter-log-hide-no-change' ];
		}

		$dbw->newUpdateQueryBuilder()
			->update( 'abuse_filter_log' )
			->set( [ 'afl_deleted' => $newValue ] )
			->where( [ 'afl_id' => $actualIDs ] )
			->caller( __METHOD__ )
			->execute();

		// Log in a DeferredUpdates to avoid potential flood
		DeferredUpdates::addCallableUpdate( function () use ( $fields, $actualIDs ) {
			$reason = $fields['dropdownreason'];
			if ( $reason === 'other' ) {
				$reason = $fields['reason'];
			} elseif ( $fields['reason'] !== '' ) {
				$reason .=
					$this->msg( 'colon-separator' )->inContentLanguage()->text() . $fields['reason'];
			}

			$action = $fields['showorhide'] === 'hide' ? 'hide-afl' : 'unhide-afl';
			foreach ( $actualIDs as $logid ) {
				$logEntry = new ManualLogEntry( 'suppress', $action );
				$logEntry->setPerformer( $this->getUser() );
				$logEntry->setTarget( $this->getTitle( $logid ) );
				$logEntry->setComment( $reason );
				$logEntry->insert();
			}
		} );

		$count = count( $actualIDs );
		$this->getOutput()->prependHTML(
			Html::successBox(
				$this->msg( 'abusefilter-log-hide-done' )->params(
					$this->getLanguage()->formatNum( $count ),
					// Messages used: abusefilter-log-hide-done-hide, abusefilter-log-hide-done-show
					$this->msg( 'abusefilter-log-hide-done-' . $fields['showorhide'] )->numParams( $count )->text()
				)->escaped()
			)
		);

		return true;
	}

}
PK       ! %      View/AbuseFilterView.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\View;

use Flow\Data\Listener\RecentChangesListener;
use MediaWiki\Context\ContextSource;
use MediaWiki\Context\IContextSource;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Html\Html;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
use OOUI;
use RecentChange;
use UnexpectedValueException;
use Wikimedia\Assert\Assert;
use Wikimedia\Rdbms\IExpression;
use Wikimedia\Rdbms\IReadableDatabase;
use Wikimedia\Rdbms\Platform\ISQLPlatform;

abstract class AbuseFilterView extends ContextSource {

	private const MAP_ACTION_TO_LOG_TYPE = [
		// action => [ rc_log_type, rc_log_action ]
		'move' => [ 'move', [ 'move', 'move_redir' ] ],
		'createaccount' => [ 'newusers', [ 'create', 'create2', 'byemail', 'autocreate' ] ],
		'delete' => [ 'delete', 'delete' ],
		'upload' => [ 'upload', [ 'upload', 'overwrite', 'revert' ] ],
	];

	protected AbuseFilterPermissionManager $afPermManager;

	/**
	 * @var array The parameters of the current request
	 */
	protected array $mParams;

	protected LinkRenderer $linkRenderer;

	protected string $basePageName;

	/**
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param IContextSource $context
	 * @param LinkRenderer $linkRenderer
	 * @param string $basePageName
	 * @param array $params
	 */
	public function __construct(
		AbuseFilterPermissionManager $afPermManager,
		IContextSource $context,
		LinkRenderer $linkRenderer,
		string $basePageName,
		array $params
	) {
		$this->mParams = $params;
		$this->setContext( $context );
		$this->linkRenderer = $linkRenderer;
		$this->basePageName = $basePageName;
		$this->afPermManager = $afPermManager;
	}

	/**
	 * @param string|int $subpage
	 * @return Title
	 */
	public function getTitle( $subpage = '' ) {
		return SpecialPage::getTitleFor( $this->basePageName, $subpage );
	}

	/**
	 * Function to show the page
	 */
	abstract public function show();

	/**
	 * Build input and button for loading a filter
	 *
	 * @return string
	 */
	public function buildFilterLoader() {
		$loadText =
			new OOUI\TextInputWidget(
				[
					'type' => 'number',
					'name' => 'wpInsertFilter',
					'id' => 'mw-abusefilter-load-filter'
				]
			);
		$loadButton =
			new OOUI\ButtonWidget(
				[
					'label' => $this->msg( 'abusefilter-test-load' )->text(),
					'id' => 'mw-abusefilter-load'
				]
			);
		$loadGroup =
			new OOUI\ActionFieldLayout(
				$loadText,
				$loadButton,
				[
					'label' => $this->msg( 'abusefilter-test-load-filter' )->text()
				]
			);
		// CSS class for reducing default input field width
		return Html::rawElement(
			'div',
			[ 'class' => 'mw-abusefilter-load-filter-id' ],
			$loadGroup
		);
	}

	/**
	 * @param IReadableDatabase $db
	 * @param string|false $action 'edit', 'move', 'createaccount', 'delete' or false for all
	 * @return IExpression
	 */
	public function buildTestConditions( IReadableDatabase $db, $action = false ) {
		Assert::parameterType( [ 'string', 'false' ], $action, '$action' );
		$editSources = [
			RecentChange::SRC_EDIT,
			RecentChange::SRC_NEW,
		];
		if ( in_array( 'flow', $this->getConfig()->get( 'AbuseFilterValidGroups' ), true ) ) {
			// TODO Should this be separated somehow? Also, this case should be handled via a hook, not
			// by special-casing Flow here.
			// @phan-suppress-next-line PhanUndeclaredClassConstant Temporary solution
			$editSources[] = RecentChangesListener::SRC_FLOW;
		}
		if ( $action === 'edit' ) {
			return $db->expr( 'rc_source', '=', $editSources );
		}
		if ( $action !== false ) {
			if ( !isset( self::MAP_ACTION_TO_LOG_TYPE[$action] ) ) {
				throw new UnexpectedValueException( __METHOD__ . ' called with invalid action: ' . $action );
			}
			[ $logType, $logAction ] = self::MAP_ACTION_TO_LOG_TYPE[$action];
			return $db->expr( 'rc_source', '=', RecentChange::SRC_LOG )
				->and( 'rc_log_type', '=', $logType )
				->and( 'rc_log_action', '=', $logAction );
		}

		// filter edit and log actions
		$conds = [];
		foreach ( self::MAP_ACTION_TO_LOG_TYPE as [ $logType, $logAction ] ) {
			$conds[] = $db->expr( 'rc_log_type', '=', $logType )
				->and( 'rc_log_action', '=', $logAction );
		}

		return $db->expr( 'rc_source', '=', $editSources )
			->orExpr(
				$db->expr( 'rc_source', '=', RecentChange::SRC_LOG )
					->andExpr( $db->orExpr( $conds ) )
			);
	}

	/**
	 * @todo Core should provide a method for this (T233222)
	 * @param ISQLPlatform $db
	 * @param Authority $authority
	 * @return array
	 */
	public function buildVisibilityConditions( ISQLPlatform $db, Authority $authority ): array {
		if ( !$authority->isAllowed( 'deletedhistory' ) ) {
			$bitmask = RevisionRecord::DELETED_USER;
		} elseif ( !$authority->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
			$bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED;
		} else {
			$bitmask = 0;
		}
		return $bitmask
			? [ $db->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask" ]
			: [];
	}

	/**
	 * @param string|int $id
	 * @param string|null $text
	 * @return string HTML
	 */
	public function getLinkToLatestDiff( $id, $text = null ) {
		return $this->linkRenderer->makeKnownLink(
			$this->getTitle( "history/$id/diff/prev/cur" ),
			$text
		);
	}

}
PK       ! O      View/AbuseFilterViewEdit.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\View;

use HtmlArmor;
use LogicException;
use MediaWiki\Context\IContextSource;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxBuilderFactory;
use MediaWiki\Extension\AbuseFilter\Filter\Filter;
use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException;
use MediaWiki\Extension\AbuseFilter\Filter\FilterVersionNotFoundException;
use MediaWiki\Extension\AbuseFilter\Filter\MutableFilter;
use MediaWiki\Extension\AbuseFilter\FilterImporter;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use MediaWiki\Extension\AbuseFilter\FilterProfiler;
use MediaWiki\Extension\AbuseFilter\FilterStore;
use MediaWiki\Extension\AbuseFilter\InvalidImportDataException;
use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
use MediaWiki\Html\Html;
use MediaWiki\Linker\Linker;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Xml\Xml;
use OOUI;
use UnexpectedValueException;
use Wikimedia\Rdbms\IDBAccessObject;
use Wikimedia\Rdbms\IExpression;
use Wikimedia\Rdbms\LBFactory;
use Wikimedia\Rdbms\LikeValue;
use Wikimedia\Rdbms\SelectQueryBuilder;

class AbuseFilterViewEdit extends AbuseFilterView {
	/**
	 * @var int|null The history ID of the current filter
	 */
	private $historyID;
	/** @var int|string */
	private $filter;

	/** @var LBFactory */
	private $lbFactory;

	/** @var PermissionManager */
	private $permissionManager;

	/** @var FilterProfiler */
	private $filterProfiler;

	/** @var FilterLookup */
	private $filterLookup;

	/** @var FilterImporter */
	private $filterImporter;

	/** @var FilterStore */
	private $filterStore;

	/** @var EditBoxBuilderFactory */
	private $boxBuilderFactory;

	/** @var ConsequencesRegistry */
	private $consequencesRegistry;

	/** @var SpecsFormatter */
	private $specsFormatter;

	/**
	 * @param LBFactory $lbFactory
	 * @param PermissionManager $permissionManager
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param FilterProfiler $filterProfiler
	 * @param FilterLookup $filterLookup
	 * @param FilterImporter $filterImporter
	 * @param FilterStore $filterStore
	 * @param EditBoxBuilderFactory $boxBuilderFactory
	 * @param ConsequencesRegistry $consequencesRegistry
	 * @param SpecsFormatter $specsFormatter
	 * @param IContextSource $context
	 * @param LinkRenderer $linkRenderer
	 * @param string $basePageName
	 * @param array $params
	 */
	public function __construct(
		LBFactory $lbFactory,
		PermissionManager $permissionManager,
		AbuseFilterPermissionManager $afPermManager,
		FilterProfiler $filterProfiler,
		FilterLookup $filterLookup,
		FilterImporter $filterImporter,
		FilterStore $filterStore,
		EditBoxBuilderFactory $boxBuilderFactory,
		ConsequencesRegistry $consequencesRegistry,
		SpecsFormatter $specsFormatter,
		IContextSource $context,
		LinkRenderer $linkRenderer,
		string $basePageName,
		array $params
	) {
		parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
		$this->lbFactory = $lbFactory;
		$this->permissionManager = $permissionManager;
		$this->filterProfiler = $filterProfiler;
		$this->filterLookup = $filterLookup;
		$this->filterImporter = $filterImporter;
		$this->filterStore = $filterStore;
		$this->boxBuilderFactory = $boxBuilderFactory;
		$this->consequencesRegistry = $consequencesRegistry;
		$this->specsFormatter = $specsFormatter;
		$this->specsFormatter->setMessageLocalizer( $this->getContext() );
		$this->filter = $this->mParams['filter'];
		$this->historyID = $this->mParams['history'] ?? null;
	}

	/**
	 * Shows the page
	 */
	public function show() {
		$out = $this->getOutput();
		$out->enableOOUI();
		$request = $this->getRequest();
		$out->setPageTitleMsg( $this->msg( 'abusefilter-edit' ) );
		$out->addHelpLink( 'Extension:AbuseFilter/Rules format' );

		if ( !is_numeric( $this->filter ) && $this->filter !== null ) {
			$this->showUnrecoverableError( 'abusefilter-edit-badfilter' );
			return;
		}
		$filter = $this->filter ? (int)$this->filter : null;
		$history_id = $this->historyID;
		if ( $this->historyID ) {
			$dbr = $this->lbFactory->getReplicaDatabase();
			$lastID = (int)$dbr->newSelectQueryBuilder()
				->select( 'afh_id' )
				->from( 'abuse_filter_history' )
				->where( [
					'afh_filter' => $filter,
				] )
				->orderBy( 'afh_id', SelectQueryBuilder::SORT_DESC )
				->caller( __METHOD__ )
				->fetchField();
			// change $history_id to null if it's current version id
			if ( $lastID === $this->historyID ) {
				$history_id = null;
			}
		}

		// Add the default warning and disallow messages in a JS variable
		$this->exposeMessages();

		$canEdit = $this->afPermManager->canEdit( $this->getAuthority() );

		if ( $filter === null && !$canEdit ) {
			// Special case: Special:AbuseFilter/new is certainly not usable if the user cannot edit
			$this->showUnrecoverableError( 'abusefilter-edit-notallowed' );
			return;
		}

		$isImport = $request->wasPosted() && $request->getRawVal( 'wpImportText' ) !== null;

		if ( !$isImport && $request->wasPosted() && $canEdit ) {
			$this->attemptSave( $filter, $history_id );
			return;
		}

		if ( $isImport ) {
			$filterObj = $this->loadImportRequest();
			if ( $filterObj === null ) {
				$this->showUnrecoverableError( 'abusefilter-import-invalid-data' );
				return;
			}
		} else {
			// The request wasn't posted (i.e. just viewing the filter) or the user cannot edit
			try {
				$filterObj = $this->loadFromDatabase( $filter, $history_id );
			} catch ( FilterNotFoundException $_ ) {
				$filterObj = null;
			}
			if ( $filterObj === null || ( $history_id && (int)$filterObj->getID() !== $filter ) ) {
				$this->showUnrecoverableError( 'abusefilter-edit-badfilter' );
				return;
			}
		}

		$this->buildFilterEditor( null, $filterObj, $filter, $history_id );
	}

	/**
	 * @param int|null $filter The filter ID or null for a new filter
	 * @param int|null $history_id The history ID of the filter, if applicable. Otherwise null
	 */
	private function attemptSave( ?int $filter, $history_id ): void {
		$out = $this->getOutput();
		$request = $this->getRequest();
		$user = $this->getUser();

		[ $newFilter, $origFilter ] = $this->loadRequest( $filter );

		$tokenFilter = $filter === null ? 'new' : (string)$filter;
		$editToken = $request->getVal( 'wpEditToken' );
		$tokenMatches = $this->getCsrfTokenSet()->matchToken( $editToken, [ 'abusefilter', $tokenFilter ] );

		if ( !$tokenMatches ) {
			// Token invalid or expired while the page was open, warn to retry
			$error = Html::warningBox( $this->msg( 'abusefilter-edit-token-not-match' )->parseAsBlock() );
			$this->buildFilterEditor( $error, $newFilter, $filter, $history_id );
			return;
		}

		$status = $this->filterStore->saveFilter( $user, $filter, $newFilter, $origFilter );

		if ( !$status->isGood() ) {
			$msg = $status->getMessages()[0];
			if ( $status->isOK() ) {
				// Fixable error, show the editing interface
				$error = Html::errorBox( $this->msg( $msg )->parseAsBlock() );
				$this->buildFilterEditor( $error, $newFilter, $filter, $history_id );
			} else {
				$this->showUnrecoverableError( $msg );
			}
		} elseif ( $status->getValue() === false ) {
			// No change
			$out->redirect( $this->getTitle()->getLocalURL() );
		} else {
			// Everything went fine!
			[ $new_id, $history_id ] = $status->getValue();
			$out->redirect(
				$this->getTitle()->getLocalURL(
					[
						'result' => 'success',
						'changedfilter' => $new_id,
						'changeid' => $history_id,
					]
				)
			);
		}
	}

	/**
	 * @param string|\Wikimedia\Message\MessageSpecifier $msg
	 */
	private function showUnrecoverableError( $msg ): void {
		$out = $this->getOutput();

		$out->addHTML( Html::errorBox( $this->msg( $msg )->parseAsBlock() ) );
		$href = $this->getTitle()->getFullURL();
		$btn = new OOUI\ButtonWidget( [
			'label' => $this->msg( 'abusefilter-return' )->text(),
			'href' => $href
		] );
		$out->addHTML( $btn );
	}

	/**
	 * Builds the full form for edit filters, adding it to the OutputPage. This method can be called in 5 different
	 * situations, for a total of 5 different data sources for $filterObj and $actions:
	 *  1 - View the result of importing a filter
	 *  2 - Create a new filter
	 *  3 - Load the current version of an existing filter
	 *  4 - Load an old version of an existing filter
	 *  5 - Show the user input again if saving fails after one of the steps above
	 *
	 * @param string|null $error An error message to show above the filter box (HTML).
	 * @param Filter $filterObj
	 * @param int|null $filter The filter ID, or null for a new filter
	 * @param int|null $history_id The history ID of the filter, if applicable. Otherwise null
	 */
	private function buildFilterEditor(
		$error,
		Filter $filterObj,
		?int $filter,
		$history_id
	) {
		$out = $this->getOutput();
		$out->addJsConfigVars( 'isFilterEditor', true );
		$lang = $this->getLanguage();
		$user = $this->getUser();
		$actions = $filterObj->getActions();

		$isCreatingNewFilter = $filter === null;
		$out->addSubtitle( $this->msg(
			$isCreatingNewFilter ? 'abusefilter-edit-subtitle-new' : 'abusefilter-edit-subtitle',
			$isCreatingNewFilter ? $filter : $this->getLanguage()->formatNum( $filter ),
			$history_id
		)->parse() );

		// Grab the current hidden flag from the DB, in case we're editing an older, public revision of a filter that is
		// currently hidden, so that we can also hide that public revision.
		if (
			( $filterObj->isHidden() || (
				$filter !== null && $this->filterLookup->getFilter( $filter, false )->isHidden() )
			) && !$this->afPermManager->canViewPrivateFilters( $user )
		) {
			$out->addHTML( $this->msg( 'abusefilter-edit-denied' )->escaped() );
			return;
		}

		// Filters that use protected variables should always be hidden from public view
		if (
			(
				$filterObj->isProtected() ||
				( $filter !== null && $this->filterLookup->getFilter( $filter, false )->isProtected() )
			) &&
			!$this->afPermManager->canViewProtectedVariables( $user )
		) {
			$out->addHTML( $this->msg( 'abusefilter-edit-denied-protected-vars' )->escaped() );
			return;
		}

		if ( $isCreatingNewFilter ) {
			$title = $this->msg( 'abusefilter-add' );
		} elseif ( $this->afPermManager->canEditFilter( $user, $filterObj ) ) {
			$title = $this->msg( 'abusefilter-edit-specific' )
				->numParams( $this->filter )
				->params( $filterObj->getName() );
		} else {
			$title = $this->msg( 'abusefilter-view-specific' )
				->numParams( $this->filter )
				->params( $filterObj->getName() );
		}
		$out->setPageTitleMsg( $title );

		$readOnly = !$this->afPermManager->canEditFilter( $user, $filterObj );

		if ( $history_id ) {
			$oldWarningMessage = $readOnly
				? 'abusefilter-edit-oldwarning-view'
				: 'abusefilter-edit-oldwarning';
			$out->addWikiMsg( $oldWarningMessage, $history_id, $filter );
		}

		if ( $error !== null ) {
			$out->addHTML( $error );
		}

		$fields = [];

		$fields['abusefilter-edit-id'] =
			$isCreatingNewFilter ?
				$this->msg( 'abusefilter-edit-new' )->escaped() :
				htmlspecialchars( $lang->formatNum( (string)$filter ) );
		$fields['abusefilter-edit-description'] =
			new OOUI\TextInputWidget( [
				'name' => 'wpFilterDescription',
				'id' => 'mw-abusefilter-edit-description-input',
				'value' => $filterObj->getName(),
				'readOnly' => $readOnly
				]
			);

		$validGroups = $this->getConfig()->get( 'AbuseFilterValidGroups' );
		if ( count( $validGroups ) > 1 ) {
			$groupSelector =
				new OOUI\DropdownInputWidget( [
					'name' => 'wpFilterGroup',
					'id' => 'mw-abusefilter-edit-group-input',
					'value' => $filterObj->getGroup(),
					'disabled' => $readOnly
				] );

			$options = [];
			foreach ( $validGroups as $group ) {
				$options += [ $this->specsFormatter->nameGroup( $group ) => $group ];
			}

			$options = Html::listDropdownOptionsOoui( $options );
			$groupSelector->setOptions( $options );

			$fields['abusefilter-edit-group'] = $groupSelector;
		}

		// Hit count display
		if ( $filterObj->getHitCount() !== null && $this->afPermManager->canSeeLogDetails( $user ) ) {
			$count_display = $this->msg( 'abusefilter-hitcount' )
				->numParams( $filterObj->getHitCount() )->text();
			$hitCount = $this->linkRenderer->makeKnownLink(
				SpecialPage::getTitleFor( 'AbuseLog' ),
				$count_display,
				[],
				[ 'wpSearchFilter' => $filterObj->getID() ]
			);

			$fields['abusefilter-edit-hitcount'] = $hitCount;
		}

		if ( $filter !== null && $filterObj->isEnabled() ) {
			// Statistics
			[
				'count' => $totalCount,
				'matches' => $matchesCount,
				'total-time' => $curTotalTime,
				'total-cond' => $curTotalConds,
			] = $this->filterProfiler->getFilterProfile( $filter );

			if ( $totalCount > 0 ) {
				$matchesPercent = round( 100 * $matchesCount / $totalCount, 2 );
				$avgTime = round( $curTotalTime / $totalCount, 2 );
				$avgCond = round( $curTotalConds / $totalCount, 1 );

				$fields['abusefilter-edit-status-label'] = $this->msg( 'abusefilter-edit-status' )
					->numParams( $totalCount, $matchesCount, $matchesPercent, $avgTime, $avgCond )
					->parse();
			}
		}

		$boxBuilder = $this->boxBuilderFactory->newEditBoxBuilder( $this, $user, $out );

		$fields['abusefilter-edit-rules'] = $boxBuilder->buildEditBox(
			$filterObj->getRules(),
			true
		);
		$fields['abusefilter-edit-notes'] =
			new OOUI\MultilineTextInputWidget( [
				'name' => 'wpFilterNotes',
				'value' => $filterObj->getComments() . "\n",
				'rows' => 15,
				'readOnly' => $readOnly,
				'id' => 'mw-abusefilter-notes-editor'
			] );

		// Build checkboxes
		$checkboxes = [ 'hidden', 'enabled', 'protected', 'deleted' ];
		$flags = '';

		if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
			$checkboxes[] = 'global';
		}

		if ( $filterObj->isThrottled() ) {
			$throttledActionNames = array_intersect(
				$filterObj->getActionsNames(),
				$this->consequencesRegistry->getDangerousActionNames()
			);

			if ( $throttledActionNames ) {
				$throttledActionsLocalized = [];
				foreach ( $throttledActionNames as $actionName ) {
					$throttledActionsLocalized[] = $this->specsFormatter->getActionMessage( $actionName )->text();
				}

				$throttledMsg = $this->msg( 'abusefilter-edit-throttled-warning' )
					->plaintextParams( $lang->commaList( $throttledActionsLocalized ) )
					->params( count( $throttledActionsLocalized ) )
					->parseAsBlock();
			} else {
				$throttledMsg = $this->msg( 'abusefilter-edit-throttled-warning-no-actions' )
					->parseAsBlock();
			}
			$flags .= Html::warningBox( $throttledMsg );
		}

		foreach ( $checkboxes as $checkboxId ) {
			// Messages that can be used here:
			// * abusefilter-edit-enabled
			// * abusefilter-edit-deleted
			// * abusefilter-edit-hidden
			// * abusefilter-edit-protected
			// * abusefilter-edit-global
			$message = "abusefilter-edit-$checkboxId";
			// isEnabled(), isDeleted(), isHidden(), isProtected(), isGlobal()
			$method = 'is' . ucfirst( $checkboxId );
			$postVar = 'wpFilter' . ucfirst( $checkboxId );

			$checkboxAttribs = [
				'name' => $postVar,
				'id' => $postVar,
				'selected' => $filterObj->$method(),
				'disabled' => $readOnly
			];
			$labelAttribs = [
				'label' => $this->msg( $message )->text(),
				'align' => 'inline',
			];

			if ( $checkboxId === 'global' && !$this->afPermManager->canEditGlobal( $user ) ) {
				$checkboxAttribs['disabled'] = 'disabled';
			}

			if ( $checkboxId == 'protected' ) {
				if ( !$this->afPermManager->canViewProtectedVariables( $user ) ) {
					$checkboxAttribs['classes'] = [ 'oo-ui-element-hidden' ];
					$labelAttribs['classes'] = [ 'oo-ui-element-hidden' ];
				} elseif ( $filterObj->isProtected() ) {
					$checkboxAttribs['disabled'] = true;
					$labelAttribs['label'] = $this->msg(
						'abusefilter-edit-protected-variable-already-protected'
					)->text();
				} else {
					$labelAttribs['label'] = new OOUI\HtmlSnippet(
						$this->msg( $message )->parse()
					);
					$labelAttribs['help'] = $this->msg( 'abusefilter-edit-protected-help-message' )->text();
					$labelAttribs['helpInline'] = true;
				}
			}

			// Set readonly on deleted if the filter isn't disabled
			if ( $checkboxId === 'deleted' && $filterObj->isEnabled() ) {
				$checkboxAttribs['disabled'] = 'disabled';
			}

			// Add infusable where needed
			if ( $checkboxId === 'deleted' || $checkboxId === 'enabled' ) {
				$checkboxAttribs['infusable'] = true;
				if ( $checkboxId === 'deleted' ) {
					$labelAttribs['id'] = $postVar . 'Label';
					$labelAttribs['infusable'] = true;
				}
			}

			$checkbox =
				new OOUI\FieldLayout(
					new OOUI\CheckboxInputWidget( $checkboxAttribs ),
					$labelAttribs
				);
			$flags .= $checkbox;
		}

		$fields['abusefilter-edit-flags'] = $flags;

		if ( $filter !== null ) {
			$tools = '';
			if ( $this->afPermManager->canRevertFilterActions( $user ) ) {
				$tools .= Html::rawElement(
					'p', [],
					$this->linkRenderer->makeLink(
						$this->getTitle( "revert/$filter" ),
						new HtmlArmor( $this->msg( 'abusefilter-edit-revert' )->parse() )
					)
				);
			}

			if ( $this->afPermManager->canUseTestTools( $user ) ) {
				// Test link
				$tools .= Html::rawElement(
					'p', [],
					$this->linkRenderer->makeLink(
						$this->getTitle( "test/$filter" ),
						new HtmlArmor( $this->msg( 'abusefilter-edit-test-link' )->parse() )
					)
				);
			}
			// Last modification details
			$userLink =
				Linker::userLink( $filterObj->getUserID(), $filterObj->getUserName() ) .
				Linker::userToolLinks( $filterObj->getUserID(), $filterObj->getUserName() );
			$fields['abusefilter-edit-lastmod'] =
				$this->msg( 'abusefilter-edit-lastmod-text' )
				->rawParams(
					$this->getLinkToLatestDiff(
						$filter,
						$lang->userTimeAndDate( $filterObj->getTimestamp(), $user )
					),
					$userLink,
					$this->getLinkToLatestDiff(
						$filter,
						$lang->userDate( $filterObj->getTimestamp(), $user )
					),
					$this->getLinkToLatestDiff(
						$filter,
						$lang->userTime( $filterObj->getTimestamp(), $user )
					)
				)->params(
					wfEscapeWikiText( $filterObj->getUserName() )
				)->parse();
			$history_display = new HtmlArmor( $this->msg( 'abusefilter-edit-viewhistory' )->parse() );
			$fields['abusefilter-edit-history'] =
				$this->linkRenderer->makeKnownLink( $this->getTitle( 'history/' . $filter ), $history_display );

			$exportText = $this->filterImporter->encodeData( $filterObj, $actions );
			$tools .= Html::rawElement( 'a', [ 'href' => '#', 'id' => 'mw-abusefilter-export-link' ],
				$this->msg( 'abusefilter-edit-export' )->parse() );
			$tools .=
				new OOUI\MultilineTextInputWidget( [
					'id' => 'mw-abusefilter-export',
					'readOnly' => true,
					'value' => $exportText,
					'rows' => 10
				] );

			$fields['abusefilter-edit-tools'] = $tools;
		}

		$form = Xml::fieldset(
			$this->msg( 'abusefilter-edit-main' )->text(),
			// TODO: deprecated, use OOUI or Codex widgets instead
			Xml::buildForm( $fields )
		);
		$form .= Xml::fieldset(
			$this->msg( 'abusefilter-edit-consequences' )->text(),
			$this->buildConsequenceEditor( $filterObj, $actions )
		);

		$urlFilter = $filter === null ? 'new' : (string)$filter;
		if ( !$readOnly ) {
			$form .=
				new OOUI\ButtonInputWidget( [
					'type' => 'submit',
					'label' => $this->msg( 'abusefilter-edit-save' )->text(),
					'useInputTag' => true,
					'accesskey' => 's',
					'flags' => [ 'progressive', 'primary' ]
				] );
			$form .= Html::hidden(
				'wpEditToken',
				$this->getCsrfTokenSet()->getToken( [ 'abusefilter', $urlFilter ] )->toString()
			);
		}

		$form = Html::rawElement( 'form',
			[
				'action' => $this->getTitle( $urlFilter )->getFullURL(),
				'method' => 'post',
				'id' => 'mw-abusefilter-editing-form'
			],
			$form
		);

		$out->addHTML( $form );

		if ( $history_id ) {
			// @phan-suppress-next-line PhanPossiblyUndeclaredVariable,PhanTypeMismatchArgumentNullable
			$out->addWikiMsg( $oldWarningMessage, $history_id, $filter );
		}
	}

	/**
	 * Builds the "actions" editor for a given filter.
	 * @param Filter $filterObj
	 * @param array[] $actions Array of rows from the abuse_filter_action table
	 *  corresponding to the filter object
	 * @return string HTML text for an action editor.
	 */
	private function buildConsequenceEditor( Filter $filterObj, array $actions ) {
		$enabledActions = $this->consequencesRegistry->getAllEnabledActionNames();

		$setActions = [];
		foreach ( $enabledActions as $action ) {
			$setActions[$action] = array_key_exists( $action, $actions );
		}

		$output = '';

		foreach ( $enabledActions as $action ) {
			$params = $actions[$action] ?? null;
			$output .= $this->buildConsequenceSelector(
				$action, $setActions[$action], $filterObj, $params );
		}

		return $output;
	}

	/**
	 * @param string $action The action to build an editor for
	 * @param bool $set Whether or not the action is activated
	 * @param Filter $filterObj
	 * @param string[]|null $parameters Action parameters. Null iff $set is false.
	 * @return string|\OOUI\FieldLayout
	 */
	private function buildConsequenceSelector( $action, $set, $filterObj, ?array $parameters ) {
		$config = $this->getConfig();
		$user = $this->getUser();
		$actions = $this->consequencesRegistry->getAllEnabledActionNames();
		if ( !in_array( $action, $actions, true ) ) {
			return '';
		}

		$readOnly = !$this->afPermManager->canEditFilter( $user, $filterObj );

		switch ( $action ) {
			case 'throttle':
				// Throttling is only available via object caching
				if ( $config->get( MainConfigNames::MainCacheType ) === CACHE_NONE ) {
					return '';
				}
				$throttleSettings =
					new OOUI\FieldLayout(
						new OOUI\CheckboxInputWidget( [
							'name' => 'wpFilterActionThrottle',
							'id' => 'mw-abusefilter-action-checkbox-throttle',
							'selected' => $set,
							'classes' => [ 'mw-abusefilter-action-checkbox' ],
							'disabled' => $readOnly
						]
						),
						[
							'label' => $this->msg( 'abusefilter-edit-action-throttle' )->text(),
							'align' => 'inline'
						]
					);
				$throttleFields = [];

				if ( $set ) {
					// @phan-suppress-next-line PhanTypeArraySuspiciousNullable $parameters is array here
					[ $throttleCount, $throttlePeriod ] = explode( ',', $parameters[1], 2 );

					$throttleGroups = array_slice( $parameters, 2 );
				} else {
					$throttleCount = 3;
					$throttlePeriod = 60;

					$throttleGroups = [ 'user' ];
				}

				$throttleFields[] =
					new OOUI\FieldLayout(
						new OOUI\TextInputWidget( [
							'type' => 'number',
							'name' => 'wpFilterThrottleCount',
							'value' => $throttleCount,
							'readOnly' => $readOnly
							]
						),
						[
							'label' => $this->msg( 'abusefilter-edit-throttle-count' )->text()
						]
					);
				$throttleFields[] =
					new OOUI\FieldLayout(
						new OOUI\TextInputWidget( [
							'type' => 'number',
							'name' => 'wpFilterThrottlePeriod',
							'value' => $throttlePeriod,
							'readOnly' => $readOnly
							]
						),
						[
							'label' => $this->msg( 'abusefilter-edit-throttle-period' )->text()
						]
					);

				$groupsHelpLink = Html::element(
					'a',
					[
						'href' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/' .
							'Extension:AbuseFilter/Actions#Throttling',
						'target' => '_blank'
					],
					$this->msg( 'abusefilter-edit-throttle-groups-help-text' )->text()
				);
				$groupsHelp = $this->msg( 'abusefilter-edit-throttle-groups-help' )
						->rawParams( $groupsHelpLink )->escaped();
				$hiddenGroups =
					new OOUI\FieldLayout(
						new OOUI\MultilineTextInputWidget( [
							'name' => 'wpFilterThrottleGroups',
							'value' => implode( "\n", $throttleGroups ),
							'rows' => 5,
							'placeholder' => $this->msg( 'abusefilter-edit-throttle-hidden-placeholder' )->text(),
							'infusable' => true,
							'id' => 'mw-abusefilter-hidden-throttle-field',
							'readOnly' => $readOnly
						]
						),
						[
							'label' => new OOUI\HtmlSnippet(
								$this->msg( 'abusefilter-edit-throttle-groups' )->parse()
							),
							'align' => 'top',
							'id' => 'mw-abusefilter-hidden-throttle',
							'help' => new OOUI\HtmlSnippet( $groupsHelp ),
							'helpInline' => true
						]
					);

				$throttleFields[] = $hiddenGroups;

				$throttleConfig = [
					'values' => $throttleGroups,
					'label' => $this->msg( 'abusefilter-edit-throttle-groups' )->parse(),
					'disabled' => $readOnly,
					'help' => $groupsHelp
				];
				$this->getOutput()->addJsConfigVars( 'throttleConfig', $throttleConfig );

				$throttleSettings .=
					Html::rawElement(
						'div',
						[ 'id' => 'mw-abusefilter-throttle-parameters' ],
						new OOUI\FieldsetLayout( [ 'items' => $throttleFields ] )
					);
				return $throttleSettings;
			case 'disallow':
			case 'warn':
				$output = '';
				$formName = $action === 'warn' ? 'wpFilterActionWarn' : 'wpFilterActionDisallow';
				$checkbox =
					new OOUI\FieldLayout(
						new OOUI\CheckboxInputWidget( [
							'name' => $formName,
							// mw-abusefilter-action-checkbox-warn, mw-abusefilter-action-checkbox-disallow
							'id' => "mw-abusefilter-action-checkbox-$action",
							'selected' => $set,
							'classes' => [ 'mw-abusefilter-action-checkbox' ],
							'disabled' => $readOnly
						]
						),
						[
							// abusefilter-edit-action-warn, abusefilter-edit-action-disallow
							'label' => $this->msg( "abusefilter-edit-action-$action" )->text(),
							'align' => 'inline'
						]
					);
				$output .= $checkbox;
				$defaultWarnMsg = $config->get( 'AbuseFilterDefaultWarningMessage' );
				$defaultDisallowMsg = $config->get( 'AbuseFilterDefaultDisallowMessage' );

				if ( $set && isset( $parameters[0] ) ) {
					$msg = $parameters[0];
				} elseif (
					( $action === 'warn' && isset( $defaultWarnMsg[$filterObj->getGroup()] ) ) ||
					( $action === 'disallow' && isset( $defaultDisallowMsg[$filterObj->getGroup()] ) )
				) {
					$msg = $action === 'warn' ? $defaultWarnMsg[$filterObj->getGroup()] :
						$defaultDisallowMsg[$filterObj->getGroup()];
				} else {
					$msg = $action === 'warn' ? 'abusefilter-warning' : 'abusefilter-disallowed';
				}

				$fields = [];
				$fields[] =
					$this->getExistingSelector( $msg, $readOnly, $action );
				$otherFieldName = $action === 'warn' ? 'wpFilterWarnMessageOther'
					: 'wpFilterDisallowMessageOther';

				$fields[] =
					new OOUI\FieldLayout(
						new OOUI\TextInputWidget( [
							'name' => $otherFieldName,
							'value' => $msg,
							// mw-abusefilter-warn-message-other, mw-abusefilter-disallow-message-other
							'id' => "mw-abusefilter-$action-message-other",
							'infusable' => true,
							'readOnly' => $readOnly
							]
						),
						[
							'label' => new OOUI\HtmlSnippet(
								// abusefilter-edit-warn-other-label, abusefilter-edit-disallow-other-label
								$this->msg( "abusefilter-edit-$action-other-label" )->parse()
							)
						]
					);

				$previewButton =
					new OOUI\ButtonInputWidget( [
						// abusefilter-edit-warn-preview, abusefilter-edit-disallow-preview
						'label' => $this->msg( "abusefilter-edit-$action-preview" )->text(),
						// mw-abusefilter-warn-preview-button, mw-abusefilter-disallow-preview-button
						'id' => "mw-abusefilter-$action-preview-button",
						'infusable' => true,
						'flags' => 'progressive'
						]
					);

				$buttonGroup = $previewButton;
				if ( $this->permissionManager->userHasRight( $user, 'editinterface' ) ) {
					$editButton =
						new OOUI\ButtonInputWidget( [
							// abusefilter-edit-warn-edit, abusefilter-edit-disallow-edit
							'label' => $this->msg( "abusefilter-edit-$action-edit" )->text(),
							// mw-abusefilter-warn-edit-button, mw-abusefilter-disallow-edit-button
							'id' => "mw-abusefilter-$action-edit-button"
							]
						);
					$buttonGroup =
						new OOUI\Widget( [
							'content' =>
								new OOUI\HorizontalLayout( [
									'items' => [ $previewButton, $editButton ],
									'classes' => [
										'mw-abusefilter-preview-buttons',
										'mw-abusefilter-javascript-tools'
									]
								] )
						] );
				}
				$previewHolder = Html::rawElement(
					'div',
					[
						// mw-abusefilter-warn-preview, mw-abusefilter-disallow-preview
						'id' => "mw-abusefilter-$action-preview",
						'style' => 'display:none'
					],
					''
				);
				$fields[] = $buttonGroup;
				$output .=
					Html::rawElement(
						'div',
						// mw-abusefilter-warn-parameters, mw-abusefilter-disallow-parameters
						[ 'id' => "mw-abusefilter-$action-parameters" ],
						new OOUI\FieldsetLayout( [ 'items' => $fields ] )
					) . $previewHolder;

				return $output;
			case 'tag':
				$tags = $set ? $parameters : [];
				'@phan-var string[] $parameters';
				$output = '';

				$checkbox =
					new OOUI\FieldLayout(
						new OOUI\CheckboxInputWidget( [
							'name' => 'wpFilterActionTag',
							'id' => 'mw-abusefilter-action-checkbox-tag',
							'selected' => $set,
							'classes' => [ 'mw-abusefilter-action-checkbox' ],
							'disabled' => $readOnly
						]
						),
						[
							'label' => $this->msg( 'abusefilter-edit-action-tag' )->text(),
							'align' => 'inline'
						]
					);
				$output .= $checkbox;

				$tagConfig = [
					'values' => $tags,
					'label' => $this->msg( 'abusefilter-edit-tag-tag' )->parse(),
					'disabled' => $readOnly
				];
				$this->getOutput()->addJsConfigVars( 'tagConfig', $tagConfig );

				$hiddenTags =
					new OOUI\FieldLayout(
						new OOUI\MultilineTextInputWidget( [
							'name' => 'wpFilterTags',
							'value' => implode( ',', $tags ),
							'rows' => 5,
							'placeholder' => $this->msg( 'abusefilter-edit-tag-hidden-placeholder' )->text(),
							'infusable' => true,
							'id' => 'mw-abusefilter-hidden-tag-field',
							'readOnly' => $readOnly
						]
						),
						[
							'label' => new OOUI\HtmlSnippet(
								$this->msg( 'abusefilter-edit-tag-tag' )->parse()
							),
							'align' => 'top',
							'id' => 'mw-abusefilter-hidden-tag'
						]
					);
				$output .=
					Html::rawElement( 'div',
						[ 'id' => 'mw-abusefilter-tag-parameters' ],
						$hiddenTags
					);
				return $output;
			case 'block':
				if ( $set && count( $parameters ) === 3 ) {
					// Both blocktalk and custom block durations available
					[ $blockTalk, $defaultAnonDuration, $defaultUserDuration ] = $parameters;
				} else {
					if ( $set && count( $parameters ) === 1 ) {
						// Only blocktalk available
						$blockTalk = $parameters[0];
					}
					$defaultAnonDuration = $config->get( 'AbuseFilterAnonBlockDuration' ) ??
						$config->get( 'AbuseFilterBlockDuration' );
					$defaultUserDuration = $config->get( 'AbuseFilterBlockDuration' );
				}
				$suggestedBlocks = $this->getLanguage()->getBlockDurations( false );
				$suggestedBlocks = self::normalizeBlocks( $suggestedBlocks );

				$output = '';
				$checkbox =
					new OOUI\FieldLayout(
						new OOUI\CheckboxInputWidget( [
							'name' => 'wpFilterActionBlock',
							'id' => 'mw-abusefilter-action-checkbox-block',
							'selected' => $set,
							'classes' => [ 'mw-abusefilter-action-checkbox' ],
							'disabled' => $readOnly
						]
						),
						[
							'label' => $this->msg( 'abusefilter-edit-action-block' )->text(),
							'align' => 'inline'
						]
					);
				$output .= $checkbox;

				$suggestedBlocks = Html::listDropdownOptionsOoui( $suggestedBlocks );

				$anonDuration =
					new OOUI\DropdownInputWidget( [
						'name' => 'wpBlockAnonDuration',
						'options' => $suggestedBlocks,
						'value' => $defaultAnonDuration,
						'disabled' => $readOnly
					] );

				$userDuration =
					new OOUI\DropdownInputWidget( [
						'name' => 'wpBlockUserDuration',
						'options' => $suggestedBlocks,
						'value' => $defaultUserDuration,
						'disabled' => $readOnly
					] );

				$blockOptions = [];
				if ( $config->get( MainConfigNames::BlockAllowsUTEdit ) === true ) {
					$talkCheckbox =
						new OOUI\FieldLayout(
							new OOUI\CheckboxInputWidget( [
								'name' => 'wpFilterBlockTalk',
								'id' => 'mw-abusefilter-action-checkbox-blocktalk',
								'selected' => isset( $blockTalk ) && $blockTalk === 'blocktalk',
								'classes' => [ 'mw-abusefilter-action-checkbox' ],
								'disabled' => $readOnly
							]
							),
							[
								'label' => $this->msg( 'abusefilter-edit-action-blocktalk' )->text(),
								'align' => 'left'
							]
						);

					$blockOptions[] = $talkCheckbox;
				}
				$blockOptions[] =
					new OOUI\FieldLayout(
						$anonDuration,
						[
							'label' => $this->msg( 'abusefilter-edit-block-anon-durations' )->text()
						]
					);
				$blockOptions[] =
					new OOUI\FieldLayout(
						$userDuration,
						[
							'label' => $this->msg( 'abusefilter-edit-block-user-durations' )->text()
						]
					);

				$output .= Html::rawElement(
						'div',
						[ 'id' => 'mw-abusefilter-block-parameters' ],
						new OOUI\FieldsetLayout( [ 'items' => $blockOptions ] )
					);

				return $output;

			default:
				// Give grep a chance to find the usages:
				// abusefilter-edit-action-disallow,
				// abusefilter-edit-action-blockautopromote,
				// abusefilter-edit-action-degroup,
				// abusefilter-edit-action-rangeblock,
				$message = 'abusefilter-edit-action-' . $action;
				$form_field = 'wpFilterAction' . ucfirst( $action );
				$status = $set;

				$thisAction =
					new OOUI\FieldLayout(
						new OOUI\CheckboxInputWidget( [
							'name' => $form_field,
							'id' => "mw-abusefilter-action-checkbox-$action",
							'selected' => $status,
							'classes' => [ 'mw-abusefilter-action-checkbox' ],
							'disabled' => $readOnly
						]
						),
						[
							'label' => $this->msg( $message )->text(),
							'align' => 'inline'
						]
					);
				return $thisAction;
		}
	}

	/**
	 * @param string $warnMsg
	 * @param bool $readOnly
	 * @param string $action
	 * @return \OOUI\FieldLayout
	 */
	public function getExistingSelector( $warnMsg, $readOnly = false, $action = 'warn' ) {
		if ( $action === 'warn' ) {
			$action = 'warning';
			$formId = 'warn';
			$inputName = 'wpFilterWarnMessage';
		} elseif ( $action === 'disallow' ) {
			$action = 'disallowed';
			$formId = 'disallow';
			$inputName = 'wpFilterDisallowMessage';
		} else {
			throw new UnexpectedValueException( "Unexpected action value $action" );
		}

		$existingSelector =
			new OOUI\DropdownInputWidget( [
				'name' => $inputName,
				// mw-abusefilter-warn-message-existing, mw-abusefilter-disallow-message-existing
				'id' => "mw-abusefilter-$formId-message-existing",
				// abusefilter-warning, abusefilter-disallowed
				'value' => $warnMsg === "abusefilter-$action" ? "abusefilter-$action" : 'other',
				'infusable' => true
			] );

		// abusefilter-warning, abusefilter-disallowed
		$options = [ "abusefilter-$action" => "abusefilter-$action" ];

		if ( $readOnly ) {
			$existingSelector->setDisabled( true );
		} else {
			// Find other messages.
			$dbr = $this->lbFactory->getReplicaDatabase();
			$pageTitlePrefix = "Abusefilter-$action";
			$titles = $dbr->newSelectQueryBuilder()
				->select( 'page_title' )
				->from( 'page' )
				->where( [
					'page_namespace' => 8,
					$dbr->expr( 'page_title', IExpression::LIKE, new LikeValue( $pageTitlePrefix, $dbr->anyString() ) )
				] )
				->caller( __METHOD__ )
				->fetchFieldValues();

			$lang = $this->getLanguage();
			foreach ( $titles as $title ) {
				if ( $lang->lcfirst( $title ) === $lang->lcfirst( $warnMsg ) ) {
					$existingSelector->setValue( $lang->lcfirst( $warnMsg ) );
				}

				if ( $title !== "Abusefilter-$action" ) {
					$options[ $lang->lcfirst( $title ) ] = $lang->lcfirst( $title );
				}
			}
		}

		// abusefilter-edit-warn-other, abusefilter-edit-disallow-other
		$options[ $this->msg( "abusefilter-edit-$formId-other" )->text() ] = 'other';

		$options = Html::listDropdownOptionsOoui( $options );
		$existingSelector->setOptions( $options );

		$existingSelector =
			new OOUI\FieldLayout(
				$existingSelector,
				[
					// abusefilter-edit-warn-message, abusefilter-edit-disallow-message
					'label' => $this->msg( "abusefilter-edit-$formId-message" )->text()
				]
			);

		return $existingSelector;
	}

	/**
	 * @todo Maybe we should also check if global values belong to $durations
	 * and determine the right point to add them if missing.
	 *
	 * @param string[] $durations
	 * @return string[]
	 */
	private static function normalizeBlocks( array $durations ) {
		global $wgAbuseFilterBlockDuration, $wgAbuseFilterAnonBlockDuration;
		// We need to have same values since it may happen that ipblocklist
		// and one (or both) of the global variables use different wording
		// for the same duration. In such case, when setting the default of
		// the dropdowns it would fail.
		$anonDuration = self::getAbsoluteBlockDuration( $wgAbuseFilterAnonBlockDuration ??
			$wgAbuseFilterBlockDuration );
		$userDuration = self::getAbsoluteBlockDuration( $wgAbuseFilterBlockDuration );
		foreach ( $durations as &$duration ) {
			$currentDuration = self::getAbsoluteBlockDuration( $duration );

			if ( $duration !== $wgAbuseFilterBlockDuration &&
				$currentDuration === $userDuration ) {
				$duration = $wgAbuseFilterBlockDuration;

			} elseif ( $duration !== $wgAbuseFilterAnonBlockDuration &&
				$currentDuration === $anonDuration ) {
				$duration = $wgAbuseFilterAnonBlockDuration;
			}
		}

		return $durations;
	}

	/**
	 * Converts a string duration to an absolute timestamp, i.e. unrelated to the current
	 * time, taking into account infinity durations as well. The second parameter of
	 * strtotime is set to 0 in order to convert the duration in seconds (instead of
	 * a timestamp), thus making it unaffected by the execution time of the code.
	 *
	 * @param string $duration
	 * @return string|int
	 */
	private static function getAbsoluteBlockDuration( $duration ) {
		if ( wfIsInfinity( $duration ) ) {
			return 'infinity';
		}
		return strtotime( $duration, 0 );
	}

	/**
	 * Loads filter data from the database by ID.
	 * @param int|null $id The filter's ID number, or null for a new filter
	 * @return Filter
	 * @throws FilterNotFoundException
	 */
	private function loadFilterData( ?int $id ): Filter {
		if ( $id === null ) {
			return MutableFilter::newDefault();
		}

		$flags = $this->getRequest()->wasPosted()
			// Load from primary database to avoid unintended reversions where there's replication lag.
			? IDBAccessObject::READ_LATEST
			: IDBAccessObject::READ_NORMAL;

		return $this->filterLookup->getFilter( $id, false, $flags );
	}

	/**
	 * Load filter data to show in the edit view from the DB.
	 * @param int|null $filter The filter ID being requested or null for a new filter
	 * @param int|null $history_id If any, the history ID being requested.
	 * @return Filter|null Null if the filter does not exist.
	 */
	private function loadFromDatabase( ?int $filter, $history_id = null ): ?Filter {
		if ( $history_id ) {
			try {
				return $this->filterLookup->getFilterVersion( $history_id );
			} catch ( FilterVersionNotFoundException $_ ) {
				return null;
			}
		} else {
			return $this->loadFilterData( $filter );
		}
	}

	/**
	 * Load data from the HTTP request. Used for saving the filter, and when the token doesn't match
	 * @param int|null $filter
	 * @return Filter[]
	 */
	private function loadRequest( ?int $filter ): array {
		$request = $this->getRequest();
		if ( !$request->wasPosted() ) {
			// Sanity
			throw new LogicException( __METHOD__ . ' called without the request being POSTed.' );
		}

		$origFilter = $this->loadFilterData( $filter );

		/** @var MutableFilter $newFilter */
		$newFilter = $origFilter instanceof MutableFilter
			? clone $origFilter
			: MutableFilter::newFromParentFilter( $origFilter );

		if ( $filter !== null ) {
			// Unchangeable values
			// @phan-suppress-next-line PhanTypeMismatchArgumentNullable
			$newFilter->setThrottled( $origFilter->isThrottled() );
			// @phan-suppress-next-line PhanTypeMismatchArgumentNullable
			$newFilter->setHitCount( $origFilter->getHitCount() );
			// These are needed if the save fails and the filter is not new
			$newFilter->setID( $origFilter->getID() );
			$newFilter->setUserID( $origFilter->getUserID() );
			$newFilter->setUserName( $origFilter->getUserName() );
			$newFilter->setTimestamp( $origFilter->getTimestamp() );
		}

		$newFilter->setName( trim( $request->getVal( 'wpFilterDescription' ) ) );
		$newFilter->setRules( trim( $request->getVal( 'wpFilterRules' ) ) );
		$newFilter->setComments( trim( $request->getVal( 'wpFilterNotes' ) ) );

		$newFilter->setGroup( $request->getVal( 'wpFilterGroup', 'default' ) );

		$newFilter->setDeleted( $request->getCheck( 'wpFilterDeleted' ) );
		$newFilter->setEnabled( $request->getCheck( 'wpFilterEnabled' ) );
		$newFilter->setHidden( $request->getCheck( 'wpFilterHidden' ) );
		$newFilter->setProtected( $request->getCheck( 'wpFilterProtected' ) );
		$newFilter->setGlobal( $request->getCheck( 'wpFilterGlobal' )
			&& $this->getConfig()->get( 'AbuseFilterIsCentral' ) );

		$actions = $this->loadActions();

		$newFilter->setActions( $actions );

		return [ $newFilter, $origFilter ];
	}

	/**
	 * @return Filter|null
	 */
	private function loadImportRequest(): ?Filter {
		$request = $this->getRequest();
		if ( !$request->wasPosted() ) {
			// Sanity
			throw new LogicException( __METHOD__ . ' called without the request being POSTed.' );
		}

		try {
			$filter = $this->filterImporter->decodeData( $request->getVal( 'wpImportText' ) );
		} catch ( InvalidImportDataException $_ ) {
			return null;
		}

		return $filter;
	}

	/**
	 * @return array[]
	 */
	private function loadActions(): array {
		$request = $this->getRequest();
		$allActions = $this->consequencesRegistry->getAllEnabledActionNames();
		$actions = [];
		foreach ( $allActions as $action ) {
			// Check if it's set
			$enabled = $request->getCheck( 'wpFilterAction' . ucfirst( $action ) );

			if ( $enabled ) {
				$parameters = [];

				if ( $action === 'throttle' ) {
					// We need to load the parameters
					$throttleCount = $request->getIntOrNull( 'wpFilterThrottleCount' );
					$throttlePeriod = $request->getIntOrNull( 'wpFilterThrottlePeriod' );
					// First explode with \n, which is the delimiter used in the textarea
					$rawGroups = explode( "\n", $request->getText( 'wpFilterThrottleGroups' ) );
					// Trim any space, both as an actual group and inside subgroups
					$throttleGroups = [];
					foreach ( $rawGroups as $group ) {
						if ( strpos( $group, ',' ) !== false ) {
							$subGroups = explode( ',', $group );
							$throttleGroups[] = implode( ',', array_map( 'trim', $subGroups ) );
						} elseif ( trim( $group ) !== '' ) {
							$throttleGroups[] = trim( $group );
						}
					}

					$parameters[0] = $this->filter;
					$parameters[1] = "$throttleCount,$throttlePeriod";
					$parameters = array_merge( $parameters, $throttleGroups );
				} elseif ( $action === 'warn' ) {
					$specMsg = $request->getVal( 'wpFilterWarnMessage' );

					if ( $specMsg === 'other' ) {
						$specMsg = $request->getVal( 'wpFilterWarnMessageOther' );
					}

					$parameters[0] = $specMsg;
				} elseif ( $action === 'block' ) {
					// TODO: Should save a boolean
					$parameters[0] = $request->getCheck( 'wpFilterBlockTalk' ) ?
						'blocktalk' : 'noTalkBlockSet';
					$parameters[1] = $request->getVal( 'wpBlockAnonDuration' );
					$parameters[2] = $request->getVal( 'wpBlockUserDuration' );
				} elseif ( $action === 'disallow' ) {
					$specMsg = $request->getVal( 'wpFilterDisallowMessage' );

					if ( $specMsg === 'other' ) {
						$specMsg = $request->getVal( 'wpFilterDisallowMessageOther' );
					}

					$parameters[0] = $specMsg;
				} elseif ( $action === 'tag' ) {
					$parameters = explode( ',', trim( $request->getText( 'wpFilterTags' ) ) );
					if ( $parameters === [ '' ] ) {
						// Since it's not possible to manually add an empty tag, this only happens
						// if the form is submitted without touching the tag input field.
						// We pass an empty array so that the widget won't show an empty tag in the topbar
						$parameters = [];
					}
				}

				$actions[$action] = $parameters;
			}
		}
		return $actions;
	}

	/**
	 * Exports the default warning and disallow messages to a JS variable
	 */
	private function exposeMessages() {
		$this->getOutput()->addJsConfigVars(
			'wgAbuseFilterDefaultWarningMessage',
			$this->getConfig()->get( 'AbuseFilterDefaultWarningMessage' )
		);
		$this->getOutput()->addJsConfigVars(
			'wgAbuseFilterDefaultDisallowMessage',
			$this->getConfig()->get( 'AbuseFilterDefaultDisallowMessage' )
		);
	}
}
PK       ! #/p,,  ,,    View/AbuseFilterViewList.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\View;

use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Context\IContextSource;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\CentralDBManager;
use MediaWiki\Extension\AbuseFilter\Filter\Flags;
use MediaWiki\Extension\AbuseFilter\FilterProfiler;
use MediaWiki\Extension\AbuseFilter\Pager\AbuseFilterPager;
use MediaWiki\Extension\AbuseFilter\Pager\GlobalAbuseFilterPager;
use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
use MediaWiki\Html\Html;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Linker\LinkRenderer;
use OOUI;
use StringUtils;
use Wikimedia\Rdbms\IConnectionProvider;

/**
 * The default view used in Special:AbuseFilter
 */
class AbuseFilterViewList extends AbuseFilterView {

	/** @var LinkBatchFactory */
	private $linkBatchFactory;

	/** @var IConnectionProvider */
	private $dbProvider;

	/** @var FilterProfiler */
	private $filterProfiler;

	/** @var SpecsFormatter */
	private $specsFormatter;

	/** @var CentralDBManager */
	private $centralDBManager;

	/**
	 * @param LinkBatchFactory $linkBatchFactory
	 * @param IConnectionProvider $dbProvider
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param FilterProfiler $filterProfiler
	 * @param SpecsFormatter $specsFormatter
	 * @param CentralDBManager $centralDBManager
	 * @param IContextSource $context
	 * @param LinkRenderer $linkRenderer
	 * @param string $basePageName
	 * @param array $params
	 */
	public function __construct(
		LinkBatchFactory $linkBatchFactory,
		IConnectionProvider $dbProvider,
		AbuseFilterPermissionManager $afPermManager,
		FilterProfiler $filterProfiler,
		SpecsFormatter $specsFormatter,
		CentralDBManager $centralDBManager,
		IContextSource $context,
		LinkRenderer $linkRenderer,
		string $basePageName,
		array $params
	) {
		parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
		$this->linkBatchFactory = $linkBatchFactory;
		$this->dbProvider = $dbProvider;
		$this->filterProfiler = $filterProfiler;
		$this->specsFormatter = $specsFormatter;
		$this->specsFormatter->setMessageLocalizer( $context );
		$this->centralDBManager = $centralDBManager;
	}

	/**
	 * Shows the page
	 */
	public function show() {
		$out = $this->getOutput();
		$request = $this->getRequest();
		$config = $this->getConfig();
		$performer = $this->getAuthority();

		$out->addWikiMsg( 'abusefilter-intro' );
		$this->showStatus();

		// New filter button
		if ( $this->afPermManager->canEdit( $performer ) ) {
			$out->enableOOUI();
			$buttons = new OOUI\HorizontalLayout( [
				'items' => [
					new OOUI\ButtonWidget( [
						'label' => $this->msg( 'abusefilter-new' )->text(),
						'href' => $this->getTitle( 'new' )->getFullURL(),
						'flags' => [ 'primary', 'progressive' ],
					] ),
					new OOUI\ButtonWidget( [
						'label' => $this->msg( 'abusefilter-import-button' )->text(),
						'href' => $this->getTitle( 'import' )->getFullURL(),
						'flags' => [ 'primary', 'progressive' ],
					] )
				]
			] );
			$out->addHTML( $buttons );
		}

		$conds = [];
		$deleted = $request->getVal( 'deletedfilters' );
		$furtherOptions = $request->getArray( 'furtheroptions', [] );
		'@phan-var array $furtherOptions';
		// Backward compatibility with old links
		if ( $request->getBool( 'hidedisabled' ) ) {
			$furtherOptions[] = 'hidedisabled';
		}
		if ( $request->getBool( 'hideprivate' ) ) {
			$furtherOptions[] = 'hideprivate';
		}
		$defaultscope = 'all';
		if ( $config->get( 'AbuseFilterCentralDB' ) !== null
				&& !$config->get( 'AbuseFilterIsCentral' ) ) {
			// Show on remote wikis as default only local filters
			$defaultscope = 'local';
		}
		$scope = $request->getVal( 'rulescope', $defaultscope );

		$searchEnabled = $this->afPermManager->canViewPrivateFilters( $performer ) && !(
			$config->get( 'AbuseFilterCentralDB' ) !== null &&
			!$config->get( 'AbuseFilterIsCentral' ) &&
			$scope === 'global' );

		if ( $searchEnabled ) {
			$querypattern = $request->getVal( 'querypattern', '' );
			$searchmode = $request->getVal( 'searchoption', null );
			if ( $querypattern === '' ) {
				// Not specified or empty, that would error out
				$querypattern = $searchmode = null;
			}
		} else {
			$querypattern = null;
			$searchmode = null;
		}

		if ( $deleted === 'show' ) {
			// Nothing
		} elseif ( $deleted === 'only' ) {
			$conds['af_deleted'] = 1;
		} else {
			// hide, or anything else.
			$conds['af_deleted'] = 0;
			$deleted = 'hide';
		}
		if ( in_array( 'hidedisabled', $furtherOptions ) ) {
			$conds['af_deleted'] = 0;
			$conds['af_enabled'] = 1;
		}
		if ( in_array( 'hideprivate', $furtherOptions ) ) {
			$conds['af_hidden'] = Flags::FILTER_PUBLIC;
		}

		if ( $scope === 'local' ) {
			$conds['af_global'] = 0;
		} elseif ( $scope === 'global' ) {
			$conds['af_global'] = 1;
		}

		if ( !$this->afPermManager->canViewProtectedVariables( $performer ) ) {
			$dbr = $this->dbProvider->getReplicaDatabase();
			$conds[] = $dbr->bitAnd( 'af_hidden', Flags::FILTER_USES_PROTECTED_VARS ) . ' = 0';
		}

		if ( $searchmode !== null ) {
			// Check the search pattern. Filtering the results is done in AbuseFilterPager
			$error = null;
			if ( !in_array( $searchmode, [ 'LIKE', 'RLIKE', 'IRLIKE' ] ) ) {
				$error = 'abusefilter-list-invalid-searchmode';
			} elseif ( $searchmode !== 'LIKE' && !StringUtils::isValidPCRERegex( "/$querypattern/" ) ) {
				// @phan-suppress-previous-line SecurityCheck-ReDoS Yes, I know...
				$error = 'abusefilter-list-regexerror';
			}

			if ( $error !== null ) {
				$out->addHTML(
					Html::rawElement(
						'p',
						[],
						Html::errorBox( $this->msg( $error )->escaped() )
					)
				);

				// Reset the conditions in case of error
				$conds = [ 'af_deleted' => 0 ];
				$searchmode = $querypattern = null;
			}
		}

		$this->showList(
			[
				'deleted' => $deleted,
				'furtherOptions' => $furtherOptions,
				'querypattern' => $querypattern,
				'searchmode' => $searchmode,
				'scope' => $scope,
			],
			$conds
		);
	}

	/**
	 * @param array $optarray
	 * @param array $conds
	 */
	private function showList( array $optarray, array $conds = [ 'af_deleted' => 0 ] ) {
		$performer = $this->getAuthority();
		$config = $this->getConfig();
		$centralDB = $config->get( 'AbuseFilterCentralDB' );
		$dbIsCentral = $config->get( 'AbuseFilterIsCentral' );
		$this->getOutput()->addHTML(
			Html::rawElement( 'h2', [], $this->msg( 'abusefilter-list' )->parse() )
		);

		$deleted = $optarray['deleted'];
		$furtherOptions = $optarray['furtherOptions'];
		$scope = $optarray['scope'];
		$querypattern = $optarray['querypattern'];
		$searchmode = $optarray['searchmode'];

		if ( $centralDB !== null && !$dbIsCentral && $scope === 'global' ) {
			// TODO: remove the circular dependency
			$pager = new GlobalAbuseFilterPager(
				$this,
				$this->linkRenderer,
				$this->afPermManager,
				$this->specsFormatter,
				$this->centralDBManager,
				$conds
			);
		} else {
			$pager = new AbuseFilterPager(
				$this,
				$this->linkRenderer,
				$this->linkBatchFactory,
				$this->afPermManager,
				$this->specsFormatter,
				$conds,
				$querypattern,
				$searchmode
			);
		}

		// Options form
		$formDescriptor = [];

		if ( $centralDB !== null ) {
			$optionsMsg = [
				'abusefilter-list-options-scope-local' => 'local',
				'abusefilter-list-options-scope-global' => 'global',
			];
			if ( $dbIsCentral ) {
				// For central wiki: add third scope option
				$optionsMsg['abusefilter-list-options-scope-all'] = 'all';
			}
			$formDescriptor['rulescope'] = [
				'name' => 'rulescope',
				'type' => 'radio',
				'flatlist' => true,
				'label-message' => 'abusefilter-list-options-scope',
				'options-messages' => $optionsMsg,
				'default' => $scope,
			];
		}

		$formDescriptor['deletedfilters'] = [
			'name' => 'deletedfilters',
			'type' => 'radio',
			'flatlist' => true,
			'label-message' => 'abusefilter-list-options-deleted',
			'options-messages' => [
				'abusefilter-list-options-deleted-show' => 'show',
				'abusefilter-list-options-deleted-hide' => 'hide',
				'abusefilter-list-options-deleted-only' => 'only',
			],
			'default' => $deleted,
		];

		$formDescriptor['furtheroptions'] = [
			'name' => 'furtheroptions',
			'type' => 'multiselect',
			'label-message' => 'abusefilter-list-options-further-options',
			'flatlist' => true,
			'options' => [
				$this->msg( 'abusefilter-list-options-hideprivate' )->parse() => 'hideprivate',
				$this->msg( 'abusefilter-list-options-hidedisabled' )->parse() => 'hidedisabled',
			],
			'default' => $furtherOptions
		];

		if ( $this->afPermManager->canViewPrivateFilters( $performer ) ) {
			$globalEnabled = $centralDB !== null && !$dbIsCentral;
			$formDescriptor['querypattern'] = [
				'name' => 'querypattern',
				'type' => 'text',
				'hide-if' => $globalEnabled ? [ '===', 'rulescope', 'global' ] : [],
				'label-message' => 'abusefilter-list-options-searchfield',
				'placeholder' => $this->msg( 'abusefilter-list-options-searchpattern' )->text(),
				'default' => $querypattern
			];

			$formDescriptor['searchoption'] = [
				'name' => 'searchoption',
				'type' => 'radio',
				'flatlist' => true,
				'label-message' => 'abusefilter-list-options-searchoptions',
				'hide-if' => $globalEnabled ?
					[ 'OR', [ '===', 'querypattern', '' ], $formDescriptor['querypattern']['hide-if'] ] :
					[ '===', 'querypattern', '' ],
				'options-messages' => [
					'abusefilter-list-options-search-like' => 'LIKE',
					'abusefilter-list-options-search-rlike' => 'RLIKE',
					'abusefilter-list-options-search-irlike' => 'IRLIKE',
				],
				'default' => $searchmode
			];
		}

		$formDescriptor['limit'] = [
			'name' => 'limit',
			'type' => 'select',
			'label-message' => 'abusefilter-list-limit',
			'options' => $pager->getLimitSelectList(),
			'default' => $pager->getLimit(),
		];

		HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
			->setTitle( $this->getTitle() )
			->setCollapsibleOptions( true )
			->setWrapperLegendMsg( 'abusefilter-list-options' )
			->setSubmitTextMsg( 'abusefilter-list-options-submit' )
			->setMethod( 'get' )
			->prepareForm()
			->displayForm( false );

		$this->getOutput()->addParserOutputContent( $pager->getFullOutput() );
	}

	/**
	 * Generates a summary of filter activity using the internal statistics.
	 */
	public function showStatus() {
		$totalCount = 0;
		$matchCount = 0;
		$overflowCount = 0;
		foreach ( $this->getConfig()->get( 'AbuseFilterValidGroups' ) as $group ) {
			$profile = $this->filterProfiler->getGroupProfile( $group );
			$totalCount += $profile[ 'total' ];
			$overflowCount += $profile[ 'overflow' ];
			$matchCount += $profile[ 'matches' ];
		}

		if ( $totalCount > 0 ) {
			$overflowPercent = round( 100 * $overflowCount / $totalCount, 2 );
			$matchPercent = round( 100 * $matchCount / $totalCount, 2 );

			$status = $this->msg( 'abusefilter-status' )
				->numParams(
					$totalCount,
					$overflowCount,
					$overflowPercent,
					$this->getConfig()->get( 'AbuseFilterConditionLimit' ),
					$matchCount,
					$matchPercent
				)->parse();

			$status = Html::rawElement( 'p', [ 'class' => 'mw-abusefilter-status' ], $status );
			$this->getOutput()->addHTML( $status );
		}
	}
}
PK       ! >h.  h.    View/AbuseFilterViewRevert.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\View;

use MediaWiki\Context\IContextSource;
use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
use MediaWiki\Extension\AbuseFilter\ActionSpecifier;
use MediaWiki\Extension\AbuseFilter\Consequences\Consequence\ReversibleConsequence;
use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesFactory;
use MediaWiki\Extension\AbuseFilter\Consequences\Parameters;
use MediaWiki\Extension\AbuseFilter\FilterLookup;
use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
use MediaWiki\Extension\AbuseFilter\Variables\UnsetVariableException;
use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
use MediaWiki\Html\Html;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Linker\Linker;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Message\Message;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\UserFactory;
use PermissionsError;
use UnexpectedValueException;
use UserBlockedError;
use Wikimedia\Rdbms\LBFactory;
use Wikimedia\Rdbms\SelectQueryBuilder;

class AbuseFilterViewRevert extends AbuseFilterView {
	/** @var int */
	private $filter;
	/**
	 * @var string|null The start time of the lookup period
	 */
	private $periodStart;
	/**
	 * @var string|null The end time of the lookup period
	 */
	private $periodEnd;
	/**
	 * @var string|null The reason provided for the revert
	 */
	private $reason;
	/**
	 * @var LBFactory
	 */
	private $lbFactory;
	/**
	 * @var UserFactory
	 */
	private $userFactory;
	/**
	 * @var FilterLookup
	 */
	private $filterLookup;
	/**
	 * @var ConsequencesFactory
	 */
	private $consequencesFactory;
	/**
	 * @var VariablesBlobStore
	 */
	private $varBlobStore;
	/**
	 * @var SpecsFormatter
	 */
	private $specsFormatter;

	/**
	 * @param LBFactory $lbFactory
	 * @param UserFactory $userFactory
	 * @param AbuseFilterPermissionManager $afPermManager
	 * @param FilterLookup $filterLookup
	 * @param ConsequencesFactory $consequencesFactory
	 * @param VariablesBlobStore $varBlobStore
	 * @param SpecsFormatter $specsFormatter
	 * @param IContextSource $context
	 * @param LinkRenderer $linkRenderer
	 * @param string $basePageName
	 * @param array $params
	 */
	public function __construct(
		LBFactory $lbFactory,
		UserFactory $userFactory,
		AbuseFilterPermissionManager $afPermManager,
		FilterLookup $filterLookup,
		ConsequencesFactory $consequencesFactory,
		VariablesBlobStore $varBlobStore,
		SpecsFormatter $specsFormatter,
		IContextSource $context,
		LinkRenderer $linkRenderer,
		string $basePageName,
		array $params
	) {
		parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
		$this->lbFactory = $lbFactory;
		$this->userFactory = $userFactory;
		$this->filterLookup = $filterLookup;
		$this->consequencesFactory = $consequencesFactory;
		$this->varBlobStore = $varBlobStore;
		$this->specsFormatter = $specsFormatter;
		$this->specsFormatter->setMessageLocalizer( $this->getContext() );
	}

	/**
	 * Shows the page
	 */
	public function show() {
		$lang = $this->getLanguage();

		$performer = $this->getAuthority();
		$out = $this->getOutput();

		if ( !$this->afPermManager->canRevertFilterActions( $performer ) ) {
			throw new PermissionsError( 'abusefilter-revert' );
		}

		$block = $performer->getBlock();
		if ( $block && $block->isSitewide() ) {
			throw new UserBlockedError( $block );
		}

		$this->loadParameters();

		if ( $this->attemptRevert() ) {
			return;
		}

		$filter = $this->filter;

		$out->addWikiMsg( 'abusefilter-revert-intro', Message::numParam( $filter ) );
		// Parse wikitext in this message to allow formatting of numero signs (T343994#9209383)
		$out->setPageTitle( $this->msg( 'abusefilter-revert-title' )->numParams( $filter )->parse() );

		// First, the search form. Limit dates to avoid huge queries
		$RCMaxAge = $this->getConfig()->get( 'RCMaxAge' );
		$min = wfTimestamp( TS_ISO_8601, time() - $RCMaxAge );
		$max = wfTimestampNow();
		$filterLink =
			$this->linkRenderer->makeLink(
				$this->getTitle( $filter ),
				$lang->formatNum( $filter )
			);
		$searchFields = [];
		$searchFields['filterid'] = [
			'type' => 'info',
			'default' => $filterLink,
			'raw' => true,
			'label-message' => 'abusefilter-revert-filter'
		];
		$searchFields['PeriodStart'] = [
			'type' => 'datetime',
			'label-message' => 'abusefilter-revert-periodstart',
			'min' => $min,
			'max' => $max
		];
		$searchFields['PeriodEnd'] = [
			'type' => 'datetime',
			'label-message' => 'abusefilter-revert-periodend',
			'min' => $min,
			'max' => $max
		];

		HTMLForm::factory( 'ooui', $searchFields, $this->getContext() )
			->setTitle( $this->getTitle( "revert/$filter" ) )
			->setWrapperLegendMsg( 'abusefilter-revert-search-legend' )
			->setSubmitTextMsg( 'abusefilter-revert-search' )
			->setMethod( 'get' )
			->setFormIdentifier( 'revert-select-date' )
			->setSubmitCallback( [ $this, 'showRevertableActions' ] )
			->showAlways();
	}

	/**
	 * Show revertable actions, called as submit callback by HTMLForm
	 * @param array $formData
	 * @param HTMLForm $dateForm
	 * @return bool
	 */
	public function showRevertableActions( array $formData, HTMLForm $dateForm ): bool {
		$lang = $this->getLanguage();
		$user = $this->getUser();
		$filter = $this->filter;

		// Look up all of them.
		$results = $this->doLookup();
		if ( $results === [] ) {
			$dateForm->addPostHtml( $this->msg( 'abusefilter-revert-preview-no-results' )->escaped() );
			return true;
		}

		// Add a summary of everything that will be reversed.
		$dateForm->addPostHtml( $this->msg( 'abusefilter-revert-preview-intro' )->parseAsBlock() );
		$list = [];

		foreach ( $results as $result ) {
			$displayActions = [];
			foreach ( $result['actions'] as $action ) {
				$displayActions[] = $this->specsFormatter->getActionDisplay( $action );
			}

			/** @var ActionSpecifier $spec */
			$spec = $result['spec'];
			$msg = $this->msg( 'abusefilter-revert-preview-item' )
				->params(
					$lang->userTimeAndDate( $result['timestamp'], $user )
				)->rawParams(
					Linker::userLink( $spec->getUser()->getId(), $spec->getUser()->getName() )
				)->params(
					$spec->getAction()
				)->rawParams(
					$this->linkRenderer->makeLink( $spec->getTitle() )
				)->params(
					$lang->commaList( $displayActions )
				)->rawParams(
					$this->linkRenderer->makeLink(
						SpecialPage::getTitleFor( 'AbuseLog' ),
						$this->msg( 'abusefilter-log-detailslink' )->text(),
						[],
						[ 'details' => $result['id'] ]
					)
				)->params(
					$spec->getUser()->getName()
				)->parse();
			$list[] = Html::rawElement( 'li', [], $msg );
		}

		$dateForm->addPostHtml( Html::rawElement( 'ul', [], implode( "\n", $list ) ) );

		// Add a button down the bottom.
		$confirmForm = [];
		$confirmForm['PeriodStart'] = [
			'type' => 'hidden',
		];
		$confirmForm['PeriodEnd'] = [
			'type' => 'hidden',
		];
		$confirmForm['Reason'] = [
			'type' => 'text',
			'label-message' => 'abusefilter-revert-reasonfield',
			'id' => 'wpReason',
		];

		$revertForm = HTMLForm::factory( 'ooui', $confirmForm, $this->getContext() )
			->setTitle( $this->getTitle( "revert/$filter" ) )
			->setTokenSalt( "abusefilter-revert-$filter" )
			->setWrapperLegendMsg( 'abusefilter-revert-confirm-legend' )
			->setSubmitTextMsg( 'abusefilter-revert-confirm' )
			->prepareForm()
			->getHTML( true );
		$dateForm->addPostHtml( $revertForm );

		return true;
	}

	/**
	 * @return array[]
	 */
	public function doLookup() {
		$periodStart = $this->periodStart;
		$periodEnd = $this->periodEnd;
		$filter = $this->filter;
		$dbr = $this->lbFactory->getReplicaDatabase();

		// Only hits from local filters can be reverted
		$conds = [ 'afl_filter_id' => $filter, 'afl_global' => 0 ];

		if ( $periodStart !== null ) {
			$conds[] = $dbr->expr( 'afl_timestamp', '>=', $dbr->timestamp( $periodStart ) );
		}
		if ( $periodEnd !== null ) {
			$conds[] = $dbr->expr( 'afl_timestamp', '<=', $dbr->timestamp( $periodEnd ) );
		}

		// Don't revert if there was no action, or the action was global
		$conds[] = $dbr->expr( 'afl_actions', '!=', '' );
		$conds['afl_wiki'] = null;

		$selectFields = [
			'afl_id',
			'afl_user',
			'afl_user_text',
			'afl_ip',
			'afl_action',
			'afl_actions',
			'afl_var_dump',
			'afl_timestamp',
			'afl_namespace',
			'afl_title',
		];
		$res = $dbr->newSelectQueryBuilder()
			->select( $selectFields )
			->from( 'abuse_filter_log' )
			->where( $conds )
			->caller( __METHOD__ )
			->orderBy( 'afl_timestamp', SelectQueryBuilder::SORT_DESC )
			->fetchResultSet();

		// TODO: get the following from ConsequencesRegistry or sth else
		static $reversibleActions = [ 'block', 'blockautopromote', 'degroup' ];

		$results = [];
		foreach ( $res as $row ) {
			$actions = explode( ',', $row->afl_actions );
			$currentReversibleActions = array_intersect( $actions, $reversibleActions );
			if ( count( $currentReversibleActions ) ) {
				$vars = $this->varBlobStore->loadVarDump( $row );
				try {
					// The variable is not lazy-loaded
					$accountName = $vars->getComputedVariable( 'accountname' )->toNative();
				} catch ( UnsetVariableException $_ ) {
					$accountName = null;
				}
				$results[] = [
					'id' => $row->afl_id,
					'actions' => $currentReversibleActions,
					'vars' => $vars,
					'spec' => new ActionSpecifier(
						$row->afl_action,
						new TitleValue( (int)$row->afl_namespace, $row->afl_title ),
						$this->userFactory->newFromAnyId( (int)$row->afl_user, $row->afl_user_text ),
						$row->afl_ip,
						$accountName
					),
					'timestamp' => $row->afl_timestamp
				];
			}
		}

		return $results;
	}

	/**
	 * Loads parameters from request
	 */
	public function loadParameters() {
		$request = $this->getRequest();

		$this->filter = (int)$this->mParams[1];
		$this->periodStart = strtotime( $request->getText( 'wpPeriodStart' ) ) ?: null;
		$this->periodEnd = strtotime( $request->getText( 'wpPeriodEnd' ) ) ?: null;
		$this->reason = $request->getVal( 'wpReason' );
	}

	/**
	 * @return bool
	 */
	public function attemptRevert() {
		$filter = $this->filter;
		$token = $this->getRequest()->getVal( 'wpEditToken' );
		if ( !$this->getCsrfTokenSet()->matchToken( $token, "abusefilter-revert-$filter" ) ) {
			return false;
		}

		$results = $this->doLookup();
		foreach ( $results as $result ) {
			foreach ( $result['actions'] as $action ) {
				$this->revertAction( $action, $result );
			}
		}
		$this->getOutput()->addHTML( Html::successBox(
			$this->msg(
				'abusefilter-revert-success',
				$filter,
				$this->getLanguage()->formatNum( $filter )
			)->parse()
		) );

		return true;
	}

	/**
	 * Helper method for typing
	 * @param string $action
	 * @param array $result
	 * @return ReversibleConsequence
	 */
	private function getConsequence( string $action, array $result ): ReversibleConsequence {
		$params = new Parameters(
			$this->filterLookup->getFilter( $this->filter, false ),
			false,
			$result['spec']
		);

		switch ( $action ) {
			case 'block':
				return $this->consequencesFactory->newBlock( $params, '', false );
			case 'blockautopromote':
				$duration = $this->getConfig()->get( 'AbuseFilterBlockAutopromoteDuration' ) * 86400;
				return $this->consequencesFactory->newBlockAutopromote( $params, $duration );
			case 'degroup':
				return $this->consequencesFactory->newDegroup( $params, $result['vars'] );
			default:
				throw new UnexpectedValueException( "Invalid action $action" );
		}
	}

	/**
	 * @param string $action
	 * @param array $result
	 * @return bool
	 */
	public function revertAction( string $action, array $result ): bool {
		$message = $this->msg(
			'abusefilter-revert-reason', $this->filter, $this->reason
		)->inContentLanguage()->text();

		$consequence = $this->getConsequence( $action, $result );
		return $consequence->revert( $this->getUser(), $message );
	}
}
PK       ! Iq      Filter/Filter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Filter;

/**
 * Immutable value object representing a "complete" filter. This can be used to represent filters
 * that already exist in the database, but you should probably use subclasses for that.
 */
class Filter extends AbstractFilter {
	/** @var LastEditInfo */
	protected $lastEditInfo;
	/** @var int|null Can be null if not specified */
	protected $id;
	/** @var int|null Can be null if the filter is not current */
	protected $hitCount;
	/** @var bool|null Can be null if the filter is not current */
	protected $throttled;

	/**
	 * @param Specs $specs
	 * @param Flags $flags
	 * @param callable|array[] $actions Array with params or callable that will return them
	 * @phan-param array[]|callable():array[] $actions
	 * @param LastEditInfo $lastEditInfo
	 * @param int|null $id
	 * @param int|null $hitCount
	 * @param bool|null $throttled
	 */
	public function __construct(
		Specs $specs,
		Flags $flags,
		$actions,
		LastEditInfo $lastEditInfo,
		?int $id = null,
		?int $hitCount = null,
		?bool $throttled = null
	) {
		parent::__construct( $specs, $flags, $actions );
		$this->lastEditInfo = clone $lastEditInfo;
		$this->id = $id;
		$this->hitCount = $hitCount;
		$this->throttled = $throttled;
	}

	/**
	 * @return LastEditInfo
	 */
	public function getLastEditInfo(): LastEditInfo {
		return clone $this->lastEditInfo;
	}

	/**
	 * @return int|null
	 */
	public function getID(): ?int {
		return $this->id;
	}

	/**
	 * @return int
	 */
	public function getUserID(): int {
		return $this->lastEditInfo->getUserID();
	}

	/**
	 * @return string
	 */
	public function getUserName(): string {
		return $this->lastEditInfo->getUserName();
	}

	/**
	 * @return string
	 */
	public function getTimestamp(): string {
		return $this->lastEditInfo->getTimestamp();
	}

	/**
	 * @return int|null
	 */
	public function getHitCount(): ?int {
		return $this->hitCount;
	}

	/**
	 * @return bool|null
	 */
	public function isThrottled(): ?bool {
		return $this->throttled;
	}

	/**
	 * Make sure we don't leave any (writeable) reference
	 */
	public function __clone() {
		parent::__clone();
		$this->lastEditInfo = clone $this->lastEditInfo;
	}
}
PK       ! ]]ͥ    "  Filter/FilterNotFoundException.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Filter;

use RuntimeException;

/**
 * @codeCoverageIgnore
 */
class FilterNotFoundException extends RuntimeException {
	/**
	 * @param int $filter
	 * @param bool $global
	 */
	public function __construct( int $filter, bool $global ) {
		$msg = $global
			? "Global filter $filter does not exist"
			: "Filter $filter does not exist";
		parent::__construct( $msg );
	}
}
PK       ! SC$      Filter/AbstractFilter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Filter;

use Wikimedia\Assert\Assert;

/**
 * Immutable value object that represents a single filter. This object can be used to represent
 * filters that do not necessarily exist in the database. You'll usually want to use subclasses.
 */
class AbstractFilter {
	/** @var Specs */
	protected $specs;
	/** @var Flags */
	protected $flags;
	/**
	 * @var array[]|null Actions and parameters, can be lazy-loaded with $actionsCallback
	 */
	protected $actions;
	/**
	 * @var callable|null
	 * @todo Evaluate whether this can be avoided, e.g. by using a JOIN. This property also makes
	 *   the class not serializable.
	 */
	protected $actionsCallback;

	/**
	 * @param Specs $specs
	 * @param Flags $flags
	 * @param callable|array[] $actions Array with params or callable that will return them
	 * @phan-param array[]|callable():array[] $actions
	 */
	public function __construct(
		Specs $specs,
		Flags $flags,
		$actions
	) {
		$this->specs = clone $specs;
		$this->flags = clone $flags;
		Assert::parameterType( 'callable|array', $actions, '$actions' );
		if ( is_callable( $actions ) ) {
			$this->actionsCallback = $actions;
		} elseif ( is_array( $actions ) ) {
			$this->setActions( $actions );
		}
	}

	/**
	 * @return Specs
	 */
	public function getSpecs(): Specs {
		return clone $this->specs;
	}

	/**
	 * @return Flags
	 */
	public function getFlags(): Flags {
		return clone $this->flags;
	}

	/**
	 * @return string
	 */
	public function getRules(): string {
		return $this->specs->getRules();
	}

	/**
	 * @return string
	 */
	public function getComments(): string {
		return $this->specs->getComments();
	}

	/**
	 * @return string
	 */
	public function getName(): string {
		return $this->specs->getName();
	}

	/**
	 * @note Callers should not rely on the order, because it's nondeterministic.
	 * @return string[]
	 */
	public function getActionsNames(): array {
		return $this->specs->getActionsNames();
	}

	/**
	 * @return string
	 */
	public function getGroup(): string {
		return $this->specs->getGroup();
	}

	/**
	 * @return bool
	 */
	public function isEnabled(): bool {
		return $this->flags->getEnabled();
	}

	/**
	 * @return bool
	 */
	public function isDeleted(): bool {
		return $this->flags->getDeleted();
	}

	/**
	 * @return bool
	 */
	public function isHidden(): bool {
		return $this->flags->getHidden();
	}

	/**
	 * @return bool
	 */
	public function isProtected(): bool {
		return $this->flags->getProtected();
	}

	/**
	 * @return int
	 */
	public function getPrivacyLevel(): int {
		return $this->flags->getPrivacyLevel();
	}

	/**
	 * @return bool
	 */
	public function isGlobal(): bool {
		return $this->flags->getGlobal();
	}

	/**
	 * @return array[]
	 */
	public function getActions(): array {
		if ( $this->actions === null ) {
			$this->setActions( call_user_func( $this->actionsCallback ) );
			// This is to ease testing
			$this->actionsCallback = null;
		}
		return $this->actions;
	}

	/**
	 * @param array $actions
	 */
	protected function setActions( array $actions ): void {
		$this->actions = $actions;
		$this->specs->setActionsNames( array_keys( $actions ) );
	}

	/**
	 * Make sure we don't leave any (writeable) reference
	 */
	public function __clone() {
		$this->specs = clone $this->specs;
		$this->flags = clone $this->flags;
	}

}
PK       ! <\4I  I  )  Filter/FilterVersionNotFoundException.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Filter;

use RuntimeException;

/**
 * @codeCoverageIgnore
 */
class FilterVersionNotFoundException extends RuntimeException {
	/**
	 * @param int $version
	 */
	public function __construct( int $version ) {
		parent::__construct( "Filter version $version does not exist" );
	}
}
PK       ! Ku    0  Filter/ClosestFilterVersionNotFoundException.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Filter;

use RuntimeException;

/**
 * @codeCoverageIgnore
 */
class ClosestFilterVersionNotFoundException extends RuntimeException {
	/**
	 * @param int $filterID
	 * @param int $historyID
	 */
	public function __construct( int $filterID, int $historyID ) {
		parent::__construct( "No version of filter $filterID closest to $historyID found" );
	}
}
PK       ! ۏqH  H    Filter/Specs.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Filter;

/**
 * (Mutable) value object that represents the "specs" of a filter.
 */
class Specs {
	/** @var string */
	private $rules;
	/** @var string */
	private $comments;
	/** @var string */
	private $name;
	/** @var string[] */
	private $actionsNames;
	/** @var string */
	private $group;

	/**
	 * @param string $rules
	 * @param string $comments
	 * @param string $name
	 * @param string[] $actionsNames
	 * @param string $group
	 */
	public function __construct( string $rules, string $comments, string $name, array $actionsNames, string $group ) {
		$this->rules = $rules;
		$this->comments = $comments;
		$this->name = $name;
		$this->actionsNames = $actionsNames;
		$this->group = $group;
	}

	/**
	 * @return string
	 */
	public function getRules(): string {
		return $this->rules;
	}

	/**
	 * @param string $rules
	 */
	public function setRules( string $rules ): void {
		$this->rules = $rules;
	}

	/**
	 * @return string
	 */
	public function getComments(): string {
		return $this->comments;
	}

	/**
	 * @param string $comments
	 */
	public function setComments( string $comments ): void {
		$this->comments = $comments;
	}

	/**
	 * @return string
	 */
	public function getName(): string {
		return $this->name;
	}

	/**
	 * @param string $name
	 */
	public function setName( string $name ): void {
		$this->name = $name;
	}

	/**
	 * @return string[]
	 */
	public function getActionsNames(): array {
		return $this->actionsNames;
	}

	/**
	 * @param string[] $actionsNames
	 */
	public function setActionsNames( array $actionsNames ): void {
		$this->actionsNames = $actionsNames;
	}

	/**
	 * @return string
	 */
	public function getGroup(): string {
		return $this->group;
	}

	/**
	 * @param string $group
	 */
	public function setGroup( string $group ): void {
		$this->group = $group;
	}
}
PK       ! $6U?  ?    Filter/ExistingFilter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Filter;

/**
 * Variant of Filter for filters that are known to exist
 */
class ExistingFilter extends Filter {
	/**
	 * @param Specs $specs
	 * @param Flags $flags
	 * @param callable|array[] $actions Array with params or callable that will return them
	 * @phan-param array[]|callable():array[] $actions
	 * @param LastEditInfo $lastEditInfo
	 * @param int $id
	 * @param int|null $hitCount
	 * @param bool|null $throttled
	 */
	public function __construct(
		Specs $specs,
		Flags $flags,
		$actions,
		LastEditInfo $lastEditInfo,
		int $id,
		?int $hitCount = null,
		?bool $throttled = null
	) {
		parent::__construct( $specs, $flags, $actions, $lastEditInfo, $id, $hitCount, $throttled );
	}

	/**
	 * @return int
	 */
	public function getID(): int {
		return $this->id;
	}
}
PK       ! 0iP  P    Filter/MutableFilter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Filter;

use LogicException;

/**
 * Value object representing a filter that can be mutated (i.e. provides setters); this representation can
 * be used to modify an existing database filter before saving it back to the DB.
 */
class MutableFilter extends Filter {
	/**
	 * Convenience shortcut to get a 'default' filter, using the defaults for the editing interface.
	 *
	 * @return self
	 * @codeCoverageIgnore
	 */
	public static function newDefault(): self {
		return new self(
			new Specs(
				'',
				'',
				'',
				[],
				''
			),
			new Flags(
				true,
				false,
				Flags::FILTER_PUBLIC,
				false
			),
			[],
			new LastEditInfo(
				0,
				'',
				''
			)
		);
	}

	/**
	 * @param Filter $filter
	 * @return self
	 */
	public static function newFromParentFilter( Filter $filter ): self {
		return new self(
			$filter->getSpecs(),
			$filter->getFlags(),
			// @phan-suppress-next-line PhanTypeMismatchArgumentNullable One is guaranteed to be set
			$filter->actions ?? $filter->actionsCallback,
			$filter->getLastEditInfo(),
			$filter->getID(),
			$filter->getHitCount(),
			$filter->isThrottled()
		);
	}

	/**
	 * @param string $rules
	 */
	public function setRules( string $rules ): void {
		$this->specs->setRules( $rules );
	}

	/**
	 * @param string $comments
	 */
	public function setComments( string $comments ): void {
		$this->specs->setComments( $comments );
	}

	/**
	 * @param string $name
	 */
	public function setName( string $name ): void {
		$this->specs->setName( $name );
	}

	/**
	 * @throws LogicException if $actions are already set; use $this->setActions to update names
	 * @param string[] $actionsNames
	 */
	public function setActionsNames( array $actionsNames ): void {
		if ( $this->actions !== null ) {
			throw new LogicException( 'Cannot set actions names with actions already set' );
		}
		$this->specs->setActionsNames( $actionsNames );
	}

	/**
	 * @param string $group
	 */
	public function setGroup( string $group ): void {
		$this->specs->setGroup( $group );
	}

	/**
	 * @param bool $enabled
	 */
	public function setEnabled( bool $enabled ): void {
		$this->flags->setEnabled( $enabled );
	}

	/**
	 * @param bool $deleted
	 */
	public function setDeleted( bool $deleted ): void {
		$this->flags->setDeleted( $deleted );
	}

	/**
	 * @param bool $hidden
	 */
	public function setHidden( bool $hidden ): void {
		$this->flags->setHidden( $hidden );
	}

	/**
	 * @param bool $protected
	 */
	public function setProtected( bool $protected ): void {
		$this->flags->setProtected( $protected );
	}

	/**
	 * @param bool $global
	 */
	public function setGlobal( bool $global ): void {
		$this->flags->setGlobal( $global );
	}

	/**
	 * @note This also updates action names
	 * @param array[] $actions
	 */
	public function setActions( array $actions ): void {
		parent::setActions( $actions );
	}

	/**
	 * @param int $id
	 */
	public function setUserID( int $id ): void {
		$this->lastEditInfo->setUserID( $id );
	}

	/**
	 * @param string $name
	 */
	public function setUserName( string $name ): void {
		$this->lastEditInfo->setUserName( $name );
	}

	/**
	 * @param string $timestamp
	 */
	public function setTimestamp( string $timestamp ): void {
		$this->lastEditInfo->setTimestamp( $timestamp );
	}

	/**
	 * @param int|null $id
	 */
	public function setID( ?int $id ): void {
		$this->id = $id;
	}

	/**
	 * @param int $hitCount
	 */
	public function setHitCount( int $hitCount ): void {
		$this->hitCount = $hitCount;
	}

	/**
	 * @param bool $throttled
	 */
	public function setThrottled( bool $throttled ): void {
		$this->throttled = $throttled;
	}
}
PK       ! 1      Filter/LastEditInfo.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Filter;

/**
 * (Mutable) value object that holds information about the last edit to a filter.
 */
class LastEditInfo {
	/** @var int */
	private $userID;
	/** @var string */
	private $userName;
	/** @var string */
	private $timestamp;

	/**
	 * @param int $userID
	 * @param string $userName
	 * @param string $timestamp
	 */
	public function __construct( int $userID, string $userName, string $timestamp ) {
		$this->userID = $userID;
		$this->userName = $userName;
		$this->timestamp = $timestamp;
	}

	/**
	 * @return int
	 */
	public function getUserID(): int {
		return $this->userID;
	}

	/**
	 * @param int $id
	 */
	public function setUserID( int $id ): void {
		$this->userID = $id;
	}

	/**
	 * @return string
	 */
	public function getUserName(): string {
		return $this->userName;
	}

	/**
	 * @param string $name
	 */
	public function setUserName( string $name ): void {
		$this->userName = $name;
	}

	/**
	 * @return string
	 */
	public function getTimestamp(): string {
		return $this->timestamp;
	}

	/**
	 * @param string $timestamp
	 */
	public function setTimestamp( string $timestamp ): void {
		$this->timestamp = $timestamp;
	}
}
PK       ! 8D	  	    Filter/Flags.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Filter;

/**
 * (Mutable) value object to represent flags that can be *manually* set on a filter.
 */
class Flags {
	/** @var bool */
	private $enabled;
	/** @var bool */
	private $deleted;
	/** @var bool */
	private $hidden;
	/** @var bool */
	private $protected;
	/** @var int */
	private $privacyLevel;
	/** @var bool */
	private $global;

	public const FILTER_PUBLIC = 0b00;
	public const FILTER_HIDDEN = 0b01;
	public const FILTER_USES_PROTECTED_VARS = 0b10;

	/**
	 * @param bool $enabled
	 * @param bool $deleted
	 * @param int $privacyLevel
	 * @param bool $global
	 */
	public function __construct( bool $enabled, bool $deleted, int $privacyLevel, bool $global ) {
		$this->enabled = $enabled;
		$this->deleted = $deleted;
		$this->hidden = (bool)( self::FILTER_HIDDEN & $privacyLevel );
		$this->protected = (bool)( self::FILTER_USES_PROTECTED_VARS & $privacyLevel );
		$this->privacyLevel = $privacyLevel;
		$this->global = $global;
	}

	/**
	 * @return bool
	 */
	public function getEnabled(): bool {
		return $this->enabled;
	}

	/**
	 * @param bool $enabled
	 */
	public function setEnabled( bool $enabled ): void {
		$this->enabled = $enabled;
	}

	/**
	 * @return bool
	 */
	public function getDeleted(): bool {
		return $this->deleted;
	}

	/**
	 * @param bool $deleted
	 */
	public function setDeleted( bool $deleted ): void {
		$this->deleted = $deleted;
	}

	/**
	 * @return bool
	 */
	public function getHidden(): bool {
		return $this->hidden;
	}

	/**
	 * @param bool $hidden
	 */
	public function setHidden( bool $hidden ): void {
		$this->hidden = $hidden;
		$this->updatePrivacyLevel();
	}

	/**
	 * @return bool
	 */
	public function getProtected(): bool {
		return $this->protected;
	}

	/**
	 * @param bool $protected
	 */
	public function setProtected( bool $protected ): void {
		$this->protected = $protected;
		$this->updatePrivacyLevel();
	}

	private function updatePrivacyLevel() {
		$hidden = $this->hidden ? self::FILTER_HIDDEN : 0;
		$protected = $this->protected ? self::FILTER_USES_PROTECTED_VARS : 0;
		$this->privacyLevel = $hidden | $protected;
	}

	/**
	 * @return int
	 */
	public function getPrivacyLevel(): int {
		return $this->privacyLevel;
	}

	/**
	 * @return bool
	 */
	public function getGlobal(): bool {
		return $this->global;
	}

	/**
	 * @param bool $global
	 */
	public function setGlobal( bool $global ): void {
		$this->global = $global;
	}
}
PK       ! ^A  A    Filter/HistoryFilter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter\Filter;

/**
 * Variant of ExistingFilters for past versions of filters
 */
class HistoryFilter extends ExistingFilter {
	/** @var int */
	private $historyID;

	/**
	 * @param Specs $specs
	 * @param Flags $flags
	 * @param callable|array[] $actions Array with params or callable that will return them
	 * @phan-param array[]|callable():array[] $actions
	 * @param LastEditInfo $lastEditInfo
	 * @param int $id
	 * @param int $historyID
	 */
	public function __construct(
		Specs $specs,
		Flags $flags,
		$actions,
		LastEditInfo $lastEditInfo,
		int $id,
		int $historyID
	) {
		parent::__construct( $specs, $flags, $actions, $lastEditInfo, $id );
		$this->historyID = $historyID;
	}

	/**
	 * @return int
	 */
	public function getHistoryID(): int {
		return $this->historyID;
	}
}
PK       ! l      AbuseFilter.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

class AbuseFilter {

	/**
	 * @deprecated
	 * @todo Phase out
	 */
	public const HISTORY_MAPPINGS = [
		'af_pattern' => 'afh_pattern',
		'af_user' => 'afh_user',
		'af_user_text' => 'afh_user_text',
		'af_actor' => 'afh_actor',
		'af_timestamp' => 'afh_timestamp',
		'af_comments' => 'afh_comments',
		'af_public_comments' => 'afh_public_comments',
		'af_deleted' => 'afh_deleted',
		'af_id' => 'afh_filter',
		'af_group' => 'afh_group',
	];

}
PK       ! C  C    ProtectedVarsAccessLogger.phpnu Iw        <?php

namespace MediaWiki\Extension\AbuseFilter;

use ManualLogEntry;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;
use MediaWiki\User\ActorStore;
use MediaWiki\User\UserIdentity;
use Psr\Log\LoggerInterface;
use Wikimedia\Assert\Assert;
use Wikimedia\Rdbms\DBError;
use Wikimedia\Rdbms\IConnectionProvider;

/**
 * Defines the API for the component responsible for logging the following interactions:
 *
 * - A user enables protected variable viewing
 * - A user disables protected variable viewing
 */
class ProtectedVarsAccessLogger {
	/**
	 * Represents a user enabling their own access to view protected variables
	 *
	 * @var string
	 */
	public const ACTION_CHANGE_ACCESS_ENABLED = 'change-access-enable';

	/**
	 * Represents a user disabling their own access to view protected variables
	 *
	 * @var string
	 */
	public const ACTION_CHANGE_ACCESS_DISABLED = 'change-access-disable';

	/**
	 * Represents a user viewing the value of a protected variable
	 *
	 * @var string
	 */
	public const ACTION_VIEW_PROTECTED_VARIABLE_VALUE = 'view-protected-var-value';

	/**
	 * @var string
	 */
	public const LOG_TYPE = 'abusefilter-protected-vars';

	private LoggerInterface $logger;
	private IConnectionProvider $lbFactory;
	private ActorStore $actorStore;
	private int $delay;

	/**
	 * @param LoggerInterface $logger
	 * @param IConnectionProvider $lbFactory
	 * @param ActorStore $actorStore
	 * @param int $delay The number of seconds after which a duplicate log entry can be
	 *  created for a debounced log
	 */
	public function __construct(
		LoggerInterface $logger,
		IConnectionProvider $lbFactory,
		ActorStore $actorStore,
		int $delay
	) {
		Assert::parameter( $delay > 0, 'delay', 'delay must be positive' );

		$this->logger = $logger;
		$this->lbFactory = $lbFactory;
		$this->actorStore = $actorStore;
		$this->delay = $delay;
	}

	/**
	 * Log when the user enables their own access
	 *
	 * @param UserIdentity $performer
	 */
	public function logAccessEnabled( UserIdentity $performer ): void {
		$this->log( $performer, $performer->getName(), self::ACTION_CHANGE_ACCESS_ENABLED, false );
	}

	/**
	 * Log when the user disables their own access
	 *
	 * @param UserIdentity $performer
	 */
	public function logAccessDisabled( UserIdentity $performer ): void {
		$this->log( $performer, $performer->getName(), self::ACTION_CHANGE_ACCESS_DISABLED, false );
	}

	/**
	 * Log when the user views the values of protected variables
	 *
	 * @param UserIdentity $performer
	 * @param string $target
	 * @param int|null $timestamp
	 */
	public function logViewProtectedVariableValue(
		UserIdentity $performer,
		string $target,
		?int $timestamp = null
	): void {
		if ( !$timestamp ) {
			$timestamp = (int)wfTimestamp();
		}
		$this->log(
			$performer,
			$target,
			self::ACTION_VIEW_PROTECTED_VARIABLE_VALUE,
			true,
			$timestamp
		);
	}

	/**
	 * @param UserIdentity $performer
	 * @param string $target
	 * @param string $action
	 * @param bool $shouldDebounce
	 * @param int|null $timestamp
	 * @param array|null $params
	 */
	private function log(
		UserIdentity $performer,
		string $target,
		string $action,
		bool $shouldDebounce,
		?int $timestamp = null,
		?array $params = []
	): void {
		if ( !$timestamp ) {
			$timestamp = (int)wfTimestamp();
		}

		// Log to CheckUser's temporary accounts log if CU is installed
		if ( MediaWikiServices::getInstance()->getExtensionRegistry()->isLoaded( 'CheckUser' ) ) {
			// Add the extension name to the action so that CheckUser has a clearer
			// reference to the source in the message key
			$action = 'af-' . $action;

			$logger = MediaWikiServices::getInstance()
				->getService( 'CheckUserTemporaryAccountLoggerFactory' )
				->getLogger();
			$logger->logFromExternal(
				$performer,
				$target,
				$action,
				$params,
				$shouldDebounce,
				$timestamp
			);
		} else {
			$dbw = $this->lbFactory->getPrimaryDatabase();
			$shouldLog = false;

			// If the log is debounced, check against the logging table before logging
			if ( $shouldDebounce ) {
				$timestampMinusDelay = $timestamp - $this->delay;
				$actorId = $this->actorStore->findActorId( $performer, $dbw );
				if ( !$actorId ) {
					$shouldLog = true;
				} else {
					$logline = $dbw->newSelectQueryBuilder()
						->select( '*' )
						->from( 'logging' )
						->where( [
							'log_type' => self::LOG_TYPE,
							'log_action' => $action,
							'log_actor' => $actorId,
							'log_namespace' => NS_USER,
							'log_title' => $target,
							$dbw->expr( 'log_timestamp', '>', $dbw->timestamp( $timestampMinusDelay ) ),
						] )
						->caller( __METHOD__ )
						->fetchRow();

					if ( !$logline ) {
						$shouldLog = true;
					}
				}
			} else {
				// If the log isn't debounced then it should always be logged
				$shouldLog = true;
			}

			// Actually write to logging table
			if ( $shouldLog ) {
				$logEntry = $this->createManualLogEntry( $action );
				$logEntry->setPerformer( $performer );
				$logEntry->setTarget( Title::makeTitle( NS_USER, $target ) );
				$logEntry->setParameters( $params );
				$logEntry->setTimestamp( wfTimestamp( TS_MW, $timestamp ) );

				try {
					$logEntry->insert( $dbw );
				} catch ( DBError $e ) {
					$this->logger->critical(
						'AbuseFilter proctected variable log entry was not recorded. ' .
						'This means access to IPs can occur without being auditable. ' .
						'Immediate fix required.'
					);

					throw $e;
				}
			}
		}
	}

	/**
	 * @internal
	 *
	 * @param string $subtype
	 * @return ManualLogEntry
	 */
	protected function createManualLogEntry( string $subtype ): ManualLogEntry {
		return new ManualLogEntry( self::LOG_TYPE, $subtype );
	}
}
PK       ! y2  2    NoopGeoLocation.phpnu [        <?php

namespace CookieWarning;

class NoopGeoLocation implements GeoLocation {

	/**
	 * {@inheritdoc}
	 * @param string $ip The IP address to lookup
	 * @return bool|null NULL if no geolocation service configured, false on error, true otherwise.
	 */
	public function locate( $ip ) {
		return null;
	}
}
PK       ! K      HttpGeoLocation.phpnu [        <?php

namespace CookieWarning;

use InvalidArgumentException;
use MediaWiki\Http\HttpRequestFactory;
use Wikimedia\IPUtils;

/**
 * Implements the GeoLocation class, which allows to locate the user based on the IP address.
 */
class HttpGeoLocation implements GeoLocation {
	/** @var string */
	private $geoIPServiceURL;
	/** @var array */
	private $locatedIPs = [];

	/** @var HttpRequestFactory */
	private $httpRequestFactory;

	/**
	 * @param string $geoIPServiceURL
	 * @param HttpRequestFactory $httpRequestFactory
	 */
	public function __construct( $geoIPServiceURL, HttpRequestFactory $httpRequestFactory ) {
		if ( !is_string( $geoIPServiceURL ) || !$geoIPServiceURL ) {
			throw new InvalidArgumentException( 'The geoIPServiceUL is invalid' );
		}
		$this->geoIPServiceURL = $geoIPServiceURL;
		$this->httpRequestFactory = $httpRequestFactory;
	}

	/**
	 * {@inheritdoc}
	 * @param string $ip The IP address to lookup
	 * @return string|null
	 */
	public function locate( $ip ) {
		if ( isset( $this->locatedIPs[$ip] ) ) {
			return $this->locatedIPs[$ip];
		}
		if ( !IPUtils::isValid( $ip ) ) {
			throw new InvalidArgumentException( "$ip is not a valid IP address." );
		}
		if ( substr( $this->geoIPServiceURL, -1 ) !== '/' ) {
			$this->geoIPServiceURL .= '/';
		}
		$json = $this->httpRequestFactory->get( $this->geoIPServiceURL . $ip, [
			'timeout' => '2',
		] );
		if ( !$json ) {
			return null;
		}
		$returnObject = json_decode( $json );
		if ( $returnObject === null || !property_exists( $returnObject, 'country_code' ) ) {
			return null;
		}
		$this->locatedIPs[$ip] = $returnObject->country_code;

		return $this->locatedIPs[$ip];
	}
}
PK       ! \s/  /    GeoLocation.phpnu [        <?php

namespace CookieWarning;

interface GeoLocation {
	/**
	 * Tries to locate the given IP address.
	 *
	 * @param string $ip The IP address to lookup
	 * @return null|string NULL on error or if locating the IP was not possible, the country
	 * code otherwise
	 */
	public function locate( $ip );
}
PK       ! Ѣy      Decisions.phpnu [        <?php

namespace CookieWarning;

use Config;
use ConfigException;
use IContextSource;
use MediaWiki\User\UserOptionsLookup;
use MWException;
use WANObjectCache;

class Decisions {
	private Config $config;
	private GeoLocation $geoLocation;
	private WANObjectCache $cache;
	private UserOptionsLookup $userOptionsLookup;

	private const CACHE_KEY = 'cookieWarningIpLookupCache:';

	public function __construct(
		Config $config,
		GeoLocation $geoLocation,
		WANObjectCache $cache,
		UserOptionsLookup $userOptionsLookup
	) {
		$this->config = $config;
		$this->geoLocation = $geoLocation;
		$this->cache = $cache;
		$this->userOptionsLookup = $userOptionsLookup;
	}

	/**
	 * Checks, if the CookieWarning information bar should be visible to this user on
	 * this page.
	 *
	 * @param IContextSource $context
	 * @return bool Returns true, if the cookie warning should be visible, false otherwise.
	 * @throws ConfigException
	 * @throws MWException
	 */
	public function shouldShowCookieWarning( IContextSource $context ) {
		$user = $context->getUser();

		return $this->config->get( 'CookieWarningEnabled' ) &&
			!$this->userOptionsLookup->getBoolOption( $user, 'cookiewarning_dismissed' ) &&
			!$context->getRequest()->getCookie( 'cookiewarning_dismissed' ) &&
			( $this->config->get( 'CookieWarningGeoIPLookup' ) === 'js' ||
				$this->isInConfiguredRegion( $context ) );
	}

	/**
	 * Checks, if the user is in one of the configured regions.
	 *
	 * @param IContextSource $context
	 * @return bool
	 * @throws ConfigException
	 * @throws MWException
	 */
	private function isInConfiguredRegion( IContextSource $context ) {
		if ( !$this->config->get( 'CookieWarningForCountryCodes' ) ||
			$this->config->get( 'CookieWarningGeoIPLookup' ) === 'none' ) {
			wfDebugLog( 'CookieWarning', 'IP geolocation not configured, skipping.' );

			return true;
		}

		$countryCode = $this->getCountryCodeFromIP( $context->getRequest()->getIP() );

		return $countryCode === '' || array_key_exists( $countryCode,
			$this->config->get( 'CookieWarningForCountryCodes' ) );
	}

	/**
	 * @return bool
	 * @throws ConfigException
	 */
	public function shouldAddResourceLoaderComponents() {
		return $this->config->get( 'CookieWarningGeoIPLookup' ) === 'js' &&
			is_array( $this->config->get( 'CookieWarningForCountryCodes' ) );
	}

	/**
	 * @param string $currentIP
	 * @return string The country code associated with the IP or empty string if not able to locate.
	 * @throws ConfigException
	 */
	private function getCountryCodeFromIP( $currentIP ) {
		$cacheKey = $this->cache->makeGlobalKey( __CLASS__, self::CACHE_KEY . $currentIP );
		$lookedUpCountryCode = $this->cache->get( $cacheKey );

		if ( is_string( $lookedUpCountryCode ) ) {
			return $lookedUpCountryCode;
		}

		wfDebugLog( 'CookieWarning', 'Try to locate the user\'s IP address.' );
		$location = $this->geoLocation->locate( $currentIP );
		if ( $location === null ) {
			wfDebugLog( 'CookieWarning',
				'Locating the user\'s IP address failed or is misconfigured.' );

			return '';
		}

		$this->cache->set( $cacheKey, $location );

		wfDebugLog( 'CookieWarning',
			'Locating the user was successful, located region: ' . $location );

		return $location;
	}
}
PK       ! TJM  M    SpecialContact.phpnu [        <?php
/**
 * Speclial:Contact, a contact form for visitors.
 * Based on SpecialEmailUser.php
 *
 * @file
 * @ingroup SpecialPage
 * @author Daniel Kinzler, brightbyte.de
 * @copyright © 2007-2014 Daniel Kinzler, Sam Reed
 * @license GPL-2.0-or-later
 */

namespace MediaWiki\Extension\ContactPage;

use ErrorPageError;
use ExtensionRegistry;
use HTMLForm;
use MailAddress;
use MediaWiki\Extension\ConfirmEdit\Hooks as ConfirmEditHooks;
use MediaWiki\Extension\ContactPage\Hooks\HookRunner;
use MediaWiki\Html\Html;
use MediaWiki\Parser\Sanitizer;
use MediaWiki\Session\SessionManager;
use MediaWiki\SpecialPage\UnlistedSpecialPage;
use MediaWiki\Status\Status;
use MediaWiki\User\Options\UserOptionsLookup;
use MediaWiki\User\User;
use UserBlockedError;
use UserMailer;

/**
 * Provides the contact form
 * @ingroup SpecialPage
 */
class SpecialContact extends UnlistedSpecialPage {
	/** @var UserOptionsLookup */
	private $userOptionsLookup;
	/** @var HookRunner|null */
	private $contactPageHookRunner;

	/**
	 * @param UserOptionsLookup $userOptionsLookup
	 */
	public function __construct( UserOptionsLookup $userOptionsLookup ) {
		parent::__construct( 'Contact' );
		$this->userOptionsLookup = $userOptionsLookup;
	}

	/**
	 * @inheritDoc
	 */
	public function getDescription() {
		return $this->msg( 'contactpage' );
	}

	/**
	 * @var string
	 */
	protected $formType;

	/**
	 * @return array
	 */
	protected function getTypeConfig() {
		$contactConfig = $this->getConfig()->get( 'ContactConfig' );

		if ( $contactConfig['default']['SenderName'] === null ) {
			$sitename = $this->getConfig()->get( 'Sitename' );
			$contactConfig['default']['SenderName'] = "Contact Form on $sitename";
		}

		if ( isset( $contactConfig[$this->formType] ) ) {
			return $contactConfig[$this->formType] + $contactConfig['default'];
		}
		return $contactConfig['default'];
	}

	/**
	 * Helper function for ::execute that returns a form
	 * specific message key if it is is not disabled.
	 * Otherwise returns the generic message key.
	 * Used to make it possible for forms to have
	 * form-specific messages.
	 *
	 * @param string $genericMessageKey The message key that will be used if no form-specific one can be used
	 * @return string
	 */
	protected function getFormSpecificMessageKey( string $genericMessageKey ) {
		$formSpecificMessageKey = $genericMessageKey . '-' . $this->formType;
		if ( !str_starts_with( $genericMessageKey, 'contactpage' ) ) {
			// If the generic message does not start with "contactpage" the form
			//  specific one will have "contactpage-" prefixed on the generic message
			//  name.
			$formSpecificMessageKey = 'contactpage-' . $formSpecificMessageKey;
		}
		if ( $this->formType && !$this->msg( $formSpecificMessageKey )->isDisabled() ) {
			// Return the form-specific message if the form type is not the empty string
			//  and the message is defined.
			return $formSpecificMessageKey;
		}
		return $genericMessageKey;
	}

	/**
	 * Main execution function
	 *
	 * @param string|null $par Parameters passed to the page
	 * @throws UserBlockedError
	 * @throws ErrorPageError
	 */
	public function execute( $par ) {
		if ( !$this->getConfig()->get( 'EnableEmail' ) ) {
			// From Special:EmailUser
			throw new ErrorPageError( 'usermaildisabled', 'usermaildisabledtext' );
		}

		$request = $this->getRequest();
		$this->formType = strtolower( $request->getText( 'formtype', $par ?? '' ) );

		$config = $this->getTypeConfig();
		$user = $this->getUser();

		// Display error if user not logged in when config requires it
		$requiresConfirmedEmail = $config['MustHaveEmail'] ?? false;
		$requiresLogin = $config['MustBeLoggedIn'] ?? false;
		if ( $requiresLogin ) {
			$this->requireNamedUser( 'contactpage-mustbeloggedin' );
		} elseif ( $requiresConfirmedEmail ) {
			// MustHaveEmail must not be set without setting MustBeLoggedIn, as
			// anon and temporary users do not have email addresses.
			$this->getOutput()->showErrorPage( 'contactpage-config-error-title',
				'contactpage-config-error' );
			return;
		}

		// Display error if sender has no confirmed email when config requires it
		if ( $requiresConfirmedEmail && !$user->isEmailConfirmed() ) {
			$this->getOutput()->showErrorPage(
				'contactpage-musthaveemail-error-title',
				'contactpage-musthaveemail-error'
			);
			return;
		}

		// Display error if no recipient user specified in configuration
		if ( !$config['RecipientUser'] ) {
			$this->getOutput()->showErrorPage( 'contactpage-config-error-title',
				'contactpage-config-error' );
			return;
		}

		// Display error if recipient has email disabled
		$recipient = User::newFromName( $config['RecipientUser'] );
		if ( $recipient === null || !$recipient->canReceiveEmail() ) {
			$this->getOutput()->showErrorPage( 'noemailtitle', 'noemailtext' );
			return;
		}

		// Blocked users cannot use the contact form if they're disabled from sending email.
		if ( $user->isBlockedFromEmailuser() ) {
			$useCustomBlockMessage = $config['UseCustomBlockMessage'] ?? false;
			if ( $useCustomBlockMessage ) {
				$this->getOutput()->showErrorPage( $this->getFormSpecificMessageKey( 'contactpage-title' ),
					$this->getFormSpecificMessageKey( 'contactpage-blocked-message' ) );
				return;
			} else {
				// If the user is blocked from emailing users then there is a block
				// @phan-suppress-next-line PhanTypeMismatchArgumentNullable
				throw new UserBlockedError( $this->getUser()->getBlock() );
			}
		}

		$this->getOutput()->setPageTitleMsg(
			$this->msg( $this->getFormSpecificMessageKey( 'contactpage-title' ) )
		);

		# Check for type in [[Special:Contact/type]]: change pagetext and prefill form fields
		$formText = $this->msg(
			$this->getFormSpecificMessageKey( 'contactpage-pagetext' )
		)->parseAsBlock();
		$formSpecificSubjectMessageKey = $this->msg( [
			'contactpage-defsubject-' . $this->formType,
			'contactpage-subject-' . $this->formType
		] );
		if ( $this->formType != '' && !$formSpecificSubjectMessageKey->isDisabled() ) {
			$subject = trim( $formSpecificSubjectMessageKey->inContentLanguage()->plain() );
		} else {
			$subject = $this->msg( 'contactpage-defsubject' )->inContentLanguage()->text();
		}

		$fromAddress = '';
		$fromName = '';
		$nameReadonly = false;
		$emailReadonly = false;
		$subjectReadonly = $config['SubjectReadonly'] ?? false;
		if ( $user->isNamed() ) {
			// Use real name if set
			$realName = $user->getRealName();
			if ( $realName ) {
				$fromName = $realName;
			} else {
				$fromName = $user->getName();
			}
			$fromAddress = $user->getEmail();
			$nameReadonly = $config['NameReadonly'] ?? false;
			$emailReadonly = $config['EmailReadonly'] ?? false;
		}

		// Show error if the following are true as they are in combination invalid configuration:
		// * The form doesn't require logging in
		// * The form requires details
		// * The email form is read only.
		// This is because the email field will be empty for anon and temp users and must be filled
		// for the form to be valid, but cannot be modified by the client.
		if ( !$requiresLogin && $emailReadonly && $config['RequireDetails'] ) {
			$this->getOutput()->showErrorPage( 'contactpage-config-error-title',
				'contactpage-config-error' );
			return;
		}

		$additional = $config['AdditionalFields'] ?? [];

		$formItems = [
			'FromName' => [
				'label-message' => $this->getFormSpecificMessageKey( 'contactpage-fromname' ),
				'type' => 'text',
				'required' => $config['RequireDetails'],
				'default' => $fromName,
				'disabled' => $nameReadonly,
			],
			'FromAddress' => [
				'label-message' => $this->getFormSpecificMessageKey( 'contactpage-fromaddress' ),
				'type' => 'email',
				'required' => $config['RequireDetails'],
				'default' => $fromAddress,
				'disabled' => $emailReadonly,
			]
		];

		if ( !$config['RequireDetails'] ) {
			$formItems['FromInfo'] = [
				'label' => '',
				'type' => 'info',
				'default' => Html::rawElement( 'small', [],
					$this->msg(
						$this->getFormSpecificMessageKey( 'contactpage-formfootnotes' )
					)->escaped()
				),
				'raw' => true,
			];
		}

		$formItems += [
			'Subject' => [
				'label-message' => $this->getFormSpecificMessageKey( 'emailsubject' ),
				'type' => 'text',
				'default' => $subject,
				'disabled' => $subjectReadonly,
			],
		] + $additional + [
			'CCme' => [
				'label-message' => $this->getFormSpecificMessageKey( 'emailccme' ),
				'type' => 'check',
				'default' => $this->userOptionsLookup->getBoolOption( $this->getUser(), 'ccmeonemails' ),
			],
			'FormType' => [
				'class' => 'HTMLHiddenField',
				'label' => 'Type',
				'default' => $this->formType,
			]
		];

		if ( $config['IncludeIP'] && $user->isRegistered() ) {
			$formItems['IncludeIP'] = [
				'label-message' => $this->getFormSpecificMessageKey( 'contactpage-includeip' ),
				'type' => 'check',
			];
		}

		if ( $this->useCaptcha() ) {
			$formItems['Captcha'] = [
				'label-message' => 'captcha-label',
				'type' => 'info',
				'default' => $this->getCaptcha(),
				'raw' => true,
			];
		}

		$form = HTMLForm::factory( 'ooui',
			$formItems, $this->getContext(), "contactpage-{$this->formType}"
		);
		$form->setWrapperLegendMsg( 'contactpage-legend' );
		$form->setSubmitTextMsg( $this->getFormSpecificMessageKey( 'emailsend' ) );
		if ( $this->formType != '' ) {
			$form->setId( "contactpage-{$this->formType}" );

			$msg = $this->msg( "contactpage-legend-{$this->formType}" );
			if ( !$msg->isDisabled() ) {
				$form->setWrapperLegendMsg( $msg );
			}

			$msg = $this->msg( "contactpage-emailsend-{$this->formType}" );
			if ( !$msg->isDisabled() ) {
				$form->setSubmitTextMsg( $msg );
			}
		}
		$form->setSubmitCallback( [ $this, 'processInput' ] );
		$form->loadData();

		// Stolen from Special:EmailUser
		if ( !$this->getContactPageHookRunner()->onEmailUserForm( $form ) ) {
			return;
		}

		$result = $form->show();

		if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
			$output = $this->getOutput();
			$output->setPageTitleMsg( $this->msg( $this->getFormSpecificMessageKey( 'emailsent' ) ) );
			$output->addWikiMsg(
				$this->getFormSpecificMessageKey( 'emailsenttext' ),
				$recipient
			);

			$output->returnToMain( false );
		} else {
			if ( $config['RLStyleModules'] ) {
				$this->getOutput()->addModuleStyles( $config['RLStyleModules'] );
			}
			if ( $config['RLModules'] ) {
				$this->getOutput()->addModules( $config['RLModules'] );
			}
			$this->getOutput()->prependHTML( trim( $formText ) );
		}
	}

	/**
	 * @param array $formData
	 * @return bool|string|array|Status
	 *     - Bool true or a good Status object indicates success,
	 *     - Bool false indicates no submission was attempted,
	 *     - Anything else indicates failure. The value may be a fatal Status
	 *       object, an HTML string, or an array of arrays (message keys and
	 *       params) or strings (message keys)
	 */
	public function processInput( $formData ) {
		$config = $this->getTypeConfig();

		$request = $this->getRequest();
		$user = $this->getUser();

		if ( $this->useCaptcha() &&
			!$this->getConfig()->get( 'Captcha' )->passCaptchaFromRequest( $request, $user )
		) {
			return [ 'contactpage-captcha-error' ];
		}

		$senderIP = $request->getIP();

		// Setup user that is going to receive the contact page response
		$contactRecipientUser = User::newFromName( $config['RecipientUser'] );
		$contactRecipientAddress = MailAddress::newFromUser( $contactRecipientUser );

		// Used when user hasn't set an email, when $wgUserEmailUseReplyTo is true,
		// or when sending CC email to user
		$siteAddress = new MailAddress(
			$config['SenderEmail'] ?: $this->getConfig()->get( 'PasswordSender' ),
			$config['SenderName']
		);

		// Initialize the sender to the site address
		$senderAddress = $siteAddress;

		$fromAddress = $formData['FromAddress'];
		$fromName = $formData['FromName'];

		$fromUserAddress = null;
		$replyTo = null;

		if ( $fromAddress ) {
			// T232199 - If the email address is invalid, bail out.
			// Don't allow it to fallback to basically @server.host.name
			if ( !Sanitizer::validateEmail( $fromAddress ) ) {
				return [ 'invalidemailaddress' ];
			}

			// Use user submitted details
			$fromUserAddress = new MailAddress( $fromAddress, $fromName );

			if ( $this->getConfig()->get( 'UserEmailUseReplyTo' ) ) {
				// Define reply-to address
				$replyTo = $fromUserAddress;
			} else {
				// Not using ReplyTo, so set the sender to $fromUserAddress
				$senderAddress = $fromUserAddress;
			}
		}

		$includeIP = isset( $config['IncludeIP'] ) && $config['IncludeIP']
			&& ( $user->isAnon() || $formData['IncludeIP'] );
		$subject = $formData['Subject'];

		if ( $fromName !== '' ) {
			if ( $includeIP ) {
				$subject = $this->msg(
					'contactpage-subject-and-sender-withip',
					$subject,
					$fromName,
					$senderIP
				)->inContentLanguage()->text();
			} else {
				$subject = $this->msg(
					'contactpage-subject-and-sender',
					$subject,
					$fromName
				)->inContentLanguage()->text();
			}
		} elseif ( $fromAddress !== '' ) {
			if ( $includeIP ) {
				$subject = $this->msg(
					'contactpage-subject-and-sender-withip',
					$subject,
					$fromAddress,
					$senderIP
				)->inContentLanguage()->text();
			} else {
				$subject = $this->msg(
					'contactpage-subject-and-sender',
					$subject,
					$fromAddress
				)->inContentLanguage()->text();
			}
		} elseif ( $includeIP ) {
			$subject = $this->msg(
				'contactpage-subject-and-sender',
				$subject,
				$senderIP
			)->inContentLanguage()->text();
		}

		$text = '';
		foreach ( $config['AdditionalFields'] as $name => $field ) {
			$class = HTMLForm::getClassFromDescriptor( $name, $field );

			$value = '';
			// TODO: Support selectandother/HTMLSelectAndOtherField
			// options, options-messages and options-message
			if ( isset( $field['options-messages'] ) ) {
				// Multiple values!
				if ( is_string( $formData[$name] ) ) {
					$optionValues = array_flip( $field['options-messages'] );
					if ( isset( $optionValues[$formData[$name]] ) ) {
						$value = $this->msg( $optionValues[$formData[$name]] )->inContentLanguage()->text();
					} else {
						$value = $formData[$name];
					}
				} elseif ( count( $formData[$name] ) ) {
					$formValues = array_flip( $formData[$name] );
					$value .= "\n";
					foreach ( $field['options-messages'] as $msg => $optionValue ) {
						$msg = $this->msg( $msg )->inContentLanguage()->text();
						$optionValue = $this->getYesOrNoMsg( isset( $formValues[$optionValue] ) );
						$value .= "\t$msg: $optionValue\n";
					}
				}
			} elseif ( isset( $field['options'] ) ) {
				if ( is_string( $formData[$name] ) ) {
					$value = $formData[$name];
				} elseif ( count( $formData[$name] ) ) {
					$formValues = array_flip( $formData[$name] );
					$value .= "\n";
					foreach ( $field['options'] as $msg => $optionValue ) {
						$optionValue = $this->getYesOrNoMsg( isset( $formValues[$optionValue] ) );
						$value .= "\t$msg: $optionValue\n";
					}
				}
			} elseif ( $class === 'HTMLCheckField' ) {
				$value = $this->getYesOrNoMsg( $formData[$name] xor
					( isset( $field['invert'] ) && $field['invert'] ) );
			} elseif ( isset( $formData[$name] ) ) {
				// HTMLTextField, HTMLTextAreaField
				// HTMLFloatField, HTMLIntField

				// Just dump the value if its wordy
				$value = $formData[$name];
			} else {
				continue;
			}

			if ( isset( $field['contactpage-email-label'] ) ) {
				$name = $field['contactpage-email-label'];
			} elseif ( isset( $field['label-message'] ) ) {
				$name = $this->msg( $field['label-message'] )->inContentLanguage()->text();
			} else {
				$name = $field['label'];
			}

			$text .= "{$name}: $value\n";
		}

		$hookRunner = $this->getContactPageHookRunner();
		if ( !$hookRunner->onContactForm( $contactRecipientAddress, $replyTo, $subject,
			$text, $this->formType, $formData )
		) {
			// TODO: Need to do some proper error handling here
			return false;
		}

		wfDebug( __METHOD__ . ': sending mail from ' . $senderAddress->toString() .
			' to ' . $contactRecipientAddress->toString() .
			' replyto ' . ( $replyTo == null ? '-/-' : $replyTo->toString() ) . "\n"
		);
		// @phan-suppress-next-line SecurityCheck-XSS UserMailer::send defaults to text/plain if passed a string
		$mailResult = UserMailer::send(
			$contactRecipientAddress,
			$senderAddress,
			$subject,
			$text,
			[ 'replyTo' => $replyTo ]
		);

		$language = $this->getLanguage();
		if ( !$mailResult->isOK() ) {
			wfDebug( __METHOD__ . ': got error from UserMailer: ' .
				$mailResult->getMessage( false, false, 'en' )->text() . "\n" );
			return [ $mailResult->getMessage( 'contactpage-usermailererror', false, $language ) ];
		}

		// if the user requested a copy of this mail, do this now,
		// unless they are emailing themselves, in which case one copy of the message is sufficient.
		if ( $formData['CCme'] && $fromUserAddress ) {
			$cc_subject = $this->msg( 'emailccsubject', $contactRecipientUser->getName(), $subject )->text();
			if ( $hookRunner->onContactForm(
				$fromUserAddress, $senderAddress, $cc_subject, $text, $this->formType, $formData )
			) {
				wfDebug( __METHOD__ . ': sending cc mail from ' . $senderAddress->toString() .
					' to ' . $fromUserAddress->toString() . "\n"
				);
				// @phan-suppress-next-line SecurityCheck-XSS UserMailer::send defaults to text/plain if passed a string
				$ccResult = UserMailer::send(
					$fromUserAddress,
					$senderAddress,
					$cc_subject,
					$text,
				);
				if ( !$ccResult->isOK() ) {
					// At this stage, the user's CC mail has failed, but their
					// original mail has succeeded. It's unlikely, but still, what to do?
					// We can either show them an error, or we can say everything was fine,
					// or we can say we sort of failed AND sort of succeeded. Of these options,
					// simply saying there was an error is probably best.
					return [ $ccResult->getMessage( 'contactpage-usermailererror', false, $language ) ];
				}
			}
		}

		$hookRunner->onContactFromComplete( $contactRecipientAddress, $replyTo, $subject, $text );

		return true;
	}

	/**
	 * @param bool $value
	 * @return string
	 */
	private function getYesOrNoMsg( $value ) {
		return $this->msg( $value ? 'htmlform-yes' : 'htmlform-no' )->inContentLanguage()->text();
	}

	/**
	 * @return bool True if CAPTCHA should be used, false otherwise
	 */
	private function useCaptcha() {
		$extRegistry = ExtensionRegistry::getInstance();
		if ( !$extRegistry->isLoaded( 'ConfirmEdit' ) ) {
			 return false;
		}
		$config = $this->getConfig();
		$captchaTriggers = $config->get( 'CaptchaTriggers' );

		return $config->get( 'CaptchaClass' )
			&& isset( $captchaTriggers['contactpage'] )
			&& $captchaTriggers['contactpage']
			&& !$this->getUser()->isAllowed( 'skipcaptcha' );
	}

	/**
	 * @return string CAPTCHA form HTML
	 */
	private function getCaptcha() {
		// NOTE: make sure we have a session. May be required for CAPTCHAs to work.
		SessionManager::getGlobalSession()->persist();

		$captcha = ConfirmEditHooks::getInstance();
		$captcha->setTrigger( 'contactpage' );
		$captcha->setAction( 'contact' );

		$formInformation = $captcha->getFormInformation();
		$formMetainfo = $formInformation;
		unset( $formMetainfo['html'] );
		$captcha->addFormInformationToOutput( $this->getOutput(), $formMetainfo );

		return '<div class="captcha">' .
			$formInformation['html'] .
			"</div>\n";
	}

	/**
	 * @return HookRunner
	 */
	private function getContactPageHookRunner() {
		if ( !$this->contactPageHookRunner ) {
			$this->contactPageHookRunner = new HookRunner( $this->getHookContainer() );
		}
		return $this->contactPageHookRunner;
	}
}
PK       !       Hooks/ContactFormHook.phpnu [        <?php

namespace MediaWiki\Extension\ContactPage\Hooks;

use MailAddress;

/**
 * This is a hook handler interface, see docs/Hooks.md in core.
 * Use the hook name "ContactForm" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface ContactFormHook {
	/**
	 * @param MailAddress &$contactRecipientAddress
	 * @param MailAddress|null &$replyTo
	 * @param string &$subject
	 * @param string &$text
	 * @param string $formType
	 * @param array $formData
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onContactForm(
		&$contactRecipientAddress,
		&$replyTo,
		&$subject,
		&$text,
		$formType,
		$formData
	);
}
PK       ! Tv l  l    Hooks/HookRunner.phpnu [        <?php

namespace MediaWiki\Extension\ContactPage\Hooks;

use MediaWiki\HookContainer\HookContainer;

/**
 * This is a hook runner class, see docs/Hooks.md in core.
 * @internal
 */
class HookRunner implements
	ContactFormHook,
	ContactFromCompleteHook,
	\MediaWiki\Hook\EmailUserFormHook
{
	private HookContainer $hookContainer;

	public function __construct( HookContainer $hookContainer ) {
		$this->hookContainer = $hookContainer;
	}

	/**
	 * @inheritDoc
	 */
	public function onContactForm(
		&$contactRecipientAddress, &$replyTo, &$subject, &$text, $formType, $formData
	) {
		return $this->hookContainer->run(
			'ContactForm',
			[ &$contactRecipientAddress, &$replyTo, &$subject, &$text, $formType, $formData ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onContactFromComplete( $contactRecipientAddress, $replyTo, $subject, $text ) {
		return $this->hookContainer->run(
			'ContactFromComplete',
			[ $contactRecipientAddress, $replyTo, $subject, $text ]
		);
	}

	/**
	 * @inheritDoc
	 */
	public function onEmailUserForm( &$form ) {
		return $this->hookContainer->run(
			'EmailUserForm',
			[ &$form ]
		);
	}
}
PK       ! $;o;    !  Hooks/ContactFromCompleteHook.phpnu [        <?php

namespace MediaWiki\Extension\ContactPage\Hooks;

use MailAddress;

/**
 * This is a hook handler interface, see docs/Hooks.md in core.
 * Use the hook name "ContactFromComplete" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface ContactFromCompleteHook {
	/**
	 * @param MailAddress $contactRecipientAddress
	 * @param MailAddress|null $replyTo
	 * @param string $subject
	 * @param string $text
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onContactFromComplete(
		$contactRecipientAddress,
		$replyTo,
		$subject,
		$text
	);
}
PK       ! o ŀ    "  specials/SpecialRandomPageTest.phpnu Iw        <?php

use MediaWiki\Specials\SpecialRandomPage;

/**
 * @covers \MediaWiki\Specials\SpecialRandomPage
 */
class SpecialRandomPageTest extends MediaWikiIntegrationTestCase {

	/** @var SpecialRandomPage */
	private $page;

	public function setUp(): void {
		$services = $this->getServiceContainer();
		$this->page = new SpecialRandomPage(
			$services->getConnectionProvider(),
			$services->getNamespaceInfo()
		);
		parent::setUp();
	}

	/**
	 * @dataProvider providerParsePar
	 * @param string $par
	 * @param array $expectedNS Array of integer namespace ids
	 */
	public function testParsePar( $par, $expectedNS ) {
		$reflectionClass = new ReflectionClass( $this->page );
		$reflectionMethod = $reflectionClass->getMethod( 'parsePar' );
		$reflectionMethod->setAccessible( true );

		$reflectionMethod->invoke( $this->page, $par );
		$this->assertEquals( $expectedNS, $this->page->getNamespaces() );
	}

	public function providerParsePar() {
		global $wgContentNamespaces;

		return [
			[ null, $wgContentNamespaces ],
			[ '', [ NS_MAIN ] ],
			[ 'main', [ NS_MAIN ] ],
			[ 'article', [ NS_MAIN ] ],
			[ '0', [ NS_MAIN ] ],
			[ 'File', [ NS_FILE ] ],
			[ 'file_talk', [ NS_FILE_TALK ] ],
			[ 'hElP', [ NS_HELP ] ],
			[ 'Project,', [ NS_PROJECT, NS_MAIN ] ],
			[ 'Project,Help,User_talk', [ NS_PROJECT, NS_HELP, NS_USER_TALK ] ],
			[ '|invalid|,|namespaces|', $wgContentNamespaces ],
		];
	}
}
PK       ! d$  d$  &  Html/TemplateParserIntegrationTest.phpnu Iw        <?php

use MediaWiki\Html\TemplateParser;
use MediaWiki\MainConfigNames;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\ObjectCache\EmptyBagOStuff;

/**
 * @group Templates
 * @covers \MediaWiki\Html\TemplateParser
 */
class TemplateParserIntegrationTest extends MediaWikiIntegrationTestCase {

	private const NAME = 'foobar';
	private const RESULT = "hello world!\n";
	private const DIR = __DIR__ . '/../../../data/templates';
	private const SECRET_KEY = 'foo';

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

		$this->overrideConfigValue( MainConfigNames::SecretKey, self::SECRET_KEY );
	}

	public function testGetTemplateNeverCacheWithoutSecretKey() {
		$this->overrideConfigValue( MainConfigNames::SecretKey, false );

		// Expect no cache interaction
		$cache = $this->createMock( BagOStuff::class );
		$cache->expects( $this->never() )->method( 'get' );
		$cache->expects( $this->never() )->method( 'set' );

		$tp = new TemplateParser( self::DIR, $cache );
		$this->assertEquals( self::RESULT, $tp->processTemplate( self::NAME, [] ) );
	}

	public function testGetTemplateCachesCompilationResult() {
		$store = null;

		// 1. Expect a cache miss, compile, and cache set
		$cache1 = $this->createMock( BagOStuff::class );
		$cache1->expects( $this->once() )->method( 'get' )->willReturn( false );
		$cache1->expects( $this->once() )->method( 'set' )
			->willReturnCallback( static function ( $key, $val ) use ( &$store ) {
				$store = [ 'key' => $key, 'val' => $val ];
			} );

		$tp1 = new TemplateParser( self::DIR, $cache1 );
		$this->assertEquals( self::RESULT, $tp1->processTemplate( self::NAME, [] ) );

		// Inspect cache
		$this->assertEquals(
			[
				'phpCode',
				'files',
				'filesHash',
				'integrityHash',
			],
			array_keys( $store['val'] ),
			'keys of the cached array'
		);
		$this->assertEquals(
			FileContentsHasher::getFileContentsHash( [
				self::DIR . '/' . self::NAME . '.mustache'
			] ),
			$store['val']['filesHash'],
			'content hash for the compiled template'
		);
		$this->assertEquals(
			hash_hmac( 'sha256', $store['val']['phpCode'], self::SECRET_KEY ),
			$store['val']['integrityHash'],
			'integrity hash for the compiled template'
		);

		// 2. Expect a cache hit that passes validation checks, and no compilation
		$cache2 = $this->createMock( BagOStuff::class );
		$cache2->expects( $this->once() )->method( 'get' )
			->willReturnCallback( static function ( $key ) use ( &$store ) {
				return $key === $store['key'] ? $store['val'] : false;
			} );
		$cache2->expects( $this->never() )->method( 'set' );

		$tp2 = $this->getMockBuilder( TemplateParser::class )
			->setConstructorArgs( [ self::DIR, $cache2 ] )
			->onlyMethods( [ 'compile' ] )
			->getMock();
		$tp2->expects( $this->never() )->method( 'compile' );

		$this->assertEquals( self::RESULT, $tp2->processTemplate( self::NAME, [] ) );
	}

	public function testGetTemplateInvalidatesCacheWhenFilesHashIsInvalid() {
		$store = null;

		// 1. Expect a cache miss, compile, and cache set
		$cache1 = $this->createMock( BagOStuff::class );
		$cache1->expects( $this->once() )->method( 'get' )->willReturn( false );
		$cache1->expects( $this->once() )->method( 'set' )
			->willReturnCallback( static function ( $key, $val ) use ( &$store ) {
				$store = [ 'key' => $key, 'val' => $val ];
			} );

		$tp1 = new TemplateParser( self::DIR, $cache1 );
		$this->assertEquals( self::RESULT, $tp1->processTemplate( self::NAME, [] ) );

		// Invalidate file hash
		$store['val']['filesHash'] = 'baz';

		// 2. Expect a cache hit that fails validation, and a re-compilation
		$cache2 = $this->createMock( BagOStuff::class );
		$cache2->expects( $this->once() )->method( 'get' )
			->willReturnCallback( static function ( $key ) use ( &$store ) {
				return $key === $store['key'] ? $store['val'] : false;
			} );
		$cache2->expects( $this->once() )->method( 'set' );

		$tp2 = $this->getMockBuilder( TemplateParser::class )
			->setConstructorArgs( [ self::DIR, $cache2 ] )
			->onlyMethods( [ 'compile' ] )
			->getMock();
		$tp2->expects( $this->once() )->method( 'compile' )
			->willReturn( $store['val'] );

		$this->assertEquals( self::RESULT, $tp2->processTemplate( self::NAME, [] ) );
	}

	public function testGetTemplateInvalidatesCacheWhenIntegrityHashIsInvalid() {
		$store = null;

		// 1. Cache miss, expect a compile and cache set
		$cache1 = $this->createMock( BagOStuff::class );
		$cache1->expects( $this->once() )->method( 'get' )->willReturn( false );
		$cache1->expects( $this->once() )->method( 'set' )
			->willReturnCallback( static function ( $key, $val ) use ( &$store ) {
				$store = [ 'key' => $key, 'val' => $val ];
			} );

		$tp1 = new TemplateParser( self::DIR, $cache1 );
		$this->assertEquals( self::RESULT, $tp1->processTemplate( self::NAME, [] ) );

		// Invalidate integrity hash
		$store['val']['integrityHash'] = 'foo';

		// 2. Expect a cache hit that fails validation, and a re-compilation
		$cache2 = $this->createMock( BagOStuff::class );
		$cache2->expects( $this->once() )->method( 'get' )
			->willReturnCallback( static function ( $key ) use ( &$store ) {
				return $key === $store['key'] ? $store['val'] : false;
			} );
		$cache2->expects( $this->once() )->method( 'set' );

		$tp2 = $this->getMockBuilder( TemplateParser::class )
			->setConstructorArgs( [ self::DIR, $cache2 ] )
			->onlyMethods( [ 'compile' ] )
			->getMock();
		$tp2->expects( $this->once() )->method( 'compile' )
			->willReturn( $store['val'] );

		$this->assertEquals( self::RESULT, $tp2->processTemplate( self::NAME, [] ) );
	}

	/**
	 * @dataProvider provideProcessTemplate
	 */
	public function testProcessTemplate( $name, $args, $result, $exception = false ) {
		$tp = new TemplateParser( self::DIR, new EmptyBagOStuff );
		if ( $exception ) {
			$this->expectException( $exception );
		}
		$this->assertEquals( $result, $tp->processTemplate( $name, $args ) );
	}

	public static function provideProcessTemplate() {
		return [
			[
				'foobar',
				[],
				"hello world!\n"
			],
			[
				'foobar_args',
				[
					'planet' => 'world',
				],
				self::RESULT,
			],
			[
				'../foobar',
				[],
				false,
				UnexpectedValueException::class
			],
			[
				"\000../foobar",
				[],
				false,
				UnexpectedValueException::class
			],
			[
				'/',
				[],
				false,
				UnexpectedValueException::class
			],
			[
				// Allegedly this can strip ext in windows.
				'baz<',
				[],
				false,
				UnexpectedValueException::class
			],
			[
				'\\foo',
				[],
				false,
				UnexpectedValueException::class
			],
			[
				'C:\bar',
				[],
				false,
				UnexpectedValueException::class
			],
			[
				"foo\000bar",
				[],
				false,
				UnexpectedValueException::class
			],
			[
				'nonexistenttemplate',
				[],
				false,
				RuntimeException::class,
			],
			[
				'has_partial',
				[
					'planet' => 'world',
				],
				"Partial hello world!\n in here\n",
			],
			[
				'bad_partial',
				[],
				false,
				Exception::class,
			],
			[
				'invalid_syntax',
				[],
				false,
				Exception::class
			],
			[
				'parentvars',
				[
					'foo' => 'f',
					'bar' => [
						[ 'baz' => 'x' ],
						[ 'baz' => 'y' ]
					]
				],
				"f\n\tf x\n\tf y\n"
			]
		];
	}

	public function testEnableRecursivePartials() {
		$tp = new TemplateParser( self::DIR, new EmptyBagOStuff );
		$data = [ 'r' => [ 'r' => [ 'r' => [] ] ] ];

		$tp->enableRecursivePartials( true );
		$this->assertEquals( 'rrr', $tp->processTemplate( 'recurse', $data ) );

		$tp->enableRecursivePartials( false );
		$this->expectException( Exception::class );
		$tp->processTemplate( 'recurse', $data );
	}

	public function testCompileReturnsPHPCodeAndMetadata() {
		$store = null;

		// 1. Expect a compile and cache set
		$cache = $this->createMock( BagOStuff::class );
		$cache->expects( $this->once() )->method( 'get' )->willReturn( false );
		$cache->expects( $this->once() )->method( 'set' )
			->willReturnCallback( static function ( $key, $val ) use ( &$store ) {
				$store = [ 'key' => $key, 'val' => $val ];
			} );
		$tp = new TemplateParser( self::DIR, $cache );
		$tp->processTemplate( 'has_partial', [] );

		// 2. Inspect cache
		$expectedFiles = [
			self::DIR . '/has_partial.mustache',
			self::DIR . '/foobar_args.mustache',
		];
		$this->assertEquals(
			$expectedFiles,
			$store['val']['files'],
			'track all files read during the compilation'
		);
		$this->assertEquals(
			FileContentsHasher::getFileContentsHash( $expectedFiles ),
			$store['val'][ 'filesHash' ],
			'hash of all files read during the compilation'
		);
	}

	public function testGetTemplateCachingHandlesRecursivePartials() {
		$store = null;

		$cache = $this->createMock( BagOStuff::class );
		$cache->expects( $this->once() )->method( 'get' )->willReturn( false );
		$cache->expects( $this->once() )->method( 'set' )
			->willReturnCallback( static function ( $key, $val ) use ( &$store ) {
				$store = [ 'key' => $key, 'val' => $val ];
			} );

		$tp = new TemplateParser( self::DIR, $cache );
		$tp->enableRecursivePartials( true );

		$data = [ 'r' => [ 'r' => [ 'r' => [] ] ] ];
		$tp->processTemplate( 'recurse', $data );

		$this->assertArrayEquals(
			[ self::DIR . '/recurse.mustache' ],
			$store['val']['files'],
			'The hash is computed from unique template files.'
		);
	}
}
PK       !     "  export/WikiExporterFactoryTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Export;

use FactoryArgTestTrait;
use MediaWiki\Export\WikiExporterFactory;
use MediaWiki\MainConfigNames;
use MediaWikiIntegrationTestCase;
use WikiExporter;
use XmlDumpWriter;

/**
 * @covers \MediaWiki\Export\WikiExporterFactory
 */
class WikiExporterFactoryTest extends MediaWikiIntegrationTestCase {
	use FactoryArgTestTrait;

	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValue(
			MainConfigNames::XmlDumpSchemaVersion,
			XmlDumpWriter::$supportedSchemas[0]
		);
	}

	protected static function getFactoryClass() {
		return WikiExporterFactory::class;
	}

	protected static function getInstanceClass() {
		return WikiExporter::class;
	}

	protected static function getExtraClassArgCount() {
		return 4;
	}

	protected function getFactoryMethodName() {
		return 'getWikiExporter';
	}

	protected function getOverriddenMockValueForParam( $param ) {
		if ( $param->getName() === 'text' ) {
			return [ WikiExporter::TEXT ];
		}
		return [];
	}
}
PK       ! 24       composer/LockFileCheckerTest.phpnu Iw        <?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\Composer\LockFileChecker;
use Wikimedia\Composer\ComposerJson;
use Wikimedia\Composer\ComposerLock;

/**
 * @covers \MediaWiki\Composer\LockFileChecker
 */
class LockFileCheckerTest extends MediaWikiIntegrationTestCase {

	private const FIXTURE_DIRECTORY = MW_INSTALL_PATH . '/tests/phpunit/data/LockFileChecker';

	public function testOk() {
		$json = new ComposerJson( self::FIXTURE_DIRECTORY . '/composer-testcase1.json' );
		$lock = new ComposerLock( self::FIXTURE_DIRECTORY . '/composer-testcase1.lock' );
		$checker = new LockFileChecker( $json, $lock );
		$errors = $checker->check();
		$this->assertNull( $errors );
	}

	public function testOutdated() {
		$json = new ComposerJson( self::FIXTURE_DIRECTORY . '/composer-testcase2.json' );
		$lock = new ComposerLock( self::FIXTURE_DIRECTORY . '/composer-testcase2.lock' );
		$checker = new LockFileChecker( $json, $lock );
		$errors = $checker->check();
		$this->assertArrayEquals( [
			'wikimedia/relpath: 2.9.9 installed, 3.0.0 required.',
		], $errors );
	}

	public function testNotInstalled() {
		$json = new ComposerJson( self::FIXTURE_DIRECTORY . '/composer-testcase3.json' );
		$lock = new ComposerLock( self::FIXTURE_DIRECTORY . '/composer-testcase3.lock' );
		$checker = new LockFileChecker( $json, $lock );
		$errors = $checker->check();
		$this->assertArrayEquals( [
			'wikimedia/relpath: 2.9.9 installed, 3.0.0 required.',
			'wikimedia/at-ease: not installed, 2.1.0 required.',
		], $errors );
	}
}
PK       ! 1o    3  libs/lockmanager/LockManagerIntegrationTestBase.phpnu Iw        <?php

abstract class LockManagerIntegrationTestBase extends MediaWikiIntegrationTestCase {

	/** @var string */
	protected $class;
	/** @var LockManager */
	protected $managerA;
	/** @var LockManager */
	protected $managerB;

	/**
	 * @param string $threadName
	 * @return LockManager
	 */
	abstract protected function getManager( $threadName );

	protected function setUp(): void {
		$this->managerA = $this->getManager( 'A' );
		$this->managerB = $this->getManager( 'B' );
		$this->class = get_class( $this->managerA );
	}

	public function testLockUnlockEx() {
		$managerA = $this->managerA;
		$managerB = $this->managerB;
		$rand = wfRandomString();
		$path = "unit_testing://resource/$rand/photo.jpeg";

		$status = $managerA->lock( [ $path ], LockManager::LOCK_EX );
		$this->assertStatusGood( $status, "Lock (outer) succeeded ({$this->class})." );

		$status = $managerA->lock( [ $path ], LockManager::LOCK_EX );
		$this->assertStatusGood( $status, "Lock (inner) succeeded ({$this->class})." );

		$status = $managerB->lock( [ $path ], LockManager::LOCK_EX );
		$this->assertStatusNotGood( $status, "Lock (EX) conflicted ({$this->class})." );

		$status = $managerB->lock( [ $path ], LockManager::LOCK_SH );
		$this->assertStatusNotGood( $status, "Lock (SH) conflicted ({$this->class})." );

		$status = $managerA->unlock( [ $path ], LockManager::LOCK_EX );
		$this->assertStatusGood( $status, "Unlock (inner) succeeded ({$this->class})." );

		$status = $managerB->lock( [ $path ], LockManager::LOCK_EX );
		$this->assertStatusNotGood( $status, "Lock still conflicted ({$this->class})." );

		$status = $managerB->lock( [ $path ], LockManager::LOCK_SH );
		$this->assertStatusNotGood( $status, "Lock still conflicted ({$this->class})." );

		$status = $managerA->unlock( [ $path ], LockManager::LOCK_EX );
		$this->assertStatusGood( $status, "Unlock (outer) succeeded ({$this->class})." );

		$status = $managerB->lock( [ $path ], LockManager::LOCK_EX );
		$this->assertStatusGood( $status, "Lock now succeeded ({$this->class})." );

		$status = $managerB->unlock( [ $path ], LockManager::LOCK_EX );
		$this->assertStatusGood( $status, "Unlock succeeded ({$this->class})." );
	}

	public function testLockUnlockSh() {
		$managerA = $this->managerA;
		$managerB = $this->managerB;
		$rand = wfRandomString();
		$path = "unit_testing://resource/$rand/file.png";

		$status = $managerA->lock( [ $path ], LockManager::LOCK_SH );
		$this->assertStatusGood( $status, "Lock (outer) succeeded ({$this->class})." );

		$status = $managerA->lock( [ $path ], LockManager::LOCK_SH );
		$this->assertStatusGood( $status, "Lock (inner) succeeded ({$this->class})." );

		$status = $managerB->lock( [ $path ], LockManager::LOCK_EX );
		$this->assertStatusNotGood( $status, "Lock (EX) conflicted ({$this->class})." );

		$status = $managerB->lock( [ $path ], LockManager::LOCK_SH );
		$this->assertStatusGood( $status, "Lock (SH) obtained ({$this->class})." );

		$status = $managerB->lock( [ $path ], LockManager::LOCK_EX );
		$this->assertStatusNotGood( $status, "Lock upgrade (EX) conflicted ({$this->class})." );

		$status = $managerA->unlock( [ $path ], LockManager::LOCK_SH );
		$this->assertStatusGood( $status, "Unlock (inner) succeeded ({$this->class})." );

		$status = $managerB->lock( [ $path ], LockManager::LOCK_EX );
		$this->assertStatusNotGood( $status, "Lock (EX) still conflicted ({$this->class})." );

		$status = $managerA->unlock( [ $path ], LockManager::LOCK_SH );
		$this->assertStatusGood( $status, "Unlock (outer) succeeded ({$this->class})." );

		$status = $managerB->lock( [ $path ], LockManager::LOCK_EX );
		$this->assertStatusGood( $status, "Lock upgrade (EX) now obtained ({$this->class})." );

		$status = $managerB->unlock( [ $path ], LockManager::LOCK_EX );
		$this->assertStatusGood( $status, "Unlock (EX) succeeded ({$this->class})." );

		$status = $managerB->unlock( [ $path ], LockManager::LOCK_SH );
		$this->assertStatusGood( $status, "Unlock (SH) succeeded ({$this->class})." );
	}
}
PK       ! .Tm
  
  3  libs/lockmanager/MemcLockManagerIntegrationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @group LockManager
 * @covers MemcLockManager
 */
class MemcLockManagerIntegrationTest extends LockManagerIntegrationTestBase {
	/** @var LockManager[] */
	private static $managersToUse = [];

	private function getManagerConfig(): ?array {
		$backends = $this->getServiceContainer()->getMainConfig()->get( MainConfigNames::LockManagers );
		foreach ( $backends as $conf ) {
			if ( $conf['class'] === MemcLockManager::class ) {
				return $conf;
			}
		}

		return null;
	}

	protected function getManager( $threadName ) {
		if ( !isset( self::$managersToUse[$threadName] ) ) {
			$conf = $this->getManagerConfig();
			if ( $conf === null ) {
				$this->markTestSkipped(
					'Configure a MemcLockManager in $wgLockManagers to enable this test' );
			}
			$conf['name'] = 'localtesting'; // swap name
			$conf['domain'] = 'testingdomain';
			$conf['lockTTL'] = 60;
			self::$managersToUse[$threadName] = new MemcLockManager( $conf );
		}

		return self::$managersToUse[$threadName];
	}
}
PK       ! -    4  libs/lockmanager/RedisLockManagerIntegrationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @group LockManager
 * @covers RedisLockManager
 */
class RedisLockManagerIntegrationTest extends LockManagerIntegrationTestBase {
	/** @var LockManager[] */
	private static $managersToUse = [];

	private function getManagerConfig(): ?array {
		$backends = $this->getServiceContainer()->getMainConfig()->get( MainConfigNames::LockManagers );
		foreach ( $backends as $conf ) {
			if ( $conf['class'] === RedisLockManager::class ) {
				return $conf;
			}
		}

		return null;
	}

	protected function getManager( $threadName ) {
		if ( !isset( self::$managersToUse[$threadName] ) ) {
			$conf = $this->getManagerConfig();
			if ( $conf === null ) {
				$this->markTestSkipped(
					'Configure a RedisLockManager in $wgLockManagers to enable this test' );
			}
			$conf['name'] = 'localtesting'; // swap name
			$conf['domain'] = 'testingdomain';
			$conf['lockTTL'] = 60;
			self::$managersToUse[$threadName] = new RedisLockManager( $conf );
		}

		return self::$managersToUse[$threadName];
	}
}
PK       ! =  =  .  libs/rdbms/resultwrapper/ResultWrapperTest.phpnu Iw        <?php

/**
 * Holds tests for ResultWrapper MediaWiki class.
 *
 * 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 Wikimedia\Rdbms\IMaintainableDatabase;

/**
 * @group Database
 * @covers \Wikimedia\Rdbms\ResultWrapper
 * @covers \Wikimedia\Rdbms\MysqliResultWrapper
 * @covers \Wikimedia\Rdbms\PostgresResultWrapper
 * @covers \Wikimedia\Rdbms\SqliteResultWrapper
 */
class ResultWrapperTest extends MediaWikiIntegrationTestCase {
	public function getSchemaOverrides( IMaintainableDatabase $db ) {
		return [
			'create' => [ 'ResultWrapperTest' ],
			'scripts' => [ __DIR__ . '/ResultWrapperTest.sql' ]
		];
	}

	public function testIteration() {
		$this->getDb()->insert(
			'ResultWrapperTest', [
				[ 'col_a' => '1', 'col_b' => 'a' ],
				[ 'col_a' => '2', 'col_b' => 'b' ],
				[ 'col_a' => '3', 'col_b' => 'c' ],
				[ 'col_a' => '4', 'col_b' => 'd' ],
				[ 'col_a' => '5', 'col_b' => 'e' ],
				[ 'col_a' => '6', 'col_b' => 'f' ],
				[ 'col_a' => '7', 'col_b' => 'g' ],
				[ 'col_a' => '8', 'col_b' => 'h' ]
			],
			__METHOD__
		);

		$expectedRows = [
			0 => (object)[ 'col_a' => '1', 'col_b' => 'a' ],
			1 => (object)[ 'col_a' => '2', 'col_b' => 'b' ],
			2 => (object)[ 'col_a' => '3', 'col_b' => 'c' ],
			3 => (object)[ 'col_a' => '4', 'col_b' => 'd' ],
			4 => (object)[ 'col_a' => '5', 'col_b' => 'e' ],
			5 => (object)[ 'col_a' => '6', 'col_b' => 'f' ],
			6 => (object)[ 'col_a' => '7', 'col_b' => 'g' ],
			7 => (object)[ 'col_a' => '8', 'col_b' => 'h' ]
		];

		$res = $this->getDb()->newSelectQueryBuilder()
			->select( [ 'col_a', 'col_b' ] )
			->from( 'ResultWrapperTest' )
			->where( '1 = 1' )
			->caller( __METHOD__ )->fetchResultSet();
		$this->assertSame( 8, $res->numRows() );
		$this->assertTrue( $res->valid() );

		$res->seek( 7 );
		$this->assertSame( 7, $res->key() );
		$this->assertArrayEquals( [ 'col_a' => '8', 0 => '8', 'col_b' => 'h', 1 => 'h', ],
			$res->fetchRow(), false, true );
		$this->assertSame( 7, $res->key() );

		$res->seek( 7 );
		$this->assertSame( 7, $res->key() );
		$this->assertEquals( (object)[ 'col_a' => '8', 'col_b' => 'h' ], $res->fetchObject() );
		$this->assertEquals( (object)[ 'col_a' => '8', 'col_b' => 'h' ], $res->current() );
		$this->assertSame( 7, $res->key() );

		$res->seek( 6 );
		$this->assertTrue( $res->valid() );
		$this->assertSame( 6, $res->key() );
		$this->assertEquals( (object)[ 'col_a' => '7', 'col_b' => 'g' ], $res->fetchObject() );
		$this->assertTrue( $res->valid() );
		$this->assertSame( 6, $res->key() );
		$this->assertEquals( (object)[ 'col_a' => '8', 'col_b' => 'h' ], $res->fetchObject() );
		$this->assertSame( 7, $res->key() );
		$this->assertFalse( $res->fetchObject() );
		$this->assertFalse( $res->current() );
		$this->assertFalse( $res->valid() );

		$this->assertArrayEquals( $expectedRows, iterator_to_array( $res, true ),
			false, true );

		$rows = [];
		foreach ( $res as $i => $row ) {
			$rows[$i] = $row;
		}
		$this->assertEquals( $expectedRows, $rows );
	}

	public function testCurrentNoResults() {
		$res = $this->getDb()->newSelectQueryBuilder()
			->select( [ 'col_a', 'col_b' ] )
			->from( 'ResultWrapperTest' )
			->where( '1 = 0' )
			->caller( __METHOD__ )->fetchResultSet();
		$this->assertFalse( $res->current() );
	}

	public function testValidNoResults() {
		$res = $this->getDb()->newSelectQueryBuilder()
			->select( [ 'col_a', 'col_b' ] )
			->from( 'ResultWrapperTest' )
			->where( '1 = 0' )
			->caller( __METHOD__ )->fetchResultSet();
		$this->assertFalse( $res->valid() );
	}

	public function testSeekNoResults() {
		$res = $this->getDb()->newSelectQueryBuilder()
			->select( [ 'col_a', 'col_b' ] )
			->from( 'ResultWrapperTest' )
			->where( '1 = 0' )
			->caller( __METHOD__ )->fetchResultSet();
		$res->seek( 0 );
		$this->assertTrue( true ); // no error
	}

	public static function provideSeekOutOfBounds() {
		return [ [ 0, 1 ], [ 1, 1 ], [ 1, 2 ], [ 1, -1 ] ];
	}

	/** @dataProvider provideSeekOutOfBounds */
	public function testSeekOutOfBounds( $numRows, $seekPos ) {
		for ( $i = 0; $i < $numRows; $i++ ) {
			$this->getDb()->insert( 'ResultWrapperTest',
				[ [ 'col_a' => $i, 'col_b' => $i ] ],
				__METHOD__ );
		}
		$res = $this->getDb()->newSelectQueryBuilder()
			->select( [ 'col_a', 'col_b' ] )
			->from( 'ResultWrapperTest' )
			->where( '1 = 0' )
			->caller( __METHOD__ )->fetchResultSet();
		$this->expectException( OutOfBoundsException::class );
		$res->seek( $seekPos );
	}
}
PK       ! Ӂl   l   .  libs/rdbms/resultwrapper/ResultWrapperTest.sqlnu Iw        CREATE TABLE /*_*/ResultWrapperTest (
    col_a VARCHAR(20),
    col_b VARCHAR(20)
) /*$wgDBTableOptions*/;
PK       ! 5o    5  libs/filebackend/MemoryFileBackendIntegrationTest.phpnu Iw        <?php

use MediaWiki\WikiMap\WikiMap;
use Wikimedia\FileBackend\MemoryFileBackend;

/**
 * @group FileBackend
 * @covers \Wikimedia\FileBackend\MemoryFileBackend
 * @covers \Wikimedia\FileBackend\FileBackendStore
 * @covers NullLockManager
 */
class MemoryFileBackendIntegrationTest extends FileBackendIntegrationTestBase {
	protected function getBackend() {
		return new MemoryFileBackend( [
			'name' => 'localtesting',
			'wikiId' => WikiMap::getCurrentWikiId(),
		] );
	}
}
PK       ! FN?\  \  4  libs/filebackend/SwiftFileBackendIntegrationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\WikiMap\WikiMap;
use Wikimedia\FileBackend\SwiftFileBackend;

/**
 * @group FileBackend
 * @covers \Wikimedia\FileBackend\SwiftFileBackend
 */
class SwiftFileBackendIntegrationTest extends FileBackendIntegrationTestBase {
	/** @var SwiftFileBackend|null */
	private static $backendToUse;

	private function getBackendConfig(): ?array {
		$backends = $this->getServiceContainer()->getMainConfig()->get( MainConfigNames::FileBackends );
		foreach ( $backends as $conf ) {
			if ( $conf['class'] === SwiftFileBackend::class ) {
				return $conf;
			}
		}
		return null;
	}

	protected function getBackend() {
		if ( !self::$backendToUse ) {
			$conf = $this->getBackendConfig();
			if ( $conf === null ) {
				$this->markTestSkipped( 'Configure a Swift file backend in $wgFileBackends to enable this test' );
			}
			$conf['name'] = 'localtesting'; // swap name
			$conf['shardViaHashLevels'] = [ // test sharding
				'unittest-cont1' => [ 'levels' => 1, 'base' => 16, 'repeat' => 1 ]
			];
			$lockManagerGroup = $this->getServiceContainer()
				->getLockManagerGroupFactory()->getLockManagerGroup();
			$conf['lockManager'] = $lockManagerGroup->get( $conf['lockManager'] );
			$conf['domainId'] = WikiMap::getCurrentWikiId();
			self::$backendToUse = new SwiftFileBackend( $conf );
		}
		return self::$backendToUse;
	}
}
PK       ! K  3  libs/filebackend/FileBackendIntegrationTestBase.phpnu Iw        <?php

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Http\HttpRequestFactory;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\Status\Status;
use Shellbox\Shellbox;
use Wikimedia\FileBackend\FileBackend;
use Wikimedia\FileBackend\FSFile\FSFile;
use Wikimedia\FileBackend\FSFile\TempFSFile;
use Wikimedia\FileBackend\FSFileBackend;

abstract class FileBackendIntegrationTestBase extends MediaWikiIntegrationTestCase {

	/** @var FileBackend */
	protected $backend;

	abstract protected function getBackend();

	protected function setUp(): void {
		$this->backend = $this->getBackend();
	}

	protected function tearDown(): void {
		$this->tearDownFiles();
	}

	private static function baseStorePath() {
		return 'mwstore://localtesting';
	}

	private function backendClass() {
		return get_class( $this->backend );
	}

	/**
	 * @dataProvider provider_testStore
	 */
	public function testStore( $op ) {
		$this->addTmpFiles( $op['src'] );
		$backendName = $this->backendClass();

		$source = $op['src'];
		$dest = $op['dst'];
		$this->prepare( [ 'dir' => dirname( $dest ) ] );

		file_put_contents( $source, "Unit test file" );

		if ( isset( $op['overwrite'] ) || isset( $op['overwriteSame'] ) ) {
			$this->backend->store( $op );
		}

		$status = $this->backend->doOperation( $op );

		$this->assertStatusGood( $status,
			"Store from $source to $dest succeeded without warnings ($backendName)." );
		$this->assertEquals( [ 0 => true ], $status->success,
			"Store from $source to $dest has proper 'success' field in Status ($backendName)." );
		$this->assertTrue( is_file( $source ),
			"Source file $source still exists ($backendName)." );
		$this->assertTrue( $this->backend->fileExists( [ 'src' => $dest ] ),
			"Destination file $dest exists ($backendName)." );

		$this->assertEquals( filesize( $source ),
			$this->backend->getFileSize( [ 'src' => $dest ] ),
			"Destination file $dest has correct size ($backendName)." );

		$props1 = FSFile::getPropsFromPath( $source );
		$props2 = $this->backend->getFileProps( [ 'src' => $dest ] );
		$this->assertEquals( $props1, $props2,
			"Source and destination have the same props ($backendName)." );

		$this->assertBackendPathsConsistent( [ $dest ], true );
	}

	public static function provider_testStore() {
		$cases = [];

		$tmpName = TempFSFile::factory( "unittests_", 'txt', wfTempDir() )->getPath();
		$toPath = self::baseStorePath() . '/unittest-cont1/e/fun/obj1.txt';
		$op = [ 'op' => 'store', 'src' => $tmpName, 'dst' => $toPath ];
		$cases[] = [ $op ];

		$op2 = $op;
		$op2['overwrite'] = true;
		$cases[] = [ $op2 ];

		$op3 = $op;
		$op3['overwriteSame'] = true;
		$cases[] = [ $op3 ];

		return $cases;
	}

	/**
	 * @dataProvider provider_testCopy
	 */
	public function testCopy( $op, $srcContent, $dstContent, $okStatus, $okSyncStatus ) {
		$backendName = $this->backendClass();

		$source = $op['src'];
		$dest = $op['dst'];
		$this->prepare( [ 'dir' => dirname( $source ) ] );
		$this->prepare( [ 'dir' => dirname( $dest ) ] );

		if ( is_string( $srcContent ) ) {
			$status = $this->backend->create( [ 'content' => $srcContent, 'dst' => $source ] );
			$this->assertStatusGood( $status, "Creation of $source succeeded ($backendName)." );
		}
		if ( is_string( $dstContent ) ) {
			$status = $this->backend->create( [ 'content' => $dstContent, 'dst' => $dest ] );
			$this->assertStatusGood( $status, "Creation of $dest succeeded ($backendName)." );
		}

		$status = $this->backend->doOperation( $op );

		if ( $okStatus ) {
			$this->assertStatusGood(
				$status,
				"Copy from $source to $dest succeeded without warnings ($backendName)." );
			$this->assertEquals( [ 0 => true ], $status->success,
				"Copy from $source to $dest has proper 'success' field in Status ($backendName)." );
			if ( !is_string( $srcContent ) ) {
				$this->assertSame(
					is_string( $dstContent ),
					$this->backend->fileExists( [ 'src' => $dest ] ),
					"Destination file $dest unchanged after no-op copy ($backendName)." );
				$this->assertSame(
					$dstContent,
					$this->backend->getFileContents( [ 'src' => $dest ] ),
					"Destination file $dest unchanged after no-op copy ($backendName)." );
			} else {
				$this->assertEquals(
					$this->backend->getFileSize( [ 'src' => $source ] ),
					$this->backend->getFileSize( [ 'src' => $dest ] ),
					"Destination file $dest has correct size ($backendName)." );
				$props1 = $this->backend->getFileProps( [ 'src' => $source ] );
				$props2 = $this->backend->getFileProps( [ 'src' => $dest ] );
				$this->assertEquals(
					$props1,
					$props2,
					"Source and destination have the same props ($backendName)." );
			}
		} else {
			$this->assertStatusNotOK(
				$status,
				"Copy from $source to $dest fails ($backendName)." );
			$this->assertSame(
				is_string( $dstContent ),
				(bool)$this->backend->fileExists( [ 'src' => $dest ] ),
				"Destination file $dest unchanged after failed copy ($backendName)." );
			$this->assertSame(
				$dstContent,
				$this->backend->getFileContents( [ 'src' => $dest ] ),
				"Destination file $dest unchanged after failed copy ($backendName)." );
		}

		$this->assertSame(
			is_string( $srcContent ),
			(bool)$this->backend->fileExists( [ 'src' => $source ] ),
			"Source file $source unchanged after copy ($backendName)."
		);
		$this->assertSame(
			$srcContent,
			$this->backend->getFileContents( [ 'src' => $source ] ),
			"Source file $source unchanged after copy ($backendName)."
		);
		if ( is_string( $dstContent ) ) {
			$this->assertTrue(
				(bool)$this->backend->fileExists( [ 'src' => $dest ] ),
				"Destination file $dest exists after copy ($backendName)." );
		}

		$this->assertBackendPathsConsistent( [ $source, $dest ], $okSyncStatus );
	}

	/**
	 * @return array (op, source exists, dest exists, op succeeds, sync check succeeds)
	 */
	public static function provider_testCopy() {
		$cases = [];

		$source = self::baseStorePath() . '/unittest-cont1/e/file.txt';
		$dest = self::baseStorePath() . '/unittest-cont2/a/fileCopied.txt';
		$opBase = [ 'op' => 'copy', 'src' => $source, 'dst' => $dest ];

		$op = $opBase;
		$cases[] = [ $op, 'yyy', false, true, true ];

		$op = $opBase;
		$op['overwrite'] = true;
		$cases[] = [ $op, 'yyy', false, true, true ];

		$op = $opBase;
		$op['overwrite'] = true;
		$cases[] = [ $op, 'yyy', 'xxx', true, true ];

		$op = $opBase;
		$op['overwriteSame'] = true;
		$cases[] = [ $op, 'yyy', false, true, true ];

		$op = $opBase;
		$op['overwriteSame'] = true;
		$cases[] = [ $op, 'yyy', 'yyy', true, true ];

		$op = $opBase;
		$op['overwriteSame'] = true;
		$cases[] = [ $op, 'yyy', 'zzz', false, true ];

		$op = $opBase;
		$op['ignoreMissingSource'] = true;
		$cases[] = [ $op, 'xxx', false, true, true ];

		$op = $opBase;
		$op['ignoreMissingSource'] = true;
		$cases[] = [ $op, false, false, true, true ];

		$op = $opBase;
		$op['ignoreMissingSource'] = true;
		$cases[] = [ $op, false, 'xxx', true, true ];

		$op = $opBase;
		$op['src'] = 'mwstore://wrongbackend/unittest-cont1/e/file.txt';
		$op['ignoreMissingSource'] = true;
		$cases[] = [ $op, false, false, false, false ];

		return $cases;
	}

	/**
	 * @dataProvider provider_testMove
	 */
	public function testMove( $op, $srcContent, $dstContent, $okStatus, $okSyncStatus ) {
		$backendName = $this->backendClass();

		$source = $op['src'];
		$dest = $op['dst'];
		$this->prepare( [ 'dir' => dirname( $source ) ] );
		$this->prepare( [ 'dir' => dirname( $dest ) ] );

		if ( is_string( $srcContent ) ) {
			$status = $this->backend->create( [ 'content' => $srcContent, 'dst' => $source ] );
			$this->assertStatusGood( $status, "Creation of $source succeeded ($backendName)." );
		}
		if ( is_string( $dstContent ) ) {
			$status = $this->backend->create( [ 'content' => $dstContent, 'dst' => $dest ] );
			$this->assertStatusGood( $status, "Creation of $dest succeeded ($backendName)." );
		}

		$oldSrcProps = $this->backend->getFileProps( [ 'src' => $source ] );

		$status = $this->backend->doOperation( $op );

		if ( $okStatus ) {
			$this->assertStatusGood(
				$status,
				"Move from $source to $dest succeeded without warnings ($backendName)." );
			$this->assertEquals( [ 0 => true ], $status->success,
				"Move from $source to $dest has proper 'success' field in Status ($backendName)." );
			if ( !is_string( $srcContent ) ) {
				$this->assertSame(
					is_string( $dstContent ),
					$this->backend->fileExists( [ 'src' => $dest ] ),
					"Destination file $dest unchanged after no-op move ($backendName)." );
				$this->assertSame(
					$dstContent,
					$this->backend->getFileContents( [ 'src' => $dest ] ),
					"Destination file $dest unchanged after no-op move ($backendName)." );
			} else {
				$this->assertEquals(
					$this->backend->getFileSize( [ 'src' => $dest ] ),
					strlen( $srcContent ),
					"Destination file $dest has correct size ($backendName)." );
				$this->assertEquals(
					$oldSrcProps,
					$this->backend->getFileProps( [ 'src' => $dest ] ),
					"Source and destination have the same props ($backendName)." );
			}
		} else {
			$this->assertStatusNotOK(
				$status,
				"Move from $source to $dest fails ($backendName)." );
			$this->assertSame(
				is_string( $dstContent ),
				(bool)$this->backend->fileExists( [ 'src' => $dest ] ),
				"Destination file $dest unchanged after failed move ($backendName)." );
			$this->assertSame(
				$dstContent,
				$this->backend->getFileContents( [ 'src' => $dest ] ),
				"Destination file $dest unchanged after failed move ($backendName)." );
			$this->assertSame(
				is_string( $srcContent ),
				(bool)$this->backend->fileExists( [ 'src' => $source ] ),
				"Source file $source unchanged after failed move ($backendName)."
			);
			$this->assertSame(
				$srcContent,
				$this->backend->getFileContents( [ 'src' => $source ] ),
				"Source file $source unchanged after failed move ($backendName)."
			);
		}

		if ( is_string( $dstContent ) ) {
			$this->assertTrue(
				(bool)$this->backend->fileExists( [ 'src' => $dest ] ),
				"Destination file $dest exists after move ($backendName)." );
		}

		$this->assertBackendPathsConsistent( [ $source, $dest ], $okSyncStatus );
	}

	/**
	 * @return array (op, source exists, dest exists, op succeeds, sync check succeeds)
	 */
	public static function provider_testMove() {
		$cases = [];

		$source = self::baseStorePath() . '/unittest-cont1/e/file.txt';
		$dest = self::baseStorePath() . '/unittest-cont2/a/fileMoved.txt';
		$opBase = [ 'op' => 'move', 'src' => $source, 'dst' => $dest ];

		$op = $opBase;
		$cases[] = [ $op, 'yyy', false, true, true ];

		$op = $opBase;
		$op['overwrite'] = true;
		$cases[] = [ $op, 'yyy', false, true, true ];

		$op = $opBase;
		$op['overwrite'] = true;
		$cases[] = [ $op, 'yyy', 'xxx', true, true ];

		$op = $opBase;
		$op['overwriteSame'] = true;
		$cases[] = [ $op, 'yyy', false, true, true ];

		$op = $opBase;
		$op['overwriteSame'] = true;
		$cases[] = [ $op, 'yyy', 'yyy', true, true ];

		$op = $opBase;
		$op['overwriteSame'] = true;
		$cases[] = [ $op, 'yyy', 'zzz', false, true ];

		$op = $opBase;
		$op['ignoreMissingSource'] = true;
		$cases[] = [ $op, 'xxx', false, true, true ];

		$op = $opBase;
		$op['ignoreMissingSource'] = true;
		$cases[] = [ $op, false, false, true, true ];

		$op = $opBase;
		$op['ignoreMissingSource'] = true;
		$cases[] = [ $op, false, 'xxx', true, true ];

		$op = $opBase;
		$op['src'] = 'mwstore://wrongbackend/unittest-cont1/e/file.txt';
		$op['ignoreMissingSource'] = true;
		$cases[] = [ $op, false, false, false, false ];

		return $cases;
	}

	/**
	 * @dataProvider provider_testDelete
	 */
	public function testDelete( $op, $srcContent, $okStatus, $okSyncStatus ) {
		$backendName = $this->backendClass();

		$source = $op['src'];
		$this->prepare( [ 'dir' => dirname( $source ) ] );

		if ( is_string( $srcContent ) ) {
			$status = $this->backend->doOperation(
				[ 'op' => 'create', 'content' => $srcContent, 'dst' => $source ] );
			$this->assertStatusGood( $status,
				"Creation of file at $source succeeded ($backendName)." );
		}

		$status = $this->backend->doOperation( $op );
		if ( $okStatus ) {
			$this->assertStatusGood( $status,
				"Deletion of file at $source succeeded without warnings ($backendName)." );
			$this->assertEquals( [ 0 => true ], $status->success,
				"Deletion of file at $source has proper 'success' field in Status ($backendName)." );
		} else {
			$this->assertStatusNotOK( $status,
				"Deletion of file at $source failed ($backendName)." );
		}

		$this->assertFalse(
			(bool)$this->backend->fileExists( [ 'src' => $source ] ),
			"Source file $source does not exist after move ($backendName)." );

		$this->assertFalse(
			$this->backend->getFileSize( [ 'src' => $source ] ),
			"Source file $source has correct size (false) ($backendName)." );

		$props1 = $this->backend->getFileProps( [ 'src' => $source ] );
		$this->assertFalse(
			$props1['fileExists'],
			"Source file $source does not exist according to props ($backendName)." );

		$this->assertBackendPathsConsistent( [ $source ], $okSyncStatus );
	}

	/**
	 * @return array (op, source content, op succeeds, sync check succeeds)
	 */
	public static function provider_testDelete() {
		$cases = [];

		$source = self::baseStorePath() . '/unittest-cont1/e/myfacefile.txt';
		$baseOp = [ 'op' => 'delete', 'src' => $source ];

		$op = $baseOp;
		$cases[] = [ $op, 'xxx', true, true ];

		$op = $baseOp;
		$op['ignoreMissingSource'] = true;
		$cases[] = [ $op, 'xxx', true, true ];

		$op = $baseOp;
		$cases[] = [ $op, false, false, true ];

		$op = $baseOp;
		$op['ignoreMissingSource'] = true;
		$cases[] = [ $op, false, true, true ];

		$op = $baseOp;
		$op['ignoreMissingSource'] = true;
		$op['src'] = 'mwstore://wrongbackend/unittest-cont1/e/file.txt';
		$cases[] = [ $op, false, false, false ];

		return $cases;
	}

	/**
	 * @dataProvider provider_testDescribe
	 */
	public function testDescribe( $op, $withSource, $okStatus ) {
		$backendName = $this->backendClass();

		$source = $op['src'];
		$this->prepare( [ 'dir' => dirname( $source ) ] );

		if ( $withSource ) {
			$status = $this->backend->doOperation(
				[ 'op' => 'create', 'content' => 'blahblah', 'dst' => $source,
					'headers' => [ 'Content-Disposition' => 'xxx' ] ] );
			$this->assertStatusGood( $status,
				"Creation of file at $source succeeded ($backendName)." );
			if ( $this->backend->hasFeatures( FileBackend::ATTR_HEADERS ) ) {
				$attr = $this->backend->getFileXAttributes( [ 'src' => $source ] );
				$this->assertHasHeaders( [ 'Content-Disposition' => 'xxx' ], $attr );
			}

			$status = $this->backend->describe( [ 'src' => $source,
				'headers' => [ 'Content-Disposition' => '' ] ] ); // remove
			$this->assertStatusGood( $status,
				"Removal of header for $source succeeded ($backendName)." );

			if ( $this->backend->hasFeatures( FileBackend::ATTR_HEADERS ) ) {
				$attr = $this->backend->getFileXAttributes( [ 'src' => $source ] );
				$this->assertFalse( isset( $attr['headers']['content-disposition'] ),
					"File 'Content-Disposition' header removed." );
			}
		}

		$status = $this->backend->doOperation( $op );
		if ( $okStatus ) {
			$this->assertStatusGood( $status,
				"Describe of file at $source succeeded without warnings ($backendName)." );
			$this->assertEquals( [ 0 => true ], $status->success,
				"Describe of file at $source has proper 'success' field in Status ($backendName)." );
			if ( $this->backend->hasFeatures( FileBackend::ATTR_HEADERS ) ) {
				$attr = $this->backend->getFileXAttributes( [ 'src' => $source ] );
				$this->assertHasHeaders( $op['headers'], $attr );
			}
		} else {
			$this->assertStatusNotOK( $status,
				"Describe of file at $source failed ($backendName)." );
		}

		$this->assertBackendPathsConsistent( [ $source ], true );
	}

	private function assertHasHeaders( array $headers, array $attr ) {
		foreach ( $headers as $n => $v ) {
			if ( $n !== '' ) {
				$this->assertTrue( isset( $attr['headers'][strtolower( $n )] ),
					"File has '$n' header." );
				$this->assertEquals( $v, $attr['headers'][strtolower( $n )],
					"File has '$n' header value." );
			} else {
				$this->assertFalse( isset( $attr['headers'][strtolower( $n )] ),
					"File does not have '$n' header." );
			}
		}
	}

	public static function provider_testDescribe() {
		$cases = [];

		$source = self::baseStorePath() . '/unittest-cont1/e/myfacefile.txt';

		$op = [ 'op' => 'describe', 'src' => $source,
			'headers' => [ 'Content-Disposition' => 'inline' ], ];
		$cases[] = [
			$op, // operation
			true, // with source
			true // succeeds
		];

		$cases[] = [
			$op, // operation
			false, // without source
			false // fails
		];

		return $cases;
	}

	/**
	 * @dataProvider provider_testCreate
	 */
	public function testCreate( $op, $alreadyExists, $okStatus, $newSize ) {
		$backendName = $this->backendClass();

		$dest = $op['dst'];
		$this->prepare( [ 'dir' => dirname( $dest ) ] );

		$oldText = 'blah...blah...waahwaah';
		if ( $alreadyExists ) {
			$status = $this->backend->doOperation(
				[ 'op' => 'create', 'content' => $oldText, 'dst' => $dest ] );
			$this->assertStatusGood( $status,
				"Creation of file at $dest succeeded ($backendName)." );
		}

		$status = $this->backend->doOperation( $op );
		if ( $okStatus ) {
			$this->assertStatusGood( $status,
				"Creation of file at $dest succeeded without warnings ($backendName)." );
			$this->assertEquals( [ 0 => true ], $status->success,
				"Creation of file at $dest has proper 'success' field in Status ($backendName)." );
		} else {
			$this->assertStatusNotOK( $status,
				"Creation of file at $dest failed ($backendName)." );
		}

		$this->assertTrue( $this->backend->fileExists( [ 'src' => $dest ] ),
			"Destination file $dest exists after creation ($backendName)." );

		$props1 = $this->backend->getFileProps( [ 'src' => $dest ] );
		$this->assertTrue( $props1['fileExists'],
			"Destination file $dest exists according to props ($backendName)." );
		if ( $okStatus ) { // file content is what we saved
			$this->assertEquals( $newSize, $props1['size'],
				"Destination file $dest has expected size according to props ($backendName)." );
			$this->assertEquals( $newSize,
				$this->backend->getFileSize( [ 'src' => $dest ] ),
				"Destination file $dest has correct size ($backendName)." );
		} else { // file content is some other previous text
			$this->assertEquals( strlen( $oldText ), $props1['size'],
				"Destination file $dest has original size according to props ($backendName)." );
			$this->assertEquals( strlen( $oldText ),
				$this->backend->getFileSize( [ 'src' => $dest ] ),
				"Destination file $dest has original size according to props ($backendName)." );
		}

		$this->assertBackendPathsConsistent( [ $dest ], true );
	}

	/**
	 * @dataProvider provider_testCreate
	 */
	public static function provider_testCreate() {
		$cases = [];

		$dest = self::baseStorePath() . '/unittest-cont2/a/myspacefile.txt';

		$op = [ 'op' => 'create', 'content' => 'test test testing', 'dst' => $dest ];
		$cases[] = [
			$op, // operation
			false, // no dest already exists
			true, // succeeds
			strlen( $op['content'] )
		];

		$op2 = $op;
		$op2['content'] = "\n";
		$cases[] = [
			$op2, // operation
			false, // no dest already exists
			true, // succeeds
			strlen( $op2['content'] )
		];

		$op2 = $op;
		$op2['content'] = "fsf\n waf 3kt";
		$cases[] = [
			$op2, // operation
			true, // dest already exists
			false, // fails
			strlen( $op2['content'] )
		];

		$op2 = $op;
		$op2['content'] = "egm'g gkpe gpqg eqwgwqg";
		$op2['overwrite'] = true;
		$cases[] = [
			$op2, // operation
			true, // dest already exists
			true, // succeeds
			strlen( $op2['content'] )
		];

		$op2 = $op;
		$op2['content'] = "39qjmg3-qg";
		$op2['overwriteSame'] = true;
		$cases[] = [
			$op2, // operation
			true, // dest already exists
			false, // succeeds
			strlen( $op2['content'] )
		];

		return $cases;
	}

	/**
	 * @dataProvider provider_quickOperations
	 */
	public function testDoQuickOperations(
		$files, $createOps, $copyOps, $moveOps, $overSelfOps, $deleteOps, $batchSize
	) {
		$backendName = $this->backendClass();

		foreach ( $files as $path ) {
			$status = $this->prepare( [ 'dir' => dirname( $path ) ] );
			$this->assertStatusGood( $status,
				"Preparing $path succeeded without warnings ($backendName)." );
		}

		foreach ( array_chunk( $createOps, $batchSize ) as $batchOps ) {
			$this->assertStatusGood(
				$this->backend->doQuickOperations( $batchOps ),
				"Creation of source files succeeded ($backendName)."
			);
		}
		foreach ( $files as $file ) {
			$this->assertTrue(
				$this->backend->fileExists( [ 'src' => $file ] ),
				"File $file exists."
			);
		}

		foreach ( array_chunk( $copyOps, $batchSize ) as $batchOps ) {
			$this->assertStatusGood(
				$this->backend->doQuickOperations( $batchOps ),
				"Quick copy of source files succeeded ($backendName)."
			);
		}
		foreach ( $files as $file ) {
			$this->assertTrue(
				$this->backend->fileExists( [ 'src' => "$file-2" ] ),
				"File $file-2 exists."
			);
		}

		foreach ( array_chunk( $moveOps, $batchSize ) as $batchOps ) {
			$this->assertStatusGood(
				$this->backend->doQuickOperations( $batchOps ),
				"Quick move of source files succeeded ($backendName)."
			);
		}
		foreach ( $files as $file ) {
			$this->assertTrue(
				$this->backend->fileExists( [ 'src' => "$file-3" ] ),
				"File $file-3 move in."
			);
			$this->assertFalse(
				$this->backend->fileExists( [ 'src' => "$file-2" ] ),
				"File $file-2 moved away."
			);
		}

		foreach ( array_chunk( $overSelfOps, $batchSize ) as $batchOps ) {
			$this->assertStatusGood(
				$this->backend->doQuickOperations( $batchOps ),
				"Quick copy/move of source files over themselves succeeded ($backendName)."
			);
		}
		foreach ( $files as $file ) {
			$this->assertTrue(
				$this->backend->fileExists( [ 'src' => $file ] ),
				"File $file still exists after copy/move over self."
			);
		}

		foreach ( array_chunk( $deleteOps, $batchSize ) as $batchOps ) {
			$this->assertStatusGood(
				$this->backend->doQuickOperations( $batchOps ),
				"Quick deletion of source files succeeded ($backendName)."
			);
		}
		foreach ( $files as $file ) {
			$this->assertFalse( $this->backend->fileExists( [ 'src' => $file ] ),
				"File $file purged." );
			$this->assertFalse( $this->backend->fileExists( [ 'src' => "$file-3" ] ),
				"File $file-3 purged." );
		}
	}

	public static function provider_quickOperations() {
		$base = self::baseStorePath();
		$files = [
			"$base/unittest-cont1/e/fileA.a",
			"$base/unittest-cont1/e/fileB.a",
			"$base/unittest-cont1/e/fileC.a"
		];

		$createOps = $copyOps = $moveOps = $overSelfOps = $deleteOps = [];
		foreach ( $files as $path ) {
			$createOps[] = [ 'op' => 'create', 'dst' => $path, 'content' => 52525 ];
			$createOps[] = [ 'op' => 'create', 'dst' => "$path-x", 'content' => 832 ];
			$createOps[] = [ 'op' => 'null' ];

			$copyOps[] = [ 'op' => 'copy', 'src' => $path, 'dst' => "$path-2" ];
			$copyOps[] = [
				'op' => 'copy',
				'src' => "$path-nothing",
				'dst' => "$path-nowhere",
				'ignoreMissingSource' => true
			];
			$copyOps[] = [ 'op' => 'null' ];

			$moveOps[] = [ 'op' => 'move', 'src' => "$path-2", 'dst' => "$path-3" ];
			$moveOps[] = [
				'op' => 'move',
				'src' => "$path-nothing",
				'dst' => "$path-nowhere",
				'ignoreMissingSource' => true
			];
			$moveOps[] = [ 'op' => 'null' ];

			$overSelfOps[] = [ 'op' => 'copy', 'src' => $path, 'dst' => $path ];
			$overSelfOps[] = [ 'op' => 'move', 'src' => $path, 'dst' => $path ];

			$deleteOps[] = [ 'op' => 'delete', 'src' => $path ];
			$deleteOps[] = [ 'op' => 'delete', 'src' => "$path-3" ];
			$deleteOps[] = [
				'op' => 'delete',
				'src' => "$path-gone",
				'ignoreMissingSource' => true
			];
			$deleteOps[] = [ 'op' => 'null' ];
		}

		return [
			[ $files, $createOps, $copyOps, $moveOps, $overSelfOps, $deleteOps, 1 ],
			[ $files, $createOps, $copyOps, $moveOps, $overSelfOps, $deleteOps, 2 ],
			[ $files, $createOps, $copyOps, $moveOps, $overSelfOps, $deleteOps, 100 ]
		];
	}

	/**
	 * @dataProvider provider_testConcatenate
	 */
	public function testConcatenate( $params, $srcs, $srcsContent, $alreadyExists, $okStatus ) {
		$backendName = $this->backendClass();

		$expContent = '';
		// Create sources
		$ops = [];
		foreach ( $srcs as $i => $source ) {
			$this->prepare( [ 'dir' => dirname( $source ) ] );
			$ops[] = [
				'op' => 'create', // operation
				'dst' => $source, // source
				'content' => $srcsContent[$i]
			];
			$expContent .= $srcsContent[$i];
		}
		$status = $this->backend->doOperations( $ops );

		$this->assertStatusGood( $status,
			"Creation of source files succeeded ($backendName)." );

		$dest = $params['dst'] = $this->getNewTempFile();
		if ( $alreadyExists ) {
			$ok = file_put_contents( $dest, 'blah...blah...waahwaah' ) !== false;
			$this->assertTrue( $ok,
				"Creation of file at $dest succeeded ($backendName)." );
		} else {
			$ok = file_put_contents( $dest, '' ) !== false;
			$this->assertTrue( $ok,
				"Creation of 0-byte file at $dest succeeded ($backendName)." );
		}

		// Combine the files into one
		$status = $this->backend->concatenate( $params );
		if ( $okStatus ) {
			$this->assertStatusGood( $status,
				"Creation of concat file at $dest succeeded without warnings ($backendName)." );
		} else {
			$this->assertStatusNotOK( $status,
				"Creation of concat file at $dest failed ($backendName)." );
		}

		if ( $okStatus ) {
			$this->assertTrue( is_file( $dest ),
				"Dest concat file $dest exists after creation ($backendName)." );
		} else {
			$this->assertTrue( is_file( $dest ),
				"Dest concat file $dest exists after failed creation ($backendName)." );
		}

		$contents = file_get_contents( $dest );
		$this->assertIsString( $contents, "File at $dest exists ($backendName)." );

		if ( $okStatus ) {
			$this->assertEquals( $expContent, $contents,
				"Concat file at $dest has correct contents ($backendName)." );
		} else {
			$this->assertNotEquals( $expContent, $contents,
				"Concat file at $dest has correct contents ($backendName)." );
		}
	}

	public static function provider_testConcatenate() {
		$cases = [];

		$srcs = [
			self::baseStorePath() . '/unittest-cont1/e/file1.txt',
			self::baseStorePath() . '/unittest-cont1/e/file2.txt',
			self::baseStorePath() . '/unittest-cont1/e/file3.txt',
			self::baseStorePath() . '/unittest-cont1/e/file4.txt',
			self::baseStorePath() . '/unittest-cont1/e/file5.txt',
			self::baseStorePath() . '/unittest-cont1/e/file6.txt',
			self::baseStorePath() . '/unittest-cont1/e/file7.txt',
			self::baseStorePath() . '/unittest-cont1/e/file8.txt',
			self::baseStorePath() . '/unittest-cont1/e/file9.txt',
			self::baseStorePath() . '/unittest-cont1/e/file10.txt'
		];
		$content = [
			'egfage',
			'ageageag',
			'rhokohlr',
			'shgmslkg',
			'kenga',
			'owagmal',
			'kgmae',
			'g eak;g',
			'lkaem;a',
			'legma'
		];
		$params = [ 'srcs' => $srcs ];

		$cases[] = [
			$params, // operation
			$srcs, // sources
			$content, // content for each source
			false, // no dest already exists
			true, // succeeds
		];

		$cases[] = [
			$params, // operation
			$srcs, // sources
			$content, // content for each source
			true, // dest already exists
			false, // succeeds
		];

		return $cases;
	}

	/**
	 * @dataProvider provider_testGetFileStat
	 */
	public function testGetFileStat( $path, $content, $alreadyExists ) {
		$backendName = $this->backendClass();

		if ( $alreadyExists ) {
			$this->prepare( [ 'dir' => dirname( $path ) ] );
			$status = $this->create( [ 'dst' => $path, 'content' => $content ] );
			$this->assertStatusGood( $status,
				"Creation of file at $path succeeded ($backendName)." );

			$size = $this->backend->getFileSize( [ 'src' => $path ] );
			$time = $this->backend->getFileTimestamp( [ 'src' => $path ] );
			$stat = $this->backend->getFileStat( [ 'src' => $path ] );

			$this->assertEquals( strlen( $content ), $size,
				"Correct file size of '$path'" );
			$this->assertTrue( abs( time() - wfTimestamp( TS_UNIX, $time ) ) < 10,
				"Correct file timestamp of '$path'" );

			$size = $stat['size'];
			$time = $stat['mtime'];
			$this->assertEquals( strlen( $content ), $size,
				"Correct file size of '$path'" );
			$this->assertTrue( abs( time() - wfTimestamp( TS_UNIX, $time ) ) < 10,
				"Correct file timestamp of '$path'" );

			$this->backend->clearCache( [ $path ] );

			$size = $this->backend->getFileSize( [ 'src' => $path ] );

			$this->assertEquals( strlen( $content ), $size,
				"Correct file size of '$path'" );

			$this->backend->preloadCache( [ $path ] );

			$size = $this->backend->getFileSize( [ 'src' => $path ] );

			$this->assertEquals( strlen( $content ), $size,
				"Correct file size of '$path'" );
		} else {
			$size = $this->backend->getFileSize( [ 'src' => $path ] );
			$time = $this->backend->getFileTimestamp( [ 'src' => $path ] );
			$stat = $this->backend->getFileStat( [ 'src' => $path ] );

			$this->assertFalse( $size, "Correct file size of '$path'" );
			$this->assertFalse( $time, "Correct file timestamp of '$path'" );
			$this->assertFalse( $stat, "Correct file stat of '$path'" );
		}
	}

	public static function provider_testGetFileStat() {
		$cases = [];

		$base = self::baseStorePath();
		$cases[] = [ "$base/unittest-cont1/e/b/z/some_file.txt", "some file contents", true ];
		$cases[] = [ "$base/unittest-cont1/e/b/some-other_file.txt", "", true ];
		$cases[] = [ "$base/unittest-cont1/e/b/some-diff_file.txt", null, false ];

		return $cases;
	}

	/**
	 * @dataProvider provider_testGetFileStat
	 */
	public function testStreamFile( $path, $content, $alreadyExists ) {
		$backendName = $this->backendClass();

		if ( $content !== null ) {
			$this->prepare( [ 'dir' => dirname( $path ) ] );
			$status = $this->create( [ 'dst' => $path, 'content' => $content ] );
			$this->assertStatusGood( $status,
				"Creation of file at $path succeeded ($backendName)." );

			ob_start();
			$this->backend->streamFile( [ 'src' => $path, 'headless' => 1, 'allowOB' => 1 ] );
			$data = ob_get_contents();
			ob_end_clean();

			$this->assertEquals( $content, $data, "Correct content streamed from '$path'" );
		} else { // 404 case
			ob_start();
			$this->backend->streamFile( [ 'src' => $path, 'headless' => 1, 'allowOB' => 1 ] );
			$data = ob_get_contents();
			ob_end_clean();

			$this->assertMatchesRegularExpression( '#<h1>File not found</h1>#', $data,
				"Correct content streamed from '$path' ($backendName)" );
		}
	}

	public static function provider_testStreamFile() {
		$cases = [];

		$base = self::baseStorePath();
		$cases[] = [ "$base/unittest-cont1/e/b/z/some_file.txt", "some file contents" ];
		$cases[] = [ "$base/unittest-cont1/e/b/some-other_file.txt", null ];

		return $cases;
	}

	public function testStreamFileRange() {
		$backendName = $this->backendClass();

		$base = self::baseStorePath();
		$path = "$base/unittest-cont1/e/b/z/range_file.txt";
		$content = "0123456789ABCDEF";

		$this->prepare( [ 'dir' => dirname( $path ) ] );
		$status = $this->create( [ 'dst' => $path, 'content' => $content ] );
		$this->assertStatusGood( $status,
			"Creation of file at $path succeeded ($backendName)." );

		static $ranges = [
			'bytes=0-0'   => '0',
			'bytes=0-3'   => '0123',
			'bytes=4-8'   => '45678',
			'bytes=15-15' => 'F',
			'bytes=14-15' => 'EF',
			'bytes=-5'    => 'BCDEF',
			'bytes=-1'    => 'F',
			'bytes=10-16' => 'ABCDEF',
			'bytes=10-99' => 'ABCDEF',
		];

		foreach ( $ranges as $range => $chunk ) {
			ob_start();
			$this->backend->streamFile( [ 'src' => $path, 'headless' => 1, 'allowOB' => 1,
				'options' => [ 'range' => $range ] ] );
			$data = ob_get_contents();
			ob_end_clean();

			$this->assertEquals( $chunk, $data, "Correct chunk streamed from '$path' for '$range'" );
		}
	}

	/**
	 * @dataProvider provider_testGetFileContents
	 */
	public function testGetFileContents( $source, $content ) {
		$backendName = $this->backendClass();

		$srcs = (array)$source;
		$content = (array)$content;
		foreach ( $srcs as $i => $src ) {
			$this->prepare( [ 'dir' => dirname( $src ) ] );
			$status = $this->backend->doOperation(
				[ 'op' => 'create', 'content' => $content[$i], 'dst' => $src ] );
			$this->assertStatusGood( $status,
				"Creation of file at $src succeeded ($backendName)." );
		}

		if ( is_array( $source ) ) {
			$contents = $this->backend->getFileContentsMulti( [ 'srcs' => $source ] );
			foreach ( $contents as $path => $data ) {
				$this->assertIsString( $data, "Contents of $path exists ($backendName)." );
				$this->assertEquals(
					current( $content ),
					$data,
					"Contents of $path is correct ($backendName)."
				);
				next( $content );
			}
			$this->assertEquals(
				$source,
				array_keys( $contents ),
				"Contents in right order ($backendName)."
			);
			$this->assertSameSize(
				$source,
				$contents,
				"Contents array size correct ($backendName)."
			);
		} else {
			$data = $this->backend->getFileContents( [ 'src' => $source ] );
			$this->assertIsString( $data, "Contents of $source exists ($backendName)." );
			$this->assertEquals( $content[0], $data, "Contents of $source is correct ($backendName)." );
		}
	}

	public static function provider_testGetFileContents() {
		$cases = [];

		$base = self::baseStorePath();
		$cases[] = [ "$base/unittest-cont1/e/b/z/some_file.txt", "some file contents" ];
		$cases[] = [ "$base/unittest-cont1/e/b/some-other_file.txt", "more file contents" ];
		$cases[] = [
			[ "$base/unittest-cont1/e/a/x.txt", "$base/unittest-cont1/e/a/y.txt",
				"$base/unittest-cont1/e/a/z.txt" ],
			[ "contents xx", "contents xy", "contents xz" ]
		];

		return $cases;
	}

	/**
	 * @dataProvider provider_testGetLocalCopy
	 */
	public function testGetLocalCopy( $source, $content ) {
		$backendName = $this->backendClass();

		$srcs = (array)$source;
		$content = (array)$content;
		foreach ( $srcs as $i => $src ) {
			$this->prepare( [ 'dir' => dirname( $src ) ] );
			$status = $this->backend->doOperation(
				[ 'op' => 'create', 'content' => $content[$i], 'dst' => $src ] );
			$this->assertStatusGood( $status,
				"Creation of file at $src succeeded ($backendName)." );
		}

		if ( is_array( $source ) ) {
			$tmpFiles = $this->backend->getLocalCopyMulti( [ 'srcs' => $source ] );
			foreach ( $tmpFiles as $path => $tmpFile ) {
				$this->assertNotNull( $tmpFile,
					"Creation of local copy of $path succeeded ($backendName)." );
				$contents = file_get_contents( $tmpFile->getPath() );
				$this->assertIsString( $contents, "Local copy of $path exists ($backendName)." );
				$this->assertEquals(
					current( $content ),
					$contents,
					"Local copy of $path is correct ($backendName)."
				);
				next( $content );
			}
			$this->assertEquals(
				$source,
				array_keys( $tmpFiles ),
				"Local copies in right order ($backendName)."
			);
			$this->assertSameSize(
				$source,
				$tmpFiles,
				"Local copies array size correct ($backendName)."
			);
		} else {
			$tmpFile = $this->backend->getLocalCopy( [ 'src' => $source ] );
			$this->assertNotNull( $tmpFile,
				"Creation of local copy of $source succeeded ($backendName)." );
			$contents = file_get_contents( $tmpFile->getPath() );
			$this->assertIsString( $contents, "Local copy of $source exists ($backendName)." );
			$this->assertEquals(
				$content[0],
				$contents,
				"Local copy of $source is correct ($backendName)."
			);
		}

		$obj = (object)[];
		$tmpFile->bind( $obj );
	}

	public static function provider_testGetLocalCopy() {
		$cases = [];

		$base = self::baseStorePath();
		$cases[] = [ "$base/unittest-cont1/e/a/z/some_file.txt", "some file contents" ];
		$cases[] = [ "$base/unittest-cont1/e/a/some-other_file.txt", "more file contents" ];
		$cases[] = [ "$base/unittest-cont1/e/a/\$odd&.txt", "test file contents" ];
		$cases[] = [
			[ "$base/unittest-cont1/e/a/x.txt", "$base/unittest-cont1/e/a/y.txt",
				"$base/unittest-cont1/e/a/z.txt" ],
			[ "contents xx $", "contents xy 111", "contents xz" ]
		];

		return $cases;
	}

	/**
	 * @dataProvider provider_testGetLocalReference
	 */
	public function testGetLocalReference( $source, $content ) {
		$backendName = $this->backendClass();

		$srcs = (array)$source;
		$content = (array)$content;
		foreach ( $srcs as $i => $src ) {
			$this->prepare( [ 'dir' => dirname( $src ) ] );
			$status = $this->backend->doOperation(
				[ 'op' => 'create', 'content' => $content[$i], 'dst' => $src ] );
			$this->assertStatusGood( $status,
				"Creation of file at $src succeeded ($backendName)." );
		}

		if ( is_array( $source ) ) {
			$tmpFiles = $this->backend->getLocalReferenceMulti( [ 'srcs' => $source ] );
			foreach ( $tmpFiles as $path => $tmpFile ) {
				$this->assertNotNull( $tmpFile,
					"Creation of local copy of $path succeeded ($backendName)." );
				$contents = file_get_contents( $tmpFile->getPath() );
				$this->assertIsString( $contents, "Local ref of $path exists ($backendName)." );
				$this->assertEquals(
					current( $content ),
					$contents,
					"Local ref of $path is correct ($backendName)."
				);
				next( $content );
			}
			$this->assertEquals(
				$source,
				array_keys( $tmpFiles ),
				"Local refs in right order ($backendName)."
			);
			$this->assertSameSize(
				$source,
				$tmpFiles,
				"Local refs array size correct ($backendName)."
			);
		} else {
			$tmpFile = $this->backend->getLocalReference( [ 'src' => $source ] );
			$this->assertNotNull( $tmpFile,
				"Creation of local copy of $source succeeded ($backendName)." );
			$contents = file_get_contents( $tmpFile->getPath() );
			$this->assertIsString( $contents, "Local ref of $source exists ($backendName)." );
			$this->assertEquals( $content[0], $contents, "Local ref of $source is correct ($backendName)." );
		}
	}

	public static function provider_testGetLocalReference() {
		$cases = [];

		$base = self::baseStorePath();
		$cases[] = [ "$base/unittest-cont1/e/a/z/some_file.txt", "some file contents" ];
		$cases[] = [ "$base/unittest-cont1/e/a/some-other_file.txt", "more file contents" ];
		$cases[] = [ "$base/unittest-cont1/e/a/\$odd&.txt", "test file contents" ];
		$cases[] = [
			[ "$base/unittest-cont1/e/a/x.txt", "$base/unittest-cont1/e/a/y.txt",
				"$base/unittest-cont1/e/a/z.txt" ],
			[ "contents xx 1111", "contents xy %", "contents xz $" ]
		];

		return $cases;
	}

	public function testGetLocalCopyAndReference404() {
		$backendName = $this->backendClass();

		$base = self::baseStorePath();

		$tmpFile = $this->backend->getLocalCopy( [
			'src' => "$base/unittest-cont1/a/not-there" ] );
		$this->assertFalse( $tmpFile, "Local copy of not existing file is false ($backendName)." );

		$tmpFile = $this->backend->getLocalReference( [
			'src' => "$base/unittest-cont1/a/not-there" ] );
		$this->assertFalse( $tmpFile, "Local ref of not existing file is false ($backendName)." );
	}

	/**
	 * Get a real HTTP request factory, overriding the restrictions on tests that do HTTP requests
	 * @return HttpRequestFactory
	 */
	private function getRealHttpRequestFactory() {
		return new HttpRequestFactory(
			new ServiceOptions(
				HttpRequestFactory::CONSTRUCTOR_OPTIONS,
				$this->getServiceContainer()->getMainConfig()
			),
			LoggerFactory::getInstance( 'http' )
		);
	}

	/**
	 * @dataProvider provider_testGetFileHttpUrl
	 */
	public function testGetFileHttpUrl( $source, $content ) {
		$backendName = $this->backendClass();

		$this->prepare( [ 'dir' => dirname( $source ) ] );
		$status = $this->backend->doOperation(
			[ 'op' => 'create', 'content' => $content, 'dst' => $source ] );
		$this->assertStatusGood( $status,
			"Creation of file at $source succeeded ($backendName)." );

		$url = $this->backend->getFileHttpUrl( [ 'src' => $source ] );

		if ( $url !== null ) { // supported
			$data = $this->getRealHttpRequestFactory()->get( $url, [], __METHOD__ );
			$this->assertEquals( $content, $data,
				"HTTP GET of URL has right contents ($backendName)." );
		}
	}

	public static function provider_testGetFileHttpUrl() {
		$cases = [];

		$base = self::baseStorePath();
		$cases[] = [ "$base/unittest-cont1/e/a/z/some_file.txt", "some file contents" ];
		$cases[] = [ "$base/unittest-cont1/e/a/some-other_file.txt", "more file contents" ];
		$cases[] = [ "$base/unittest-cont1/e/a/\$odd&.txt", "test file contents" ];

		return $cases;
	}

	public function testAddShellboxInputFile() {
		if ( wfIsWindows() ) {
			$this->markTestSkipped( 'This test requires a POSIX environment.' );
		}
		$backendName = $this->backendClass();
		$base = self::baseStorePath();
		$src = "$base/unittest-cont1/e/a/b/fileA.txt";
		$contents = 'test';
		$this->prepare( [ 'dir' => dirname( $src ) ] );
		$status = $this->backend->create( [ 'content' => $contents, 'dst' => $src ] );
		$this->assertStatusGood( $status,
			"Creation of file at $src succeeded ($backendName)." );
		$executor = Shellbox::createBoxedExecutor();
		$executor->setUrlFileClient( $this->getRealHttpRequestFactory()->createGuzzleClient() );
		$command = $executor->createCommand();
		$this->backend->addShellboxInputFile( $command, 'fileA.txt', [ 'src' => $src ] );
		$result = $command
			->params( 'cat', 'fileA.txt' )
			->routeName( 'test' )
			->execute();
		$this->assertSame( 'test', $result->getStdout() );
	}

	public static function provider_testPrepareAndClean() {
		$base = self::baseStorePath();

		return [
			[ "$base/unittest-cont1/e/a/z/some_file1.txt", true ],
			[ "$base/unittest-cont2/a/z/some_file2.txt", true ],
			# Specific to FS backend with no basePath field set
			# [ "$base/unittest-cont3/a/z/some_file3.txt", false ],
		];
	}

	/**
	 * @dataProvider provider_testPrepareAndClean
	 */
	public function testPrepareAndClean( $path, $isOK ) {
		$backendName = $this->backendClass();

		$status = $this->prepare( [ 'dir' => dirname( $path ) ] );
		if ( $isOK ) {
			$this->assertStatusGood( $status,
				"Preparing dir $path succeeded without warnings ($backendName)." );
		} else {
			$this->assertStatusNotOK( $status,
				"Preparing dir $path failed ($backendName)." );
		}

		$status = $this->backend->secure( [ 'dir' => dirname( $path ) ] );
		if ( $isOK ) {
			$this->assertStatusGood( $status,
				"Securing dir $path succeeded without warnings ($backendName)." );
		} else {
			$this->assertStatusNotOK( $status,
				"Securing dir $path failed ($backendName)." );
		}

		$status = $this->backend->publish( [ 'dir' => dirname( $path ) ] );
		if ( $isOK ) {
			$this->assertStatusGood( $status,
				"Publishing dir $path succeeded without warnings ($backendName)." );
		} else {
			$this->assertStatusNotOK( $status,
				"Publishing dir $path failed ($backendName)." );
		}

		$status = $this->backend->clean( [ 'dir' => dirname( $path ) ] );
		if ( $isOK ) {
			$this->assertStatusGood( $status,
				"Cleaning dir $path succeeded without warnings ($backendName)." );
		} else {
			$this->assertStatusNotOK( $status,
				"Cleaning dir $path failed ($backendName)." );
		}
	}

	public function testRecursiveClean() {
		$backendName = $this->backendClass();

		$base = self::baseStorePath();
		$dirs = [
			"$base/unittest-cont1",
			"$base/unittest-cont1/e",
			"$base/unittest-cont1/e/a",
			"$base/unittest-cont1/e/a/b",
			"$base/unittest-cont1/e/a/b/c",
			"$base/unittest-cont1/e/a/b/c/d0",
			"$base/unittest-cont1/e/a/b/c/d1",
			"$base/unittest-cont1/e/a/b/c/d2",
			"$base/unittest-cont1/e/a/b/c/d0/1",
			"$base/unittest-cont1/e/a/b/c/d0/2",
			"$base/unittest-cont1/e/a/b/c/d1/3",
			"$base/unittest-cont1/e/a/b/c/d1/4",
			"$base/unittest-cont1/e/a/b/c/d2/5",
			"$base/unittest-cont1/e/a/b/c/d2/6"
		];
		foreach ( $dirs as $dir ) {
			$status = $this->prepare( [ 'dir' => $dir ] );
			$this->assertStatusGood( $status,
				"Preparing dir $dir succeeded without warnings ($backendName)." );
		}

		if ( $this->backend instanceof FSFileBackend ) {
			foreach ( $dirs as $dir ) {
				$this->assertTrue( $this->backend->directoryExists( [ 'dir' => $dir ] ),
					"Dir $dir exists ($backendName)." );
			}
		}

		$status = $this->backend->clean(
			[ 'dir' => "$base/unittest-cont1", 'recursive' => 1 ] );
		$this->assertStatusGood( $status,
			"Recursive cleaning of dir $dir succeeded without warnings ($backendName)." );

		foreach ( $dirs as $dir ) {
			$this->assertFalse( $this->backend->directoryExists( [ 'dir' => $dir ] ),
				"Dir $dir no longer exists ($backendName)." );
		}
	}

	public function testDoOperationsSuccessful() {
		$base = self::baseStorePath();

		$fileA = "$base/unittest-cont1/e/a/b/fileA.txt";
		$fileAContents = '3tqtmoeatmn4wg4qe-mg3qt3 tq';
		$fileB = "$base/unittest-cont1/e/a/b/fileB.txt";
		$fileBContents = 'g-jmq3gpqgt3qtg q3GT ';
		$fileC = "$base/unittest-cont1/e/a/b/fileC.txt";
		$fileCContents = 'eigna[ogmewt 3qt g3qg flew[ag';
		$fileD = "$base/unittest-cont1/e/a/b/fileD.txt";

		$this->prepare( [ 'dir' => dirname( $fileA ) ] );
		$this->create( [ 'dst' => $fileA, 'content' => $fileAContents ] );
		$this->prepare( [ 'dir' => dirname( $fileB ) ] );
		$this->create( [ 'dst' => $fileB, 'content' => $fileBContents ] );
		$this->prepare( [ 'dir' => dirname( $fileC ) ] );
		$this->create( [ 'dst' => $fileC, 'content' => $fileCContents ] );
		$this->prepare( [ 'dir' => dirname( $fileD ) ] );

		$status = $this->backend->doOperations( [
			[ 'op' => 'describe', 'src' => $fileA,
				'headers' => [ 'X-Content-Length' => '91.3' ], 'disposition' => 'inline' ],
			[ 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC, 'overwrite' => 1 ],
			// Now: A:<A>, B:<B>, C:<A>, D:<empty> (file:<orginal contents>)
			[ 'op' => 'copy', 'src' => $fileC, 'dst' => $fileA, 'overwriteSame' => 1 ],
			// Now: A:<A>, B:<B>, C:<A>, D:<empty>
			[ 'op' => 'move', 'src' => $fileC, 'dst' => $fileD, 'overwrite' => 1 ],
			// Now: A:<A>, B:<B>, C:<empty>, D:<A>
			[ 'op' => 'move', 'src' => $fileB, 'dst' => $fileC ],
			// Now: A:<A>, B:<empty>, C:<B>, D:<A>
			[ 'op' => 'move', 'src' => $fileD, 'dst' => $fileA, 'overwriteSame' => 1 ],
			// Now: A:<A>, B:<empty>, C:<B>, D:<empty>
			[ 'op' => 'move', 'src' => $fileC, 'dst' => $fileA, 'overwrite' => 1 ],
			// Now: A:<B>, B:<empty>, C:<empty>, D:<empty>
			[ 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC ],
			// Now: A:<B>, B:<empty>, C:<B>, D:<empty>
			[ 'op' => 'move', 'src' => $fileA, 'dst' => $fileC, 'overwriteSame' => 1 ],
			// Now: A:<empty>, B:<empty>, C:<B>, D:<empty>
			[ 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ],
			// Does nothing
			[ 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ],
			// Does nothing
			[ 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ],
			// Does nothing
			[ 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ],
			// Does nothing
			[ 'op' => 'null' ],
			// Does nothing
		] );

		$this->assertStatusGood( $status, "Operation batch succeeded" );
		$this->assertCount( 14, $status->success,
			"Operation batch has correct success array" );

		$this->assertFalse( $this->backend->fileExists( [ 'src' => $fileA ] ),
			"File does not exist at $fileA" );
		$this->assertFalse( $this->backend->fileExists( [ 'src' => $fileB ] ),
			"File does not exist at $fileB" );
		$this->assertFalse( $this->backend->fileExists( [ 'src' => $fileD ] ),
			"File does not exist at $fileD" );

		$this->assertTrue( $this->backend->fileExists( [ 'src' => $fileC ] ),
			"File exists at $fileC" );
		$this->assertEquals( $fileBContents,
			$this->backend->getFileContents( [ 'src' => $fileC ] ),
			"Correct file contents of $fileC" );
		$this->assertEquals( strlen( $fileBContents ),
			$this->backend->getFileSize( [ 'src' => $fileC ] ),
			"Correct file size of $fileC" );
		$this->assertEquals( Wikimedia\base_convert( sha1( $fileBContents ), 16, 36, 31 ),
			$this->backend->getFileSha1Base36( [ 'src' => $fileC ] ),
			"Correct file SHA-1 of $fileC" );
	}

	public function testDoOperationsPipeline() {
		$base = self::baseStorePath();

		$fileAContents = '3tqtmoeatmn4wg4qe-mg3qt3 tq';
		$fileBContents = 'g-jmq3gpqgt3qtg q3GT ';
		$fileCContents = 'eigna[ogmewt 3qt g3qg flew[ag';

		$tmpNameA = TempFSFile::factory( "unittests_", 'txt', wfTempDir() )->getPath();
		$tmpNameB = TempFSFile::factory( "unittests_", 'txt', wfTempDir() )->getPath();
		$tmpNameC = TempFSFile::factory( "unittests_", 'txt', wfTempDir() )->getPath();
		$this->addTmpFiles( [ $tmpNameA, $tmpNameB, $tmpNameC ] );
		file_put_contents( $tmpNameA, $fileAContents );
		file_put_contents( $tmpNameB, $fileBContents );
		file_put_contents( $tmpNameC, $fileCContents );

		$fileA = "$base/unittest-cont1/e/a/b/fileA.txt";
		$fileB = "$base/unittest-cont1/e/a/b/fileB.txt";
		$fileC = "$base/unittest-cont1/e/a/b/fileC.txt";
		$fileD = "$base/unittest-cont1/e/a/b/fileD.txt";

		$this->prepare( [ 'dir' => dirname( $fileA ) ] );
		$this->create( [ 'dst' => $fileA, 'content' => $fileAContents ] );
		$this->prepare( [ 'dir' => dirname( $fileB ) ] );
		$this->prepare( [ 'dir' => dirname( $fileC ) ] );
		$this->prepare( [ 'dir' => dirname( $fileD ) ] );

		$status = $this->backend->doOperations( [
			[ 'op' => 'store', 'src' => $tmpNameA, 'dst' => $fileA, 'overwriteSame' => 1 ],
			[ 'op' => 'store', 'src' => $tmpNameB, 'dst' => $fileB, 'overwrite' => 1 ],
			[ 'op' => 'store', 'src' => $tmpNameC, 'dst' => $fileC, 'overwrite' => 1 ],
			[ 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC, 'overwrite' => 1 ],
			// Now: A:<A>, B:<B>, C:<A>, D:<empty> (file:<orginal contents>)
			[ 'op' => 'copy', 'src' => $fileC, 'dst' => $fileA, 'overwriteSame' => 1 ],
			// Now: A:<A>, B:<B>, C:<A>, D:<empty>
			[ 'op' => 'move', 'src' => $fileC, 'dst' => $fileD, 'overwrite' => 1 ],
			// Now: A:<A>, B:<B>, C:<empty>, D:<A>
			[ 'op' => 'move', 'src' => $fileB, 'dst' => $fileC ],
			// Now: A:<A>, B:<empty>, C:<B>, D:<A>
			[ 'op' => 'move', 'src' => $fileD, 'dst' => $fileA, 'overwriteSame' => 1 ],
			// Now: A:<A>, B:<empty>, C:<B>, D:<empty>
			[ 'op' => 'move', 'src' => $fileC, 'dst' => $fileA, 'overwrite' => 1 ],
			// Now: A:<B>, B:<empty>, C:<empty>, D:<empty>
			[ 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC ],
			// Now: A:<B>, B:<empty>, C:<B>, D:<empty>
			[ 'op' => 'move', 'src' => $fileA, 'dst' => $fileC, 'overwriteSame' => 1 ],
			// Now: A:<empty>, B:<empty>, C:<B>, D:<empty>
			[ 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ],
			// Does nothing
			[ 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ],
			// Does nothing
			[ 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ],
			// Does nothing
			[ 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ],
			// Does nothing
			[ 'op' => 'null' ],
			// Does nothing
		] );

		$this->assertStatusGood( $status, "Operation batch succeeded" );
		$this->assertCount( 16, $status->success,
			"Operation batch has correct success array" );

		$this->assertFalse( $this->backend->fileExists( [ 'src' => $fileA ] ),
			"File does not exist at $fileA" );
		$this->assertFalse( $this->backend->fileExists( [ 'src' => $fileB ] ),
			"File does not exist at $fileB" );
		$this->assertFalse( $this->backend->fileExists( [ 'src' => $fileD ] ),
			"File does not exist at $fileD" );

		$this->assertTrue( $this->backend->fileExists( [ 'src' => $fileC ] ),
			"File exists at $fileC" );
		$this->assertEquals( $fileBContents,
			$this->backend->getFileContents( [ 'src' => $fileC ] ),
			"Correct file contents of $fileC" );
		$this->assertEquals( strlen( $fileBContents ),
			$this->backend->getFileSize( [ 'src' => $fileC ] ),
			"Correct file size of $fileC" );
		$this->assertEquals( Wikimedia\base_convert( sha1( $fileBContents ), 16, 36, 31 ),
			$this->backend->getFileSha1Base36( [ 'src' => $fileC ] ),
			"Correct file SHA-1 of $fileC" );
	}

	public function testDoOperationsFailing() {
		$base = self::baseStorePath();

		$fileA = "$base/unittest-cont2/a/b/fileA.txt";
		$fileAContents = '3tqtmoeatmn4wg4qe-mg3qt3 tq';
		$fileB = "$base/unittest-cont2/a/b/fileB.txt";
		$fileBContents = 'g-jmq3gpqgt3qtg q3GT ';
		$fileC = "$base/unittest-cont2/a/b/fileC.txt";
		$fileCContents = 'eigna[ogmewt 3qt g3qg flew[ag';
		$fileD = "$base/unittest-cont2/a/b/fileD.txt";

		$this->prepare( [ 'dir' => dirname( $fileA ) ] );
		$this->create( [ 'dst' => $fileA, 'content' => $fileAContents ] );
		$this->prepare( [ 'dir' => dirname( $fileB ) ] );
		$this->create( [ 'dst' => $fileB, 'content' => $fileBContents ] );
		$this->prepare( [ 'dir' => dirname( $fileC ) ] );
		$this->create( [ 'dst' => $fileC, 'content' => $fileCContents ] );

		$status = $this->backend->doOperations( [
			[ 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC, 'overwrite' => 1 ],
			// Now: A:<A>, B:<B>, C:<A>, D:<empty> (file:<orginal contents>)
			[ 'op' => 'copy', 'src' => $fileC, 'dst' => $fileA, 'overwriteSame' => 1 ],
			// Now: A:<A>, B:<B>, C:<A>, D:<empty>
			[ 'op' => 'copy', 'src' => $fileB, 'dst' => $fileD, 'overwrite' => 1 ],
			// Now: A:<A>, B:<B>, C:<A>, D:<B>
			[ 'op' => 'move', 'src' => $fileC, 'dst' => $fileD ],
			// Now: A:<A>, B:<B>, C:<A>, D:<empty> (failed)
			[ 'op' => 'move', 'src' => $fileB, 'dst' => $fileC, 'overwriteSame' => 1 ],
			// Now: A:<A>, B:<B>, C:<A>, D:<empty> (failed)
			[ 'op' => 'move', 'src' => $fileB, 'dst' => $fileA, 'overwrite' => 1 ],
			// Now: A:<B>, B:<empty>, C:<A>, D:<empty>
			[ 'op' => 'delete', 'src' => $fileD ],
			// Now: A:<B>, B:<empty>, C:<A>, D:<empty>
			[ 'op' => 'null' ],
			// Does nothing
		], [ 'force' => 1 ] );

		$this->assertStatusWarning( 'backend-fail-alreadyexists', $status );
		$this->assertStatusWarning( 'backend-fail-notsame', $status );
		$this->assertCount( 8, $status->success,
			"Operation batch has correct success array" );

		$this->assertFalse( $this->backend->fileExists( [ 'src' => $fileB ] ),
			"File does not exist at $fileB" );
		$this->assertFalse( $this->backend->fileExists( [ 'src' => $fileD ] ),
			"File does not exist at $fileD" );

		$this->assertTrue( $this->backend->fileExists( [ 'src' => $fileA ] ),
			"File does not exist at $fileA" );
		$this->assertTrue( $this->backend->fileExists( [ 'src' => $fileC ] ),
			"File exists at $fileC" );
		$this->assertEquals( $fileBContents,
			$this->backend->getFileContents( [ 'src' => $fileA ] ),
			"Correct file contents of $fileA" );
		$this->assertEquals( strlen( $fileBContents ),
			$this->backend->getFileSize( [ 'src' => $fileA ] ),
			"Correct file size of $fileA" );
		$this->assertEquals( Wikimedia\base_convert( sha1( $fileBContents ), 16, 36, 31 ),
			$this->backend->getFileSha1Base36( [ 'src' => $fileA ] ),
			"Correct file SHA-1 of $fileA" );
	}

	public function testGetFileList() {
		$backendName = $this->backendClass();
		$base = self::baseStorePath();

		// This is null on FSFileBackend, because it knows about all containers
		// that exist, whereas Swift would need to do a remote request, so it
		// just returns an empty iterator.
		$iter = $this->backend->getFileList( [ 'dir' => "$base/unittest-cont-notexists" ] );
		$this->assertThat( $iter, $this->logicalOr( $this->isNull(), $this->countOf( 0 ) ) );

		$files = [
			"$base/unittest-cont1/e/test1.txt",
			"$base/unittest-cont1/e/test2.txt",
			"$base/unittest-cont1/e/test3.txt",
			"$base/unittest-cont1/e/subdir1/test1.txt",
			"$base/unittest-cont1/e/subdir1/test2.txt",
			"$base/unittest-cont1/e/subdir2/test3.txt",
			"$base/unittest-cont1/e/subdir2/test4.txt",
			"$base/unittest-cont1/e/subdir2/subdir/test1.txt",
			"$base/unittest-cont1/e/subdir2/subdir/test2.txt",
			"$base/unittest-cont1/e/subdir2/subdir/test3.txt",
			"$base/unittest-cont1/e/subdir2/subdir/test4.txt",
			"$base/unittest-cont1/e/subdir2/subdir/test5.txt",
			"$base/unittest-cont1/e/subdir2/subdir/sub/test0.txt",
			"$base/unittest-cont1/e/subdir2/subdir/sub/120-px-file.txt",
		];

		// Add the files
		$ops = [];
		foreach ( $files as $file ) {
			$this->prepare( [ 'dir' => dirname( $file ) ] );
			$ops[] = [ 'op' => 'create', 'content' => 'xxy', 'dst' => $file ];
		}
		$status = $this->backend->doQuickOperations( $ops );
		$this->assertStatusGood( $status,
			"Creation of files succeeded ($backendName)." );

		// Expected listing at root
		$expected = [
			"e/test1.txt",
			"e/test2.txt",
			"e/test3.txt",
			"e/subdir1/test1.txt",
			"e/subdir1/test2.txt",
			"e/subdir2/test3.txt",
			"e/subdir2/test4.txt",
			"e/subdir2/subdir/test1.txt",
			"e/subdir2/subdir/test2.txt",
			"e/subdir2/subdir/test3.txt",
			"e/subdir2/subdir/test4.txt",
			"e/subdir2/subdir/test5.txt",
			"e/subdir2/subdir/sub/test0.txt",
			"e/subdir2/subdir/sub/120-px-file.txt",
		];
		sort( $expected );

		// Actual listing (no trailing slash) at root
		$iter = $this->backend->getFileList( [ 'dir' => "$base/unittest-cont1" ] );
		$this->assertNotNull( $iter );
		$list = $this->listToArray( $iter );
		sort( $list );
		$this->assertEquals( $expected, $list, "Correct file listing ($backendName)." );

		// Actual listing (no trailing slash) at root with advise
		$iter = $this->backend->getFileList( [
			'dir' => "$base/unittest-cont1",
			'adviseStat' => 1
		] );
		$this->assertNotNull( $iter );
		$list = $this->listToArray( $iter );
		sort( $list );
		$this->assertEquals( $expected, $list, "Correct file listing ($backendName)." );

		// Actual listing (with trailing slash) at root
		$list = [];
		$iter = $this->backend->getFileList( [ 'dir' => "$base/unittest-cont1/" ] );
		$this->assertNotNull( $iter );
		foreach ( $iter as $file ) {
			$list[] = $file;
		}
		sort( $list );
		$this->assertEquals( $expected, $list, "Correct file listing ($backendName)." );

		// Expected listing at subdir
		$expected = [
			"test1.txt",
			"test2.txt",
			"test3.txt",
			"test4.txt",
			"test5.txt",
			"sub/test0.txt",
			"sub/120-px-file.txt",
		];
		sort( $expected );

		// Actual listing (no trailing slash) at subdir
		$iter = $this->backend->getFileList( [ 'dir' => "$base/unittest-cont1/e/subdir2/subdir" ] );
		$this->assertNotNull( $iter );
		$list = $this->listToArray( $iter );
		sort( $list );
		$this->assertEquals( $expected, $list, "Correct file listing ($backendName)." );

		// Actual listing (no trailing slash) at subdir with advise
		$iter = $this->backend->getFileList( [
			'dir' => "$base/unittest-cont1/e/subdir2/subdir",
			'adviseStat' => 1
		] );
		$this->assertNotNull( $iter );
		$list = $this->listToArray( $iter );
		sort( $list );
		$this->assertEquals( $expected, $list, "Correct file listing ($backendName)." );

		// Actual listing (with trailing slash) at subdir
		$list = [];
		$iter = $this->backend->getFileList( [ 'dir' => "$base/unittest-cont1/e/subdir2/subdir/" ] );
		$this->assertNotNull( $iter );
		foreach ( $iter as $file ) {
			$list[] = $file;
		}
		sort( $list );
		$this->assertEquals( $expected, $list, "Correct file listing ($backendName)." );

		// Actual listing (using iterator second time)
		$list = $this->listToArray( $iter );
		sort( $list );
		$this->assertEquals( $expected, $list, "Correct file listing ($backendName), second iteration." );

		// Actual listing (top files only) at root
		$iter = $this->backend->getTopFileList( [ 'dir' => "$base/unittest-cont1" ] );
		$this->assertNotNull( $iter );
		$list = $this->listToArray( $iter );
		sort( $list );
		$this->assertEquals( [], $list, "Correct top file listing ($backendName)." );

		// Expected listing (top files only) at subdir
		$expected = [
			"test1.txt",
			"test2.txt",
			"test3.txt",
			"test4.txt",
			"test5.txt"
		];
		sort( $expected );

		// Actual listing (top files only) at subdir
		$iter = $this->backend->getTopFileList(
			[ 'dir' => "$base/unittest-cont1/e/subdir2/subdir" ]
		);
		$this->assertNotNull( $iter );
		$list = $this->listToArray( $iter );
		sort( $list );
		$this->assertEquals( $expected, $list, "Correct top file listing ($backendName)." );

		// Actual listing (top files only) at subdir with advise
		$iter = $this->backend->getTopFileList( [
			'dir' => "$base/unittest-cont1/e/subdir2/subdir",
			'adviseStat' => 1
		] );
		$this->assertNotNull( $iter );
		$list = $this->listToArray( $iter );
		sort( $list );
		$this->assertEquals( $expected, $list, "Correct top file listing ($backendName)." );

		foreach ( $files as $file ) { // clean up
			$this->backend->doOperation( [ 'op' => 'delete', 'src' => $file ] );
		}

		$iter = $this->backend->getFileList( [ 'dir' => "$base/unittest-cont1/not/exists" ] );
		$this->assertNotNull( $iter );
		foreach ( $iter as $item ) {
			// no errors
		}
	}

	public function testGetDirectoryList() {
		$backendName = $this->backendClass();

		$base = self::baseStorePath();
		$files = [
			"$base/unittest-cont1/e/test1.txt",
			"$base/unittest-cont1/e/test2.txt",
			"$base/unittest-cont1/e/test3.txt",
			"$base/unittest-cont1/e/subdir1/test1.txt",
			"$base/unittest-cont1/e/subdir1/test2.txt",
			"$base/unittest-cont1/e/subdir2/test3.txt",
			"$base/unittest-cont1/e/subdir2/test4.txt",
			"$base/unittest-cont1/e/subdir2/subdir/test1.txt",
			"$base/unittest-cont1/e/subdir3/subdir/test2.txt",
			"$base/unittest-cont1/e/subdir4/subdir/test3.txt",
			"$base/unittest-cont1/e/subdir4/subdir/test4.txt",
			"$base/unittest-cont1/e/subdir4/subdir/test5.txt",
			"$base/unittest-cont1/e/subdir4/subdir/sub/test0.txt",
			"$base/unittest-cont1/e/subdir4/subdir/sub/120-px-file.txt",
		];

		// Add the files
		$ops = [];
		foreach ( $files as $file ) {
			$this->prepare( [ 'dir' => dirname( $file ) ] );
			$ops[] = [ 'op' => 'create', 'content' => 'xxy', 'dst' => $file ];
		}
		$status = $this->backend->doQuickOperations( $ops );
		$this->assertStatusGood( $status,
			"Creation of files succeeded ($backendName)." );

		$this->assertTrue(
			$this->backend->directoryExists( [ 'dir' => "$base/unittest-cont1/e/subdir1" ] ),
			"Directory exists in ($backendName)."
		);
		$this->assertTrue(
			$this->backend->directoryExists( [ 'dir' => "$base/unittest-cont1/e/subdir2/subdir" ] ),
			"Directory exists in ($backendName)."
		);
		$this->assertFalse(
			$this->backend->directoryExists( [ 'dir' => "$base/unittest-cont1/e/subdir2/test1.txt" ] ),
			"Directory does not exists in ($backendName)."
		);

		// Expected listing
		$expected = [
			"e",
		];
		sort( $expected );

		// Actual listing (no trailing slash)
		$list = [];
		$iter = $this->backend->getTopDirectoryList( [ 'dir' => "$base/unittest-cont1" ] );
		$this->assertNotNull( $iter );
		foreach ( $iter as $file ) {
			$list[] = $file;
		}
		sort( $list );

		$this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." );

		// Expected listing
		$expected = [
			"subdir1",
			"subdir2",
			"subdir3",
			"subdir4",
		];
		sort( $expected );

		// Actual listing (no trailing slash)
		$list = [];
		$iter = $this->backend->getTopDirectoryList( [ 'dir' => "$base/unittest-cont1/e" ] );
		$this->assertNotNull( $iter );
		foreach ( $iter as $file ) {
			$list[] = $file;
		}
		sort( $list );

		$this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." );

		// Actual listing (with trailing slash)
		$list = [];
		$iter = $this->backend->getTopDirectoryList( [ 'dir' => "$base/unittest-cont1/e/" ] );
		$this->assertNotNull( $iter );
		foreach ( $iter as $file ) {
			$list[] = $file;
		}
		sort( $list );

		$this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." );

		// Expected listing
		$expected = [
			"subdir",
		];
		sort( $expected );

		// Actual listing (no trailing slash)
		$list = [];
		$iter = $this->backend->getTopDirectoryList( [ 'dir' => "$base/unittest-cont1/e/subdir2" ] );
		$this->assertNotNull( $iter );
		foreach ( $iter as $file ) {
			$list[] = $file;
		}
		sort( $list );

		$this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." );

		// Actual listing (with trailing slash)
		$list = [];
		$iter = $this->backend->getTopDirectoryList(
			[ 'dir' => "$base/unittest-cont1/e/subdir2/" ]
		);
		$this->assertNotNull( $iter );

		foreach ( $iter as $file ) {
			$list[] = $file;
		}
		sort( $list );

		$this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." );

		// Actual listing (using iterator second time)
		$list = [];
		foreach ( $iter as $file ) {
			$list[] = $file;
		}
		sort( $list );

		$this->assertEquals(
			$expected,
			$list,
			"Correct top dir listing ($backendName), second iteration."
		);

		// Expected listing (recursive)
		$expected = [
			"e",
			"e/subdir1",
			"e/subdir2",
			"e/subdir3",
			"e/subdir4",
			"e/subdir2/subdir",
			"e/subdir3/subdir",
			"e/subdir4/subdir",
			"e/subdir4/subdir/sub",
		];
		sort( $expected );

		// Actual listing (recursive)
		$list = [];
		$iter = $this->backend->getDirectoryList( [ 'dir' => "$base/unittest-cont1/" ] );
		$this->assertNotNull( $iter );
		foreach ( $iter as $file ) {
			$list[] = $file;
		}
		sort( $list );

		$this->assertEquals( $expected, $list, "Correct dir listing ($backendName)." );

		// Expected listing (recursive)
		$expected = [
			"subdir",
			"subdir/sub",
		];
		sort( $expected );

		// Actual listing (recursive)
		$list = [];
		$iter = $this->backend->getDirectoryList( [ 'dir' => "$base/unittest-cont1/e/subdir4" ] );
		$this->assertNotNull( $iter );
		foreach ( $iter as $file ) {
			$list[] = $file;
		}
		sort( $list );

		$this->assertEquals( $expected, $list, "Correct dir listing ($backendName)." );

		// Actual listing (recursive, second time)
		$list = [];
		foreach ( $iter as $file ) {
			$list[] = $file;
		}
		sort( $list );

		$this->assertEquals( $expected, $list, "Correct dir listing ($backendName)." );

		$iter = $this->backend->getDirectoryList( [ 'dir' => "$base/unittest-cont1/e/subdir1" ] );
		$this->assertNotNull( $iter );
		$items = $this->listToArray( $iter );
		$this->assertEquals( [], $items, "Directory listing is empty." );

		foreach ( $files as $file ) { // clean up
			$this->backend->doOperation( [ 'op' => 'delete', 'src' => $file ] );
		}

		$iter = $this->backend->getDirectoryList( [ 'dir' => "$base/unittest-cont1/not/exists" ] );
		$this->assertNotNull( $iter );
		foreach ( $iter as $file ) {
			// no errors
		}

		$items = $this->listToArray( $iter );
		$this->assertEquals( [], $items, "Directory listing is empty." );

		$iter = $this->backend->getDirectoryList( [ 'dir' => "$base/unittest-cont1/e/not/exists" ] );
		$this->assertNotNull( $iter );
		$items = $this->listToArray( $iter );
		$this->assertEquals( [], $items, "Directory listing is empty." );
	}

	public function testLockCalls() {
		$backendName = $this->backendClass();
		$base = $this->backend->getContainerStoragePath( 'test' );

		$paths = [
			"$base/test1.txt",
			"$base/test2.txt",
			"$base/test3.txt",
			"$base/subdir1",
			"$base/subdir1", // duplicate
			"$base/subdir1/test1.txt",
			"$base/subdir1/test2.txt",
			"$base/subdir2",
			"$base/subdir2", // duplicate
			"$base/subdir2/test3.txt",
			"$base/subdir2/test4.txt",
			"$base/subdir2/subdir",
			"$base/subdir2/subdir/test1.txt",
			"$base/subdir2/subdir/test2.txt",
			"$base/subdir2/subdir/test3.txt",
			"$base/subdir2/subdir/test4.txt",
			"$base/subdir2/subdir/test5.txt",
			"$base/subdir2/subdir/sub",
			"$base/subdir2/subdir/sub/test0.txt",
			"$base/subdir2/subdir/sub/120-px-file.txt",
		];

		for ( $i = 0; $i < 2; $i++ ) {
			$status = $this->backend->lockFiles( $paths, LockManager::LOCK_EX );
			$this->assertStatusGood( $status,
				"Locking of files succeeded ($backendName) ($i)." );

			$status = $this->backend->lockFiles( $paths, LockManager::LOCK_SH );
			$this->assertStatusGood( $status,
				"Locking of files succeeded ($backendName) ($i)." );

			$status = $this->backend->unlockFiles( $paths, LockManager::LOCK_SH );
			$this->assertStatusGood( $status,
				"Locking of files succeeded ($backendName) ($i)." );

			$status = $this->backend->unlockFiles( $paths, LockManager::LOCK_EX );
			$this->assertStatusGood( $status,
				"Locking of files succeeded ($backendName). ($i)" );

			# # Flip the acquire/release ordering around ##

			$status = $this->backend->lockFiles( $paths, LockManager::LOCK_SH );
			$this->assertStatusGood( $status,
				"Locking of files succeeded ($backendName) ($i)." );

			$status = $this->backend->lockFiles( $paths, LockManager::LOCK_EX );
			$this->assertStatusGood( $status,
				"Locking of files succeeded ($backendName) ($i)." );

			$status = $this->backend->unlockFiles( $paths, LockManager::LOCK_EX );
			$this->assertStatusGood( $status,
				"Locking of files succeeded ($backendName). ($i)" );

			$status = $this->backend->unlockFiles( $paths, LockManager::LOCK_SH );
			$this->assertStatusGood( $status,
				"Locking of files succeeded ($backendName) ($i)." );
		}

		$status = Status::newGood();
		$sl = $this->backend->getScopedFileLocks( $paths, LockManager::LOCK_EX, $status );
		$this->assertInstanceOf( ScopedLock::class, $sl,
			"Scoped locking of files succeeded ($backendName)." );
		$this->assertStatusGood( $status,
			"Scoped locking of files succeeded ($backendName)." );

		ScopedLock::release( $sl );
		$this->assertNull( $sl,
			"Scoped unlocking of files succeeded ($backendName)." );
		$this->assertStatusGood( $status,
			"Scoped unlocking of files succeeded ($backendName)." );
	}

	// helper function
	private function listToArray( $iter ) {
		return is_array( $iter ) ? $iter : iterator_to_array( $iter );
	}

	// test helper wrapper for backend prepare() function
	private function prepare( array $params ) {
		return $this->backend->prepare( $params );
	}

	// test helper wrapper for backend prepare() function
	private function create( array $params ) {
		$params['op'] = 'create';

		return $this->backend->doQuickOperations( [ $params ] );
	}

	public function tearDownFiles() {
		$containers = [ 'unittest-cont1', 'unittest-cont2' ];
		foreach ( $containers as $container ) {
			$this->deleteFiles( $container );
		}
	}

	private function deleteFiles( $container ) {
		$base = self::baseStorePath();
		$iter = $this->backend->getFileList( [ 'dir' => "$base/$container" ] );
		if ( $iter ) {
			foreach ( $iter as $file ) {
				$this->backend->quickDelete( [ 'src' => "$base/$container/$file" ] );
			}
			// free the directory, to avoid Permission denied under windows on rmdir
			unset( $iter );
		}
		$this->backend->clean( [ 'dir' => "$base/$container", 'recursive' => 1 ] );
	}

	protected function assertBackendPathsConsistent( array $paths, $okSyncStatus ) {
	}

}
PK       ! tWn	  	  1  libs/filebackend/FSFileBackendIntegrationTest.phpnu Iw        <?php

use MediaWiki\Logger\LoggerFactory;
use MediaWiki\WikiMap\WikiMap;
use Wikimedia\FileBackend\FSFileBackend;

/**
 * @group FileRepo
 * @group FileBackend
 * @group medium
 *
 * @covers \Wikimedia\FileBackend\FileBackend
 *
 * @covers \Wikimedia\FileBackend\FileOps\CopyFileOp
 * @covers \Wikimedia\FileBackend\FileOps\CreateFileOp
 * @covers \Wikimedia\FileBackend\FileOps\DeleteFileOp
 * @covers \Wikimedia\FileBackend\FileOps\DescribeFileOp
 * @covers \Wikimedia\FileBackend\FSFile\FSFile
 * @covers \Wikimedia\FileBackend\FSFileBackend
 * @covers \Wikimedia\FileBackend\FileIteration\FSFileBackendDirList
 * @covers \Wikimedia\FileBackend\FileIteration\FSFileBackendFileList
 * @covers \Wikimedia\FileBackend\FileIteration\FSFileBackendList
 * @covers \Wikimedia\FileBackend\FileOpHandle\FSFileOpHandle
 * @covers \FileBackendDBRepoWrapper
 * @covers \Wikimedia\FileBackend\FileBackendError
 * @covers \MediaWiki\FileBackend\FileBackendGroup
 * @covers \Wikimedia\FileBackend\FileBackendMultiWrite
 * @covers \Wikimedia\FileBackend\FileBackendStore
 * @covers \Wikimedia\FileBackend\FileOpHandle\FileBackendStoreOpHandle
 * @covers \Wikimedia\FileBackend\FileIteration\FileBackendStoreShardDirIterator
 * @covers \Wikimedia\FileBackend\FileIteration\FileBackendStoreShardFileIterator
 * @covers \Wikimedia\FileBackend\FileIteration\FileBackendStoreShardListIterator
 * @covers \Wikimedia\FileBackend\FileOps\FileOp
 * @covers \Wikimedia\FileBackend\FileOpBatch
 * @covers \Wikimedia\FileBackend\HTTPFileStreamer
 * @covers \LockManagerGroup
 * @covers \Wikimedia\FileBackend\FileOps\MoveFileOp
 * @covers \Wikimedia\FileBackend\FileOps\NullFileOp
 * @covers \Wikimedia\FileBackend\FileOps\StoreFileOp
 * @covers \Wikimedia\FileBackend\FSFile\TempFSFile
 *
 * @covers \FSLockManager
 * @covers \LockManager
 * @covers \NullLockManager
 */
class FSFileBackendIntegrationTest extends FileBackendIntegrationTestBase {
	protected function getBackend() {
		$tmpDir = $this->getNewTempDirectory();
		$lockManagerGroup = $this->getServiceContainer()
			->getLockManagerGroupFactory()->getLockManagerGroup();
		return new FSFileBackend( [
			'name' => 'localtesting',
			'lockManager' => $lockManagerGroup->get( 'fsLockManager' ),
			'wikiId' => WikiMap::getCurrentWikiId(),
			'logger' => LoggerFactory::getInstance( 'FileOperation' ),
			'containerPaths' => [
				'unittest-cont1' => "{$tmpDir}/localtesting-cont1",
				'unittest-cont2' => "{$tmpDir}/localtesting-cont2" ]
		] );
	}
}
PK       ! {F맯    9  libs/filebackend/FileBackendMultiWriteIntegrationTest.phpnu Iw        <?php

use MediaWiki\Logger\LoggerFactory;
use Wikimedia\FileBackend\FileBackendMultiWrite;
use Wikimedia\FileBackend\FSFileBackend;

/**
 * @group FileRepo
 * @group FileBackend
 * @group medium
 * @covers \Wikimedia\FileBackend\FileBackendMultiWrite
 */
class FileBackendMultiWriteIntegrationTest extends FileBackendIntegrationTestBase {
	protected function getBackend() {
		$tmpDir = $this->getNewTempDirectory();
		$lockManagerGroup = $this->getServiceContainer()
			->getLockManagerGroupFactory()->getLockManagerGroup();
		return new FileBackendMultiWrite( [
			'name' => 'localtesting',
			'lockManager' => $lockManagerGroup->get( 'fsLockManager' ),
			'parallelize' => 'implicit',
			'wikiId' => 'testdb',
			'logger' => LoggerFactory::getInstance( 'FileOperation' ),
			'backends' => [
				[
					'name' => 'localmultitesting1',
					'class' => FSFileBackend::class,
					'containerPaths' => [
						'unittest-cont1' => "{$tmpDir}/localtestingmulti1-cont1",
						'unittest-cont2' => "{$tmpDir}/localtestingmulti1-cont2" ],
					'isMultiMaster' => false
				],
				[
					'name' => 'localmultitesting2',
					'class' => FSFileBackend::class,
					'containerPaths' => [
						'unittest-cont1' => "{$tmpDir}/localtestingmulti2-cont1",
						'unittest-cont2' => "{$tmpDir}/localtestingmulti2-cont2" ],
					'isMultiMaster' => true
				]
			]
		] );
	}

	protected function assertBackendPathsConsistent( array $paths, $okSyncStatus ) {
		$status = $this->backend->consistencyCheck( $paths );
		if ( $okSyncStatus ) {
			$this->assertStatusGood( $status, "Files synced: " . implode( ',', $paths ) );
		} else {
			$this->assertStatusNotOK( $status, "Files not synced: " . implode( ',', $paths ) );
		}
	}

}
PK       ! B  B  #  libs/uuid/GlobalIdGeneratorTest.phpnu Iw        <?php

use Wikimedia\Timestamp\ConvertibleTimestamp;
use Wikimedia\UUID\GlobalIdGenerator;

/**
 * @covers \Wikimedia\UUID\GlobalIdGenerator
 */
class GlobalIdGeneratorTest extends PHPUnit\Framework\TestCase {

	use MediaWikiCoversValidator;
	use MediaWikiTestCaseTrait;

	/** @var GlobalIdGenerator */
	private $globalIdGenerator;

	/**
	 * @dataProvider provider_testTimestampedUID
	 */
	public function testTimestampedUID( $method, $digitlen, $bits, $tbits, $hostbits ) {
		$id = $this->globalIdGenerator->$method();
		$this->assertTrue( ctype_digit( $id ), "UID made of digit characters" );
		$this->assertLessThanOrEqual( $digitlen, strlen( $id ),
			"UID has the right number of digits" );
		$this->assertLessThanOrEqual( $bits, strlen( Wikimedia\base_convert( $id, 10, 2 ) ),
			"UID has the right number of bits" );

		$ids = [];
		for ( $i = 0; $i < 300; $i++ ) {
			$ids[] = $this->globalIdGenerator->$method();
		}

		$lastId = array_shift( $ids );

		$this->assertSame( array_unique( $ids ), $ids, "All generated IDs are unique." );

		foreach ( $ids as $id ) {
			// Convert string to binary and pad to full length so we can
			// extract segments
			$id_bin = Wikimedia\base_convert( $id, 10, 2, $bits );
			$lastId_bin = Wikimedia\base_convert( $lastId, 10, 2, $bits );

			$timestamp_bin = substr( $id_bin, 0, $tbits );
			$last_timestamp_bin = substr( $lastId_bin, 0, $tbits );

			$this->assertGreaterThanOrEqual(
				$last_timestamp_bin,
				$timestamp_bin,
				"timestamp ($timestamp_bin) of current ID ($id_bin) >= timestamp ($last_timestamp_bin) " .
					"of prior one ($lastId_bin)" );

			$hostbits_bin = substr( $id_bin, -$hostbits );
			$last_hostbits_bin = substr( $lastId_bin, -$hostbits );

			if ( $hostbits ) {
				$this->assertEquals(
					$hostbits_bin,
					$last_hostbits_bin,
					"Host ID ($hostbits_bin) of current ID ($id_bin) is same as host ID ($last_hostbits_bin) " .
						"of prior one ($lastId_bin)." );
			}

			$lastId = $id;
		}
	}

	public static function provider_testTimestampedUID() {
		// [ method name, expected length, bits, hostbits ]
		return [
			[ 'newTimestampedUID128', 39, 128, 46, 48 ],
			[ 'newTimestampedUID128', 39, 128, 46, 48 ],
			[ 'newTimestampedUID88', 27, 88, 46, 32 ],
		];
	}

	public function testUUIDv1() {
		$ids = [];
		for ( $i = 0; $i < 100; $i++ ) {
			$id = $this->globalIdGenerator->newUUIDv1();
			$this->assertMatchesRegularExpression(
				'!^[0-9a-f]{8}-[0-9a-f]{4}-1[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$!',
				$id,
				"UID $id has the right format"
			);
			$ids[] = $id;

			$id = $this->globalIdGenerator->newRawUUIDv1();
			$this->assertMatchesRegularExpression(
				'!^[0-9a-f]{12}1[0-9a-f]{3}[89ab][0-9a-f]{15}$!',
				$id,
				"UID $id has the right format"
			);

			$id = $this->globalIdGenerator->newRawUUIDv1();
			$this->assertMatchesRegularExpression(
				'!^[0-9a-f]{12}1[0-9a-f]{3}[89ab][0-9a-f]{15}$!',
				$id,
				"UID $id has the right format"
			);
		}

		$this->assertEquals( array_unique( $ids ), $ids, "All generated IDs are unique." );
	}

	public function testUUIDv4() {
		$ids = [];
		for ( $i = 0; $i < 100; $i++ ) {
			$id = $this->globalIdGenerator->newUUIDv4();
			$ids[] = $id;
			$this->assertMatchesRegularExpression(
				'!^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$!',
				$id,
				"UID $id has the right format"
			);
		}

		$this->assertEquals( array_unique( $ids ), $ids, 'All generated IDs are unique.' );
	}

	public function testRawUUIDv4() {
		for ( $i = 0; $i < 100; $i++ ) {
			$id = $this->globalIdGenerator->newRawUUIDv4();
			$this->assertMatchesRegularExpression(
				'!^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$!',
				$id,
				"UID $id has the right format"
			);
		}
	}

	public function testNewSequentialID() {
		$id1 = $this->globalIdGenerator->newSequentialPerNodeID( 'test', 32 );
		$id2 = $this->globalIdGenerator->newSequentialPerNodeID( 'test', 32 );

		$this->assertIsFloat( $id1, "ID returned as float" );
		$this->assertIsFloat( $id2, "ID returned as float" );
		$this->assertGreaterThan( 0, $id1, "ID greater than 1" );
		$this->assertGreaterThan( $id1, $id2, "IDs increasing in value" );
	}

	public function testNewSequentialIDs() {
		$ids = $this->globalIdGenerator->newSequentialPerNodeIDs( 'test', 32, 5 );
		$lastId = null;
		foreach ( $ids as $id ) {
			$this->assertIsFloat( $id, "ID returned as float" );
			$this->assertGreaterThan( 0, $id, "ID greater than 1" );
			if ( $lastId ) {
				$this->assertGreaterThan( $lastId, $id, "IDs increasing in value" );
			}
			$lastId = $id;
		}
	}

	public static function provideGetTimestampFromUUIDv1() {
		yield [ '65d143b0-3c7a-11ea-b77f-2e728ce88125', '20200121181818' ];
	}

	/**
	 * @param string $uuid
	 * @param string $ts
	 * @dataProvider provideGetTimestampFromUUIDv1
	 */
	public function testGetTimestampFromUUIDv1( string $uuid, string $ts ) {
		$this->assertEquals( $ts, $this->globalIdGenerator->getTimestampFromUUIDv1( $uuid ) );
		$this->assertEquals(
			ConvertibleTimestamp::convert( TS_ISO_8601, $ts ),
			$this->globalIdGenerator->getTimestampFromUUIDv1( $uuid, TS_ISO_8601 )
		);
	}

	public static function provideGetTimestampFromUUIDv1InvalidUUIDv1() {
		yield [ 'this_is_an_invalid_uuid_v1' ];
		yield [ 'e5bb7f6b-0f28-4867-a93c-1b33b5c63adf' ]; // This is a UUIDv4
	}

	/**
	 * @param string $uuid
	 * @dataProvider provideGetTimestampFromUUIDv1InvalidUUIDv1
	 */
	public function testGetTimestampFromUUIDv1InvalidUUIDv1( string $uuid ) {
		$this->expectException( InvalidArgumentException::class );
		$this->globalIdGenerator->getTimestampFromUUIDv1( $uuid );
	}

	protected function setUp(): void {
		$this->globalIdGenerator = new GlobalIdGenerator(
			wfTempDir(),
			static function ( $command ) {
				return wfShellExec( $command );
			}
		);
	}

	protected function tearDown(): void {
		// Clean up - T46850
		$this->globalIdGenerator->unitTestTearDown();
		parent::tearDown();
	}

}
PK       ! )    +  language/LanguageFactoryIntegrationTest.phpnu Iw        <?php
namespace MediaWiki\Tests\Languages;

use MediaWikiIntegrationTestCase;
use Wikimedia\Bcp47Code\Bcp47CodeValue;

/**
 * @group Language
 * @covers \MediaWiki\Languages\LanguageFactory
 */
class LanguageFactoryIntegrationTest extends MediaWikiIntegrationTestCase {
	private function createFactory() {
		return $this->getServiceContainer()->getLanguageFactory();
	}

	/**
	 * @dataProvider provideCodes
	 */
	public function testGetParentLanguage( $code, $ignore, $parent = null ) {
		$factory = $this->createFactory();
		$lang = $factory->getParentLanguage( $code );
		$this->assertSame( $parent, $lang ? $lang->getCode() : null );
	}

	/**
	 * @dataProvider provideCodes
	 */
	public function testGetParentLanguageBcp47Code( $ignore, $bcp47code, $parent = null ) {
		$factory = $this->createFactory();
		$bcp47obj = new Bcp47CodeValue( $bcp47code );
		$lang = $factory->getParentLanguage( $bcp47obj );
		$this->assertSame( $parent, $lang ? $lang->getCode() : null );
	}

	public static function provideCodes() {
		return [
			# Basic codes
			[ 'de', 'de' ],
			[ 'fr', 'fr' ],
			[ 'ja', 'ja' ],
			# Base languages with variants are their own parents
			[ 'en', 'en', 'en' ],
			[ 'sr', 'sr', 'sr' ],
			[ 'crh', 'crh', 'crh' ],
			[ 'zh', 'zh', 'zh' ],
			# Variant codes
			[ 'zh-hans', 'zh-Hans', 'zh' ],
			# Non standard codes
			# Unlike deprecated codes, this *are* valid internal codes and
			# will be returned from Language::getCode()
			[ 'cbk-zam', 'cbk' ],
			[ 'de-formal', 'de-x-formal' ],
			[ 'eml', 'egl' ],
			[ 'en-rtl', 'en-x-rtl' ],
			[ 'es-formal', 'es-x-formal' ],
			[ 'hu-formal', 'hu-x-formal' ],
			[ 'map-bms', 'jv-x-bms' ],
			[ 'mo', 'ro-Cyrl-MD' ],
			[ 'nrm', 'nrf' ],
			[ 'nl-informal', 'nl-x-informal' ],
			[ 'roa-tara', 'nap-x-tara' ],
			[ 'simple', 'en-simple' ],
			[ 'sr-ec', 'sr-Cyrl', 'sr' ],
			[ 'sr-el', 'sr-Latn', 'sr' ],
			[ 'zh-cn', 'zh-Hans-CN', 'zh' ],
			[ 'zh-sg', 'zh-Hans-SG', 'zh' ],
			[ 'zh-my', 'zh-Hans-MY', 'zh' ],
			[ 'zh-tw', 'zh-Hant-TW', 'zh' ],
			[ 'zh-hk', 'zh-Hant-HK', 'zh' ],
			[ 'zh-mo', 'zh-Hant-MO', 'zh' ],
			[ 'zh-hans', 'zh-Hans', 'zh' ],
			[ 'zh-hant', 'zh-Hant', 'zh' ],
		];
	}

}
PK       ! HãJ
  
  !  language/SpecialPageAliasTest.phpnu Iw        <?php

use MediaWiki\Languages\LanguageNameUtils;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;
use UtfNormal\Validator;

/**
 * Verifies that special page aliases are valid, with no slashes.
 *
 * @group Language
 * @group SpecialPageAliases
 * @group SystemTest
 * @group medium
 * @todo This should be a structure test
 *
 * @author Katie Filbert < aude.wiki@gmail.com >
 */
class SpecialPageAliasTest extends MediaWikiIntegrationTestCase {
	/** @var ?array Cache language names */
	private static $langNames = null;

	/**
	 * @throws Exception
	 */
	public static function setUpBeforeClass(): void {
		if ( !self::$langNames ) {
			$langNameUtils = MediaWikiServices::getInstance()->getLanguageNameUtils();
			self::$langNames = $langNameUtils->getLanguageNames(
				LanguageNameUtils::AUTONYMS,
				LanguageNameUtils::SUPPORTED
			);
		}
	}

	/** @return void */
	public static function tearDownAfterClass(): void {
		self::$langNames = null;
	}

	/**
	 * @coversNothing
	 */
	public function testValidSpecialPageAliases() {
		foreach ( $this->validSpecialPageAliasesProvider() as [ $languageCode, $specialPageAliases ] ) {
			foreach ( $specialPageAliases as $specialPage => $aliases ) {
				foreach ( $aliases as $alias ) {
					$msg = "\$specialPageAliases[$languageCode][$specialPage] → '$alias' ";

					$this->assertStringNotContainsString( '/', $alias, $msg .
						'must not contain slashes'
					);

					$this->assertNotNull( Title::makeTitleSafe( NS_SPECIAL, $alias ), $msg .
						'is not a valid title'
					);

					$normalized = Validator::cleanUp( $alias );
					$this->assertSame( $normalized, $alias, $msg .
						'must be normalized UTF-8'
					);

					// Technically this is optional (see LocalisationCache::recache) but good practice
					if ( str_contains( $alias, ' ' ) ) {
						$this->addWarning( $msg .
							'should be in canonical DBkey form with underscores instead of spaces'
						);
					}
				}
			}
		}
	}

	/**
	 * @return Generator
	 */
	public function validSpecialPageAliasesProvider() {
		$languageNameUtils = MediaWikiServices::getInstance()->getLanguageNameUtils();
		foreach ( self::$langNames as $code => $_ ) {
			$specialPageAliases = $this->getSpecialPageAliases( $languageNameUtils, $code );
			if ( $specialPageAliases ) {
				yield [ $code, $specialPageAliases ];
			}
		}
	}

	/**
	 * @param LanguageNameUtils $languageNameUtils
	 * @param string $code
	 *
	 * @return string[][]
	 */
	protected function getSpecialPageAliases( LanguageNameUtils $languageNameUtils, string $code ): array {
		$file = $languageNameUtils->getMessagesFileName( $code );

		if ( is_readable( $file ) ) {
			include $file;
			return $specialPageAliases ?? [];
		}

		return [];
	}

}
PK       ! ;-d  d  (  parser/ParserObserverIntegrationTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use MediaWiki\Parser\ParserObserver;
use MediaWikiIntegrationTestCase;
use TestLogger;

/**
 * @covers \MediaWiki\Parser\ParserObserver
 * @group Database
 */
class ParserObserverIntegrationTest extends MediaWikiIntegrationTestCase {

	/**
	 * @param bool $duplicate
	 * @param int $count
	 *
	 * @dataProvider provideDuplicateParse
	 */
	public function testDuplicateParse( bool $duplicate, int $count ) {
		$logger = new TestLogger( true );
		$observer = new ParserObserver( $logger );
		$this->setService( '_ParserObserver', $observer );
		$contentRenderer = $this->getServiceContainer()->getContentRenderer();
		// Create a test page. Parse it twice if a duplicate is desired, or once otherwise.
		$page = $this->getExistingTestPage();
		$contentRenderer->getParserOutput( $page->getContent(), $page->getTitle() );
		if ( $duplicate ) {
			$contentRenderer->getParserOutput( $page->getContent(), $page->getTitle() );
		}

		$this->assertCount( $count, $logger->getBuffer() );
	}

	public static function provideDuplicateParse() {
		yield [ true, 1 ];
		yield [ false, 0 ];
	}
}
PK       ! BA(      parser/TidyTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use MediaWiki\Parser\MWTidy;
use MediaWiki\Parser\Sanitizer;

/**
 * @group Parser
 * @covers \MediaWiki\Parser\MWTidy
 */
class TidyTest extends \MediaWikiIntegrationTestCase {

	/**
	 * @dataProvider provideTestWrapping
	 */
	public function testTidyWrapping( $expected, $text, $msg = '' ) {
		$text = MWTidy::tidy( $text );
		// We don't care about where Tidy wants to stick is <p>s
		$text = trim( preg_replace( '#</?p>#', '', $text ) );
		// Windows, we love you!
		$text = str_replace( "\r", '', $text );
		$this->assertEquals( $expected, $text, $msg );
	}

	public static function provideTestWrapping() {
		$testMathML = <<<'MathML'
<math xmlns="http://www.w3.org/1998/Math/MathML">
    <mrow>
      <mi>a</mi>
      <mo>&InvisibleTimes;</mo>
      <msup>
        <mi>x</mi>
        <mn>2</mn>
      </msup>
      <mo>+</mo>
      <mi>b</mi>
      <mo>&InvisibleTimes; </mo>
      <mi>x</mi>
      <mo>+</mo>
      <mi>c</mi>
    </mrow>
  </math>
MathML;
		$testMathML = Sanitizer::normalizeCharReferences( $testMathML );
		return [
			[
				'<mw:editsection page="foo" section="bar">foo</mw:editsection>',
				'<mw:editsection page="foo" section="bar">foo</mw:editsection>',
				'<mw:editsection> should survive tidy'
			],
			[
				'<meta property="mw:PageProp/toc" />',
				'<meta property="mw:PageProp/toc" />',
				'TOC_PLACEHOLDER should survive tidy',
			],
			[ "<link foo=\"bar\" />foo", '<link foo="bar"/>foo', '<link> should survive tidy' ],
			[ "<meta foo=\"bar\" />foo", '<meta foo="bar"/>foo', '<meta> should survive tidy' ],
			[ $testMathML, $testMathML, '<math> should survive tidy' ],
		];
	}
}
PK       ! B>N
  N
  %  parser/SanitizerValidateEmailTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser;

use MediaWiki\Parser\Sanitizer;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Parser\Sanitizer::validateEmail
 * @todo should be made a pure unit test once ::validateEmail is migrated to proper DI
 */
class SanitizerValidateEmailTest extends MediaWikiIntegrationTestCase {

	public static function provideValidEmails() {
		yield 'normal #1' => [ 'user@example.com' ];
		yield 'normal #2' => [ 'user@example.museum' ];
		yield 'with uppercase #1' => [ 'USER@example.com' ];
		yield 'with uppercase #2' => [ 'user@EXAMPLE.COM' ];
		yield 'with uppercase #3' => [ 'user@Example.com' ];
		yield 'with uppercase #4' => [ 'USER@eXAMPLE.com' ];
		yield 'with plus #1' => [ 'user+sub@example.com' ];
		yield 'with plus #2' => [ 'user+@example.com' ];
		yield 'TLD not neeeded #1' => [ "user@localhost" ];
		yield 'TLD not neeeded #2' => [ "FooBar@localdomain" ];
		yield 'TLD not neeeded #3' => [ "nobody@mycompany" ];

		yield 'with hythen #1' => [ "user-foo@example.org" ];
		yield 'with hythen #2' => [ "userfoo@ex-ample.org" ];

		yield 'email with dot #1' => [ "user.@localdomain" ];
		yield 'email with dot #2' => [ ".@localdomain" ];

		yield 'funny characters' => [ "\$user!ex{this}@123.com" ];
		yield 'numerical TLD' => [ "user@example.1234" ];
		yield 'only one character needed' => [ 'user@a' ];
	}

	/**
	 * @dataProvider provideValidEmails
	 */
	public function testValidateEmail_valid( string $addr ) {
		$this->assertTrue( Sanitizer::validateEmail( $addr ) );
	}

	public static function provideInvalidEmails() {
		yield 'whitespace before #1' => [ " user@host.com" ];
		yield 'whitespace before #2' => [ "\tuser@host.com" ];
		yield 'whitespace after #1' => [ "user@host.com " ];
		yield 'whitespace after #2' => [ "user@host.com\t" ];
		yield 'with whitespace #1' => [ "User user@host" ];
		yield 'with whitespace #2' => [ "first last@mycompany" ];
		yield 'with whitespace #3' => [ "firstlast@my company" ];

		// T28948 : comma were matched by an incorrect regexp range
		yield 'invalid comma #1' => [ "user,foo@example.org" ];
		yield 'invalid comma #2' => [ "userfoo@ex,ample.org" ];

		yield 'domain beginning with dot #1' => [ "user@." ];
		yield 'domain beginning with dot #2' => [ "user@.localdomain" ];
		yield 'domain beginning with dot #3' => [ "user@localdomain." ];
		yield 'domain beginning with dot #4' => [ ".@a............" ];

		yield 'missing @' => [ 'useràexample.com' ];
	}

	/**
	 * @dataProvider provideInvalidEmails
	 */
	public function testValidateEmail_invalid( string $addr ) {
		$this->assertFalse( Sanitizer::validateEmail( $addr ) );
	}
}
PK       !     $  parser/Parsoid/ParsoidParserTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser\Parsoid;

use MediaWiki\MediaWikiServices;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\Parsoid\ParsoidParser;
use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Parser\Parsoid\ParsoidParser::parse
 * @group Database
 */
class ParsoidParserTest extends MediaWikiIntegrationTestCase {

	/** @dataProvider provideParsoidParserHtml */
	public function testParsoidParserHtml( $args, $expected, $getTextOpts = [] ) {
		$parsoidParser = $this->getServiceContainer()
			->getParsoidParserFactory()->create();
		if ( is_string( $args[1] ?? '' ) ) {
			// Make a PageReference from a string
			$args[1] = Title::newFromText( $args[1] ?? 'Main Page' );
		}
		if ( ( $args[2] ?? null ) === null ) {
			// Make default ParserOptions if none are provided
			$args[2] = ParserOptions::newFromAnon();
		}
		$output = $parsoidParser->parse( ...$args );
		$html = $output->getRawText();
		$this->assertStringContainsString( $expected, $html );
		$this->assertSame(
			$args[1]->getPrefixedDBkey(),
			$output->getExtensionData( ParsoidParser::PARSOID_TITLE_KEY )
		);
		$usedOptions = [
			'collapsibleSections',
			'disableContentConversion',
			'interfaceMessage',
			'isPreview',
			'maxIncludeSize',
			'suppressSectionEditLinks',
			'wrapclass',
		];
		$this->assertEqualsCanonicalizing( $usedOptions, $output->getUsedOptions() );

		$pipeline = MediaWikiServices::getInstance()->getDefaultOutputPipeline();
		$pipeline->run( $output, $args[2], [] );
		$this->assertArrayEquals( $usedOptions, $output->getUsedOptions() );
	}

	public static function provideParsoidParserHtml() {
		return [
			[ [ 'Hello, World' ], 'Hello, World' ],
			[ [ '__NOTOC__' ], '<meta property="mw:PageProp/notoc"' ],
			// Once we support $linestart and other parser options we
			// can extend these tests.
		];
	}

	public function testParsoidParseRevisions() {
		$helloWorld = 'Hello, World';

		$page = $this->getNonexistingTestPage( 'Test' );
		$this->editPage( $page, $helloWorld );
		$pageTitle = $page->getTitle();

		$parsoidParser = $this->getServiceContainer()
			->getParsoidParserFactory()->create();
		$opts = ParserOptions::newFromAnon();
		$output = $parsoidParser->parse(
			$helloWorld,
			$pageTitle,
			$opts,
			true,
			true,
			$page->getRevisionRecord()->getId()
		);
		$html = $output->getRawText();
		$this->assertStringContainsString( $helloWorld, $html );
		$this->assertSame(
			$pageTitle->getPrefixedDBkey(),
			$output->getExtensionData( ParsoidParser::PARSOID_TITLE_KEY )
		);
		$usedOptions = [
			'collapsibleSections',
			'disableContentConversion',
			'interfaceMessage',
			'isPreview',
			'maxIncludeSize',
			'suppressSectionEditLinks',
			'wrapclass',
		];
		$this->assertArrayEquals( $usedOptions, $output->getUsedOptions() );

		$pipeline = MediaWikiServices::getInstance()->getDefaultOutputPipeline();
		$pipeline->run( $output, $opts, [] );
		$this->assertArrayEquals( $usedOptions, $output->getUsedOptions() );
	}
}
PK       ! Oi|!  !  /  parser/Parsoid/LanguageVariantConverterTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser\Parsoid;

use MediaWiki\Language\Language;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\Parsoid\LanguageVariantConverter;
use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter;
use MediaWikiIntegrationTestCase;
use Wikimedia\Bcp47Code\Bcp47CodeValue;
use Wikimedia\Parsoid\Core\PageBundle;
use Wikimedia\Parsoid\Parsoid;

/**
 * @group Database
 * @covers \MediaWiki\Parser\Parsoid\LanguageVariantConverter
 */
class LanguageVariantConverterTest extends MediaWikiIntegrationTestCase {
	public function setUp(): void {
		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, true );
	}

	public static function provideConvertPageBundleVariant() {
		yield 'No source or base, rely on page language (en)' => [
			new PageBundle(
				'<p>test language conversion</p>',
				[ 'parsoid-data' ],
				[ 'mw-data' ],
				Parsoid::defaultHTMLVersion(),
				[]
			),
			null,
			'en-x-piglatin',
			null,
			'>esttay anguagelay onversioncay<'
		];
		yield 'Source variant is base language' => [
			new PageBundle(
				'<p>test language conversion</p>',
				[ 'parsoid-data' ],
				[ 'mw-data' ],
				Parsoid::defaultHTMLVersion(),
				[ 'content-language' => 'en' ]
			),
			null,
			'en-x-piglatin',
			'en',
			'>esttay anguagelay onversioncay<'
		];
		yield 'Source language is null' => [
			new PageBundle(
				'<p>Бутун инсанлар сербестлик, менлик ве укъукъларда мусавий олып дунйагъа келелер.</p>',
				[ 'parsoid-data' ],
				[ 'mw-data' ],
				Parsoid::defaultHTMLVersion(),
				[ 'content-language' => 'crh' ]
			),
			null,
			'crh-Latn',
			null,
			'>Butun insanlar serbestlik, menlik ve uquqlarda musaviy olıp dunyağa keleler.</'
		];
		yield 'Source language is explicit' => [
			new PageBundle(
				'<p>Бутун инсанлар сербестлик, менлик ве укъукъларда мусавий олып дунйагъа келелер.</p>',
				[ 'parsoid-data' ],
				[ 'mw-data' ],
				Parsoid::defaultHTMLVersion(),
				[ 'content-language' => 'crh' ]
			),
			null,
			'crh-Latn',
			'crh-Cyrl',
			'>Butun insanlar serbestlik, menlik ve uquqlarda musaviy olıp dunyağa keleler.</'
		];
		yield 'Content language is provided via HTTP header' => [
			new PageBundle(
				'<p>Бутун инсанлар сербестлик, менлик ве укъукъларда мусавий олып дунйагъа келелер.</p>',
				[ 'parsoid-data' ],
				[ 'mw-data' ],
				Parsoid::defaultHTMLVersion(),
				[ 'content-language' => 'crh-Cyrl' ]
			),
			'crh',
			'crh-Latn',
			'crh-Cyrl',
			'>Butun insanlar serbestlik, menlik ve uquqlarda musaviy olıp dunyağa keleler.</'
		];
		yield 'Content language is variant' => [
			new PageBundle(
				'<p>Бутун инсанлар сербестлик, менлик ве укъукъларда мусавий олып дунйагъа келелер.</p>',
				[ 'parsoid-data' ],
				[ 'mw-data' ],
				Parsoid::defaultHTMLVersion(),
				[]
			),
			'crh-Cyrl',
			'crh-Latn',
			null,
			'>Butun insanlar serbestlik, menlik ve uquqlarda musaviy olıp dunyağa keleler.</'
		];
		yield 'No content-language, but source variant provided' => [
			new PageBundle(
				'<p>Бутун инсанлар сербестлик, менлик ве укъукъларда мусавий олып дунйагъа келелер.</p>',
				[ 'parsoid-data' ],
				[ 'mw-data' ],
				Parsoid::defaultHTMLVersion(),
				[]
			),
			null,
			'crh-Latn',
			'crh-Cyrl',
			'>Butun insanlar serbestlik, menlik ve uquqlarda musaviy olıp dunyağa keleler.</'
		];
		yield 'Source variant is a base language code' => [
			new PageBundle(
				'<p>Бутун инсанлар сербестлик, менлик ве укъукъларда мусавий олып дунйагъа келелер.</p>',
				[ 'parsoid-data' ],
				[ 'mw-data' ],
				Parsoid::defaultHTMLVersion(),
				[]
			),
			null,
			'crh-Latn',
			'crh',
			'>Butun insanlar serbestlik, menlik ve uquqlarda musaviy olıp dunyağa keleler.</'
		];
		yield 'Base language does not support variants' => [
			new PageBundle(
				'<p>Hallo Wereld</p>',
				[ 'parsoid-data' ],
				[ 'mw-data' ],
				Parsoid::defaultHTMLVersion(),
				[]
			),
			'nl',
			'nl-be',
			null,
			'>Hallo Wereld<',
			false // The output language is currently not indicated. Should be expected to be 'nl' in the future.
		];
	}

	/**
	 * @dataProvider provideConvertPageBundleVariant
	 */
	public function testConvertPageBundleVariant(
		PageBundle $pageBundle,
		$contentLanguage,
		$target,
		$source,
		$expected,
		$expectedLanguage = null
	) {
		$expectedLanguage ??= $target;

		$page = $this->getExistingTestPage();
		$languageVariantConverter = $this->getLanguageVariantConverter( $page );
		if ( $contentLanguage ) {
			$contentLanguage = $this->getLanguageBcp47( $contentLanguage );
			$languageVariantConverter->setPageLanguageOverride( $contentLanguage );
		}
		$target = $this->getLanguageBcp47( $target );
		if ( $source ) {
			$source = $this->getLanguageBcp47( $source );
		}

		$outputPageBundle = $languageVariantConverter->convertPageBundleVariant( $pageBundle, $target, $source );

		$html = $outputPageBundle->toHtml();
		$stripped = preg_replace( ':</?span[^>]*>:', '', $html );
		$this->assertStringContainsString( $expected, $stripped );

		if ( $expectedLanguage !== false ) {
			$this->assertMatchesRegularExpression( "@<meta http-equiv=\"content-language\" content=\"($expectedLanguage)\"/>@i", $html );
			$this->assertMatchesRegularExpression( "@^$expectedLanguage@i", $outputPageBundle->headers['content-language'] );
		}
		$this->assertEquals( Parsoid::defaultHTMLVersion(), $outputPageBundle->version );
	}

	public function provideConvertParserOutputVariant() {
		foreach ( $this->provideConvertPageBundleVariant() as $name => $case ) {
			$case[0] = PageBundleParserOutputConverter::parserOutputFromPageBundle( $case[0] );
			yield $name => $case;
		}
	}

	/**
	 * @dataProvider provideConvertParserOutputVariant
	 */
	public function testConvertParserOutputVariant(
		ParserOutput $parserOutput,
		$contentLanguage,
		$target,
		$source,
		$expected,
		$expectedLanguage = null
	) {
		$expectedLanguage ??= $target;

		$page = $this->getExistingTestPage();
		$languageVariantConverter = $this->getLanguageVariantConverter( $page );
		if ( $contentLanguage ) {
			$contentLanguage = $this->getLanguageBcp47( $contentLanguage );
			$languageVariantConverter->setPageLanguageOverride( $contentLanguage );
		}
		$target = $this->getLanguageBcp47( $target );
		if ( $source ) {
			$source = $this->getLanguageBcp47( $source );
		}

		// Set some misc metadata in $parserOutput so we can verify it was
		// preserved.
		$parserOutput->setExtensionData( 'my-key', 'my-data' );

		$modifiedParserOutput = $languageVariantConverter
			->convertParserOutputVariant( $parserOutput, $target, $source );

		$this->assertSame( 'my-data', $modifiedParserOutput->getExtensionData( 'my-key' ) );

		$html = $modifiedParserOutput->getRawText();
		$stripped = preg_replace( ':</?span[^>]*>:', '', $html );
		$this->assertStringContainsString( $expected, $stripped );
		if ( $expectedLanguage !== false ) {
			$this->assertMatchesRegularExpression( "@<meta http-equiv=\"content-language\" content=\"($expectedLanguage)\"/>@i", $html );
		}

		$extensionData = $modifiedParserOutput
			->getExtensionData( PageBundleParserOutputConverter::PARSOID_PAGE_BUNDLE_KEY );
		$this->assertEquals( Parsoid::defaultHTMLVersion(), $extensionData['version'] );

		if ( $expectedLanguage !== false ) {
			$this->assertMatchesRegularExpression( "@^$expectedLanguage@i", $extensionData['headers']['content-language'] );
			$this->assertSame( $expectedLanguage, (string)$modifiedParserOutput->getLanguage() );
		}
	}

	private function getLanguageBcp47( $bcp47Code ): Language {
		$languageFactory = $this->getServiceContainer()->getLanguageFactory();
		return $languageFactory->getLanguage( new Bcp47CodeValue( $bcp47Code ) );
	}

	private function getLanguageVariantConverter( PageIdentity $pageIdentity ): LanguageVariantConverter {
		return new LanguageVariantConverter(
			$pageIdentity,
			$this->getServiceContainer()->getParsoidPageConfigFactory(),
			$this->getServiceContainer()->getService( '_Parsoid' ),
			$this->getServiceContainer()->getParsoidSiteConfig(),
			$this->getServiceContainer()->getTitleFactory(),
			$this->getServiceContainer()->getLanguageConverterFactory(),
			$this->getServiceContainer()->getLanguageFactory()
		);
	}
}
PK       ! gm    (  parser/Parsoid/Config/SiteConfigTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser\Parsoid\Config;

use MediaWiki\Content\TextContentHandler;
use MediaWiki\MainConfigNames;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Parser\Parsoid\Config\SiteConfig
 */
class SiteConfigTest extends MediaWikiIntegrationTestCase {

	public static function provideSupportsContentModels() {
		yield [ CONTENT_MODEL_WIKITEXT, true ];
		yield [ CONTENT_MODEL_JSON, true ];
		yield [ CONTENT_MODEL_JAVASCRIPT, false ];
		yield [ 'with-text', true ];
		yield [ 'xyzzy', false ];
	}

	/**
	 * @dataProvider provideSupportsContentModels
	 */
	public function testSupportsContentModel( $model, $expected ) {
		$contentHandlers = $this->getConfVar( MainConfigNames::ContentHandlers );
		$this->overrideConfigValue( MainConfigNames::ContentHandlers, [
			'with-text' => [ 'factory' => static function () {
				return new TextContentHandler( 'with-text', [ CONTENT_FORMAT_WIKITEXT, 'plain/test' ] );
			} ],
		] + $contentHandlers );

		$this->resetServices();
		$siteConfig = $this->getServiceContainer()->getParsoidSiteConfig();
		$this->assertSame( $expected, $siteConfig->supportsContentModel( $model ) );
	}
}
PK       ! u8
  
  (  parser/Parsoid/Config/DataAccessTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser\Parsoid\Config;

use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Category\TrackingCategories;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Content\Transform\ContentTransformer;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\File\BadFileLookup;
use MediaWiki\Parser\ParserFactory;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\Parsoid\Config\SiteConfig;
use MediaWiki\Title\TitleValue;
use MediaWikiIntegrationTestCase;
use RepoGroup;
use Wikimedia\Parsoid\Config\PageConfig;
use Wikimedia\Rdbms\ReadOnlyMode;

/**
 * @covers \MediaWiki\Parser\Parsoid\Config\DataAccess
 */
class DataAccessTest extends MediaWikiIntegrationTestCase {

	private const DEFAULT_CONFIG = [
		MainConfigNames::SVGMaxSize => 5120,
	];

	private function createMockOrOverride( string $class, array $overrides ) {
		return $overrides[$class] ?? $this->createNoOpMock( $class );
	}

	/**
	 * TODO it might save code to have this helper always return a
	 * TestingAccessWrapper?
	 *
	 * @param array $configOverrides Configuration options overriding default ServiceOptions config defined in
	 *                               DEFAULT_CONFIG above.
	 * @param array $serviceOverrides
	 *
	 * @return DataAccess
	 */
	private function createDataAccess(
		array $configOverrides = [],
		array $serviceOverrides = []
	): SiteConfig {
		return new DataAccess(
			new ServiceOptions(
				DataAccess::CONSTRUCTOR_OPTIONS,
				array_replace( self::DEFAULT_CONFIG, $configOverrides )
			),
			$this->createMockOrOverride( RepoGroup::class, $serviceOverrides ),
			$this->createMockOrOverride( BadFileLookup::class, $serviceOverrides ),
			$this->createMockOrOverride( HookContainer::class, $serviceOverrides ),
			$this->createMockOrOverride( ContentTransformer::class, $serviceOverrides ),
			$this->createMockOrOverride( TrackingCategories::class, $serviceOverrides ),
			$this->createMockOrOverride( ReadOnlyMode::class, $serviceOverrides ),
			$this->createMockOrOverride( ParserFactory::class, $serviceOverrides ),
			$this->createMockOrOverride( LinkBatchFactory::class, $serviceOverrides )
		);
	}

	public function testAddTrackingCategory() {
		$this->overrideConfigValue( MainConfigNames::LanguageCode, 'qqx' );
		$pageConfig = $this->createMock( PageConfig::class );
		$pageConfig->method( 'getLinkTarget' )->willReturn(
			TitleValue::tryNew( NS_MAIN, 'Main Page' )
		);
		$dataAccess = $this->getServiceContainer()->getParsoidDataAccess();
		$parserOutput = new ParserOutput();
		$dataAccess->addTrackingCategory( $pageConfig, $parserOutput, 'broken-file-category' );
		$this->assertSame(
			[ '(broken-file-category)' ],
			$parserOutput->getCategoryNames()
		);
	}
}
PK       ! j}    +  parser/Parsoid/HtmlTransformFactoryTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser\Parsoid;

use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Parser\Parsoid\HtmlToContentTransform;
use MediaWiki\Parser\Parsoid\HtmlTransformFactory;
use MediaWikiIntegrationTestCase;
use Wikimedia\Parsoid\Utils\ContentUtils;
use Wikimedia\Parsoid\Utils\DOMCompat;

/**
 * @coversDefaultClass \MediaWiki\Parser\Parsoid\HtmlTransformFactory
 */
class HtmlTransformFactoryTest extends MediaWikiIntegrationTestCase {
	/**
	 * @covers ::__construct
	 */
	public function testGetContentTransformFactory() {
		$factory = $this->getServiceContainer()->getHtmlTransformFactory();
		$this->assertInstanceOf( HtmlTransformFactory::class, $factory );
	}

	/**
	 * @covers ::getHtmlToContentTransform
	 */
	public function testGetHtmlToContentTransform() {
		$factory = $this->getServiceContainer()->getHtmlTransformFactory();
		$modifiedHTML = '<p>Hello World</p>';

		$transform = $factory->getHtmlToContentTransform(
			$modifiedHTML,
			PageIdentityValue::localIdentity( 0, NS_MAIN, 'Test' )
		);

		$this->assertInstanceOf( HtmlToContentTransform::class, $transform );

		$actualHTML = ContentUtils::toXML( DOMCompat::getBody( $transform->getModifiedDocument() ) );
		$this->assertStringContainsString( $modifiedHTML, $actualHTML );
	}

}
PK       ! !G_	:  	:  -  parser/Parsoid/HtmlToContentTransformTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Parser\Parsoid;

use Composer\Semver\Semver;
use LogicException;
use MediaWiki\Content\JsonContent;
use MediaWiki\Content\WikitextContent;
use MediaWiki\MainConfigNames;
use MediaWiki\MainConfigSchema;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Parser\Parsoid\Config\PageConfig;
use MediaWiki\Parser\Parsoid\HtmlToContentTransform;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWikiIntegrationTestCase;
use Psr\Log\NullLogger;
use Wikimedia\Parsoid\Core\ClientError;
use Wikimedia\Parsoid\Core\SelserData;
use Wikimedia\Parsoid\Parsoid;
use Wikimedia\Parsoid\Utils\ContentUtils;
use Wikimedia\Stats\Emitters\NullEmitter;
use Wikimedia\Stats\StatsCache;
use Wikimedia\Stats\StatsFactory;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Parser\Parsoid\HtmlToContentTransform
 * @group Database
 */
class HtmlToContentTransformTest extends MediaWikiIntegrationTestCase {
	private const MODIFIED_HTML = '<html><head>' .
	'<meta charset="utf-8"/><meta property="mw:htmlVersion" content="2.4.0"/></head>' .
	'<body>Modified HTML</body></html>';

	private const ORIG_BODY = '<body>Original Content</body>';
	private const ORIG_HTML = '<html>' . self::ORIG_BODY . '</html>';
	private const ORIG_DATA_MW = [ 'ids' => [ 'mwAQ' => [] ] ];
	private const ORIG_DATA_PARSOID = [ 'ids' => [ 'mwAQ' => [ 'pi' => [ [ [ 'k' => '1' ] ] ] ] ] ];
	private const MODIFIED_DATA_MW = [ 'ids' => [ 'mwAQ' => [
		'parts' => [ [
			'template' => [
				'target' => [ 'wt' => '1x', 'href' => './Template:1x' ],
				'params' => [ '1' => [ 'wt' => 'hi' ] ],
				'i' => 0
			]
		] ]
	] ] ];

	private function setOriginalData( HtmlToContentTransform $transform ) {
		$transform->setOriginalRevisionId( 1 );
		$transform->setOriginalSchemaVersion( '2.4.0' );
		$transform->setOriginalHtml( self::ORIG_HTML );
		$transform->setOriginalDataMW( self::ORIG_DATA_MW );
		$transform->setOriginalDataParsoid( self::ORIG_DATA_PARSOID );
	}

	private function createHtmlToContentTransform( $html = '' ) {
		return new HtmlToContentTransform(
			$html ?? self::ORIG_HTML,
			PageIdentityValue::localIdentity( 7, NS_MAIN, 'Test' ),
			new Parsoid(
				$this->getServiceContainer()->getParsoidSiteConfig(),
				$this->getServiceContainer()->getParsoidDataAccess()
			),
			MainConfigSchema::getDefaultValue( MainConfigNames::ParsoidSettings ),
			$this->getServiceContainer()->getParsoidPageConfigFactory(),
			$this->getServiceContainer()->getContentHandlerFactory()
		);
	}

	private function createHtmlToContentTransformWithOriginalData( $html = '', ?array $options = null ) {
		$transform = $this->createHtmlToContentTransform( $html );

		$options ??= [
			'contentmodel' => 'wikitext',
			'offsetType' => 'byte',
		];

		// Set some options to assert on $transform object.
		$transform->setOptions( $options );

		$this->setOriginalData( $transform );

		return $transform;
	}

	public function testGetOriginalBodyRequiresValidDataParsoid() {
		$transform = $this->createHtmlToContentTransform( self::ORIG_HTML );
		$transform->setOriginalSchemaVersion( '2.4.0' );
		$transform->setOriginalHtml( self::ORIG_HTML );

		// Invalid data-parsoid structure!
		$transform->setOriginalDataParsoid( [ 'foo' => 'bar' ] );

		$exception = new ClientError( 'Invalid data-parsoid was provided.' );
		$this->expectException( get_class( $exception ) );
		$this->expectExceptionMessage( $exception->getMessage() );

		// Should throw because setOriginalDataParsoid got bad data.
		// Note that setOriginalDataParsoid can't validate immediately, because it
		// may not know the schema version. The order in which the setters are called
		// should not matter. All checks happen in getters.
		$transform->getOriginalBody();
	}

	public function testHasOriginalHtml() {
		$transform = $this->createHtmlToContentTransform( self::MODIFIED_HTML );
		$this->assertFalse( $transform->hasOriginalHtml() );

		$transform->setOriginalDataParsoid( self::ORIG_DATA_PARSOID );
		$this->assertFalse( $transform->hasOriginalHtml() );

		$transform->setOriginalHtml( self::ORIG_HTML );
		$this->assertTrue( $transform->hasOriginalHtml() );
	}

	public function testGetOriginalSchemaVersion() {
		$transform = $this->createHtmlToContentTransform( self::ORIG_HTML ); // no version inline
		$this->assertSame( Parsoid::defaultHTMLVersion(), $transform->getOriginalSchemaVersion() );

		$transform = $this->createHtmlToContentTransform( self::MODIFIED_HTML ); // has version inline
		$this->assertSame( '2.4.0', $transform->getOriginalSchemaVersion() );

		$transform->setOriginalSchemaVersion( '2.3.0' );
		$this->assertSame( '2.3.0', $transform->getOriginalSchemaVersion() );
	}

	public function testGetSchemaVersion() {
		$transform = $this->createHtmlToContentTransform( self::ORIG_HTML ); // no version inline
		$this->assertSame( Parsoid::defaultHTMLVersion(), $transform->getSchemaVersion() );

		// Should have an effect, since the HTML has no version specified inline
		$transform->setOriginalSchemaVersion( '2.3.0' );
		$this->assertSame( '2.3.0', $transform->getSchemaVersion() );

		$transform = $this->createHtmlToContentTransform( self::MODIFIED_HTML ); // has version inline
		$this->assertSame( '2.4.0', $transform->getSchemaVersion() );

		// Should have no impact, since the HTML has a version specified inline
		$transform->setOriginalSchemaVersion( '2.3.0' );
		$this->assertSame( '2.4.0', $transform->getSchemaVersion() );
	}

	public function testHasOriginalDataParsoid() {
		$transform = $this->createHtmlToContentTransform( self::MODIFIED_HTML );
		$this->assertFalse( $transform->hasOriginalDataParsoid() );

		$transform->setOriginalDataParsoid( self::ORIG_DATA_PARSOID );
		$this->assertTrue( $transform->hasOriginalDataParsoid() );
	}

	public function testGetOriginalHtml() {
		$transform = $this->createHtmlToContentTransform( self::MODIFIED_HTML );

		$this->assertFalse( $transform->hasOriginalHtml() );

		$transform->setOriginalSchemaVersion( '2.4.0' );
		$transform->setOriginalHtml( self::ORIG_HTML );

		$this->assertTrue( $transform->hasOriginalHtml() );
		$this->assertSame( self::ORIG_HTML, $transform->getOriginalHtml() );
	}

	public function testGetOriginalBody() {
		$transform = $this->createHtmlToContentTransform( self::MODIFIED_HTML );
		$transform->setOriginalSchemaVersion( '2.4.0' );
		$transform->setOriginalHtml( self::ORIG_HTML );

		$this->assertSame(
			self::ORIG_BODY,
			ContentUtils::toXML( $transform->getOriginalBody() )
		);
	}

	private function assertTransformHasOriginalContent( HtmlToContentTransform $transform, $text ) {
		$this->assertTrue( $transform->knowsOriginalContent() );

		$access = TestingAccessWrapper::newFromObject( $transform );

		/** @var PageConfig $pageConfig */
		$pageConfig = $access->getPageConfig();

		$this->assertSame( $text, $pageConfig->getPageMainContent() );

		/** @var ?SelserData $selserData */
		$selserData = $access->getSelserData();

		$this->assertNotNull( $selserData );
	}

	public function testOldId() {
		$text = 'Lorem Ipsum';
		$rev = $this->editPage( __METHOD__, $text )->getValue()['revision-record'];

		$transform = $this->createHtmlToContentTransformWithOriginalData();
		$transform->setOriginalRevisionId( $rev->getId() );

		$this->assertSame( $rev->getId(), $transform->getOriginalRevisionId() );

		$this->assertTransformHasOriginalContent( $transform, $text );
	}

	public function testSetOriginalRevision() {
		$text = 'Lorem Ipsum';

		$page = PageIdentityValue::localIdentity( 17, NS_MAIN, 'Test' );
		$rev = new MutableRevisionRecord( $page );
		$rev->setContent( SlotRecord::MAIN, new WikitextContent( $text ) );

		$transform = $this->createHtmlToContentTransformWithOriginalData();
		$transform->setOriginalRevision( $rev );
		$this->assertSame( $rev->getId(), $transform->getOriginalRevisionId() );

		$this->assertTransformHasOriginalContent( $transform, $text );
	}

	public function testSetOriginalText() {
		$text = 'Lorem Ipsum';

		$transform = $this->createHtmlToContentTransformWithOriginalData();
		$transform->setOriginalText( $text );

		$this->assertTransformHasOriginalContent( $transform, $text );
	}

	public function testSetOriginalContent() {
		$text = 'Lorem Ipsum';

		$transform = $this->createHtmlToContentTransformWithOriginalData();
		$transform->setOriginalContent( new JsonContent( $text ) );

		$this->assertTransformHasOriginalContent( $transform, $text );
		$this->assertSame( CONTENT_MODEL_JSON, $transform->getContentModel() );
	}

	public function testOptions() {
		$transform = $this->createHtmlToContentTransformWithOriginalData( '', [] );

		$this->assertSame( 'wikitext', $transform->getContentModel() );
		$this->assertSame( 'byte', $transform->getOffsetType() );

		$transform->setOptions( [
			'contentmodel' => 'text',
			'offsetType' => 'ucs2',
		] );

		$this->assertSame( 'text', $transform->getContentModel() );
		$this->assertSame( 'ucs2', $transform->getOffsetType() );
	}

	/**
	 * Assert that in case we set only one of the options, the other(s)
	 * should fall back to their correct defaults.
	 */
	public function testOptionsForIndividualDefaults() {
		$transform = $this->createHtmlToContentTransformWithOriginalData( '', [] );

		$this->assertSame( 'wikitext', $transform->getContentModel() );
		$this->assertSame( 'byte', $transform->getOffsetType() );

		$transform = $this->createHtmlToContentTransformWithOriginalData( '', [] );
		// Set only content model
		$transform->setOptions( [ 'contentmodel' => 'text' ] );

		$this->assertSame( 'text', $transform->getContentModel() );
		$this->assertSame( 'byte', $transform->getOffsetType() );

		$transform = $this->createHtmlToContentTransformWithOriginalData( '', [] );
		// Set only offset type
		$transform->setOptions( [ 'offsetType' => 'ucs2' ] );

		$this->assertSame( 'wikitext', $transform->getContentModel() );
		$this->assertSame( 'ucs2', $transform->getOffsetType() );
	}

	private function getTextFromFile( string $name ): string {
		return trim( file_get_contents( __DIR__ . "/data/Transform/$name" ) );
	}

	public function testDowngrade() {
		$html = $this->getTextFromFile( 'Minimal.html' ); // Uses profile version 2.4.0
		$transform = $this->createHtmlToContentTransform( $html );
		$transform->setMetrics( StatsFactory::newNull() );

		$transform->setOriginalSchemaVersion( '999.0.0' );
		$transform->setOriginalHtml( $html );
		$transform->setOriginalDataParsoid( self::ORIG_DATA_PARSOID );

		// should automatically apply downgrade
		$oldBody = $transform->getOriginalBody();

		// all getters should now reflect the state after the downgrade.
		// we expect a version >= 2.4.0 and < 3.0.0. So use ^2.4.0
		$this->assertTrue( Semver::satisfies( $transform->getOriginalSchemaVersion(), '^2.4.0' ) );
		$this->assertNotSame( $html, $transform->getOriginalHtml() );
		$this->assertNotSame( $oldBody, ContentUtils::toXML( $transform->getOriginalBody() ) );
	}

	public function testModifiedDataMW() {
		$html = $this->getTextFromFile( 'Minimal-999.html' ); // Uses profile version 2.4.0
		$transform = $this->createHtmlToContentTransform( $html );

		$transform->setOriginalHtml( self::ORIG_HTML );
		$transform->setOriginalDataParsoid( self::ORIG_DATA_PARSOID );
		$transform->setModifiedDataMW( self::MODIFIED_DATA_MW );
		// should automatically apply downgrade
		$doc = $transform->getModifiedDocument();
		$html = ContentUtils::toXML( $doc );

		// all getters should now reflect the state after the downgrade.
		$this->assertNotSame( '"hi"', $html );
	}

	public function testMetrics() {
		$html = '<html><body>xyz</body></html>'; // no schema version!
		$transform = $this->createHtmlToContentTransform( $html );

		$statsCache = new StatsCache();
		$statsFactory = new StatsFactory( $statsCache, new NullEmitter(), new NullLogger() );
		$transform->setMetrics( $statsFactory );

		// getSchemaVersion should ioncrement the html2wt.original.version.notinline counter
		// because the input HTML doesn't contain a schema version.
		$transform->getSchemaVersion();
		$this->assertCount( 1, $statsCache->getAllMetrics() );
		$this->assertNotNull(
			$statsCache->get(
				'',
				'html2wt_original_version_total',
				'Wikimedia\Stats\Metrics\CounterMetric'
			)->getName()
		);
	}

	public function testHtmlSize() {
		$html = '<html><body>hällö</body></html>'; // use some multi-byte characters
		$transform = $this->createHtmlToContentTransform( $html );

		// make sure it counts characters, not bytes
		$this->assertSame( 31, $transform->getModifiedHtmlSize() );
	}

	public function testSetOriginalHTML() {
		$html = '<html><body>xyz</body></html>'; // no schema version!
		$transform = $this->createHtmlToContentTransform( $html );

		// mainly check that this doesn't explode.
		$transform->setOriginalSchemaVersion( '999.0.0' );
		$transform->setOriginalHtml( 'hi' );

		$this->assertTrue( $transform->hasOriginalHtml() );
		$this->assertFalse( $transform->hasOriginalDataParsoid() );
	}

	public function testSetOriginalDataParsoidAfterGetModified() {
		// Use HTML that contains a schema version!
		// Otherwise, we won't trigger the right error.
		$html = $this->getTextFromFile( 'Minimal.html' );
		$transform = $this->createHtmlToContentTransform( $html );

		$transform->getModifiedDocument();

		$this->expectException( LogicException::class );
		$this->expectExceptionMessage( 'getModifiedDocument()' );

		$transform->setOriginalDataParsoid( [] );
	}

	public function testOffsetTypeMismatch() {
		$transform = $this->createHtmlToContentTransform( self::ORIG_HTML );
		$this->setOriginalData( $transform );

		// Set some options to assert on $transform.
		$transform->setOptions( [
			'contentmodel' => 'wikitext',
			'offsetType' => 'byte',
		] );

		$dataParsoid = self::ORIG_DATA_PARSOID;
		$dataParsoid['offsetType'] = 'UCS2';

		$transform->setOriginalDataParsoid( $dataParsoid );

		$this->expectException( ClientError::class );
		$transform->getOriginalBody();
	}

	public function testHtmlToWikitextContent() {
		$transform = $this->createHtmlToContentTransform( self::ORIG_HTML );

		// Set some options to assert on $transform.
		$transform->setOptions( [
			'contentmodel' => null,
			'offsetType' => 'byte',
		] );

		$content = $transform->htmlToContent();
		$this->assertInstanceOf( WikitextContent::class, $content );
		$this->assertStringContainsString( 'Original Content', $content->getText() );
	}

	public function testHtmlToJsonContent() {
		$jsonConfigHtml = $this->getTextFromFile( 'JsonConfig.html' );
		$transform = $this->createHtmlToContentTransform( $jsonConfigHtml );

		// Set some options to assert on $transform.
		$transform->setOptions( [
			'contentmodel' => CONTENT_MODEL_JSON,
			'offsetType' => 'byte',
		] );

		$content = $transform->htmlToContent();
		$this->assertInstanceOf( JsonContent::class, $content );
	}
}
PK       !     .  parser/Parsoid/data/Transform/Minimal-999.htmlnu Iw        <!DOCTYPE html>
<html><head><meta charset="utf-8"/><meta property="mw:html:version" content="999.0.0"/></head><body id="mwAA" lang="en" class="mw-content-ltr sitedir-ltr ltr mw-body-content parsoid-body mediawiki mw-parser-output" dir="ltr">123</body></html>
PK       ! 7      *  parser/Parsoid/data/Transform/Minimal.htmlnu Iw        <!DOCTYPE html>
<html><head><meta charset="utf-8"/><meta property="mw:htmlVersion" content="2.4.0"/></head><body id="mwAA" lang="en" class="mw-content-ltr sitedir-ltr ltr mw-body-content parsoid-body mediawiki mw-parser-output" dir="ltr">123</body></html>PK       !      -  parser/Parsoid/data/Transform/JsonConfig.htmlnu Iw        <!DOCTYPE html>
<html prefix="dc: http://purl.org/dc/terms/ mw: http://mediawiki.org/rdf/"><head prefix="mwr: http://en.wikipedia.org/wiki/Special:Redirect/"><meta charset="utf-8"/><meta property="mw:articleNamespace" content="0"/><link rel="dc:isVersionOf" href="//en.wikipedia.org/wiki/Main_Page"/><title></title><base href="//en.wikipedia.org/wiki/"/><link rel="stylesheet" href="//en.wikipedia.org/w/load.php?modules=mediawiki.legacy.commonPrint,shared|mediawiki.skinning.elements|mediawiki.skinning.content|mediawiki.skinning.interface|skins.vector.styles|site|mediawiki.skinning.content.parsoid|ext.cite.style&amp;only=styles&amp;skin=vector"/></head><body lang="en" class="mw-content-ltr sitedir-ltr ltr mw-body mw-body-content mediawiki" dir="ltr"><table class="mw-json mw-json-object"><tbody><tr><th>a</th><td class="value mw-json-number">4</td></tr><tr><th>b</th><td class="value mw-json-number">3</td></tr></tbody></table></body></html>PK       ! eVC  VC    HTMLForm/HTMLFormFieldTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\HTMLForm;

use DomainException;
use InvalidArgumentException;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\HTMLForm\Field\HTMLFormFieldCloner;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\HTMLForm\HTMLFormField;
use MediaWiki\Message\Message;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use MediaWikiCoversValidator;
use MediaWikiIntegrationTestCase;
use StatusValue;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\HTMLForm\HTMLFormField
 */
class HTMLFormFieldTest extends MediaWikiIntegrationTestCase {
	use MediaWikiCoversValidator;

	public function getNewForm( $descriptor, $requestData = [] ) {
		$requestData += [ 'wpEditToken' => 'ABC123' ];
		$request = new FauxRequest( $requestData, true );
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setRequest( $request );
		$form = HTMLForm::factory( 'ooui', $descriptor, $context );
		$form->setTitle( Title::makeTitle( NS_MAIN, 'Main Page' ) )->setSubmitCallback( static function () {
			return true;
		} )->prepareForm();
		$status = $form->trySubmit();
		$this->assertTrue( $status );
		return $form;
	}

	/**
	 * @dataProvider provideCondState
	 */
	public function testCondState( $fieldInfo, $requestData, $callback, $exception = null ) {
		if ( $exception ) {
			$this->expectException( $exception[0] );
			$this->expectExceptionMessageMatches( $exception[1] );
		}
		$form = $this->getNewForm( array_merge_recursive( $fieldInfo, [
			'check1' => [ 'type' => 'check' ],
			'check2' => [ 'type' => 'check', 'invert' => true ],
			'check3' => [ 'type' => 'check', 'name' => 'foo' ],
			'select1' => [ 'type' => 'select', 'options' => [ 'a' => 'a', 'b' => 'b', 'c' => 'c' ], 'default' => 'b' ],
			'text1' => [ 'type' => 'text' ],
			'cloner' => [
				'class' => HTMLFormFieldCloner::class,
				'fields' => [
					'check1' => [ 'type' => 'check' ],
					'check2' => [ 'type' => 'check', 'invert' => true ],
					'check3' => [ 'type' => 'check', 'name' => 'foo' ],
				]
			]
		] ), $requestData );
		$callback( $form, $form->mFieldData );
	}

	public function provideCondState() {
		yield 'Field hidden if "check" field is checked' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ '===', 'check1', '1' ] ],
			],
			'requestData' => [
				'wpcheck1' => '1',
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'text1' )->isHidden( $fieldData ) );
			}
		];
		yield 'Field hidden if "check" field is not checked' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ '===', 'check1', '' ] ],
			],
			'requestData' => [],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'text1' )->isHidden( $fieldData ) );
			}
		];
		yield 'Field not hidden if "check" field is not checked' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ '===', 'check1', '1' ] ],
			],
			'requestData' => [],
			'callback' => function ( $form, $fieldData ) {
				$this->assertFalse( $form->getField( 'text1' )->isHidden( $fieldData ) );
			}
		];
		yield 'Field hidden if "check" field (invert) is checked' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ '===', 'check2', '1' ] ],
			],
			'requestData' => [
				'wpcheck2' => '1',
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'text1' )->isHidden( $fieldData ) );
			}
		];
		yield 'Field hidden if "check" field (invert) is not checked' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ '!==', 'check2', '1' ] ],
			],
			'requestData' => [],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'text1' )->isHidden( $fieldData ) );
			}
		];
		yield 'Field not hidden if "check" field (invert) is checked' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ '!==', 'check2', '1' ] ],
			],
			'requestData' => [
				'wpcheck2' => '1',
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertFalse( $form->getField( 'text1' )->isHidden( $fieldData ) );
			}
		];
		yield 'Field hidden if "select" field has value' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ '===', 'select1', 'a' ] ],
			],
			'requestData' => [
				'wpselect1' => 'a',
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'text1' )->isHidden( $fieldData ) );
			}
		];
		yield 'Field hidden if "text" field has value' => [
			'fieldInfo' => [
				'select1' => [ 'hide-if' => [ '===', 'text1', 'hello' ] ],
			],
			'requestData' => [
				'wptext1' => 'hello',
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'select1' )->isHidden( $fieldData ) );
			}
		];

		yield 'Field hidden using AND conditions' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ 'AND',
					[ '===', 'check1', '1' ],
					[ '===', 'select1', 'a' ]
				] ],
			],
			'requestData' => [
				'wpcheck1' => '1',
				'wpselect1' => 'a',
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'text1' )->isHidden( $fieldData ) );
			}
		];
		yield 'Field hidden using OR conditions' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ 'OR',
					[ '===', 'check1', '1' ],
					[ '===', 'select1', 'a' ]
				] ],
			],
			'requestData' => [
				'wpcheck1' => '1',
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'text1' )->isHidden( $fieldData ) );
			}
		];
		yield 'Field hidden using NAND conditions' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ 'NAND',
					[ '===', 'check1', '1' ],
					[ '===', 'select1', 'a' ]
				] ],
			],
			'requestData' => [
				'wpcheck1' => '1',
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'text1' )->isHidden( $fieldData ) );
			}
		];
		yield 'Field hidden using NOR conditions' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ 'NOR',
					[ '===', 'check1', '1' ],
					[ '===', 'select1', 'a' ]
				] ],
			],
			'requestData' => [],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'text1' )->isHidden( $fieldData ) );
			}
		];
		yield 'Field hidden using complex conditions' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ 'OR',
					[ 'NOT', [ 'AND',
						[ '===', 'check1', '1' ],
						[ '===', 'check2', '1' ]
					] ],
					[ '===', 'select1', 'a' ]
				] ],
			],
			'requestData' => [],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'text1' )->isHidden( $fieldData ) );
			}
		];

		yield 'Invalid conditional specification (unsupported)' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ '>', 'test1', '10' ] ],
			],
			'requestData' => [],
			'callback' => null,
			'exception' => [ InvalidArgumentException::class, '/Unknown operation/' ],
		];
		yield 'Invalid conditional specification (NOT)' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ 'NOT', '===', 'check1', '1' ] ],
			],
			'requestData' => [],
			'callback' => null,
			'exception' => [ InvalidArgumentException::class, '/NOT takes exactly one parameter/' ],
		];
		yield 'Invalid conditional specification (AND/OR/NAND/NOR)' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ 'AND', '===', 'check1', '1' ] ],
			],
			'requestData' => [],
			'callback' => null,
			'exception' => [ InvalidArgumentException::class, '/Expected array, found string/' ],
		];
		yield 'Invalid conditional specification (===/!==) 1' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ '===', 'check1' ] ],
			],
			'requestData' => [],
			'callback' => null,
			'exception' => [ InvalidArgumentException::class, '/=== takes exactly two parameters/' ],
		];
		yield 'Invalid conditional specification (===/!==) 2' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ '===', [ '===', 'check1', '1' ], '1' ] ],
			],
			'requestData' => [],
			'callback' => null,
			'exception' => [ InvalidArgumentException::class, '/Parameters for === must be strings/' ],
		];

		yield 'Field disabled if "check" field is checked' => [
			'fieldInfo' => [
				'text1' => [ 'disable-if' => [ '===', 'check1', '1' ] ],
			],
			'requestData' => [
				'wpcheck1' => '1',
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'text1' )->isDisabled( $fieldData ) );
			}
		];
		yield 'Field disabled if hidden' => [
			'fieldInfo' => [
				'text1' => [ 'hide-if' => [ '===', 'check1', '1' ] ],
			],
			'requestData' => [
				'wpcheck1' => '1',
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'text1' )->isDisabled( $fieldData ) );
			}
		];

		yield 'Field disabled even the field it relied on is named' => [
			'fieldInfo' => [
				'text1' => [ 'disable-if' => [ '===', 'check3', '1' ] ],
			],
			'requestData' => [
				'foo' => '1',
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'text1' )->isDisabled( $fieldData ) );
			}
		];
		yield 'Field disabled even the \'wp\' prefix is used (back-compat)' => [
			'fieldInfo' => [
				'text1' => [ 'disable-if' => [ '===', 'wpcheck1', '1' ] ],
			],
			'requestData' => [
				'wpcheck1' => '1',
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $form->getField( 'text1' )->isDisabled( $fieldData ) );
			}
		];
		yield 'Field name does not exist' => [
			'fieldInfo' => [
				'text1' => [ 'disable-if' => [ '===', 'foo', '1' ] ],
			],
			'requestData' => [],
			'callback' => null,
			'exception' => [ DomainException::class, '/no field named foo/' ],
		];

		yield 'Field disabled in cloner if "check" field is checked' => [
			'fieldInfo' => [
				'cloner' => [ 'fields' => [
					'check2' => [ 'disable-if' => [ '===', 'check1', '1' ] ],
				] ]
			],
			'requestData' => [
				'wpcloner' => [ 0 => [ 'check1' => '1' ] ],
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $this->getFieldInCloner( $form, 'cloner', 0, 'check2' )
					->isDisabled( $fieldData ) );
			}
		];
		yield 'Field disabled in cloner if "check" (invert) field is checked' => [
			'fieldInfo' => [
				'cloner' => [ 'fields' => [
					'check1' => [ 'disable-if' => [ '===', 'check2', '1' ] ],
				] ]
			],
			'requestData' => [
				'wpcloner' => [ 0 => [ 'check2' => '1' ] ],
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $this->getFieldInCloner( $form, 'cloner', 0, 'check1' )
					->isDisabled( $fieldData ) );
			}
		];
		yield 'Field disabled in cloner if "check" (named) field is checked' => [
			'fieldInfo' => [
				'cloner' => [ 'fields' => [
					'check1' => [ 'disable-if' => [ '===', 'check3', '1' ] ],
				] ]
			],
			'requestData' => [
				'wpcloner' => [ 0 => [ 'foo' => '1' ] ],
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $this->getFieldInCloner( $form, 'cloner', 0, 'check1' )
					->isDisabled( $fieldData ) );
			}
		];
		yield 'Field disabled in cloner if "select" (outside) field has value' => [
			'fieldInfo' => [
				'cloner' => [ 'fields' => [
					'check1' => [ 'disable-if' => [ '===', 'select1', 'a' ] ],
				] ]
			],
			'requestData' => [
				'wpselect1' => 'a',
			],
			'callback' => function ( $form, $fieldData ) {
				$this->assertTrue( $this->getFieldInCloner( $form, 'cloner', 0, 'check1' )
					->isDisabled( $fieldData ) );
			}
		];
	}

	private function getFieldInCloner( $form, $clonerName, $index, $fieldName ) {
		$cloner = TestingAccessWrapper::newFromObject( $form->getField( $clonerName ) );
		return $cloner->getFieldsForKey( $index )[$fieldName];
	}

	/**
	 * @dataProvider provideParseCondState
	 */
	public function testParseCondState( $fieldName, $condState, $excepted ) {
		$form = $this->getNewForm( [
			'normal' => [ 'type' => 'check' ],
			'named' => [ 'type' => 'check', 'name' => 'foo' ],
			'test' => [ 'type' => 'text' ],
			'cloner' => [
				'class' => HTMLFormFieldCloner::class,
				'fields' => [
					'normal' => [ 'type' => 'check' ],
					'named' => [ 'type' => 'check', 'name' => 'foo' ],
					'test' => [ 'type' => 'text' ],
				]
			]
		], [] );
		$field = $form->getField( $fieldName ?? 'test' );
		$wrapped = TestingAccessWrapper::newFromObject( $field );
		if ( $field instanceof HTMLFormFieldCloner ) {
			$field = $wrapped->getFieldsForKey( 0 )['test'];
			$wrapped = TestingAccessWrapper::newFromObject( $field );
		}
		$parsed = $wrapped->parseCondState( $condState );
		$this->assertSame( $excepted, $parsed );
	}

	public static function provideParseCondState() {
		yield 'Normal' => [
			null,
			[ '===', 'normal', '1' ],
			[ '===', 'wpnormal', '1' ],
		];
		yield 'With the \'wp\' prefix' => [
			null,
			[ '===', 'wpnormal', '1' ],
			[ '===', 'wpnormal', '1' ],
		];
		yield 'Named' => [
			null,
			[ '===', 'named', '1' ],
			[ '===', 'foo', '1' ],
		];

		yield 'Normal in cloner' => [
			'cloner',
			[ '===', 'normal', '1' ],
			[ '===', 'wpcloner[0][normal]', '1' ],
		];
		yield 'Named in cloner' => [
			'cloner',
			[ '===', 'named', '1' ],
			[ '===', 'wpcloner[0][foo]', '1' ],
		];
	}

	public function testNoticeInfo() {
		$form = $this->getNewForm( [
			'withNotice' => [ 'type' => 'check', 'notices' => [ 'a notice' ] ],
			'withoutNotice' => [ 'type' => 'check' ],
		], [] );

		$configWithNotice = $configWithoutNotice = [];
		$form->getField( 'withNotice' )->getOOUI( '' )->getConfig( $configWithNotice );
		$form->getField( 'withoutNotice' )->getOOUI( '' )->getConfig( $configWithoutNotice );

		$this->assertArrayHasKey( 'notices', $configWithNotice );
		$this->assertSame(
			[ 'a notice' ],
			$configWithNotice['notices']
		);
		$this->assertArrayNotHasKey( 'notices', $configWithoutNotice );
	}

	/**
	 * @dataProvider provideCallables
	 */
	public function testValidationCallbacks( callable $callable ) {
		$field = new class( [
			'parent' => $this->getNewForm( [] ),
			'fieldname' => __FUNCTION__,
			'validation-callback' => $callable
		] ) extends HTMLFormField {
			public function getInputHTML( $value ) {
				return '';
			}
		};

		$this->assertTrue( $field->validate( '', [] ) );
	}

	public static function provideCallables() {
		$callable = new class() {
			public function validate( $value, array $fields, HTMLForm $form ): bool {
				return $value || $fields || $form->wasSubmitted();
			}

			public static function validateStatic( $value, array $fields, HTMLForm $form ): bool {
				return $value || $fields || $form->wasSubmitted();
			}

			public function __invoke( ...$values ): bool {
				return self::validateStatic( ...$values );
			}
		};

		return [
			'Closure (short)' => [
				static fn ( $value, array $fields, HTMLForm $form ) => $value || $fields || $form->wasSubmitted()
			],
			'Closure (traditional)' => [
				static function ( $value, array $fields, HTMLForm $form ) {
					return $value || $fields || $form->wasSubmitted();
				}
			],
			'Array' => [ [ $callable, 'validate' ] ],
			'Array (static)' => [ [ get_class( $callable ), 'validateStatic' ] ],
			'String' => [ get_class( $callable ) . '::validateStatic' ],
			'Invokable' => [ $callable ]
		];
	}

	/**
	 * @dataProvider provideValidationResults
	 */
	public function testValidationCallbackResults( $callbackResult, $expected ) {
		$field = new class( [
			'parent' => $this->getNewForm( [] ),
			'fieldname' => __FUNCTION__,
			'validation-callback' => static fn () => $callbackResult
		] ) extends HTMLFormField {
			public function getInputHTML( $value ) {
				return '';
			}
		};

		$this->assertEquals( $expected, $field->validate( '', [] ) );
	}

	public static function provideValidationResults() {
		$ok = ( new Status() )
			->warning( 'test-warning' )
			->setOK( true );

		return [
			'Ok Status' => [ $ok, "<p>⧼test-warning⧽\n</p>" ],
			'Good Status' => [ Status::newGood(), true ],
			'Fatal Status' => [ Status::newFatal( 'test-fatal' ), "<p>⧼test-fatal⧽\n</p>" ],
			'Good StatusValue' => [ StatusValue::newGood(), true ],
			'Fatal StatusValue' => [ Status::newFatal( 'test-fatal' ), "<p>⧼test-fatal⧽\n</p>" ],
			'String' => [ '<strong>Invalid input</strong>', '<strong>Invalid input</strong>' ],
			'True' => [ true, true ],
			'False' => [ false, false ]
		];
	}

	public function testValidationCallbackResultMessage() {
		$message = $this->createMock( Message::class );

		$this->testValidationCallbackResults( $message, $message );
	}

	/**
	 * @dataProvider provideValues
	 */
	public function testValidateWithRequiredNotGiven( $value ) {
		$field = new class( [
			'parent' => $this->getNewForm( [] ),
			'fieldname' => __FUNCTION__,
			'required' => true
		] ) extends HTMLFormField {
			public function getInputHTML( $value ) {
				return '';
			}
		};

		$returnValue = $field->validate( $value, [ 'text' => $value ] );

		$this->assertInstanceOf( Message::class, $returnValue );
		$this->assertEquals( 'htmlform-required', $returnValue->getKey() );
	}

	public static function provideValues() {
		return [
			'Empty string' => [ '' ],
			'False' => [ false ],
			'Null' => [ null ]
		];
	}
}
PK       ! =
  
    HTMLForm/HTMLFormTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\HTMLForm;

use LogicException;
use MediaWiki\Context\RequestContext;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Language\RawMessage;
use MediaWiki\Output\OutputPage;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\HTMLForm\HTMLForm
 *
 * @license GPL-2.0-or-later
 * @author Gergő Tisza
 */
class HTMLFormTest extends MediaWikiIntegrationTestCase {

	private function newInstance() {
		$context = new RequestContext();
		$out = new OutputPage( $context );
		$out->setTitle( Title::newMainPage() );
		$context->setOutput( $out );
		$form = new HTMLForm( [], $context );
		$form->setTitle( Title::makeTitle( NS_MAIN, 'Foo' ) );
		return $form;
	}

	public function testGetHTML_empty() {
		$form = $this->newInstance();
		$form->prepareForm();
		$html = $form->getHTML( false );
		$this->assertStringStartsWith( '<form ', $html );
	}

	public function testGetHTML_noPrepare() {
		$form = $this->newInstance();
		$this->expectException( LogicException::class );
		$form->getHTML( false );
	}

	public function testAutocompleteDefaultsToNull() {
		$form = $this->newInstance();
		$this->assertStringNotContainsString( 'autocomplete', $form->wrapForm( '' ) );
	}

	public function testAutocompleteWhenSetToNull() {
		$form = $this->newInstance();
		$form->setAutocomplete( null );
		$this->assertStringNotContainsString( 'autocomplete', $form->wrapForm( '' ) );
	}

	public function testAutocompleteWhenSetToFalse() {
		$form = $this->newInstance();
		// Previously false was used instead of null to indicate the attribute should not be set
		$form->setAutocomplete( false );
		$this->assertStringNotContainsString( 'autocomplete', $form->wrapForm( '' ) );
	}

	public function testAutocompleteWhenSetToOff() {
		$form = $this->newInstance();
		$form->setAutocomplete( 'off' );
		$this->assertStringContainsString( ' autocomplete="off"', $form->wrapForm( '' ) );
	}

	public function testGetPreText() {
		$this->hideDeprecated( HTMLForm::class . '::setPreText' );
		$this->hideDeprecated( HTMLForm::class . '::getPreText' );
		$this->hideDeprecated( HTMLForm::class . '::addPreText' );

		$preText = 'TEST';
		$form = $this->newInstance();
		$form->setPreText( $preText );
		$this->assertSame( $preText, $form->getPreText() );
		$form->addPreText( $preText );
		$this->assertSame( $preText . $preText, $form->getPreText() );
	}

	public function testGetPreHtml() {
		$this->hideDeprecated( HTMLForm::class . '::setIntro' );

		$preHtml = 'TEST';
		$form = $this->newInstance();
		$form->setPreHtml( $preHtml );
		$this->assertSame( $preHtml, $form->getPreHtml() );
		$preHtml = 'TEST2';
		$form->setIntro( $preHtml );
		$this->assertSame( $preHtml, $form->getPreHtml() );
		$preHtml = 'TEST';
		$form->addPreHtml( $preHtml );
		$this->assertSame( $preHtml . '2' . $preHtml, $form->getPreHtml() );
	}

	public function testGetPostHtml() {
		$this->hideDeprecated( HTMLForm::class . '::setPostText' );
		$this->hideDeprecated( HTMLForm::class . '::addPostText' );

		$postHtml = 'TESTED';
		$form = $this->newInstance();
		$form->setPostHtml( $postHtml );
		$this->assertSame( $postHtml, $form->getPostHtml() );
		$postHtml = 'TESTED2';
		$form->setPostText( $postHtml );
		$this->assertSame( $postHtml, $form->getPostHtml() );
		$postHtml = 'TESTED';
		$form->addPostHtml( $postHtml );
		$this->assertSame( $postHtml . '2' . $postHtml, $form->getPostHtml() );
		$form->addPostText( $postHtml );
		$this->assertSame( $postHtml . '2' . $postHtml . $postHtml, $form->getPostHtml() );
	}

	public function testCollapsible() {
		$form = $this->newInstance();
		$form->prepareForm()->getHTML( '' );
		$this->assertContains( 'mediawiki.htmlform', $form->getOutput()->getModules() );
		$this->assertNotContains( 'jquery.makeCollapsible', $form->getOutput()->getModules() );

		$form = $this->newInstance();
		$form->setCollapsibleOptions( null );
		$form->prepareForm()->getHTML( '' );
		$this->assertContains( 'jquery.makeCollapsible', $form->getOutput()->getModules() );

		$form = $this->newInstance();
		$form->setCollapsibleOptions( false );
		$form->prepareForm()->getHTML( '' );
		$this->assertContains( 'jquery.makeCollapsible', $form->getOutput()->getModules() );

		$form = $this->newInstance();
		$form->setCollapsibleOptions( true );
		$form->prepareForm()->getHTML( '' );
		$this->assertContains( 'jquery.makeCollapsible', $form->getOutput()->getModules() );
	}

	public function testGetErrorsOrWarningsWithRawParams() {
		$form = $this->newInstance();
		$msg = new RawMessage( 'message with $1' );
		$msg->rawParams( '<a href="raw">params</a>' );
		$status = Status::newFatal( $msg );

		$result = $form->getErrorsOrWarnings( $status, 'error' );

		$this->assertStringContainsString( 'message with <a href="raw">params</a>', $result );
	}

	/**
	 * @dataProvider provideCsrf
	 * @param string|null $formTokenSalt Salt to pass to HTMLForm::setTokenSalt()
	 * @param array $requestData HTTP request data
	 * @param array|null $tokens User's CSRF tokens in a salt => value format, or null for anon
	 * @param bool $shouldBeAuthorized
	 */
	public function testCsrf(
		?string $formTokenSalt,
		array $requestData,
		?array $tokens,
		bool $shouldBeAuthorized
	) {
		$user = $this->createNoOpMock( User::class, [ 'isRegistered', 'matchEditToken' ] );
		$user->method( 'isRegistered' )->willReturn( $tokens !== null );
		$user->method( 'matchEditToken' )->willReturnCallback(
			static function ( $token, $salt ) use ( $tokens ) {
				return $tokens && isset( $tokens[$salt] ) && $tokens[$salt] === $token;
			} );
		$context = $this->createConfiguredMock( RequestContext::class, [
			'getRequest' => new FauxRequest( $requestData, true ),
			'getUser' => $user,
		] );
		$form = new HTMLForm( [], $context );
		if ( $formTokenSalt !== null ) {
			$form->setTokenSalt( $formTokenSalt );
		}
		$form->setSubmitCallback( static function () {
			return true;
		} );

		$this->assertSame( $shouldBeAuthorized, $form->tryAuthorizedSubmit() );
	}

	public static function provideCsrf() {
		return [
			// form token salt, request data, tokens, should be authorized?
			'Anon user, CSRF token ignored' => [ null, [], null, true ],
			'No CSRF token sent' => [ null, [], [ '' => '123' ], false ],
			'Wrong CSRF token sent' => [ null, [ 'wpEditToken' => 'xyz' ], [ '' => '123' ], false ],
			// this isn't possible but helps catch errors in the test itself
			'User has no CSRF token' => [ null, [ 'wpEditToken' => 'xyz' ], [], false ],
			'Correct CSRF token' => [ null, [ 'wpEditToken' => '123' ], [ '' => '123' ], true ],
			'Wrong CSRF token type' => [ 'delete', [ 'wpEditToken' => '123' ], [ '' => '123' ], false ],
			'Correct non-default CSRF token' => [ 'delete', [ 'wpEditToken' => 'xyz' ],
				[ '' => 123, 'delete' => 'xyz' ], true ],
		];
	}

}
PK       ! &    ,  HTMLForm/Field/HTMLRestrictionsFieldTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\HTMLForm\Field;

use EmptyIterator;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
use MediaWiki\HTMLForm\Field\HTMLRestrictionsField;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Language\Language;
use MediaWiki\Page\PageSelectQueryBuilder;
use MediaWiki\Page\PageStore;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Title\Title;
use MediaWikiCoversValidator;
use MediaWikiIntegrationTestCase;
use MWRestrictions;
use StatusValue;

/**
 * @covers \MediaWiki\HTMLForm\Field\HTMLRestrictionsField
 */
class HTMLRestrictionsFieldTest extends MediaWikiIntegrationTestCase {

	use MediaWikiCoversValidator;

	public function testConstruct() {
		$htmlForm = $this->createMock( HTMLForm::class );
		$htmlForm->method( 'msg' )->willReturnCallback( 'wfMessage' );
		$languageMock = $this->createMock( Language::class );
		$languageMock->method( 'getCode' )->willReturn( 'en' );
		$titleMock = $this->createMock( Title::class );

		$htmlForm->method( 'getLanguage' )->willReturn( $languageMock );
		$htmlForm->method( 'getTitle' )->willReturn( $titleMock );

		$field = new HTMLRestrictionsField( [ 'fieldname' => 'restrictions', 'parent' => $htmlForm ] );
		$this->assertEquals( MWRestrictions::newDefault(), $field->getDefault(),
			'defaults to the default MWRestrictions object' );

		$field = new HTMLRestrictionsField( [
			'fieldname' => 'restrictions',
			'label' => 'foo',
			'help' => 'bar',
			'default' => 'baz',
			'parent' => $htmlForm,
		] );
		$this->assertEquals( 'foo', $field->getLabel(), 'label can be customized' );
		$this->assertEquals( 'bar', $field->getHelpText(), 'help text can be customized' );
		$this->assertEquals( 'baz', $field->getDefault(), 'default can be customized' );
	}

	/**
	 * @dataProvider provideValidate
	 */
	public function testForm( $ipText, $value ) {
		$request = new FauxRequest( [ 'wprestrictions-ip' => $ipText ], true );
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setRequest( $request );
		$form = HTMLForm::factory( 'ooui', [
			'restrictions' => [ 'class' => HTMLRestrictionsField::class ],
		], $context );

		$pageStore = $this->createMock( PageStore::class );
		$this->setService( 'PageStore', $pageStore );
		$queryBuilderMock = $this->createMock( PageSelectQueryBuilder::class );
		$queryBuilderMock->method( 'fetchPageRecords' )->willReturn( new EmptyIterator() );
		$queryBuilderMock->method( 'wherePageIds' )->willReturnSelf();
		$queryBuilderMock->method( 'caller' )->willReturnSelf();
		$pageStore->method( 'newSelectQueryBuilder' )->willReturn( $queryBuilderMock );

		$form->setTitle( Title::makeTitle( NS_MAIN, 'Main Page' ) )->setSubmitCallback( static function () {
			return true;
		} )->prepareForm();
		$status = $form->trySubmit();

		if ( $status instanceof StatusValue ) {
			$this->assertEquals( $value !== false, $status->isGood() );
		} elseif ( $value === false ) {
			$this->assertFalse( $status );
		} else {
			$this->assertTrue( $status );
		}

		if ( $value !== false ) {
			$restrictions = $form->mFieldData['restrictions'];
			$this->assertInstanceOf( MWRestrictions::class, $restrictions );
			$this->assertEquals( $value, $restrictions->toArray()['IPAddresses'] );
		}

		$form->getHTML( $status );
	}

	public static function provideValidate() {
		return [
			// submitted text, value of 'IPAddresses' key or false for validation error
			[ null, [ '0.0.0.0/0', '::/0' ] ],
			[ '', [] ],
			[ "1.2.3.4\n::0", [ '1.2.3.4', '::0' ] ],
			[ "1.2.3.4\n::/x", false ],
		];
	}
}
PK       ! I&:    2  HTMLForm/Field/HTMLAutoCompleteSelectFieldTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\HTMLForm\Field;

use InvalidArgumentException;
use MediaWiki\HTMLForm\Field\HTMLAutoCompleteSelectField;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\HTMLForm\Field\HTMLAutoCompleteSelectField
 */
class HTMLAutoCompleteSelectFieldTest extends MediaWikiIntegrationTestCase {

	private const OPTIONS = [
		'Bulgaria'     => 'BGR',
		'Burkina Faso' => 'BFA',
		'Burundi'      => 'BDI',
	];

	/**
	 * Verify that attempting to instantiate an HTMLAutoCompleteSelectField
	 * without providing any autocomplete options causes an exception to be
	 * thrown.
	 */
	public function testMissingAutocompletions() {
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( "called without any autocompletions" );

		$htmlForm = $this->createMock( HTMLForm::class );
		new HTMLAutoCompleteSelectField( [ 'fieldname' => 'Test', 'parent' => $htmlForm ] );
	}

	/**
	 * Test that the optional select dropdown is included or excluded based on
	 * the presence or absence of the 'options' parameter.
	 */
	public function testOptionalSelectElement() {
		$htmlForm = $this->createMock( HTMLForm::class );
		$htmlForm->method( 'msg' )->willReturnCallback( 'wfMessage' );

		$params = [
			'fieldname'         => 'Test',
			'autocomplete-data' => self::OPTIONS,
			'options'           => self::OPTIONS,
			'parent'            => $htmlForm
		];

		$field = new HTMLAutoCompleteSelectField( $params );
		$html = $field->getInputHTML( false );
		$this->assertMatchesRegularExpression( '/select/', $html,
			"When the 'options' parameter is set, the HTML includes a <select>" );

		unset( $params['options'] );
		$field = new HTMLAutoCompleteSelectField( $params );
		$html = $field->getInputHTML( false );
		$this->assertDoesNotMatchRegularExpression( '/select/', $html,
			"When the 'options' parameter is not set, the HTML does not include a <select>" );
	}
}
PK       ! V  V  )  HTMLForm/Field/HTMLTitleTextFieldTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\HTMLForm\Field;

use InvalidArgumentException;
use MediaWiki\HTMLForm\Field\HTMLTitleTextField;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Interwiki\InterwikiLookupAdapter;
use MediaWiki\Message\Message;
use MediaWiki\Site\HashSiteStore;
use MediaWiki\Site\Site;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleFactory;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\HTMLForm\Field\HTMLTitleTextField
 */
class HTMLTitleTextFieldTest extends MediaWikiIntegrationTestCase {

	/**
	 * @dataProvider provideInterwiki
	 */
	public function testInterwiki( array $config, string $value, $expected ) {
		$this->setupInterwikiTable();
		$titleFactory = $this->createMock( TitleFactory::class );
		$titleFactory->method( 'newFromTextThrow' )->willReturnCallback( static function ( $text, $ns ) {
			$ret = Title::newFromTextThrow( $text, $ns );
			// Mark the title as nonexistent to avoid DB queries.
			$ret->resetArticleID( 0 );
			return $ret;
		} );
		$this->setService( 'TitleFactory', $titleFactory );
		$htmlForm = $this->createMock( HTMLForm::class );
		$htmlForm->method( 'msg' )->willReturnCallback( 'wfMessage' );

		$field = new HTMLTitleTextField( $config + [ 'fieldname' => 'foo', 'parent' => $htmlForm ] );
		$result = $field->validate( $value, [ 'foo' => $value ] );
		if ( $result instanceof Message ) {
			$this->assertSame( $expected, $result->getKey() );
		} else {
			$this->assertSame( $expected, $result );
		}
	}

	public static function provideInterwiki() {
		return [
			'local title' => [ [ 'interwiki' => false ], 'SomeTitle', true ],
			'interwiki title, default' => [ [], 'unittest_foo:SomeTitle', 'htmlform-title-interwiki' ],
			'interwiki title, disallowed' => [ [ 'interwiki' => false ],
				'unittest_foo:SomeTitle', 'htmlform-title-interwiki' ],
			'interwiki title, allowed' => [ [ 'interwiki' => true ],
				'unittest_foo:SomeTitle', true ],
			'namespace safety check' => [ [ 'interwiki' => true, 'namespace' => NS_TALK ],
				'SomeTitle', 'htmlform-title-badnamespace' ],
			'interwiki ignores namespace' => [ [ 'interwiki' => true, 'namespace' => NS_TALK ],
				'unittest_foo:SomeTitle', true ],
			'creatable safety check' => [ [ 'interwiki' => true, 'creatable' => true ],
				'Special:Version', 'htmlform-title-not-creatable' ],
			'interwiki ignores creatable' => [ [ 'interwiki' => true, 'creatable' => true ],
				'unittest_foo:Special:Version', true ],
			'exists safety check' => [ [ 'interwiki' => true, 'exists' => true ],
				'SomeTitle', 'htmlform-title-not-exists' ],
			'interwiki ignores exists' => [ [ 'interwiki' => true, 'exists' => true ],
				'unittest_foo:SomeTitle', true ],
		];
	}

	public function testInterwiki_relative() {
		$this->expectException( InvalidArgumentException::class );
		$field = new HTMLTitleTextField( [
			'fieldname' => 'foo',
			'interwiki' => true,
			'relative' => true,
			'parent' => $this->createMock( HTMLForm::class )
		] );
		$field->validate( 'SomeTitle', [ 'foo' => 'SomeTitle' ] );
	}

	protected function setupInterwikiTable() {
		$site = new Site( Site::TYPE_MEDIAWIKI );
		$site->setGlobalId( 'unittest_foowiki' );
		$site->addInterwikiId( 'unittest_foo' );
		$this->setService( 'InterwikiLookup', new InterwikiLookupAdapter( new HashSiteStore( [ $site ] ) ) );
		$this->assertTrue( Title::newFromText( 'unittest_foo:SomeTitle' )->isExternal() );
	}

}
PK       ! ̟\    (  HTMLForm/Field/HTMLUserTextFieldTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\HTMLForm\Field;

use MediaWiki\HTMLForm\Field\HTMLUserTextField;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Message\Message;
use MediaWiki\User\UserFactory;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\HTMLForm\Field\HTMLUserTextField
 */
class HTMLUserTextFieldTest extends MediaWikiIntegrationTestCase {

	/**
	 * @dataProvider provideInputs
	 */
	public function testInputs( array $config, string $value, $expected ) {
		$origUserFactory = $this->getServiceContainer()->getUserFactory();
		$userFactory = $this->createMock( UserFactory::class );
		$userFactory->method( 'newFromName' )->willReturnCallback( static function ( ...$params ) use ( $origUserFactory ) {
			$user = $origUserFactory->newFromName( ...$params );
			if ( $user ) {
				$user->mId = 0;
				$user->setItemLoaded( 'id' );
			}
			return $user;
		} );
		$this->setService( 'UserFactory', $userFactory );
		$htmlForm = $this->createMock( HTMLForm::class );
		$htmlForm->method( 'msg' )->willReturnCallback( 'wfMessage' );

		$field = new HTMLUserTextField( $config + [ 'fieldname' => 'foo', 'parent' => $htmlForm ] );
		$result = $field->validate( $value, [ 'foo' => $value ] );
		if ( $result instanceof Message ) {
			$this->assertSame( $expected, $result->getKey() );
		} else {
			$this->assertSame( $expected, $result );
		}
	}

	public static function provideInputs() {
		return [
			'valid username' => [
				[],
				'SomeUser',
				true
			],
			'external username when not allowed' => [
				[],
				'imported>SomeUser',
				'htmlform-user-not-valid'
			],
			'external username when allowed' => [
				[ 'external' => true ],
				'imported>SomeUser',
				true
			],
			'valid IP' => [
				[ 'ipallowed' => true ],
				'1.2.3.4',
				true
			],
			'valid IP, but not allowed' => [
				[ 'ipallowed' => false ],
				'1.2.3.4',
				'htmlform-user-not-valid'
			],
			'invalid IP' => [
				[ 'ipallowed' => true ],
				'1.2.3.456',
				'htmlform-user-not-valid'
			],
			'valid usemod IP' => [
				[ 'usemodwiki-ipallowed' => true, 'ipallowed' => true, 'exists' => true ],
				'1.2.3.xxx',
				true,
			],
			'valid usemod IP, but not allowed' => [
				[ 'usemodwiki-ipallowed' => false, 'ipallowed' => true, 'exists' => true ],
				'1.2.3.xxx',
				'htmlform-user-not-valid',
			],
			'invalid usemod IP because not enough "x"' => [
				[ 'usemodwiki-ipallowed' => true, 'ipallowed' => true, 'exists' => true ],
				'1.2.3.x',
				'htmlform-user-not-exists',
			],
			'invalid usemod IP because capital "x"' => [
				[ 'usemodwiki-ipallowed' => true, 'ipallowed' => true, 'exists' => true ],
				'1.2.3.XXX',
				'htmlform-user-not-exists',
			],
			'invalid usemod IP because first part not valid IPv4' => [
				[ 'usemodwiki-ipallowed' => true, 'ipallowed' => true, 'exists' => true ],
				'1.2.456.xxx',
				'htmlform-user-not-valid',
			],
			'valid IP range' => [
				[ 'iprange' => true ],
				'1.2.3.4/30',
				true
			],
			'valid IP range, but not allowed' => [
				[ 'iprange' => false ],
				'1.2.3.4/30',
				'htmlform-user-not-valid'
			],
			'invalid IP range (bad syntax)' => [
				[ 'iprange' => true ],
				'1.2.3.4/x',
				'htmlform-user-not-valid'
			],
			'invalid IP range (exceeds limits)' => [
				[
					'iprange' => true,
					'iprangelimits' => [
						'IPv4' => 11,
						'IPv6' => 11,
					],
				],
				'1.2.3.4/10',
				'ip_range_exceeded'
			],
			'valid username, but does not exist' => [
				[ 'exists' => true ],
				'SomeUser',
				'htmlform-user-not-exists'
			],
		];
	}

}
PK       ! 
U[  [  *  HTMLForm/Field/HTMLSelectNamespaceTest.phpnu Iw        <?php
namespace MediaWiki\Tests\Integration\HTMLForm\Field;

use MediaWiki\HTMLForm\Field\HTMLSelectNamespace;
use MediaWiki\Language\Language;
use MediaWiki\MediaWikiServices;
use MediaWiki\Tests\Integration\HTMLForm\HTMLFormFieldTestCase;

/**
 * @covers MediaWiki\HTMLForm\Field\HTMLSelectNamespace
 */
class HTMLSelectNamespaceTest extends HTMLFormFieldTestCase {
	/** @inheritDoc */
	protected $className = HTMLSelectNamespace::class;

	/**
	 * Until T277470 is fixed, because each time this is run it might be on a box that has
	 * different extensions/config, we just have to grab the data structure ourselves. Ick.
	 */
	private static function makeNamespaceOptionsList( Language $language ): string {
		$namespaces = $language->getNamespaces();
		$expectedOptions = '';
		foreach ( $namespaces as $id => $label ) {
			if ( $id < 0 ) {
				// Don't list special namespaces
				continue;
			}
			if ( $id === 0 ) {
				$repLabel = wfMessage( 'blanknamespace' )->inLanguage( $language )->text();
			} else {
				$repLabel = str_replace( '_', ' ', $label );
			}
			$expectedOptions .= "<option value=\"$id\">$repLabel</option>";
		}
		return $expectedOptions;
	}

	public static function provideInputHtml() {
		$expectedOptions = static::makeNamespaceOptionsList(
			MediaWikiServices::getInstance()->getContentLanguage()
		);

		yield 'Basic list' => [
			[],
			'',
			"<select class=\"namespaceselector\" id=\"mw-input-testfield\" name=\"testfield\">\n<option value=\"all\">all</option>\n" . $expectedOptions . "\n</select>"
		];

		yield 'Basic list, explicitly in userlang' => [
			[
				'in-user-lang' => false
			],
			'',
			"<select class=\"namespaceselector\" id=\"mw-input-testfield\" name=\"testfield\">\n<option value=\"all\">all</option>\n" . $expectedOptions . "\n</select>"
		];

		yield 'Basic list, blank all' => [
			[
				'all' => '',
			],
			'',
			"<select class=\"namespaceselector\" id=\"mw-input-testfield\" name=\"testfield\">\n<option value=\"\" selected=\"\">all</option>\n" . $expectedOptions . "\n</select>"
		];
	}

	public static function provideInputCodex() {
		$expectedOptions = static::makeNamespaceOptionsList(
			MediaWikiServices::getInstance()->getContentLanguage()
		);

		yield 'Basic list' => [
			[],
			'',
			false,
			"<select name=\"testfield\" id=\"mw-input-testfield\" class=\"cdx-select\"><option value=\"all\">all</option>" . $expectedOptions . "</select>"
		];

		yield 'Basic list, explicitly in userlang' => [
			[
				'in-user-lang' => false
			],
			'',
			false,
			"<select name=\"testfield\" id=\"mw-input-testfield\" class=\"cdx-select\"><option value=\"all\">all</option>" . $expectedOptions . "</select>"
		];

		yield 'Basic list, blank all' => [
			[
				'all' => '',
			],
			'',
			false,
			"<select name=\"testfield\" id=\"mw-input-testfield\" class=\"cdx-select\"><option value=\"\" selected=\"\">all</option>" . $expectedOptions . "</select>"
		];
	}

	public static function provideInputOOUI() {
		$expectedOptions = str_replace(
			'"', "'",
			static::makeNamespaceOptionsList( MediaWikiServices::getInstance()->getContentLanguage() )
		);

		yield 'Basic list' => [
			[],
			'',
			"<div id='mw-input-testfield' class='oo-ui-widget oo-ui-widget-enabled oo-ui-inputWidget oo-ui-dropdownInputWidget oo-ui-dropdownInputWidget-php mw-widget-namespaceInputWidget'><select tabindex='0' name='testfield' class='oo-ui-inputWidget-input oo-ui-indicator-down'><option value='all' selected='selected'>all</option>" . $expectedOptions . "</select></div>"
		];

		yield 'Basic list, explicitly in userlang' => [
			[
				'in-user-lang' => false
			],
			'',
			"<div id='mw-input-testfield' class='oo-ui-widget oo-ui-widget-enabled oo-ui-inputWidget oo-ui-dropdownInputWidget oo-ui-dropdownInputWidget-php mw-widget-namespaceInputWidget'><select tabindex='0' name='testfield' class='oo-ui-inputWidget-input oo-ui-indicator-down'><option value='all' selected='selected'>all</option>" . $expectedOptions . "</select></div>"

		];

		yield 'Basic list, blank all' => [
			[
				'all' => '',
			],
			'',
			"<div id='mw-input-testfield' class='oo-ui-widget oo-ui-widget-enabled oo-ui-inputWidget oo-ui-dropdownInputWidget oo-ui-dropdownInputWidget-php mw-widget-namespaceInputWidget'><select tabindex='0' name='testfield' class='oo-ui-inputWidget-input oo-ui-indicator-down'><option value='' selected='selected'>all</option>" . $expectedOptions . "</select></div>"

		];
	}
}
PK       ! pS  S  &  HTMLForm/Field/HTMLButtonFieldTest.phpnu Iw        <?php
namespace MediaWiki\Tests\Integration\HTMLForm\Field;

use MediaWiki\HTMLForm\Field\HTMLButtonField;
use MediaWiki\Tests\Integration\HTMLForm\HTMLFormFieldTestCase;

/**
 * @covers MediaWiki\HTMLForm\Field\HTMLButtonField
 */
class HTMLButtonFieldTest extends HTMLFormFieldTestCase {
	/** @inheritDoc */
	protected $className = HTMLButtonField::class;

	public static function provideInputHtml() {
		yield 'Basic button' => [
			[
				'buttonlabel' => 'Click me',
			],
			'',
			'<button class="mw-htmlform-submit" id="mw-input-testfield" type="button" name="testfield">Click me</button>'
		];

		yield 'Button with CSS class' => [
			[
				'buttonlabel' => 'Click me',
				'cssclass' => 'my-button',
			],
			'',
			'<button class="mw-htmlform-submit my-button" id="mw-input-testfield" type="button" name="testfield">Click me</button>'
		];

		yield 'Primary progressive button' => [
			[
				'buttonlabel' => 'Click me',
				'flags' => [ 'primary', 'progressive' ]
			],
			'',
			'<button class="mw-htmlform-submit mw-htmlform-primary mw-htmlform-progressive" id="mw-input-testfield" type="button" name="testfield">Click me</button>'
		];

		yield 'Destructive button' => [
			[
				'buttonlabel' => 'Click me',
				'flags' => [ 'destructive' ]
			],
			'',
			'<button class="mw-htmlform-submit mw-htmlform-destructive" id="mw-input-testfield" type="button" name="testfield">Click me</button>'
		];

		yield 'Quiet button with CSS class' => [
			[
				'buttonlabel' => 'Click me',
				'cssclass' => 'my-button',
				'flags' => [ 'quiet' ]
			],
			'',
			'<button class="mw-htmlform-submit my-button mw-htmlform-quiet" id="mw-input-testfield" type="button" name="testfield">Click me</button>'
		];

		yield 'Disabled button' => [
			[
				'buttonlabel' => 'Click me',
				'disabled' => true
			],
			'',
			'<button class="mw-htmlform-submit" id="mw-input-testfield" type="button" name="testfield" disabled="">Click me</button>'
		];
	}

	public static function provideInputCodex() {
		yield 'Basic button' => [
			[
				'buttonlabel' => 'Click me',
			],
			'',
			false,
			'<button class="mw-htmlform-submit cdx-button" id="mw-input-testfield" type="button" name="testfield">Click me</button>'
		];

		yield 'Button with CSS class' => [
			[
				'buttonlabel' => 'Click me',
				'cssclass' => 'my-button',
			],
			'',
			false,
			'<button class="mw-htmlform-submit cdx-button my-button" id="mw-input-testfield" type="button" name="testfield">Click me</button>'
		];

		yield 'Primary progressive button' => [
			[
				'buttonlabel' => 'Click me',
				'flags' => [ 'primary', 'progressive' ]
			],
			'',
			false,
			'<button class="mw-htmlform-submit cdx-button cdx-button--weight-primary cdx-button--action-progressive" id="mw-input-testfield" type="button" name="testfield">Click me</button>'
		];

		yield 'Destructive button' => [
			[
				'buttonlabel' => 'Click me',
				'flags' => [ 'destructive' ]
			],
			'',
			false,
			'<button class="mw-htmlform-submit cdx-button cdx-button--action-destructive" id="mw-input-testfield" type="button" name="testfield">Click me</button>'
		];

		yield 'Quiet button with CSS class' => [
			[
				'buttonlabel' => 'Click me',
				'cssclass' => 'my-button',
				'flags' => [ 'quiet' ]
			],
			'',
			false,
			'<button class="mw-htmlform-submit cdx-button my-button cdx-button--weight-quiet" id="mw-input-testfield" type="button" name="testfield">Click me</button>'
		];

		yield 'Disabled button' => [
			[
				'buttonlabel' => 'Click me',
				'disabled' => true
			],
			'',
			false,
			'<button class="mw-htmlform-submit cdx-button" id="mw-input-testfield" type="button" name="testfield" disabled="">Click me</button>'
		];
	}

	public static function provideInputOOUI() {
		yield 'Basic button' => [
			[
				'buttonlabel' => 'Click me',
			],
			'',
			"<span id='mw-input-testfield' class='mw-htmlform-submit  oo-ui-widget oo-ui-widget-enabled oo-ui-inputWidget oo-ui-buttonElement oo-ui-buttonElement-framed oo-ui-labelElement oo-ui-buttonInputWidget'><button type='button' tabindex='0' name='testfield' value='' class='oo-ui-inputWidget-input oo-ui-buttonElement-button'><span class='oo-ui-iconElement-icon oo-ui-iconElement-noIcon'></span><span class='oo-ui-labelElement-label'>Click me</span><span class='oo-ui-indicatorElement-indicator oo-ui-indicatorElement-noIndicator'></span></button></span>"
		];

		yield 'Button with CSS class' => [
			[
				'buttonlabel' => 'Click me',
				'cssclass' => 'my-button',
			],
			'',
			"<span id='mw-input-testfield' class='mw-htmlform-submit my-button oo-ui-widget oo-ui-widget-enabled oo-ui-inputWidget oo-ui-buttonElement oo-ui-buttonElement-framed oo-ui-labelElement oo-ui-buttonInputWidget'><button type='button' tabindex='0' name='testfield' value='' class='oo-ui-inputWidget-input oo-ui-buttonElement-button'><span class='oo-ui-iconElement-icon oo-ui-iconElement-noIcon'></span><span class='oo-ui-labelElement-label'>Click me</span><span class='oo-ui-indicatorElement-indicator oo-ui-indicatorElement-noIndicator'></span></button></span>"
		];

		yield 'Primary progressive button' => [
			[
				'buttonlabel' => 'Click me',
				'flags' => [ 'primary', 'progressive' ]
			],
			'',
			"<span id='mw-input-testfield' class='mw-htmlform-submit  oo-ui-widget oo-ui-widget-enabled oo-ui-inputWidget oo-ui-buttonElement oo-ui-buttonElement-framed oo-ui-labelElement oo-ui-flaggedElement-primary oo-ui-flaggedElement-progressive oo-ui-buttonInputWidget'><button type='button' tabindex='0' name='testfield' value='' class='oo-ui-inputWidget-input oo-ui-buttonElement-button'><span class='oo-ui-iconElement-icon oo-ui-iconElement-noIcon'></span><span class='oo-ui-labelElement-label'>Click me</span><span class='oo-ui-indicatorElement-indicator oo-ui-indicatorElement-noIndicator'></span></button></span>"

		];

		yield 'Destructive button' => [
			[
				'buttonlabel' => 'Click me',
				'flags' => [ 'destructive' ]
			],
			'',
			"<span id='mw-input-testfield' class='mw-htmlform-submit  oo-ui-widget oo-ui-widget-enabled oo-ui-inputWidget oo-ui-buttonElement oo-ui-buttonElement-framed oo-ui-labelElement oo-ui-flaggedElement-destructive oo-ui-buttonInputWidget'><button type='button' tabindex='0' name='testfield' value='' class='oo-ui-inputWidget-input oo-ui-buttonElement-button'><span class='oo-ui-iconElement-icon oo-ui-iconElement-noIcon'></span><span class='oo-ui-labelElement-label'>Click me</span><span class='oo-ui-indicatorElement-indicator oo-ui-indicatorElement-noIndicator'></span></button></span>"
		];

		yield 'Quiet button with CSS class' => [
			[
				'buttonlabel' => 'Click me',
				'cssclass' => 'my-button',
				'flags' => [ 'quiet' ]
			],
			'',
			"<span id='mw-input-testfield' class='mw-htmlform-submit my-button oo-ui-widget oo-ui-widget-enabled oo-ui-inputWidget oo-ui-buttonElement oo-ui-buttonElement-framed oo-ui-labelElement oo-ui-flaggedElement-quiet oo-ui-buttonInputWidget'><button type='button' tabindex='0' name='testfield' value='' class='oo-ui-inputWidget-input oo-ui-buttonElement-button'><span class='oo-ui-iconElement-icon oo-ui-iconElement-noIcon'></span><span class='oo-ui-labelElement-label'>Click me</span><span class='oo-ui-indicatorElement-indicator oo-ui-indicatorElement-noIndicator'></span></button></span>"

		];

		yield 'Disabled button' => [
			[
				'buttonlabel' => 'Click me',
				'disabled' => true
			],
			'',
			"<span id='mw-input-testfield' aria-disabled='true' class='mw-htmlform-submit  oo-ui-widget oo-ui-widget-disabled oo-ui-inputWidget oo-ui-buttonElement oo-ui-buttonElement-framed oo-ui-labelElement oo-ui-buttonInputWidget'><button type='button' tabindex='-1' aria-disabled='true' name='testfield' disabled='disabled' value='' class='oo-ui-inputWidget-input oo-ui-buttonElement-button'><span class='oo-ui-iconElement-icon oo-ui-iconElement-noIcon'></span><span class='oo-ui-labelElement-label'>Click me</span><span class='oo-ui-indicatorElement-indicator oo-ui-indicatorElement-noIndicator'></span></button></span>"
		];
	}
}
PK       ! Nb    %  HTMLForm/Field/HTMLRadioFieldTest.phpnu Iw        <?php
namespace MediaWiki\Tests\Integration\HTMLForm\Field;

use MediaWiki\HTMLForm\Field\HTMLRadioField;
use MediaWiki\Tests\Integration\HTMLForm\HTMLFormFieldTestCase;

/**
 * @covers MediaWiki\HTMLForm\Field\HTMLRadioField
 */
class HTMLRadioFieldTest extends HTMLFormFieldTestCase {
	/** @inheritDoc */
	protected $className = HTMLRadioField::class;

	public static function provideInputCodex() {
		yield 'Radios with none selected' => [
			[
				'options' => [
					'One' => '1',
					'Two' => '2',
					'Three' => '3'
				]
			],
			null,
			false,
			<<<HTML
				<div class="cdx-radio">
					<input id="mw-input-testfield-1" type="radio" name="testfield" class="cdx-radio__input" value="1">
					<span class="cdx-radio__icon"></span>
					<div class="cdx-radio__label cdx-label"><label class="cdx-label__label" for="mw-input-testfield-1">One</label></div>
				</div>
				<div class="cdx-radio">
					<input id="mw-input-testfield-2" type="radio" name="testfield" class="cdx-radio__input" value="2">
					<span class="cdx-radio__icon"></span>
					<div class="cdx-radio__label cdx-label"><label class="cdx-label__label" for="mw-input-testfield-2">Two</label></div>
				</div>
				<div class="cdx-radio">
					<input id="mw-input-testfield-3" type="radio" name="testfield" class="cdx-radio__input" value="3">
					<span class="cdx-radio__icon"></span>
					<div class="cdx-radio__label cdx-label"><label class="cdx-label__label" for="mw-input-testfield-3">Three</label></div>
				</div>
			HTML
		];

		yield 'Radios with one selected' => [
			[
				'options' => [
					'One' => '1',
					'Two' => '2',
					'Three' => '3'
				]
			],
			'2',
			false,
			<<<HTML
				<div class="cdx-radio">
					<input id="mw-input-testfield-1" type="radio" name="testfield" class="cdx-radio__input" value="1">
					<span class="cdx-radio__icon"></span>
					<div class="cdx-radio__label cdx-label"><label class="cdx-label__label" for="mw-input-testfield-1">One</label></div>
				</div>
				<div class="cdx-radio">
					<input id="mw-input-testfield-2" type="radio" name="testfield" class="cdx-radio__input" value="2" checked="">
					<span class="cdx-radio__icon"></span>
					<div class="cdx-radio__label cdx-label"><label class="cdx-label__label" for="mw-input-testfield-2">Two</label></div>
				</div>
				<div class="cdx-radio">
					<input id="mw-input-testfield-3" type="radio" name="testfield" class="cdx-radio__input" value="3">
					<span class="cdx-radio__icon"></span>
					<div class="cdx-radio__label cdx-label"><label class="cdx-label__label" for="mw-input-testfield-3">Three</label></div>
				</div>
			HTML
		];

		yield 'Radios with descriptions' => [
			[
				'options' => [
					'One' => '1',
					'Two' => '2',
					'Three' => '3'
				],
				'option-descriptions' => [
					'1' => 'First',
					'2' => 'Second',
					'3' => 'Third'
				]
			],
			'2',
			false,
			<<<HTML
				<div class="cdx-radio">
					<input id="mw-input-testfield-1" type="radio" name="testfield" class="cdx-radio__input" value="1" aria-describedby="mw-input-testfield-1-description">
					<span class="cdx-radio__icon"></span>
					<div class="cdx-radio__label cdx-label">
						<label class="cdx-label__label" for="mw-input-testfield-1">One</label>
						<span id="mw-input-testfield-1-description" class="cdx-label__description">First</span>
					</div>
				</div>
				<div class="cdx-radio">
					<input id="mw-input-testfield-2" type="radio" name="testfield" class="cdx-radio__input" value="2" aria-describedby="mw-input-testfield-2-description" checked="">
					<span class="cdx-radio__icon"></span>
					<div class="cdx-radio__label cdx-label">
						<label class="cdx-label__label" for="mw-input-testfield-2">Two</label>
						<span id="mw-input-testfield-2-description" class="cdx-label__description">Second</span>
					</div>
				</div>
				<div class="cdx-radio">
					<input id="mw-input-testfield-3" type="radio" name="testfield" class="cdx-radio__input" value="3" aria-describedby="mw-input-testfield-3-description">
					<span class="cdx-radio__icon"></span>
					<div class="cdx-radio__label cdx-label">
						<label class="cdx-label__label" for="mw-input-testfield-3">Three</label>
						<span id="mw-input-testfield-3-description" class="cdx-label__description">Third</span>
					</div>
				</div>
			HTML
		];
	}
}
PK       ! ٫"
  
  %  HTMLForm/Field/HTMLCheckFieldTest.phpnu Iw        <?php
namespace MediaWiki\Tests\Integration\HTMLForm\Field;

use MediaWiki\HTMLForm\Field\HTMLCheckField;
use MediaWiki\Tests\Integration\HTMLForm\HTMLFormFieldTestCase;

/**
 * @covers MediaWiki\HTMLForm\Field\HTMLCheckField
 */
class HTMLCheckFieldTest extends HTMLFormFieldTestCase {
	/** @inheritDoc */
	protected $className = HTMLCheckField::class;

	public static function provideInputCodex() {
		yield 'Basic checkbox' => [
			[
				'label' => 'Check me',
			],
			false,
			false,
			<<<HTML
			<div class="cdx-checkbox">
				<input name="testfield" type="checkbox" value="1" id="mw-input-testfield" class=" cdx-checkbox__input" />
				<span class="cdx-checkbox__icon">\u{00A0}</span>
				<label for="mw-input-testfield" class="cdx-checkbox__label">Check me</label>
			</div>
			HTML
		];

		yield 'Checked checkbox with CSS class' => [
			[
				'label' => 'Check me',
				'cssclass' => 'my-checkbox'
			],
			'1',
			false,
			<<<HTML
			<div class="cdx-checkbox">
				<input name="testfield" type="checkbox" value="1" checked="checked" id="mw-input-testfield" class="my-checkbox cdx-checkbox__input" />
				<span class="cdx-checkbox__icon">\u{00A0}</span>
				<label for="mw-input-testfield" class="cdx-checkbox__label">Check me</label>
			</div>
			HTML
		];

		yield 'Inverted checkbox' => [
			[
				'label' => 'Check me',
				'invert' => true
			],
			false,
			false,
			<<<HTML
			<div class="cdx-checkbox">
				<input name="testfield" type="checkbox" value="1" checked="checked" id="mw-input-testfield" class=" cdx-checkbox__input" />
				<span class="cdx-checkbox__icon">\u{00A0}</span>
				<label for="mw-input-testfield" class="cdx-checkbox__label">Check me</label>
			</div>
			HTML
		];

		yield 'Disabled checkbox with error state' => [
			[
				'label' => 'Check me',
				'disabled' => true,
			],
			false,
			true,
			<<<HTML
			<div class="cdx-checkbox cdx-checkbox--status-error">
				<input name="testfield" type="checkbox" value="1" id="mw-input-testfield" disabled="" class=" cdx-checkbox__input" />
				<span class="cdx-checkbox__icon">\u{00A0}</span>
				<label for="mw-input-testfield" class="cdx-checkbox__label">Check me</label>
			</div>
			HTML
		];

		yield 'Checkbox with tooltip and accesskey' => [
			[
				'label' => 'Watch',
				'tooltip' => 'watch'
			],
			false,
			false,
			<<<HTML
			<div class="cdx-checkbox" title="Add this page to your watchlist [w]">
				<input name="testfield" type="checkbox" value="1" title="Add this page to your watchlist [w]" accesskey="w" id="mw-input-testfield" class=" cdx-checkbox__input" />
				<span class="cdx-checkbox__icon">\u{00A0}</span>
				<label for="mw-input-testfield" class="cdx-checkbox__label">Watch</label>
			</div>
			HTML
		];
	}
}
PK       ! ֪    "  HTMLForm/HTMLFormFieldTestCase.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\HTMLForm;

use InvalidArgumentException;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\HTMLForm\HTMLFormField;
use MediaWikiIntegrationTestCase;

abstract class HTMLFormFieldTestCase extends MediaWikiIntegrationTestCase {
	/** @var string|null */
	protected $className = null;

	/**
	 * Augment assertEquals a little bit by stripping newlines and tabs from $expected.
	 * This allows $expected to be expressed as a heredoc string.
	 *
	 * @param string $expected HTML
	 * @param string $actual HTML
	 * @param string $msg Optional message
	 */
	private function assertHTMLEqualStrippingWhitespace( $expected, $actual, $msg = '' ) {
		$this->assertEquals(
			str_replace( [ "\n", "\t" ], '', $expected ),
			str_replace( [ "\n", "\t" ], '', $actual ),
			$msg
		);
	}

	public function constructField( array $params ): HTMLFormField {
		if ( $this->className === null ) {
			throw new InvalidArgumentException(
				'HTMLFormFieldTestCase subclass ' . __CLASS__ . ' must override $className or constructField()'
			);
		}
		return new $this->className( $params + [
			'parent' => $this->createMock( HTMLForm::class ),
			'name' => 'testfield'
		] );
	}

	public static function provideInputHtml() {
		return [];
	}

	/**
	 * @dataProvider provideInputHtml
	 */
	public function testGetInputHtml( $params, $value, $expected ) {
		$field = $this->constructField( $params );
		$this->assertHTMLEqualStrippingWhitespace( $expected, $field->getInputHtml( $value ) );
	}

	public static function provideInputOOUI() {
		return [];
	}

	/**
	 * @dataProvider provideInputOOUI
	 */
	public function testGetInputOOUI( $params, $value, $expected ) {
		\OOUI\Theme::setSingleton( new \OOUI\BlankTheme() );

		$field = $this->constructField( $params );
		$this->assertHTMLEqualStrippingWhitespace( $expected, $field->getInputOOUI( $value ) );
	}

	public static function provideInputCodex() {
		return [];
	}

	/**
	 * @dataProvider provideInputCodex
	 */
	public function testGetInputCodex( $params, $value, $hasError, $expected ) {
		$field = $this->constructField( $params );
		$this->assertHTMLEqualStrippingWhitespace( $expected, $field->getInputCodex( $value, $hasError ) );
	}

}
PK       !  IV!  V!    http/HttpRequestFactoryTest.phpnu Iw        <?php

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Http\HttpRequestFactory;
use MediaWiki\MainConfigNames;
use MediaWiki\Status\Status;
use Psr\Log\NullLogger;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Http\HttpRequestFactory
 * @todo Inject UrlUtils into MWHttpRequest and make this a unit test.
 */
class HttpRequestFactoryTest extends MediaWikiIntegrationTestCase {

	/**
	 * @param array|null $options
	 * @return HttpRequestFactory
	 */
	private function newFactory( $options = null ) {
		if ( !$options ) {
			$options = [
				MainConfigNames::HTTPTimeout => 1,
				MainConfigNames::HTTPConnectTimeout => 1,
				MainConfigNames::HTTPMaxTimeout => INF,
				MainConfigNames::HTTPMaxConnectTimeout => INF
			];
		}
		$options += [
			MainConfigNames::LocalVirtualHosts => [],
			MainConfigNames::LocalHTTPProxy => false,
		];
		return new HttpRequestFactory(
			new ServiceOptions( HttpRequestFactory::CONSTRUCTOR_OPTIONS, $options ),
			new NullLogger
		);
	}

	/**
	 * @param MWHttpRequest $req
	 * @param string $expectedUrl
	 * @param array $expectedOptions
	 * @return HttpRequestFactory
	 */
	private function newFactoryWithFakeRequest(
		MWHttpRequest $req,
		$expectedUrl,
		$expectedOptions = []
	) {
		$factory = $this->getMockBuilder( HttpRequestFactory::class )
			->onlyMethods( [ 'create' ] )
			->disableOriginalConstructor()
			->getMock();

		$factory->method( 'create' )
			->willReturnCallback(
				function ( $url, array $options = [], $caller = __METHOD__ )
					use ( $req, $expectedUrl, $expectedOptions )
				{
					$this->assertSame( $expectedUrl, $url );

					foreach ( $expectedOptions as $opt => $exp ) {
						$this->assertArrayHasKey( $opt, $options );
						$this->assertSame( $exp, $options[$opt] );
					}

					return $req;
				}
			);

		return $factory;
	}

	/**
	 * @param Status|string $result
	 * @return MWHttpRequest
	 */
	private function newFakeRequest( $result ) {
		if ( !( $result instanceof Status ) ) {
			$result = Status::newGood( $result );
		}

		$req = $this->getMockBuilder( MWHttpRequest::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getContent', 'execute' ] )
			->getMock();

		$req->method( 'getContent' )
			->willReturn( $result->getValue() );
		$req->method( 'execute' )
			->willReturn( $result );

		return $req;
	}

	public function testCreate() {
		$factory = $this->newFactory();
		$this->assertInstanceOf( MWHttpRequest::class, $factory->create( 'http://example.test' ) );
	}

	public function testGetUserAgent() {
		$factory = $this->newFactory();
		$this->assertStringStartsWith( 'MediaWiki/', $factory->getUserAgent() );
	}

	public function testGet() {
		$req = $this->newFakeRequest( __METHOD__ );
		$factory = $this->newFactoryWithFakeRequest(
			$req, 'https://example.test', [ 'method' => 'GET' ]
		);

		$this->assertSame( __METHOD__, $factory->get( 'https://example.test' ) );
	}

	public function testPost() {
		$req = $this->newFakeRequest( __METHOD__ );
		$factory = $this->newFactoryWithFakeRequest(
			$req, 'https://example.test', [ 'method' => 'POST' ]
		);

		$this->assertSame( __METHOD__, $factory->post( 'https://example.test' ) );
	}

	public function testRequest() {
		$req = $this->newFakeRequest( __METHOD__ );
		$factory = $this->newFactoryWithFakeRequest(
			$req, 'https://example.test', [ 'method' => 'GET' ]
		);

		$this->assertSame( __METHOD__, $factory->request( 'GET', 'https://example.test' ) );
	}

	public function testRequest_failed() {
		$status = new class extends Status {
			public function getWikiText( $shortContext = false, $longContext = false, $lang = null ) {
				// Status::getWikiText doesn't work in unit tests
				return '';
			}
		};
		$status->fatal( 'testing' );

		$req = $this->newFakeRequest( $status );
		$factory = $this->newFactoryWithFakeRequest(
			$req, 'https://example.test', [ 'method' => 'POST' ]
		);

		$this->assertNull( $factory->request( 'POST', 'https://example.test' ) );
	}

	public static function provideCreateTimeouts() {
		return [
			'normal config defaults' => [
				[
					MainConfigNames::HTTPTimeout => 10,
					MainConfigNames::HTTPConnectTimeout => 20,
					MainConfigNames::HTTPMaxTimeout => INF,
					MainConfigNames::HTTPMaxConnectTimeout => INF
				],
				[],
				[
					'timeout' => 10,
					'connectTimeout' => 20
				]
			],
			'config defaults overridden by max' => [
				[
					MainConfigNames::HTTPTimeout => 10,
					MainConfigNames::HTTPConnectTimeout => 20,
					MainConfigNames::HTTPMaxTimeout => 9,
					MainConfigNames::HTTPMaxConnectTimeout => 11
				],
				[],
				[
					'timeout' => 9,
					'connectTimeout' => 11
				]
			],
			'create option overridden by max config' => [
				[
					MainConfigNames::HTTPTimeout => 1,
					MainConfigNames::HTTPConnectTimeout => 2,
					MainConfigNames::HTTPMaxTimeout => 9,
					MainConfigNames::HTTPMaxConnectTimeout => 11
				],
				[
					'timeout' => 100,
					'connectTimeout' => 200
				],
				[
					'timeout' => 9,
					'connectTimeout' => 11
				]
			],
			'create option below max config' => [
				[
					MainConfigNames::HTTPTimeout => 1,
					MainConfigNames::HTTPConnectTimeout => 2,
					MainConfigNames::HTTPMaxTimeout => 9,
					MainConfigNames::HTTPMaxConnectTimeout => 11
				],
				[
					'timeout' => 7,
					'connectTimeout' => 8
				],
				[
					'timeout' => 7,
					'connectTimeout' => 8
				]
			],
			'max config overridden by max create option ' => [
				[
					MainConfigNames::HTTPTimeout => 1,
					MainConfigNames::HTTPConnectTimeout => 2,
					MainConfigNames::HTTPMaxTimeout => 9,
					MainConfigNames::HTTPMaxConnectTimeout => 11
				],
				[
					'timeout' => 100,
					'connectTimeout' => 200,
					'maxTimeout' => 100,
					'maxConnectTimeout' => 200
				],
				[
					'timeout' => 100,
					'connectTimeout' => 200
				]
			],
		];
	}

	/** @dataProvider provideCreateTimeouts */
	public function testCreateTimeouts( $config, $createOptions, $expected ) {
		$factory = $this->newFactory( $config );
		$request = $factory->create( 'https://example.test', $createOptions );
		$request = TestingAccessWrapper::newFromObject( $request );
		foreach ( $expected as $key => $expectedValue ) {
			$this->assertEquals( $expectedValue, $request->$key, "key $key" );
		}
	}

	/** @dataProvider provideCreateTimeouts */
	public function testCreateMultiTimeouts( $config, $createOptions, $expected ) {
		$factory = $this->newFactory( $config );
		$multi = $factory->createMultiClient( $createOptions );
		$multi = TestingAccessWrapper::newFromObject( $multi );
		$this->assertEquals( $expected['connectTimeout'], $multi->connTimeout );
		$this->assertEquals( $expected['timeout'], $multi->reqTimeout );
	}

	/** @dataProvider provideCreateTimeouts */
	public function testCreateGuzzleClient( $config, $createOptions, $expected ) {
		$factory = $this->newFactory( $config );
		$client = $factory->createGuzzleClient(
			[
				'timeout' => $createOptions['timeout'] ?? null,
				'connect_timeout' => $createOptions['connectTimeout'] ?? null,
				'maxTimeout' => $createOptions['maxTimeout'] ?? null,
				'maxConnectTimeout' => $createOptions['maxConnectTimeout'] ?? null
			]
		);
		$this->assertEquals(
			$expected['connectTimeout'],
			$client->getConfig( 'connect_timeout' )
		);
		$this->assertEquals(
			$expected['timeout'],
			$client->getConfig( 'timeout' )
		);
	}

	public function testCreateGuzzleClientRespectsTelemetry() {
		$mockTelemetry = $this->getMockBuilder( \MediaWiki\Http\Telemetry::class )
			->disableOriginalConstructor()
			->getMock();
		$mockTelemetry->expects( $this->once() )
			->method( 'getRequestHeaders' )
			->willReturn( [
				'X-Request-Id' => 'nice_hash'
			] );

		$factory = new HttpRequestFactory(
			new ServiceOptions( HttpRequestFactory::CONSTRUCTOR_OPTIONS, [
				MainConfigNames::HTTPTimeout => 1,
				MainConfigNames::HTTPConnectTimeout => 1,
				MainConfigNames::HTTPMaxTimeout => INF,
				MainConfigNames::HTTPMaxConnectTimeout => INF,
				MainConfigNames::LocalVirtualHosts => [],
				MainConfigNames::LocalHTTPProxy => false,
			] ),
			new NullLogger(),
			$mockTelemetry
		);

		$client = $factory->createGuzzleClient(
			[
				'timeout' => $createOptions['timeout'] ?? null,
				'connect_timeout' => $createOptions['connectTimeout'] ?? null,
				'maxTimeout' => $createOptions['maxTimeout'] ?? null,
				'maxConnectTimeout' => $createOptions['maxConnectTimeout'] ?? null
			]
		);
		$headers = $client->getConfig( 'headers' );
		$this->assertSame( 'nice_hash', $headers['X-Request-Id'] );
	}
}
PK       ! y ߬    $  Permissions/RestrictionStoreTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\Permissions;

use MediaWiki\Cache\CacheKeyHelper;
use MediaWiki\Cache\LinkCache;
use MediaWiki\CommentStore\CommentStore;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Linker\LinksMigration;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageStore;
use MediaWiki\Permissions\RestrictionStore;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Rdbms\IDBAccessObject;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Database
 *
 * See \MediaWiki\Tests\Unit\Permissions\RestrictionStoreTest for unit tests
 *
 * @covers \MediaWiki\Permissions\RestrictionStore
 */
class RestrictionStoreTest extends MediaWikiIntegrationTestCase {
	private const DEFAULT_RESTRICTION_TYPES = [ 'create', 'edit', 'move', 'upload' ];

	private WANObjectCache $wanCache;
	private ILoadBalancer $loadBalancer;
	private LinkCache $linkCache;
	private LinksMigration $linksMigration;
	private HookContainer $hookContainer;
	private CommentStore $commentStore;
	private PageStore $pageStore;

	/** @var array */
	private static $testPageRestrictionSource;
	/** @var array */
	private static $testPageRestrictionCascade;
	/** @var array */
	private static $testFileRestrictionSource;
	/** @var array */
	private static $testFileTarget;

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

		$services = $this->getServiceContainer();
		$this->wanCache = $services->getMainWANObjectCache();
		$this->loadBalancer = $services->getDBLoadBalancer();
		$this->linkCache = $services->getLinkCache();
		$this->linksMigration = $services->getLinksMigration();
		$this->hookContainer = $services->getHookContainer();
		$this->commentStore = $services->getCommentStore();
		$this->pageStore = $services->getPageStore();
	}

	public function addDBDataOnce() {
		self::$testPageRestrictionCascade =
			$this->insertPage( 'Template:RestrictionStoreTestA', 'wooooooo' );
		$this->insertPage( 'Template:RestrictionStoreTestB', '{{RestrictionStoreTestA}}' );

		self::$testPageRestrictionSource =
			$this->insertPage( 'RestrictionStoreTest_1', '{{RestrictionStoreTestB}}' );

		$this->updateRestrictions( self::$testPageRestrictionSource['title'], [ 'edit' => 'sysop' ] );

		self::$testFileTarget = $this->insertPage( 'File:RestrictionStoreTest.jpg', 'test file' );
		self::$testFileRestrictionSource =
			$this->insertPage( 'RestrictionStoreTest_File', '[[File:RestrictionStoreTest.jpg]]' );

		$this->updateRestrictions( self::$testFileRestrictionSource['title'], [ 'edit' => 'sysop' ], 1 );
	}

	private function newRestrictionStore( array $options = [] ) {
		return new RestrictionStore(
			new ServiceOptions( RestrictionStore::CONSTRUCTOR_OPTIONS, $options + [
				MainConfigNames::NamespaceProtection => [],
				MainConfigNames::RestrictionLevels => [ '', 'autoconfirmed', 'sysop' ],
				MainConfigNames::RestrictionTypes => self::DEFAULT_RESTRICTION_TYPES,
				MainConfigNames::SemiprotectedRestrictionLevels => [ 'autoconfirmed' ],
			] ),
			$this->wanCache,
			$this->loadBalancer,
			$this->linkCache,
			$this->linksMigration,
			$this->commentStore,
			$this->hookContainer,
			$this->pageStore
		);
	}

	private function updateRestrictions( $page, array $limit, int $cascade = 1 ) {
		$this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $page )
			->doUpdateRestrictions(
				$limit,
				[],
				$cascade,
				'test',
				$this->getTestSysop()->getUser()
			);
	}

	public function testGetCascadeProtectionSources() {
		$page = self::$testPageRestrictionCascade['title'];
		$pageSource = self::$testPageRestrictionSource['title'];

		[ $sources, $restrictions, $tlSources, $ilSources ] = $this->newRestrictionStore()
			->getCascadeProtectionSources( $page );
		$this->assertCount( 1, $sources );
		$this->assertCount( 1, $tlSources );
		$this->assertCount( 0, $ilSources );
		$this->assertTrue( $pageSource->isSamePageAs( $sources[$pageSource->getId()] ) );
		$this->assertArrayEquals( [ 'edit' => [ 'sysop' ] ], $restrictions );

		[ $sources, $restrictions, $tlSources, $ilSources ] = $this->newRestrictionStore()
			->getCascadeProtectionSources( $pageSource );
		$this->assertCount( 0, $sources );
		$this->assertCount( 0, $tlSources );
		$this->assertCount( 0, $ilSources );
		$this->assertCount( 0, $restrictions );
	}

	public function testGetCascadeProtectionSourcesSpecialPage() {
		[ $sources, $restrictions, $tlSources, $ilSources ] = $this->newRestrictionStore()
			->getCascadeProtectionSources( SpecialPage::getTitleFor( 'Whatlinkshere' ) );
		$this->assertCount( 0, $sources );
		$this->assertCount( 0, $tlSources );
		$this->assertCount( 0, $ilSources );
		$this->assertCount( 0, $restrictions );
	}

	public function testGetCascadeProtectionSourcesFile() {
		$page = self::$testFileTarget['title'];
		$pageSource = self::$testFileRestrictionSource['title'];

		[ $sources, $restrictions, $tlSources, $ilSources ] = $this->newRestrictionStore()
			->getCascadeProtectionSources( $page );

		$this->assertCount( 1, $sources );
		$this->assertTrue( $pageSource->isSamePageAs( $sources[$pageSource->getId()] ) );
		$this->assertArrayEquals( [ 'edit' => [ 'sysop' ] ], $restrictions );
		$this->assertCount( 1, $ilSources );
		$this->assertCount( 0, $tlSources );

		[ $sources, $restrictions, $tlSources, $ilSources ] = $this->newRestrictionStore()
			->getCascadeProtectionSources( $pageSource );
		$this->assertCount( 0, $sources );
		$this->assertCount( 0, $tlSources );
		$this->assertCount( 0, $ilSources );
		$this->assertCount( 0, $restrictions );
	}

	/**
	 * @dataProvider provideLoadRestrictions
	 */
	public function testLoadRestrictions( $page, $expectedCacheSubmap, ?array $restrictions = null ) {
		$cacheKey = CacheKeyHelper::getKeyForPage( $page );

		if ( $restrictions ) {
			$this->updateRestrictions( $page, $restrictions );
		}

		$restrictionStore = $this->newRestrictionStore();
		$restrictionStore->loadRestrictions( $page );
		$wrapper = TestingAccessWrapper::newFromObject( $restrictionStore );
		$this->assertArraySubmapSame(
			$expectedCacheSubmap,
			$wrapper->cache[$cacheKey]
		);
	}

	public static function provideLoadRestrictions(): array {
		return [
			'Regular page with restrictions' => [
				Title::makeTitle( NS_MAIN, 'RestrictionStoreTest_1' ),
				[ 'restrictions' => [ 'edit' => [ 'sysop' ] ] ]
			],
			'Nonexistent page' => [
				PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ),
				[ 'create_protection' => null ]
			],
			'Nonexistent page with restrictions' => [
				PageIdentityValue::localIdentity( 0, NS_MAIN, 'X' ),
				[ 'create_protection' => [ 'expiry' => 'infinity' ] ],
				[ 'create' => 'sysop' ]
			],
		];
	}

	public function testLoadRestrictions_latest() {
		$pageSource = self::$testPageRestrictionSource['title'];
		$cacheKey = CacheKeyHelper::getKeyForPage( $pageSource );

		$restrictionStore = $this->newRestrictionStore();
		$restrictionStore->loadRestrictions( $pageSource );
		$wrapper = TestingAccessWrapper::newFromObject( $restrictionStore );
		$this->assertArraySubmapSame(
			[ 'restrictions' => [ 'edit' => [ 'sysop' ] ] ],
			$wrapper->cache[$cacheKey]
		);

		$this->updateRestrictions( $pageSource, [ 'move' => 'sysop' ] );
		$restrictionStore->loadRestrictions( $pageSource, IDBAccessObject::READ_LATEST );
		$this->assertArraySubmapSame(
			[ 'restrictions' => [ 'move' => [ 'sysop' ] ] ],
			$wrapper->cache[$cacheKey]
		);
	}
}
PK       ! ߯OL  L    Permissions/RateLimiterTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\Permissions;

use Liuggio\StatsdClient\Entity\StatsdData;
use Liuggio\StatsdClient\Entity\StatsdDataInterface;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\RateLimiter;
use MediaWiki\Permissions\RateLimitSubject;
use MediaWiki\User\CentralId\CentralIdLookup;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\Stats\BufferingStatsdDataFactory;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\WRStats\BagOStuffStatsStore;
use Wikimedia\WRStats\WRStatsFactory;

/**
 * @covers \MediaWiki\Permissions\RateLimiter
 * @group Database
 */
class RateLimiterTest extends MediaWikiIntegrationTestCase {

	/**
	 * @return MockObject|CentralIdLookup
	 */
	private function getMockContralIdProvider() {
		$mockCentralIdLookup = $this->createNoOpMock(
			CentralIdLookup::class,
			[ 'centralIdFromLocalUser', 'getProviderId' ]
		);

		$mockCentralIdLookup->method( 'centralIdFromLocalUser' )
			->willReturnCallback( static function ( UserIdentity $user ) {
				return $user->getId() % 100;
			} );
		$mockCentralIdLookup->method( 'getProviderId' )
			->willReturn( 'test' );

		return $mockCentralIdLookup;
	}

	/**
	 * @covers \Wikimedia\WRStats\WRStatsFactory
	 * @covers \Wikimedia\WRStats\BagOStuffStatsStore
	 */
	public function testPingLimiterGlobal() {
		$limits = [
			'read' => [ // not limitable, will be ignored
				'anon' => [ 1, 60 ],
			],
			'edit' => [
				'anon' => [ 1, 60 ],
			],
			'purge' => [
				'ip' => [ 1, 60 ],
				'subnet' => [ 1, 60 ],
			],
			'rollback' => [
				'user' => [ 1, 60 ],
			],
			'move' => [
				'user-global' => [ 1, 60 ],
			],
			'delete' => [
				'ip-all' => [ 1, 60 ],
				'subnet-all' => [ 1, 60 ],
			],
		];

		// Set up a fake cache for storing limits
		$cache = new HashBagOStuff( [ 'keyspace' => 'xwiki' ] );
		$cacheAccess = TestingAccessWrapper::newFromObject( $cache );
		$cacheAccess->keyspace = 'xwiki';

		$statsFactory = new WRStatsFactory( new BagOStuffStatsStore( $cache ) );

		$stats = new BufferingStatsdDataFactory( 'test.' );
		$limiter = $this->newRateLimiter( $limits, [], $statsFactory );
		$limiter->setStats( $stats );

		// Set up some fake users
		$anon1 = $this->newFakeAnon( '1.2.3.4' );
		$anon2 = $this->newFakeAnon( '1.2.3.8' );
		$anon3 = $this->newFakeAnon( '6.7.8.9' );
		$anon4 = $this->newFakeAnon( '6.7.8.1' );

		// The mock ContralIdProvider uses the local id MOD 10 as the global ID.
		// So Frank has global ID 11, and Jane has global ID 56.
		// Kara's global ID is 0, which means no global ID.
		$frankX1 = $this->newFakeUser( 'Frank', '1.2.3.4', 111 );
		$frankX2 = $this->newFakeUser( 'Frank', '1.2.3.8', 111 );
		$frankY1 = $this->newFakeUser( 'Frank', '1.2.3.4', 211 );
		$janeX1 = $this->newFakeUser( 'Jane', '1.2.3.4', 456 );
		$janeX3 = $this->newFakeUser( 'Jane', '6.7.8.9', 456 );
		$janeY1 = $this->newFakeUser( 'Jane', '1.2.3.4', 756 );
		$karaX1 = $this->newFakeUser( 'Kara', '5.5.5.5', 100 );
		$karaY1 = $this->newFakeUser( 'Kara', '5.5.5.5', 200 );

		// Test limits on wiki X
		$this->assertFalse( $limiter->limit( $anon1, 'read' ), 'First anon read' );
		$this->assertFalse( $limiter->limit( $anon2, 'read' ), 'Second anon read (should ignore any limits)' );

		$this->assertFalse( $limiter->limit( $anon1, 'edit' ), 'First anon edit' );
		$this->assertTrue( $limiter->limit( $anon2, 'edit' ), 'Second anon edit' );

		$this->assertFalse( $limiter->limit( $anon1, 'purge' ), 'Anon purge' );
		$this->assertTrue( $limiter->limit( $anon1, 'purge' ), 'Anon purge via same IP' );

		$this->assertFalse( $limiter->limit( $anon3, 'purge' ), 'Anon purge via different subnet' );
		$this->assertTrue( $limiter->limit( $anon2, 'purge' ), 'Anon purge via same subnet' );

		$this->assertFalse( $limiter->limit( $frankX1, 'rollback' ), 'First rollback' );
		$this->assertTrue( $limiter->limit( $frankX2, 'rollback' ), 'Second rollback via different IP' );
		$this->assertFalse( $limiter->limit( $janeX1, 'rollback' ), 'Rlbk by different user, same IP' );

		$this->assertFalse( $limiter->limit( $frankX1, 'move' ), 'First move' );
		$this->assertTrue( $limiter->limit( $frankX2, 'move' ), 'Second move via different IP' );
		$this->assertFalse( $limiter->limit( $janeX1, 'move' ), 'Move by different user, same IP' );
		$this->assertFalse( $limiter->limit( $karaX1, 'move' ), 'Move by another user' );
		$this->assertTrue( $limiter->limit( $karaX1, 'move' ), 'Second move by another user' );

		$this->assertFalse( $limiter->limit( $frankX1, 'delete' ), 'First delete' );
		$this->assertTrue( $limiter->limit( $janeX1, 'delete' ), 'Delete via same IP' );

		$this->assertTrue( $limiter->limit( $frankX2, 'delete' ), 'Delete via same subnet' );
		$this->assertFalse( $limiter->limit( $janeX3, 'delete' ), 'Delete via different subnet' );

		// Now test how limits carry over to wiki Y
		$cacheAccess->keyspace = 'ywiki';

		$this->assertFalse( $limiter->limit( $anon3, 'edit' ), 'Anon edit on wiki Y' );
		$this->assertTrue( $limiter->limit( $anon4, 'purge' ), 'Anon purge on wiki Y, same subnet' );
		$this->assertFalse( $limiter->limit( $frankY1, 'rollback' ), 'Rollback on wiki Y, same name' );
		$this->assertTrue( $limiter->limit( $frankY1, 'move' ), 'Move on wiki Y, same name' );
		$this->assertTrue( $limiter->limit( $janeY1, 'move' ), 'Move on wiki Y, different user' );
		$this->assertTrue( $limiter->limit( $frankY1, 'delete' ), 'Delete on wiki Y, same IP' );

		// For a user without a global ID, user-global acts as a local restriction
		$this->assertFalse( $limiter->limit( $karaY1, 'move' ), 'Move by another user' );
		$this->assertTrue( $limiter->limit( $karaY1, 'move' ), 'Second move by another user' );

		// Check stats entries for conditions
		$statsData = $stats->getData();
		$this->assertStatsHasCount( 'test.RateLimiter.limit.edit.tripped_by.anon', 1, $statsData );
		$this->assertStatsHasCount( 'test.RateLimiter.limit.purge.tripped_by.ip', 1, $statsData );
		$this->assertStatsHasCount( 'test.RateLimiter.limit.purge.tripped_by.subnet', 1, $statsData );
		$this->assertStatsHasCount( 'test.RateLimiter.limit.delete.tripped_by.ip_all', 1, $statsData );
		$this->assertStatsHasCount( 'test.RateLimiter.limit.delete.tripped_by.subnet_all', 1, $statsData );
		$this->assertStatsHasCount( 'test.RateLimiter.limit.rollback.tripped_by.user', 1, $statsData );
		$this->assertStatsHasCount( 'test.RateLimiter.limit.move.tripped_by.user_global', 1, $statsData );
	}

	public function testPingLimiterWithStaleCache() {
		$limits = [
			'edit' => [
				'user' => [ 1, 60 ],
			],
		];

		$bagTime = 1600000000.0;
		$appTime = 1600000000;
		$bag = new HashBagOStuff();

		$statsFactory = new WRStatsFactory( new BagOStuffStatsStore( $bag ) );
		$statsFactory->setCurrentTime( $appTime );

		$bag->setMockTime( $bagTime ); // this is a reference!

		$user = $this->newFakeUser( 'Frank', '1.2.3.4', 111 );
		$limiter = $this->newRateLimiter( $limits, [], $statsFactory );

		$this->assertFalse( $limiter->limit( $user, 'edit' ), 'limit not reached' );
		$this->assertTrue( $limiter->limit( $user, 'edit' ), 'limit reached' );

		// Make it so that rate limits are expired according to MWTimestamp::time(),
		// but not according to $cache->getCurrentTime(), emulating the conditions
		// that trigger T246991.
		$bagTime += 10;
		$statsFactory->setCurrentTime( $appTime += 100 );

		$this->assertFalse( $limiter->limit( $user, 'edit' ), 'limit expired' );
		$this->assertTrue( $limiter->limit( $user, 'edit' ), 'limit functional after expiry' );
	}

	public function testPingLimiterRate() {
		$limits = [
			'edit' => [
				'user' => [ 3, 60 ],
			],
		];

		$fakeTime = 1600000000;
		$cache = new HashBagOStuff();

		$cache->setMockTime( $fakeTime ); // this is a reference!
		$statsFactory = new WRStatsFactory( new BagOStuffStatsStore( $cache ) );
		$statsFactory->setCurrentTime( $fakeTime );

		$user = $this->newFakeUser( 'Frank', '1.2.3.4', 111 );
		$limiter = $this->newRateLimiter( $limits, [], $statsFactory );

		// The limit is 3 per 60 second. Do 5 edits at an emulated 50 second interval.
		// They should all pass. This tests that the counter doesn't just keeps increasing
		// but gets reset in an appropriate way.
		$this->assertFalse( $limiter->limit( $user, 'edit' ), 'first ping should pass' );

		$statsFactory->setCurrentTime( $fakeTime += 50 );
		$this->assertFalse( $limiter->limit( $user, 'edit' ), 'second ping should pass' );

		$statsFactory->setCurrentTime( $fakeTime += 50 );
		$this->assertFalse( $limiter->limit( $user, 'edit' ), 'third ping should pass' );

		$statsFactory->setCurrentTime( $fakeTime += 50 );
		$this->assertFalse( $limiter->limit( $user, 'edit' ), 'fourth ping should pass' );

		$statsFactory->setCurrentTime( $fakeTime += 50 );
		$this->assertFalse( $limiter->limit( $user, 'edit' ), 'fifth ping should pass' );
	}

	public function testPingLimiterHook() {
		$limits = [
			'edit' => [
				'user' => [ 3, 60 ],
			],
		];

		$stats = new BufferingStatsdDataFactory( 'test.' );

		$user = $this->newFakeUser( 'Frank', '1.2.3.4', 111 );
		$limiter = $this->newRateLimiter( $limits, [] );
		$limiter->setStats( $stats );

		// Hook leaves $result false
		$this->setTemporaryHook(
			'PingLimiter',
			static function ( &$user, $action, &$result, $incrBy ) {
				return false;
			}
		);
		$this->assertFalse(
			$limiter->limit( $user, 'edit' ),
			'Hooks that just return false leave $result false'
		);
		$this->removeTemporaryHook( 'PingLimiter' );

		// Hook sets $result to true
		$this->setTemporaryHook(
			'PingLimiter',
			static function ( &$user, $action, &$result, $incrBy ) {
				$result = true;
				return false;
			}
		);
		$this->assertTrue(
			$limiter->limit( $user, 'edit' ),
			'Hooks can set $result to true'
		);
		$this->assertFalse(
			$limiter->limit( $user, 'read' ),
			'The "read" permission will bypass the hook'
		);
		$this->removeTemporaryHook( 'PingLimiter' );

		// Unknown action
		$this->assertFalse(
			$limiter->limit( $user, 'FakeActionWithNoRateLimit' ),
			'Actions with no rate limit set do not trip the rate limiter'
		);

		$statsData = $stats->getData();
		$this->assertStatsHasCount( 'test.RateLimiter.limit.edit.result.passed_by_hook', 1, $statsData );
		$this->assertStatsHasCount( 'test.RateLimiter.limit.edit.result.tripped_by_hook', 1, $statsData );

		$this->assertStatsNotHasCount( 'test.RateLimiter.limit.edit.result.passed', $statsData );
		$this->assertStatsNotHasCount( 'test.RateLimiter.limit.edit.result.tripped', $statsData );
	}

	public function testIsLimitableAction() {
		$limits = [
			'read' => [ // will be ignored, because 'read' is non-limitable
				'user' => [ 3, 60 ],
			],
			'edit' => [
				'user' => [ 3, 60 ],
			],
		];

		$limiter = $this->newRateLimiter( $limits, [] );

		$this->assertTrue( $limiter->isLimitable( 'edit' ), 'edit should be limitable' );
		$this->assertFalse( $limiter->isLimitable( 'read' ), 'read should be non-limitable' );
		$this->assertFalse( $limiter->isLimitable( 'move' ), 'move should not be limited' );

		// Set the hook!
		$this->setTemporaryHook(
			'PingLimiter',
			static function () {
				// no-op
			}
		);

		$this->assertTrue( $limiter->isLimitable( 'edit' ), 'edit should be limitable' );
		$this->assertFalse( $limiter->isLimitable( 'read' ), 'read should be non-limitable' );
		$this->assertTrue( $limiter->isLimitable( 'move' ), 'move should be limitable because of hook' );
	}

	public static function provideIsExempt() {
		$user = new UserIdentityValue( 123, 'Foo' );

		yield 'IP not excluded'
			=> [ [], new RateLimitSubject( $user, '1.2.3.4', [] ), false ];

		yield 'IP excluded'
			=> [ [ '1.2.3.4' ], new RateLimitSubject( $user, '1.2.3.4', [] ), true ];

		yield 'IP subnet excluded'
			=> [ [ '1.2.3.0/8' ], new RateLimitSubject( $user, '1.2.3.4', [] ), true ];

		$flags = [ RateLimitSubject::EXEMPT => true ];
		yield 'noratelimit right'
			=> [ [], new RateLimitSubject( $user, '1.2.3.4', $flags ), true ];
	}

	/**
	 * @dataProvider provideIsExempt
	 *
	 * @param array $rateLimitExcludeIps
	 * @param RateLimitSubject $subject
	 * @param bool $expected
	 */
	public function testIsExempt(
		array $rateLimitExcludeIps,
		RateLimitSubject $subject,
		bool $expected
	) {
		$limiter = $this->newRateLimiter( [], $rateLimitExcludeIps );

		$this->assertSame( $expected, $limiter->isExempt( $subject ) );
	}

	private function newFakeAnon( string $ip ) {
		return new RateLimitSubject(
			new UserIdentityValue( 0, $ip ),
			$ip,
			[ RateLimitSubject::NEWBIE => true ]
		);
	}

	private function newFakeUser( string $name, string $ip, int $id, $newbie = false ) {
		return new RateLimitSubject(
			new UserIdentityValue( $id, $name ),
			$ip,
			[ RateLimitSubject::NEWBIE => $newbie ]
		);
	}

	/**
	 * @param array $limits
	 * @param array $excludedIPs
	 * @param WRStatsFactory|null $statsFactory
	 *
	 * @return RateLimiter
	 * @throws \Exception
	 */
	protected function newRateLimiter(
		array $limits,
		array $excludedIPs,
		?WRStatsFactory $statsFactory = null
	): RateLimiter {
		$statsFactory ??= new WRStatsFactory( new BagOStuffStatsStore( new HashBagOStuff() ) );

		$services = $this->getServiceContainer();

		$limiter = new RateLimiter(
			new ServiceOptions( RateLimiter::CONSTRUCTOR_OPTIONS, [
				MainConfigNames::RateLimits => $limits,
				MainConfigNames::RateLimitsExcludedIPs => $excludedIPs,
			] ),
			$statsFactory,
			$this->getMockContralIdProvider(),
			$services->getUserFactory(),
			$services->getUserGroupManager(),
			$services->getHookContainer()
		);

		return $limiter;
	}

	/**
	 * Test limit with different limit types:
	 * - newbie trips the 'user' limit when 'newbie' not set
	 * - newbie trips the 'ip' limit on shared IP when 'user' is set
	 */
	public function testLimitTypes() {
		$limits = [
			'edit' => [
				'user' => [ 1, 60 ],
				'ip' => [ 2, 60 ],
			],
		];

		$newbie1 = $this->newFakeUser( 'User1', '127.0.0.1', 1, true );
		$newbie2 = $this->newFakeUser( 'User2', '127.0.0.1', 2, true );
		$newbie3 = $this->newFakeUser( 'User3', '127.0.0.1', 3, true );

		$stats = new BufferingStatsdDataFactory( 'test.' );
		$limiter = $this->newRateLimiter( $limits, [] );
		$limiter->setStats( $stats );

		$this->assertFalse( $limiter->limit( $newbie1, 'edit' ) );
		$this->assertFalse( $limiter->limit( $newbie2, 'edit' ) );
		$this->assertTrue( $limiter->limit( $newbie3, 'edit' ) );

		$statsData = $stats->getData();
		$this->assertStatsHasCount( 'test.RateLimiter.limit.edit.result.passed', 1, $statsData );
		$this->assertStatsHasCount( 'test.RateLimiter.limit.edit.result.tripped', 1, $statsData );
		$this->assertStatsHasCount( 'test.RateLimiter.limit.edit.tripped_by.ip', 1, $statsData );
	}

	/**
	 * Test limit when 'newbie' is set:
	 * - 'newbie' limit takes precedence over 'user' limit for newbie
	 * - newbie trips the 'ip' limit on a shared IP, when 'newbie' limit is set
	 */
	public function testLimitTypes_newbie() {
		$limits = [
			'edit' => [
				'user' => [ 10, 60 ],
				'newbie' => [ 1, 60 ],
				'ip' => [ 1, 60 ],
			],
		];

		$newbie1 = $this->newFakeUser( 'User1', '127.0.0.1', 1, true );
		$newbie2 = $this->newFakeUser( 'User2', '127.0.0.1', 2, true );

		$limiter = $this->newRateLimiter( $limits, [] );

		$this->assertFalse( $limiter->limit( $newbie1, 'edit' ) );
		$this->assertTrue( $limiter->limit( $newbie1, 'edit' ) );
		$this->assertTrue( $limiter->limit( $newbie2, 'edit' ) );
	}

	/**
	 * Test that '&can-bypass' can be used to impose limits on users
	 * who are otherwise exempt from limits.
	 */
	public function testCanBypass() {
		$limits = [
			'edit' => [
				'user' => [ 1, 60 ],
			],
			'delete' => [
				'&can-bypass' => false,
				'user' => [ 1, 60 ],
			],
		];

		$user = new RateLimitSubject(
			new UserIdentityValue( 7, 'Garth' ),
			'127.0.0.1',
			[ RateLimitSubject::EXEMPT => true ]
		);

		$stats = new BufferingStatsdDataFactory( 'test.' );
		$limiter = $this->newRateLimiter( $limits, [] );
		$limiter->setStats( $stats );

		$this->assertFalse( $limiter->limit( $user, 'edit' ) );
		$this->assertFalse( $limiter->limit( $user, 'delete' ) );

		$this->assertFalse( $limiter->limit( $user, 'edit' ), 'bypass should be granted' );
		$this->assertTrue( $limiter->limit( $user, 'delete' ), 'bypass should be denied' );

		$statsData = $stats->getData();
		$this->assertStatsHasCount( 'test.RateLimiter.limit.edit.result.exempt', 1, $statsData );
		$this->assertStatsNotHasCount( 'test.RateLimiter.limit.delete.result.exempt', $statsData );
		$this->assertStatsHasCount( 'test.RateLimiter.limit.delete.result.tripped', 1, $statsData );
	}

	/**
	 * Test that setting the increment to 0 causes the RateLimiter to operate in
	 * peek mode, checking a rate limit without setting it.
	 *
	 * Regression test for T381033.
	 */
	public function testPeek() {
		$limits = [
			'edit' => [
				'user' => [ 1, 60 ],
			],
		];

		$user = new RateLimitSubject( new UserIdentityValue( 7, 'Garth' ), '127.0.0.1', [] );

		$limiter = $this->newRateLimiter( $limits, [] );

		// initial peek should pass
		$this->assertFalse( $limiter->limit( $user, 'edit', 0 ) );

		// check that repeated peeking doesn't trigger the limit
		$this->assertFalse( $limiter->limit( $user, 'edit', 0 ) );

		// first increment should pass but trigger the limit
		$this->assertFalse( $limiter->limit( $user, 'edit', 1 ) );

		// peek should fail now
		$this->assertTrue( $limiter->limit( $user, 'edit', 0 ) );
	}

	/**
	 * Test that the most permissive limit is used when a limit is defined for
	 * multiple groups a user belongs to.
	 */
	public function testGroupLimits() {
		$limits = [
			'edit' => [
				'user' => [ 1, 60 ],
				'autoconfirmed' => [ 2, 60 ],
			],
		];

		$user = $this->getTestUser( [ 'autoconfirmed' ] )->getUser();
		$user = new RateLimitSubject( $user, '127.0.0.1', [] );

		$stats = new BufferingStatsdDataFactory( 'test.' );
		$limiter = $this->newRateLimiter( $limits, [] );
		$limiter->setStats( $stats );

		$this->assertFalse( $limiter->limit( $user, 'edit' ) );
		$this->assertFalse( $limiter->limit( $user, 'edit' ), 'limit for autoconfirmed used' );
		$this->assertTrue( $limiter->limit( $user, 'edit' ), 'limit for autoconfirmed exceeded' );

		$statsData = $stats->getData();
		$this->assertStatsHasCount( 'test.RateLimiter.limit.edit.result.passed', 1, $statsData );
		$this->assertStatsHasCount( 'test.RateLimiter.limit.edit.result.tripped', 1, $statsData );
		$this->assertStatsHasCount( 'test.RateLimiter.limit.edit.tripped_by.autoconfirmed', 1, $statsData );
	}

	/**
	 * @param string $key
	 * @param ?int $value
	 * @param StatsdData[] $statsData
	 */
	private function assertStatsHasCount( string $key, ?int $value, array $statsData ) {
		$metric = StatsdDataInterface::STATSD_METRIC_COUNT;

		foreach ( $statsData as $data ) {
			if ( $data->getMetric() === $metric && $data->getValue() === $value && $data->getKey() == $key ) {
				$this->addToAssertionCount( 1 );
				return;
			}
		}

		$this->fail( "Missing metric data entry: $key/$metric/$value" );
	}

	/**
	 * @param string $key
	 * @param StatsdData[] $statsData
	 */
	private function assertStatsNotHasCount( string $key, array $statsData ) {
		$metric = StatsdDataInterface::STATSD_METRIC_COUNT;

		foreach ( $statsData as $data ) {
			if ( $data->getMetric() === $metric && $data->getKey() == $key ) {
				$this->fail( "Metric data entry was not expected to be present: $key/$metric" );
			}
		}

		$this->addToAssertionCount( 1 );
	}

}
PK       !       /  Permissions/PermissionStatusIntegrationTest.phpnu Iw        <?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
 */

namespace MediaWiki\Tests\Integration\Permissions;

use MediaWiki\Block\Block;
use MediaWiki\Block\BlockErrorFormatter;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Language\FormatterFactory;
use MediaWiki\Language\RawMessage;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;
use PermissionsError;
use ThrottledError;
use UserBlockedError;

/**
 * @covers \MediaWiki\Permissions\PermissionStatus
 */
class PermissionStatusIntegrationTest extends MediaWikiIntegrationTestCase {

	private function makeBlockedStatus() {
		$user = new UserIdentityValue( 1, 'Primus' );
		$blocker = new UserIdentityValue( 2, 'Secundus' );

		$block = $this->createNoOpMock(
			Block::class,
			[
				'getTargetUserIdentity',
				'getBlocker',
				'getIdentifier',
				'getTargetName',
				'getReasonComment',
				'getExpiry',
				'getTimestamp',
			]
		);
		$block->method( 'getTargetUserIdentity' )->willReturn( $user );
		$block->method( 'getTargetName' )->willReturn( $user->getName() );
		$block->method( 'getBlocker' )->willReturn( $blocker );
		$block->method( 'getIdentifier' )->willReturn( 'TEST' );
		$block->method( 'getReasonComment' )->willReturn(
			CommentStoreComment::newUnsavedComment( 'testing' )
		);
		$block->method( 'getTimestamp' )->willReturn( '20220102334455' );
		$block->method( 'getExpiry' )->willReturn( '20250102334455' );

		$status = PermissionStatus::newEmpty();
		$status->fatal( 'blockednoreason' );
		$status->setBlock( $block );
		return $status;
	}

	private function assertThrowErrorPageError( PermissionStatus $status, $expected ) {
		$this->expectExceptionMessage( $expected->getMessage() );

		$status->throwErrorPageError();
	}

	public function testThrowErrorPageError_unknown() {
		$status = PermissionStatus::newEmpty();
		$status->fatal( 'permissionserrorstext-withaction', 'move' );
		$expected = new PermissionsError(
			null,
			[ [ 'permissionserrorstext-withaction', 'move' ] ]
		);

		$this->assertThrowErrorPageError( $status, $expected );
	}

	public function testThrowErrorPageError_known() {
		$status = PermissionStatus::newEmpty();
		$status->setPermission( 'move' );
		$status->fatal( 'permissionserrorstext-withaction', 'move' );
		$expected = new PermissionsError(
			'move',
			[ [ 'permissionserrorstext-withaction', 'move' ] ]
		);

		$this->assertThrowErrorPageError( $status, $expected );
	}

	public function testThrowErrorPageError_blocked() {
		$blockErrorFormatter = $this->createNoOpMock( BlockErrorFormatter::class, [ 'getMessages' ] );
		$blockErrorFormatter->method( 'getMessages' )->willReturn( [ new RawMessage( 'testing' ) ] );

		$formatterFactory = $this->createNoOpMock( FormatterFactory::class, [ 'getBlockErrorFormatter' ] );
		$formatterFactory->method( 'getBlockErrorFormatter' )->willReturn( $blockErrorFormatter );

		$this->setService( 'FormatterFactory', $formatterFactory );

		$status = $this->makeBlockedStatus();
		$block = $status->getBlock();
		$expected = new UserBlockedError( $block );

		$this->assertThrowErrorPageError( $status, $expected );
	}

	public function testThrowErrorPageError_throttled() {
		$status = PermissionStatus::newEmpty();
		$status->setRateLimitExceeded();
		$expected = new ThrottledError();

		$this->assertThrowErrorPageError( $status, $expected );
	}

}
PK       ! c"r K
  K
  &  Permissions/GrantsLocalizationTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\Permissions;

use HtmlArmor;
use MediaWiki\Html\Html;
use MediaWiki\Message\Message;
use MediaWiki\Permissions\GrantsLocalization;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWikiIntegrationTestCase;

/**
 * @author Zabe
 *
 * @covers \MediaWiki\Permissions\GrantsLocalization
 */
class GrantsLocalizationTest extends MediaWikiIntegrationTestCase {
	private GrantsLocalization $grantsLocalization;

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

		$this->grantsLocalization = $this->getServiceContainer()->getGrantsLocalization();
	}

	/**
	 * @dataProvider grantDescriptions
	 */
	public function testGetGrantDescription( string $grant ) {
		$message = new Message( 'grant-' . $grant );
		$this->assertSame(
			$message->text(),
			$this->grantsLocalization->getGrantDescription( $grant )
		);
		$this->assertSame(
			$message->inLanguage( 'de' )->text(),
			$this->grantsLocalization->getGrantDescription( $grant, 'de' )
		);
	}

	public function grantDescriptions() {
		yield [ 'blockusers' ];
		yield [ 'createeditmovepage' ];
		yield [ 'delete' ];
	}

	public function testGetNonExistingGrantDescription() {
		$message = ( new Message( 'grant-generic' ) )->params( 'foo' );
		$this->assertSame(
			$message->text(),
			$this->grantsLocalization->getGrantDescription( 'foo' )
		);
		$this->assertSame(
			$message->inLanguage( 'zh' )->text(),
			$this->grantsLocalization->getGrantDescription( 'foo', 'zh' )
		);
	}

	public function testGetGrantDescriptions() {
		$this->assertSame(
			[
				'blockusers' => ( new Message( 'grant-blockusers' ) )->inLanguage( 'de' )->text(),
				'delete' => ( new Message( 'grant-delete' ) )->inLanguage( 'de' )->text(),
			],
			$this->grantsLocalization->getGrantDescriptions(
				[
					'blockusers',
					'delete',
				],
				'de'
			)
		);
	}

	public function testGetGrantsLink() {
		$this->assertSame(
			$this->getServiceContainer()->getLinkRenderer()->makeKnownLink(
				SpecialPage::getTitleFor( 'Listgrants', false, 'delete' ),
				new HtmlArmor(
					( new Message( 'grant-delete' ) )->escaped() . ' ' .
					Html::element( 'span', [ 'class' => 'mw-grant mw-grantriskgroup-vandalism' ],
						( new Message( 'grantriskgroup-vandalism' ) )->text() )
				)
			),
			$this->grantsLocalization->getGrantsLink( 'delete' )
		);
	}

	public function testGetGrantsWikiText() {
		$this->assertSame(
			"*<span class=\"mw-grantgroup\">Perform high volume activity</span>\n:High-volume (bot) access <span class=\"mw-grant mw-grantriskgroup-low\"></span>\n\n",
			$this->grantsLocalization->getGrantsWikiText( [ 'highvolume' ] )
		);
	}
}
PK       ! 8b  b  !  context/DerivativeContextTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\Context;

use MediaWiki\Actions\ActionFactory;
use MediaWiki\Config\HashConfig;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\Language\Language;
use MediaWiki\Output\OutputPage;
use MediaWiki\Permissions\Authority;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use WikiPage;

/**
 * @covers \MediaWiki\Context\DerivativeContext
 */
class DerivativeContextTest extends MediaWikiIntegrationTestCase {

	public function provideGetterSetter() {
		$initialContext = new RequestContext();
		yield 'get/set Context' => [
			'initialContext' => $initialContext,
			'initialValue' => $initialContext,
			'newValue' => new RequestContext(),
			'getter' => 'getContext',
			'setter' => 'setContext'
		];
		yield 'get/set Config' => [
			'initialContext' => $initialContext,
			'initialValue' => new HashConfig(),
			'newValue' => new HashConfig(),
			'getter' => 'getConfig',
			'setter' => 'setConfig'
		];
		yield 'get/set OutputPage' => [
			'initialContext' => $initialContext,
			'initialValue' => $this->createNoOpMock( OutputPage::class ),
			'newValue' => $this->createNoOpMock( OutputPage::class ),
			'getter' => 'getOutput',
			'setter' => 'setOutput'
		];
		yield 'get/set User' => [
			'initialContext' => $initialContext,
			'initialValue' => $this->createNoOpMock( User::class ),
			'newValue' => $this->createNoOpMock( User::class ),
			'getter' => 'getUser',
			'setter' => 'setUser'
		];
		yield 'get/set Authority' => [
			'initialContext' => $initialContext,
			'initialValue' => $this->createNoOpMock( Authority::class ),
			'newValue' => $this->createNoOpMock( Authority::class ),
			'getter' => 'getAuthority',
			'setter' => 'setAuthority'
		];
		yield 'get/set Language' => [
			'initialContext' => $initialContext,
			'initialValue' => $this->createNoOpMock( Language::class ),
			'newValue' => $this->createNoOpMock( Language::class ),
			'getter' => 'getLanguage',
			'setter' => 'setLanguage'
		];
		yield 'get/set WebRequest' => [
			'initialContext' => $initialContext,
			'initialValue' => new FauxRequest(),
			'newValue' => new FauxRequest(),
			'getter' => 'getRequest',
			'setter' => 'setRequest'
		];
		$initialTitle = $this->createMock( Title::class );
		$initialTitle->expects( $this->any() )->method( 'equals' );
		yield 'get/set Title' => [
			'initialContext' => $initialContext,
			'initialValue' => $initialTitle,
			'newValue' => $this->createNoOpMock( Title::class ),
			'getter' => 'getTitle',
			'setter' => 'setTitle',
		];
		$initialWikiPage = $this->createMock( WikiPage::class );
		$initialWikiPage->expects( $this->any() )->method( 'getTitle' )->willReturn( $initialTitle );
		$newWikiPage = $this->createMock( WikiPage::class );
		$newWikiPage->expects( $this->any() )->method( 'getTitle' );
		yield 'get/set WikiPage' => [
			'initialContext' => $initialContext,
			'initialValue' => $initialWikiPage,
			'newValue' => $newWikiPage,
			'getter' => 'getWikiPage',
			'setter' => 'setWikiPage',
		];
		yield 'get/set ActionName' => [
			'initialContext' => $initialContext,
			'initialValue' => 'initActionName',
			'newValue' => 'newActionName',
			'getter' => 'getActionName',
			'setter' => 'setActionName',
		];
	}

	/**
	 * @dataProvider provideGetterSetter
	 */
	public function testGetterSetter(
		IContextSource $initialContext,
		$initialValue,
		$newValue,
		string $getter,
		string $setter
	) {
		if ( $setter !== 'setContext' ) {
			$initialContext->$setter( $initialValue );
		}

		$derivativeContext = new DerivativeContext( $initialContext );
		$this->assertSame( $initialValue, $derivativeContext->$getter(), 'Get inital value' );
		$derivativeContext->$setter( $newValue );
		$this->assertSame( $newValue, $derivativeContext->$getter(), 'Get new value' );
	}

	public function testOverideActionName() {
		$parent = new RequestContext();
		$parent->setActionName( 'view' );

		$factory = $this->createMock( ActionFactory::class );
		$factory
			->method( 'getActionName' )
			->willReturnOnConsecutiveCalls( 'foo', 'bar', 'baz' );
		$this->setService( 'ActionFactory', $factory );

		$derivative = new DerivativeContext( $parent );
		$this->assertSame( 'view', $derivative->getActionName(), 'default to parent cache' );

		$derivative->setTitle( $this->createMock( Title::class ) );
		$this->assertSame( 'foo', $derivative->getActionName(), 'recompute after change' );
		$this->assertSame( 'foo', $derivative->getActionName(), 'local cache' );

		$derivative->setWikiPage( $this->createMock( WikiPage::class ) );
		$this->assertSame( 'bar', $derivative->getActionName(), 'recompute after change' );
		$this->assertSame( 'bar', $derivative->getActionName(), 'local cache' );

		$derivative->setActionName( 'custom' );
		$this->assertSame( 'custom', $derivative->getActionName(), 'override' );
	}
}
PK       ! wQ=v*  v*    context/RequestContextTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\Context;

use LogicException;
use MediaWiki\Actions\ActionFactory;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Permissions\UltimateAuthority;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Session\PHPSessionHandler;
use MediaWiki\Session\SessionManager;
use MediaWiki\Title\Title;
use MediaWiki\User\Options\StaticUserOptionsLookup;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;
use Skin;
use SkinFallback;

/**
 * @covers \MediaWiki\Context\RequestContext
 * @group Database
 * @group RequestContext
 */
class RequestContextTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers \MediaWiki\Context\RequestContext::sanitizeLangCode
	 *
	 * @dataProvider provideSanitizeLangCode
	 */
	public function testSanitizeLangCode(
		?string $input, string $expected
	): void {
		$this->assertSame(
			$expected,
			RequestContext::sanitizeLangCode( $input )
		);
	}

	public static function provideSanitizeLangCode() {
		global $wgLanguageCode;

		yield 'Null' => [ null, $wgLanguageCode ];
		yield 'Blank' => [ '', $wgLanguageCode ];

		yield 'Current' => [ $wgLanguageCode, $wgLanguageCode ];

		yield 'Non-English' => [ 'fr', 'fr' ];
		yield 'Documentation falls back to default' => [ 'qqq', $wgLanguageCode ];

		yield 'Sub-codes' => [ 'fr-fr', 'fr-fr' ];

		yield 'Lower-casing' => [ 'en-GB', 'en-gb' ];

		yield 'Valid codes unknown to MW' => [ 'zzz', 'zzz' ];
		yield 'Valid sub-codes unknown to MW' => [ 'en-IN', 'en-in' ];
		yield 'Extended codes' => [ 'en-US-aave', 'en-us-aave' ];

		yield 'Invalid code' => [ 'z!!z', 'z!!z' ];

		yield 'Attempted XSS code' => [ 'a&#0', $wgLanguageCode ];
	}

	/**
	 * Test the relationship between title and wikipage in RequestContext
	 * @covers \MediaWiki\Context\RequestContext::getWikiPage
	 * @covers \MediaWiki\Context\RequestContext::getTitle
	 */
	public function testWikiPageTitle() {
		$context = new RequestContext();

		$curTitle = Title::makeTitle( NS_MAIN, "A" );
		$context->setTitle( $curTitle );
		$this->assertTrue( $curTitle->equals( $context->getWikiPage()->getTitle() ),
			"When a title is first set WikiPage should be created on-demand for that title." );

		$curTitle = Title::makeTitle( NS_MAIN, "B" );
		$context->setWikiPage( $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $curTitle ) );
		$this->assertTrue( $curTitle->equals( $context->getTitle() ),
			"Title must be updated when a new WikiPage is provided." );

		$curTitle = Title::makeTitle( NS_MAIN, "C" );
		$context->setTitle( $curTitle );
		$this->assertTrue(
			$curTitle->equals( $context->getWikiPage()->getTitle() ),
			"When a title is updated the WikiPage should be purged "
				. "and recreated on-demand with the new title."
		);
	}

	/**
	 * @covers \MediaWiki\Context\RequestContext::importScopedSession
	 */
	public function testImportScopedSession() {
		// Make sure session handling is started
		if ( !PHPSessionHandler::isInstalled() ) {
			PHPSessionHandler::install(
				SessionManager::singleton()
			);
		}
		$oldSessionId = session_id();

		$context = RequestContext::getMain();

		$oInfo = $context->exportSession();
		$this->assertEquals( '127.0.0.1', $oInfo['ip'], "Correct initial IP address." );
		$this->assertSame( 0, $oInfo['userId'], "Correct initial user ID." );
		$this->assertFalse( SessionManager::getGlobalSession()->isPersistent(),
			'Global session isn\'t persistent to start' );

		$user = User::newFromName( 'UnitTestContextUser' );
		$user->addToDatabase();

		$sinfo = [
			'sessionId' => 'd612ee607c87e749ef14da4983a702cd',
			'userId' => $user->getId(),
			'ip' => '192.0.2.0',
			'headers' => [
				'USER-AGENT' => 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:18.0) Gecko/20100101 Firefox/18.0'
			]
		];
		// importScopedSession() sets these variables
		$this->setMwGlobals( [
			'wgRequest' => new FauxRequest,
		] );
		$sc = RequestContext::importScopedSession( $sinfo ); // load new context

		$info = $context->exportSession();
		$this->assertEquals( $sinfo['ip'], $info['ip'], "Correct IP address." );
		$this->assertEquals( $sinfo['headers'], $info['headers'], "Correct headers." );
		$this->assertEquals( $sinfo['sessionId'], $info['sessionId'], "Correct session ID." );
		$this->assertEquals( $sinfo['userId'], $info['userId'], "Correct user ID." );
		$this->assertEquals(
			$sinfo['ip'],
			$context->getRequest()->getIP(),
			"Correct context IP address."
		);
		$this->assertEquals(
			$sinfo['headers'],
			$context->getRequest()->getAllHeaders(),
			"Correct context headers."
		);
		$this->assertEquals(
			$sinfo['sessionId'],
			SessionManager::getGlobalSession()->getId(),
			"Correct context session ID."
		);
		if ( \MediaWiki\Session\PHPSessionHandler::isEnabled() ) {
			$this->assertEquals( $sinfo['sessionId'], session_id(), "Correct context session ID." );
		} else {
			$this->assertEquals( $oldSessionId, session_id(), "Unchanged PHP session ID." );
		}
		$this->assertTrue( $context->getUser()->isRegistered(), "Correct context user." );
		$this->assertEquals( $sinfo['userId'], $context->getUser()->getId(), "Correct context user ID." );
		$this->assertEquals(
			'UnitTestContextUser',
			$context->getUser()->getName(),
			"Correct context user name."
		);

		unset( $sc ); // restore previous context

		$info = $context->exportSession();
		$this->assertEquals( $oInfo['ip'], $info['ip'], "Correct restored IP address." );
		$this->assertEquals( $oInfo['headers'], $info['headers'], "Correct restored headers." );
		$this->assertEquals( $oInfo['sessionId'], $info['sessionId'], "Correct restored session ID." );
		$this->assertEquals( $oInfo['userId'], $info['userId'], "Correct restored user ID." );
		$this->assertFalse( SessionManager::getGlobalSession()->isPersistent(),
			'Global session isn\'t persistent after restoring the context' );
	}

	/**
	 * @covers \MediaWiki\Context\RequestContext::getUser
	 * @covers \MediaWiki\Context\RequestContext::setUser
	 * @covers \MediaWiki\Context\RequestContext::getAuthority
	 * @covers \MediaWiki\Context\RequestContext::setAuthority
	 */
	public function testTestGetSetAuthority() {
		$context = new RequestContext();

		$user = $this->getTestUser()->getUser();

		$context->setUser( $user );
		$this->assertTrue( $user->equals( $context->getAuthority()->getUser() ) );
		$this->assertTrue( $user->equals( $context->getUser() ) );

		$authorityActor = new UserIdentityValue( 42, 'Test' );
		$authority = new UltimateAuthority( $authorityActor );

		$context->setAuthority( $authority );
		$this->assertTrue( $context->getUser()->equals( $authorityActor ) );
		$this->assertTrue( $context->getAuthority()->getUser()->equals( $authorityActor ) );
	}

	/**
	 * @covers \MediaWiki\Context\RequestContext
	 */
	public function testGetActionName() {
		$factory = $this->createMock( ActionFactory::class );
		$factory
			// Assert calling only once.
			// Determining the action name is an expensive operation that
			// must be cached by the context, as it involved database and hooks.
			->expects( $this->once() )
			->method( 'getActionName' )
			->willReturn( 'foo' );
		$this->setService( 'ActionFactory', $factory );

		$context = new RequestContext();
		$this->assertSame( 'foo', $context->getActionName(), 'value from factory' );
		$this->assertSame( 'foo', $context->getActionName(), 'cached' );
	}

	/**
	 * @covers \MediaWiki\Context\RequestContext
	 */
	public function testSetActionName() {
		$factory = $this->createMock( ActionFactory::class );
		$factory
			->expects( $this->never() )
			->method( 'getActionName' );
		$this->setService( 'ActionFactory', $factory );

		$context = new RequestContext();
		$context->setActionName( 'fixed' );
		$this->assertSame( 'fixed', $context->getActionName() );
	}

	/**
	 * @covers \MediaWiki\Context\RequestContext
	 */
	public function testOverideActionName() {
		$factory = $this->createMock( ActionFactory::class );
		$factory
			->method( 'getActionName' )
			->willReturnOnConsecutiveCalls( 'aaa', 'bbb' );
		$this->setService( 'ActionFactory', $factory );

		$context = new RequestContext();
		$this->assertSame( 'aaa', $context->getActionName(), 'first from factory' );
		$this->assertSame( 'aaa', $context->getActionName(), 'cached first' );

		// Ignore warning from clearActionName
		@$context->setTitle( $this->createMock( Title::class ) );
		$this->assertSame( 'bbb', $context->getActionName(), 'second from factory' );
		$this->assertSame( 'bbb', $context->getActionName(), 'cached second' );
	}

	private function registerTestSkin() {
		$skin = $this->createMock( Skin::class );

		$skinFactory = $this->getServiceContainer()->getSkinFactory();
		$skinFactory->register( 'test', 'test',
			static function () use ( $skin ) {
				return $skin;
			}
		);
		return $skin;
	}

	public function testGetSkinFromDefault() {
		$this->overrideConfigValue( MainConfigNames::DefaultSkin, 'test' );
		$skin = $this->registerTestSkin();
		$context = new RequestContext();
		$this->assertSame( $skin, $context->getSkin() );
	}

	public function testGetSkinFromPref() {
		$optionsLookup = new StaticUserOptionsLookup( [], [ 'skin' => 'test' ] );
		$this->setService( 'UserOptionsLookup', $optionsLookup );

		$skin = $this->registerTestSkin();

		$context = new RequestContext();
		$this->assertSame( $skin, $context->getSkin() );
	}

	public function testGetSkinFromStringHook() {
		$skin = $this->registerTestSkin();
		$this->setTemporaryHook(
			'RequestContextCreateSkin',
			static function ( $context, &$skin ) {
				$skin = 'test';
			}
		);
		$context = new RequestContext();
		$this->assertSame( $skin, $context->getSkin() );
	}

	public function testGetSkinFromObjectHook() {
		$skin = $this->createMock( Skin::class );
		$this->setTemporaryHook(
			'RequestContextCreateSkin',
			static function ( $context, &$skinRes ) use ( $skin ) {
				$skinRes = $skin;
			}
		);
		$context = new RequestContext();
		$this->assertSame( $skin, $context->getSkin() );
	}

	public function testGetSkinFromBadPrefs() {
		// T342733
		$this->overrideConfigValue( MainConfigNames::DefaultSkin, 'test' );
		$optionsLookup = new StaticUserOptionsLookup( [], [ 'skin' => '' ] );
		$this->setService( 'UserOptionsLookup', $optionsLookup );
		$skin = $this->registerTestSkin();

		$context = new RequestContext();
		$this->assertSame( $skin, $context->getSkin() );
	}

	public function testGetSkinFromBadDefault() {
		$this->overrideConfigValues( [
			MainConfigNames::DefaultSkin => 'nonexistent',
			MainConfigNames::HiddenPrefs => [ 'skin' ]
		] );
		$context = new RequestContext();
		$this->assertInstanceOf( SkinFallback::class, $context->getSkin() );
	}

	public function testCloningNotAllowed() {
		$context = RequestContext::getMain();
		$this->expectException( LogicException::class );

		clone $context;
	}
}
PK       ! UM  M  &  CommentFormatter/CommentParserTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\CommentFormatter;

use LinkCacheTestTrait;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\CommentFormatter\CommentFormatter;
use MediaWiki\CommentFormatter\CommentParser;
use MediaWiki\CommentFormatter\CommentParserFactory;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Config\SiteConfiguration;
use MediaWiki\Context\RequestContext;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\Title;
use RepoGroup;

/**
 * @group Database
 * @covers \MediaWiki\CommentFormatter\CommentParser
 * @group Database
 */
class CommentParserTest extends \MediaWikiIntegrationTestCase {
	use DummyServicesTrait;
	use LinkCacheTestTrait;

	/**
	 * @return RepoGroup
	 */
	private function getRepoGroup() {
		$repoGroup = $this->createNoOpMock( RepoGroup::class, [ 'findFiles' ] );
		$repoGroup->method( 'findFiles' )->willReturn( [] );
		return $repoGroup;
	}

	private function getParser() {
		$services = $this->getServiceContainer();
		return new CommentParser(
			$services->getLinkRenderer(),
			$services->getLinkBatchFactory(),
			$services->getLinkCache(),
			$this->getRepoGroup(),
			$services->getContentLanguage(),
			$services->getContentLanguage(),
			$services->getTitleParser(),
			$services->getNamespaceInfo(),
			$services->getHookContainer()
		);
	}

	private function getFormatter() {
		$parserFactory = $this->createNoOpMock( CommentParserFactory::class, [ 'create' ] );
		$parserFactory->method( 'create' )->willReturnCallback( function () {
			return $this->getParser();
		} );
		return new CommentFormatter( $parserFactory );
	}

	/**
	 * @before
	 */
	public function interwikiSetUp() {
		$this->setService( 'InterwikiLookup', function () {
			return $this->getDummyInterwikiLookup( [
				'interwiki' => [
					'iw_prefix' => 'interwiki',
					'iw_url' => 'https://interwiki/$1',
				]
			] );
		} );
	}

	/**
	 * @before
	 */
	public function configSetUp() {
		$conf = new SiteConfiguration();
		$conf->settings = [
			'wgServer' => [
				'foowiki' => '//foo.example.org'
			],
			'wgArticlePath' => [
				'foowiki' => '/foo/$1',
			],
		];
		$conf->suffixes = [ 'wiki' ];
		$this->setMwGlobals( 'wgConf', $conf );
		$this->overrideConfigValues( [
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::ArticlePath => '/wiki/$1',
			MainConfigNames::CapitalLinks => true,
			MainConfigNames::LanguageCode => 'en',
		] );
	}

	public static function provideFormatComment() {
		return [
			// MediaWiki\CommentFormatter\CommentFormatter::format
			[
				'a&lt;script&gt;b',
				'a<script>b',
			],
			[
				'a—b',
				'a&mdash;b',
			],
			[
				"&#039;&#039;&#039;not bolded&#039;&#039;&#039;",
				"'''not bolded'''",
			],
			[
				"try &lt;script&gt;evil&lt;/scipt&gt; things",
				"try <script>evil</scipt> things",
			],
			// MediaWiki\CommentFormatter\CommentParser::doSectionLinks
			[
				'<span class="autocomment"><a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→<bdi dir="ltr">autocomment</bdi></a></span>',
				"/* autocomment */",
			],
			[
				'<span class="autocomment"><a href="/wiki/Special:BlankPage#linkie.3F" title="Special:BlankPage">→<bdi dir="ltr">&#91;[linkie?]]</bdi></a></span>',
				"/* [[linkie?]] */",
			],
			[
				'<span class="autocomment">: </span> // Edit via via',
				// Regression test for T222857
				"/*  */ // Edit via via",
			],
			[
				'<span class="autocomment">: </span> foobar',
				// Regression test for T222857
				"/**/ foobar",
			],
			[
				'<span class="autocomment"><a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→<bdi dir="ltr">autocomment</bdi></a>: </span> post',
				"/* autocomment */ post",
			],
			[
				'pre <span class="autocomment"><a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→<bdi dir="ltr">autocomment</bdi></a></span>',
				"pre /* autocomment */",
			],
			[
				'pre <span class="autocomment"><a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→<bdi dir="ltr">autocomment</bdi></a>: </span> post',
				"pre /* autocomment */ post",
			],
			[
				'<span class="autocomment"><a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→<bdi dir="ltr">autocomment</bdi></a>: </span> multiple? <span class="autocomment"><a href="/wiki/Special:BlankPage#autocomment2" title="Special:BlankPage">→<bdi dir="ltr">autocomment2</bdi></a></span>',
				"/* autocomment */ multiple? /* autocomment2 */",
			],
			[
				'<span class="autocomment"><a href="/wiki/Special:BlankPage#autocomment_containing_.2F.2A" title="Special:BlankPage">→<bdi dir="ltr">autocomment containing /*</bdi></a>: </span> T70361',
				"/* autocomment containing /* */ T70361"
			],
			[
				'<span class="autocomment"><a href="/wiki/Special:BlankPage#autocomment_containing_.22quotes.22" title="Special:BlankPage">→<bdi dir="ltr">autocomment containing &quot;quotes&quot;</bdi></a></span>',
				"/* autocomment containing \"quotes\" */"
			],
			[
				'<span class="autocomment"><a href="/wiki/Special:BlankPage#autocomment_containing_.3Cscript.3Etags.3C.2Fscript.3E" title="Special:BlankPage">→<bdi dir="ltr">autocomment containing &lt;script&gt;tags&lt;/script&gt;</bdi></a></span>',
				"/* autocomment containing <script>tags</script> */"
			],
			[
				'<span class="autocomment"><a href="#autocomment">→<bdi dir="ltr">autocomment</bdi></a></span>',
				"/* autocomment */",
				false, true
			],
			[
				'<span class="autocomment">autocomment</span>',
				"/* autocomment */",
				null
			],
			[
				'',
				"/* */",
				false, true
			],
			[
				'',
				"/* */",
				null
			],
			[
				'<span class="autocomment">[[</span>',
				"/* [[ */",
				false, true
			],
			[
				'<span class="autocomment">[[</span>',
				"/* [[ */",
				null
			],
			[
				"foo <span class=\"autocomment\"><a href=\"#.23\">→<bdi dir=\"ltr\">&#91;[#_\t_]]</bdi></a></span>",
				"foo /* [[#_\t_]] */",
				false, true
			],
			[
				"foo <span class=\"autocomment\"><a href=\"#_.09\">#_\t_</a></span>",
				"foo /* [[#_\t_]] */",
				null
			],
			[
				'<span class="autocomment"><a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→<bdi dir="ltr">autocomment</bdi></a></span>',
				"/* autocomment */",
				false, false
			],
			[
				'<span class="autocomment"><a class="external" rel="nofollow" href="//foo.example.org/foo/Special:BlankPage#autocomment">→<bdi dir="ltr">autocomment</bdi></a></span>',
				"/* autocomment */",
				false, false, 'foowiki'
			],
			// MediaWiki\CommentFormatter\CommentParser::doWikiLinks
			[
				'abc <a href="/w/index.php?title=Link&amp;action=edit&amp;redlink=1" class="new" title="Link (page does not exist)">link</a> def',
				"abc [[link]] def",
			],
			[
				'abc <a href="/w/index.php?title=Link&amp;action=edit&amp;redlink=1" class="new" title="Link (page does not exist)">text</a> def',
				"abc [[link|text]] def",
			],
			[
				'abc <a href="/wiki/Special:BlankPage" title="Special:BlankPage">Special:BlankPage</a> def',
				"abc [[Special:BlankPage|]] def",
			],
			[
				'abc <a href="/w/index.php?title=%C4%84%C5%9B%C5%BC&amp;action=edit&amp;redlink=1" class="new" title="Ąśż (page does not exist)">ąśż</a> def',
				"abc [[%C4%85%C5%9B%C5%BC]] def",
			],
			[
				'abc <a href="/wiki/Special:BlankPage#section" title="Special:BlankPage">#section</a> def',
				"abc [[#section]] def",
			],
			[
				'abc <a href="/w/index.php?title=/subpage&amp;action=edit&amp;redlink=1" class="new" title="/subpage (page does not exist)">/subpage</a> def',
				"abc [[/subpage]] def",
			],
			[
				'abc <a href="/w/index.php?title=%22evil!%22&amp;action=edit&amp;redlink=1" class="new" title="&quot;evil!&quot; (page does not exist)">&quot;evil!&quot;</a> def',
				"abc [[\"evil!\"]] def",
			],
			[
				'abc [[&lt;script&gt;very evil&lt;/script&gt;]] def',
				"abc [[<script>very evil</script>]] def",
			],
			[
				'abc [[|]] def',
				"abc [[|]] def",
			],
			[
				'abc <a href="/w/index.php?title=Link&amp;action=edit&amp;redlink=1" class="new" title="Link (page does not exist)">link</a> def',
				"abc [[link]] def",
				false, false
			],
			[
				'abc <a class="external" rel="nofollow" href="//foo.example.org/foo/Link">link</a> def',
				"abc [[link]] def",
				false, false, 'foowiki'
			],
			[
				'<a href="/w/index.php?title=Special:Upload&amp;wpDestFile=LinkerTest.jpg" class="new" title="LinkerTest.jpg">Media:LinkerTest.jpg</a>',
				'[[Media:LinkerTest.jpg]]'
			],
			[
				'<a href="/wiki/Special:BlankPage" title="Special:BlankPage">Special:BlankPage</a>',
				'[[:Special:BlankPage]]'
			],
			[
				'<a href="/w/index.php?title=Link&amp;action=edit&amp;redlink=1" class="new" title="Link (page does not exist)">linktrail</a>...',
				'[[link]]trail...'
			],
			[
				'<a href="/wiki/Present" title="Present">Present</a>',
				'[[Present]]',
			],
			[
				'<a href="https://interwiki/Some_page" class="extiw" title="interwiki:Some page">interwiki:Some page</a>',
				'[[interwiki:Some page]]',
			],
			[
				'<a href="https://interwiki/Present" class="extiw" title="interwiki:Present">interwiki:Present</a> <a href="/wiki/Present" title="Present">Present</a>',
				'[[interwiki:Present]] [[Present]]'
			]
		];
		// phpcs:enable
	}

	/**
	 * @dataProvider provideFormatComment
	 */
	public function testFormatComment(
		$expected, $comment, $title = false, $local = false, $wikiId = null
	) {
		$conf = new SiteConfiguration();
		$conf->settings = [
			'wgServer' => [
				'foowiki' => '//foo.example.org',
			],
			'wgArticlePath' => [
				'foowiki' => '/foo/$1',
			],
		];
		$conf->suffixes = [ 'wiki' ];

		$this->setMwGlobals( 'wgConf', $conf );
		$this->overrideConfigValues( [
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::ArticlePath => '/wiki/$1',
			MainConfigNames::CapitalLinks => true,
			// TODO: update tests when the default changes
			MainConfigNames::FragmentMode => [ 'legacy' ],
			MainConfigNames::LanguageCode => 'en',
		] );

		$this->addGoodLinkObject( 1, Title::makeTitle( NS_MAIN, 'Present' ) );

		if ( $title === false ) {
			// We need a page title that exists
			$title = Title::makeTitle( NS_SPECIAL, 'BlankPage' );
		}

		$parser = $this->getParser();
		$result = $parser->finalize(
			$parser->preprocess(
				$comment,
				$title,
				$local,
				$wikiId
			)
		);

		$this->assertEquals( $expected, $result );
	}

	public static function provideFormatLinksInComment() {
		return [
			[
				'foo bar <a href="/wiki/Special:BlankPage" title="Special:BlankPage">Special:BlankPage</a>',
				'foo bar [[Special:BlankPage]]',
				null,
			],
			[
				'<a href="/wiki/Special:BlankPage" title="Special:BlankPage">Special:BlankPage</a>',
				'[[ :Special:BlankPage]]',
				null,
			],
			[
				'[[Foo<a href="/wiki/Special:BlankPage" title="Special:BlankPage">Special:BlankPage</a>',
				'[[Foo[[Special:BlankPage]]',
				null,
			],
			[
				'<a class="external" rel="nofollow" href="//foo.example.org/foo/Foo%27bar">Foo&#039;bar</a>',
				"[[Foo'bar]]",
				'foowiki',
			],
			[
				'<a class="external" rel="nofollow" href="//foo.example.org/foo/Foo$100bar">Foo$100bar</a>',
				'[[Foo$100bar]]',
				'foowiki',
			],
			[
				'foo bar <a class="external" rel="nofollow" href="//foo.example.org/foo/Special:BlankPage">Special:BlankPage</a>',
				'foo bar [[Special:BlankPage]]',
				'foowiki',
			],
			[
				'foo bar <a class="external" rel="nofollow" href="//foo.example.org/foo/File:Example">Image:Example</a>',
				'foo bar [[Image:Example]]',
				'foowiki',
			],
		];
		// phpcs:enable
	}

	/**
	 * @covers \MediaWiki\CommentFormatter\CommentFormatter
	 * @covers \MediaWiki\CommentFormatter\CommentParser
	 * @dataProvider provideCommentBlock
	 */
	public function testCommentBlock(
		$expected, $comment, $title = null, $local = false, $wikiId = null, $useParentheses = true
	) {
		$conf = new SiteConfiguration();
		$conf->settings = [
			'wgServer' => [
				'foowiki' => '//foo.example.org'
			],
			'wgArticlePath' => [
				'foowiki' => '/foo/$1',
			],
		];
		$conf->suffixes = [ 'wiki' ];
		$this->setMwGlobals( 'wgConf', $conf );
		$this->overrideConfigValues( [
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::ArticlePath => '/wiki/$1',
			MainConfigNames::CapitalLinks => true,
		] );

		$formatter = $this->getFormatter();
		$this->assertEquals(
			$expected,
			$formatter->formatBlock( $comment, $title, $local, $wikiId, $useParentheses )
		);
	}

	public static function provideCommentBlock() {
		return [
			[
				' <span class="comment">(Test)</span>',
				'Test'
			],
			'Empty comment' => [ '', '' ],
			'Backwards compatibility empty comment' => [ '', '*' ],
			'No parenthesis' => [
				' <span class="comment comment--without-parentheses">Test</span>',
				'Test',
				null, false, null,
				false
			],
			'Page exist link' => [
				' <span class="comment">(<a href="/wiki/Special:BlankPage" title="Special:BlankPage">Special:BlankPage</a>)</span>',
				'[[Special:BlankPage]]'
			],
			'Page does not exist link' => [
				' <span class="comment">(<a href="/w/index.php?title=Test&amp;action=edit&amp;redlink=1" class="new" title="Test (page does not exist)">Test</a>)</span>',
				'[[Test]]'
			],
			'Link to other page section' => [
				' <span class="comment">(<a href="/wiki/Special:BlankPage#Test" title="Special:BlankPage">#Test</a>)</span>',
				'[[#Test]]',
				Title::makeTitle( NS_SPECIAL, 'BlankPage' )
			],
			'$local is true' => [
				' <span class="comment">(<a href="#Test">#Test</a>)</span>',
				'[[#Test]]',
				Title::makeTitle( NS_SPECIAL, 'BlankPage' ),
				true
			],
			'Given wikiId' => [
				' <span class="comment">(<a class="external" rel="nofollow" href="//foo.example.org/foo/Test">Test</a>)</span>',
				'[[Test]]',
				null, false,
				'foowiki'
			],
			'Section link to external wiki page' => [
				' <span class="comment">(<a class="external" rel="nofollow" href="//foo.example.org/foo/Special:BlankPage#Test">#Test</a>)</span>',
				'[[#Test]]',
				Title::makeTitle( NS_SPECIAL, 'BlankPage' ),
				false,
				'foowiki'
			],
		];
	}

	/**
	 * Note that we test the new HTML escaping variant.
	 *
	 * @dataProvider provideFormatLinksInComment
	 */
	public function testFormatLinksInComment( $expected, $input, $wiki ) {
		$parser = $this->getParser();
		$title = Title::makeTitle( NS_SPECIAL, 'BlankPage' );
		$result = $parser->finalize(
			$parser->preprocess(
				$input, $title, false, $wiki, false
			)
		);

		$this->assertEquals( $expected, $result );
	}

	public function testLinkCacheInteraction() {
		$services = $this->getServiceContainer();
		$present = $this->getExistingTestPage( 'Present' )->getTitle();
		$absent = $this->getNonexistingTestPage( 'Absent' )->getTitle();

		$parser = $this->getParser();
		$linkCache = $services->getLinkCache();
		$result = $parser->finalize( [
			$parser->preprocess( "[[$present]]" ),
			$parser->preprocess( "[[$absent]]" )
		] );
		$expected = [
			'<a href="/wiki/Present" title="Present">Present</a>',
			'<a href="/w/index.php?title=Absent&amp;action=edit&amp;redlink=1" class="new" title="Absent (page does not exist)">Absent</a>'
		];
		$this->assertSame( $expected, $result );
		$this->assertGreaterThan( 0, $linkCache->getGoodLinkID( $present ) );
		$this->assertTrue( $linkCache->isBadLink( $absent ) );

		// Run the comment batch again and confirm that LinkBatch does not need
		// to execute a query. This is a CommentParser responsibility since
		// LinkBatch does not provide a transparent read-through cache.
		// TODO: Generic $this->assertQueryCount() would do the job.
		$dbProvider = $services->getConnectionProvider();
		$linkBatchFactory = new LinkBatchFactory(
			$services->getLinkCache(),
			$services->getTitleFormatter(),
			$services->getContentLanguage(),
			$services->getGenderCache(),
			$dbProvider,
			$services->getLinksMigration(),
			LoggerFactory::getInstance( 'LinkBatch' )
		);
		$parser = new CommentParser(
			$services->getLinkRenderer(),
			$linkBatchFactory,
			$linkCache,
			$this->getRepoGroup(),
			$services->getContentLanguage(),
			$services->getContentLanguage(),
			$services->getTitleParser(),
			$services->getNamespaceInfo(),
			$services->getHookContainer()
		);
		$result = $parser->finalize( [
			$parser->preprocess( "[[$present]]" ),
			$parser->preprocess( "[[$absent]]" )
		] );
		$this->assertSame( $expected, $result );
	}

	/**
	 * Regression test for T300311
	 */
	public function testInterwikiLinkCachePollution() {
		$present = $this->getExistingTestPage( 'Template:Present' )->getTitle();

		$this->getServiceContainer()->getLinkCache()->clear();
		$parser = $this->getParser();
		$result = $parser->finalize(
			$parser->preprocess( "[[interwiki:$present]] [[$present]]" )
		);
		$this->assertSame(
			// phpcs:ignore Generic.Files.LineLength
			"<a href=\"https://interwiki/$present\" class=\"extiw\" title=\"interwiki:$present\">interwiki:$present</a> <a href=\"/wiki/$present\" title=\"$present\">$present</a>",
			$result
		);
	}

	/**
	 * Regression test for T293665
	 */
	public function testAlwaysKnownPages() {
		$this->setTemporaryHook( 'TitleIsAlwaysKnown',
			static function ( $target, &$isKnown ) {
				$isKnown = $target->getText() == 'AlwaysKnownFoo';
			}
		);

		$title = Title::makeTitle( NS_USER, 'AlwaysKnownFoo' );
		$this->assertFalse( $title->exists() );

		$parser = $this->getParser();
		$result = $parser->finalize( $parser->preprocess( 'test [[User:AlwaysKnownFoo]]' ) );

		$this->assertSame(
			'test <a href="/wiki/User:AlwaysKnownFoo" title="User:AlwaysKnownFoo">User:AlwaysKnownFoo</a>',
			$result
		);
	}

	/**
	 * @dataProvider provideRevComment
	 */
	public function testRevComment(
		string $expected,
		bool $isSysop = false,
		int $visibility = 0,
		bool $local = false,
		bool $isPublic = false,
		bool $useParentheses = true,
		?string $comment = 'Some comment!'
	) {
		$pageData = $this->insertPage( 'RevCommentTestPage' );
		$revisionRecord = new MutableRevisionRecord( $pageData['title'] );
		if ( $comment ) {
			$revisionRecord->setComment( CommentStoreComment::newUnsavedComment( $comment ) );
		}
		$revisionRecord->setVisibility( $visibility );

		$context = RequestContext::getMain();
		$user = $isSysop ? $this->getTestSysop()->getUser() : $this->getTestUser()->getUser();
		$context->setUser( $user );

		$formatter = $this->getFormatter();
		$authority = RequestContext::getMain()->getAuthority();
		$this->assertEquals( $expected, $formatter->formatRevision( $revisionRecord, $authority, $local, $isPublic, $useParentheses ) );
	}

	public static function provideRevComment() {
		return [
			'Should be visible' => [
				' <span class="comment">(Some comment!)</span>'
			],
			'Should not have parenthesis' => [
				' <span class="comment comment--without-parentheses">Some comment!</span>',
				false, 0, false, false,
				false
			],
			'Should be empty' => [
				'',
				false, 0, false, false, true,
				null
			],
			'Deleted comment should not be visible to normal users' => [
				' <span class="history-deleted comment"> <span class="comment">(edit summary removed)</span></span>',
				false,
				RevisionRecord::DELETED_COMMENT
			],
			'Deleted comment should not be visible to normal users even if public' => [
				' <span class="history-deleted comment"> <span class="comment">(edit summary removed)</span></span>',
				false,
				RevisionRecord::DELETED_COMMENT,
				false,
				true
			],
			'Deleted comment should be visible to sysops' => [
				' <span class="history-deleted comment"> <span class="comment">(Some comment!)</span></span>',
				true,
				RevisionRecord::DELETED_COMMENT
			],
		];
	}

}
PK       ! u?4  4  ,  CommentFormatter/RowCommentFormatterTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\CommentFormatter;

use MediaWiki\CommentFormatter\CommentParser;
use MediaWiki\CommentFormatter\RowCommentFormatter;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\Tests\Unit\CommentFormatter\CommentFormatterTestUtils;
use MediaWiki\Tests\Unit\DummyServicesTrait;

/**
 * @covers \MediaWiki\CommentFormatter\RowCommentFormatter
 * @covers \MediaWiki\CommentFormatter\RowCommentIterator
 */
class RowCommentFormatterTest extends \MediaWikiIntegrationTestCase {
	use DummyServicesTrait;

	private function getParser() {
		return new class extends CommentParser {
			public function __construct() {
			}

			public function preprocess(
				string $comment, ?LinkTarget $selfLinkTarget = null, $samePage = false,
				$wikiId = null, $enableSectionLinks = true
			) {
				if ( $comment === '' || $comment === '*' ) {
					return $comment; // Hack to make it work more like the real parser
				}
				return CommentFormatterTestUtils::dumpArray( [
					'comment' => $comment,
					'selfLinkTarget' => $selfLinkTarget,
					'samePage' => $samePage,
					'wikiId' => $wikiId,
					'enableSectionLinks' => $enableSectionLinks
				] );
			}
		};
	}

	private function newCommentFormatter() {
		return new RowCommentFormatter(
			$this->getDummyCommentParserFactory( $this->getParser() ),
			$this->getServiceContainer()->getCommentStore()
		);
	}

	public function testFormatRows() {
		$rows = [
			(object)[
				'comment_text' => 'hello',
				'comment_data' => null,
				'namespace' => '0',
				'title' => 'Page',
				'id' => 1
			]
		];
		$commentFormatter = $this->newCommentFormatter();
		$result = $commentFormatter->formatRows(
			$rows,
			'comment',
			'namespace',
			'title',
			'id'
		);
		$this->assertSame(
			[
				1 => 'comment=hello, selfLinkTarget=0:Page, !samePage, !wikiId, enableSectionLinks'
			],
			$result
		);
	}

	public function testRowsWithFormatItems() {
		$rows = [
			(object)[
				'comment_text' => 'hello',
				'comment_data' => null,
				'namespace' => '0',
				'title' => 'Page',
				'id' => 1
			]
		];
		$commentFormatter = $this->newCommentFormatter();
		$result = $commentFormatter->formatItems(
			$commentFormatter->rows( $rows )
				->commentKey( 'comment' )
				->namespaceField( 'namespace' )
				->titleField( 'title' )
				->indexField( 'id' )
		);
		$this->assertSame(
			[
				1 => 'comment=hello, selfLinkTarget=0:Page, !samePage, !wikiId, enableSectionLinks'
			],
			$result
		);
	}

	public function testRowsWithCreateBatch() {
		$rows = [
			(object)[
				'comment_text' => 'hello',
				'comment_data' => null,
				'namespace' => '0',
				'title' => 'Page',
				'id' => 1
			]
		];
		$commentFormatter = $this->newCommentFormatter();
		$result = $commentFormatter->createBatch()
			->comments(
				$commentFormatter->rows( $rows )
					->commentKey( 'comment' )
					->namespaceField( 'namespace' )
					->titleField( 'title' )
					->indexField( 'id' )
			)
			->samePage( true )
			->execute();
		$this->assertSame(
			[
				1 => 'comment=hello, selfLinkTarget=0:Page, samePage, !wikiId, enableSectionLinks'
			],
			$result
		);
	}
}
PK       ! 2f}#  }#  )  CommentFormatter/CommentFormatterTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\CommentFormatter;

use MediaWiki\CommentFormatter\CommentFormatter;
use MediaWiki\CommentFormatter\CommentItem;
use MediaWiki\CommentFormatter\CommentParser;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\SimpleAuthority;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Tests\Unit\CommentFormatter\CommentFormatterTestUtils;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;

/**
 * Trivial comment formatting with a mocked parser. Can't be a unit test because
 * of the wfMessage() calls.
 *
 * @covers \MediaWiki\CommentFormatter\CommentFormatter
 */
class CommentFormatterTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;

	private function getParser() {
		return new class extends CommentParser {
			public function __construct() {
			}

			public function preprocess(
				string $comment, ?LinkTarget $selfLinkTarget = null, $samePage = false,
				$wikiId = null, $enableSectionLinks = true
			) {
				if ( $comment === '' || $comment === '*' ) {
					return $comment; // Hack to make it work more like the real parser
				}
				return CommentFormatterTestUtils::dumpArray( [
					'comment' => $comment,
					'selfLinkTarget' => $selfLinkTarget,
					'samePage' => $samePage,
					'wikiId' => $wikiId,
					'enableSectionLinks' => $enableSectionLinks
				] );
			}

			public function preprocessUnsafe(
				$comment, ?LinkTarget $selfLinkTarget = null, $samePage = false, $wikiId = null,
				$enableSectionLinks = true
			) {
				return CommentFormatterTestUtils::dumpArray( [
					'comment' => $comment,
					'selfLinkTarget' => $selfLinkTarget,
					'samePage' => $samePage,
					'wikiId' => $wikiId,
					'enableSectionLinks' => $enableSectionLinks,
					'unsafe' => true
				] );
			}

			public function finalize( $comments ) {
				return $comments;
			}
		};
	}

	private function newCommentFormatter() {
		return new CommentFormatter(
			$this->getDummyCommentParserFactory( $this->getParser() )
		);
	}

	public function testCreateBatch() {
		$formatter = $this->newCommentFormatter();
		$result = $formatter->createBatch()
			->strings( [ 'key' => 'c' ] )
			->useBlock()
			->useParentheses()
			->samePage()
			->execute();
		$this->assertSame(
			[
				'key' =>
				// parentheses have to come after something so I guess it
				// makes sense that there is a space here
					' ' .
					'<span class="comment">(comment=c, samePage, !wikiId, enableSectionLinks)</span>'
			],
			$result
		);
	}

	public function testFormatItems() {
		$formatter = $this->newCommentFormatter();
		$result = $formatter->formatItems( [
			'key' => new CommentItem( 'c' )
		] );
		$this->assertSame(
			[ 'key' => 'comment=c, !samePage, !wikiId, enableSectionLinks' ],
			$result
		);
	}

	public function testFormat() {
		$formatter = $this->newCommentFormatter();
		$result = $formatter->format(
			'c',
			new TitleValue( 0, 'Page' ),
			true,
			'enwiki'
		);
		$this->assertSame(
			'comment=c, selfLinkTarget=0:Page, samePage, wikiId=enwiki, enableSectionLinks',
			$result
		);
	}

	public function testFormatBlock() {
		$formatter = $this->newCommentFormatter();
		$result = $formatter->formatBlock(
			'c',
			new TitleValue( 0, 'Page' ),
			true,
			'enwiki',
			true
		);
		$this->assertSame(
			' <span class="comment">' .
			'(comment=c, selfLinkTarget=0:Page, samePage, wikiId=enwiki, enableSectionLinks)' .
			'</span>',
			$result
		);
	}

	public function testFormatLinksUnsafe() {
		$formatter = $this->newCommentFormatter();
		$result = $formatter->formatLinksUnsafe(
			'c',
			new TitleValue( 0, 'Page' ),
			true,
			'enwiki'
		);
		$this->assertSame(
			'comment=c, selfLinkTarget=0:Page, samePage, wikiId=enwiki, !enableSectionLinks, unsafe',
			$result
		);
	}

	public function testFormatLinks() {
		$formatter = $this->newCommentFormatter();
		$result = $formatter->formatLinks(
			'c',
			new TitleValue( 0, 'Page' ),
			true,
			'enwiki'
		);
		$this->assertSame(
			'comment=c, selfLinkTarget=0:Page, samePage, wikiId=enwiki, !enableSectionLinks',
			$result
		);
	}

	public function testFormatStrings() {
		$formatter = $this->newCommentFormatter();
		$result = $formatter->formatStrings(
			[
				'a' => 'A',
				'b' => 'B'
			],
			new TitleValue( 0, 'Page' ),
			true,
			'enwiki'
		);
		$this->assertSame(
			[
				'a' => 'comment=A, selfLinkTarget=0:Page, samePage, wikiId=enwiki, enableSectionLinks',
				'b' => 'comment=B, selfLinkTarget=0:Page, samePage, wikiId=enwiki, enableSectionLinks'
			],
			$result
		);
	}

	public static function provideFormatRevision() {
		$normal = ' <span class="comment">(' .
			'comment=hello, selfLinkTarget=Page, !samePage, enableSectionLinks' .
			')</span>';
		$deleted = ' <span class="history-deleted comment"> ' .
			'<span class="comment">(edit summary removed)</span></span>';
		$deletedAllowed = ' <span class="history-deleted comment"> ' .
			'<span class="comment">(' .
			'comment=hello, selfLinkTarget=Page, !samePage, enableSectionLinks' .
			')</span></span>';

		return [
			'not deleted' => [
				'hello', false, false, false, true,
				$normal,
			],
			'deleted, for public, not allowed' => [
				'hello', true, true, false, true,
				$deleted
			],
			'deleted, for public, allowed' => [
				'hello', true, true, true, true,
				$deleted
			],
			'deleted, for private, not allowed' => [
				'hello', false, true, false, true,
				$deleted
			],
			'deleted, for private, allowed' => [
				'hello', false, true, true, true,
				$deletedAllowed,
			],
			'empty' => [
				'', false, false, false, true,
				''
			],
			'asterisk' => [
				'*', false, false, false, true,
				''
			]
		];
	}

	/**
	 * @param string $text
	 * @param bool $isDeleted
	 * @param bool $isAllowed
	 * @return array<RevisionRecord|Authority>
	 */
	private function makeRevisionAndAuthority( $text, $isDeleted, $isAllowed ) {
		$page = new PageIdentityValue( 1, 0, 'Page', false );
		$rev = new MutableRevisionRecord( $page );
		$comment = new CommentStoreComment( 1, $text );
		$rev->setId( 100 );
		$rev->setComment( $comment );
		$rev->setVisibility( $isDeleted ? RevisionRecord::DELETED_COMMENT : 0 );
		$user = new UserIdentityValue( 1, 'Sysop' );
		$rights = $isAllowed ? [ 'deletedhistory' ] : [];
		$authority = new SimpleAuthority( $user, $rights );
		return [ $rev, $authority ];
	}

	/** @dataProvider provideFormatRevision */
	public function testFormatRevision( $comment, $isPublic, $isDeleted, $isAllowed, $useParentheses,
		$expected
	) {
		[ $rev, $authority ] = $this->makeRevisionAndAuthority(
			$comment, $isDeleted, $isAllowed );
		$formatter = $this->newCommentFormatter();
		$result = $formatter->formatRevision(
			$rev,
			$authority,
			false,
			$isPublic
		);
		$this->assertSame(
			$expected,
			$result
		);
	}

	/** @dataProvider provideFormatRevision */
	public function testFormatRevisions( $comment, $isPublic, $isDeleted, $isAllowed, $useParentheses,
		$expected
	) {
		[ $rev, $authority ] = $this->makeRevisionAndAuthority(
			$comment, $isDeleted, $isAllowed );
		$formatter = $this->newCommentFormatter();
		$result = $formatter->formatRevisions(
			[ 'key' => $rev ],
			$authority,
			false,
			$isPublic
		);
		$this->assertSame(
			[ 'key' => $expected ],
			$result
		);
	}

	public function testFormatRevisionsById() {
		[ $rev, $authority ] = $this->makeRevisionAndAuthority(
			'hello', false, false );
		$formatter = $this->newCommentFormatter();
		$result = $formatter->formatRevisions(
			[ 'key' => $rev ],
			$authority,
			false,
			false,
			true,
			true
		);
		$this->assertSame(
			[ 100 => ' <span class="comment">(' .
				'comment=hello, selfLinkTarget=Page, !samePage, enableSectionLinks' .
				')</span>'
			],
			$result
		);
	}

	/** @dataProvider provideFormatRevision */
	public function testCreateRevisionBatch( $comment, $isPublic, $isDeleted, $isAllowed, $useParentheses,
		$expected
	) {
		[ $rev, $authority ] = $this->makeRevisionAndAuthority(
			$comment, $isDeleted, $isAllowed );
		$formatter = $this->newCommentFormatter();
		$result = $formatter->createRevisionBatch()
			->authority( $authority )
			->hideIfDeleted( $isPublic )
			->useParentheses()
			->revisions( [ 'key' => $rev ] )
			->execute();
		$this->assertSame(
			[ 'key' => $expected ],
			$result
		);
	}

	public function testCreateRevisionBatchById() {
		[ $rev, $authority ] = $this->makeRevisionAndAuthority(
			'hello', false, false );
		$formatter = $this->newCommentFormatter();
		$result = $formatter->createRevisionBatch()
			->authority( $authority )
			->useParentheses()
			->indexById()
			->revisions( [ 'key' => $rev ] )
			->execute();
		$this->assertSame(
			[ 100 => ' <span class="comment">(' .
				'comment=hello, selfLinkTarget=Page, !samePage, enableSectionLinks' .
				')</span>'
			],
			$result
		);
	}
}
PK       ! 6@"  "  "  watchlist/WatchlistManagerTest.phpnu Iw        <?php

use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\User\UserIdentityValue;

/**
 * @covers \MediaWiki\Watchlist\WatchlistManager
 *
 * @author DannyS712
 * @group Database
 */
class WatchlistManagerTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;

	/**
	 * While this *could* be moved to a unit test, it is specifically kept
	 * here as an integration test to double check that the actual integration
	 * between this service and others, and getting this service from the
	 * service container, works as expected. Please don't move it to
	 * the unit tests.
	 *
	 * @covers \MediaWiki\Watchlist\WatchlistManager::isWatched
	 * @covers \MediaWiki\Watchlist\WatchlistManager::isTempWatched
	 * @covers \MediaWiki\Watchlist\WatchlistManager::addWatch
	 * @covers \MediaWiki\Watchlist\WatchlistManager::addWatchIgnoringRights()
	 * @covers \MediaWiki\Watchlist\WatchlistManager::removeWatch()
	 */
	public function testWatchlist() {
		$userIdentity = new UserIdentityValue( 100, 'User Name' );
		$authority = $this->mockUserAuthorityWithPermissions(
			$userIdentity,
			[ 'editmywatchlist', 'viewmywatchlist' ]
		);
		$title = new PageIdentityValue( 100, NS_MAIN, 'Page_db_Key_goesHere', PageIdentityValue::LOCAL );

		$services = $this->getServiceContainer();
		$watchedItemStore = $services->getWatchedItemStore();
		$watchlistManager = $services->getWatchlistManager();

		$watchedItemStore->clearUserWatchedItems( $userIdentity );

		$this->assertFalse(
			$watchlistManager->isWatched( $authority, $title ),
			'The article has not been watched yet'
		);
		$this->assertFalse(
			$watchlistManager->isTempWatched( $authority, $title ),
			"The article hasn't been temporarily watched"
		);

		$watchlistManager->addWatch( $authority, $title );
		$this->assertTrue( $watchlistManager->isWatched( $authority, $title ), 'The article has been watched' );
		$this->assertFalse(
			$watchlistManager->isTempWatched( $authority, $title ),
			"The article hasn't been temporarily watched"
		);

		$watchlistManager->removeWatch( $authority, $title );
		$this->assertFalse( $watchlistManager->isWatched( $authority, $title ), 'The article has been unwatched' );
		$this->assertFalse(
			$watchlistManager->isTempWatched( $authority, $title ),
			"The article hasn't been temporarily watched"
		);

		$watchlistManager->addWatch( $authority, $title, '2 weeks' );
		$this->assertTrue( $watchlistManager->isWatched( $authority, $title ), 'The article has been watched' );
		$this->assertTrue(
			$watchlistManager->isTempWatched( $authority, $title ), 'The article has been tempoarily watched'
		);

		$watchlistManager->removeWatch( $authority, $title );
		$this->assertFalse( $watchlistManager->isWatched( $authority, $title ), 'The article has been unwatched' );
		$this->assertFalse(
			$watchlistManager->isTempWatched( $authority, $title ),
			"The article hasn't been temporarily watched"
		);

		$watchlistManager->addWatchIgnoringRights( $userIdentity, $title );
		$this->assertTrue( $watchlistManager->isWatched( $authority, $title ), 'The article has been watched' );
		$this->assertFalse(
			$watchlistManager->isTempWatched( $authority, $title ),
			"The article hasn't been temporarily watched"
		);
	}

	/**
	 * Can't move to unit tests because if the user is missing rights
	 * the status is from User::newFatalPermissionDeniedStatus which uses
	 * MediaWikiServices
	 *
	 * @covers \MediaWiki\Watchlist\WatchlistManager::isWatched
	 * @covers \MediaWiki\Watchlist\WatchlistManager::isWatchedIgnoringRights
	 * @covers \MediaWiki\Watchlist\WatchlistManager::isTempWatched
	 * @covers \MediaWiki\Watchlist\WatchlistManager::isTempWatchedIgnoringRights
	 * @covers \MediaWiki\Watchlist\WatchlistManager::addWatch
	 * @covers \MediaWiki\Watchlist\WatchlistManager::addWatchIgnoringRights()
	 * @covers \MediaWiki\Watchlist\WatchlistManager::removeWatch()
	 */
	public function testWatchlistNoRights() {
		$userIdentity = new UserIdentityValue( 100, 'User Name' );
		$authority = $this->mockUserAuthorityWithPermissions( $userIdentity, [] );
		$title = new PageIdentityValue( 100, NS_MAIN, 'Page_db_Key_goesHere', PageIdentityValue::LOCAL );

		$services = $this->getServiceContainer();
		$watchedItemStore = $services->getWatchedItemStore();
		$watchlistManager = $services->getWatchlistManager();

		$watchedItemStore->clearUserWatchedItems( $userIdentity );

		$this->assertFalse( $watchlistManager->isWatched( $authority, $title ) );
		$this->assertFalse( $watchlistManager->isTempWatched( $authority, $title ) );
		$this->assertFalse( $watchlistManager->isWatchedIgnoringRights( $userIdentity, $title ) );
		$this->assertFalse( $watchlistManager->isTempWatchedIgnoringRights( $userIdentity, $title ) );

		$watchlistManager->addWatch( $authority, $title );
		$this->assertFalse( $watchlistManager->isWatched( $authority, $title ) );
		$this->assertFalse( $watchlistManager->isTempWatched( $authority, $title ) );
		$this->assertFalse( $watchlistManager->isWatchedIgnoringRights( $userIdentity, $title ) );
		$this->assertFalse( $watchlistManager->isTempWatchedIgnoringRights( $userIdentity, $title ) );

		$watchlistManager->addWatchIgnoringRights( $userIdentity, $title );
		$this->assertFalse( $watchlistManager->isWatched( $authority, $title ) );
		$this->assertFalse( $watchlistManager->isTempWatched( $authority, $title ) );
		$this->assertTrue( $watchlistManager->isWatchedIgnoringRights( $userIdentity, $title ) );
		$this->assertFalse( $watchlistManager->isTempWatchedIgnoringRights( $userIdentity, $title ) );

		$watchlistManager->addWatchIgnoringRights( $userIdentity, $title, '1 week' );
		$this->assertFalse( $watchlistManager->isWatched( $authority, $title ) );
		$this->assertFalse( $watchlistManager->isTempWatched( $authority, $title ) );
		$this->assertTrue( $watchlistManager->isWatchedIgnoringRights( $userIdentity, $title ) );
		$this->assertTrue( $watchlistManager->isTempWatchedIgnoringRights( $userIdentity, $title ) );

		$watchlistManager->removeWatch( $authority, $title );
		$this->assertFalse( $watchlistManager->isWatched( $authority, $title ) );
		$this->assertFalse( $watchlistManager->isTempWatched( $authority, $title ) );
		$this->assertTrue( $watchlistManager->isWatchedIgnoringRights( $userIdentity, $title ) );
		$this->assertTrue( $watchlistManager->isTempWatchedIgnoringRights( $userIdentity, $title ) );

		$watchlistManager->removeWatchIgnoringRights( $userIdentity, $title );
		$this->assertFalse( $watchlistManager->isWatched( $authority, $title ) );
		$this->assertFalse( $watchlistManager->isTempWatched( $authority, $title ) );
		$this->assertFalse( $watchlistManager->isWatchedIgnoringRights( $userIdentity, $title ) );
		$this->assertFalse( $watchlistManager->isTempWatchedIgnoringRights( $userIdentity, $title ) );
	}

	/**
	 * Can't move to unit tests because if the user is missing rights
	 * the status is from User::newFatalPermissionDeniedStatus which uses
	 * MediaWikiServices
	 *
	 * @covers \MediaWiki\Watchlist\WatchlistManager::addWatch()
	 */
	public function testAddWatchUserNotPermittedStatusNotGood() {
		$userIdentity = new UserIdentityValue( 100, 'User Name' );
		$performer = $this->mockUserAuthorityWithPermissions( $userIdentity, [] );
		$title = new PageIdentityValue( 100, NS_MAIN, 'Page_db_Key_goesHere', PageIdentityValue::LOCAL );

		$services = $this->getServiceContainer();
		$watchedItemStore = $services->getWatchedItemStore();
		$watchlistManager = $services->getWatchlistManager();

		$watchedItemStore->clearUserWatchedItems( $userIdentity );

		$actual = $watchlistManager->addWatch( $performer, $title );

		$this->assertStatusNotGood( $actual );
		$this->assertFalse( $watchlistManager->isWatchedIgnoringRights( $userIdentity, $title ) );
	}

	/**
	 * Can't move to unit tests because if the user is missing rights
	 * the status is from User::newFatalPermissionDeniedStatus which uses
	 * MediaWikiServices
	 *
	 * @covers \MediaWiki\Watchlist\WatchlistManager::removeWatch()
	 */
	public function testRemoveWatchWithoutRights() {
		$userIdentity = new UserIdentityValue( 100, 'User Name' );
		$performer = $this->mockUserAuthorityWithPermissions( $userIdentity, [] );
		$title = new PageIdentityValue( 100, NS_MAIN, 'Page_db_Key_goesHere', PageIdentityValue::LOCAL );

		$services = $this->getServiceContainer();
		$watchlistManager = $services->getWatchlistManager();

		$watchlistManager->addWatchIgnoringRights( $userIdentity, $title );

		$actual = $watchlistManager->removeWatch( $performer, $title );

		$this->assertStatusNotGood( $actual );
		$this->assertTrue( $watchlistManager->isWatchedIgnoringRights( $userIdentity, $title ) );
	}

}
PK       ! ?O	  O	  -  search/SearchSuggestionSetIntegrationTest.phpnu Iw        <?php

/**
 * Test for filter utilities.
 *
 * 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
 */

use MediaWiki\Title\Title;

class SearchSuggestionSetIntegrationTest extends MediaWikiIntegrationTestCase {
	/** @return iterable */
	public static function provideTitles(): iterable {
		$mainspaceTitle1 = Title::makeTitle( NS_MAIN, 'Title' );
		$mainspaceTitle1->resetArticleID( 10 );
		yield 'Array of 1 Title with NS:0' => [ [ $mainspaceTitle1 ], 1 ];

		$mainspaceTitle2 = Title::makeTitle( NS_MAIN, 'Title1' );
		$mainspaceTitle2->resetArticleID( 20 );
		$mainspaceTitle3 = Title::makeTitle( NS_MAIN, 'Title2' );
		$mainspaceTitle3->resetArticleID( 30 );
		yield 'Array of 2 Titles with NS:0' => [
			[ $mainspaceTitle2, $mainspaceTitle3 ],
			2
		];

		$talkTitle = Title::makeTitle( NS_TALK, 'Test' );
		$talkTitle->resetArticleID( 40 );
		yield 'Array of another Title with NS:1' => [ [ $talkTitle ], 1 ];
	}

	/**
	 * NOTE: This is made an integration test because SearchSuggestion::fromText()
	 *   calls Title::isValid() when following the execution and that tries to
	 *   access MediaWiki services to get a Title Parser object which is not possible
	 *   in a unit test as services are not available. That's why this ends up being
	 *   an integration test instead.
	 *
	 * @covers \SearchSuggestionSet::fromTitles
	 * @dataProvider provideTitles
	 */
	public function testFromTitles( array $titles, $expected ): void {
		$actual = SearchSuggestionSet::fromTitles( $titles );

		$this->assertSame( $expected, $actual->getSize() );
		$this->assertInstanceOf( SearchSuggestionSet::class, $actual );
		$this->assertCount( $expected, $actual->getSuggestions() );
	}
}
PK       ! ʿ5~    0  editpage/Constraint/ChangeTagsConstraintTest.phpnu Iw        <?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();
	}

	public function testPass() {
		$tagName = 'tag-for-constraint-test-pass';
		$this->getServiceContainer()->getChangeTagsStore()->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';
		$this->getServiceContainer()->getChangeTagsStore()->defineTag( $tagName );

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

}
PK       ! kq$    A  editpage/Constraint/EditFilterMergedContentHookConstraintTest.phpnu Iw        <?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\Content\Content;
use MediaWiki\Context\RequestContext;
use MediaWiki\EditPage\Constraint\EditFilterMergedContentHookConstraint;
use MediaWiki\EditPage\Constraint\IEditConstraint;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Language\Language;
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
		);
	}

}
PK       ! x)y.  .     RenameUser/RenameuserSQLTest.phpnu Iw        <?php

use MediaWiki\Block\DatabaseBlock;
use MediaWiki\RenameUser\RenameuserSQL;

/**
 * @group Database
 * @covers \MediaWiki\RenameUser\RenameuserSQL
 */
class RenameuserSQLTest extends MediaWikiIntegrationTestCase {
	public function testRename() {
		$oldUser = $this->getMutableTestUser()->getUser();
		$admin = $this->getTestSysop()->getUser();
		$oldName = $oldUser->getName();
		$newName = 'RenameuserSQL.new';
		$userId = $oldUser->getId();
		$adminActor = $admin->getActorId();

		$this->editPage( __CLASS__, 'test' );

		$blockStatus = $this->getServiceContainer()->getBlockUserFactory()
			->newBlockUser(
				$oldUser,
				$admin,
				'infinity',
				'reason'
			)
			->placeBlock();
		$this->assertStatusGood( $blockStatus );
		/** @var DatabaseBlock $block */
		$block = $blockStatus->getValue();
		$blockId = $block->getId();

		$renamer = new RenameuserSQL( $oldName, $newName, $userId, $admin );
		$this->assertTrue( $renamer->rename() );

		$this->newSelectQueryBuilder()
			->select( 'user_name' )
			->from( 'user' )
			->where( [ 'user_id' => $userId ] )
			->assertFieldValue( $newName );

		$this->newSelectQueryBuilder()
			->select( 'actor_name' )
			->from( 'actor' )
			->where( [ 'actor_user' => $userId ] )
			->assertFieldValue( $newName );

		$this->newSelectQueryBuilder()
			->select( 'log_title' )
			->from( 'logging' )
			->where( [
				'log_type' => 'block',
				'log_actor' => $adminActor
			] )
			->assertFieldValue( $newName );

		$this->newSelectQueryBuilder()
			->select( 'rc_title' )
			->from( 'recentchanges' )
			->where( [
				'rc_actor' => $adminActor,
				'rc_log_type' => 'block'
			] )
			->assertFieldValue( $newName );

		$block = $this->getServiceContainer()->getDatabaseBlockStore()
			->newFromTarget( "#$blockId" );
		$this->assertSame( $newName, $block->getTargetName() );
	}
}
PK       ! J&  &  )  Rest/Handler/LanguageLinksHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use MediaWiki\Config\ServiceOptions;
use MediaWiki\Interwiki\ClassicInterwikiLookup;
use MediaWiki\Languages\LanguageNameUtils;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Rest\Handler\LanguageLinksHandler;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\RequestData;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;
use Wikimedia\Message\MessageValue;

/**
 * @covers \MediaWiki\Rest\Handler\LanguageLinksHandler
 *
 * @group Database
 */
class LanguageLinksHandlerTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;
	use HandlerTestTrait;

	public function addDBData() {
		$defaults = [
			'iw_local' => 0,
			'iw_api' => '/w/api.php',
			'iw_url' => ''
		];

		$base = 'https://wiki.test/';

		$this->overrideConfigValue(
			MainConfigNames::InterwikiCache,
			ClassicInterwikiLookup::buildCdbHash( [
				[ 'iw_prefix' => 'de', 'iw_url' => $base . '/de', 'iw_wikiid' => 'dewiki' ] + $defaults,
				[ 'iw_prefix' => 'en', 'iw_url' => $base . '/en', 'iw_wikiid' => 'enwiki' ] + $defaults,
				[ 'iw_prefix' => 'fr', 'iw_url' => $base . '/fr', 'iw_wikiid' => 'frwiki' ] + $defaults
			] )
		);

		$this->editPage( __CLASS__ . '_Foo', 'Foo [[fr:Fou baux]] [[de:Füh bär]]' );
	}

	private function newHandler() {
		$languageNameUtils = new LanguageNameUtils(
			new ServiceOptions(
				LanguageNameUtils::CONSTRUCTOR_OPTIONS,
				[
					MainConfigNames::ExtraLanguageNames => [],
					MainConfigNames::UsePigLatinVariant => false,
					MainConfigNames::UseXssLanguage => false,
				]
			),
			$this->getServiceContainer()->getHookContainer()
		);

		$titleCodec = $this->getDummyMediaWikiTitleCodec();

		return new LanguageLinksHandler(
			$this->getServiceContainer()->getConnectionProvider(),
			$languageNameUtils,
			$titleCodec,
			$titleCodec,
			$this->getServiceContainer()->getPageStore(),
			$this->getServiceContainer()->getPageRestHelperFactory()
		);
	}

	private function assertLink( $expected, $actual ) {
		foreach ( $expected as $key => $value ) {
			$this->assertArrayHasKey( $key, $actual );
			$this->assertSame( $value, $actual[$key], $key );
		}
	}

	public function testExecute() {
		$title = __CLASS__ . '_Foo';
		$request = new RequestData( [ 'pathParams' => [ 'title' => $title ] ] );

		$handler = $this->newHandler();
		$data = $this->executeHandlerAndGetBodyData( $handler, $request );

		$this->assertCount( 2, $data );

		$links = [];
		foreach ( $data as $row ) {
			$links[$row['code']] = $row;
		}

		$this->assertArrayHasKey( 'de', $links );
		$this->assertArrayHasKey( 'fr', $links );

		$this->assertLink( [
			'code' => 'de',
			'name' => 'Deutsch',
			'title' => 'Füh bär',
			'key' => 'Füh_bär',
		], $links['de'] );

		$this->assertLink( [
			'code' => 'fr',
			'name' => 'français',
			'title' => 'Fou baux',
			'key' => 'Fou_baux',
		], $links['fr'] );
	}

	public function testCacheControl() {
		$title = Title::newFromText( __METHOD__ );
		$this->editPage( $title, 'First' );

		$request = new RequestData( [ 'pathParams' => [ 'title' => $title->getPrefixedDBkey() ] ] );

		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request );

		$firstETag = $response->getHeaderLine( 'ETag' );
		$this->assertSame(
			wfTimestamp( TS_RFC2822, $title->getTouched() ),
			$response->getHeaderLine( 'Last-Modified' )
		);

		$this->editPage( $title, 'Second' );

		Title::clearCaches();
		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request );

		$this->assertNotEquals( $response->getHeaderLine( 'ETag' ), $firstETag );
		$this->assertSame(
			wfTimestamp( TS_RFC2822, $title->getTouched() ),
			$response->getHeaderLine( 'Last-Modified' )
		);
	}

	public function testExecute_notFound() {
		$title = __CLASS__ . '_Xyzzy';
		$request = new RequestData( [ 'pathParams' => [ 'title' => $title ] ] );

		$handler = $this->newHandler();

		$this->expectExceptionObject(
			new LocalizedHttpException( new MessageValue( 'rest-nonexistent-title' ), 404 )
		);
		$this->executeHandler( $handler, $request );
	}

	public function testExecute_forbidden() {
		// The mock PermissionHandler forbids access to pages that have "Forbidden" in the name
		$title = __CLASS__ . '_Forbidden';
		$this->editPage( $title, 'Forbidden text' );
		$request = new RequestData( [ 'pathParams' => [ 'title' => $title ] ] );

		$handler = $this->newHandler();

		$this->expectExceptionObject(
			new LocalizedHttpException( new MessageValue( 'rest-permission-denied-title' ), 403 )
		);
		$this->executeHandler( $handler, $request, [ 'userCan' => false ], [], [], [],
			$this->mockAnonAuthority( static function ( string $permission, ?PageIdentity $target ) {
				return $target && !preg_match( '/Forbidden/', $target->getDBkey() );
			} ) );
	}

}
PK       ! ^    %  Rest/Handler/DiscoveryHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use JsonSchemaAssertionTrait;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\Rest\BasicAccess\StaticBasicAuthorizer;
use MediaWiki\Rest\Handler\DiscoveryHandler;
use MediaWiki\Rest\Reporter\MWErrorReporter;
use MediaWiki\Rest\RequestData;
use MediaWiki\Rest\RequestInterface;
use MediaWiki\Rest\ResponseFactory;
use MediaWiki\Rest\Router;
use MediaWiki\Rest\Validator\Validator;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Constraint\Constraint;
use Wikimedia\Message\ITextFormatter;
use Wikimedia\Message\MessageSpecifier;

/**
 * @covers \MediaWiki\Rest\Handler\DiscoveryHandler
 *
 * @group Database
 */
class DiscoveryHandlerTest extends MediaWikiIntegrationTestCase {
	use HandlerTestTrait;
	use JsonSchemaAssertionTrait;

	private function createRouter(
		RequestInterface $request,
		$specFile
	): Router {
		$services = $this->getServiceContainer();

		$conf = $services->getMainConfig();

		$authority = $this->mockRegisteredUltimateAuthority();
		$authorizer = new StaticBasicAuthorizer();

		$objectFactory = $services->getObjectFactory();
		$restValidator = new Validator( $objectFactory,
			$request,
			$authority
		);

		$formatter = new class implements ITextFormatter {
			public function getLangCode() {
				return 'qqx';
			}

			public function format( MessageSpecifier $message ): string {
				return $message->dump();
			}
		};
		$responseFactory = new ResponseFactory( [ $formatter ] );

		return ( new Router(
			[ $specFile ],
			[],
			new ServiceOptions( Router::CONSTRUCTOR_OPTIONS, $conf ),
			$services->getLocalServerObjectCache(),
			$responseFactory,
			$authorizer,
			$authority,
			$objectFactory,
			$restValidator,
			new MWErrorReporter(),
			$services->getHookContainer(),
			$this->getSession( true )
		) );
	}

	private function newHandler() {
		$config = $this->getServiceContainer()->getMainConfig();
		return new DiscoveryHandler(
			$config
		);
	}

	private function assertWellFormedDiscoveryDoc( array $discovery ) {
		$schemaFile = MW_INSTALL_PATH . '/docs/rest/discovery-1.0.json';

		$this->assertMatchesJsonSchema( $schemaFile, $discovery, [
			'https://www.mediawiki.org/schema/mwapi-1.0' => MW_INSTALL_PATH . '/docs/rest/mwapi-1.0.json',
			'https://spec.openapis.org/oas/3.0/schema/2021-09-28' => __DIR__ . '/data/OpenApi-3.0.json',
		] );
	}

	private static function assertContainsRecursive(
		array $expected,
		array $actual,
		string $message = ''
	) {
		foreach ( $expected as $key => $value ) {
			Assert::assertArrayHasKey( $key, $actual, $message );

			if ( is_array( $value ) ) {
				Assert::assertIsArray( $actual[$key], $message );

				self::assertContainsRecursive( $value, $actual[$key], $message );
			} elseif ( $value instanceof Constraint ) {
				$value->evaluate( $actual[$key], $message );
			} else {
				Assert::assertSame( $value, $actual[$key], $message );
			}
		}
	}

	public function testGetInfoSpecSuccess() {
		$this->overrideConfigValues( [
			MainConfigNames::Sitename => 'Test Site',
			MainConfigNames::RightsText => 'Test License',
			MainConfigNames::RightsUrl => 'https://example.com/license',
			MainConfigNames::EmergencyContact => 'test@example.com',
			MainConfigNames::CanonicalServer => 'https://example.com:1234',
			MainConfigNames::RestPath => '/api',
		] );

		$request = new RequestData( [] );
		$router = $this->createRouter( $request, __DIR__ . '/SpecTestRoutes.json' );

		$handler = $this->newHandler();
		$response = $this->executeHandler(
			$handler,
			$request,
			[],
			[],
			[],
			[],
			null,
			null,
			$router
		);
		$this->assertSame( 200, $response->getStatusCode() );
		$this->assertArrayHasKey( 'Content-Type', $response->getHeaders() );
		$this->assertSame( 'application/json', $response->getHeaderLine( 'Content-Type' ) );
		$data = json_decode( (string)$response->getBody(), true );

		$this->assertIsArray( $data, 'Body must be a JSON array' );
		$this->assertWellFormedDiscoveryDoc( $data );

		$expected = [
			'info' => [
				'title' => 'Test Site',
				'contact' => [
					'email' => 'test@example.com',
				],
			],
			'servers' => [
				[ 'url' => 'https://example.com:1234/api', ],
			],
			'modules' => [
				'mock/v1' => [
					'info' => [
						'version' => '1.0',
						'title' => 'test module',
					],
					'base' => 'https://example.com:1234/api/mock/v1',
					'spec' => 'https://example.com:1234/api/specs/v0/module/mock%2Fv1',
				],
			],
		];

		self::assertContainsRecursive( $expected, $data );
	}

}
PK       ! wFE  E  $  Rest/Handler/PageHTMLHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use Exception;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Hook\ParserLogLinterDataHook;
use MediaWiki\MainConfigNames;
use MediaWiki\Rest\Handler\PageHTMLHandler;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\RequestData;
use MediaWiki\Title\Title;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Http\Message\StreamInterface;
use Wikimedia\Message\MessageValue;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\Parsoid\Core\ClientError;
use Wikimedia\Parsoid\Core\ResourceLimitExceededException;
use Wikimedia\Parsoid\Parsoid;
use Wikimedia\Parsoid\Utils\ContentUtils;
use Wikimedia\Parsoid\Utils\DOMUtils;
use WikiPage;

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

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

	private const HTML = '>World</';

	private HashBagOStuff $parserCacheBagOStuff;

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

		$this->parserCacheBagOStuff = new HashBagOStuff();
		// Protect the ObjectCacheFactory from the service container reset,
		// so the emulated parser cache persists between calls to executeHandler().
		$objectCacheFactory = $this->getServiceContainer()->getObjectCacheFactory();
		$this->setService( 'ObjectCacheFactory', $objectCacheFactory );
	}

	/**
	 * @param Parsoid|MockObject|null $parsoid
	 *
	 * @return PageHTMLHandler
	 */
	private function newHandler( ?Parsoid $parsoid = null ): PageHTMLHandler {
		if ( $parsoid ) {
			$this->resetServicesWithMockedParsoid( $parsoid );
		} else {
			// ParserOutputAccess has a localCache which can return stale content.
			// Resetting ensures that ParsoidCachePrewarmJob gets a fresh copy
			// of ParserOutputAccess without these problems!
			$this->resetServices();
		}

		return $this->newPageHtmlHandler();
	}

	public function testExecuteWithHtml() {
		$page = $this->getExistingTestPage( 'HtmlEndpointTestPage/with/slashes' );
		$this->assertStatusGood( $this->editPage( $page, self::WIKITEXT ),
			'Edited a page'
		);

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

		$handler = $this->newHandler();
		$data = $this->executeHandlerAndGetBodyData( $handler, $request, [
			'format' => 'with_html'
		] );

		$this->assertResponseData( $page, $data );
		$this->assertStringContainsString( '<!DOCTYPE html>', $data['html'] );
		$this->assertStringContainsString( '<html', $data['html'] );
		$this->assertStringContainsString( self::HTML, $data['html'] );
	}

	public function testExecuteWillLint() {
		$this->overrideConfigValue( MainConfigNames::ParsoidSettings, [
			'linting' => true
		] );

		$mockHandler = $this->createMock( ParserLogLinterDataHook::class );
		$mockHandler->expects( $this->once() ) // this is the critical assertion in this test case!
		->method( 'onParserLogLinterData' );

		$this->setTemporaryHook(
			'ParserLogLinterData',
			$mockHandler
		);

		$page = $this->getExistingTestPage( 'HtmlEndpointTestPage/with/slashes' );

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

		$handler = $this->newHandler();
		$this->executeHandlerAndGetBodyData( $handler, $request, [
			'format' => 'with_html'
		] );
	}

	public function testExecuteWithHtmlForSystemMessagePage() {
		$title = Title::newFromText( 'MediaWiki:Logouttext' );
		$page = $this->getNonexistingTestPage( $title );

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

		$handler = $this->newHandler();
		$data = $this->executeHandlerAndGetBodyData( $handler, $request, [
			'format' => 'with_html'
		] );

		// Let's create and test on a full HTML document since system message pages
		// will not return a full HTML document by default.
		$data['html'] = ContentUtils::toXML( DOMUtils::parseHTML( $data['html'] ) );

		$this->assertSame( $title->getPrefixedDBkey(), $data['key'] );
		$this->assertSame( $title->getPrefixedText(), $data['title'] );
		$this->assertStringContainsString( '<!DOCTYPE html>', $data['html'] );
		$this->assertStringContainsString( '<html', $data['html'] );
		$this->assertStringContainsString( '<meta http-equiv', $data['html'] );
		$this->assertStringContainsString( 'content="en"', $data['html'] );

		$msg = wfMessage( 'logouttext' )->inLanguage( 'en' )->useDatabase( false );
		$this->assertStringContainsString( $msg->parse(), $data['html'] );
	}

	public function testExecuteHtmlOnly() {
		$page = $this->getExistingTestPage( 'HtmlEndpointTestPage/with/slashes' );
		$this->assertStatusGood( $this->editPage( $page, self::WIKITEXT ),
			'Edited a page'
		);

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

		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request, [
			'format' => 'html'
		] );

		$htmlResponse = (string)$response->getBody();
		$this->assertStringContainsString( '<!DOCTYPE html>', $htmlResponse );
		$this->assertStringContainsString( '<html', $htmlResponse );
		$this->assertStringContainsString( self::HTML, $htmlResponse );
	}

	public function testExecuteHtmlOnlyForSystemMessagePage() {
		$title = Title::newFromText( 'MediaWiki:Logouttext/de' );
		$page = $this->getNonexistingTestPage( $title );

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

		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request, [
			'format' => 'html'
		] );

		$htmlResponse = (string)$response->getBody();
		// Let's create and test on a full HTML document since system message pages
		// will not return a full HTML document by default.
		$htmlResponse = ContentUtils::toXML( DOMUtils::parseHTML( $htmlResponse ) );

		$this->assertStringContainsString( '<!DOCTYPE html>', $htmlResponse );
		$this->assertStringContainsString( '<html', $htmlResponse );
		$this->assertStringContainsString( '<meta http-equiv', $htmlResponse );
		$this->assertStringContainsString( 'content="de"', $htmlResponse );

		$msg = wfMessage( 'logouttext' )->inLanguage( 'de' )->useDatabase( false );
		$this->assertStringContainsString( $msg->parse(), $htmlResponse );
	}

	/**
	 * Assert that we return a 404 even if an associated remote file description
	 * page exists (T353688).
	 */
	public function testRemoteDescriptionWithNonexistentFilePage() {
		$name = 'JustSomeSillyFile.png';

		$this->installMockFileRepo( $name );

		$page = $this->getNonexistingTestPage( "File:$name" );

		$request = new RequestData(
			[ 'pathParams' => [ 'title' => $page->getTitle()->getPrefixedDBkey() ] ]
		);
		$handler = $this->newHandler();
		$exception = $this->executeHandlerAndGetHttpException( $handler, $request, [
			'format' => 'with_html'
		] );

		$this->assertSame( 404, $exception->getCode() );
	}

	/**
	 * Assert that we return the local page content even if an associated remote
	 * file description page exists (T353688).
	 */
	public function testRemoteDescriptionWithExistingFilePage() {
		$name = 'JustSomeSillyFile.png';

		$this->installMockFileRepo( $name );

		$pageName = "File:$name";
		$this->editPage( $pageName, 'Local content' );

		$request = new RequestData(
			[ 'pathParams' => [ 'title' => $pageName ] ]
		);
		$handler = $this->newHandler();
		$data = $this->executeHandlerAndGetBodyData( $handler, $request, [
			'format' => 'with_html'
		] );

		$this->assertSame( $pageName, $data['key'] );
		$this->assertSame( $pageName, $data['title'] );

		$this->assertStringContainsString( '<html', $data['html'] );
		$this->assertStringContainsString( 'Local content', $data['html'] );
	}

	/**
	 * @dataProvider provideExecuteWithVariant
	 */
	public function testExecuteWithVariant(
		string $format,
		callable $bodyHtmlHandler,
		string $expectedContentLanguage,
		string $expectedVaryHeader
	) {
		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, true );
		$page = $this->getExistingTestPage( 'HtmlVariantConversion' );
		$this->assertStatusGood( $this->editPage( $page, '<p>test language conversion</p>' ),
			'Edited a page'
		);

		$acceptLanguage = 'en-x-piglatin';
		$request = new RequestData(
			[
				'pathParams' => [ 'title' => $page->getTitle()->getPrefixedText() ],
				'headers' => [
					'Accept-Language' => $acceptLanguage
				]
			]
		);

		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request, [
			'format' => $format
		] );

		$htmlBody = $bodyHtmlHandler( $response->getBody() );
		$contentLanguageHeader = $response->getHeaderLine( 'Content-Language' );
		$varyHeader = $response->getHeaderLine( 'Vary' );

		$this->assertStringContainsString( '>esttay anguagelay onversioncay<', $htmlBody );
		$this->assertEquals( $expectedContentLanguage, $contentLanguageHeader );
		$this->assertStringContainsStringIgnoringCase( $expectedVaryHeader, $varyHeader );
		$this->assertStringContainsString( $acceptLanguage, $response->getHeaderLine( 'ETag' ) );
	}

	public static function provideExecuteWithVariant() {
		yield 'with_html request should contain accept language but not content language' => [
			'with_html',
			static function ( StreamInterface $response ) {
				return json_decode( $response->getContents(), true )['html'];
			},
			'',
			'accept-language'
		];

		yield 'html request should contain accept and content language' => [
			'html',
			static function ( StreamInterface $response ) {
				return $response->getContents();
			},
			'en-x-piglatin',
			'accept-language'
		];
	}

	public function testEtagLastModified() {
		$time = time();
		MWTimestamp::setFakeTime( $time );

		$page = $this->getExistingTestPage( 'HtmlEndpointTestPage/with/slashes' );
		$request = new RequestData(
			[ 'pathParams' => [ 'title' => $page->getTitle()->getPrefixedText() ] ]
		);

		// First, test it works if nothing was cached yet.
		// Make some time pass since page was created:
		$time += 10;
		MWTimestamp::setFakeTime( $time );
		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request, [
			'format' => 'html'
		] );
		$this->assertArrayHasKey( 'ETag', $response->getHeaders() );
		$etag = $response->getHeaderLine( 'ETag' );
		$this->assertStringMatchesFormat( '"' . $page->getLatest() . '/%x-%x-%x-%x-%x/%s"', $etag );
		$this->assertArrayHasKey( 'Last-Modified', $response->getHeaders() );
		$this->assertSame( MWTimestamp::convert( TS_RFC2822, $time ),
			$response->getHeaderLine( 'Last-Modified' ) );

		// Now, test that headers work when getting from cache too.
		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request, [
			'format' => 'html'
		] );
		$this->assertArrayHasKey( 'ETag', $response->getHeaders() );
		$this->assertSame( $etag, $response->getHeaderLine( 'ETag' ) );
		$etag = $response->getHeaderLine( 'ETag' );
		$this->assertStringMatchesFormat( '"' . $page->getLatest() . '/%x-%x-%x-%x-%x/%s"', $etag );
		$this->assertArrayHasKey( 'Last-Modified', $response->getHeaders() );
		$this->assertSame( MWTimestamp::convert( TS_RFC2822, $time ),
			$response->getHeaderLine( 'Last-Modified' ) );

		// Now, expire the cache
		$time += 1000;
		MWTimestamp::setFakeTime( $time );
		$this->assertTrue(
			$page->getTitle()->invalidateCache( MWTimestamp::convert( TS_MW, $time ) ),
			'Can invalidate cache'
		);
		DeferredUpdates::doUpdates();

		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request, [
			'format' => 'html'
		] );
		$this->assertArrayHasKey( 'ETag', $response->getHeaders() );
		$this->assertNotSame( $etag, $response->getHeaderLine( 'ETag' ) );
		$etag = $response->getHeaderLine( 'ETag' );
		$this->assertStringMatchesFormat( '"' . $page->getLatest() . '/%x-%x-%x-%x-%x/%s"', $etag );
		$this->assertArrayHasKey( 'Last-Modified', $response->getHeaders() );
		$this->assertSame( MWTimestamp::convert( TS_RFC2822, $time ),
			$response->getHeaderLine( 'Last-Modified' ) );
	}

	public static function provideHandlesParsoidError() {
		yield 'ClientError' => [
			new ClientError( 'TEST_TEST' ),
			new LocalizedHttpException(
				new MessageValue( 'rest-html-backend-error' ),
				400,
				[
					'reason' => 'TEST_TEST'
				]
			)
		];
		yield 'ResourceLimitExceededException' => [
			new ResourceLimitExceededException( 'TEST_TEST' ),
			new LocalizedHttpException(
				new MessageValue( 'rest-resource-limit-exceeded' ),
				413,
				[
					'reason' => 'TEST_TEST'
				]
			)
		];
	}

	/**
	 * @dataProvider provideHandlesParsoidError
	 */
	public function testHandlesParsoidError(
		Exception $parsoidException,
		Exception $expectedException
	) {
		$page = $this->getExistingTestPage( 'HtmlEndpointTestPage/with/slashes' );
		$request = new RequestData(
			[ 'pathParams' => [ 'title' => $page->getTitle()->getPrefixedText() ] ]
		);

		$parsoid = $this->createNoOpMock( Parsoid::class, [ 'wikitext2html' ] );
		$parsoid->expects( $this->once() )
			->method( 'wikitext2html' )
			->willThrowException( $parsoidException );

		$handler = $this->newHandler( $parsoid );
		$this->expectExceptionObject( $expectedException );
		$this->executeHandler( $handler, $request, [
			'format' => 'html'
		] );
	}

	public function testExecute_missingparam() {
		$request = new RequestData();

		$this->expectExceptionObject(
			new LocalizedHttpException(
				new MessageValue( "paramvalidator-missingparam", [ 'title' ] ),
				400
			)
		);

		$handler = $this->newHandler();
		$this->executeHandler( $handler, $request );
	}

	public function testExecute_error() {
		$request = new RequestData( [ 'pathParams' => [ 'title' => 'DoesNotExist8237456assda1234' ] ] );

		$this->expectExceptionObject(
			new LocalizedHttpException(
				new MessageValue( "rest-nonexistent-title", [ 'testing' ] ),
				404
			)
		);

		$handler = $this->newHandler();
		$this->executeHandler( $handler, $request, [ 'format' => 'html' ] );
	}

	/**
	 * @param WikiPage $page
	 * @param array $data
	 */
	private function assertResponseData( WikiPage $page, array $data ): void {
		$this->assertSame( $page->getId(), $data['id'] );
		$this->assertSame( $page->getTitle()->getPrefixedDBkey(), $data['key'] );
		$this->assertSame( $page->getTitle()->getPrefixedText(), $data['title'] );
		$this->assertSame( $page->getLatest(), $data['latest']['id'] );
		$this->assertSame(
			wfTimestampOrNull( TS_ISO_8601, $page->getTimestamp() ),
			$data['latest']['timestamp']
		);
		$this->assertSame( CONTENT_MODEL_WIKITEXT, $data['content_model'] );
		$this->assertSame( 'https://example.com/rights', $data['license']['url'] );
		$this->assertSame( 'some rights', $data['license']['title'] );
	}

	/**
	 * Request One:
	 *
	 * When a request is made with no stash entries in the stash and stashing
	 * is set to false, don't stash anything. At this point, the stash is empty.
	 *
	 * Request Two:
	 *
	 * Once a request is made with stashing option set to true, we should have
	 * one entry in parsoid stash. So at this point, the stash is no longer empty
	 * as before.
	 *
	 * Request Three:
	 *
	 * Upon the third request, there is already a stash entry and if the 3rd request's
	 * stashing option is set to false, we're not invalidating the stash entries that
	 * exiting with the UUID. So, if we request a parsoid stashed object from the stash
	 * with a given UUID that exist, we should have a hit.
	 */
	public function testExecuteStashParsoidOutput() {
		$page = $this->getExistingTestPage();
		$outputStash = $this->getParsoidOutputStash();

		[ /* $html1 */, $etag1, $stashKey1 ] = $this->executePageHTMLRequest( $page );
		$this->assertNull( $outputStash->get( $stashKey1 ) );

		[ /* $html2 */, $etag2, $stashKey2 ] = $this->executePageHTMLRequest( $page, [ 'stash' => true ] );
		$this->assertNotNull( $outputStash->get( $stashKey2 ) );

		[ /* $html3 */, $etag3, $stashKey3 ] = $this->executePageHTMLRequest( $page );
		/**
		 * The stash for the previous request should still live at this point.
		 */
		$this->assertNotNull( $outputStash->get( $stashKey2 ) );
		$this->assertNotNull( $outputStash->get( $stashKey3 ) );
		$this->assertSame( $etag1, $etag3 );
		$this->assertNotSame( $etag1, $etag2 );

		// Make sure the output for stashed and unstashed doesn't have the same tag,
		// since it will actually be different!
		// FIXME: implement flavors and write test cases for them.
	}

	public function testETagVariesOnFormat() {
		$page = $this->getExistingTestPage();

		[ /* $html1 */, $etag1 ] =
			$this->executePageHTMLRequest( $page, [], [ 'format' => 'html' ] );

		[ /* $html2 */, $etag2 ] =
			$this->executePageHTMLRequest( $page, [], [ 'format' => 'with_html' ] );

		$this->assertNotSame( $etag1, $etag2 );
	}

	public function testStashingWithRateLimitExceeded() {
		// Set the rate limit to 1 request per minute
		$this->overrideConfigValue(
			MainConfigNames::RateLimits, [
				'stashbasehtml' => [
					'&can-bypass' => false,
					'ip' => [ 1, 60 ],
					'newbie' => [ 1, 60 ]
				]
			]
		);

		$page = $this->getExistingTestPage();
		$authority = $this->getAuthority();

		$this->executePageHTMLRequest( $page, [ 'stash' => true ], [], $authority );
		// In this request, the rate limit has been exceeded, so it should throw.
		$this->expectException( LocalizedHttpException::class );
		$this->expectExceptionCode( 429 );
		$this->executePageHTMLRequest( $page, [ 'stash' => true ], [], $authority );
	}

}
PK       ! ?c  #  Rest/Handler/ParsoidHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use Composer\Semver\Semver;
use Exception;
use Generator;
use MediaWiki\Content\JavaScriptContent;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Language\Language;
use MediaWiki\Language\LanguageCode;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Parser\ParserCache;
use MediaWiki\Parser\ParserCacheFactory;
use MediaWiki\Parser\Parsoid\Config\PageConfigFactory;
use MediaWiki\Parser\Parsoid\HtmlToContentTransform;
use MediaWiki\Parser\Parsoid\HtmlTransformFactory;
use MediaWiki\Parser\RevisionOutputCache;
use MediaWiki\Permissions\UltimateAuthority;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\Rest\Handler\Helper\HtmlInputTransformHelper;
use MediaWiki\Rest\Handler\Helper\ParsoidFormatHelper;
use MediaWiki\Rest\Handler\ParsoidHandler;
use MediaWiki\Rest\HttpException;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\RequestData;
use MediaWiki\Rest\RequestInterface;
use MediaWiki\Rest\Response;
use MediaWiki\Rest\ResponseFactory;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Tests\Rest\RestTestTrait;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\TitleValue;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\Message\MessageValue;
use Wikimedia\Parsoid\Config\DataAccess;
use Wikimedia\Parsoid\Config\PageConfig;
use Wikimedia\Parsoid\Config\SiteConfig;
use Wikimedia\Parsoid\Core\ClientError;
use Wikimedia\Parsoid\Core\ResourceLimitExceededException;
use Wikimedia\Parsoid\DOM\Document;
use Wikimedia\Parsoid\Parsoid;
use Wikimedia\Stats\StatsFactory;

/**
 * @group Database
 * @covers \MediaWiki\Rest\Handler\ParsoidHandler
 * @covers \MediaWiki\Parser\Parsoid\HtmlToContentTransform
 */
class ParsoidHandlerTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;
	use RestTestTrait;

	/**
	 * Default request attributes, see ParsoidHandler::getRequestAttributes()
	 */
	private const DEFAULT_ATTRIBS = [
		'pageName' => '',
		'oldid' => null,
		'body_only' => null,
		'errorEnc' => 'plain',
		'iwp' => 'exwiki',
		'subst' => null,
		'offsetType' => 'byte',
		'opts' => [],
		'envOptions' => [
			'prefix' => 'exwiki',
			'domain' => 'wiki.example.com',
			'pageName' => '',
			'cookie' => '',
			'reqId' => 'test+test+test',
			'userAgent' => 'UTAgent',
			'htmlVariantLanguage' => null,
			'outputContentVersion' => Parsoid::AVAILABLE_VERSIONS[0],
		],
	];

	/** @var string Imperfect wikitext to be preserved if selser is applied. Corresponds to Selser.html. */
	private const IMPERFECT_WIKITEXT = "<div  >Turaco</DIV>";

	/** @var string Normalized version of IMPERFECT_WIKITEXT, expected when no selser is applied. */
	private const NORMALIZED_WIKITEXT = "<div>Turaco</div>";

	public function setUp(): void {
		// enable Pig Latin variant conversion
		$this->overrideConfigValues( [
			MainConfigNames::UsePigLatinVariant => true,
			MainConfigNames::ParsoidSettings => [
				'useSelser' => true,
				'linting' => true,
			]
		] );
	}

	private function createRouter( $authority, $request ) {
		return $this->newRouter( [
			'authority' => $authority,
			'request' => $request,
		] );
	}

	private function newParsoidHandler( $methodOverrides = [], $serviceOverrides = [] ): ParsoidHandler {
		$method = 'POST';

		$revisionLookup = $this->getServiceContainer()->getRevisionLookup();
		$dataAccess = $serviceOverrides['ParsoidDataAccess'] ?? $this->getServiceContainer()->getParsoidDataAccess();
		$siteConfig = $serviceOverrides['ParsoidSiteConfig'] ?? $this->getServiceContainer()->getParsoidSiteConfig();
		$pageConfigFactory = $serviceOverrides['ParsoidPageConfigFactory']
			?? $this->getServiceContainer()->getParsoidPageConfigFactory();

		$handler = new class (
			$this,
			$revisionLookup,
			$siteConfig,
			$pageConfigFactory,
			$dataAccess,
			$methodOverrides
		) extends ParsoidHandler {
			private $testCase;
			private $overrides;

			public function __construct(
				$testCase,
				RevisionLookup $revisionLookup,
				SiteConfig $siteConfig,
				PageConfigFactory $pageConfigFactory,
				DataAccess $dataAccess,
				array $overrides
			) {
				parent::__construct(
					$revisionLookup,
					$siteConfig,
					$pageConfigFactory,
					$dataAccess
				);

				$this->testCase = $testCase;
				$this->overrides = $overrides;
			}

			protected function parseHTML( string $html, bool $validateXMLNames = false ): Document {
				if ( isset( $this->overrides['parseHTML'] ) ) {
					return $this->overrides['parseHTML']( $html, $validateXMLNames );
				}

				return parent::parseHTML(
					$html,
					$validateXMLNames
				);
			}

			protected function newParsoid(): Parsoid {
				if ( isset( $this->overrides['newParsoid'] ) ) {
					return $this->overrides['newParsoid']();
				}

				return parent::newParsoid();
			}

			public function getRequest(): RequestInterface {
				if ( isset( $this->overrides['getRequest'] ) ) {
					return $this->overrides['getRequest']();
				}

				return parent::getRequest();
			}

			protected function getHtmlInputTransformHelper(
				array $attribs,
				string $html,
				PageIdentity $page
			): HtmlInputTransformHelper {
				if ( isset( $this->overrides['getHtmlInputHelper'] ) ) {
					return $this->overrides['getHtmlInputHelper']();
				}

				return parent::getHtmlInputTransformHelper(
					$attribs,
					$html,
					$page
				);
			}

			public function execute(): Response {
				ParsoidHandlerTest::fail( 'execute was not expected to be called' );
			}

			public function &getRequestAttributes(): array {
				if ( isset( $this->overrides['getRequestAttributes'] ) ) {
					return $this->overrides['getRequestAttributes']();
				}

				return parent::getRequestAttributes();
			}

			public function acceptable( array &$attribs ): bool {
				if ( isset( $this->overrides['acceptable'] ) ) {
					return $this->overrides['acceptable']( $attribs );
				}

				return parent::acceptable( $attribs );
			}

			public function tryToCreatePageConfig(
				array $attribs, ?string $wikitext = null, bool $html2WtMode = false
			): PageConfig {
				if ( isset( $this->overrides['tryToCreatePageConfig'] ) ) {
					return $this->overrides['tryToCreatePageConfig'](
						$attribs, $wikitext, $html2WtMode
					);
				}
				$attribs += [
					'pagelanguage' => $this->testCase->createLanguageMock( 'en' ),
				];

				return parent::tryToCreatePageConfig(
					$attribs, $wikitext, $html2WtMode
				);
			}

			public function wt2html(
				PageConfig $pageConfigConfig,
				array $attribs,
				?string $wikitext = null
			) {
				return parent::wt2html(
					$pageConfigConfig,
					$attribs,
					$wikitext
				);
			}

			public function html2wt( $page, array $attribs, string $html ) {
				return parent::html2wt(
					$page,
					$attribs,
					$html
				);
			}

			public function pb2pb( array $attribs ) {
				return parent::pb2pb( $attribs );
			}

			public function updateRedLinks(
				PageConfig $pageConfig,
				array $attribs,
				array $revision
			) {
				return parent::updateRedLinks(
					$pageConfig,
					$attribs,
					$revision
				);
			}

			public function languageConversion(
				PageConfig $pageConfig,
				array $attribs,
				array $revision
			) {
				return parent::languageConversion(
					$pageConfig,
					$attribs,
					$revision
				);
			}
		};

		$authority = new UltimateAuthority( new UserIdentityValue( 0, '127.0.0.1' ) );
		$request = new RequestData( [ 'method' => $method ] );
		$router = $this->createRouter( $authority, $request );
		$config = [];

		$formatter = $this->getDummyTextFormatter( true );

		/** @var ResponseFactory|MockObject $responseFactory */
		$responseFactory = new ResponseFactory( [ 'qqx' => $formatter ] );

		if ( !$request->hasBody() && $method === 'POST' ) {
			// Send an empty body if none was provided.
			$request->setParsedBody( [] );
		}

		$handler->initContext( $this->newModule( [ 'router' => $router ] ), 'test', $config );
		$handler->initServices( $authority, $responseFactory, $this->createHookContainer() );
		$handler->initSession( $this->getSession( true ) );
		$handler->initForExecute( $request );

		return $handler;
	}

	/**
	 * @param PageIdentity $page
	 * @param int|string|RevisionRecord|null $revIdOrText
	 *
	 * @return PageConfig
	 */
	private function getPageConfig( PageIdentity $page, $revIdOrText = null ): PageConfig {
		$rev = null;
		if ( is_string( $revIdOrText ) ) {
			$rev = new MutableRevisionRecord( $page );
			$rev->setContent( SlotRecord::MAIN, new WikitextContent( $revIdOrText ) );
		} else {
			// may be null or an int or a RevisionRecord
			$rev = $revIdOrText;
		}

		return $this->getServiceContainer()->getParsoidPageConfigFactory()->create( $page, null, $rev );
	}

	private function getPageConfigFactory( PageIdentity $page ): PageConfigFactory {
		/** @var PageConfigFactory|MockObject $pageConfigFactory */
		$pageConfigFactory = $this->createNoOpMock( PageConfigFactory::class, [ 'create' ] );
		$pageConfigFactory->method( 'create' )->willReturn( $this->getPageConfig( $page ) );
		return $pageConfigFactory;
	}

	private function getTextFromFile( string $name ): string {
		return trim( file_get_contents( __DIR__ . "/data/Transform/$name" ) );
	}

	private function getJsonFromFile( string $name ): array {
		$text = $this->getTextFromFile( $name );
		return json_decode( $text, JSON_OBJECT_AS_ARRAY );
	}

	// Mostly lifted from the contentTypeMatcher in tests/api-testing/REST/Transform.js
	private function contentTypeMatcher( string $expected, string $actual ): bool {
		if ( $expected === 'application/json' ) {
			return $actual === $expected;
		}

		$pattern = '/^([-\w]+\/[-\w]+); charset=utf-8; profile="https:\/\/www.mediawiki.org\/wiki\/Specs\/([-\w]+)\/(\d+\.\d+\.\d+)"$/';

		preg_match( $pattern, $expected, $expectedParts );
		if ( !$expectedParts ) {
			return false;
		}
		[ , $expectedMime, $expectedSpec, $expectedVersion ] = $expectedParts;

		preg_match( $pattern, $actual, $actualParts );
		if ( !$actualParts ) {
			return false;
		}
		[ , $actualMime, $actualSpec, $actualVersion ] = $actualParts;

		// Match version using caret semantics
		if ( !Semver::satisfies( $actualVersion, "^{$expectedVersion}" ) ) {
			return false;
		}

		if ( $actualMime !== $expectedMime || $actualSpec !== $expectedSpec ) {
			return false;
		}

		return true;
	}

	public function provideHtml2wt() {
		$profileVersion = '2.6.0';
		$wikitextProfileUri = 'https://www.mediawiki.org/wiki/Specs/wikitext/1.0.0';
		$htmlProfileUri = 'https://www.mediawiki.org/wiki/Specs/HTML/' . $profileVersion;
		$dataParsoidProfileUri = 'https://www.mediawiki.org/wiki/Specs/data-parsoid/' . $profileVersion;

		$wikiTextContentType = "text/plain; charset=utf-8; profile=\"$wikitextProfileUri\"";
		$htmlContentType = "text/html;profile=\"$htmlProfileUri\"";
		$dataParsoidContentType = "application/json;profile=\"$dataParsoidProfileUri\"";

		$htmlHeaders = [
			'content-type' => $htmlContentType,
		];

		// NOTE: profile version 999 is a placeholder for a future feature, see T78676
		$htmlContentType999 = 'text/html;profile="https://www.mediawiki.org/wiki/Specs/HTML/999.0.0"';
		$htmlHeaders999 = [
			'content-type' => $htmlContentType999,
		];

		// should convert html to wikitext ///////////////////////////////////
		$html = $this->getTextFromFile( 'MainPage-data-parsoid.html' );
		$expectedText = [
			'MediaWiki has been successfully installed',
			'== Getting started ==',
		];

		$attribs = [];
		yield 'should convert html to wikitext' => [
			$attribs,
			$html,
			$expectedText,
		];

		// should load original wikitext by revision id ////////////////////
		$attribs = [
			'oldid' => 1, // will be replaced by the actual revid
		];
		yield 'should load original wikitext by revision id' => [
			$attribs,
			$html,
			$expectedText,
		];

		// should accept original wikitext in body ////////////////////
		$originalWikitext = $this->getTextFromFile( 'OriginalMainPage.wikitext' );
		$attribs = [
			'opts' => [
				'original' => [
					'wikitext' => [
						'headers' => [
							'content-type' => $wikiTextContentType,
						],
						'body' => $originalWikitext,
					]
				]
			],
		];
		yield 'should accept original wikitext in body' => [
			$attribs,
			$html,
			$expectedText, // TODO: ensure it's actually used!
		];

		// should use original html for selser (default) //////////////////////
		$originalDataParsoid = $this->getJsonFromFile( 'MainPage-original.data-parsoid' );
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'html' => [
						'headers' => $htmlHeaders,
						'body' => $this->getTextFromFile( 'MainPage-original.html' ),
					],
					'data-parsoid' => [
						'headers' => [
							'content-type' => $dataParsoidContentType,
						],
						'body' => $originalDataParsoid
					]
				]
			],
		];
		yield 'should use original html for selser (default)' => [
			$attribs,
			$html,
			$expectedText,
		];

		// should use original html for selser (1.1.1, meta) ///////////////////
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'html' => [
						'headers' => [
							// XXX: If this is required anyway, how do we know we are using the
							//      version given in the HTML?
							'content-type' => 'text/html; profile="mediawiki.org/specs/html/1.1.1"',
						],
						'body' => $this->getTextFromFile( 'MainPage-data-parsoid-1.1.1.html' ),
					],
					'data-parsoid' => [
						'headers' => [
							'content-type' => $dataParsoidContentType,
						],
						'body' => $originalDataParsoid
					]
				]
			],
		];
		yield 'should use original html for selser (1.1.1, meta)' => [
			$attribs,
			$html,
			$expectedText,
		];

		// should accept original html for selser (1.1.1, headers) ////////////
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'html' => [
						'headers' => [
							// Set the schema version to 1.1.1!
							'content-type' => 'text/html; profile="mediawiki.org/specs/html/1.1.1"',
						],
						// No schema version in HTML
						'body' => $this->getTextFromFile( 'MainPage-original.html' ),
					],
					'data-parsoid' => [
						'headers' => [
							'content-type' => $dataParsoidContentType,
						],
						'body' => $originalDataParsoid
					]
				]
			],
		];
		yield 'should use original html for selser (1.1.1, headers)' => [
			$attribs,
			$html,
			$expectedText,
		];

		// Return original wikitext when HTML doesn't change ////////////////////////////
		// New and old html are identical, which should produce no diffs
		// and reuse the original wikitext.
		$html = $this->getTextFromFile( 'Selser.html' );

		// Original wikitext (to be preserved by selser)
		$originalWikitext = self::IMPERFECT_WIKITEXT;

		// Normalized wikitext (when no selser is applied)
		$normalizedWikitext = self::NORMALIZED_WIKITEXT;

		$dataParsoid = [ // Per Selser.html
			'ids' => [
				'mwAA' => [ 'dsr' => [ 0, 19, 0, 0 ] ],
				'mwAg' => [ 'stx' => 'html', 'dsr' => [ 0, 19, 7, 6 ] ],
				'mwAQ' => []
			]
		];

		$attribs = [
			'oldid' => 1, // Will be replaced by the revision ID of the default test page
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'html' => [
						'headers' => $htmlHeaders,
						// original HTML is the same as the new HTML
						'body' => $html
					],
					'data-parsoid' => [
						'body' => $dataParsoid,
					]
				]
			],
		];

		yield 'selser should return original wikitext if the HTML didn\'t change (original HTML given)' => [
			$attribs,
			$html,
			[ $originalWikitext ], // Returns original wikitext, because HTML didn't change.
		];

		unset( $attribs['opts']['original'] );
		yield 'selser should return original wikitext if the HTML didn\'t change (original HTML from ParserCache)' => [
			$attribs,
			$html,
			[ $originalWikitext ], // Returns original wikitext, because HTML didn't change.
		];

		// Should fall back to non-selective serialization. //////////////////
		// Without the original wikitext, use non-selective serialization.
		$attribs = [
			// No wikitext, no revid/oldid
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'html' => [
						'headers' => $htmlHeaders,
						// original HTML is the same as the new HTML
						'body' => $html
					],
					'data-parsoid' => [
						'body' => $dataParsoid,
					]
				]
			],
		];
		yield 'Should fall back to non-selective serialization' => [
			$attribs,
			$html,
			[ $normalizedWikitext ],
		];

		// should apply data-parsoid to duplicated ids /////////////////////////
		$dataParsoid = [
			'ids' => [
				'mwAA' => [],
				'mwBB' => [ 'autoInsertedEnd' => true, 'stx' => 'html' ]
			]
		];
		$html = '<html><body id="mwAA"><div id="mwBB">data-parsoid test</div>' .
			'<div id="mwBB">data-parsoid test</div></body></html>';
		$originalHtml = '<html><body id="mwAA"><div id="mwBB">data-parsoid test</div></body></html>';

		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'html' => [
						'headers' => $htmlHeaders,
						'body' => $originalHtml
					],
					'data-parsoid' => [
						'body' => $dataParsoid,
					]
				]
			],
		];
		yield 'should apply data-parsoid to duplicated ids' => [
			$attribs,
			$html,
			[ '<div>data-parsoid test<div>data-parsoid test' ],
		];

		// should ignore data-parsoid if the input format is not pagebundle ////////////////////////
		$html = '<html><body id="mwAA"><div id="mwBB">data-parsoid test</div>' .
			'<div id="mwBB">data-parsoid test</div></body></html>';
		$originalHtml = '<html><body id="mwAA"><div id="mwBB">data-parsoid test</div></body></html>';

		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_HTML,
				'original' => [
					'html' => [
						'headers' => $htmlHeaders,
						'body' => $originalHtml
					],
					'data-parsoid' => [
						// This has 'autoInsertedEnd' => true, which would cause
						// closing </div> tags to be omitted.
						'body' => $dataParsoid,
					]
				]
			],
		];
		yield 'should ignore data-parsoid if the input format is not pagebundle' => [
			$attribs,
			$html,
			[ '<div>data-parsoid test</div><div>data-parsoid test</div>' ],
		];

		// should apply original data-mw ///////////////////////////////////////
		$html = '<p about="#mwt1" typeof="mw:Transclusion" id="mwAQ">hi</p>';
		$originalHtml = '<p about="#mwt1" typeof="mw:Transclusion" id="mwAQ">ho</p>';
		$dataParsoid = [ 'ids' => [ 'mwAQ' => [ 'pi' => [ [ [ 'k' => '1' ] ] ] ] ] ];
		$dataMediaWiki = [
			'ids' => [
				'mwAQ' => [
					'parts' => [ [
						'template' => [
							'target' => [ 'wt' => '1x', 'href' => './Template:1x' ],
							'params' => [ '1' => [ 'wt' => 'hi' ] ],
							'i' => 0
						]
					] ]
				]
			]
		];
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'html' => [
						'headers' => $htmlHeaders,
						'body' => $originalHtml
					],
					'data-parsoid' => [
						'body' => $dataParsoid,
					],
					'data-mw' => [
						'body' => $dataMediaWiki,
					],
				]
			],
		];
		yield 'should apply original data-mw' => [
			$attribs,
			$html,
			[ '{{1x|hi}}' ],
		];

		// should give precedence to inline data-mw over original ////////
		$html = '<p about="#mwt1" typeof="mw:Transclusion" data-mw=\'{"parts":[{"template":{"target":{"wt":"1x","href":"./Template:1x"},"params":{"1":{"wt":"hi"}},"i":0}}]}\' id="mwAQ">hi</p>';
		$originalHtml = '<p about="#mwt1" typeof="mw:Transclusion" id="mwAQ">ho</p>';
		$dataParsoid = [ 'ids' => [ 'mwAQ' => [ 'pi' => [ [ [ 'k' => '1' ] ] ] ] ] ];
		$dataMediaWiki = [ 'ids' => [ 'mwAQ' => [] ] ]; // Missing data-mw.parts!
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'html' => [
						'headers' => $htmlHeaders,
						'body' => $originalHtml
					],
					'data-parsoid' => [
						'body' => $dataParsoid,
					],
					'data-mw' => [
						'body' => $dataMediaWiki,
					],
				]
			],
		];
		yield 'should give precedence to inline data-mw over original' => [
			$attribs,
			$html,
			[ '{{1x|hi}}' ],
		];

		// should not apply original data-mw if modified is supplied ///////////
		$html = '<p about="#mwt1" typeof="mw:Transclusion" id="mwAQ">hi</p>';
		$originalHtml = '<p about="#mwt1" typeof="mw:Transclusion" id="mwAQ">ho</p>';
		$dataParsoid = [ 'ids' => [ 'mwAQ' => [ 'pi' => [ [ [ 'k' => '1' ] ] ] ] ] ];
		$dataMediaWiki = [ 'ids' => [ 'mwAQ' => [] ] ]; // Missing data-mw.parts!
		$dataMediaWikiModified = [
			'ids' => [
				'mwAQ' => [
					'parts' => [ [
						'template' => [
							'target' => [ 'wt' => '1x', 'href' => './Template:1x' ],
							'params' => [ '1' => [ 'wt' => 'hi' ] ],
							'i' => 0
						]
					] ]
				]
			]
		];
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'data-mw' => [ // modified data
					'body' => $dataMediaWikiModified,
				],
				'original' => [
					'html' => [
						'headers' => $htmlHeaders999,
						'body' => $originalHtml
					],
					'data-parsoid' => [
						'body' => $dataParsoid,
					],
					'data-mw' => [ // original data
						'body' => $dataMediaWiki,
					],
				]
			],
		];
		yield 'should not apply original data-mw if modified is supplied' => [
			$attribs,
			$html,
			[ '{{1x|hi}}' ],
		];

		// should apply original data-mw when modified is absent (captions 1) ///////////
		$html = $this->getTextFromFile( 'Image.html' );
		$dataParsoid = [ 'ids' => [
			'mwAg' => [ 'optList' => [ [ 'ck' => 'caption', 'ak' => 'Testing 123' ] ] ],
			'mwAw' => [ 'a' => [ 'href' => './File:Foobar.jpg' ], 'sa' => [] ],
			'mwBA' => [
				'a' => [ 'resource' => './File:Foobar.jpg', 'height' => '28', 'width' => '240' ],
				'sa' => [ 'resource' => 'File:Foobar.jpg' ]
			]
		] ];
		$dataMediaWiki = [ 'ids' => [ 'mwAg' => [ 'caption' => 'Testing 123' ] ] ];

		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'data-parsoid' => [
						'body' => $dataParsoid,
					],
					'data-mw' => [ // original data
						'body' => $dataMediaWiki,
					],
					'html' => [
						'headers' => $htmlHeaders999,
						'body' => $html
					],
				]
			],
		];
		yield 'should apply original data-mw when modified is absent (captions 1)' => [
			$attribs,
			$html, // modified HTML
			[ '[[File:Foobar.jpg|Testing 123]]' ],
		];

		// should give precedence to inline data-mw over modified (captions 2) /////////////
		$htmlModified = $this->getTextFromFile( 'Image-data-mw.html' );
		$dataMediaWikiModified = [
			'ids' => [
				'mwAg' => [ 'caption' => 'Testing 123' ]
			]
		];

		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'data-mw' => [
					'body' => $dataMediaWikiModified,
				],
				'original' => [
					'data-parsoid' => [
						'body' => $dataParsoid,
					],
					'data-mw' => [ // original data
						'body' => $dataMediaWiki,
					],
					'html' => [
						'headers' => $htmlHeaders999,
						'body' => $html
					],
				]
			],
		];
		yield 'should give precedence to inline data-mw over modified (captions 2)' => [
			$attribs,
			$htmlModified, // modified HTML
			[ '[[File:Foobar.jpg]]' ],
		];

		// should give precedence to modified data-mw over original (captions 3) /////////////
		$dataMediaWikiModified = [
			'ids' => [
				'mwAg' => []
			]
		];

		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'data-mw' => [
					'body' => $dataMediaWikiModified,
				],
				'original' => [
					'data-parsoid' => [
						'body' => $dataParsoid,
					],
					'data-mw' => [ // original data
						'body' => $dataMediaWiki,
					],
					'html' => [
						'headers' => $htmlHeaders999,
						'body' => $html
					],
				]
			],
		];
		yield 'should give precedence to modified data-mw over original (captions 3)' => [
			$attribs,
			$html, // modified HTML
			[ '[[File:Foobar.jpg]]' ],
		];

		// should apply extra normalizations ///////////////////
		$htmlModified = 'Foo<h2></h2>Bar';
		$attribs = [
			'opts' => [
				'original' => []
			],
		];
		yield 'should apply extra normalizations' => [
			$attribs,
			$htmlModified, // modified HTML
			[ 'FooBar' ], // empty tag was stripped
		];

		// should apply version downgrade ///////////
		$htmlOfMinimal = $this->getTextFromFile( 'Minimal.html' ); // Uses profile version 2.4.0
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'html' => [
						'headers' => [
							// Specify newer profile version for original HTML
							'content-type' => 'text/html;profile="https://www.mediawiki.org/wiki/Specs/HTML/999.0.0"'
						],
						// The profile version given inline in the original HTML doesn't matter, it's ignored
						'body' => $htmlOfMinimal,
					],
					'data-parsoid' => [ 'body' => [ 'ids' => [] ] ],
					'data-mw' => [ 'body' => [ 'ids' => [] ] ], // required by version 999.0.0
				]
			],
		];
		yield 'should apply version downgrade' => [
			$attribs,
			$htmlOfMinimal,
			[ '123' ]
		];

		// should not apply version downgrade if versions are the same ///////////
		$htmlOfMinimal = $this->getTextFromFile( 'Minimal.html' ); // Uses profile version 2.4.0
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'html' => [
						'headers' => [
							// Specify the exact same version specified inline in Minimal.html 2.4.0
							'content-type' => 'text/html;profile="https://www.mediawiki.org/wiki/Specs/HTML/2.4.0"'
						],
						// The profile version given inline in the original HTML doesn't matter, it's ignored
						'body' => $htmlOfMinimal,
					],
					'data-parsoid' => [ 'body' => [ 'ids' => [] ] ],
				]
			],
		];
		yield 'should not apply version downgrade if versions are the same' => [
			$attribs,
			$htmlOfMinimal,
			[ '123' ]
		];

		// should convert html to json ///////////////////////////////////
		$html = $this->getTextFromFile( 'JsonConfig.html' );
		$expectedText = [
			'{"a":4,"b":3}',
		];

		$attribs = [
			'opts' => [
				// even if the path says "wikitext", the contentmodel from the body should win.
				'format' => ParsoidFormatHelper::FORMAT_WIKITEXT,
				'contentmodel' => CONTENT_MODEL_JSON,
			],
		];
		yield 'should convert html to json' => [
			$attribs,
			$html,
			$expectedText,
			[ 'content-type' => 'application/json' ],
		];

		// page bundle input should work with no original data present  ///////////
		$htmlOfMinimal = $this->getTextFromFile( 'Minimal.html' ); // Uses profile version 2.4.0
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [],
			],
		];
		yield 'page bundle input should work with no original data present' => [
			$attribs,
			$htmlOfMinimal,
			[ '123' ]
		];
	}

	private function makePage( $title, $wikitext ): RevisionRecord {
		$title = new TitleValue( NS_MAIN, $title );
		$rev = $this->getServiceContainer()->getRevisionLookup()->getRevisionByTitle( $title );

		if ( $rev ) {
			return $rev;
		}

		/** @var RevisionRecord $rev */
		[ 'revision-record' => $rev ] = $this->editPage( 'Test_html2wt', $wikitext )->getValue();

		return $rev;
	}

	/**
	 * @dataProvider provideHtml2wt
	 *
	 * @param array $attribs
	 * @param string $html
	 * @param string[] $expectedText
	 * @param string[] $expectedHeaders
	 *
	 * @covers \MediaWiki\Parser\Parsoid\HtmlToContentTransform
	 * @covers \MediaWiki\Rest\Handler\ParsoidHandler::html2wt
	 */
	public function testHtml2wt(
		array $attribs,
		string $html,
		array $expectedText,
		array $expectedHeaders = []
	) {
		$wikitextProfileUri = 'https://www.mediawiki.org/wiki/Specs/wikitext/1.0.0';
		$expectedHeaders += [
			'content-type' => "text/plain; charset=utf-8; profile=\"$wikitextProfileUri\"",
		];

		$wikitext = self::IMPERFECT_WIKITEXT;

		$rev = $this->makePage( 'Test_html2wt', $wikitext );
		$page = $rev->getPage();

		$pageConfig = $this->getPageConfig( $page );

		$attribs += self::DEFAULT_ATTRIBS;
		$attribs['opts'] += self::DEFAULT_ATTRIBS['opts'];
		$attribs['opts']['from'] ??= 'html';
		$attribs['envOptions'] += self::DEFAULT_ATTRIBS['envOptions'];

		if ( $attribs['oldid'] ) {
			// Set the actual ID of an existing revision
			$attribs['oldid'] = $rev->getId();
		}

		$handler = $this->newParsoidHandler();

		$response = $handler->html2wt( $pageConfig, $attribs, $html );
		$body = $response->getBody();
		$body->rewind();
		$wikitext = $body->getContents();

		foreach ( $expectedHeaders as $name => $value ) {
			$this->assertSame( $value, $response->getHeaderLine( $name ) );
		}

		foreach ( (array)$expectedText as $exp ) {
			$this->assertStringContainsString( $exp, $wikitext );
		}
	}

	public function provideHtml2wtThrows() {
		$html = '<html lang="en"><body>123</body></html>';

		$profileVersion = '2.4.0';
		$htmlProfileUri = 'https://www.mediawiki.org/wiki/Specs/HTML/' . $profileVersion;
		$htmlContentType = "text/html;profile=\"$htmlProfileUri\"";
		$htmlHeaders = [
			'content-type' => $htmlContentType,
		];

		// XXX: what does version 999.0.0 mean?!
		$htmlContentType999 = 'text/html;profile="https://www.mediawiki.org/wiki/Specs/HTML/999.0.0"';
		$htmlHeaders999 = [
			'content-type' => $htmlContentType999,
		];

		// Content-type of original html is missing ////////////////////////////
		$attribs = [
			'opts' => [
				'original' => [
					'html' => [
						// no headers with content type
						'body' => $html,
					],
				]
			],
		];
		yield 'Content-type of original html is missing' => [
			$attribs,
			$html,
			new LocalizedHttpException(
				new MessageValue( 'rest-html-backend-error', [ 'Content-type of original html is missing.' ] ),
				400,
				[ 'reason' => 'Content-type of original html is missing.' ]
			)
		];

		// should fail to downgrade the original version for an unknown transition ///////////
		$htmlOfMinimal = $this->getTextFromFile( 'Minimal.html' );
		$htmlOfMinimal2222 = $this->getTextFromFile( 'Minimal-2222.html' );
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'html' => [
						'headers' => [
							// Specify version 2222.0.0!
							'content-type' => 'text/html;profile="https://www.mediawiki.org/wiki/Specs/HTML/2222.0.0"'
						],
						'body' => $htmlOfMinimal2222,
					],
					'data-parsoid' => [ 'body' => [ 'ids' => [] ] ],
				]
			],
		];
		yield 'should fail to downgrade the original version for an unknown transition' => [
			$attribs,
			$htmlOfMinimal,
			new LocalizedHttpException(
				new MessageValue( 'rest-html-backend-error', [ 'No downgrade possible from schema version 2222.0.0 to 2.4.0.' ] ),
				400,
				[ 'reason' => 'No downgrade possible from schema version 2222.0.0 to 2.4.0.' ]
			)
		];

		// DSR offsetType mismatch: UCS2 vs byte ///////////////////////////////
		$attribs = [
			'offsetType' => 'byte',
			'envOptions' => [],
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'html' => [
						'headers' => $htmlHeaders,
						'body' => $html,
					],
					'data-parsoid' => [
						'body' => [
							'offsetType' => 'UCS2',
							'ids' => [],
						]
					],
				]
			],
		];
		yield 'DSR offsetType mismatch: UCS2 vs byte' => [
			$attribs,
			$html,
			new LocalizedHttpException(
				new MessageValue( 'rest-html-backend-error', [ 'DSR offsetType mismatch: UCS2 vs byte' ] ),
				400,
				[ 'reason' => 'DSR offsetType mismatch: UCS2 vs byte' ]
			)
		];

		// DSR offsetType mismatch: byte vs UCS2 ///////////////////////////////
		$attribs = [
			'offsetType' => 'UCS2',
			'envOptions' => [],
			'opts' => [
				// Enable selser
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'html' => [
						'headers' => $htmlHeaders,
						'body' => $html,
					],
					'data-parsoid' => [
						'body' => [
							'offsetType' => 'byte',
							'ids' => [],
						]
					],
				]
			],
		];
		yield 'DSR offsetType mismatch: byte vs UCS2' => [
			$attribs,
			$html,
			new LocalizedHttpException(
				new MessageValue( 'rest-html-backend-error', [ 'DSR offsetType mismatch: byte vs UCS2' ] ),
				400,
				[ 'reason' => 'DSR offsetType mismatch: byte vs UCS2' ]
			)
		];

		// Could not find previous revision ////////////////////////////
		$attribs = [
			'oldid' => 1155779922,
			'opts' => [
				// set original HTML to enable selser
				'original' => [
					'html' => [
						'headers' => $htmlHeaders,
						'body' => $html,
					]
				]
			]
		];
		yield 'Could not find previous revision' => [
			$attribs,
			$html,
			new LocalizedHttpException( new MessageValue( "rest-specified-revision-unavailable" ),
				404
			)
		];

		// should return a 400 for missing inline data-mw (2.x) ///////////////////
		$html = '<p about="#mwt1" typeof="mw:Transclusion" id="mwAQ">hi</p>';
		$dataParsoid = [ 'ids' => [ 'mwAQ' => [ 'pi' => [ [ [ 'k' => '1' ] ] ] ] ] ];
		$htmlOrig = '<p about="#mwt1" typeof="mw:Transclusion" id="mwAQ">ho</p>';
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'data-parsoid' => [
						'body' => $dataParsoid,
					],
					'html' => [
						'headers' => $htmlHeaders,
						// slightly modified
						'body' => $htmlOrig,
					]
				]
			],
		];
		yield 'should return a 400 for missing inline data-mw (2.x)' => [
			$attribs,
			$html,
			new LocalizedHttpException( new MessageValue( 'rest-parsoid-error', [ 'Cannot serialize mw:Transclusion without data-mw.parts or data-parsoid.src' ] ),
				400
			)
		];

		// should return a 400 for not supplying data-mw //////////////////////
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'data-parsoid' => [
						'body' => $dataParsoid,
					],
					'html' => [
						'headers' => $htmlHeaders999,
						'body' => $htmlOrig,
					]
				]
			],
		];
		yield 'should return a 400 for not supplying data-mw' => [
			$attribs,
			$html,
			new LocalizedHttpException(
				new MessageValue( 'rest-html-backend-error', [ 'Invalid data-mw was provided.' ] ),
				400,
				[ 'reason' => 'Invalid data-mw was provided.' ]
			)
		];

		// should return a 400 for missing modified data-mw
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'data-parsoid' => [
						'body' => $dataParsoid,
					],
					'data-mw' => [
						'body' => [
							// Missing data-mw.parts!
							'ids' => [ 'mwAQ' => [] ],
						]
					],
					'html' => [
						'headers' => $htmlHeaders999,
						'body' => $htmlOrig,
					]
				]
			],
		];
		yield 'should return a 400 for missing modified data-mw' => [
			$attribs,
			$html,
			new LocalizedHttpException( new MessageValue( 'rest-parsoid-error', [ 'Cannot serialize mw:Transclusion without data-mw.parts or data-parsoid.src' ] ),
				400
			)
		];

		// should return http 400 if supplied data-parsoid is empty ////////////
		$html = '<html><head></head><body><p>hi</p></body></html>';
		$htmlOrig = '<html><head></head><body><p>ho</p></body></html>';
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				'original' => [
					'data-parsoid' => [
						'body' => [],
					],
					'html' => [
						'headers' => $htmlHeaders,
						'body' => $htmlOrig,
					]
				]
			],
		];
		yield 'should return http 400 if supplied data-parsoid is empty' => [
			$attribs,
			$html,
			new LocalizedHttpException(
				new MessageValue( 'rest-html-backend-error', [ 'Invalid data-parsoid was provided.' ] ),
				400,
				[ 'reason' => 'Invalid data-parsoid was provided.' ]
			)
		];

		// TODO: ResourceLimitExceededException from $parsoid->dom2wikitext -> 413
		// TODO: ClientError from $parsoid->dom2wikitext -> 413
		// TODO: Errors from PageBundle->validate
	}

	/**
	 * @dataProvider provideHtml2wtThrows
	 *
	 * @param array $attribs
	 * @param string $html
	 * @param Exception $expectedException
	 */
	public function testHtml2wtThrows(
		array $attribs,
		string $html,
		Exception $expectedException
	) {
		if ( isset( $attribs['oldid'] ) ) {
			// If a specific revision ID is requested, it's almost certain to no exist.
			// So we are testing with a non-existing page.
			$page = $this->getNonexistingTestPage();
		} else {
			$page = $this->getExistingTestPage();
		}

		$pageConfig = $this->getPageConfig( $page );

		$attribs += self::DEFAULT_ATTRIBS;
		$attribs['opts'] += self::DEFAULT_ATTRIBS['opts'];
		$attribs['opts']['from'] ??= 'html';
		$attribs['envOptions'] += self::DEFAULT_ATTRIBS['envOptions'];

		$handler = $this->newParsoidHandler();

		try {
			$handler->html2wt( $pageConfig, $attribs, $html );
			$this->fail( 'Expected exception: ' . $expectedException );
		} catch ( Exception $e ) {
			$this->assertInstanceOf( get_class( $expectedException ), $e );
			$this->assertSame( $expectedException->getCode(), $e->getCode() );

			if ( $expectedException instanceof HttpException ) {
				/** @var HttpException $e */
				$this->assertSame(
					$expectedException->getErrorData(),
					array_intersect_key(
						$expectedException->getErrorData(),
						$e->getErrorData()
					)
				);
			}

			if ( $expectedException instanceof LocalizedHttpException ) {
				/** @var LocalizedHttpException $expectedException */
				$this->assertInstanceOf( LocalizedHttpException::class, $e );
				$this->assertEquals( $expectedException->getMessageValue(), $e->getMessageValue() );
				$this->assertSame( $expectedException->getErrorData(), $e->getErrorData() );
			}

			$this->assertSame( $expectedException->getMessage(), $e->getMessage() );
		}
	}

	public static function provideDom2wikitextException() {
		yield 'ClientError' => [
			new ClientError( 'test' ),
			new LocalizedHttpException( new MessageValue( 'rest-parsoid-error', [ 'test' ] ), 400 )
		];

		yield 'ResourceLimitExceededException' => [
			new ResourceLimitExceededException( 'test' ),
			new LocalizedHttpException( new MessageValue( 'rest-parsoid-resource-exceeded', [ 'test' ] ), 413 )
		];
	}

	/**
	 * @dataProvider provideDom2wikitextException
	 *
	 * @param Exception $throw
	 * @param Exception $expectedException
	 */
	public function testHtml2wtHandlesDom2wikitextException(
		Exception $throw,
		Exception $expectedException
	) {
		$html = '<p>hi</p>';
		$page = $this->getExistingTestPage();
		$attribs = [
			'opts' => [
				'from' => ParsoidFormatHelper::FORMAT_HTML
			]
		] + self::DEFAULT_ATTRIBS;

		// Make a fake Parsoid that throws
		/** @var Parsoid|MockObject $parsoid */
		$parsoid = $this->createNoOpMock( Parsoid::class, [ 'dom2wikitext' ] );
		$parsoid->method( 'dom2wikitext' )->willThrowException( $throw );

		// Make a fake HtmlTransformFactory that returns an HtmlToContentTransform that uses the fake Parsoid.
		/** @var HtmlTransformFactory|MockObject $factory */
		$factory = $this->createNoOpMock( HtmlTransformFactory::class, [ 'getHtmlToContentTransform' ] );
		$factory->method( 'getHtmlToContentTransform' )->willReturn( new HtmlToContentTransform(
			$html,
			$page,
			$parsoid,
			[],
			$this->getPageConfigFactory( $page ),
			$this->getServiceContainer()->getContentHandlerFactory()
		) );

		// Use an HtmlInputTransformHelper that uses the fake HtmlTransformFactory, so it ends up
		// using the HtmlToContentTransform that has the fake Parsoid which throws an exception.
		$handler = $this->newParsoidHandler( [
			'getHtmlInputHelper' => function () use ( $factory, $page, $html ) {
				$helper = new HtmlInputTransformHelper(
					StatsFactory::newNull(),
					$factory,
					$this->getServiceContainer()->getParsoidOutputStash(),
					$this->getServiceContainer()->getParserOutputAccess(),
					$this->getServiceContainer()->getPageStore(),
					$this->getServiceContainer()->getRevisionLookup(),
					[],
					$page,
					[ 'html' => $html ],
					[]
				);
				return $helper;
			}
		] );

		try {
			$handler->html2wt( $page, $attribs, $html );
			$this->fail( 'Expected exception ' . get_class( $expectedException ) . ' not thrown' );
		} catch ( Exception $e ) {
			$this->assertSame( $expectedException->getCode(), $e->getCode() );
			$this->assertSame( $expectedException->getMessage(), $e->getMessage() );

			if ( $expectedException instanceof LocalizedHttpException ) {
				$this->assertEquals( $expectedException->getMessageValue(), $e->getMessageValue() );
				$this->assertSame( $expectedException->getErrorData(), $e->getErrorData() );

			}
			$this->assertSame( $expectedException->getMessage(), $e->getMessage() );

		}
	}

	/** @return Generator */
	public function provideTryToCreatePageConfigData() {
		$en = $this->createLanguageMock( 'en' );
		$ar = $this->createLanguageMock( 'ar' );
		$de = $this->createLanguageMock( 'de' );
		yield 'Default attribs for tryToCreatePageConfig()' => [
			'attribs' => [ 'oldid' => 1, 'pageName' => 'Test', 'pagelanguage' => $en ],
			'wikitext' => null,
			'html2WtMode' => false,
			'expectedPageLanguage' => $en,
		];

		yield 'tryToCreatePageConfig with wikitext' => [
			'attribs' => [ 'oldid' => 1, 'pageName' => 'Test', 'pagelanguage' => $en ],
			'wikitext' => "=test=",
			'html2WtMode' => false,
			'expected page language' => $en,
		];

		yield 'tryToCreatePageConfig with html2WtMode set to true' => [
			'attribs' => [ 'oldid' => 1, 'pageName' => 'Test', 'pagelanguage' => null ],
			'wikitext' => null,
			'html2WtMode' => true,
			'expected page language' => $en,
		];

		yield 'tryToCreatePageConfig with both wikitext and html2WtMode' => [
			'attribs' => [ 'oldid' => 1, 'pageName' => 'Test', 'pagelanguage' => $ar ],
			'wikitext' => "=header=",
			'html2WtMode' => true,
			'expected page language' => $ar,
		];

		yield 'Try to create a page config with pageName set to empty string' => [
			'attribs' => [ 'oldid' => 1, 'pageName' => '', 'pagelanguage' => $de ],
			'wikitext' => null,
			'html2WtMode' => false,
			'expected page language' => $de,
		];

		yield 'Try to create a page config with pageName set to zero string' => [
			'attribs' => [ 'oldid' => 1, 'pageName' => '0', 'pagelanguage' => $de ],
			'wikitext' => null,
			'html2WtMode' => false,
			'expected page language' => $de,
		];

		yield 'Try to create a page config with no page language' => [
			'attribs' => [ 'oldid' => 1, 'pageName' => '', 'pagelanguage' => null ],
			'wikitext' => null,
			false,
			'expected page language' => $en,
		];
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\ParsoidHandler::tryToCreatePageConfig
	 *
	 * @dataProvider provideTryToCreatePageConfigData
	 */
	public function testTryToCreatePageConfig(
		array $attribs,
		?string $wikitext,
		$html2WtMode,
		Language $expectedLanguage
	) {
		// Create a page, if needed, to test with oldid
		$origContent = 'Test content for ' . __METHOD__;
		$page = $this->getNonexistingTestPage();
		$this->editPage( $page, $origContent );
		$expectedWikitext = $wikitext ?? $origContent;
		$pageConfig = $this->newParsoidHandler()->tryToCreatePageConfig( $attribs, $wikitext, $html2WtMode );

		$this->assertSame(
			$expectedWikitext,
			$pageConfig->getRevisionContent()->getContent( SlotRecord::MAIN )
		);

		$pageName = ( $attribs['pageName'] === '' ) ? 'Main Page' : $attribs['pageName'];
		$this->assertSame( $pageName, $pageConfig->getLinkTarget()->getPrefixedText() );

		$this->assertSame( $expectedLanguage->getCode(), $pageConfig->getPageLanguageBcp47()->getCode() );
	}

	/** @return Generator */
	public function provideTryToCreatePageConfigDataThrows() {
		$en = $this->createLanguageMock( 'en' );
		yield "PageConfig with oldid that doesn't exist" => [
			'attribs' => [ 'oldid' => null, 'pageName' => 'Test', 'pagelanguage' => $en ],
			'wikitext' => null,
			'html2WtMode' => false,
		];

		yield 'PageConfig with a bad title' => [
			[ 'oldid' => null, 'pageName' => 'Special:Badtitle', 'pagelanguage' => $en ],
			'wikitext' => null,
			'html2WtMode' => false,
		];

		yield "PageConfig with a revision that doesn't exist" => [
			// 'oldid' is so large because we want to emulate a revision
			// that doesn't exist.
			[ 'oldid' => 12345678, 'pageName' => 'Test', 'pagelanguage' => $en ],
			'wikitext' => null,
			'html2WtMode' => false,
		];
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\ParsoidHandler::tryToCreatePageConfig
	 *
	 * @dataProvider provideTryToCreatePageConfigDataThrows
	 */
	public function testTryToCreatePageConfigThrows( array $attribs, $wikitext, $html2WtMode ) {
		$this->expectException( HttpException::class );
		$this->expectExceptionCode( 404 );

		$this->newParsoidHandler()->tryToCreatePageConfig( $attribs, $wikitext, $html2WtMode );
	}

	public static function provideRoundTripNoSelser() {
		yield 'space in heading' => [
			"==foo==\nsomething\n"
		];
	}

	public static function provideRoundTripNeedingSelser() {
		yield 'uppercase tags' => [
			"<DIV>foo</div>"
		];
	}

	/**
	 * @dataProvider provideRoundTripNoSelser
	 */
	public function testRoundTripWithHTML( $wikitext ) {
		$handler = $this->newParsoidHandler();

		$attribs = self::DEFAULT_ATTRIBS;
		$attribs['opts']['from'] = ParsoidFormatHelper::FORMAT_WIKITEXT;
		$attribs['opts']['format'] = ParsoidFormatHelper::FORMAT_HTML;

		$pageConfig = $handler->tryToCreatePageConfig( $attribs, $wikitext );
		$response = $handler->wt2html( $pageConfig, $attribs, $wikitext );
		$body = $response->getBody();
		$body->rewind();
		$html = $body->getContents();

		// Got HTML, now convert back
		$attribs = self::DEFAULT_ATTRIBS;
		$attribs['opts']['from'] = ParsoidFormatHelper::FORMAT_HTML;
		$attribs['opts']['format'] = ParsoidFormatHelper::FORMAT_WIKITEXT;

		$pageConfig = $handler->tryToCreatePageConfig( $attribs, null, true );
		$response = $handler->html2wt( $pageConfig, $attribs, $html );
		$body = $response->getBody();
		$body->rewind();
		$actual = $body->getContents();

		// apply some normalization before comparing
		$actual = trim( $actual );
		$wikitext = trim( $wikitext );

		$this->assertSame( $wikitext, $actual );
	}

	/**
	 * @dataProvider provideRoundTripNoSelser
	 */
	public function testRoundTripWithPageBundleWithoutOriginalHTML( $wikitext ) {
		$handler = $this->newParsoidHandler();

		$attribs = self::DEFAULT_ATTRIBS;
		$attribs['opts']['from'] = ParsoidFormatHelper::FORMAT_WIKITEXT;
		$attribs['opts']['format'] = ParsoidFormatHelper::FORMAT_PAGEBUNDLE;

		$pageConfig = $handler->tryToCreatePageConfig( $attribs, $wikitext );
		$response = $handler->wt2html( $pageConfig, $attribs, $wikitext );
		$body = $response->getBody();
		$body->rewind();
		$pbJson = $body->getContents();

		$pbData = json_decode( $pbJson, JSON_OBJECT_AS_ARRAY );
		$html = $pbData['html']['body']; // HTML with data-parsoid stripped out

		// Got HTML, now convert back
		$attribs = self::DEFAULT_ATTRIBS;
		$attribs['opts']['from'] = ParsoidFormatHelper::FORMAT_PAGEBUNDLE;
		$attribs['opts']['format'] = ParsoidFormatHelper::FORMAT_WIKITEXT;
		$attribs['opts']['original'] = [
			'data-parsoid' => $pbData['data-parsoid'],
		];

		$pageConfig = $handler->tryToCreatePageConfig( $attribs, null, true );
		$response = $handler->html2wt( $pageConfig, $attribs, $html );
		$body = $response->getBody();
		$body->rewind();
		$actual = $body->getContents();

		// apply some normalization before comparing
		$actual = trim( $actual );
		$wikitext = trim( $wikitext );

		$this->assertSame( $wikitext, $actual );
	}

	/**
	 * @dataProvider provideRoundTripNoSelser
	 * @dataProvider provideRoundTripNeedingSelser
	 */
	public function testRoundTripWithSelser( $wikitext ) {
		$handler = $this->newParsoidHandler();

		$attribs = self::DEFAULT_ATTRIBS;
		$attribs['opts']['from'] = ParsoidFormatHelper::FORMAT_WIKITEXT;
		$attribs['opts']['format'] = ParsoidFormatHelper::FORMAT_PAGEBUNDLE;

		$page = $this->getExistingTestPage();
		$revid = $page->getLatest();

		$pageConfig = $handler->tryToCreatePageConfig( $attribs, $wikitext );
		$response = $handler->wt2html( $pageConfig, $attribs, $wikitext );

		// NOTE: Make sure there is no ETag if no stashing was requested (T331629)
		$etag = $response->getHeaderLine( 'etag' );
		$this->assertSame( '', $etag, 'ETag' );

		$body = $response->getBody();
		$body->rewind();
		$pbJson = $body->getContents();

		$pbData = json_decode( $pbJson, JSON_OBJECT_AS_ARRAY );
		$html = $pbData['html']['body']; // HTML with data-parsoid stripped out

		// Got HTML, now convert back
		$attribs = self::DEFAULT_ATTRIBS;
		$attribs['oldid'] = $revid;
		$attribs['opts']['revid'] = $revid;
		$attribs['opts']['from'] = ParsoidFormatHelper::FORMAT_PAGEBUNDLE;
		$attribs['opts']['format'] = ParsoidFormatHelper::FORMAT_WIKITEXT;
		$attribs['opts']['original'] = $pbData;
		$attribs['opts']['original']['wikitext']['body'] = $wikitext;

		$pageConfig = $handler->tryToCreatePageConfig( $attribs, $wikitext, true );
		$response = $handler->html2wt( $pageConfig, $attribs, $html );
		$body = $response->getBody();
		$body->rewind();
		$actual = $body->getContents();

		// apply some normalization before comparing
		$actual = trim( $actual );
		$wikitext = trim( $wikitext );

		$this->assertSame( $wikitext, $actual );
	}

	/**
	 * @dataProvider provideRoundTripNoSelser
	 * @dataProvider provideRoundTripNeedingSelser
	 */
	public function testRoundTripWithStashing( $wikitext ) {
		$handler = $this->newParsoidHandler();

		$attribs = self::DEFAULT_ATTRIBS;
		$attribs['opts']['from'] = ParsoidFormatHelper::FORMAT_WIKITEXT;
		$attribs['opts']['format'] = ParsoidFormatHelper::FORMAT_HTML;
		$attribs['opts']['stash'] = true;

		$page = $this->getExistingTestPage();
		$revid = $page->getLatest();

		$pageConfig = $handler->tryToCreatePageConfig( $attribs, $wikitext );
		$response = $handler->wt2html( $pageConfig, $attribs, $wikitext );

		$etag = $response->getHeaderLine( 'etag' );
		$this->assertNotEmpty( $etag, 'ETag' );

		$body = $response->getBody();
		$body->rewind();
		$html = $body->getContents();

		// Got HTML, now convert back
		$attribs = self::DEFAULT_ATTRIBS;
		$attribs['oldid'] = $revid;
		$attribs['opts']['revid'] = $revid;
		$attribs['opts']['from'] = ParsoidFormatHelper::FORMAT_PAGEBUNDLE;
		$attribs['opts']['format'] = ParsoidFormatHelper::FORMAT_WIKITEXT;
		$attribs['opts']['original']['etag'] = $etag;
		$attribs['opts']['original']['wikitext'] = $wikitext;

		$pageConfig = $handler->tryToCreatePageConfig( $attribs, $wikitext, true );
		$response = $handler->html2wt( $pageConfig, $attribs, $html );
		$body = $response->getBody();
		$body->rewind();
		$actual = $body->getContents();

		// apply some normalization before comparing
		$actual = trim( $actual );
		$wikitext = trim( $wikitext );

		$this->assertSame( $wikitext, $actual );
	}

	public function provideLanguageConversion() {
		$en = $this->createLanguageMock( 'en' );
		$enPigLatin = $this->createLanguageMock( 'en-x-piglatin' );
		$profileVersion = Parsoid::AVAILABLE_VERSIONS[0];
		$htmlProfileUri = 'https://www.mediawiki.org/wiki/Specs/HTML/' . $profileVersion;
		$htmlContentType = "text/html; charset=utf-8; profile=\"$htmlProfileUri\"";

		$defaultAttribs = [
			'oldid' => null,
			'pageName' => __METHOD__,
			'opts' => [],
			'envOptions' => [
				'inputContentVersion' => Parsoid::defaultHTMLVersion()
			]
		];

		$attribs = [
			'pagelanguage' => $en,
			'opts' => [
				'updates' => [
					'variant' => [
						'source' => $en,
						'target' => $enPigLatin
					]
				],
			],
		] + $defaultAttribs;

		$revision = [
			'contentmodel' => CONTENT_MODEL_WIKITEXT,
			'html' => [
				'headers' => [
					'content-type' => $htmlContentType,
				],
				'body' => '<p>test language conversion</p>',
			],
		];

		yield [
			$attribs,
			$revision,
			'>esttay anguagelay onversioncay<',
			[
				'content-type' => $htmlContentType,
				'content-language' => $enPigLatin->toBcp47Code(),
			]
		];
	}

	/**
	 * @dataProvider provideLanguageConversion
	 */
	public function testLanguageConversion(
		array $attribs,
		array $revision,
		string $expectedText,
		array $expectedHeaders
	) {
		$handler = $this->newParsoidHandler();

		$pageConfig = $handler->tryToCreatePageConfig( $attribs, null, true );
		$response = $handler->languageConversion( $pageConfig, $attribs, $revision );

		$body = $response->getBody();
		$body->rewind();
		$actual = $body->getContents();

		$pb = json_decode( $actual, true );
		$this->assertNotEmpty( $pb );
		$this->assertArrayHasKey( 'html', $pb );
		$this->assertArrayHasKey( 'body', $pb['html'] );

		$this->assertStringContainsString( $expectedText, $pb['html']['body'] );

		foreach ( $expectedHeaders as $key => $value ) {
			$this->assertArrayHasKey( $key, $pb['html']['headers'] );
			$this->assertSame( $value, $pb['html']['headers'][$key] );
		}
	}

	public static function provideWt2html() {
		$profileVersion = '2.6.0';
		$htmlProfileUri = 'https://www.mediawiki.org/wiki/Specs/HTML/' . $profileVersion;
		$pbProfileUri = 'https://www.mediawiki.org/wiki/Specs/pagebundle/' . $profileVersion;
		$dpProfileUri = 'https://www.mediawiki.org/wiki/Specs/data-parsoid/' . $profileVersion;

		$htmlContentType = "text/html; charset=utf-8; profile=\"$htmlProfileUri\"";
		$pbContentType = "application/json; charset=utf-8; profile=\"$pbProfileUri\"";
		$dpContentType = "application/json; charset=utf-8; profile=\"$dpProfileUri\"";
		$lintContentType = "application/json";

		$htmlHeaders = [
			'content-type' => $htmlContentType,
		];

		$pbHeaders = [
			'content-type' => $pbContentType,
		];

		$lintHeaders = [
			'content-type' => $lintContentType,
		];

		// should get from a title and revision (html) ///////////////////////////////////
		$expectedText = [
			'>First Revision Content<',
			'<html', // full document
			'data-parsoid=' // annotated
		];

		$unexpectedText = [];

		$attribs = [
			'oldid' => 1, // will be replaced by a real revision id
		];
		yield 'should get from a title and revision (html)' => [
			$attribs,
			null,
			$expectedText,
			$unexpectedText,
			$htmlHeaders
		];

		// should get from a title and revision (pagebundle) ///////////////////////////////////
		$expectedText = [ // bits of json
			'"body":"<!DOCTYPE html>',
			'First Revision Content</p>',
			'contentmodel' => 'wikitext',
			'data-parsoid' => [
				'headers' => [
					'content-type' => $dpContentType,
				],
				'body' => [
					'counter' => 2,
					'ids' => [ // NOTE: match "First Revision Content"
						'mwAA' => [ 'dsr' => [ 0, 22, 0, 0 ] ],
						'mwAQ' => [],
						'mwAg' => [ 'dsr' => [ 0, 22, 0, 0 ] ],
					],
					'offsetType' => 'ucs2', // as provided in the input
				]
			],
		];

		$unexpectedText = [];

		$attribs = [
			'oldid' => 1, // will be replaced by a real revision id
			'opts' => [ 'format' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE ],
			// Ensure this is ucs2 so we have a ucs2 offsetType test since
			// Parsoid's rt-testing script is node.js based and hence needs
			// ucs2 offsets to function correctly!
			'offsetType' => 'ucs2', // make sure this is looped through to data-parsoid attribute
		];
		yield 'should get from a title and revision (pagebundle)' => [
			$attribs,
			null,
			$expectedText,
			$unexpectedText,
			$pbHeaders
		];

		// should parse the given wikitext ///////////////////////////////////
		$wikitext = 'lorem ipsum';
		$expectedText = [
			'>lorem ipsum<',
			'<html', // full document
			'data-parsoid=' // annotated
		];

		$unexpectedText = [];

		$attribs = [];
		yield 'should parse the given wikitext' => [
			$attribs,
			$wikitext,
			$expectedText,
			$unexpectedText,
			$htmlHeaders
		];

		// should parse the given wikitext (body_only) ///////////////////////////////////
		$wikitext = 'lorem ipsum';
		$expectedText = [ '>lorem ipsum<' ];

		$unexpectedText = [ '<html' ];

		$attribs = [
			'body_only' => true
		];
		yield 'should parse the given wikitext (body_only)' => [
			$attribs,
			$wikitext,
			$expectedText,
			$unexpectedText,
			$htmlHeaders
		];

		// should lint the given wikitext ///////////////////////////////////
		$wikitext = "{|\nhi\n|ho\n|}";
		$expectedText = [
			'"type":"fostered"',
			'"dsr"'
		];

		$unexpectedText = [
			'<html'
		];

		$attribs = [
			'opts' => [ 'format' => ParsoidFormatHelper::FORMAT_LINT ]
		];

		yield 'should lint the given wikitext' => [
			$attribs,
			$wikitext,
			$expectedText,
			$unexpectedText,
			$lintHeaders
		];

		// should lint the given wikitext 2 ///////////////////////////////////
		$wikitext = "{|\n|wide\n|wide\n|wide\n|wide\n|wide\n|wide\n|}";
		if ( ExtensionRegistry::getInstance()->isLoaded( 'Linter' ) ) {
			$expectedText = [];
		} else {
			$expectedText = [
				'"type":"large-tables"',
				'"dsr"'
			];
		}

		$unexpectedText = [
			'<html'
		];

		$attribs = [
			'opts' => [ 'format' => ParsoidFormatHelper::FORMAT_LINT ]
		];

		yield 'should lint the given wikitext 2' => [
			$attribs,
			$wikitext,
			$expectedText,
			$unexpectedText,
			$lintHeaders
		];

		// should lint the given wikitext 3 ///////////////////////////////////

		// Multibyte characters before lint error
		$wikitext = "ăăă ''test";

		$expectedText = [
			'"type":"missing-end-tag"',
			// '"dsr":[7,13,2,0]', // 'byte' offsets
			'"dsr":[4,10,2,0]', // 'ucs2' offsets
		];

		$unexpectedText = [
			'<html'
		];

		$attribs = [
			'opts' => [ 'format' => ParsoidFormatHelper::FORMAT_LINT ],
			'offsetType' => 'ucs2',
		];

		yield 'should lint the given wikitext 3' => [
			$attribs,
			$wikitext,
			$expectedText,
			$unexpectedText,
			$lintHeaders
		];

		// should parse the given JSON ///////////////////////////////////
		$wikitext = '{ "color": "green" }';

		// should be rendered as table, not interpreted as wikitext
		$expectedText = [
			'>color</th>',
			'>green</td>',
			'<html',
		];

		$unexpectedText = [ '<p>' ];

		$attribs = [
			'opts' => [
				'contentmodel' => CONTENT_MODEL_JSON,
			]
		];
		yield 'should parse the given JSON' => [
			$attribs,
			$wikitext,
			$expectedText,
			$unexpectedText,
			$htmlHeaders
		];
	}

	/**
	 * @dataProvider provideWt2html
	 *
	 * @param array $attribs
	 * @param string|null $text
	 * @param array $expectedData
	 * @param string[] $unexpectedHtml
	 * @param string[] $expectedHeaders
	 */
	public function testWt2html(
		array $attribs,
		?string $text,
		array $expectedData,
		array $unexpectedHtml,
		array $expectedHeaders = []
	) {
		$htmlProfileUri = 'https://www.mediawiki.org/wiki/Specs/html/2.6.0';
		$expectedHeaders += [
			'content-type' => "text/x-wiki; charset=utf-8; profile=\"$htmlProfileUri\"",
		];

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$status = $this->editPage( $page, 'First Revision Content' );
		$currentRev = $status->getNewRevision();

		$attribs += self::DEFAULT_ATTRIBS;
		$attribs['opts'] += self::DEFAULT_ATTRIBS['opts'];
		$attribs['opts']['from'] ??= 'wikitext';
		$attribs['opts']['format'] ??= 'html';
		$attribs['envOptions'] += self::DEFAULT_ATTRIBS['envOptions'];

		if ( $attribs['oldid'] ) {
			// Set the actual ID of an existing revision
			$attribs['oldid'] = $currentRev->getId();

			// Make sure we are testing against a non-current revision
			$this->editPage( $page, 'this is not the content you are looking for' );
		}

		$handler = $this->newParsoidHandler();

		$revTextOrId = $text ?? $attribs['oldid'] ?? null;
		$pageConfig = $this->getPageConfig( $page, $revTextOrId );
		$response = $handler->wt2html( $pageConfig, $attribs, $text );
		$body = $response->getBody();
		$body->rewind();
		$data = $body->getContents();

		foreach ( $expectedHeaders as $name => $value ) {
			$responseHeaderValue = $response->getHeaderLine( $name );
			if ( $name === 'content-type' ) {
				$this->assertTrue( $this->contentTypeMatcher( $value, $responseHeaderValue ) );
			} else {
				$this->assertSame( $value, $responseHeaderValue );
			}
		}

		// HACK: try to parse as json, just in case:
		$jsonData = json_decode( $data, JSON_OBJECT_AS_ARRAY );

		foreach ( $expectedData as $index => $exp ) {
			if ( is_int( $index ) ) {
				$this->assertStringContainsString( $exp, $data );
			} else {
				$this->assertArrayHasKey( $index, $jsonData );
				if ( $index === 'data-parsoid' ) {
					// FIXME: Assert headers as well
					$this->assertArrayHasKey( 'body', $jsonData[$index] );
					$this->assertSame( $exp['body'], $jsonData[$index]['body'] );
				} else {
					$this->assertSame( $exp, $jsonData[$index] );
				}
			}
		}

		foreach ( $unexpectedHtml as $exp ) {
			$this->assertStringNotContainsString( $exp, $data );
		}
	}

	public function testLenientRevisionHandling() {
		$page1 = $this->getNonexistingTestPage( "Page1" );
		$status = $this->editPage( $page1, 'Page 1 revision content' );
		$rev1 = $status->getNewRevision();

		$page2 = $this->getNonexistingTestPage( "Page2" );
		$status = $this->editPage( $page2, '#REDIRECT [[Page1]]' );
		$rev2 = $status->getNewRevision();

		$handler = $this->newParsoidHandler();

		// Test 1: <page1, rev1>
		$attribs = self::DEFAULT_ATTRIBS;
		$attribs['opts'] += self::DEFAULT_ATTRIBS['opts'];
		$attribs['opts']['from'] ??= 'wikitext';
		$attribs['opts']['format'] ??= 'html';
		$attribs['envOptions'] += self::DEFAULT_ATTRIBS['envOptions'];
		$attribs['oldid'] = $rev1->getId();

		$pageConfig = $this->getPageConfig( $page1, $attribs['oldid'] );
		$response = $handler->wt2html( $pageConfig, $attribs );
		$body = $response->getBody();
		$body->rewind();
		$data = $body->getContents();
		$this->assertStringContainsString( 'Page 1 revision content', $data );

		// Test 2: <page2, rev2>
		$attribs['oldid'] = $rev2->getId();
		$pageConfig = $this->getPageConfig( $page2, $attribs['oldid'] );
		$response = $handler->wt2html( $pageConfig, $attribs );
		$body = $response->getBody();
		$body->rewind();
		$data = $body->getContents();
		$this->assertStringContainsString( '<link rel="mw:PageProp/redirect" ', $data );

		// Test 2: <page2, rev1> <-- should transparently redirect
		$attribs['oldid'] = $rev1->getId();
		$pageConfig = $this->getPageConfig( $page2, $attribs['oldid'] );
		$response = $handler->wt2html( $pageConfig, $attribs );
		$body = $response->getBody();
		$body->rewind();
		$data = $body->getContents();
		$this->assertStringContainsString( 'Page 1 revision content', $data );

		// Test 3 repeated with ParserCache to ensure nothing is written to cache!
		$parserCache = $this->createNoOpMock( ParserCache::class, [ 'save', 'get', 'getDirty', 'makeParserOutputKey' ] );
		// This is the critical assertion -- no cache svaes for mismatched rev & page params
		$parserCache->expects( $this->never() )->method( 'save' );
		// Ensures there is a cache miss
		$parserCache->method( 'get' )->willReturn( false );
		$parserCache->method( 'getDirty' )->willReturn( false );
		// Verify that the cache is queried
		$parserCache->expects( $this->atLeastOnce() )->method( 'makeParserOutputKey' );
		$parserCacheFactory = $this->createNoOpMock(
			ParserCacheFactory::class,
			[ 'getParserCache', 'getRevisionOutputCache' ]
		);
		$parserCacheFactory->method( 'getParserCache' )->willReturn( $parserCache );
		$parserCacheFactory->method( 'getRevisionOutputCache' )->willReturn(
			$this->createNoOpMock( RevisionOutputCache::class )
		);
		$this->setService( 'ParserCacheFactory', $parserCacheFactory );
		$handler = $this->newParsoidHandler();
		$handler->wt2html( $pageConfig, $attribs ); // Reuse pageconfig & attribs from test 3
	}

	public function testWt2html_ParserCache() {
		$page = $this->getExistingTestPage();
		$pageConfig = $this->getPageConfig( $page );

		$parserCache = $this->createNoOpMock( ParserCache::class, [ 'save', 'get', 'getDirty', 'makeParserOutputKey' ] );

		// This is the critical assertion in this test case: the save() method should
		// be called exactly once!
		$parserCache->expects( $this->once() )->method( 'save' );
		$parserCache->method( 'get' )->willReturn( false );
		$parserCache->method( 'getDirty' )->willReturn( false );
		// These methods will be called by ParserOutputAccess:qa
		$parserCache->expects( $this->atLeastOnce() )->method( 'makeParserOutputKey' );

		$parserCacheFactory = $this->createNoOpMock(
			ParserCacheFactory::class,
			[ 'getParserCache', 'getRevisionOutputCache' ]
		);
		$parserCacheFactory->method( 'getParserCache' )->willReturn( $parserCache );
		$parserCacheFactory->method( 'getRevisionOutputCache' )->willReturn(
			$this->createNoOpMock( RevisionOutputCache::class )
		);

		$this->setService( 'ParserCacheFactory', $parserCacheFactory );

		$attribs = self::DEFAULT_ATTRIBS;
		$attribs['opts']['from'] = 'wikitext';
		$attribs['opts']['format'] = 'html';

		$handler = $this->newParsoidHandler();

		// This should trigger a parser cache write, because we didn't set a write-ratio
		$handler->wt2html( $pageConfig, $attribs );
	}

	public function testWt2html_variant_conversion() {
		$page = $this->getExistingTestPage();
		$pageConfig = $this->getPageConfig( $page );

		$attribs = self::DEFAULT_ATTRIBS;
		$attribs['opts']['from'] = 'wikitext';
		$attribs['opts']['format'] = 'html';
		$attribs['opts']['accept-language'] = 'en-x-piglatin';

		$handler = $this->newParsoidHandler();

		// This should trigger a parser cache write, because we didn't set a write-ratio
		$response = $handler->wt2html( $pageConfig, $attribs );

		$body = $response->getBody();
		$body->rewind();
		$data = $body->getContents();

		$this->assertStringContainsString(
			'<meta http-equiv="content-language" content="en-x-piglatin"/>',
			$data
		);
	}

	public function testWt2html_NonParsoidContentModel() {
		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, new JavaScriptContent( '"not wikitext"' ) );
		$pageConfig = $this->getPageConfig( $page );

		$attribs = self::DEFAULT_ATTRIBS;
		$attribs['opts']['from'] = 'wikitext';
		// Asking for a 'pagebundle' here because of T325137.
		$attribs['opts']['format'] = 'pagebundle';

		$handler = $this->newParsoidHandler();
		$response = $handler->wt2html( $pageConfig, $attribs );

		$this->assertSame( 200, $response->getStatusCode() );

		$body = $response->getBody();
		$body->rewind();
		$data = $body->getContents();

		$jsonData = json_decode( $data, JSON_OBJECT_AS_ARRAY );

		$this->assertIsArray( $jsonData );
		$this->assertStringContainsString( "not wikitext", $jsonData['html']['body'] );
	}

	// TODO: test wt2html failure modes
	// TODO: test redlinks

	public function createLanguageMock( string $code ) {
		// Ensure that we always return the same object for a given code.
		static $seen = [];
		if ( !isset( $seen[$code] ) ) {
			$langMock = $this->createMock( Language::class );
			$langMock
				->method( 'getCode' )
				->willReturn( $code );
			$bcp47 = LanguageCode::bcp47( $code );
			$langMock
				->method( 'getHtmlCode' )
				->willReturn( $bcp47 );
			$langMock
				->method( 'toBcp47Code' )
				->willReturn( $bcp47 );
			$langMock
				->method( 'getDir' )
				->willReturn( 'ltr' );
			$seen[$code] = $langMock;
		}
		return $seen[$code];
	}

}
PK       ! Cd  d  &  Rest/Handler/ModuleSpecHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use JsonSchemaAssertionTrait;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\Rest\BasicAccess\StaticBasicAuthorizer;
use MediaWiki\Rest\Handler;
use MediaWiki\Rest\Handler\ModuleSpecHandler;
use MediaWiki\Rest\Reporter\MWErrorReporter;
use MediaWiki\Rest\RequestData;
use MediaWiki\Rest\RequestInterface;
use MediaWiki\Rest\ResponseFactory;
use MediaWiki\Rest\Router;
use MediaWiki\Rest\Validator\Validator;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Constraint\Constraint;
use Wikimedia\Message\ITextFormatter;
use Wikimedia\Message\MessageSpecifier;
use Wikimedia\ParamValidator\ParamValidator;

/**
 * @covers \MediaWiki\Rest\Handler\ModuleSpecHandler
 *
 * @group Database
 */
class ModuleSpecHandlerTest extends MediaWikiIntegrationTestCase {
	use HandlerTestTrait;
	use JsonSchemaAssertionTrait;

	private function createRouter(
		RequestInterface $request,
		$specFile
	): Router {
		$services = $this->getServiceContainer();

		$conf = $services->getMainConfig();

		$authority = $this->mockRegisteredUltimateAuthority();
		$authorizer = new StaticBasicAuthorizer();

		$objectFactory = $services->getObjectFactory();
		$restValidator = new Validator( $objectFactory,
			$request,
			$authority
		);

		$formatter = new class implements ITextFormatter {
			public function getLangCode() {
				return 'qqx';
			}

			public function format( MessageSpecifier $message ): string {
				return $message->dump();
			}
		};
		$responseFactory = new ResponseFactory( [ $formatter ] );

		return ( new Router(
			[ $specFile ],
			[],
			new ServiceOptions( Router::CONSTRUCTOR_OPTIONS, $conf ),
			$services->getLocalServerObjectCache(),
			$responseFactory,
			$authorizer,
			$authority,
			$objectFactory,
			$restValidator,
			new MWErrorReporter(),
			$services->getHookContainer(),
			$this->getSession( true )
		) );
	}

	private function newHandler() {
		$config = $this->getServiceContainer()->getMainConfig();
		return new ModuleSpecHandler(
			$config
		);
	}

	private function assertWellFormedOAS( array $spec ) {
		$this->assertMatchesJsonSchema(
			__DIR__ . '/data/OpenApi-3.0.json',
			$spec
		);
	}

	private static function assertContainsRecursive(
		array $expected,
		array $actual,
		string $message = ''
	) {
		foreach ( $expected as $key => $value ) {
			Assert::assertArrayHasKey( $key, $actual, $message );

			if ( is_array( $value ) ) {
				Assert::assertIsArray( $actual[$key], $message );

				self::assertContainsRecursive( $value, $actual[$key], $message );
			} elseif ( $value instanceof Constraint ) {
				$value->evaluate( $actual[$key], $message );
			} else {
				Assert::assertSame( $value, $actual[$key], $message );
			}
		}
	}

	public static function provideGetInfoSpecSuccess() {
		yield 'module and version' => [
			__DIR__ . '/SpecTestModule.json',
			[
				'pathParams' => [ 'module' => 'mock', 'version' => 'v1' ]
			],
			[
				'info' => [
					'title' => 'mock/v1 Module',
					'version' => '1.3-test',
					'contact' => [
						'email' => 'test@example.com'
					],
				],
				'servers' => [
					[ 'url' => 'https://example.com:1234/api/mock/v1' ]
				],
				'paths' => [
					'/foo/bar' => [
						'get' => [
							'parameters' => [ [ 'name' => 'q', 'in' => 'query' ] ],
							'responses' => [ 200 => [ 'description' => 'OK' ] ]
						],
						'post' => [
							'requestBody' => [
								'required' => true,
								'content' => [
									'application/json' => [
										'schema' => [
											'type' => 'object',
											'required' => [ 'b' ],
											'properties' => [
												'b' => [ 'type' => 'string' ]
											],
										]
									]
								]
							],
							'responses' => [ 200 => [ 'description' => 'OK' ] ]
						],
					]
				],
				'components' => [
					'schemas' => [
						'boolean-param' => [ 'type' => 'boolean' ],
					],
					'responses' => [
						'GenericErrorResponse' => self::anything(),
					],
				]
			]
		];
		yield 'prefix-less module' => [
			__DIR__ . '/SpecTestFlatRoutes.json',
			[
				'pathParams' => [ 'module' => '-' ]
			],
			[
				'info' => [
					'title' => 'Extra Routes',
					'version' => 'undefined',
					'license' => [
						'name' => 'Test License',
						'url' => 'https://example.com/license',
					],
				],
				'servers' => [
					[ 'url' => 'https://example.com:1234/api' ]
				],
				'paths' => [
					'/mock/v1/foo/bar' => [
						'get' => [ 'responses' => [ 200 => [ 'description' => 'OK' ] ] ],
					]
				],
			]
		];
	}

	/**
	 * @dataProvider provideGetInfoSpecSuccess
	 */
	public function testGetInfoSpecSuccess( $specFile, $params, $expected ) {
		$this->overrideConfigValues( [
			MainConfigNames::RightsText => 'Test License',
			MainConfigNames::RightsUrl => 'https://example.com/license',
			MainConfigNames::EmergencyContact => 'test@example.com',
			MainConfigNames::CanonicalServer => 'https://example.com:1234',
			MainConfigNames::RestPath => '/api',
		] );

		$request = new RequestData( $params );

		$router = $this->createRouter( $request, $specFile );

		$handler = $this->newHandler();
		$response = $this->executeHandler(
			$handler,
			$request,
			[],
			[],
			[],
			[],
			null,
			null,
			$router
		);
		$this->assertSame( 200, $response->getStatusCode() );
		$this->assertArrayHasKey( 'Content-Type', $response->getHeaders() );
		$this->assertSame( 'application/json', $response->getHeaderLine( 'Content-Type' ) );
		$data = json_decode( (string)$response->getBody(), true );

		$this->assertIsArray( $data, 'Body must be a JSON array' );
		$this->assertWellFormedOAS( $data );
		$this->assertContainsRecursive( $expected, $data );
	}

	public static function newFooBarHandler() {
		return new class extends Handler {
			public function getParamSettings() {
				return [
					'q' => [
						Handler::PARAM_SOURCE => 'query',
						ParamValidator::PARAM_REQUIRED => 'false',
					],
				];
			}

			public function getBodyParamSettings(): array {
				return [
					'b' => [
						Handler::PARAM_SOURCE => 'body',
						ParamValidator::PARAM_REQUIRED => 'true',
					],
				];
			}

			public function execute() {
				return 'foo bar';
			}
		};
	}

}
PK       !     &  Rest/Handler/PageSourceHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use Exception;
use MediaWiki\Content\TextContent;
use MediaWiki\MainConfigNames;
use MediaWiki\Rest\Handler\PageSourceHandler;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\RequestData;
use MediaWiki\Revision\SlotRecord;
use MediaWikiIntegrationTestCase;
use Wikimedia\Message\MessageValue;
use WikiPage;

/**
 * @covers \MediaWiki\Rest\Handler\PageSourceHandler
 * @group Database
 */
class PageSourceHandlerTest extends MediaWikiIntegrationTestCase {
	use HandlerTestTrait;
	use PageHandlerTestTrait;

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

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

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

		$this->overrideConfigValues( [
			MainConfigNames::RightsUrl => 'https://example.com/rights',
			MainConfigNames::RightsText => 'some rights',
		] );
	}

	/**
	 * @return PageSourceHandler
	 * @throws Exception
	 */
	private function newHandler(): PageSourceHandler {
		return $this->newPageSourceHandler();
	}

	public function testExecuteBare() {
		$page = $this->getExistingTestPage( 'Talk:SourceEndpointTestPage/with/slashes' );
		$request = new RequestData(
			[ 'pathParams' => [ 'title' => $page->getTitle()->getPrefixedText() ] ]
		);

		$htmlUrl = 'https://wiki.example.com/rest/mock/page/Talk%3ASourceEndpointTestPage%2Fwith%2Fslashes/html';

		$handler = $this->newHandler();
		$config = [ 'format' => 'bare' ];
		$data = $this->executeHandlerAndGetBodyData( $handler, $request, $config );

		$this->assertResponseData( $page, $data );
		$this->assertSame( $htmlUrl, $data['html_url'] );
	}

	public function testExecuteSource() {
		$page = $this->getExistingTestPage( 'Talk:SourceEndpointTestPage/with/slashes' );
		$request = new RequestData(
			[ 'pathParams' => [ 'title' => $page->getTitle()->getPrefixedText() ] ]
		);

		$handler = $this->newHandler();
		$config = [ 'format' => 'source' ];
		$data = $this->executeHandlerAndGetBodyData( $handler, $request, $config );

		/** @var TextContent $content */
		$content = $page->getRevisionRecord()->getContent( SlotRecord::MAIN );

		$this->assertResponseData( $page, $data );
		$this->assertSame( $content->getText(), $data['source'] );
	}

	public function testExecuteRestbaseCompat() {
		$page = $this->getExistingTestPage( 'Talk:SourceEndpointTestPage/with/slashes' );
		$request = new RequestData(
			[
				'pathParams' => [ 'title' => $page->getTitle()->getPrefixedText() ],
				'headers' => [ 'x-restbase-compat' => 'true' ]
			]

		);

		$htmlUrl = 'https://wiki.example.com/rest/mock/page/Talk%3ASourceEndpointTestPage%2Fwith%2Fslashes/html';

		$handler = $this->newHandler();
		$config = [ 'format' => 'bare' ];
		$data = $this->executeHandlerAndGetBodyData( $handler, $request, $config );

		$this->assertRestbaseCompatibleResponseData( $page, $data );
	}

	public function testExecute_missingparam() {
		$request = new RequestData();

		$this->expectExceptionObject(
			new LocalizedHttpException(
				new MessageValue( "paramvalidator-missingparam", [ 'title' ] ),
				400
			)
		);

		$handler = $this->newHandler();
		$this->executeHandler( $handler, $request );
	}

	public function testExecute_error() {
		$request = new RequestData( [ 'pathParams' => [ 'title' => 'DoesNotExist8237456assda1234' ] ] );

		$this->expectExceptionObject(
			new LocalizedHttpException(
				new MessageValue( "rest-nonexistent-title", [ 'testing' ] ),
				404
			)
		);

		$handler = $this->newHandler();
		$config = [ 'format' => 'bare' ];
		$this->executeHandler( $handler, $request, $config );
	}

	public function testExecute_message() {
		$request = new RequestData( [ 'pathParams' => [ 'title' => 'MediaWiki:Ok' ] ] );

		$this->expectExceptionObject(
			new LocalizedHttpException(
				new MessageValue( "rest-nonexistent-title", [ 'testing' ] ),
				404
			)
		);

		$handler = $this->newHandler();
		$config = [ 'format' => 'bare' ];
		$this->executeHandler( $handler, $request, $config );
	}

	/**
	 * @param WikiPage $page
	 * @param array $data
	 */
	private function assertResponseData( WikiPage $page, array $data ): void {
		$this->assertSame( $page->getId(), $data['id'] );
		$this->assertSame( $page->getTitle()->getPrefixedDBkey(), $data['key'] );
		$this->assertSame( $page->getTitle()->getPrefixedText(), $data['title'] );
		$this->assertSame( $page->getLatest(), $data['latest']['id'] );
		$this->assertSame(
			wfTimestampOrNull( TS_ISO_8601, $page->getTimestamp() ),
			$data['latest']['timestamp']
		);
		$this->assertSame( CONTENT_MODEL_WIKITEXT, $data['content_model'] );
		$this->assertSame( 'https://example.com/rights', $data['license']['url'] );
		$this->assertSame( 'some rights', $data['license']['title'] );
	}

	/**
	 * @param WikiPage $page
	 * @param array $data
	 */
	private function assertRestbaseCompatibleResponseData( WikiPage $page, array $data ): void {
		$this->assertArrayHasKey( 'items', $data );
		$this->assertSame( $page->getTitle()->getPrefixedDBkey(), $data['items'][0]['title'] );
		$this->assertSame( $page->getId(), $data['items'][0]['page_id'] );
		$this->assertSame( $page->getLatest(), $data['items'][0]['rev'] );
		$this->assertSame( $page->getNamespace(), $data['items'][0]['namespace'] );
		$this->assertSame( $page->getUser(), $data['items'][0]['user_id'] );
		$this->assertSame( $page->getUserText(), $data['items'][0]['user_text'] );
		$this->assertSame(
			wfTimestampOrNull( TS_ISO_8601, $page->getTimestamp() ),
			$data['items'][0]['timestamp']
		);
		$this->assertSame( $page->getComment(), $data['items'][0]['comment'] );
		$this->assertSame( [], $data['items'][0]['tags'] );
		$this->assertSame( [], $data['items'][0]['restrictions'] );
		$this->assertSame(
			$page->getTitle()->getPageLanguage()->getCode(),
			$data['items'][0]['page_language']
		);
		$this->assertSame( $page->isRedirect(), $data['items'][0]['redirect'] );
	}

}
PK       ! tM    1  Rest/Handler/OpenSearchDescriptionHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use MediaWiki\Config\HashConfig;
use MediaWiki\MainConfigNames;
use MediaWiki\MainConfigSchema;
use MediaWiki\Rest\Handler\OpenSearchDescriptionHandler;
use MediaWiki\Rest\RequestData;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Rest\Handler\OpenSearchDescriptionHandler
 */
class OpenSearchDescriptionHandlerTest extends MediaWikiIntegrationTestCase {

	use HandlerTestTrait;

	private function newHandler() {
		$config = new HashConfig( [
			MainConfigNames::Favicon => MainConfigSchema::getDefaultValue(
				MainConfigNames::Favicon
			),
			MainConfigNames::OpenSearchTemplates => MainConfigSchema::getDefaultValue(
				MainConfigNames::OpenSearchTemplates
			),
		] );
		$urlUtils = $this->getServiceContainer()->getUrlUtils();

		$handler = new OpenSearchDescriptionHandler( $config, $urlUtils );
		return $handler;
	}

	public function testOpenSearchDescription() {
		$req = new RequestData( [] );
		$handler = $this->newHandler( $req );

		$resp = $this->executeHandler( $handler, $req );
		$this->assertSame(
			'application/opensearchdescription+xml',
			$resp->getHeaderLine( 'content-type' )
		);

		$xml = (string)$resp->getBody();
		$this->assertMatchesRegularExpression( '!^<\?xml!', $xml );
		$this->assertMatchesRegularExpression( '!<OpenSearchDescription!', $xml );
		$this->assertMatchesRegularExpression( '!<Url type="text/html" method="get" template=!', $xml );
	}

	// TODO: write tests for wgOpenSearchTemplates and the OpenSearchUrls hook.
}
PK       ! in    %  Rest/Handler/HTMLHandlerTestTrait.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use Exception;
use MediaWiki\Block\BlockErrorFormatter;
use MediaWiki\Context\IContextSource;
use MediaWiki\Edit\ParsoidOutputStash;
use MediaWiki\Edit\ParsoidRenderID;
use MediaWiki\Edit\SimpleParsoidOutputStash;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\UserAuthority;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Rest\RequestData;
use MediaWiki\User\User;
use Wikimedia\ObjectCache\HashBagOStuff;
use WikiPage;

/**
 * This trait is used in PageHTMLHandlerTest.php & RevisionHTMLHandlerTest.php
 * to construct requests and perform stashing for the Parsoid Output stash feature.
 */
trait HTMLHandlerTestTrait {

	/** @var ParsoidOutputStash|null */
	private $parsoidOutputStash = null;

	private function getParsoidOutputStash(): ParsoidOutputStash {
		if ( !$this->parsoidOutputStash ) {
			$chFactory = $this->getServiceContainer()->getContentHandlerFactory();
			$this->parsoidOutputStash = new SimpleParsoidOutputStash( $chFactory, new HashBagOStuff(), 120 );
		}
		return $this->parsoidOutputStash;
	}

	private function getAuthority(): Authority {
		$services = $this->getServiceContainer();
		return new UserAuthority(
		// We need a newly created user because we want IP and newbie to apply.
			new User(),
			new FauxRequest(),
			$this->createMock( IContextSource::class ),
			$services->getPermissionManager(),
			$services->getRateLimiter(),
			$this->createMock( BlockErrorFormatter::class )
		);
	}

	/**
	 * @param WikiPage $page
	 * @param array $queryParams
	 * @param array $config
	 *
	 * @return array
	 * @throws Exception
	 */
	private function executePageHTMLRequest(
		WikiPage $page,
		array $queryParams = [],
		array $config = [],
		?Authority $authority = null
	): array {
		$handler = $this->newHandler();
		$request = new RequestData( [
			'pathParams' => [ 'title' => $page->getTitle()->getPrefixedDBkey() ],
			'queryParams' => $queryParams,
		] );
		$result = $this->executeHandler(
			$handler,
			$request,
			$config + [ 'format' => 'html' ],
			[],
			[],
			[],
			$authority
		);
		$etag = $result->getHeaderLine( 'ETag' );
		$stashKey = ParsoidRenderID::newFromETag( $etag );

		return [ $result->getBody()->getContents(), $etag, $stashKey ];
	}

	/**
	 * @param int $revId
	 * @param array $queryParams
	 * @param array $config
	 *
	 * @return array
	 * @throws Exception
	 */
	private function executeRevisionHTMLRequest(
		int $revId,
		array $queryParams = [],
		array $config = [],
		?Authority $authority = null
	): array {
		$handler = $this->newHandler();
		$request = new RequestData( [
			'pathParams' => [ 'id' => $revId ],
			'queryParams' => $queryParams,
		] );
		$result = $this->executeHandler(
			$handler,
			$request,
			$config + [ 'format' => 'html' ],
			[],
			[],
			[],
			$authority
		);
		$etag = $result->getHeaderLine( 'ETag' );
		$stashKey = ParsoidRenderID::newFromETag( $etag );

		return [ $result->getBody()->getContents(), $etag, $stashKey ];
	}
}
PK       ! vSi       Rest/Handler/SpecTestModule.jsonnu Iw        {
	"mwapi": "1.0.0",
	"moduleId": "mock/v1",
	"info": {
		"version": "1.3-test"
	},
	"paths": {
		"/foo/bar": {
			"get": {
				"handler": {
					"factory": "MediaWiki\\Tests\\Rest\\Handler\\ModuleSpecHandlerTest::newFooBarHandler"
				}
			},
			"post": {
				"handler": {
					"factory": "MediaWiki\\Tests\\Rest\\Handler\\ModuleSpecHandlerTest::newFooBarHandler"
				}
			}
		}
	}
}
PK       ! r      $  Rest/Handler/SpecTestFlatRoutes.jsonnu Iw        [
	{
		"path": "/mock/v1/foo/bar",
		"factory": "MediaWiki\\Tests\\Rest\\Handler\\ModuleSpecHandlerTest::newFooBarHandler"
	}
]
PK       ! IG  G  (  Rest/Handler/PageRedirectHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

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

/**
 * @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 HashBagOStuff $parserCacheBagOStuff;

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

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

	private function getHandler( $name, RequestInterface $request ) {
		switch ( $name ) {
			case 'source':
			case 'bare':
				return $this->newPageSourceHandler();
			case 'html':
			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 {
		if ( preg_match( '/\?(.*?)(#.*)?$/', $url, $m ) ) {
			$urlParameters = wfCgiToArray( $m[1] );
		} else {
			$urlParameters = [];
		}
		$this->assertArrayEquals( $queryParams, $urlParameters );
	}
}
PK       ! Fɋ?  ?  $  Rest/Handler/CreationHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use MediaWiki\Api\ApiUsageException;
use MediaWiki\Config\HashConfig;
use MediaWiki\Content\WikitextContent;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Rest\Handler\CreationHandler;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\RequestData;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Session\Token;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWikiIntegrationTestCase;
use MockTitleTrait;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\Message\DataMessageValue;
use Wikimedia\Message\MessageValue;
use Wikimedia\Message\ParamType;
use Wikimedia\Message\ScalarParam;

/**
 * @covers \MediaWiki\Rest\Handler\CreationHandler
 */
class CreationHandlerTest extends MediaWikiIntegrationTestCase {
	use ActionModuleBasedHandlerTestTrait;
	use DummyServicesTrait;
	use MockTitleTrait;

	private function newHandler( $resultData, $throwException = null, $csrfSafe = false ) {
		$config = new HashConfig( [
			MainConfigNames::RightsUrl => 'https://creativecommons.org/licenses/by-sa/4.0/',
			MainConfigNames::RightsText => 'CC-BY-SA 4.0'
		] );

		// Claims that wikitext and plaintext are defined, but trying to get the actual
		// content handlers would break
		$contentHandlerFactory = $this->getDummyContentHandlerFactory( [
			CONTENT_MODEL_WIKITEXT => true,
			CONTENT_MODEL_TEXT => true,
		] );

		$titleCodec = $this->getDummyMediaWikiTitleCodec();

		/** @var RevisionLookup|MockObject $revisionLookup */
		$revisionLookup = $this->createNoOpMock( RevisionLookup::class, [ 'getRevisionById' ] );
		$revisionLookup->method( 'getRevisionById' )
			->willReturnCallback( function ( $id ) {
				$title = $this->makeMockTitle( __CLASS__ );
				$rev = new MutableRevisionRecord( $title );
				$rev->setId( $id );
				$rev->setContent( SlotRecord::MAIN, new WikitextContent( "Content of revision $id" ) );
				return $rev;
			} );

		$handler = new CreationHandler(
			$config,
			$contentHandlerFactory,
			$titleCodec,
			$titleCodec,
			$revisionLookup
		);

		$apiMain = $this->getApiMain( $csrfSafe );
		$dummyModule = $this->getDummyApiModule( $apiMain, 'edit', $resultData, $throwException );

		$handler->setApiMain( $apiMain );
		$handler->overrideActionModule(
			'edit',
			'action',
			$dummyModule
		);

		return $handler;
	}

	public static function provideExecute() {
		// NOTE: Prefix hard coded in a fake for Router::getRouteUrl() in HandlerTestTrait
		$baseUrl = 'https://wiki.example.com/rest/v1/page/';
		$token = strval( new Token( 'TOKEN', '' ) );

		yield "create with token" => [
			[ // Request data received by CreationHandler
				'method' => 'POST',
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'token' => $token,
					'title' => 'Foo',
					'source' => 'Lorem Ipsum',
					'comment' => 'Testing'
				] ),
			],
			[ // Fake request expected to be passed into ApiEditPage
				'title' => 'Foo',
				'text' => 'Lorem Ipsum',
				'summary' => 'Testing',
				'createonly' => '1',
			],
			[ // Mock response returned by ApiEditPage
				"edit" => [
					"new" => true,
					"result" => "Success",
					"pageid" => 94542,
					"title" => "Foo",
					"contentmodel" => "wikitext",
					"oldrevid" => 0,
					"newrevid" => 371707,
					"newtimestamp" => "2018-12-18T16:59:42Z",
				]
			],
			[ // Response expected to be generated by CreationHandler
				'id' => 94542,
				'title' => 'Foo',
				'key' => 'Foo',
				'content_model' => 'wikitext',
				'latest' => [
					'id' => 371707,
					'timestamp' => "2018-12-18T16:59:42Z"
				],
				'license' => [
					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
					'title' => 'CC-BY-SA 4.0'
				],
				'source' => 'Content of revision 371707'
			],
			$baseUrl . 'Foo',
			false,
			true,
		];

		yield "create with model" => [
			[ // Request data received by CreationHandler
				'method' => 'POST',
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'title' => 'Talk:Foo',
					'source' => 'Lorem Ipsum',
					'comment' => 'Testing',
					'content_model' => CONTENT_MODEL_TEXT,
				] ),
			],
			[ // Fake request expected to be passed into ApiEditPage
				'title' => 'Talk:Foo',
				'text' => 'Lorem Ipsum',
				'summary' => 'Testing',
				'contentmodel' => CONTENT_MODEL_TEXT,
				'createonly' => '1',
				'token' => '+\\',
			],
			[ // Mock response returned by ApiEditPage
				"edit" => [
					"new" => true,
					"result" => "Success",
					"pageid" => 94542,
					"title" => "Talk:Foo",
					"contentmodel" => CONTENT_MODEL_TEXT,
					"oldrevid" => 0,
					"newrevid" => 371707,
					"newtimestamp" => "2018-12-18T16:59:42Z",
				]
			],
			[ // Response expected to be generated by CreationHandler
				'id' => 94542,
				'title' => 'Talk:Foo',
				'key' => 'Talk:Foo',
				'content_model' => CONTENT_MODEL_TEXT,
				'latest' => [
					'id' => 371707,
					'timestamp' => "2018-12-18T16:59:42Z"
				],
				'license' => [
					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
					'title' => 'CC-BY-SA 4.0'
				],
				'source' => 'Content of revision 371707'
			],
			$baseUrl . 'Talk:Foo',
			true,
			false,
		];

		yield "create without token" => [
			[ // Request data received by CreationHandler
				'method' => 'POST',
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'title' => 'foo/bar',
					'source' => 'Lorem Ipsum',
					'comment' => 'Testing',
					'content_model' => CONTENT_MODEL_WIKITEXT,
				] ),
			],
			[ // Fake request expected to be passed into ApiEditPage
				'title' => 'foo/bar',
				'text' => 'Lorem Ipsum',
				'summary' => 'Testing',
				'contentmodel' => 'wikitext',
				'createonly' => '1',
				'token' => '+\\', // use known-good token for current user (anon)
			],
			[ // Mock response returned by ApiEditPage
				"edit" => [
					"new" => true,
					"result" => "Success",
					"pageid" => 94542,
					"title" => "Foo/bar",
					"contentmodel" => "wikitext",
					"oldrevid" => 0,
					"newrevid" => 371707,
					"newtimestamp" => "2018-12-18T16:59:42Z",
				]
			],
			[ // Response expected to be generated by CreationHandler
				'id' => 94542,
				'title' => 'Foo/bar',
				'key' => 'Foo/bar',
				'content_model' => 'wikitext',
				'latest' => [
					'id' => 371707,
					'timestamp' => "2018-12-18T16:59:42Z"
				],
				'license' => [
					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
					'title' => 'CC-BY-SA 4.0'
				],
				'source' => 'Content of revision 371707'
			],
			$baseUrl . 'Foo%2Fbar',
			true,
			false,
		];

		yield "create with space" => [
			[ // Request data received by CreationHandler
				'method' => 'POST',
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'title' => 'foo (ba+r)',
					'source' => 'Lorem Ipsum',
					'comment' => 'Testing'
				] ),
			],
			[ // Fake request expected to be passed into ApiEditPage
				'title' => 'foo (ba+r)',
				'text' => 'Lorem Ipsum',
				'summary' => 'Testing',
				'createonly' => '1',
				'token' => '+\\', // use known-good token for current user (anon)
			],
			[ // Mock response returned by ApiEditPage
				"edit" => [
					"new" => true,
					"result" => "Success",
					"pageid" => 94542,
					"title" => "Foo (ba+r)",
					"contentmodel" => "wikitext",
					"oldrevid" => 0,
					"newrevid" => 371707,
					"newtimestamp" => "2018-12-18T16:59:42Z",
				]
			],
			[ // Response expected to be generated by CreationHandler
				'id' => 94542,
				'title' => 'Foo (ba+r)',
				'key' => 'Foo_(ba+r)',
				'content_model' => 'wikitext',
				'latest' => [
					'id' => 371707,
					'timestamp' => "2018-12-18T16:59:42Z"
				],
				'license' => [
					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
					'title' => 'CC-BY-SA 4.0'
				],
				'source' => 'Content of revision 371707'
			],
			$baseUrl . 'Foo_(ba%2Br)',
			true,
			false
		];
	}

	/**
	 * @dataProvider provideExecute
	 */
	public function testExecute(
		$requestData,
		$expectedActionParams,
		$actionResult,
		$expectedResponse,
		$expectedRedirect,
		$csrfSafe,
		$hasToken
	) {
		$request = new RequestData( $requestData );

		$handler = $this->newHandler( $actionResult, null, $csrfSafe );

		$session = $this->getSession( $csrfSafe );

		$session->method( 'hasToken' )->willReturn( $hasToken );

		$session->method( 'getToken' )->willReturn( new Token( 'TOKEN', '' ) );

		$response = $this->executeHandler( $handler, $request, [], [], [], [], null, $session );

		$this->assertSame( 201, $response->getStatusCode() );
		$this->assertSame(
			$expectedRedirect,
			$response->getHeaderLine( 'Location' )
		);
		$this->assertSame( 'application/json', $response->getHeaderLine( 'Content-Type' ) );

		$responseData = json_decode( $response->getBody(), true );
		$this->assertIsArray( $responseData, 'Body must be a JSON array' );

		// Check parameters passed to ApiEditPage by CreationHandler based on $requestData
		foreach ( $expectedActionParams as $key => $value ) {
			$this->assertSame(
				$value,
				$handler->getApiMain()->getVal( $key ),
				"ApiEditPage param: $key"
			);
		}

		// Check response that CreationHandler created after receiving $actionResult from ApiEditPage
		foreach ( $expectedResponse as $key => $value ) {
			$this->assertArrayHasKey( $key, $responseData );
			$this->assertSame(
				$value,
				$responseData[ $key ],
				"CreationHandler response field: $key"
			);
		}
	}

	public static function provideBodyValidation() {
		yield "missing source field" => [
			[ // Request data received by CreationHandler
				'method' => 'POST',
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'token' => 'TOKEN',
					'title' => 'Foo',
					'comment' => 'Testing',
					'content_model' => CONTENT_MODEL_WIKITEXT,
				] ),
			],
			MessageValue::new( 'rest-body-validation-error', [
				DataMessageValue::new( 'paramvalidator-missingparam', [], 'missingparam' )
					->plaintextParams( 'source' )
			] ),
		];
		yield "missing comment field" => [
			[ // Request data received by CreationHandler
				'method' => 'POST',
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'token' => 'TOKEN',
					'title' => 'Foo',
					'source' => 'Lorem Ipsum',
					'content_model' => CONTENT_MODEL_WIKITEXT,
				] ),
			],
			MessageValue::new( 'rest-body-validation-error', [
				DataMessageValue::new( 'paramvalidator-missingparam', [], 'missingparam' )
					->plaintextParams( 'comment' )
			] ),
		];
		yield "missing title field" => [
			[ // Request data received by CreationHandler
				'method' => 'POST',
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'token' => 'TOKEN',
					'comment' => 'Testing',
					'source' => 'Lorem Ipsum',
					'content_model' => CONTENT_MODEL_WIKITEXT,
				] ),
			],
			MessageValue::new( 'rest-body-validation-error', [
				DataMessageValue::new( 'paramvalidator-missingparam', [], 'missingparam' )
					->plaintextParams( 'title' )
			] ),
		];
	}

	/**
	 * @dataProvider provideBodyValidation
	 */
	public function testBodyValidation( array $requestData, MessageValue $expectedMessage ) {
		$request = new RequestData( $requestData );

		$handler = $this->newHandler( [] );

		$exception = $this->executeHandlerAndGetHttpException( $handler, $request );

		$this->assertSame( 400, $exception->getCode(), 'HTTP status' );
		$this->assertInstanceOf( LocalizedHttpException::class, $exception );

		/** @var LocalizedHttpException $exception */
		$this->assertEquals( $expectedMessage, $exception->getMessageValue() );
	}

	public static function provideHeaderValidation() {
		yield "bad content type" => [
			[ // Request data received by CreationHandler
				'method' => 'POST',
				'headers' => [
					'Content-Type' => 'text/plain',
				],
				'bodyContents' => json_encode( [
					'title' => 'Foo',
					'source' => 'Lorem Ipsum',
					'comment' => 'Testing',
					'content_model' => CONTENT_MODEL_WIKITEXT,
				] ),
			],
			415
		];
	}

	/**
	 * @dataProvider provideHeaderValidation
	 */
	public function testHeaderValidation( array $requestData, $expectedStatus ) {
		$request = new RequestData( $requestData );

		$handler = $this->newHandler( [] );

		$exception = $this->executeHandlerAndGetHttpException( $handler, $request );

		$this->assertSame( $expectedStatus, $exception->getCode(), 'HTTP status' );
	}

	/*
	 * FIXME: Status::newFatal invokes MediaWikiServices, which is not allowed in a dataProvider.
	 */
	public static function provideErrorMapping() {
		yield "missingtitle" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-missingtitle' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-missingtitle' ), 404 ),
		];
		yield "protectedpage" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-protectedpage' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-protectedpage' ), 403 ),
		];
		yield "articleexists" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-articleexists' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-articleexists' ), 409 ),
		];
		yield "editconflict" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-editconflict' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-editconflict' ), 409 ),
		];
		yield "ratelimited" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-ratelimited' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-ratelimited' ), 429 ),
		];
		yield "badtoken" => [
			new ApiUsageException(
				null,
				Status::newFatal( 'apierror-badtoken', Message::plaintextParam( 'BAD' ) )
			),
			new LocalizedHttpException(
				new MessageValue(
					'apierror-badtoken',
					[ new ScalarParam( ParamType::PLAINTEXT, 'BAD' ) ]
				), 403
			),
		];

		// Unmapped errors should be passed through with a status 400.
		yield "no-direct-editing" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-no-direct-editing' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-no-direct-editing' ), 400 ),
		];
		yield "badformat" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-badformat' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-badformat' ), 400 ),
		];
		yield "emptypage" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-emptypage' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-emptypage' ), 400 ),
		];
	}

	public function testErrorMapping() {
		$provideErrorMapping = $this->provideErrorMapping();
		foreach ( $provideErrorMapping as $expected ) {
			$apiUsageException = $expected[0];
			$expectedHttpException = $expected[1];
			$requestData = [ // Request data received by CreationHandler
				'method' => 'POST',
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'title' => 'Foo',
					'source' => 'Lorem Ipsum',
					'comment' => 'Testing',
					'content_model' => CONTENT_MODEL_WIKITEXT,
				] ),
			];
			$request = new RequestData( $requestData );

			$handler = $this->newHandler( [], $apiUsageException );

			$exception = $this->executeHandlerAndGetHttpException( $handler, $request );

			$this->assertSame( $expectedHttpException->getMessage(), $exception->getMessage() );
			$this->assertSame( $expectedHttpException->getCode(), $exception->getCode(), 'HTTP status' );

			$errorData = $exception->getErrorData();
			if ( $expectedHttpException->getErrorData() ) {
				foreach ( $expectedHttpException->getErrorData() as $key => $value ) {
					$this->assertSame( $value, $errorData[$key], 'Error data key $key' );
				}
			}

			if ( $expectedHttpException instanceof LocalizedHttpException ) {
				/** @var LocalizedHttpException $exception */
				$this->assertEquals(
					$expectedHttpException->getMessageValue(),
					$exception->getMessageValue()
				);
			}
		}
	}

}
PK       ! {
:  :  &  Rest/Handler/MediaLinksHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Rest\Handler\MediaLinksHandler;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\RequestData;
use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;
use Wikimedia\Message\MessageValue;

/**
 * @covers \MediaWiki\Rest\Handler\MediaLinksHandler
 *
 * @group Database
 */
class MediaLinksHandlerTest extends MediaWikiIntegrationTestCase {

	use MediaTestTrait;

	public function addDBDataOnce() {
		$this->editPage( __CLASS__ . '_Foo', 'Foo [[Image:Existing.jpg]] [[Image:Missing.jpg]]' );
	}

	private function newHandler() {
		return new MediaLinksHandler(
			$this->getServiceContainer()->getConnectionProvider(),
			$this->makeMockRepoGroup( [ 'Existing.jpg' ] ),
			$this->getServiceContainer()->getPageStore()
		);
	}

	private function assertLink( $expected, $actual ) {
		foreach ( $expected as $key => $value ) {
			$this->assertArrayHasKey( $key, $actual );
			$this->assertSame( $value, $actual[$key], $key );
		}
	}

	public function testExecute() {
		$title = __CLASS__ . '_Foo';
		$request = new RequestData( [ 'pathParams' => [ 'title' => $title ] ] );

		$user = RequestContext::getMain()->getUser();
		$userOptionsManager = $this->getServiceContainer()->getUserOptionsManager();
		$this->overrideConfigValue( MainConfigNames::ImageLimits, [
			$userOptionsManager->getIntOption( $user, 'imagesize' ) => [ 100, 100 ],
		] );

		$handler = $this->newHandler();
		$data = $this->executeHandlerAndGetBodyData( $handler, $request );

		$this->assertArrayHasKey( 'files', $data );
		$this->assertCount( 2, $data['files'] );

		$links = [];
		foreach ( $data['files'] as $row ) {
			$links[$row['title']] = $row;
		}

		$this->assertArrayHasKey( 'Existing.jpg', $links );
		$this->assertArrayHasKey( 'Missing.jpg', $links );

		// NOTE: See MediaTestTrait::makeMockFile() for hard-coded values.
		$this->assertLink( [
			'title' => 'Existing.jpg',
			// File repo mocks will end up calling File namespace ns6
			'file_description_url' => 'https://example.com/wiki/ns6:Existing.jpg',
			'latest' => [
				'timestamp' => '2020-01-02T03:04:05Z',
				'user' => [ 'id' => 7, 'name' => 'Alice' ]
			],
			'preferred' => [
				'mediatype' => 'test',
				'size' => null,
				'width' => 100,
				'height' => 67,
				'duration' => 678,
				'url' => 'https://media.example.com/static/thumb/Existing.jpg',
			],
			'original' => [
				'mediatype' => 'test',
				'size' => 12345,
				'width' => 600,
				'height' => 400,
				'duration' => 678,
				'url' => 'https://media.example.com/static/Existing.jpg',
			],
		], $links['Existing.jpg'] );

		// NOTE: MediaTestTrait::makeMockRepoGroup() treats files with "missing" in the
		// name as non-existent.
		$this->assertLink( [
			'title' => 'Missing.jpg',
			// File repo mocks will end up calling File namespace ns6
			'file_description_url' => 'https://example.com/wiki/ns6:Missing.jpg',
			'latest' => null,
			'preferred' => null,
			'original' => null,
		], $links['Missing.jpg'] );
	}

	public function testCacheControl() {
		$title = Title::newFromText( __METHOD__ );
		$this->editPage( $title, 'First' );

		$request = new RequestData( [ 'pathParams' => [ 'title' => $title->getPrefixedDBkey() ] ] );

		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request );

		$firstETag = $response->getHeaderLine( 'ETag' );
		$this->assertSame(
			wfTimestamp( TS_RFC2822, $title->getTouched() ),
			$response->getHeaderLine( 'Last-Modified' )
		);

		$this->editPage( $title, 'Second' );

		Title::clearCaches();
		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request );

		$this->assertNotEquals( $response->getHeaderLine( 'ETag' ), $firstETag );
		$this->assertSame(
			wfTimestamp( TS_RFC2822, $title->getTouched() ),
			$response->getHeaderLine( 'Last-Modified' )
		);
	}

	public function testExecute_notFound() {
		$title = __CLASS__ . '_Xyzzy';
		$request = new RequestData( [ 'pathParams' => [ 'title' => $title ] ] );

		$handler = $this->newHandler();

		$this->expectExceptionObject(
			new LocalizedHttpException( new MessageValue( 'rest-nonexistent-title' ), 404 )
		);
		$this->executeHandler( $handler, $request );
	}

	public function testMaxNumLinks() {
		$title = __CLASS__ . '_Foo';

		$handler = new class (
			$this->getServiceContainer()->getConnectionProvider(),
			$this->makeMockRepoGroup( [ 'Existing.jpg' ] ),
			$this->getServiceContainer()->getPageStore()
		) extends MediaLinksHandler {
			protected function getMaxNumLinks(): int {
				return 1;
			}
		};

		$request = new RequestData( [ 'pathParams' => [ 'title' => $title ] ] );

		$this->expectExceptionObject(
			new LocalizedHttpException( new MessageValue( 'rest-media-too-many-links' ), 400 )
		);

		$data = $this->executeHandlerAndGetBodyData( $handler, $request );
	}
}
PK       ! c<       Rest/Handler/SpecTestRoutes.jsonnu Iw        {
	"mwapi": "1.0.0",
	"moduleId": "mock/v1",
	"info": {
		"version": "1.0",
		"title": "test module"
	},
	"paths": {
		"/foo/bar": {
			"get": {
				"handler": {
					"factory": "MediaWiki\\Tests\\Rest\\Handler\\ModuleSpecHandlerTest::newFooBarHandler"
				}
			}
		}
	}
}
PK       !     %  Rest/Handler/MediaFileHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
use MediaWiki\Rest\Handler\MediaFileHandler;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\RequestData;
use MediaWiki\Title\Title;
use MediaWikiLangTestCase;
use Wikimedia\Message\MessageValue;

/**
 * @covers \MediaWiki\Rest\Handler\MediaFileHandler
 *
 * @group Database
 */
class MediaFileHandlerTest extends MediaWikiLangTestCase {

	use MediaTestTrait;

	public function addDBDataOnce() {
		$this->editPage( 'File:' . __CLASS__ . '.jpg', 'Test image description' );
	}

	private function newHandler() {
		return new MediaFileHandler(
			$this->makeMockRepoGroup( [ __CLASS__ . '.jpg' ] ),
			$this->getServiceContainer()->getPageStore()
		);
	}

	private function assertFile( $expected, $actual ) {
		foreach ( $expected as $key => $value ) {
			$this->assertArrayHasKey( $key, $actual );
			$this->assertSame( $value, $actual[$key], $key );
		}
	}

	public function testExecute() {
		// NOTE: "File:" namespace prefix is optional for title parameter.
		$title = __CLASS__ . '.jpg';
		$request = new RequestData( [ 'pathParams' => [ 'title' => $title ] ] );

		$user = RequestContext::getMain()->getUser();
		$userOptionsManager = $this->getServiceContainer()->getUserOptionsManager();
		$this->overrideConfigValue( MainConfigNames::ImageLimits, [
			$userOptionsManager->getIntOption( $user, 'imagesize' ) => [ 100, 100 ],
			$userOptionsManager->getIntOption( $user, 'thumbsize' ) => [ 20, 20 ],
		] );

		$handler = $this->newHandler();
		$data = $this->executeHandlerAndGetBodyData( $handler, $request );

		$this->assertFile(
			[
				'title' => $title,
				// File repo mocks will end up calling File namespace ns6
				'file_description_url' => 'https://example.com/wiki/ns6:' . $title,
				'latest' => [
					'timestamp' => '2020-01-02T03:04:05Z',
					'user' => [
						'id' => 7,
						'name' => 'Alice',
					],
				],
				'preferred' => [
					'mediatype' => 'test',
					'size' => null,
					'width' => 100,
					'height' => 67,
					'duration' => 678,
					'url' => 'https://media.example.com/static/thumb/' . $title,
				],
				'original' => [
					'mediatype' => 'test',
					'size' => 12345,
					'width' => 600,
					'height' => 400,
					'duration' => 678,
					'url' => 'https://media.example.com/static/' . $title,
				],
				'thumbnail' => [
					'mediatype' => 'test',
					'size' => null,
					'width' => 20,
					'height' => 13,
					'duration' => 678,
					'url' => 'https://media.example.com/static/thumb/' . $title,
				],
			],
			$data
		);
	}

	public function testCacheControl() {
		$title = 'File:' . __CLASS__ . '.jpg';
		$request = new RequestData( [ 'pathParams' => [ 'title' => $title ] ] );

		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request );

		// Mock image timestamp from MediaTestTrait::makeMockFile
		$this->assertSame(
			'Thu, 02 Jan 2020 03:04:05 GMT',
			$response->getHeaderLine( 'Last-Modified' )
		);
		// Mock image hash from MediaTestTrait::makeMockFile
		$this->assertSame(
			'"DEADBEEF"',
			$response->getHeaderLine( 'ETag' )
		);
	}

	public function testExecute_notFound() {
		// NOTE: MediaTestTrait::makeMockRepoGroup() will treat files with "missing" in
		// the name as non-existent.
		$title = __CLASS__ . '_Missing.png';
		$request = new RequestData( [ 'pathParams' => [ 'title' => $title ] ] );

		$handler = $this->newHandler();

		$this->expectExceptionObject(
			new LocalizedHttpException( new MessageValue( 'rest-nonexistent-title' ), 404 )
		);
		$this->executeHandler( $handler, $request );
	}

	public function testExecute_wrongNamespace() {
		$title = Title::newFromText( 'User:' . __CLASS__ . '.jpg' );
		$this->editPage( $title, 'First' );
		$request = new RequestData( [ 'pathParams' => [ 'title' => $title->getPrefixedDBkey() ] ] );

		$handler = $this->newHandler();

		$this->expectExceptionObject(
			new LocalizedHttpException( new MessageValue( 'rest-cannot-load-file' ), 404 )
		);
		$this->executeHandler( $handler, $request );
	}

}
PK       ! m2K]  K]  (  Rest/Handler/ParsoidOutputAccessTest.phpnu Iw        <?php

use MediaWiki\Content\JavaScriptContent;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Edit\ParsoidRenderID;
use MediaWiki\Language\Language;
use MediaWiki\Language\LanguageCode;
use MediaWiki\Page\ParserOutputAccess;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\ParserOutputFlags;
use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter;
use MediaWiki\Parser\Parsoid\ParsoidOutputAccess;
use MediaWiki\Parser\Parsoid\ParsoidParser;
use MediaWiki\Parser\Parsoid\ParsoidParserFactory;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionAccessException;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\Parsoid\Config\PageConfig;
use Wikimedia\Parsoid\Core\ContentMetadataCollector;
use Wikimedia\Parsoid\Core\PageBundle;
use Wikimedia\Parsoid\Parsoid;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess
 * @group Database
 */
class ParsoidOutputAccessTest extends MediaWikiIntegrationTestCase {
	private const WIKITEXT = 'Hello \'\'\'Parsoid\'\'\'!';
	private const MOCKED_HTML = 'mocked HTML';
	private const ENV_OPTS = [ 'pageBundle' => true ];

	protected function setUp(): void {
		parent::setUp();
		// This class is deprecated, as all are its methods.
		$this->filterDeprecated( '/' . preg_quote( ParsoidOutputAccess::class, '/' ) . '/' );
	}

	/**
	 * @param int $expectedCalls
	 * @param string|null $version
	 *
	 * @return MockObject|Parsoid
	 */
	private function newMockParsoid( int $expectedCalls = 1, ?string $version = null ) {
		$parsoid = $this->createNoOpMock( Parsoid::class, [ 'wikitext2html' ] );
		$parsoid->expects( $this->exactly( $expectedCalls ) )->method( 'wikitext2html' )->willReturnCallback(
			static function (
				PageConfig $pageConfig, $options, &$headers, ?ContentMetadataCollector $metadata = null
			) use ( $version ) {
				$wikitext = $pageConfig->getRevisionContent()->getContent( SlotRecord::MAIN );
				if ( $metadata !== null ) {
					$metadata->setExtensionData( 'my-key', 'my-data' );
					$metadata->setPageProperty( 'forcetoc', '' );
					$metadata->setOutputFlag( ParserOutputFlags::NO_GALLERY );
				}

				return new PageBundle(
					self::MOCKED_HTML . ' of ' . $wikitext,
					[ 'parsoid-data' ],
					[ 'mw-data' ],
					$version ?? Parsoid::defaultHTMLVersion(),
					[ 'content-language' => 'en' ],
					$pageConfig->getContentModel()
				);
			}
		);

		return $parsoid;
	}

	/**
	 * @param int $expectedParses
	 * @param array $parsoidCacheConfig
	 * @param BagOStuff|null $parserCacheBag
	 * @param string|null $version
	 *
	 * @return ParsoidOutputAccess
	 * @throws Exception
	 */
	private function resetServicesWithMockedParsoid(
		$expectedParses,
		$parsoidCacheConfig = [],
		?BagOStuff $parserCacheBag = null,
		?string $version = null
	): void {
		$services = $this->getServiceContainer();

		$mockParsoid = $this->newMockParsoid( $expectedParses, $version );
		$parsoidParser = new ParsoidParser(
			$mockParsoid,
			$services->getParsoidPageConfigFactory(),
			$services->getLanguageConverterFactory(),
			$services->getParserFactory(),
			$services->getGlobalIdGenerator()
		);

		// Create a mock Parsoid factory that returns the ParsoidParser object
		// with the mocked Parsoid object.
		$mockParsoidParserFactory = $this->createNoOpMock( ParsoidParserFactory::class, [ 'create' ] );
		$mockParsoidParserFactory->expects( $this->exactly( $expectedParses ) )
			->method( 'create' )
			->willReturn( $parsoidParser );

		$this->setService( 'ParsoidParserFactory', $mockParsoidParserFactory );
	}

	/**
	 * @param ?ParserOutputAccess $parserOutputAccess
	 * @return ParsoidOutputAccess
	 */
	private function getParsoidOutputAccessWithCache(
		?ParserOutputAccess $parserOutputAccess = null
	): ParsoidOutputAccess {
		$services = $this->getServiceContainer();
		return new ParsoidOutputAccess(
			$services->getParsoidParserFactory(),
			$parserOutputAccess ?? $services->getParserOutputAccess(),
			$services->getPageStore(),
			$services->getRevisionLookup(),
			$services->getParsoidSiteConfig(),
			$services->getContentHandlerFactory()
		);
	}

	/**
	 * @return ParserOptions
	 */
	private function getParserOptions() {
		return ParserOptions::newFromAnon();
	}

	private function getHtml( $value ) {
		if ( $value instanceof StatusValue ) {
			$value = $value->getValue();
		}

		if ( $value instanceof ParserOutput ) {
			$value = $value->getRawText();
		}

		$html = preg_replace( '/<!--.*?-->/s', '', $value );
		$html = trim( preg_replace( '/[\r\n]{2,}/', "\n", $html ) );
		$html = trim( preg_replace( '/\s{2,}/', ' ', $html ) );
		return $html;
	}

	private function assertContainsHtml( $needle, $actual, $msg = '' ) {
		$this->assertNotNull( $actual );

		if ( $actual instanceof StatusValue ) {
			$this->assertStatusOK( $actual, 'isOK' );
		}

		$this->assertStringContainsString( $needle, $this->getHtml( $actual ), $msg );
	}

	/**
	 * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput
	 */
	public function testGetParserOutputThrowsIfRevisionNotFound() {
		$this->resetServicesWithMockedParsoid( 0 );
		$access = $this->getParsoidOutputAccessWithCache();
		$parserOptions = $this->getParserOptions();

		$page = $this->getNonexistingTestPage( __METHOD__ );

		$this->expectException( RevisionAccessException::class );
		$access->getParserOutput( $page, $parserOptions );
	}

	/**
	 * Tests that getParserOutput() will return output.
	 *
	 * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput
	 */
	public function testGetParserOutput() {
		$this->resetServicesWithMockedParsoid( 1 );
		$access = $this->getParsoidOutputAccessWithCache();
		$parserOptions = $this->getParserOptions();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, self::WIKITEXT );

		$status = $access->getParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status );

		$output = $status->getValue();

		// check that ParsoidRenderID::newFromParserOutput()  doesn't throw
		$this->assertNotNull( ParsoidRenderID::newFromParserOutput( $output ) );

		// Ensure that we can still create a valid instance of PageBundle from the ParserOutput
		$pageBundle = PageBundleParserOutputConverter::pageBundleFromParserOutput( $output );
		$this->assertSame( $output->getRawText(), $pageBundle->html );

		// Ensure that the expected mw and parsoid fields are set in the PageBundle
		$this->assertNotEmpty( $pageBundle->mw );
		$this->assertNotEmpty( $pageBundle->parsoid );
		$this->assertNotEmpty( $pageBundle->headers );
		$this->assertNotEmpty( $pageBundle->version );

		// Check that the metadata set by our mock parsoid is preserved
		$this->checkMetadata( $output );
	}

	/**
	 * Tests that getParserOutput() will place the generated output for the latest revision
	 * in the parsoid parser cache.
	 *
	 * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput
	 */
	public function testLatestRevisionIsCached() {
		$this->resetServicesWithMockedParsoid( 1 );
		$access = $this->getParsoidOutputAccessWithCache();
		$parserOptions = $this->getParserOptions();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, self::WIKITEXT );

		$status = $access->getParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status );
		$this->checkMetadata( $status );

		// Get the ParserOutput from cache
		$status = $access->getCachedParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status );
		$this->checkMetadata( $status );

		// Get the ParserOutput from cache, without supplying a PageRecord
		$status = $access->getCachedParserOutput( $page->getTitle(), $parserOptions );
		$this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status );
		$this->checkMetadata( $status );

		// Get the ParserOutput again, this should not trigger a new parse.
		$status = $access->getParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status );
		$this->checkMetadata( $status );
	}

	/**
	 * Tests that getParserOutput() will force a parse since we know that
	 * the revision is not in the cache.
	 *
	 * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput
	 */
	public function testLatestRevisionWithForceParse() {
		$this->resetServicesWithMockedParsoid( 2 );
		$access = $this->getParsoidOutputAccessWithCache();
		$parserOptions = $this->getParserOptions();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, self::WIKITEXT );

		$status = $access->getParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status );
		$this->checkMetadata( $status );

		// Get the ParserOutput again, this should trigger a new parse
		// since we're forcing it to.
		$status = $access->getParserOutput(
			$page,
			$parserOptions,
			null,
			ParserOutputAccess::OPT_FORCE_PARSE
		);
		$this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status );
		$this->checkMetadata( $status );
	}

	/**
	 * Tests that getParserOutput() will force a parse since we know that
	 * the revision is not in the cache.
	 *
	 * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput
	 */
	public function testLatestRevisionWithNoUpdateCache() {
		$cacheBag = $this->getMockBuilder( HashBagOStuff::class )
			->onlyMethods( [ 'set', 'setMulti' ] )
			->getMock();
		$cacheBag->expects( $this->never() )->method( 'set' );
		$cacheBag->expects( $this->never() )->method( 'setMulti' );

		// ParserCache should not get anything stored in it.
		$this->resetServicesWithMockedParsoid( 1, [], $cacheBag );
		$access = $this->getParsoidOutputAccessWithCache();
		$parserOptions = $this->getParserOptions();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, self::WIKITEXT );

		$status = $access->getParserOutput(
			$page,
			$parserOptions,
			null,
			ParserOutputAccess::OPT_NO_UPDATE_CACHE
		);
		$this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status );
		$this->checkMetadata( $status );
	}

	/**
	 * Tests that getParserOutput() will not call Parsoid and will not write to ParserCache
	 * for unsupported content models.
	 *
	 * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput
	 */
	public function testNonParsoidOutput() {
		// Expect no cache writes!
		$cacheBag = $this->getMockBuilder( HashBagOStuff::class )
			->onlyMethods( [ 'set', 'setMulti' ] )
			->getMock();
		$cacheBag->expects( $this->never() )->method( 'set' );
		$cacheBag->expects( $this->never() )->method( 'setMulti' );

		// Expect no calls to parsoid!
		$this->resetServicesWithMockedParsoid( 0, [], $cacheBag );
		$access = $this->getParsoidOutputAccessWithCache();
		$parserOptions = $this->getParserOptions();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, new JavaScriptContent( '"not wikitext"' ) );

		$status = $access->getParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( 'not wikitext', $status );

		/** @var ParserOutput $parserOutput */
		$parserOutput = $status->getValue();
		$this->assertNotNull(
			ParsoidRenderID::newFromParserOutput( $parserOutput )->getKey()
		);
	}

	public function testOldRevisionIsCached() {
		$this->resetServicesWithMockedParsoid( 1 );
		$access = $this->getParsoidOutputAccessWithCache();
		$parserOptions = $this->getParserOptions();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$status1 = $this->editPage( $page, self::WIKITEXT );
		$rev = $status1->getValue()['revision-record'];

		// Make an edit so that the revision we're getting output
		// for below is not the current revision.
		$this->editPage( $page, 'Second revision' );

		$access->getParserOutput( $page, $parserOptions, $rev );

		// Get the ParserOutput from cache, using revision object
		$status = $access->getCachedParserOutput( $page, $parserOptions, $rev );
		$this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status );
		$this->checkMetadata( $status );

		// Get the ParserOutput from cache, using revision ID
		$status = $access->getCachedParserOutput( $page->getTitle(), $parserOptions, $rev->getId() );
		$this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status );
		$this->checkMetadata( $status );

		// Get the ParserOutput again, this should not trigger a new parse.
		$status = $access->getParserOutput( $page, $parserOptions, $rev );
		$this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status );
		$this->checkMetadata( $status );
	}

	public function testGetParserOutputWithOldRevision() {
		$this->resetServicesWithMockedParsoid( 2 );
		$access = $this->getParsoidOutputAccessWithCache();
		$parserOptions = $this->getParserOptions();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$status1 = $this->editPage( $page, self::WIKITEXT );
		$rev1 = $status1->getValue()['revision-record'];

		$this->editPage( $page, 'Second revision' );

		$status2 = $access->getParserOutput( $page, $parserOptions );
		$this->assertContainsHtml( self::MOCKED_HTML . ' of Second revision', $status2 );
		$this->checkMetadata( $status2 );

		$status1 = $access->getParserOutput( $page, $parserOptions, $rev1 );
		$this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status1 );
		$this->checkMetadata( $status1 );

		// Again, using just the revision ID
		$status1 = $access->getParserOutput( $page, $parserOptions, $rev1->getId() );
		$this->assertContainsHtml( self::MOCKED_HTML . ' of ' . self::WIKITEXT, $status1 );
		$this->checkMetadata( $status1 );

		// check that ParsoidRenderID::newFromParserOutput() doesn't throw
		$output1 = $status1->getValue();
		$this->assertNotNull( ParsoidRenderID::newFromParserOutput( $output1 ) );
	}

	/**
	 * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput
	 */
	public function testParseWithPageRecordAndNoRevision() {
		$pageRecord = $this->getExistingTestPage( __METHOD__ )->toPageRecord();
		$pOpts = ParserOptions::newFromAnon();

		$parsoidOutputAccess = $this->getServiceContainer()->getParsoidOutputAccess();
		$status = $parsoidOutputAccess->getParserOutput( $pageRecord, $pOpts, null );

		$this->assertInstanceOf( Status::class, $status );
		$this->assertTrue( $status->isOK() );
		$this->assertInstanceOf( ParserOutput::class, $status->getValue() );

		/** @var ParserOutput $parserOutput */
		$parserOutput = $status->getValue();
		$this->assertStringContainsString( __METHOD__, $parserOutput->getRawText() );
		$this->assertNotEmpty( $parserOutput->getRenderId() );
		$this->assertNotEmpty( $parserOutput->getCacheRevisionId() );
		$this->assertNotEmpty( $parserOutput->getCacheTime() );
	}

	private function checkMetadata( $output ) {
		$parserOutput = $output instanceof StatusValue ? $output->getValue() : $output;

		// Check the metadata added by ::newMockParsoid() is preserved
		$this->assertSame( 'my-data', $parserOutput->getExtensionData( 'my-key' ) );
		$this->assertSame( '', $parserOutput->getPageProperty( 'forcetoc' ) );
		$this->assertSame( true, $parserOutput->getOutputFlag( ParserOutputFlags::NO_GALLERY ) );
	}

	/**
	 * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput
	 */
	public function testParseWithPageRecordAndRevision() {
		$page = $this->getExistingTestPage( __METHOD__ );
		$pageRecord = $page->toPageRecord();
		$pOpts = ParserOptions::newFromAnon();
		$revRecord = $page->getRevisionRecord();

		$parsoidOutputAccess = $this->getServiceContainer()->getParsoidOutputAccess();
		$status = $parsoidOutputAccess->getParserOutput( $pageRecord, $pOpts, $revRecord );

		$this->assertInstanceOf( Status::class, $status );
		$this->assertTrue( $status->isOK() );
		$this->assertInstanceOf( ParserOutput::class, $status->getValue() );

		/** @var ParserOutput $parserOutput */
		$parserOutput = $status->getValue();
		$this->assertStringContainsString( __METHOD__, $parserOutput->getRawText() );
		$this->assertNotEmpty( $parserOutput->getRenderId() );
		$this->assertNotEmpty( $parserOutput->getCacheRevisionId() );
		$this->assertNotEmpty( $parserOutput->getCacheTime() );
	}

	/**
	 * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput
	 */
	public function testParseWithPageIdentityAndRevisionId() {
		$page = $this->getExistingTestPage( __METHOD__ );
		$pOpts = ParserOptions::newFromAnon();
		$revId = $page->getLatest();

		$parsoidOutputAccess = $this->getServiceContainer()->getParsoidOutputAccess();
		$status = $parsoidOutputAccess->getParserOutput( $page->getTitle(), $pOpts, $revId );

		$this->assertInstanceOf( Status::class, $status );
		$this->assertTrue( $status->isOK() );
		$this->assertInstanceOf( ParserOutput::class, $status->getValue() );

		/** @var ParserOutput $parserOutput */
		$parserOutput = $status->getValue();
		$this->assertStringContainsString( __METHOD__, $parserOutput->getRawText() );
		$this->assertNotEmpty( $parserOutput->getRenderId() );
		$this->assertNotEmpty( $parserOutput->getCacheRevisionId() );
		$this->assertNotEmpty( $parserOutput->getCacheTime() );
	}

	/**
	 * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::parseUncacheable
	 */
	public function testParseWithNonExistingPageAndFakeRevision() {
		$page = $this->getNonexistingTestPage( __METHOD__ );
		$pOpts = ParserOptions::newFromAnon();

		// Create a fake revision record
		$revRecord = new MutableRevisionRecord( $page->getTitle() );
		$revRecord->setId( 0 );
		$revRecord->setPageId( $page->getId() );
		$revRecord->setContent(
			SlotRecord::MAIN,
			new WikitextContent( 'test' )
		);

		$parsoidOutputAccess = $this->getServiceContainer()->getParsoidOutputAccess();
		$status = $parsoidOutputAccess->parseUncacheable( $page->getTitle(), $pOpts, $revRecord );

		$this->assertInstanceOf( Status::class, $status );
		$this->assertTrue( $status->isOK() );
		$this->assertInstanceOf( ParserOutput::class, $status->getValue() );

		/** @var ParserOutput $parserOutput */
		$parserOutput = $status->getValue();
		$this->assertStringContainsString( __METHOD__, $parserOutput->getRawText() );
		$this->assertNotEmpty( $parserOutput->getRenderId() );
		// The revision ID is set to 0, so that's what is in the cache.
		$this->assertSame( 0, $parserOutput->getCacheRevisionId() );
		$this->assertNotEmpty( $parserOutput->getCacheTime() );
	}

	/**
	 * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::parseUncacheable
	 */
	public function testParseDeletedRevision() {
		$page = $this->getNonexistingTestPage( __METHOD__ );
		$pOpts = ParserOptions::newFromAnon();

		// Create a fake revision record
		$revRecord = new MutableRevisionRecord( $page->getTitle() );
		$revRecord->setId( 0 );
		$revRecord->setPageId( $page->getId() );
		$revRecord->setContent(
			SlotRecord::MAIN,
			new WikitextContent( 'test' )
		);
		// Induce a RevisionAccessException
		$revRecord->setVisibility( RevisionRecord::DELETED_TEXT );

		$parsoidOutputAccess = $this->getServiceContainer()->getParsoidOutputAccess();
		$status = $parsoidOutputAccess->parseUncacheable( $page->getTitle(), $pOpts, $revRecord );

		$this->assertStatusNotOK( $status );
		$this->assertStatusMessagesExactly(
			StatusValue::newFatal( 'parsoid-revision-access', 'Not an available content version.' ),
			$status
		);
	}

	/**
	 * Mock the language class based on a language code.
	 *
	 * @param string $langCode
	 *
	 * @return Language|Language&MockObject|MockObject
	 */
	private function getLanguageMock( string $langCode ) {
		$language = $this->createMock( Language::class );
		$language->method( 'getCode' )->willReturn( $langCode );
		$language->method( 'getDir' )->willReturn( 'ltr' );
		$bcp47 = LanguageCode::bcp47( $langCode );
		$language
			->method( 'getHtmlCode' )
			->willReturn( $bcp47 );
		$language
			->method( 'toBcp47Code' )
			->willReturn( $bcp47 );
		return $language;
	}

	/** @return Generator */
	public function provideParserOptionsWithLanguageOverride() {
		$parserOptions = $this->createMock( ParserOptions::class );
		$parserOptions->method( 'optionsHash' )->willReturn( '' );
		$parserOptions->method( 'getUseParsoid' )->willReturn( true );
		$parserOptions->method( 'getTargetLanguage' )
			->willReturn( null );
		yield 'ParserOptions with no language' => [ $parserOptions, 'en' ];

		$langCode = 'de';
		$parserOptions = $this->createMock( ParserOptions::class );
		$parserOptions->method( 'optionsHash' )->willReturn( '' );
		$parserOptions->method( 'getUseParsoid' )->willReturn( true );
		$parserOptions->method( 'getTargetLanguage' )
			->willReturn( $this->getLanguageMock( $langCode ) );
		yield 'ParserOptions for "de" language' => [ $parserOptions, $langCode ];

		$langCode = 'ar';
		$parserOptions = $this->createMock( ParserOptions::class );
		$parserOptions->method( 'optionsHash' )->willReturn( '' );
		$parserOptions->method( 'getUseParsoid' )->willReturn( true );
		$parserOptions->method( 'getTargetLanguage' )
			->willReturn( $this->getLanguageMock( $langCode ) );
		yield 'ParserOptions for "ar" language' => [ $parserOptions, $langCode ];
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper::getParserOutput
	 * @dataProvider provideParserOptionsWithLanguageOverride
	 */
	public function testGetParserOutputWithLanguageOverride( $parserOptions, $expectedLangCode ) {
		$services = $this->getServiceContainer();
		$parserOutputAccess = $services->getParsoidOutputAccess();

		$content = 'Test content for ' . __METHOD__;
		$page = Title::makeTitle( NS_MAIN, 'TestGetParserOutputWithLanguageOverride' );
		$this->editPage( $page, $content );

		$status = $parserOutputAccess->getParserOutput( $page, $parserOptions );

		$this->assertTrue( $status->isOK() );

		// assert dummy content in parsoid output HTML
		$html = $status->getValue()->getRawText();
		$this->assertStringContainsString( $content, $html );

		if ( $parserOptions->getTargetLanguage() !== null ) {
			$targetLanguage = $parserOptions->getTargetLanguage()->getCode();
			$this->assertSame( $expectedLangCode, $targetLanguage );
			$this->assertInstanceOf( Language::class, $parserOptions->getTargetLanguage() );
		} else {
			$this->assertNull( $parserOptions->getTargetLanguage() );
		}

		// assert the page language in parsoid output HTML
		$this->assertStringContainsString( 'lang="' . $expectedLangCode . '"', $html );
		$this->assertStringContainsString( 'content="' . $expectedLangCode . '"', $html );
	}

	/**
	 * @covers \MediaWiki\Parser\Parsoid\ParsoidOutputAccess::getParserOutput
	 */
	public function testRerenderForNonDefaultVersion() {
		// Rendering is asked for twice because version is not the Parsoid default
		// so even though the output is found in the primary cache, it's obsolete.
		$this->resetServicesWithMockedParsoid( 2, [], null, '1.1.1' );

		$parserOutputAccess = $this->getServiceContainer()->getParserOutputAccess();
		$access = $this->getParsoidOutputAccessWithCache( $parserOutputAccess );

		$parserOptions = $this->getParserOptions();
		$page = $this->getExistingTestPage();

		$access->getParserOutput( $page, $parserOptions );

		// Clear the localCache since that has priority and updating the Parsoid
		// default version would require a process restart anyways.
		$testingAccess = TestingAccessWrapper::newFromObject( $parserOutputAccess );
		$testingAccess->localCache->clear();

		$access->getParserOutput( $page, $parserOptions );
	}

}
PK       ! ǉ    %  Rest/Handler/TransformHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use MediaWiki\MainConfigNames;
use MediaWiki\Rest\Handler\Helper\ParsoidFormatHelper;
use MediaWiki\Rest\Handler\TransformHandler;
use MediaWiki\Rest\RequestData;
use MediaWiki\Rest\RequestInterface;
use MediaWikiIntegrationTestCase;
use Wikimedia\Parsoid\Parsoid;

/**
 * @group Database
 */
class TransformHandlerTest extends MediaWikiIntegrationTestCase {
	use HandlerTestTrait;

	public static function provideRequest() {
		$profileVersion = Parsoid::AVAILABLE_VERSIONS[0];
		$htmlProfileUri = 'https://www.mediawiki.org/wiki/Specs/HTML/' . $profileVersion;
		$htmlContentType = "text/html; charset=utf-8; profile=\"$htmlProfileUri\"";
		$pbProfileUri = 'https://www.mediawiki.org/wiki/Specs/pagebundle/' . $profileVersion;
		$pbContentType = "application/json; charset=utf-8; profile=\"$pbProfileUri\"";

		$wikitextProfileUri = 'https://www.mediawiki.org/wiki/Specs/wikitext/1.0.0';

		$defaultParams = [
			'method' => 'POST',
			'headers' => [
				'content-type' => 'application/json',
			],
		];

		// Convert wikitext to HTML ////////////////////////////////////////////////////////////////
		$request = new RequestData( [
			'pathParams' => [
				'from' => ParsoidFormatHelper::FORMAT_WIKITEXT,
				'format' => ParsoidFormatHelper::FORMAT_HTML,
			],
			'bodyContents' => json_encode( [
				'wikitext' => '== h2 ==',
			] )
		] + $defaultParams );

		yield 'should transform wikitext to HTML' => [
			$request,
			'>h2</h2>',
			200,
			[ 'content-type' => $htmlContentType ],
		];

		// Convert HTML to wikitext ////////////////////////////////////////////////////////////////
		$request = new RequestData( [
				'pathParams' => [
					'from' => ParsoidFormatHelper::FORMAT_HTML,
					'format' => ParsoidFormatHelper::FORMAT_WIKITEXT,
				],
				'bodyContents' => json_encode( [
					'html' => '<pre>hi ho</pre>',
				] )
			] + $defaultParams );

		yield 'should transform HTML to wikitext' => [
			$request,
			'hi ho',
			200,
			[ 'content-type' => "text/plain; charset=utf-8; profile=\"$wikitextProfileUri\"" ],
		];

		// Perform language variant conversion //////////////////////////////////////////////////////
		$request = new RequestData( [
				'pathParams' => [
					'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
					'format' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
				],
				'bodyContents' => json_encode( [
					// NOTE: input for pb2pb is expected in the 'original' structure for some reason
					'original' => [
						'html' => [
							'headers' => [
								'content-type' => $htmlContentType,
							],
							'body' => '<p>test language conversion</p>',
						],
					],
					'updates' => [
						'variant' => [
							'source' => 'en',
							'target' => 'en-x-piglatin'
						]
					]
				] ),
				'headers' => [
					'content-type' => 'application/json',
					'content-language' => 'en',
					'accept-language' => 'en-x-piglatin',
				]
			] + $defaultParams );

		yield 'should apply language variant conversion' => [
			$request,
			[
				// pig latin!
				'>esttay anguagelay onversioncay<',
				// NOTE: quotes are escaped because this is embedded in JSON
				'<meta http-equiv=\"content-language\" content=\"en-x-piglatin\"/>'
			],
			200,
			// NOTE: Parsoid returns a content-language header in the page bundle,
			// but that header is not applied to the HTTP response, which is JSON.
			[ 'content-type' => $pbContentType ],
		];
	}

	/**
	 * @dataProvider provideRequest
	 * @covers \MediaWiki\Rest\Handler\TransformHandler::execute
	 */
	public function testRequest(
		RequestInterface $request,
		$expectedText,
		$expectedStatus = 200,
		$expectedHeaders = []
	) {
		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, true );

		$revisionLookup = $this->getServiceContainer()->getRevisionLookup();
		$dataAccess = $this->getServiceContainer()->getParsoidDataAccess();
		$siteConfig = $this->getServiceContainer()->getParsoidSiteConfig();
		$pageConfigFactory = $this->getServiceContainer()->getParsoidPageConfigFactory();

		$handler = new TransformHandler(
			$revisionLookup,
			$siteConfig,
			$pageConfigFactory,
			$dataAccess
		);
		$response = $this->executeHandler( $handler, $request );
		$response->getBody()->rewind();
		$data = $response->getBody()->getContents();

		$this->assertSame( $expectedStatus, $response->getStatusCode(), 'Status' );

		foreach ( (array)$expectedText as $txt ) {
			$this->assertStringContainsString( $txt, $data );
		}

		foreach ( $expectedHeaders as $key => $expectedHeader ) {
			$this->assertSame( $expectedHeader, $response->getHeaderLine( $key ), $key );
		}
	}

}
PK       ! 	;    *  Rest/Handler/RevisionSourceHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use Exception;
use MediaWiki\Content\TextContent;
use MediaWiki\MainConfigNames;
use MediaWiki\Rest\Handler\RevisionSourceHandler;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\RequestData;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWikiIntegrationTestCase;
use Wikimedia\Message\MessageValue;
use Wikimedia\ObjectCache\BagOStuff;

/**
 * @covers \MediaWiki\Rest\Handler\RevisionSourceHandler
 * @group Database
 */
class RevisionSourceHandlerTest extends MediaWikiIntegrationTestCase {
	use HandlerTestTrait;

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

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

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

		$this->overrideConfigValues( [
			MainConfigNames::RightsUrl => 'https://example.com/rights',
			MainConfigNames::RightsText => 'some rights',
		] );
	}

	/**
	 * @param BagOStuff|null $cache
	 * @return RevisionSourceHandler
	 * @throws Exception
	 */
	private function newHandler( ?BagOStuff $cache = null ): RevisionSourceHandler {
		$handler = new RevisionSourceHandler(
			$this->getServiceContainer()->getPageRestHelperFactory()
		);

		return $handler;
	}

	private function getExistingPageWithRevisions( $name ) {
		$page = $this->getNonexistingTestPage( $name );

		$this->editPage( $page, self::WIKITEXT );
		$revisions['first'] = $page->getRevisionRecord();

		$this->editPage( $page, 'DEAD BEEF' );
		$revisions['latest'] = $page->getRevisionRecord();

		return [ $page, $revisions ];
	}

	public function testExecuteBare() {
		[ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ );

		$firstRev = $revisions['first'];
		$request = new RequestData(
			[ 'pathParams' => [ 'id' => $firstRev->getId() ] ]
		);

		$htmlUrl = "https://wiki.example.com/rest/mock/revision/{$firstRev->getId()}/html";

		$handler = $this->newHandler();
		$config = [ 'format' => 'bare' ];
		$data = $this->executeHandlerAndGetBodyData( $handler, $request, $config );

		$this->assertResponseData( $firstRev, $data );
		$this->assertSame( $htmlUrl, $data['html_url'] );
	}

	public function testExecuteSource() {
		[ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ );

		$firstRev = $revisions['first'];
		$request = new RequestData(
			[ 'pathParams' => [ 'id' => $firstRev->getId() ] ]
		);

		$handler = $this->newHandler();
		$config = [ 'format' => 'source' ];
		$data = $this->executeHandlerAndGetBodyData( $handler, $request, $config );

		/** @var TextContent $content */
		$content = $firstRev->getContent( SlotRecord::MAIN );

		$this->assertResponseData( $firstRev, $data );
		$this->assertSame( $content->getText(), $data['source'] );
	}

	public function testExecute_missingparam() {
		$request = new RequestData();

		$this->expectExceptionObject(
			new LocalizedHttpException(
				new MessageValue( "paramvalidator-missingparam", [ 'title' ] ),
				400
			)
		);

		$handler = $this->newHandler();
		$this->executeHandler( $handler, $request );
	}

	public function testExecute_error() {
		$request = new RequestData( [ 'pathParams' => [ 'id' => '2074398742' ] ] );

		$this->expectExceptionObject(
			new LocalizedHttpException(
				new MessageValue( "rest-nonexistent-revision", [ 'testing' ] ),
				404
			)
		);

		$handler = $this->newHandler();
		$this->executeHandler( $handler, $request );
	}

	/**
	 * @param RevisionRecord $rev
	 * @param array $data
	 */
	private function assertResponseData( RevisionRecord $rev, array $data ): void {
		$this->assertSame( $rev->getId(), $data['id'] );
		$this->assertSame( $rev->getSize(), $data['size'] );
		$this->assertSame( $rev->isMinor(), $data['minor'] );
		$this->assertSame(
			wfTimestampOrNull( TS_ISO_8601, $rev->getTimestamp() ),
			$data['timestamp']
		);
		$this->assertSame( $rev->getPage()->getId(), $data['page']['id'] );
		$this->assertSame( $rev->getPage()->getDBkey(), $data['page']['key'] ); // assume main namespace
		$this->assertSame(
			$rev->getPageAsLinkTarget()->getText(),
			$data['page']['title']
		); // assume main namespace
		$this->assertSame( CONTENT_MODEL_WIKITEXT, $data['content_model'] );
		$this->assertSame( 'https://example.com/rights', $data['license']['url'] );
		$this->assertSame( 'some rights', $data['license']['title'] );
		$this->assertSame( $rev->getComment()->text, $data['comment'] );
		$this->assertSame( $rev->getUser()->getId(), $data['user']['id'] );
		$this->assertSame( $rev->getUser()->getName(), $data['user']['name'] );
	}

	/**
	 * @param RevisionRecord $rev
	 * @param array $data
	 */
	private function assertRestbaseCompatibleResponseData( RevisionRecord $rev, array $data ): void {
		$page = $this->getServiceContainer()->getWikiPageFactory()
			->newFromTitle( $rev->getPage() );

		$this->assertSame( $page->getTitle()->getPrefixedDBkey(), $data['title'] );
		$this->assertSame( $rev->getPage()->getId(), $data['page_id'] );
		$this->assertSame( $rev->getId(), $data['rev'] );
		$this->assertSame( $rev->getPage()->getNamespace(), $data['namespace'] );
		$this->assertSame( $page->getUser(), $data['user_id'] );
		$this->assertSame( $page->getUserText(), $data['user_text'] );
		$this->assertSame(
			wfTimestampOrNull( TS_ISO_8601, $page->getTimestamp() ),
			$data['timestamp']
		);
		$this->assertSame( $rev->getComment()->text, $data['comment'] );
		$this->assertSame( [], $data['tags'] );
		$this->assertSame( [], $data['restrictions'] );
		$this->assertSame(
			$page->getTitle()->getPageLanguage()->getCode(),
			$data['page_language']
		);
		$this->assertSame( $page->isRedirect(), $data['redirect'] );
	}

}
PK       ! V4?  ?  (  Rest/Handler/RevisionHTMLHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use Exception;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\MainConfigNames;
use MediaWiki\MainConfigSchema;
use MediaWiki\Parser\Parsoid\ParsoidParser;
use MediaWiki\Parser\Parsoid\ParsoidParserFactory;
use MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper;
use MediaWiki\Rest\Handler\Helper\PageRestHelperFactory;
use MediaWiki\Rest\Handler\Helper\RevisionContentHelper;
use MediaWiki\Rest\Handler\RevisionHTMLHandler;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\RequestData;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiIntegrationTestCase;
use Psr\Http\Message\StreamInterface;
use ReflectionClass;
use Wikimedia\Message\MessageValue;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\Parsoid\Core\ClientError;
use Wikimedia\Parsoid\Core\ResourceLimitExceededException;
use Wikimedia\Parsoid\Parsoid;
use Wikimedia\Stats\StatsFactory;

/**
 * @covers \MediaWiki\Rest\Handler\RevisionHTMLHandler
 * @group Database
 */
class RevisionHTMLHandlerTest extends MediaWikiIntegrationTestCase {
	use HandlerTestTrait;
	use HTMLHandlerTestTrait;

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

	private const HTML = '>World<';

	private HashBagOStuff $parserCacheBagOStuff;

	/** @var int */
	private static $uuidCounter = 0;

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

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

	/**
	 * @return RevisionHTMLHandler
	 */
	private function newHandler(): RevisionHTMLHandler {
		$services = $this->getServiceContainer();
		$config = [
			MainConfigNames::RightsUrl => 'https://example.com/rights',
			MainConfigNames::RightsText => 'some rights',
			MainConfigNames::ParsoidCacheConfig =>
				MainConfigSchema::getDefaultValue( MainConfigNames::ParsoidCacheConfig )
		];

		$helperFactory = $this->createNoOpMock(
			PageRestHelperFactory::class,
			[ 'newRevisionContentHelper', 'newHtmlOutputRendererHelper' ]
		);

		$helperFactory->method( 'newRevisionContentHelper' )
			->willReturn( new RevisionContentHelper(
				new ServiceOptions( RevisionContentHelper::CONSTRUCTOR_OPTIONS, $config ),
				$services->getRevisionLookup(),
				$services->getTitleFormatter(),
				$services->getPageStore(),
				$services->getTitleFactory(),
				$services->getConnectionProvider(),
				$services->getChangeTagsStore()
			) );

		$parsoidOutputStash = $this->getParsoidOutputStash();
		$helperFactory->method( 'newHtmlOutputRendererHelper' )
			->willReturnCallback( static function ( $page, $parameters, $authority, $revision, $lenientRevHandling ) use ( $services, $parsoidOutputStash ) {
				return new HtmlOutputRendererHelper(
					$parsoidOutputStash,
					StatsFactory::newNull(),
					$services->getParserOutputAccess(),
					$services->getPageStore(),
					$services->getRevisionLookup(),
					$services->getRevisionRenderer(),
					$services->getParsoidSiteConfig(),
					$services->getHtmlTransformFactory(),
					$services->getContentHandlerFactory(),
					$services->getLanguageFactory(),
					$page,
					$parameters,
					$authority,
					$revision,
					$lenientRevHandling
				);
			} );

		$handler = new RevisionHTMLHandler(
			$helperFactory
		);

		return $handler;
	}

	private function getExistingPageWithRevisions( $name ) {
		$page = $this->getNonexistingTestPage( $name );

		$this->editPage( $page, self::WIKITEXT );
		$revisions['first'] = $page->getRevisionRecord();

		$this->editPage( $page, 'DEAD BEEF' );
		$revisions['latest'] = $page->getRevisionRecord();

		return [ $page, $revisions ];
	}

	public function testExecuteWithHtml() {
		[ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ );
		$this->assertStatusGood( $this->editPage( $page, self::WIKITEXT ),
			'Edited a page'
		);

		$request = new RequestData(
			[ 'pathParams' => [ 'id' => $revisions['first']->getId() ] ]
		);

		$handler = $this->newHandler();
		$data = $this->executeHandlerAndGetBodyData( $handler, $request, [
			'format' => 'with_html'
		] );

		$this->assertResponseData( $revisions['first'], $data );
		$this->assertStringContainsString( '<!DOCTYPE html>', $data['html'] );
		$this->assertStringContainsString( '<html', $data['html'] );
		$this->assertStringContainsString( self::HTML, $data['html'] );
	}

	public function testExecuteHtmlOnly() {
		[ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ );
		$this->assertStatusGood( $this->editPage( $page, self::WIKITEXT ),
			'Edited a page'
		);

		$request = new RequestData(
			[ 'pathParams' => [ 'id' => $revisions['first']->getId() ] ]
		);

		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request, [
			'format' => 'html'
		] );

		$htmlResponse = (string)$response->getBody();
		$this->assertStringContainsString( '<!DOCTYPE html>', $htmlResponse );
		$this->assertStringContainsString( '<html', $htmlResponse );
		$this->assertStringContainsString( self::HTML, $htmlResponse );
	}

	public function testEtagLastModified() {
		$time = time();
		MWTimestamp::setFakeTime( $time );

		[ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ );
		$request = new RequestData(
			[ 'pathParams' => [ 'id' => $revisions['first']->getId() ] ]
		);

		// First, test it works if nothing was cached yet.
		// Make some time pass since page was created:
		MWTimestamp::setFakeTime( $time + 10 );
		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request, [
			'format' => 'html'
		] );
		$this->assertArrayHasKey( 'ETag', $response->getHeaders() );
		$this->assertArrayHasKey( 'Last-Modified', $response->getHeaders() );
		$this->assertSame( MWTimestamp::convert( TS_RFC2822, $time + 10 ),
			$response->getHeaderLine( 'Last-Modified' ) );

		$etag = $response->getHeaderLine( 'ETag' );

		// Now, test that headers work when getting from cache too.
		MWTimestamp::setFakeTime( $time + 20 );
		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request, [
			'format' => 'html'
		] );
		$this->assertArrayHasKey( 'ETag', $response->getHeaders() );
		$this->assertSame( $etag, $response->getHeaderLine( 'ETag' ) );
		$this->assertArrayHasKey( 'Last-Modified', $response->getHeaders() );
		$this->assertSame( MWTimestamp::convert( TS_RFC2822, $time + 10 ),
			$response->getHeaderLine( 'Last-Modified' ) );

		// Now, expire the cache, and assert we are getting a new timestamp back
		MWTimestamp::setFakeTime( $time + 10000 );
		$this->assertTrue(
			$page->getTitle()->invalidateCache( MWTimestamp::convert( TS_MW, $time ) ),
			'Can invalidate cache'
		);
		DeferredUpdates::doUpdates();

		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request, [
			'format' => 'html'
		] );
		$this->assertArrayHasKey( 'ETag', $response->getHeaders() );
		$this->assertNotSame( $etag, $response->getHeaderLine( 'ETag' ) );
		$this->assertArrayHasKey( 'Last-Modified', $response->getHeaders() );
		$this->assertSame( MWTimestamp::convert( TS_RFC2822, $time + 10000 ),
			$response->getHeaderLine( 'Last-Modified' ) );
	}

	public static function provideHandlesParsoidError() {
		yield 'ClientError' => [
			new ClientError( 'TEST_TEST' ),
			new LocalizedHttpException(
				new MessageValue( 'rest-html-backend-error' ),
				400,
				[
					'reason' => 'TEST_TEST'
				]
			)
		];
		yield 'ResourceLimitExceededException' => [
			new ResourceLimitExceededException( 'TEST_TEST' ),
			new LocalizedHttpException(
				new MessageValue( 'rest-resource-limit-exceeded' ),
				413,
				[
					'reason' => 'TEST_TEST'
				]
			)
		];
	}

	/**
	 * @dataProvider provideHandlesParsoidError
	 */
	public function testHandlesParsoidError(
		Exception $parsoidException,
		Exception $expectedException
	) {
		[ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ );
		$request = new RequestData(
			[ 'pathParams' => [ 'id' => $revisions['first']->getId() ] ]
		);

		$services = $this->getServiceContainer();
		$parsoidParser = $services->getParsoidParserFactory()->create();

		// Mock Parsoid
		$mockParsoid = $this->createNoOpMock( Parsoid::class, [ 'wikitext2html' ] );
		$mockParsoid->expects( $this->once() )
			->method( 'wikitext2html' )
			->willThrowException( $parsoidException );

		// Install it in the ParsoidParser object
		$reflector = new ReflectionClass( ParsoidParser::class );
		$prop = $reflector->getProperty( 'parsoid' );
		$prop->setAccessible( true );
		$prop->setValue( $parsoidParser, $mockParsoid );
		$this->assertEquals( $prop->getValue( $parsoidParser ), $mockParsoid );

		// Create a mock Parsoid factory that returns the ParsoidParser object
		// with the mocked Parsoid object.
		$mockParsoidParserFactory = $this->createNoOpMock( ParsoidParserFactory::class, [ 'create' ] );
		$mockParsoidParserFactory->expects( $this->once() )
			->method( 'create' )
			->willReturn( $parsoidParser );

		// Ensure WiktiextContentHandler has the mock ParsoidParserFactory
		$wtHandler = $services->getContentHandlerFactory()->getContentHandler( 'wikitext' );
		$reflector = new ReflectionClass( 'WikitextContentHandler' );
		$prop = $reflector->getProperty( 'parsoidParserFactory' );
		$prop->setAccessible( true );
		$prop->setValue( $wtHandler, $mockParsoidParserFactory );
		$this->assertEquals( $prop->getValue( $wtHandler ), $mockParsoidParserFactory );

		$handler = $this->newHandler();
		$this->expectExceptionObject( $expectedException );
		$this->executeHandler( $handler, $request, [
			'format' => 'html'
		] );
	}

	public function testExecute_missingparam() {
		$request = new RequestData();

		$this->expectExceptionObject(
			new LocalizedHttpException(
				new MessageValue( "paramvalidator-missingparam", [ 'revision' ] ),
				400
			)
		);

		$handler = $this->newHandler();
		$this->executeHandler( $handler, $request );
	}

	public function testExecute_error() {
		$request = new RequestData( [ 'pathParams' => [ 'id' => '2076419894' ] ] );

		$this->expectExceptionObject(
			new LocalizedHttpException(
				new MessageValue( "rest-nonexistent-revision", [ 'testing' ] ),
				404
			)
		);

		$handler = $this->newHandler();
		$this->executeHandler( $handler, $request );
	}

	/**
	 * @param RevisionRecord $rev
	 * @param array $data
	 */
	private function assertResponseData( RevisionRecord $rev, array $data ): void {
		$title = $rev->getPageAsLinkTarget();

		$this->assertSame( $rev->getId(), $data['id'] );
		$this->assertSame( $rev->getSize(), $data['size'] );
		$this->assertSame( $rev->isMinor(), $data['minor'] );
		$this->assertSame(
			wfTimestampOrNull( TS_ISO_8601, $rev->getTimestamp() ),
			$data['timestamp']
		);
		$this->assertSame( $title->getArticleID(), $data['page']['id'] );
		$this->assertSame( $title->getDBkey(), $data['page']['key'] ); // assume main namespace
		$this->assertSame( $title->getText(), $data['page']['title'] ); // assume main namespace
		$this->assertSame( CONTENT_MODEL_WIKITEXT, $data['content_model'] );
		$this->assertSame( 'https://example.com/rights', $data['license']['url'] );
		$this->assertSame( 'some rights', $data['license']['title'] );
		$this->assertSame( $rev->getComment()->text, $data['comment'] );
		$this->assertSame( $rev->getUser()->getId(), $data['user']['id'] );
		$this->assertSame( $rev->getUser()->getName(), $data['user']['name'] );
	}

	/**
	 * The below 2 request are described as follows;
	 *
	 * Request One:
	 *   This request stashes data-parsoid to the parsoid output stash and caches the
	 *   stash key in ::cachedStashedKey so that we can use to perform a stash lookup
	 *   in the near future.
	 *
	 * Request Two:
	 *   This request then uses the request header ETag which is the same as that in
	 *   the cached stashed key container because during the second request, no stashing
	 *   was done and the page revision is the same. So what is in the output response headers
	 *   in the user's browser will be exactly what is in the parsoid output stash.
	 *
	 * NOTE: if we make another request which actually stashes, that cached stash key will
	 *   be updated, and we can use it to access the stash's latest entry.
	 */
	public function testExecuteStashParsoidOutput() {
		[ /* page */, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ );
		$outputStash = $this->getParsoidOutputStash();

		[ /* $html1 */, $etag1, $stashKey1 ] = $this->executeRevisionHTMLRequest(
			$revisions['first']->getId(),
			[ 'stash' => true ]
		);
		$this->assertNotNull( $outputStash->get( $stashKey1 ) );

		[ /* $html2 */, $etag2, $stashKey2 ] = $this->executeRevisionHTMLRequest(
			$revisions['first']->getId(),
			[ 'stash' => false ]
		);

		// The etags should be different, but the stash key should be identicl
		$this->assertNotSame( $etag1, $etag2 );
		$this->assertSame( $stashKey1->getKey(), $stashKey2->getKey() );

		// Ensure nothing has changed with the output stash
		$this->assertNotNull( $outputStash->get( $stashKey1 ) );

		// Make sure the output for stashed and unstashed doesn't have the same tag,
		// since it will actually be different!
		// FIXME: implement flavors
	}

	public function testETagVariesOnFormat() {
		$page = $this->getExistingTestPage();

		[ /* $html1 */, $etag1 ] =
			$this->executeRevisionHTMLRequest( $page->getLatest(), [], [ 'format' => 'html' ] );

		[ /* $html2 */, $etag2 ] =
			$this->executeRevisionHTMLRequest( $page->getLatest(), [], [ 'format' => 'with_html' ] );

		$this->assertNotSame( $etag1, $etag2 );
	}

	public function testStashingWithRateLimitExceeded() {
		// Set the rate limit to 1 request per minute
		$this->overrideConfigValue(
			MainConfigNames::RateLimits,
			[
				'stashbasehtml' => [
					'&can-bypass' => false,
					'ip' => [ 1, 60 ],
					'newbie' => [ 1, 60 ]
				]
			]
		);

		$page = $this->getExistingTestPage();

		$authority = $this->getAuthority();
		$this->executeRevisionHTMLRequest( $page->getLatest(), [ 'stash' => true ], [], $authority );
		// In this request, the rate limit has been exceeded, so it should throw.
		$this->expectException( LocalizedHttpException::class );
		$this->expectExceptionCode( 429 );
		$this->executeRevisionHTMLRequest( $page->getLatest(), [ 'stash' => true ], [], $authority );
	}

	/**
	 * @dataProvider provideExecuteWithVariant
	 */
	public function testExecuteWithVariant(
		string $format,
		callable $bodyHtmlHandler,
		string $expectedContentLanguage,
		string $expectedVaryHeader
	) {
		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, true );
		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, '<p>test language conversion</p>', 'Edited a page' );
		$revRecord = $page->getRevisionRecord();

		$acceptLanguage = 'en-x-piglatin';
		$request = new RequestData(
			[
				'pathParams' => [ 'id' => $revRecord->getId() ],
				'headers' => [
					'Accept-Language' => $acceptLanguage
				]
			]
		);

		$handler = $this->newHandler();
		$response = $this->executeHandler( $handler, $request, [
			'format' => $format
		] );

		$responseBody = json_decode( $response->getBody(), true );
		$htmlBody = $bodyHtmlHandler( $response->getBody() );
		$contentLanguageHeader = $response->getHeaderLine( 'Content-Language' );
		$varyHeader = $response->getHeaderLine( 'Vary' );

		// html format doesn't return a response in JSON format
		if ( $responseBody ) {
			$this->assertResponseData( $revRecord, $responseBody );
		}
		$this->assertStringContainsString( '>esttay anguagelay onversioncay<', $htmlBody );
		$this->assertEquals( $expectedContentLanguage, $contentLanguageHeader );
		$this->assertStringContainsStringIgnoringCase( $expectedVaryHeader, $varyHeader );
		$this->assertStringContainsString( $acceptLanguage, $response->getHeaderLine( 'ETag' ) );
	}

	public static function provideExecuteWithVariant() {
		yield 'with_html request should contain accept language but not content language' => [
			'with_html',
			static function ( StreamInterface $response ) {
				return json_decode( $response->getContents(), true )['html'];
			},
			'',
			'accept-language'
		];
		yield 'html request should contain accept and content language' => [
			'html',
			static function ( StreamInterface $response ) {
				return $response->getContents();
			},
			'en-x-piglatin',
			'accept-language'
		];
	}
}
PK       !  JOR  R  2  Rest/Handler/data/Transform/MainPage-original.htmlnu Iw        <!DOCTYPE html>
<html prefix="dc: http://purl.org/dc/terms/ mw: http://mediawiki.org/rdf/" about="http://localhost/index.php/Special:Redirect/revision/1"><head prefix="mwr: http://localhost/index.php/Special:Redirect/"><meta property="mw:articleNamespace" content="0"/><link rel="dc:replaces" resource="mwr:revision/0"/><meta property="dc:modified" content="2014-09-12T22:46:59.000Z"/><meta about="mwr:user/0" property="dc:title" content="MediaWiki default"/><link rel="dc:contributor" resource="mwr:user/0"/><meta property="mw:revisionSHA1" content="8e0aa2f2a7829587801db67d0424d9b447e09867"/><meta property="dc:description" content=""/><link rel="dc:isVersionOf" href="http://localhost/index.php/Main_Page"/><title>Main_Page</title><base href="http://localhost/index.php/"/><link rel="stylesheet" href="//localhost/load.php?modules=mediawiki.legacy.commonPrint,shared|mediawiki.skinning.elements|mediawiki.skinning.content|mediawiki.skinning.interface|skins.vector.styles|site|mediawiki.skinning.content.parsoid&amp;only=styles&amp;debug=true&amp;skin=vector"/></head><body id="mwAA" lang="en" class="mw-content-ltr sitedir-ltr ltr mw-body mw-body-content mediawiki" dir="ltr"><p id="mwAQ"><strong id="mwAg">MediaWiki has been successfully installed.</strong></p>

<p id="mwAw">Consult the <a rel="mw:ExtLink" href="//meta.wikimedia.org/wiki/Help:Contents" id="mwBA">User's Guide</a> for information on using the wiki software.</p>

<h2 id="mwBQ"> Getting started </h2>
<ul id="mwBg"><li id="mwBw"> <a rel="mw:ExtLink" href="//www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings" id="mwCA">Configuration settings list</a></li>
<li id="mwCQ"> <a rel="mw:ExtLink" href="//www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ" id="mwCg">MediaWiki FAQ</a></li>
<li id="mwCw"> <a rel="mw:ExtLink" href="https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce" id="mwDA">MediaWiki release mailing list</a></li>
<li id="mwDQ"> <a rel="mw:ExtLink" href="//www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources" id="mwDg">Localise MediaWiki for your language</a></li></ul></body></html>PK       ! I    :  Rest/Handler/data/Transform/MainPage-original.data-parsoidnu Iw        {
	"counter": 14,
	"ids": {
		"mwAA": { "dsr": [ 0, 592, 0, 0 ] },
		"mwAQ": { "dsr": [ 0, 59, 0, 0 ] },
		"mwAg": { "stx": "html", "dsr": [ 0, 59, 8, 9 ] },
		"mwAw": { "dsr": [ 61, 171, 0, 0 ] },
		"mwBA": { "dsr": [ 73, 127, 41, 1 ] },
		"mwBQ": { "dsr": [ 173, 194, 2, 2 ] },
		"mwBg": { "dsr": [ 195, 592, 0, 0 ] },
		"mwBw": { "dsr": [ 195, 300, 1, 0 ] },
		"mwCA": { "dsr": [ 197, 300, 75, 1 ] },
		"mwCQ": { "dsr": [ 301, 373, 1, 0 ] },
		"mwCg": { "dsr": [ 303, 373, 56, 1 ] },
		"mwCw": { "dsr": [ 374, 472, 1, 0 ] },
		"mwDA": { "dsr": [ 376, 472, 65, 1 ] },
		"mwDQ": { "dsr": [ 473, 592, 1, 0 ] },
		"mwDg": { "dsr": [ 475, 592, 80, 1 ] }
	}
}
PK       ! |	  	  6  Rest/Handler/data/Transform/MainPage-data-parsoid.htmlnu Iw        <!DOCTYPE html>
<html prefix="dc: http://purl.org/dc/terms/ mw: http://mediawiki.org/rdf/" about="http://localhost/index.php/Special:Redirect/revision/1"><head prefix="mwr: http://localhost/index.php/Special:Redirect/"><meta property="mw:articleNamespace" content="0"/><link rel="dc:replaces" resource="mwr:revision/0"/><meta property="dc:modified" content="2014-09-12T22:46:59.000Z"/><meta about="mwr:user/0" property="dc:title" content="MediaWiki default"/><link rel="dc:contributor" resource="mwr:user/0"/><meta property="mw:revisionSHA1" content="8e0aa2f2a7829587801db67d0424d9b447e09867"/><meta property="dc:description" content=""/><link rel="dc:isVersionOf" href="http://localhost/index.php/Main_Page"/><title>Main_Page</title><base href="http://localhost/index.php/"/><link rel="stylesheet" href="//localhost/load.php?modules=mediawiki.legacy.commonPrint,shared|mediawiki.skinning.elements|mediawiki.skinning.content|mediawiki.skinning.interface|skins.vector.styles|site|mediawiki.skinning.content.parsoid&amp;only=styles&amp;debug=true&amp;skin=vector"/></head><body data-parsoid='{"dsr":[0,592,0,0]}' lang="en" class="mw-content-ltr sitedir-ltr ltr mw-body mw-body-content mediawiki" dir="ltr"><p data-parsoid='{"dsr":[0,59,0,0]}'><strong data-parsoid='{"stx":"html","dsr":[0,59,8,9]}'>MediaWiki has been successfully installed.</strong></p>

<p data-parsoid='{"dsr":[61,171,0,0]}'>Consult the <a rel="mw:ExtLink" href="//meta.wikimedia.org/wiki/Help:Contents" data-parsoid='{"dsr":[73,127,41,1]}'>User's Guide</a> for information on using the wiki software.</p>

<h2 data-parsoid='{"dsr":[173,194,2,2]}'> Getting started </h2>
<ul data-parsoid='{"dsr":[195,592,0,0]}'><li data-parsoid='{"dsr":[195,300,1,0]}'> <a rel="mw:ExtLink" href="//www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings" data-parsoid='{"dsr":[197,300,75,1]}'>Configuration settings list</a></li>
<li data-parsoid='{"dsr":[301,373,1,0]}'> <a rel="mw:ExtLink" href="//www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ" data-parsoid='{"dsr":[303,373,56,1]}'>MediaWiki FAQ</a></li>
<li data-parsoid='{"dsr":[374,472,1,0]}'> <a rel="mw:ExtLink" href="https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce" data-parsoid='{"dsr":[376,472,65,1]}'>MediaWiki release mailing list</a></li>
<li data-parsoid='{"dsr":[473,592,1,0]}'> <a rel="mw:ExtLink" href="//www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources" data-parsoid='{"dsr":[475,592,80,1]}'>Localise MediaWiki for your language</a></li></ul></body></html>PK       !     ,  Rest/Handler/data/Transform/Minimal-999.htmlnu Iw        <!DOCTYPE html>
<html><head><meta charset="utf-8"/><meta property="mw:html:version" content="999.0.0"/></head><body id="mwAA" lang="en" class="mw-content-ltr sitedir-ltr ltr mw-body-content parsoid-body mediawiki mw-parser-output" dir="ltr">123</body></html>
PK       ! 7      (  Rest/Handler/data/Transform/Minimal.htmlnu Iw        <!DOCTYPE html>
<html><head><meta charset="utf-8"/><meta property="mw:htmlVersion" content="2.4.0"/></head><body id="mwAA" lang="en" class="mw-content-ltr sitedir-ltr ltr mw-body-content parsoid-body mediawiki mw-parser-output" dir="ltr">123</body></html>PK       ! uw@  @  '  Rest/Handler/data/Transform/Selser.htmlnu Iw        <!DOCTYPE html>
<html prefix="dc: http://purl.org/dc/terms/ mw: http://mediawiki.org/rdf/" about="http://host.docker.internal:8888/w/index.php/Special:Redirect/revision/2"><head prefix="mwr: http://host.docker.internal:8888/w/index.php/Special:Redirect/"><meta charset="utf-8"/><meta property="mw:pageId" content="2"/><meta property="mw:pageNamespace" content="0"/><link rel="dc:replaces" resource="mwr:revision/0"/><meta property="mw:revisionSHA1" content="a599f2d6421dd38f1eb942c68e4077b6a8f243a4"/><meta property="dc:modified" content="2022-09-08T08:40:31.000Z"/><meta property="mw:htmlVersion" content="2.6.0"/><meta property="mw:html:version" content="2.6.0"/><link rel="dc:isVersionOf" href="http://host.docker.internal:8888/w/index.php/Test_html2wt"/><base href="http://host.docker.internal:8888/w/index.php/"/><title>Test html2wt</title><link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=mediawiki.skinning.content.parsoid%7Cmediawiki.skinning.interface%7Csite.styles&amp;only=styles&amp;skin=vector"/><meta http-equiv="content-language" content="en"/><meta http-equiv="vary" content="Accept"/></head><body id="mwAA" lang="en" class="mw-content-ltr sitedir-ltr ltr mw-body-content parsoid-body mediawiki mw-parser-output" dir="ltr"><section data-mw-section-id="0" id="mwAQ"><div id="mwAg">Turaco</div></section></body></html>
PK       ! :Q  Q  5  Rest/Handler/data/Transform/OriginalMainPage.wikitextnu Iw        <strong>MediaWiki has been successfully installed.</strong>

Consult the [//meta.wikimedia.org/wiki/Help:Contents User's Guide] for information on using the wiki software.

== Getting started ==
* [//www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]
* [//www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]
* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]
* [//www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]
PK       ! o;  ;  &  Rest/Handler/data/Transform/Image.htmlnu Iw        <p><span class="mw-default-size" typeof="mw:Image" id="mwAg"><a href="./File:Foobar.jpg" id="mwAw"><img resource="./File:Foobar.jpg" src="//upload.wikimedia.org/wikipedia/commons/3/3a/Foobar.jpg" data-file-width="240" data-file-height="28" data-file-type="bitmap" height="28" width="240" id="mwBA"/></a></span></p>
PK       !      +  Rest/Handler/data/Transform/JsonConfig.htmlnu Iw        <!DOCTYPE html>
<html prefix="dc: http://purl.org/dc/terms/ mw: http://mediawiki.org/rdf/"><head prefix="mwr: http://en.wikipedia.org/wiki/Special:Redirect/"><meta charset="utf-8"/><meta property="mw:articleNamespace" content="0"/><link rel="dc:isVersionOf" href="//en.wikipedia.org/wiki/Main_Page"/><title></title><base href="//en.wikipedia.org/wiki/"/><link rel="stylesheet" href="//en.wikipedia.org/w/load.php?modules=mediawiki.legacy.commonPrint,shared|mediawiki.skinning.elements|mediawiki.skinning.content|mediawiki.skinning.interface|skins.vector.styles|site|mediawiki.skinning.content.parsoid|ext.cite.style&amp;only=styles&amp;skin=vector"/></head><body lang="en" class="mw-content-ltr sitedir-ltr ltr mw-body mw-body-content mediawiki" dir="ltr"><table class="mw-json mw-json-object"><tbody><tr><th>a</th><td class="value mw-json-number">4</td></tr><tr><th>b</th><td class="value mw-json-number">3</td></tr></tbody></table></body></html>PK       ! #H  H  .  Rest/Handler/data/Transform/Image-data-mw.htmlnu Iw        <p><span class="mw-default-size" typeof="mw:Image" data-mw="{}" id="mwAg"><a href="./File:Foobar.jpg" id="mwAw"><img resource="./File:Foobar.jpg" src="//upload.wikimedia.org/wikipedia/commons/3/3a/Foobar.jpg" data-file-width="240" data-file-height="28" data-file-type="bitmap" height="28" width="240" id="mwBA"/></a></span></p>
PK       ! Zs!
  !
  <  Rest/Handler/data/Transform/MainPage-data-parsoid-1.1.1.htmlnu Iw        <!DOCTYPE html>
<html prefix="dc: http://purl.org/dc/terms/ mw: http://mediawiki.org/rdf/" about="http://localhost/index.php/Special:Redirect/revision/1"><head prefix="mwr: http://localhost/index.php/Special:Redirect/"><meta property="mw:articleNamespace" content="0"/><link rel="dc:replaces" resource="mwr:revision/0"/><meta property="dc:modified" content="2014-09-12T22:46:59.000Z"/><meta about="mwr:user/0" property="dc:title" content="MediaWiki default"/><link rel="dc:contributor" resource="mwr:user/0"/><meta property="mw:revisionSHA1" content="8e0aa2f2a7829587801db67d0424d9b447e09867"/><meta property="dc:description" content=""/><meta property="mw:html:version" content="1.1.1"/><link rel="dc:isVersionOf" href="http://localhost/index.php/Main_Page"/><title>Main_Page</title><base href="http://localhost/index.php/"/><link rel="stylesheet" href="//localhost/load.php?modules=mediawiki.legacy.commonPrint,shared|mediawiki.skinning.elements|mediawiki.skinning.content|mediawiki.skinning.interface|skins.vector.styles|site|mediawiki.skinning.content.parsoid&amp;only=styles&amp;debug=true&amp;skin=vector"/></head><body data-parsoid='{"dsr":[0,592,0,0]}' lang="en" class="mw-content-ltr sitedir-ltr ltr mw-body mw-body-content mediawiki" dir="ltr"><p data-parsoid='{"dsr":[0,59,0,0]}'><strong data-parsoid='{"stx":"html","dsr":[0,59,8,9]}'>MediaWiki has been successfully installed.</strong></p>

<p data-parsoid='{"dsr":[61,171,0,0]}'>Consult the <a rel="mw:ExtLink" href="//meta.wikimedia.org/wiki/Help:Contents" data-parsoid='{"dsr":[73,127,41,1]}'>User's Guide</a> for information on using the wiki software.</p>

<h2 data-parsoid='{"dsr":[173,194,2,2]}'> Getting started </h2>
<ul data-parsoid='{"dsr":[195,592,0,0]}'><li data-parsoid='{"dsr":[195,300,1,0]}'> <a rel="mw:ExtLink" href="//www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings" data-parsoid='{"dsr":[197,300,75,1]}'>Configuration settings list</a></li>
<li data-parsoid='{"dsr":[301,373,1,0]}'> <a rel="mw:ExtLink" href="//www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ" data-parsoid='{"dsr":[303,373,56,1]}'>MediaWiki FAQ</a></li>
<li data-parsoid='{"dsr":[374,472,1,0]}'> <a rel="mw:ExtLink" href="https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce" data-parsoid='{"dsr":[376,472,65,1]}'>MediaWiki release mailing list</a></li>
<li data-parsoid='{"dsr":[473,592,1,0]}'> <a rel="mw:ExtLink" href="//www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources" data-parsoid='{"dsr":[475,592,80,1]}'>Localise MediaWiki for your language</a></li></ul></body></html>PK       !     -  Rest/Handler/data/Transform/Minimal-2222.htmlnu Iw        <!DOCTYPE html>
<html><head><meta charset="utf-8"/><meta property="mw:html:version" content="2222.0.0"/></head><body id="mwAA" lang="en" class="mw-content-ltr sitedir-ltr ltr mw-body-content parsoid-body mediawiki mw-parser-output" dir="ltr">123</body></html>PK       ! m  m  "  Rest/Handler/data/OpenApi-3.0.jsonnu Iw        {
	"id": "https://spec.openapis.org/oas/3.0/schema/2021-09-28",
	"$schema": "http://json-schema.org/draft-04/schema#",
	"description": "The description of OpenAPI v3.0.x documents, as defined by https://spec.openapis.org/oas/v3.0.3",
	"type": "object",
	"required": [
		"openapi",
		"info",
		"paths"
	],
	"properties": {
		"openapi": {
			"type": "string",
			"pattern": "^3\\.0\\.\\d(-.+)?$"
		},
		"info": {
			"$ref": "#/definitions/Info"
		},
		"externalDocs": {
			"$ref": "#/definitions/ExternalDocumentation"
		},
		"servers": {
			"type": "array",
			"items": {
				"$ref": "#/definitions/Server"
			}
		},
		"security": {
			"type": "array",
			"items": {
				"$ref": "#/definitions/SecurityRequirement"
			}
		},
		"tags": {
			"type": "array",
			"items": {
				"$ref": "#/definitions/Tag"
			},
			"uniqueItems": true
		},
		"paths": {
			"$ref": "#/definitions/Paths"
		},
		"components": {
			"$ref": "#/definitions/Components"
		}
	},
	"patternProperties": {
		"^x-": {
		}
	},
	"additionalProperties": false,
	"definitions": {
		"Reference": {
			"type": "object",
			"required": [
				"$ref"
			],
			"patternProperties": {
				"^\\$ref$": {
					"type": "string",
					"format": "uri-reference"
				}
			}
		},
		"Info": {
			"type": "object",
			"required": [
				"title",
				"version"
			],
			"properties": {
				"title": {
					"type": "string"
				},
				"description": {
					"type": "string"
				},
				"termsOfService": {
					"type": "string",
					"format": "uri-reference"
				},
				"contact": {
					"$ref": "#/definitions/Contact"
				},
				"license": {
					"$ref": "#/definitions/License"
				},
				"version": {
					"type": "string"
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"Contact": {
			"type": "object",
			"properties": {
				"name": {
					"type": "string"
				},
				"url": {
					"type": "string",
					"format": "uri-reference"
				},
				"email": {
					"type": "string",
					"format": "email"
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"License": {
			"type": "object",
			"required": [
				"name"
			],
			"properties": {
				"name": {
					"type": "string"
				},
				"url": {
					"type": "string",
					"format": "uri-reference"
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"Server": {
			"type": "object",
			"required": [
				"url"
			],
			"properties": {
				"url": {
					"type": "string"
				},
				"description": {
					"type": "string"
				},
				"variables": {
					"type": "object",
					"additionalProperties": {
						"$ref": "#/definitions/ServerVariable"
					}
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"ServerVariable": {
			"type": "object",
			"required": [
				"default"
			],
			"properties": {
				"enum": {
					"type": "array",
					"items": {
						"type": "string"
					}
				},
				"default": {
					"type": "string"
				},
				"description": {
					"type": "string"
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"Components": {
			"type": "object",
			"properties": {
				"schemas": {
					"type": "object",
					"patternProperties": {
						"^[a-zA-Z0-9\\.\\-_]+$": {
							"oneOf": [
								{
									"$ref": "#/definitions/Schema"
								},
								{
									"$ref": "#/definitions/Reference"
								}
							]
						}
					}
				},
				"responses": {
					"type": "object",
					"patternProperties": {
						"^[a-zA-Z0-9\\.\\-_]+$": {
							"oneOf": [
								{
									"$ref": "#/definitions/Reference"
								},
								{
									"$ref": "#/definitions/Response"
								}
							]
						}
					}
				},
				"parameters": {
					"type": "object",
					"patternProperties": {
						"^[a-zA-Z0-9\\.\\-_]+$": {
							"oneOf": [
								{
									"$ref": "#/definitions/Reference"
								},
								{
									"$ref": "#/definitions/Parameter"
								}
							]
						}
					}
				},
				"examples": {
					"type": "object",
					"patternProperties": {
						"^[a-zA-Z0-9\\.\\-_]+$": {
							"oneOf": [
								{
									"$ref": "#/definitions/Reference"
								},
								{
									"$ref": "#/definitions/Example"
								}
							]
						}
					}
				},
				"requestBodies": {
					"type": "object",
					"patternProperties": {
						"^[a-zA-Z0-9\\.\\-_]+$": {
							"oneOf": [
								{
									"$ref": "#/definitions/Reference"
								},
								{
									"$ref": "#/definitions/RequestBody"
								}
							]
						}
					}
				},
				"headers": {
					"type": "object",
					"patternProperties": {
						"^[a-zA-Z0-9\\.\\-_]+$": {
							"oneOf": [
								{
									"$ref": "#/definitions/Reference"
								},
								{
									"$ref": "#/definitions/Header"
								}
							]
						}
					}
				},
				"securitySchemes": {
					"type": "object",
					"patternProperties": {
						"^[a-zA-Z0-9\\.\\-_]+$": {
							"oneOf": [
								{
									"$ref": "#/definitions/Reference"
								},
								{
									"$ref": "#/definitions/SecurityScheme"
								}
							]
						}
					}
				},
				"links": {
					"type": "object",
					"patternProperties": {
						"^[a-zA-Z0-9\\.\\-_]+$": {
							"oneOf": [
								{
									"$ref": "#/definitions/Reference"
								},
								{
									"$ref": "#/definitions/Link"
								}
							]
						}
					}
				},
				"callbacks": {
					"type": "object",
					"patternProperties": {
						"^[a-zA-Z0-9\\.\\-_]+$": {
							"oneOf": [
								{
									"$ref": "#/definitions/Reference"
								},
								{
									"$ref": "#/definitions/Callback"
								}
							]
						}
					}
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"Schema": {
			"type": "object",
			"properties": {
				"title": {
					"type": "string"
				},
				"multipleOf": {
					"type": "number",
					"minimum": 0,
					"exclusiveMinimum": true
				},
				"maximum": {
					"type": "number"
				},
				"exclusiveMaximum": {
					"type": "boolean",
					"default": false
				},
				"minimum": {
					"type": "number"
				},
				"exclusiveMinimum": {
					"type": "boolean",
					"default": false
				},
				"maxLength": {
					"type": "integer",
					"minimum": 0
				},
				"minLength": {
					"type": "integer",
					"minimum": 0,
					"default": 0
				},
				"pattern": {
					"type": "string",
					"format": "regex"
				},
				"maxItems": {
					"type": "integer",
					"minimum": 0
				},
				"minItems": {
					"type": "integer",
					"minimum": 0,
					"default": 0
				},
				"uniqueItems": {
					"type": "boolean",
					"default": false
				},
				"maxProperties": {
					"type": "integer",
					"minimum": 0
				},
				"minProperties": {
					"type": "integer",
					"minimum": 0,
					"default": 0
				},
				"required": {
					"type": "array",
					"items": {
						"type": "string"
					},
					"minItems": 1,
					"uniqueItems": true
				},
				"enum": {
					"type": "array",
					"items": {
					},
					"minItems": 1,
					"uniqueItems": false
				},
				"type": {
					"type": "string",
					"enum": [
						"array",
						"boolean",
						"integer",
						"number",
						"object",
						"string"
					]
				},
				"not": {
					"oneOf": [
						{
							"$ref": "#/definitions/Schema"
						},
						{
							"$ref": "#/definitions/Reference"
						}
					]
				},
				"allOf": {
					"type": "array",
					"items": {
						"oneOf": [
							{
								"$ref": "#/definitions/Schema"
							},
							{
								"$ref": "#/definitions/Reference"
							}
						]
					}
				},
				"oneOf": {
					"type": "array",
					"items": {
						"oneOf": [
							{
								"$ref": "#/definitions/Schema"
							},
							{
								"$ref": "#/definitions/Reference"
							}
						]
					}
				},
				"anyOf": {
					"type": "array",
					"items": {
						"oneOf": [
							{
								"$ref": "#/definitions/Schema"
							},
							{
								"$ref": "#/definitions/Reference"
							}
						]
					}
				},
				"items": {
					"oneOf": [
						{
							"$ref": "#/definitions/Schema"
						},
						{
							"$ref": "#/definitions/Reference"
						}
					]
				},
				"properties": {
					"type": "object",
					"additionalProperties": {
						"oneOf": [
							{
								"$ref": "#/definitions/Schema"
							},
							{
								"$ref": "#/definitions/Reference"
							}
						]
					}
				},
				"additionalProperties": {
					"oneOf": [
						{
							"$ref": "#/definitions/Schema"
						},
						{
							"$ref": "#/definitions/Reference"
						},
						{
							"type": "boolean"
						}
					],
					"default": true
				},
				"description": {
					"type": "string"
				},
				"format": {
					"type": "string"
				},
				"default": {
				},
				"nullable": {
					"type": "boolean",
					"default": false
				},
				"discriminator": {
					"$ref": "#/definitions/Discriminator"
				},
				"readOnly": {
					"type": "boolean",
					"default": false
				},
				"writeOnly": {
					"type": "boolean",
					"default": false
				},
				"example": {
				},
				"externalDocs": {
					"$ref": "#/definitions/ExternalDocumentation"
				},
				"deprecated": {
					"type": "boolean",
					"default": false
				},
				"xml": {
					"$ref": "#/definitions/XML"
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"Discriminator": {
			"type": "object",
			"required": [
				"propertyName"
			],
			"properties": {
				"propertyName": {
					"type": "string"
				},
				"mapping": {
					"type": "object",
					"additionalProperties": {
						"type": "string"
					}
				}
			}
		},
		"XML": {
			"type": "object",
			"properties": {
				"name": {
					"type": "string"
				},
				"namespace": {
					"type": "string",
					"format": "uri"
				},
				"prefix": {
					"type": "string"
				},
				"attribute": {
					"type": "boolean",
					"default": false
				},
				"wrapped": {
					"type": "boolean",
					"default": false
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"Response": {
			"type": "object",
			"required": [
				"description"
			],
			"properties": {
				"description": {
					"type": "string"
				},
				"headers": {
					"type": "object",
					"additionalProperties": {
						"oneOf": [
							{
								"$ref": "#/definitions/Header"
							},
							{
								"$ref": "#/definitions/Reference"
							}
						]
					}
				},
				"content": {
					"type": "object",
					"additionalProperties": {
						"$ref": "#/definitions/MediaType"
					}
				},
				"links": {
					"type": "object",
					"additionalProperties": {
						"oneOf": [
							{
								"$ref": "#/definitions/Link"
							},
							{
								"$ref": "#/definitions/Reference"
							}
						]
					}
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"MediaType": {
			"type": "object",
			"properties": {
				"schema": {
					"oneOf": [
						{
							"$ref": "#/definitions/Schema"
						},
						{
							"$ref": "#/definitions/Reference"
						}
					]
				},
				"example": {
				},
				"examples": {
					"type": "object",
					"additionalProperties": {
						"oneOf": [
							{
								"$ref": "#/definitions/Example"
							},
							{
								"$ref": "#/definitions/Reference"
							}
						]
					}
				},
				"encoding": {
					"type": "object",
					"additionalProperties": {
						"$ref": "#/definitions/Encoding"
					}
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false,
			"allOf": [
				{
					"$ref": "#/definitions/ExampleXORExamples"
				}
			]
		},
		"Example": {
			"type": "object",
			"properties": {
				"summary": {
					"type": "string"
				},
				"description": {
					"type": "string"
				},
				"value": {
				},
				"externalValue": {
					"type": "string",
					"format": "uri-reference"
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"Header": {
			"type": "object",
			"properties": {
				"description": {
					"type": "string"
				},
				"required": {
					"type": "boolean",
					"default": false
				},
				"deprecated": {
					"type": "boolean",
					"default": false
				},
				"allowEmptyValue": {
					"type": "boolean",
					"default": false
				},
				"style": {
					"type": "string",
					"enum": [
						"simple"
					],
					"default": "simple"
				},
				"explode": {
					"type": "boolean"
				},
				"allowReserved": {
					"type": "boolean",
					"default": false
				},
				"schema": {
					"oneOf": [
						{
							"$ref": "#/definitions/Schema"
						},
						{
							"$ref": "#/definitions/Reference"
						}
					]
				},
				"content": {
					"type": "object",
					"additionalProperties": {
						"$ref": "#/definitions/MediaType"
					},
					"minProperties": 1,
					"maxProperties": 1
				},
				"example": {
				},
				"examples": {
					"type": "object",
					"additionalProperties": {
						"oneOf": [
							{
								"$ref": "#/definitions/Example"
							},
							{
								"$ref": "#/definitions/Reference"
							}
						]
					}
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false,
			"allOf": [
				{
					"$ref": "#/definitions/ExampleXORExamples"
				},
				{
					"$ref": "#/definitions/SchemaXORContent"
				}
			]
		},
		"Paths": {
			"type": "object",
			"patternProperties": {
				"^\\/": {
					"$ref": "#/definitions/PathItem"
				},
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"PathItem": {
			"type": "object",
			"properties": {
				"$ref": {
					"type": "string"
				},
				"summary": {
					"type": "string"
				},
				"description": {
					"type": "string"
				},
				"servers": {
					"type": "array",
					"items": {
						"$ref": "#/definitions/Server"
					}
				},
				"parameters": {
					"type": "array",
					"items": {
						"oneOf": [
							{
								"$ref": "#/definitions/Parameter"
							},
							{
								"$ref": "#/definitions/Reference"
							}
						]
					},
					"uniqueItems": true
				}
			},
			"patternProperties": {
				"^(get|put|post|delete|options|head|patch|trace)$": {
					"$ref": "#/definitions/Operation"
				},
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"Operation": {
			"type": "object",
			"required": [
				"responses"
			],
			"properties": {
				"tags": {
					"type": "array",
					"items": {
						"type": "string"
					}
				},
				"summary": {
					"type": "string"
				},
				"description": {
					"type": "string"
				},
				"externalDocs": {
					"$ref": "#/definitions/ExternalDocumentation"
				},
				"operationId": {
					"type": "string"
				},
				"parameters": {
					"type": "array",
					"items": {
						"oneOf": [
							{
								"$ref": "#/definitions/Parameter"
							},
							{
								"$ref": "#/definitions/Reference"
							}
						]
					},
					"uniqueItems": true
				},
				"requestBody": {
					"oneOf": [
						{
							"$ref": "#/definitions/RequestBody"
						},
						{
							"$ref": "#/definitions/Reference"
						}
					]
				},
				"responses": {
					"$ref": "#/definitions/Responses"
				},
				"callbacks": {
					"type": "object",
					"additionalProperties": {
						"oneOf": [
							{
								"$ref": "#/definitions/Callback"
							},
							{
								"$ref": "#/definitions/Reference"
							}
						]
					}
				},
				"deprecated": {
					"type": "boolean",
					"default": false
				},
				"security": {
					"type": "array",
					"items": {
						"$ref": "#/definitions/SecurityRequirement"
					}
				},
				"servers": {
					"type": "array",
					"items": {
						"$ref": "#/definitions/Server"
					}
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"Responses": {
			"type": "object",
			"properties": {
				"default": {
					"oneOf": [
						{
							"$ref": "#/definitions/Response"
						},
						{
							"$ref": "#/definitions/Reference"
						}
					]
				}
			},
			"patternProperties": {
				"^[1-5](?:\\d{2}|XX)$": {
					"oneOf": [
						{
							"$ref": "#/definitions/Response"
						},
						{
							"$ref": "#/definitions/Reference"
						}
					]
				},
				"^x-": {
				}
			},
			"minProperties": 1,
			"additionalProperties": false
		},
		"SecurityRequirement": {
			"type": "object",
			"additionalProperties": {
				"type": "array",
				"items": {
					"type": "string"
				}
			}
		},
		"Tag": {
			"type": "object",
			"required": [
				"name"
			],
			"properties": {
				"name": {
					"type": "string"
				},
				"description": {
					"type": "string"
				},
				"externalDocs": {
					"$ref": "#/definitions/ExternalDocumentation"
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"ExternalDocumentation": {
			"type": "object",
			"required": [
				"url"
			],
			"properties": {
				"description": {
					"type": "string"
				},
				"url": {
					"type": "string",
					"format": "uri-reference"
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"ExampleXORExamples": {
			"description": "Example and examples are mutually exclusive",
			"not": {
				"required": [
					"example",
					"examples"
				]
			}
		},
		"SchemaXORContent": {
			"description": "Schema and content are mutually exclusive, at least one is required",
			"not": {
				"required": [
					"schema",
					"content"
				]
			},
			"oneOf": [
				{
					"required": [
						"schema"
					]
				},
				{
					"required": [
						"content"
					],
					"description": "Some properties are not allowed if content is present",
					"allOf": [
						{
							"not": {
								"required": [
									"style"
								]
							}
						},
						{
							"not": {
								"required": [
									"explode"
								]
							}
						},
						{
							"not": {
								"required": [
									"allowReserved"
								]
							}
						},
						{
							"not": {
								"required": [
									"example"
								]
							}
						},
						{
							"not": {
								"required": [
									"examples"
								]
							}
						}
					]
				}
			]
		},
		"Parameter": {
			"type": "object",
			"properties": {
				"name": {
					"type": "string"
				},
				"in": {
					"type": "string"
				},
				"description": {
					"type": "string"
				},
				"required": {
					"type": "boolean",
					"default": false
				},
				"deprecated": {
					"type": "boolean",
					"default": false
				},
				"allowEmptyValue": {
					"type": "boolean",
					"default": false
				},
				"style": {
					"type": "string"
				},
				"explode": {
					"type": "boolean"
				},
				"allowReserved": {
					"type": "boolean",
					"default": false
				},
				"schema": {
					"oneOf": [
						{
							"$ref": "#/definitions/Schema"
						},
						{
							"$ref": "#/definitions/Reference"
						}
					]
				},
				"content": {
					"type": "object",
					"additionalProperties": {
						"$ref": "#/definitions/MediaType"
					},
					"minProperties": 1,
					"maxProperties": 1
				},
				"example": {
				},
				"examples": {
					"type": "object",
					"additionalProperties": {
						"oneOf": [
							{
								"$ref": "#/definitions/Example"
							},
							{
								"$ref": "#/definitions/Reference"
							}
						]
					}
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false,
			"required": [
				"name",
				"in"
			],
			"allOf": [
				{
					"$ref": "#/definitions/ExampleXORExamples"
				},
				{
					"$ref": "#/definitions/SchemaXORContent"
				},
				{
					"$ref": "#/definitions/ParameterLocation"
				}
			]
		},
		"ParameterLocation": {
			"description": "Parameter location",
			"oneOf": [
				{
					"description": "Parameter in path",
					"required": [
						"required"
					],
					"properties": {
						"in": {
							"enum": [
								"path"
							]
						},
						"style": {
							"enum": [
								"matrix",
								"label",
								"simple"
							],
							"default": "simple"
						},
						"required": {
							"enum": [
								true
							]
						}
					}
				},
				{
					"description": "Parameter in query",
					"properties": {
						"in": {
							"enum": [
								"query"
							]
						},
						"style": {
							"enum": [
								"form",
								"spaceDelimited",
								"pipeDelimited",
								"deepObject"
							],
							"default": "form"
						}
					}
				},
				{
					"description": "Parameter in header",
					"properties": {
						"in": {
							"enum": [
								"header"
							]
						},
						"style": {
							"enum": [
								"simple"
							],
							"default": "simple"
						}
					}
				},
				{
					"description": "Parameter in cookie",
					"properties": {
						"in": {
							"enum": [
								"cookie"
							]
						},
						"style": {
							"enum": [
								"form"
							],
							"default": "form"
						}
					}
				}
			]
		},
		"RequestBody": {
			"type": "object",
			"required": [
				"content"
			],
			"properties": {
				"description": {
					"type": "string"
				},
				"content": {
					"type": "object",
					"additionalProperties": {
						"$ref": "#/definitions/MediaType"
					}
				},
				"required": {
					"type": "boolean",
					"default": false
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"SecurityScheme": {
			"oneOf": [
				{
					"$ref": "#/definitions/APIKeySecurityScheme"
				},
				{
					"$ref": "#/definitions/HTTPSecurityScheme"
				},
				{
					"$ref": "#/definitions/OAuth2SecurityScheme"
				},
				{
					"$ref": "#/definitions/OpenIdConnectSecurityScheme"
				}
			]
		},
		"APIKeySecurityScheme": {
			"type": "object",
			"required": [
				"type",
				"name",
				"in"
			],
			"properties": {
				"type": {
					"type": "string",
					"enum": [
						"apiKey"
					]
				},
				"name": {
					"type": "string"
				},
				"in": {
					"type": "string",
					"enum": [
						"header",
						"query",
						"cookie"
					]
				},
				"description": {
					"type": "string"
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"HTTPSecurityScheme": {
			"type": "object",
			"required": [
				"scheme",
				"type"
			],
			"properties": {
				"scheme": {
					"type": "string"
				},
				"bearerFormat": {
					"type": "string"
				},
				"description": {
					"type": "string"
				},
				"type": {
					"type": "string",
					"enum": [
						"http"
					]
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false,
			"oneOf": [
				{
					"description": "Bearer",
					"properties": {
						"scheme": {
							"type": "string",
							"pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$"
						}
					}
				},
				{
					"description": "Non Bearer",
					"not": {
						"required": [
							"bearerFormat"
						]
					},
					"properties": {
						"scheme": {
							"not": {
								"type": "string",
								"pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$"
							}
						}
					}
				}
			]
		},
		"OAuth2SecurityScheme": {
			"type": "object",
			"required": [
				"type",
				"flows"
			],
			"properties": {
				"type": {
					"type": "string",
					"enum": [
						"oauth2"
					]
				},
				"flows": {
					"$ref": "#/definitions/OAuthFlows"
				},
				"description": {
					"type": "string"
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"OpenIdConnectSecurityScheme": {
			"type": "object",
			"required": [
				"type",
				"openIdConnectUrl"
			],
			"properties": {
				"type": {
					"type": "string",
					"enum": [
						"openIdConnect"
					]
				},
				"openIdConnectUrl": {
					"type": "string",
					"format": "uri-reference"
				},
				"description": {
					"type": "string"
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"OAuthFlows": {
			"type": "object",
			"properties": {
				"implicit": {
					"$ref": "#/definitions/ImplicitOAuthFlow"
				},
				"password": {
					"$ref": "#/definitions/PasswordOAuthFlow"
				},
				"clientCredentials": {
					"$ref": "#/definitions/ClientCredentialsFlow"
				},
				"authorizationCode": {
					"$ref": "#/definitions/AuthorizationCodeOAuthFlow"
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"ImplicitOAuthFlow": {
			"type": "object",
			"required": [
				"authorizationUrl",
				"scopes"
			],
			"properties": {
				"authorizationUrl": {
					"type": "string",
					"format": "uri-reference"
				},
				"refreshUrl": {
					"type": "string",
					"format": "uri-reference"
				},
				"scopes": {
					"type": "object",
					"additionalProperties": {
						"type": "string"
					}
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"PasswordOAuthFlow": {
			"type": "object",
			"required": [
				"tokenUrl",
				"scopes"
			],
			"properties": {
				"tokenUrl": {
					"type": "string",
					"format": "uri-reference"
				},
				"refreshUrl": {
					"type": "string",
					"format": "uri-reference"
				},
				"scopes": {
					"type": "object",
					"additionalProperties": {
						"type": "string"
					}
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"ClientCredentialsFlow": {
			"type": "object",
			"required": [
				"tokenUrl",
				"scopes"
			],
			"properties": {
				"tokenUrl": {
					"type": "string",
					"format": "uri-reference"
				},
				"refreshUrl": {
					"type": "string",
					"format": "uri-reference"
				},
				"scopes": {
					"type": "object",
					"additionalProperties": {
						"type": "string"
					}
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"AuthorizationCodeOAuthFlow": {
			"type": "object",
			"required": [
				"authorizationUrl",
				"tokenUrl",
				"scopes"
			],
			"properties": {
				"authorizationUrl": {
					"type": "string",
					"format": "uri-reference"
				},
				"tokenUrl": {
					"type": "string",
					"format": "uri-reference"
				},
				"refreshUrl": {
					"type": "string",
					"format": "uri-reference"
				},
				"scopes": {
					"type": "object",
					"additionalProperties": {
						"type": "string"
					}
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		},
		"Link": {
			"type": "object",
			"properties": {
				"operationId": {
					"type": "string"
				},
				"operationRef": {
					"type": "string",
					"format": "uri-reference"
				},
				"parameters": {
					"type": "object",
					"additionalProperties": {
					}
				},
				"requestBody": {
				},
				"description": {
					"type": "string"
				},
				"server": {
					"$ref": "#/definitions/Server"
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false,
			"not": {
				"description": "Operation Id and Operation Ref are mutually exclusive",
				"required": [
					"operationId",
					"operationRef"
				]
			}
		},
		"Callback": {
			"type": "object",
			"additionalProperties": {
				"$ref": "#/definitions/PathItem"
			},
			"patternProperties": {
				"^x-": {
				}
			}
		},
		"Encoding": {
			"type": "object",
			"properties": {
				"contentType": {
					"type": "string"
				},
				"headers": {
					"type": "object",
					"additionalProperties": {
						"oneOf": [
							{
								"$ref": "#/definitions/Header"
							},
							{
								"$ref": "#/definitions/Reference"
							}
						]
					}
				},
				"style": {
					"type": "string",
					"enum": [
						"form",
						"spaceDelimited",
						"pipeDelimited",
						"deepObject"
					]
				},
				"explode": {
					"type": "boolean"
				},
				"allowReserved": {
					"type": "boolean",
					"default": false
				}
			},
			"patternProperties": {
				"^x-": {
				}
			},
			"additionalProperties": false
		}
	}
}
PK       ! h~m1  m1  -  Rest/Handler/Helper/PageContentHelperTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler\Helper;

use Exception;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\ExistingPageRecord;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Permissions\Authority;
use MediaWiki\Rest\Handler\Helper\PageContentHelper;
use MediaWiki\Rest\HttpException;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\Response;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Rest\Handler\Helper\PageContentHelper
 * @group Database
 */
class PageContentHelperTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;

	private const NO_REVISION_ETAG = '"7afa43d0f642f1fda1b8e30f4f67243049f5fe77"';

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

	/**
	 * @param array $params
	 * @param Authority|null $authority
	 * @return PageContentHelper
	 * @throws Exception
	 */
	private function newHelper(
		array $params = [],
		?Authority $authority = null
	): PageContentHelper {
		$helper = new PageContentHelper(
			new ServiceOptions(
				PageContentHelper::CONSTRUCTOR_OPTIONS,
				[
					MainConfigNames::RightsUrl => 'https://example.com/rights',
					MainConfigNames::RightsText => 'some rights',
				]
			),
			$this->getServiceContainer()->getRevisionLookup(),
			$this->getServiceContainer()->getTitleFormatter(),
			$this->getServiceContainer()->getPageStore(),
			$this->getServiceContainer()->getTitleFactory(),
			$this->getServiceContainer()->getConnectionProvider(),
			$this->getServiceContainer()->getChangeTagsStore()
		);

		$authority = $authority ?: $this->mockRegisteredUltimateAuthority();
		$helper->init( $authority, $params );
		return $helper;
	}

	public function testGetRole() {
		$helper = $this->newHelper();
		$this->assertSame( SlotRecord::MAIN, $helper->getRole() );
	}

	public function testGetTitle() {
		$this->getExistingTestPage( 'Foo' );

		$helper = $this->newHelper( [ 'title' => 'Foo' ] );
		$this->assertSame( 'Foo', $helper->getTitleText() );

		$this->assertInstanceOf( ExistingPageRecord::class, $helper->getPage() );
		$this->assertSame(
			'Foo',
			$this->getServiceContainer()->getTitleFormatter()->getPrefixedDBkey( $helper->getPage() )
		);
	}

	public function testGetTargetRevisionAndContent() {
		$page = $this->getExistingTestPage( __METHOD__ );
		$rev = $page->getRevisionRecord();

		$helper = $this->newHelper( [ 'title' => $page->getTitle()->getPrefixedDBkey() ] );

		$targetRev = $helper->getTargetRevision();
		$this->assertInstanceOf( RevisionRecord::class, $targetRev );
		$this->assertSame( $rev->getId(), $targetRev->getId() );

		$pageContent = $helper->getContent();
		$this->assertSame(
			$rev->getContent( SlotRecord::MAIN )->serialize(),
			$pageContent->serialize()
		);
	}

	/**
	 * Ensure we can load the page with title "0" (T353687).
	 */
	public function testT353687() {
		$page = $this->getExistingTestPage( '0' );
		$rev = $page->getRevisionRecord();

		$helper = $this->newHelper( [ 'title' => $page->getTitle()->getPrefixedDBkey() ] );

		// Key assertion: this should not throw!
		$helper->checkAccess();

		$targetRev = $helper->getTargetRevision();
		$this->assertInstanceOf( RevisionRecord::class, $targetRev );
		$this->assertSame( $rev->getId(), $targetRev->getId() );
	}

	public function testNoTitle() {
		$helper = $this->newHelper();

		$this->assertNull( $helper->getTitleText() );
		$this->assertNull( $helper->getPage() );

		$this->assertFalse( $helper->hasContent() );

		$this->assertNull( $helper->getTargetRevision() );

		try {
			$helper->getContent();
			$this->fail( 'Expected HttpException' );
		} catch ( HttpException $ex ) {
			$this->assertSame( 404, $ex->getCode() );
		}

		try {
			$helper->checkAccess();
			$this->fail( 'Expected HttpException' );
		} catch ( HttpException $ex ) {
			$this->assertSame( 404, $ex->getCode() );
		}
	}

	public static function provideBadTitle() {
		yield [ '_' ];
		yield [ '::Hello' ];
		yield [ 'Special:Blankpage' ];
	}

	/**
	 * @dataProvider provideBadTitle
	 */
	public function testBadTitle( $badTitle ) {
		$helper = $this->newHelper( [ 'title' => $badTitle ] );

		$this->assertNotNull( $helper->getTitleText() );
		$this->assertNull( $helper->getPage() );

		$this->assertFalse( $helper->hasContent() );
		$this->assertNull( $helper->getTargetRevision() );

		try {
			$helper->getContent();
			$this->fail( 'Expected HttpException' );
		} catch ( HttpException $ex ) {
			$this->assertSame( 404, $ex->getCode() );
		}

		try {
			$helper->checkAccess();
			$this->fail( 'Expected HttpException' );
		} catch ( HttpException $ex ) {
			$this->assertSame( 404, $ex->getCode() );
		}
	}

	public function testCheckHasContent() {
		$page = $this->getNonexistingTestPage( __METHOD__ )->getDBkey();
		$helper = $this->newHelper( [ 'title' => $page ] );

		$this->expectException( LocalizedHttpException::class );
		$this->expectExceptionCode( 404 );

		$helper->checkHasContent();
	}

	public function testCheckAccessPermission() {
		$helper = $this->newHelper();

		$this->expectException( LocalizedHttpException::class );
		$this->expectExceptionCode( 403 );

		$helper->checkAccessPermission();
	}

	public function testNonExistingPage() {
		$page = $this->getNonexistingTestPage( __METHOD__ );
		$title = $page->getTitle();
		$helper = $this->newHelper( [ 'title' => $title->getPrefixedDBkey() ] );

		$this->assertSame( $title->getPrefixedDBkey(), $helper->getTitleText() );
		$this->assertNull( $helper->getPage() );

		$this->assertFalse( $helper->hasContent() );
		$this->assertTrue( $helper->isAccessible() );

		$this->assertNull( $helper->getTargetRevision() );

		$this->assertNull( $helper->getLastModified() );
		$this->assertSame( self::NO_REVISION_ETAG, $helper->getETag() );

		try {
			$helper->getContent();
			$this->fail( 'Expected HttpException' );
		} catch ( HttpException $ex ) {
			$this->assertSame( 404, $ex->getCode() );
		}

		try {
			$helper->checkAccess();
			$this->fail( 'Expected HttpException' );
		} catch ( HttpException $ex ) {
			$this->assertSame( 404, $ex->getCode() );
		}
	}

	public function testForbidenPage() {
		$page = $this->getExistingTestPage( __METHOD__ );
		$title = $page->getTitle();
		$helper = $this->newHelper(
			[ 'title' => $title->getPrefixedDBkey() ],
			$this->mockAnonNullAuthority()
		);

		$this->assertSame( $title->getPrefixedDBkey(), $helper->getTitleText() );
		$this->assertTrue( $helper->getPage()->isSamePageAs( $title ) );

		$this->assertTrue( $helper->hasContent() );
		$this->assertFalse( $helper->useDefaultSystemMessage() );
		$this->assertNull( $helper->getDefaultSystemMessage() );
		$this->assertFalse( $helper->isAccessible() );

		$this->assertNull( $helper->getLastModified() );

		try {
			$helper->checkAccess();
			$this->fail( 'Expected HttpException' );
		} catch ( HttpException $ex ) {
			$this->assertSame( 403, $ex->getCode() );
		}
	}

	public function testForbiddenMessagePage() {
		$page = $this->getNonexistingTestPage(
			Title::makeTitle( NS_MEDIAWIKI, 'Logouttext' )
		);
		$title = $page->getTitle();
		$helper = $this->newHelper(
			[ 'title' => $title->getPrefixedDBkey() ],
			$this->mockAnonNullAuthority()
		);

		$this->assertSame( $title->getPrefixedDBkey(), $helper->getTitleText() );
		$this->assertNull( $helper->getPage() );

		$this->assertTrue( $helper->hasContent() );
		$this->assertTrue( $helper->useDefaultSystemMessage() );
		$this->assertNotNull( $helper->getDefaultSystemMessage() );
		$this->assertFalse( $helper->isAccessible() );

		$this->assertNull( $helper->getLastModified() );

		$this->expectException( HttpException::class );
		$this->expectExceptionCode( 403 );
		$helper->checkAccess();
	}

	public function testMessagePage() {
		$page = $this->getNonexistingTestPage(
			Title::makeTitle( NS_MEDIAWIKI, 'Logouttext' )
		);
		$title = $page->getTitle();
		$helper = $this->newHelper(
			[ 'title' => $title->getPrefixedDBkey() ],
			$this->mockAnonUltimateAuthority()
		);

		$this->assertSame( $title->getPrefixedDBkey(), $helper->getTitleText() );
		$this->assertNull( $helper->getPage() );

		$this->assertTrue( $helper->hasContent() );
		$this->assertTrue( $helper->useDefaultSystemMessage() );
		$this->assertNotNull( $helper->getDefaultSystemMessage() );
		$this->assertTrue( $helper->isAccessible() );

		$this->assertNull( $helper->getLastModified() );

		// The line below should not throw any exception
		$helper->checkAccess();
	}

	public function testParameterSettings() {
		$helper = $this->newHelper();
		$settings = $helper->getParamSettings();
		$this->assertArrayHasKey( 'title', $settings );
	}

	public function testCacheControl() {
		$helper = $this->newHelper();

		$response = new Response();

		$helper->setCacheControl( $response ); // default
		$this->assertStringContainsString( 'max-age=5', $response->getHeaderLine( 'Cache-Control' ) );

		$helper->setCacheControl( $response, 2 ); // explicit
		$this->assertStringContainsString( 'max-age=2', $response->getHeaderLine( 'Cache-Control' ) );

		$helper->setCacheControl( $response, 1000 * 1000 ); // too big
		$this->assertStringContainsString( 'max-age=5', $response->getHeaderLine( 'Cache-Control' ) );
	}

	public function testConstructMetadata() {
		$page = $this->getExistingTestPage( __METHOD__ );
		$title = $page->getTitle();

		$revision = $page->getRevisionRecord();
		$content = $revision->getContent( SlotRecord::MAIN );
		$expected = [
			'id' => $title->getArticleID(),
			'key' => $title->getPrefixedDBkey(),
			'title' => $title->getPrefixedText(),
			'latest' => [
				'id' => $revision->getId(),
				'timestamp' => wfTimestampOrNull( TS_ISO_8601, $revision->getTimestamp() )
			],
			'content_model' => $content->getModel(),
			'license' => [
				'url' => 'https://example.com/rights',
				'title' => 'some rights',
			]
		];

		$helper = $this->newHelper( [ 'title' => $title->getPrefixedDBkey() ] );
		$data = $helper->constructMetadata();

		$this->assertEquals( $expected, $data );
	}

	public function provideConstructRestbaseCompatibleMetadata() {
		$pageName = 'User:Morg';
		$page = PageIdentityValue::localIdentity( 7, NS_USER, 'Morg' );
		$user = UserIdentityValue::newRegistered( 444, 'Morg' );
		$comment = CommentStoreComment::newUnsavedComment( 'just an edit' );
		$timestamp = '20220102112233';

		$rev = new MutableRevisionRecord( $page );
		$rev->setId( 123 );
		$rev->setUser( $user );
		$rev->setComment( $comment );
		$rev->setTimestamp( $timestamp );

		$expected = [
			'title' => $pageName,
			'page_id' => 7,
			'rev' => 123,
			'tid' => 'DUMMY',
			'namespace' => NS_USER,
			'user_id' => 444,
			'user_text' => 'Morg',
			'comment' => $comment->text,
			'timestamp' => wfTimestampOrNull( TS_ISO_8601, $timestamp ),
			'tags' => [],
			'restrictions' => [],
			'page_language' => 'en',
			'redirect' => false
		];

		yield [
			$pageName,
			$rev,
			$expected
		];

		// Construct a revision with a hidden comment
		$rev = new MutableRevisionRecord( $page );
		$rev->setId( 123 );
		$rev->setUser( $user );
		$rev->setComment( $comment );
		$rev->setTimestamp( $timestamp );
		$rev->setVisibility( RevisionRecord::DELETED_COMMENT );

		$expectedHiddenComment = [
			'comment' => null,
			'restrictions' => [ 'commenthidden' ],
		] + $expected;

		yield [
			$pageName,
			$rev,
			$expectedHiddenComment
		];

		// Construct a revision with a suppressed user
		$rev = new MutableRevisionRecord( $page );
		$rev->setId( 123 );
		$rev->setUser( $user );
		$rev->setComment( $comment );
		$rev->setTimestamp( $timestamp );
		$rev->setVisibility( RevisionRecord::DELETED_USER );

		$expectedHiddenComment = [
				'user_text' => null,
				'restrictions' => [ 'userhidden' ],
			] + $expected;

		yield [
			$pageName,
			$rev,
			$expectedHiddenComment
		];
	}

	/**
	 * @dataProvider provideConstructRestbaseCompatibleMetadata
	 */
	public function testConstructRestbaseCompatibleMetadata(
		string $pageName,
		RevisionRecord $revision,
		array $expected
	) {
		$helper = $this->newHelper( [ 'title' => $pageName ] );

		$helperAccess = TestingAccessWrapper::newFromObject( $helper );
		$helperAccess->pageIdentity = $revision->getPage();
		$helperAccess->targetRevision = $revision;

		$data = $helper->constructRestbaseCompatibleMetadata();

		$this->assertEquals(
			$expected,
			$data
		);
	}

}
PK       ! +    4  Rest/Handler/Helper/HtmlOutputRendererHelperTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler\Helper;

use Exception;
use MediaWiki\Content\CssContent;
use MediaWiki\Content\IContentHandlerFactory;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Edit\ParsoidRenderID;
use MediaWiki\Edit\SimpleParsoidOutputStash;
use MediaWiki\Hook\ParserLogLinterDataHook;
use MediaWiki\Logger\Spi as LoggerSpi;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageRecord;
use MediaWiki\Page\PageReference;
use MediaWiki\Page\ParserOutputAccess;
use MediaWiki\Parser\ParserCache;
use MediaWiki\Parser\ParserCacheFactory;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\Parsoid\HtmlTransformFactory;
use MediaWiki\Parser\Parsoid\LanguageVariantConverter;
use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter;
use MediaWiki\Parser\Parsoid\ParsoidParser;
use MediaWiki\Parser\Parsoid\ParsoidParserFactory;
use MediaWiki\Parser\RevisionOutputCache;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\Response;
use MediaWiki\Rest\ResponseInterface;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionAccessException;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Status\Status;
use MediaWiki\Utils\MWTimestamp;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Rule\InvocationOrder;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Wikimedia\Bcp47Code\Bcp47Code;
use Wikimedia\Bcp47Code\Bcp47CodeValue;
use Wikimedia\Message\MessageValue;
use Wikimedia\ObjectCache\EmptyBagOStuff;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\Parsoid\Core\ClientError;
use Wikimedia\Parsoid\Core\PageBundle;
use Wikimedia\Parsoid\Core\ResourceLimitExceededException;
use Wikimedia\Parsoid\Parsoid;
use Wikimedia\Stats\StatsFactory;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper
 * @group Database
 */
class HtmlOutputRendererHelperTest extends MediaWikiIntegrationTestCase {
	private const CACHE_EPOCH = '20001111010101';

	private const TIMESTAMP_OLD = '20200101112233';
	private const TIMESTAMP = '20200101223344';
	private const TIMESTAMP_LATER = '20200101234200';

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

	private const HTML_OLD = '>Goat<';
	private const HTML = '>World<';

	private const PARAM_DEFAULTS = [
		'stash' => false,
		'flavor' => 'view',
	];

	private const MOCK_HTML = 'mocked HTML';
	private const MOCK_HTML_VARIANT = 'ockedmay HTML';

	private function exactlyOrAny( ?int $count ): InvocationOrder {
		return $count === null ? $this->any() : $this->exactly( $count );
	}

	/**
	 * @param LoggerInterface|null $logger
	 *
	 * @return LoggerSpi
	 */
	private function getLoggerSpi( $logger = null ) {
		$spi = $this->createNoOpMock( LoggerSpi::class, [ 'getLogger' ] );
		$spi->method( 'getLogger' )->willReturn( $logger ?? new NullLogger() );
		return $spi;
	}

	/**
	 * @return MockObject|ParserOutputAccess
	 */
	public function newMockParserOutputAccess( ?string $expectedHtml ): ParserOutputAccess {
		$expectedCalls = [
			'getParserOutput' => null,
		];
		$access = $this->createNoOpMock( ParserOutputAccess::class, array_keys( $expectedCalls ) );
		$access->expects( $this->exactlyOrAny( $expectedCalls[ 'getParserOutput' ] ) )
			->method( 'getParserOutput' )
			->willReturnCallback( function (
				PageRecord $page,
				ParserOptions $parserOpts,
				?RevisionRecord $rev = null,
				int $options = 0
			) use ( $expectedHtml ) {
				// Note that HtmlOutputRendererHelper only passes
				// non-null RevisionRecords here, so getMockHtml() will
				// always return <p>-wrapped main slot content.
				$pout = $this->makeParserOutput(
					$parserOpts,
					$expectedHtml ?? $this->getMockHtml( $rev ),
					$rev,
					$page
				); // will use fake time
				return Status::newGood( $pout );
			} );
		return $access;
	}

	private function getMockHtml( $rev ) {
		if ( $rev instanceof RevisionRecord ) {
			$html = '<p>' . $rev->getContent( SlotRecord::MAIN )->getText() . '</p>';
		} elseif ( is_int( $rev ) ) {
			$html = '<p>rev:' . $rev . '</p>';
		} else {
			$html = self::MOCK_HTML;
		}

		return $html;
	}

	/**
	 * @param ParserOptions $parserOpts
	 * @param string $html
	 * @param RevisionRecord|int|null $rev
	 * @param PageIdentity $page
	 * @param string|null $version
	 *
	 * @return ParserOutput
	 */
	private function makeParserOutput(
		ParserOptions $parserOpts,
		string $html,
		$rev,
		PageIdentity $page,
		?string $version = null
	): ParserOutput {
		static $counter = 0;
		$lang = $parserOpts->getTargetLanguage();
		$lang = $lang ? $lang->getCode() : 'en';
		$version ??= Parsoid::defaultHTMLVersion();

		$html = "<!DOCTYPE html><html lang=\"$lang\"><body><div id='t3s7'>$html</div></body></html>";

		$revTimestamp = null;
		if ( $rev instanceof RevisionRecord ) {
			$revTimestamp = $rev->getTimestamp();
			$rev = $rev->getId() ?? 0;
		}

		$pout = new ParserOutput( $html );
		$pout->setCacheRevisionId( $rev ?? $page->getLatest() );
		$pout->setCacheTime( wfTimestampNow() ); // will use fake time
		if ( $revTimestamp ) {
			$pout->setRevisionTimestamp( $revTimestamp );
		}
		// We test that UUIDs are unique, so make a cheap unique UUID
		$pout->setRenderId( 'bogus-uuid-' . strval( $counter++ ) );
		$pout->setExtensionData( PageBundleParserOutputConverter::PARSOID_PAGE_BUNDLE_KEY, [
			'parsoid' => [ 'ids' => [
				't3s7' => [ 'dsr' => [ 0, 0, 0, 0 ] ],
			] ],
			'mw' => [ 'ids' => [] ],
			'version' => $version,
			'headers' => [
				'content-language' => $lang
			]
		] );

		$pout->setLanguage( new Bcp47CodeValue( $lang ) );
		return $pout;
	}

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

		$this->overrideConfigValue( MainConfigNames::CacheEpoch, self::CACHE_EPOCH );
	}

	/**
	 * @return MockObject|Authority
	 */
	private function newAuthority(): MockObject {
		$authority = $this->createNoOpMock( Authority::class, [ 'authorizeWrite' ] );
		$authority->method( 'authorizeWrite' )->willReturn( true );
		return $authority;
	}

	/**
	 * @return MockObject|Authority
	 */
	private function newAuthorityWhoCantStash(): MockObject {
		$authority = $this->createNoOpMock( Authority::class, [ 'authorizeWrite' ] );
		$authority->method( 'authorizeWrite' )->willReturnCallback(
			static function ( $action, $target, PermissionStatus $status ) {
				if ( $action === 'stashbasehtml' ) {
					$status->setRateLimitExceeded();
					$status->setPermission( $action );
					return false;
				}

				return true;
			}
		);
		return $authority;
	}

	private function newHelper(
		array $options,
		PageIdentity $page,
		array $parameters = [],
		?Authority $authority = null,
		$revision = null,
		bool $lenientRevHandling = false
	): HtmlOutputRendererHelper {
		$chFactory = $this->getServiceContainer()->getContentHandlerFactory();
		$cache = $options['cache'] ?? new EmptyBagOStuff();
		$stash = new SimpleParsoidOutputStash( $chFactory, $cache, 1 );

		$services = $this->getServiceContainer();

		if ( isset( $options['ParsoidParserFactory'] ) ) {
			$this->resetServicesWithMockedParsoidParserFactory( $options['ParsoidParserFactory'] );
		}

		return new HtmlOutputRendererHelper(
			$stash,
			StatsFactory::newNull(),
			$options['ParserOutputAccess'] ?? $this->newMockParserOutputAccess(
				$options['expectedHtml'] ?? null
			),
			$services->getPageStore(),
			$services->getRevisionLookup(),
			$services->getRevisionRenderer(),
			$services->getParsoidSiteConfig(),
			$options['HtmlTransformFactory'] ?? $services->getHtmlTransformFactory(),
			$services->getContentHandlerFactory(),
			$services->getLanguageFactory(),
			$page, $parameters, $authority, $revision, $lenientRevHandling
		);
	}

	private function getExistingPageWithRevisions(
		$name, $wikitext = self::WIKITEXT, $wikitextOld = self::WIKITEXT_OLD
	) {
		$page = $this->getNonexistingTestPage( $name );

		MWTimestamp::setFakeTime( self::TIMESTAMP_OLD );
		$this->editPage( $page, $wikitextOld );
		$revisions['first'] = $page->getRevisionRecord();

		MWTimestamp::setFakeTime( self::TIMESTAMP );
		$this->editPage( $page, $wikitext );
		$revisions['latest'] = $page->getRevisionRecord();

		MWTimestamp::setFakeTime( self::TIMESTAMP_LATER );
		return [ $page, $revisions ];
	}

	private function getNonExistingPageWithFakeRevision( $name ) {
		$page = $this->getNonexistingTestPage( $name );
		MWTimestamp::setFakeTime( self::TIMESTAMP_OLD );

		$content = new WikitextContent( self::WIKITEXT_OLD );
		$rev = new MutableRevisionRecord( $page->getTitle() );
		$rev->setPageId( $page->getId() );
		$rev->setContent( SlotRecord::MAIN, $content );

		return [ $page, $rev ];
	}

	public static function provideRevisionReferences() {
		return [
			'current' => [ null, [ 'html' => self::HTML, 'timestamp' => self::TIMESTAMP ] ],
			'old' => [ 'first', [ 'html' => self::HTML_OLD, 'timestamp' => self::TIMESTAMP_OLD ] ],
		];
	}

	/**
	 * @dataProvider provideRevisionReferences()
	 */
	public function testGetHtml( $revRef ) {
		[ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ );

		// Test with just the revision ID, not the object! We do that elsewhere.
		$revId = $revRef ? $revisions[ $revRef ]->getId() : null;

		$helper = $this->newHelper( [ 'expectedHtml' => $this->getMockHtml( $revId ) ], $page, self::PARAM_DEFAULTS, $this->newAuthority() );

		if ( $revId ) {
			$helper->setRevision( $revId );
			$this->assertSame( $revId, $helper->getRevisionId() );
		} else {
			// current revision
			$this->assertSame( 0, $helper->getRevisionId() );
		}

		$htmlresult = $helper->getHtml()->getRawText();

		$this->assertStringContainsString( $this->getMockHtml( $revId ), $htmlresult );
	}

	public function testGetHtmlWithVariant() {
		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, true );
		$page = $this->getExistingTestPage( __METHOD__ );

		$helper = $this->newHelper( [ 'expectedHtml' => self::MOCK_HTML ], $page, self::PARAM_DEFAULTS, $this->newAuthority() );
		$helper->setVariantConversionLanguage( new Bcp47CodeValue( 'en-x-piglatin' ) );

		$htmlResult = $helper->getHtml()->getRawText();
		$this->assertStringContainsString( self::MOCK_HTML_VARIANT, $htmlResult );
		$this->assertStringContainsString( 'en-x-piglatin', $helper->getETag() );

		$pbResult = $helper->getPageBundle();
		$this->assertStringContainsString( self::MOCK_HTML_VARIANT, $pbResult->html );
		$this->assertStringContainsString( 'en-x-piglatin', $pbResult->headers['content-language'] );
	}

	public function testGetHtmlWillLint() {
		$this->overrideConfigValue( MainConfigNames::ParsoidSettings, [
			'linting' => true
		] );

		$page = $this->getExistingTestPage( __METHOD__ );

		$mockHandler = $this->createMock( ParserLogLinterDataHook::class );
		$mockHandler->expects( $this->once() ) // this is the critical assertion in this test case!
			->method( 'onParserLogLinterData' );

		$this->setTemporaryHook(
			'ParserLogLinterData',
			$mockHandler
		);

		// Ensure that the ParserOutputAccess isn't holding cached html.
		$this->resetServices();
		// Use the real ParserOutputAccess, so we use the real hook container.
		$access = $this->getServiceContainer()->getParserOutputAccess();

		$helper = $this->newHelper( [ 'ParserOutputAccess' => $access ], $page, self::PARAM_DEFAULTS, $this->newAuthority() );

		// Do it.
		$helper->getHtml();
	}

	public function testGetPageBundleWithOptions() {
		$this->markTestSkipped( 'T347426: Support for non-default output content major version has been disabled.' );
		$page = $this->getExistingTestPage( __METHOD__ );

		$helper = $this->newHelper( [], $page, self::PARAM_DEFAULTS, $this->newAuthority() );

		// Calling setParsoidOptions must disable caching and force the ETag to null
		$helper->setOutputProfileVersion( '999.0.0' );

		$pb = $helper->getPageBundle();

		// NOTE: Check that the options are present in the HTML.
		//       We don't do real parsing, so this is how they are represented in the output.
		$this->assertStringContainsString( '"outputContentVersion":"999.0.0"', $pb->html );
		$this->assertStringContainsString( '"offsetType":"byte"', $pb->html );

		$response = new Response();
		$helper->putHeaders( $response, true );
		$this->assertStringContainsString( 'private', $response->getHeaderLine( 'Cache-Control' ) );
	}

	public function testGetPreviewHtml_setContent() {
		$page = $this->getNonexistingTestPage();

		$helper = $this->newHelper( [], $page, self::PARAM_DEFAULTS, $this->newAuthority() );
		$helper->setContent( new WikitextContent( 'text to preview' ) );

		// getRevisionId() should return null for fake revisions.
		$this->assertNull( $helper->getRevisionId() );

		$htmlresult = $helper->getHtml()->getRawText();

		$this->assertStringContainsString( 'text to preview', $htmlresult );
	}

	public function testGetPreviewHtml_setContentSource() {
		$page = $this->getNonexistingTestPage();

		$helper = $this->newHelper( [], $page, self::PARAM_DEFAULTS, $this->newAuthority() );
		$helper->setContentSource( 'text to preview', CONTENT_MODEL_WIKITEXT );

		$htmlresult = $helper->getHtml()->getRawText();

		$this->assertStringContainsString( 'text to preview', $htmlresult );
	}

	public function testHtmlIsStashedForExistingPage() {
		[ $page, ] = $this->getExistingPageWithRevisions( __METHOD__ );

		$cache = new HashBagOStuff();

		$helper = $this->newHelper(
			[ 'cache' => $cache, 'expectedHtml' => self::MOCK_HTML ], $page, self::PARAM_DEFAULTS, $this->newAuthority()
		);
		$helper->setStashingEnabled( true );

		$htmlresult = $helper->getHtml()->getRawText();
		$this->assertStringContainsString( self::MOCK_HTML, $htmlresult );

		$eTag = $helper->getETag();
		$parsoidStashKey = ParsoidRenderID::newFromETag( $eTag );

		$chFactory = $this->createNoOpMock( IContentHandlerFactory::class );
		$stash = new SimpleParsoidOutputStash( $chFactory, $cache, 1 );
		$this->assertNotNull( $stash->get( $parsoidStashKey ) );
	}

	public function testHtmlIsStashedForFakeRevision() {
		$page = $this->getNonexistingTestPage();

		$cache = new HashBagOStuff();
		$text = 'just some wikitext';

		$helper = $this->newHelper( [ 'cache' => $cache ], $page, self::PARAM_DEFAULTS, $this->newAuthority() );
		$helper->setContent( new WikitextContent( $text ) );
		$helper->setStashingEnabled( true );

		$htmlresult = $helper->getHtml()->getRawText();
		$this->assertStringContainsString( $text, $htmlresult );

		$eTag = $helper->getETag();
		$parsoidStashKey = ParsoidRenderID::newFromETag( $eTag );

		$chFactory = $this->getServiceContainer()->getContentHandlerFactory();
		$stash = new SimpleParsoidOutputStash( $chFactory, $cache, 1 );

		$selserContext = $stash->get( $parsoidStashKey );
		$this->assertNotNull( $selserContext );

		/** @var WikitextContent $stashedContent */
		$stashedContent = $selserContext->getContent();
		$this->assertNotNull( $stashedContent );
		$this->assertInstanceOf( WikitextContent::class, $stashedContent );
		$this->assertSame( $text, $stashedContent->getText() );
	}

	public function testStashRateLimit() {
		$page = $this->getExistingTestPage( __METHOD__ );

		$authority = $this->newAuthorityWhoCantStash();
		$helper = $this->newHelper( [], $page, self::PARAM_DEFAULTS, $authority );
		$helper->setStashingEnabled( true );

		$this->expectException( LocalizedHttpException::class );
		$this->expectExceptionCode( 429 );
		$helper->getHtml();
	}

	public function testInteractionOfStashAndFlavor() {
		$page = $this->getExistingTestPage( __METHOD__ );

		$authority = $this->newAuthorityWhoCantStash();
		$helper = $this->newHelper( [], $page, self::PARAM_DEFAULTS, $authority );

		// Assert that the initial flavor is "view"
		$this->assertSame( 'view', $helper->getFlavor() );

		// Assert that we can change the flavor to "edit"
		$helper->setFlavor( 'edit' );
		$this->assertSame( 'edit', $helper->getFlavor() );

		// Assert that enabling stashing will force the flavor to be "stash"
		$helper->setStashingEnabled( true );
		$this->assertSame( 'stash', $helper->getFlavor() );

		// Assert that disabling stashing will reset the flavor to "view"
		$helper->setStashingEnabled( false );
		$this->assertSame( 'view', $helper->getFlavor() );

		// Assert that we cannot change the flavor to "view" when stashing is enabled
		$helper->setStashingEnabled( true );
		$helper->setFlavor( 'view' );
		$this->assertSame( 'stash', $helper->getFlavor() );
	}

	public function testGetHtmlFragment() {
		$page = $this->getExistingTestPage();

		$expectedHtml = '<html><body><section data-mw-section-id=0><p>Contents</p></section></body></html>';
		$helper = $this->newHelper( [
			'ParsoidParserFactory' => $this->newMockParsoidParserFactory( [
				'expectedHtml' => $expectedHtml
			] ),
			'expectedHtml' => $expectedHtml,

		], $page, self::PARAM_DEFAULTS, $this->newAuthority() );
		$helper->setFlavor( 'fragment' );
		$helper->setContentSource( 'Contents', CONTENT_MODEL_WIKITEXT );

		$htmlresult = $helper->getHtml()->getRawText();

		$this->assertStringContainsString( 'fragment', $helper->getETag() );
		$this->assertStringContainsString( '<p>Contents</p>', $htmlresult );
		$this->assertStringNotContainsString( "<body", $htmlresult );
		$this->assertStringNotContainsString( "<section", $htmlresult );
	}

	public function testGetHtmlForEdit() {
		$page = $this->getExistingTestPage();

		$helper = $this->newHelper( [], $page, self::PARAM_DEFAULTS, $this->newAuthority() );
		$helper->setContentSource( 'hello {{world}}', CONTENT_MODEL_WIKITEXT );
		$helper->setFlavor( 'edit' );

		$htmlresult = $helper->getHtml()->getRawText();

		$this->assertStringContainsString( 'edit', $helper->getETag() );

		$this->assertStringContainsString( 'hello', $htmlresult );
		$this->assertStringContainsString( 'data-parsoid=', $htmlresult );
		$this->assertStringContainsString( '"dsr":', $htmlresult );
	}

	/**
	 * @dataProvider provideRevisionReferences()
	 */
	public function testETagLastModified( $revRef ) {
		[ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ );
		$rev = $revRef ? $revisions[ $revRef ] : null;

		$cache = new HashBagOStuff();

		// First, test it works if nothing was cached yet.
		$helper = $this->newHelper( [ 'cache' => $cache ], $page, self::PARAM_DEFAULTS, $this->newAuthority(), $rev );

		// put HTML into the cache
		$pout = $helper->getHtml();

		$renderId = ParsoidRenderID::newFromParserOutput( $pout );
		$lastModified = $pout->getCacheTime();

		if ( $rev ) {
			$this->assertSame( $rev->getId(), $helper->getRevisionId() );
		} else {
			// current revision
			$this->assertSame( 0, $helper->getRevisionId() );
		}

		// make sure the etag didn't change after getHtml();
		$this->assertStringContainsString( $renderId->getKey(), $helper->getETag() );
		$this->assertSame(
			MWTimestamp::convert( TS_MW, $lastModified ),
			MWTimestamp::convert( TS_MW, $helper->getLastModified() )
		);

		// Now, expire the cache. etag and timestamp should change
		$now = MWTimestamp::convert( TS_UNIX, self::TIMESTAMP_LATER ) + 10000;
		MWTimestamp::setFakeTime( $now );
		$this->assertTrue(
			$page->getTitle()->invalidateCache( MWTimestamp::convert( TS_MW, $now ) ),
			'Cannot invalidate cache'
		);
		DeferredUpdates::doUpdates();
		$page->clear();

		$helper = $this->newHelper( [ 'cache' => $cache ], $page, self::PARAM_DEFAULTS, $this->newAuthority(), $rev );

		$this->assertStringNotContainsString( $renderId->getKey(), $helper->getETag() );
		$this->assertSame(
			MWTimestamp::convert( TS_MW, $now ),
			MWTimestamp::convert( TS_MW, $helper->getLastModified() )
		);
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper::init
	 * @covers \MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper::parseUncacheable
	 */
	public function testETagLastModifiedWithPageIdentity() {
		[ $fakePage, $fakeRevision ] = $this->getNonExistingPageWithFakeRevision( __METHOD__ );
		$pp = $this->createMock( ParsoidParser::class );
		$pp->expects( $this->once() )
			->method( 'parse' )
			->willReturnCallback( function (
				$text,
				PageReference $page,
				ParserOptions $parserOpts,
				bool $linestart = true,
				bool $clearState = true,
				?int $revId = null
			) use ( $fakePage, $fakeRevision ) {
				self::assertTrue( $page->isSamePageAs( $fakePage ), '$page and $fakePage should be the same' );
				self::assertSame( $revId, $fakeRevision->getId(), '$rev and $fakeRevision should be the same' );

				$html = $this->getMockHtml( $fakeRevision );
				$pout = $this->makeParserOutput( $parserOpts, $html, $fakeRevision, $page );
				return $pout;
			} );
		$options['ParsoidParser'] = $pp;
		$options['ParsoidParserFactory'] = $this->newMockParsoidParserFactory(
			$options
		);

		$helper = $this->newHelper( $options, $fakePage, self::PARAM_DEFAULTS, $this->newAuthority() );
		$helper->setRevision( $fakeRevision );

		$this->assertNull( $helper->getRevisionId() );

		$pout = $helper->getHtml();
		$renderId = ParsoidRenderID::newFromParserOutput( $pout );
		$lastModified = $pout->getCacheTime();

		$this->assertStringContainsString( $renderId->getKey(), $helper->getETag() );
		$this->assertSame(
			MWTimestamp::convert( TS_MW, $lastModified ),
			MWTimestamp::convert( TS_MW, $helper->getLastModified() )
		);
	}

	public static function provideETagSuffix() {
		yield 'stash + html' =>
		[ [ 'stash' => true ], 'html', '/stash/html' ];

		yield 'view html' =>
		[ [], 'html', '/view/html' ];

		yield 'stash + wrapped' =>
		[ [ 'stash' => true ], 'with_html', '/stash/with_html' ];

		yield 'view wrapped' =>
		[ [], 'with_html', '/view/with_html' ];

		yield 'stash' =>
		[ [ 'stash' => true ], '', '/stash' ];

		yield 'flavor = fragment' =>
		[ [ 'flavor' => 'fragment' ], '', '/fragment' ];

		yield 'flavor = fragment + stash = true: stash should take over' =>
		[ [ 'stash' => true, 'flavor' => 'fragment' ], '', '/stash' ];

		yield 'nothing' =>
		[ [], '', '/view' ];
	}

	/**
	 * @dataProvider provideETagSuffix()
	 */
	public function testETagSuffix( array $params, string $mode, string $suffix ) {
		$page = $this->getExistingTestPage( __METHOD__ );

		$cache = new HashBagOStuff();

		// First, test it works if nothing was cached yet.
		$helper = $this->newHelper( [
			'cache' => $cache,
		], $page, $params + self::PARAM_DEFAULTS, $this->newAuthority() );
		if ( ( $params['flavor'] ?? null ) === 'fragment' ) {
			$helper->setContentSource( "fragment test", CONTENT_MODEL_WIKITEXT );
		}

		$etag = $helper->getETag( $mode );
		$etag = trim( $etag, '"' );
		$this->assertStringEndsWith( $suffix, $etag );
	}

	public static function provideVariantConversionLanguage() {
		yield 'simple code'
		=> [ 'en', new Bcp47CodeValue( 'en' ) ];

		yield 'code with dashes'
		=> [ 'en-x-piglatin', new Bcp47CodeValue( 'en-x-piglatin' ) ];

		yield 'obsolete alias'
		=> [ 'zh-min-nan', new Bcp47CodeValue( 'nan' ) ];

		yield 'obsolete alias in source language'
		=> [ 'en', new Bcp47CodeValue( 'en' ),
			'zh-min-nan', new Bcp47CodeValue( 'nan' ) ];

		yield 'target and source given as objects'
		=> [ new Bcp47CodeValue( 'x y z' ), new Bcp47CodeValue( 'x y z' ),
			new Bcp47CodeValue( 'a,b,c' ), new Bcp47CodeValue( 'a,b,c' ) ];

		yield 'complex accept-language header (T350852)'
		=> [ 'da, en-gb;q=0.8, en;q=0.7', new Bcp47CodeValue( 'da' ) ];
	}

	/**
	 * @dataProvider provideVariantConversionLanguage
	 */
	public function testSetVariantConversionLanguage(
		$target,
		Bcp47Code $expectedTarget,
		$source = null,
		?Bcp47Code $expectedSource = null
	) {
		$converter = $this->createNoOpMock(
			LanguageVariantConverter::class,
			[ 'convertPageBundleVariant' ]
		);

		// This is the key assertion in this test:
		$converter->expects( $this->once() )
			->method( 'convertPageBundleVariant' )->with(
				$this->anything(),
				$expectedTarget,
				$expectedSource
			);

		$transformFactory = $this->createNoOpMock(
			HtmlTransformFactory::class,
			[ 'getLanguageVariantConverter' ]
		);
		$transformFactory->method( 'getLanguageVariantConverter' )
			->willReturn( $converter );

		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, true );
		$page = $this->getExistingTestPage( __METHOD__ );

		$helper = $this->newHelper( [ 'HtmlTransformFactory' => $transformFactory ], $page, self::PARAM_DEFAULTS, $this->newAuthority() );

		// call method under test
		$helper->setVariantConversionLanguage( $target, $source );

		// Secondary assertion, to ensure that the ETag varies on the right thing.
		$this->assertStringEndsWith( "+lang:$expectedTarget\"", $helper->getETag() );

		$helper->getPageBundle();
	}

	public static function provideHandlesParsoidError() {
		yield 'ClientError' => [
			new ClientError( 'TEST_TEST' ),
			new LocalizedHttpException(
				new MessageValue( 'rest-html-backend-error' ),
				400,
				[
					'reason' => 'TEST_TEST'
				]
			)
		];
		yield 'ResourceLimitExceededException' => [
			new ResourceLimitExceededException( 'TEST_TEST' ),
			new LocalizedHttpException(
				new MessageValue( 'rest-resource-limit-exceeded' ),
				413,
				[
					'reason' => 'TEST_TEST'
				]
			)
		];
		yield 'RevisionAccessException' => [
			new RevisionAccessException( 'TEST_TEST' ),
			new LocalizedHttpException(
				new MessageValue( 'rest-nonexistent-title' ),
				404,
				[
					'reason' => 'TEST_TEST'
				]
			)
		];
	}

	private function newMockParsoidParserFactory( array $options = [] ) {
		if ( isset( $options['Parsoid'] ) ) {
			$mockParsoid = $options['Parsoid'];
		} else {
			$mockParsoid = $this->createNoOpMock( Parsoid::class, [
				'wikitext2html',
			] );
			$mockParsoid
				->method( 'wikitext2html' )
				->willReturn( new PageBundle(
					$options['expectedHtml'] ?? 'This is HTML'
				) );
		}

		// Install it in the ParsoidParser object
		if ( isset( $options['ParsoidParser'] ) ) {
			$parsoidParser = $options['ParsoidParser'];
		} else {
			$services = $this->getServiceContainer();
			$parsoidParser = new ParsoidParser(
				$mockParsoid,
				$services->getParsoidPageConfigFactory(),
				$services->getLanguageConverterFactory(),
				$services->getParserFactory(),
				$services->getGlobalIdGenerator()
			);
		}

		// Create a mock Parsoid factory that returns the ParsoidParser object
		// with the mocked Parsoid object.
		$mockParsoidParserFactory = $this->createNoOpMock( ParsoidParserFactory::class, [ 'create' ] );
		$mockParsoidParserFactory->method( 'create' )->willReturn( $parsoidParser );
		return $mockParsoidParserFactory;
	}

	private function resetServicesWithMockedParsoid( ?Parsoid $mockParsoid = null ): void {
		$services = $this->getServiceContainer();
		$mockParsoidParserFactory = $this->newMockParsoidParserFactory( [
			'Parsoid' => $mockParsoid,
		] );
		$this->resetServicesWithMockedParsoidParserFactory( $mockParsoidParserFactory );
	}

	private function resetServicesWithMockedParsoidParserFactory( ?ParsoidParserFactory $mockParsoidParserFactory = null ): void {
		$this->setService( 'ParsoidParserFactory', $mockParsoidParserFactory );
	}

	private function newRealParserOutputAccess( $overrides = [] ): array {
		$services = $this->getServiceContainer();

		if ( isset( $overrides['parserCache'] ) ) {
			$parserCache = $overrides['parserCache'];
		} else {
			$parserCache = $this->createNoOpMock(
				ParserCache::class,
				[ 'get', 'save', 'makeParserOutputKey', ]
			);
			$parserCache->method( 'get' )->willReturn( false );
			$parserCache->method( 'save' )->willReturn( null );
			$parserCache->method( 'makeParserOutputKey' )->willReturn( 'test-key' );
		}

		if ( isset( $overrides['revisionCache'] ) ) {
			$revisionCache = $overrides['revisionCache'];
		} else {
			$revisionCache = $this->createNoOpMock( RevisionOutputCache::class, [ 'get', 'save' ] );
			$revisionCache->method( 'get' )->willReturn( false );
			$revisionCache->method( 'save' )->willReturn( null );
		}

		$parserCacheFactory = $this->createNoOpMock(
			ParserCacheFactory::class,
			[ 'getParserCache', 'getRevisionOutputCache' ]
		);
		$parserCacheFactory->method( 'getParserCache' )->willReturn( $parserCache );
		$parserCacheFactory->method( 'getRevisionOutputCache' )->willReturn( $revisionCache );
		$parserOutputAccess = new ParserOutputAccess(
			$parserCacheFactory,
			$services->getRevisionLookup(),
			$services->getRevisionRenderer(),
			StatsFactory::newNull(),
			$services->getDBLoadBalancerFactory(),
			$services->getChronologyProtector(),
			$this->getLoggerSpi(),
			$services->getWikiPageFactory(),
			$services->getTitleFormatter()
		);
		return [
			'ParserOutputAccess' => $parserOutputAccess,
		];
	}

	/**
	 * @dataProvider provideHandlesParsoidError
	 */
	public function testHandlesParsoidError(
		Exception $parsoidException,
		Exception $expectedException
	) {
		$page = $this->getExistingTestPage( __METHOD__ );

		$parsoid = $this->createNoOpMock( Parsoid::class, [ 'wikitext2html' ] );
		$parsoid->method( 'wikitext2html' )
			->willThrowException( $parsoidException );

		$parserCache = $this->createNoOpMock( ParserCache::class, [ 'get', 'getDirty', 'makeParserOutputKey' ] );
		$parserCache->method( 'get' )->willReturn( false );
		$parserCache->method( 'getDirty' )->willReturn( false );
		$parserCache->expects( $this->atLeastOnce() )->method( 'makeParserOutputKey' );

		$this->resetServicesWithMockedParsoid( $parsoid );
		$access = $this->newRealParserOutputAccess( [ 'parserCache' => $parserCache ] );

		$helper = $this->newHelper( $access, $page, self::PARAM_DEFAULTS, $this->newAuthority() );

		$this->expectExceptionObject( $expectedException );
		$helper->getHtml();
	}

	public static function provideParsoidOutputStatus() {
		yield 'parsoid-client-error' => [
			Status::newFatal( 'parsoid-client-error' ),
			new LocalizedHttpException(
				new MessageValue( 'rest-html-backend-error' ),
				400,
				[
					'reason' => 'parsoid-client-error'
				]
			)
		];
		yield 'parsoid-resource-limit-exceeded' => [
			Status::newFatal( 'parsoid-resource-limit-exceeded' ),
			new LocalizedHttpException(
				new MessageValue( 'rest-resource-limit-exceeded' ),
				413,
				[
					'reason' => 'TEST_TEST'
				]
			)
		];
		yield 'missing-revision-permission' => [
			Status::newFatal( 'missing-revision-permission' ),
			new LocalizedHttpException(
				new MessageValue( 'rest-permission-denied-revision' ),
				403,
				[
					'reason' => 'missing-revision-permission'
				]
			)
		];
		yield 'parsoid-revision-access' => [
			Status::newFatal( 'parsoid-revision-access' ),
			new LocalizedHttpException(
				new MessageValue( 'rest-specified-revision-unavailable' ),
				404,
				[
					'reason' => 'parsoid-revision-access'
				]
			)
		];
	}

	/**
	 * @dataProvider provideParsoidOutputStatus
	 */
	public function testParsoidOutputStatus(
		Status $parserOutputStatus,
		Exception $expectedException
	) {
		$page = $this->getExistingTestPage( __METHOD__ );

		$parserAccess = $this->createNoOpMock( ParserOutputAccess::class, [ 'getParserOutput' ] );
		$parserAccess->method( 'getParserOutput' )
			->willReturn( $parserOutputStatus );

		$helper = $this->newHelper( [
			'ParserOutputAccess' => $parserAccess,
		], $page, self::PARAM_DEFAULTS, $this->newAuthority() );

		$this->expectExceptionObject( $expectedException );
		$helper->getHtml();
	}

	public function testWillUseParserCache() {
		$page = $this->getExistingTestPage( __METHOD__ );

		// NOTE: Use a simple PageIdentity here, to make sure the relevant PageRecord
		//       will be looked up as needed.
		$page = PageIdentityValue::localIdentity( $page->getId(), $page->getNamespace(), $page->getDBkey() );

		// This is the key assertion in this test case: get() and save() are both called.
		$parserCache = $this->createNoOpMock( ParserCache::class, [ 'get', 'getDirty', 'save', 'makeParserOutputKey' ] );
		$parserCache->expects( $this->once() )->method( 'get' )->willReturn( false );
		$parserCache->method( 'getDirty' )->willReturn( false );
		$parserCache->expects( $this->once() )->method( 'save' );
		$parserCache->expects( $this->atLeastOnce() )->method( 'makeParserOutputKey' );

		$this->resetServicesWithMockedParsoid();
		$access = $this->newRealParserOutputAccess( [
			'parserCache' => $parserCache,
			'revisionCache' => $this->createNoOpMock( RevisionOutputCache::class )
		] );

		$helper = $this->newHelper( $access, $page, self::PARAM_DEFAULTS, $this->newAuthority() );

		$helper->getHtml();
	}

	public function testGetParserOutputWithLanguageOverride() {
		[ $page, [ 'latest' => $revision ] ] = $this->getExistingPageWithRevisions( __METHOD__, '{{PAGELANGUAGE}}' );

		$options = [
			# use real ParserOutputAccess to exercise caching
			'ParserOutputAccess' => $this->getServiceContainer()->getParserOutputAccess(),
		];
		$helper = $this->newHelper( $options, $page, [], $this->newAuthority(), $revision );
		$helper->setPageLanguage( 'ar' );

		// check nominal content language
		$this->assertSame( 'ar', $helper->getHtmlOutputContentLanguage()->toBcp47Code() );

		// check content language in HTML
		$output = $helper->getHtml();
		$html = $output->getRawText();
		$this->assertStringContainsString( 'lang="ar"', $html );
		$this->assertStringContainsString( '>ar<', $html ); # {{PAGELANGUAGE}}

		// Check that cache is properly split on page language (T376783)
		$helper = $this->newHelper( $options, $page, [], $this->newAuthority(), $revision );
		$helper->setPageLanguage( 'en' );
		$this->assertSame( 'en', $helper->getHtmlOutputContentLanguage()->toBcp47Code() );
		$output = $helper->getHtml();
		$html = $output->getRawText();
		$this->assertStringContainsString( 'lang="en"', $html );
		$this->assertStringContainsString( '>en<', $html ); # {{PAGELANGUAGE}}
	}

	public function testGetParserOutputWithRedundantPageLanguage() {
		$poa = $this->createMock( ParserOutputAccess::class );
		$poa->expects( $this->once() )
			->method( 'getParserOutput' )
			->willReturnCallback( function (
				PageIdentity $page,
				ParserOptions $parserOpts,
				$revision = null,
				int $options = 0
			) {
				$usedOptions = [ 'targetLanguage' ];
				self::assertNull( $parserOpts->getTargetLanguage(), 'No target language should be set in ParserOptions' );
				self::assertTrue( $parserOpts->isSafeToCache( $usedOptions ) );

				$html = $this->getMockHtml( $revision );
				$pout = $this->makeParserOutput( $parserOpts, $html, $revision, $page );
				return Status::newGood( $pout );
			} );

		$page = $this->getExistingTestPage();

		$helper = $this->newHelper( [ 'ParserOutputAccess' => $poa ], $page, [], $this->newAuthority() );

		// Explicitly set the page language to the default.
		$pageLanguage = $page->getTitle()->getPageLanguage();
		$helper->setPageLanguage( $pageLanguage );

		// Trigger parsing, so the assertions in the mock are executed.
		$helper->getHtml();
	}

	public function provideInit() {
		$page = PageIdentityValue::localIdentity( 7, NS_MAIN, 'Köfte' );
		$authority = $this->createNoOpMock( Authority::class );

		yield 'Minimal' => [
			$page,
			[],
			$authority,
			null,
			[
				'page' => $page,
				'authority' => $authority,
				'revisionOrId' => null,
				'stash' => false,
				'flavor' => 'view',
			]
		];

		$rev = $this->createNoOpMock( RevisionRecord::class, [ 'getId' ] );
		$rev->method( 'getId' )->willReturn( 7 );

		yield 'Revision and Language' => [
			$page,
			[],
			$authority,
			$rev,
			[
				'revisionOrId' => $rev,
			]
		];

		yield 'revid and stash' => [
			$page,
			[ 'stash' => true ],
			$authority,
			8,
			[
				'stash' => true,
				'flavor' => 'stash',
				'revisionOrId' => 8,
			]
		];

		yield 'flavor' => [
			$page,
			[ 'flavor' => 'fragment' ],
			$authority,
			8,
			[
				'flavor' => 'fragment',
			]
		];

		yield 'stash winds over flavor' => [
			$page,
			[ 'flavor' => 'fragment', 'stash' => true ],
			$authority,
			8,
			[
				'flavor' => 'stash',
			]
		];
	}

	/**
	 * Whitebox test for ensuring that init() sets the correct members.
	 * Testing init() against behavior would mean duplicating all tests that use setters.
	 *
	 * @param PageIdentity $page
	 * @param array $parameters
	 * @param Authority $authority
	 * @param RevisionRecord|int|null $revision
	 * @param array $expected
	 *
	 * @dataProvider provideInit
	 */
	public function testInit(
		PageIdentity $page,
		array $parameters,
		Authority $authority,
		$revision,
		array $expected
	) {
		$helper = $this->newHelper( [], $page, $parameters, $authority, $revision );

		$wrapper = TestingAccessWrapper::newFromObject( $helper );
		foreach ( $expected as $name => $value ) {
			$this->assertSame( $value, $wrapper->$name );
		}
	}

	/**
	 * @dataProvider providePutHeaders
	 */
	public function testPutHeaders( ?string $targetLanguage, bool $setContentLanguageHeader ) {
		$this->overrideConfigValue( MainConfigNames::UsePigLatinVariant, true );
		$page = $this->getExistingTestPage( __METHOD__ );
		$expectedCalls = [];

		$helper = $this->newHelper( [], $page, self::PARAM_DEFAULTS, $this->newAuthority() );

		if ( $targetLanguage ) {
			$helper->setVariantConversionLanguage( new Bcp47CodeValue( $targetLanguage ) );
			$expectedCalls['addHeader'] = [ [ 'Vary', 'Accept-Language' ] ];
		}

		if ( $setContentLanguageHeader ) {
			$expectedCalls['setHeader'][] = [ 'Content-Language', $targetLanguage ?: 'en' ];

			$version = Parsoid::defaultHTMLVersion();
			$expectedCalls['setHeader'][] = [
				'Content-Type',
				'text/html; charset=utf-8; profile="https://www.mediawiki.org/wiki/Specs/HTML/' . $version . '"',
			];
		}

		$responseInterface = $this->getResponseInterfaceMock( $expectedCalls );
		$helper->putHeaders( $responseInterface, $setContentLanguageHeader );
	}

	public static function providePutHeaders() {
		yield 'no target variant language' => [ null, true ];
		yield 'target language is set but setContentLanguageHeader is false' => [ 'en-x-piglatin', false ];
		yield 'target language and setContentLanguageHeader flag is true' =>
			[ 'en-x-piglatin', true ];
	}

	private function getResponseInterfaceMock( array $expectedCalls ) {
		$responseInterface = $this->createNoOpMock( ResponseInterface::class, array_keys( $expectedCalls ) );
		foreach ( $expectedCalls as $method => $arguments ) {
			$responseInterface
				->expects( $this->exactly( count( $arguments ) ) )
				->method( $method )
				->willReturnCallback( function ( ...$actualArgs ) use ( $arguments ) {
					static $expectedArgs;
					$expectedArgs ??= $arguments;
					$this->assertContains( $actualArgs, $expectedArgs );
					$argIdx = array_search( $actualArgs, $expectedArgs, true );
					unset( $expectedArgs[$argIdx] );
				} );
		}

		return $responseInterface;
	}

	public static function provideFlavorsForBadModelOutput() {
		yield 'view' => [ 'view' ];
		yield 'edit' => [ 'edit' ];
		// fragment mode is only for posted wikitext fragments not part of a revision
		// and should not be used with real revisions
		//
		// yield 'fragment' => [ 'fragment' ];
	}

	/**
	 * @dataProvider provideFlavorsForBadModelOutput
	 */
	public function testNonParsoidOutput( string $flavor ) {
		$this->resetServicesWithMockedParsoid();

		$page = $this->getNonexistingTestPage( __METHOD__ );
		$this->editPage( $page, new CssContent( '"not wikitext"' ) );

		$helper = $this->newHelper( [
				'cache' => new HashBagOStuff(),
			] + $this->newRealParserOutputAccess(), $page, self::PARAM_DEFAULTS, $this->newAuthority() );
		$helper->setFlavor( $flavor );

		$output = $helper->getHtml();
		$this->assertStringContainsString( 'not wikitext', $output->getRawText() );
		$this->assertNotNull( ParsoidRenderID::newFromParserOutput( $output )->getKey() );
	}

}
PK       ! f[*f    .  Rest/Handler/Helper/PageRedirectHelperTest.phpnu Iw        <?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 MediaWiki\Title\Title;
use MediaWiki\Title\TitleValue;
use MediaWikiIntegrationTestCase;

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

	private function newRedirectHelper( $queryParams = [], $headers = [] ) {
		$services = $this->getServiceContainer();

		$redirectStore = $this->createNoOpMock( RedirectStore::class, [ 'getRedirectTarget' ] );
		$redirectStore->method( 'getRedirectTarget' )
			->willReturnCallback( static function ( PageIdentity $page ) use ( $services ) {
				if ( $page->getDBkey() === 'Redirect_to_self' ) {
					return TitleValue::newFromPage( $page );
				}

				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->newRouterForPageHandler( 'https://example.test', '/api' );
		$request = new RequestData( [ 'queryParams' => $queryParams, 'headers' => $headers ] );

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

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

		yield 'Relative' => [
			'Föö+Bar',
			null,
			true,
			'/api/test/F%C3%B6%C3%B6%2BBar',
		];

		yield 'Query Params' => [
			'Föö+Bar',
			[ 'a' => 1 ],
			true,
			'/api/test/F%C3%B6%C3%B6%2BBar?a=1',
		];

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

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

	public static function provideNormalizationRedirect() {
		$page = new PageIdentityValue( 7, NS_MAIN, 'Foo', false );
		yield [ $page, 'foo', '/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', '/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 function testNormalizationRedirect_absolute() {
		$helper = $this->newRedirectHelper( [] );
		$helper->setUseRelativeRedirects( false );

		$page = new PageIdentityValue( 7, NS_MAIN, 'Foo', false );
		$resp = $helper->createNormalizationRedirectResponseIfNeeded( $page, 'foo' );

		$this->assertNotNull( $resp );
		$this->assertStringStartsWith( 'https://', $resp->getHeaderLine( 'Location' ) );
	}

	public static function provideWikiRedirect() {
		$page = new PageIdentityValue( 7, NS_MAIN, 'Redirect_to_foo', false );
		yield 'Wiki redirect' => [ $page, '/api/test/Foo' ];

		$page = new PageIdentityValue( 7, NS_MAIN, 'Redirect_to_self', false );
		yield 'Self-redirect (T353688)' => [ $page, null ];

		$page = new PageIdentityValue( 7, NS_MAIN, 'foo', false );
		yield 'no redirect' => [ $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(
			'/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 by 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' );
	}

}
PK       !       1  Rest/Handler/Helper/PageRestHelperFactoryTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler\Helper;

use MediaWiki\Permissions\Authority;
use MediaWiki\Rest\Handler\Helper\HtmlInputTransformHelper;
use MediaWiki\Rest\Handler\Helper\HtmlMessageOutputHelper;
use MediaWiki\Rest\Handler\Helper\HtmlOutputHelper;
use MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Rest\Handler\Helper\PageRestHelperFactory
 * @group Database
 */
class PageRestHelperFactoryTest extends MediaWikiIntegrationTestCase {

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\PageRestHelperFactory::newHtmlMessageOutputHelper
	 * @covers \MediaWiki\Rest\Handler\Helper\PageRestHelperFactory::newHtmlOutputRendererHelper
	 */
	public function testNewHtmlOutputHelpers() {
		$page = $this->getNonexistingTestPage( __METHOD__ );
		$helperFactory = $this->getServiceContainer()->getPageRestHelperFactory();

		$helper = $helperFactory->newHtmlMessageOutputHelper( $page );

		$this->assertInstanceOf( HtmlMessageOutputHelper::class, $helper );
		$this->assertInstanceOf( HtmlOutputHelper::class, $helper );

		$authority = $this->createNoOpMock( Authority::class );
		$helper = $helperFactory->newHtmlOutputRendererHelper( $page, [], $authority );

		$this->assertInstanceOf( HtmlOutputRendererHelper::class, $helper );
		$this->assertInstanceOf( HtmlOutputHelper::class, $helper );
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\PageRestHelperFactory::newHtmlInputTransformHelper
	 */
	public function testNewHtmlInputTransformHelper() {
		$page = $this->getNonexistingTestPage( __METHOD__ );
		$helperFactory = $this->getServiceContainer()->getPageRestHelperFactory();

		$helper = $helperFactory->newHtmlInputTransformHelper( [], $page, 'foo', [] );

		$this->assertInstanceOf( HtmlInputTransformHelper::class, $helper );
	}
}
PK       ! a*(  *(  1  Rest/Handler/Helper/RevisionContentHelperTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler\Helper;

use Exception;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\ExistingPageRecord;
use MediaWiki\Permissions\Authority;
use MediaWiki\Rest\Handler\Helper\PageContentHelper;
use MediaWiki\Rest\Handler\Helper\RevisionContentHelper;
use MediaWiki\Rest\HttpException;
use MediaWiki\Rest\Response;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper
 * @group Database
 */
class RevisionContentHelperTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;

	private const NO_REVISION_ETAG = '"b620cd7841f9ea8f545f11cc44ce794f848fa2d3"';

	/**
	 * @param array $params
	 * @param Authority|null $authority
	 * @return RevisionContentHelper
	 * @throws Exception
	 */
	private function newHelper(
		array $params = [],
		?Authority $authority = null
	): RevisionContentHelper {
		$helper = new RevisionContentHelper(
			new ServiceOptions(
				PageContentHelper::CONSTRUCTOR_OPTIONS,
				[
					MainConfigNames::RightsUrl => 'https://example.com/rights',
					MainConfigNames::RightsText => 'some rights',
				]
			),
			$this->getServiceContainer()->getRevisionLookup(),
			$this->getServiceContainer()->getTitleFormatter(),
			$this->getServiceContainer()->getPageStore(),
			$this->getServiceContainer()->getTitleFactory(),
			$this->getServiceContainer()->getConnectionProvider(),
			$this->getServiceContainer()->getChangeTagsStore()
		);

		$authority = $authority ?: $this->mockRegisteredUltimateAuthority();
		$helper->init( $authority, $params );
		return $helper;
	}

	private function getExistingPageWithRevisions( $name ) {
		$page = $this->getNonexistingTestPage( $name );

		$this->editPage( $page, 'First revision of ' . $name );
		$revisions['first'] = $page->getRevisionRecord();

		$this->editPage( $page, 'DEAD BEEF' );
		$revisions['latest'] = $page->getRevisionRecord();

		return [ $page, $revisions ];
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getRole()
	 */
	public function testGetRole() {
		$helper = $this->newHelper();
		$this->assertSame( SlotRecord::MAIN, $helper->getRole() );
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getTitleText()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getPage()
	 */
	public function testGetPage() {
		[ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ );

		$helper = $this->newHelper( [ 'id' => $revisions['first']->getId() ] );
		$this->assertSame( $page->getTitle()->getPrefixedDBKey(), $helper->getTitleText() );

		$this->assertInstanceOf( ExistingPageRecord::class, $helper->getPage() );
		$this->assertTrue( $helper->getPage()->isSamePageAs( $page->getTitle() ) );
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getTargetRevision()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getContent()
	 */
	public function testGetTargetRevisionAndContent() {
		[ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ );

		$helper = $this->newHelper( [ 'id' => $revisions['first']->getId() ] );

		$targetRev = $helper->getTargetRevision();
		$this->assertInstanceOf( RevisionRecord::class, $targetRev );
		$this->assertSame( $revisions['first']->getId(), $targetRev->getId() );

		$pageContent = $helper->getContent();
		$this->assertSame(
			$revisions['first']->getContent( SlotRecord::MAIN )->serialize(),
			$pageContent->serialize()
		);
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getTitleText()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getPage()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::isAccessible()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::hasContent()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getTargetRevision()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getContent()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getLastModified()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getETag()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::checkAccess()
	 */
	public function testNoTitle() {
		$helper = $this->newHelper();

		$this->assertNull( $helper->getTitleText() );
		$this->assertNull( $helper->getPage() );

		$this->assertFalse( $helper->hasContent() );
		$this->assertFalse( $helper->isAccessible() );

		$this->assertNull( $helper->getTargetRevision() );

		$this->assertNull( $helper->getLastModified() );
		$this->assertSame( self::NO_REVISION_ETAG, $helper->getETag() );

		try {
			$helper->getContent();
			$this->fail( 'Expected HttpException' );
		} catch ( HttpException $ex ) {
			$this->assertSame( 404, $ex->getCode() );
		}

		try {
			$helper->checkAccess();
			$this->fail( 'Expected HttpException' );
		} catch ( HttpException $ex ) {
			$this->assertSame( 404, $ex->getCode() );
		}
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getTitleText()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getPage()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::isAccessible()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::hasContent()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getTargetRevision()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getContent()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getLastModified()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getETag()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::checkAccess()
	 */
	public function testNonExistingRevision() {
		$helper = $this->newHelper( [ 'id' => 287436534 ] );

		$this->assertSame( 287436534, $helper->getRevisionId() );
		$this->assertNull( $helper->getTitleText() );
		$this->assertNull( $helper->getPage() );

		$this->assertFalse( $helper->hasContent() );
		$this->assertFalse( $helper->isAccessible() );

		$this->assertNull( $helper->getTargetRevision() );

		$this->assertNull( $helper->getLastModified() );
		$this->assertSame( self::NO_REVISION_ETAG, $helper->getETag() );

		try {
			$helper->getContent();
			$this->fail( 'Expected HttpException' );
		} catch ( HttpException $ex ) {
			$this->assertSame( 404, $ex->getCode() );
		}

		try {
			$helper->checkAccess();
			$this->fail( 'Expected HttpException' );
		} catch ( HttpException $ex ) {
			$this->assertSame( 404, $ex->getCode() );
		}
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getTitleText()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getPage()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::isAccessible()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::hasContent()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getTargetRevision()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getContent()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getLastModified()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getETag()
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::checkAccess()
	 */
	public function testForbidenPage() {
		[ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ );
		$title = $page->getTitle();
		$helper = $this->newHelper(
			[ 'id' => $revisions['first']->getId() ],
			$this->mockAnonNullAuthority()
		);

		$this->assertSame( $title->getPrefixedDBkey(), $helper->getTitleText() );
		$this->assertTrue( $helper->getPage()->isSamePageAs( $title ) );

		$this->assertTrue( $helper->hasContent() );
		$this->assertFalse( $helper->isAccessible() );

		$this->assertNull( $helper->getLastModified() );

		try {
			$helper->checkAccess();
			$this->fail( 'Expected HttpException' );
		} catch ( HttpException $ex ) {
			$this->assertSame( 403, $ex->getCode() );
		}
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::getParamSettings()
	 */
	public function testParameterSettings() {
		$helper = $this->newHelper();
		$settings = $helper->getParamSettings();
		$this->assertArrayHasKey( 'id', $settings );
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::setCacheControl()
	 */
	public function testCacheControl() {
		$helper = $this->newHelper();

		$response = new Response();

		$helper->setCacheControl( $response ); // default
		$this->assertStringContainsString( 'max-age=5', $response->getHeaderLine( 'Cache-Control' ) );

		$helper->setCacheControl( $response, 2 ); // explicit
		$this->assertStringContainsString( 'max-age=2', $response->getHeaderLine( 'Cache-Control' ) );

		$helper->setCacheControl( $response, 1000 * 1000 ); // too big
		$this->assertStringContainsString( 'max-age=5', $response->getHeaderLine( 'Cache-Control' ) );
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\RevisionContentHelper::constructMetadata()
	 */
	public function testConstructMetadata() {
		[ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ );
		$title = $page->getTitle();

		$revision = $revisions['first'];
		$content = $revision->getContent( SlotRecord::MAIN );
		$expected = [
			'id' => $revision->getId(),
			'size' => $revision->getSize(),
			'minor' => $revision->isMinor(),
			'timestamp' => wfTimestampOrNull( TS_ISO_8601, $revision->getTimestamp() ),
			'content_model' => $content->getModel(),
			'page' => [
				'id' => $title->getArticleID(),
				'key' => $title->getPrefixedDBkey(),
				'title' => $title->getPrefixedText(),
			],
			'license' => [
				'url' => 'https://example.com/rights',
				'title' => 'some rights',
			],
			'user' => [
				'id' => $revision->getUser()->getId(),
				'name' => $revision->getUser()->getName(),
			],
			'comment' => '',
			'delta' => null, // first revision doesn't have a delta for now
		];

		$helper = $this->newHelper( [ 'id' => $revision->getId() ] );
		$data = $helper->constructMetadata();
		$this->assertEquals( $expected, $data );
	}

}
PK       ! qoÓ  Ó  4  Rest/Handler/Helper/HtmlInputTransformHelperTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler\Helper;

use Exception;
use LogicException;
use MediaWiki\Content\TextContent;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Edit\ParsoidRenderID;
use MediaWiki\Edit\SelserContext;
use MediaWiki\MainConfigNames;
use MediaWiki\MainConfigSchema;
use MediaWiki\Message\TextFormatter;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\Parsoid\HtmlToContentTransform;
use MediaWiki\Parser\Parsoid\HtmlTransformFactory;
use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter;
use MediaWiki\Rest\Handler\Helper\HtmlInputTransformHelper;
use MediaWiki\Rest\Handler\Helper\ParsoidFormatHelper;
use MediaWiki\Rest\HttpException;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\ResponseFactory;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWikiIntegrationTestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\NullLogger;
use Wikimedia\Bcp47Code\Bcp47Code;
use Wikimedia\Message\MessageValue;
use Wikimedia\Parsoid\Core\ClientError;
use Wikimedia\Parsoid\Core\PageBundle;
use Wikimedia\Parsoid\Core\ResourceLimitExceededException;
use Wikimedia\Parsoid\Parsoid;
use Wikimedia\Parsoid\Utils\ContentUtils;
use Wikimedia\Stats\BufferingStatsdDataFactory;
use Wikimedia\Stats\Emitters\NullEmitter;
use Wikimedia\Stats\StatsCache;
use Wikimedia\Stats\StatsFactory;

/**
 * @covers \MediaWiki\Rest\Handler\Helper\HtmlInputTransformHelper
 * @group Database
 */
class HtmlInputTransformHelperTest extends MediaWikiIntegrationTestCase {
	private const CACHE_EPOCH = '20001111010101';

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

		$this->overrideConfigValue( MainConfigNames::CacheEpoch, self::CACHE_EPOCH );
	}

	/**
	 * @param array $methodOverrides
	 *
	 * @return MockObject|HtmlTransformFactory
	 */
	public function newMockHtmlTransformFactory( $methodOverrides = [] ): HtmlTransformFactory {
		$factory = $this->createNoOpMock( HtmlTransformFactory::class, [ 'getHtmlToContentTransform' ] );

		$factory->method( 'getHtmlToContentTransform' )->willReturnCallback(
			function ( $html ) use ( $methodOverrides ) {
				return $this->newHtmlToContentTransform( $html, $methodOverrides );
			}
		);

		return $factory;
	}

	/**
	 * @param array $transformMethodOverrides
	 * @param StatsFactory|null $stats
	 * @param ?PageIdentity $page
	 * @param array|string $body Body structure, or an HTML string
	 * @param array $parameters
	 * @param RevisionRecord|null $originalRevision
	 * @param Bcp47Code|null $pageLanguage
	 *
	 * @return HtmlInputTransformHelper
	 * @throws Exception
	 */
	private function newHelper(
		array $transformMethodOverrides = [],
		?StatsFactory $stats = null,
		?PageIdentity $page = null,
		$body = '',
		array $parameters = [],
		?RevisionRecord $originalRevision = null,
		?Bcp47Code $pageLanguage = null
	): HtmlInputTransformHelper {
		// TODO: $cache = $cache ?: new EmptyBagOStuff();
		// TODO: $stash = new SimpleParsoidOutputStash( $cache, 1 );

		$stats = $stats ?? StatsFactory::newNull();
		return new HtmlInputTransformHelper(
			$stats,
			$this->newMockHtmlTransformFactory( $transformMethodOverrides ),
			$this->getServiceContainer()->getParsoidOutputStash(),
			$this->getServiceContainer()->getParserOutputAccess(),
			$this->getServiceContainer()->getPageStore(),
			$this->getServiceContainer()->getRevisionLookup(),
			[], /* envOptions */
			$page,
			$body,
			$parameters,
			$originalRevision,
			$pageLanguage
		);
	}

	private function getTextFromFile( string $name ): string {
		return trim( file_get_contents( __DIR__ . "/../data/Transform/$name" ) );
	}

	private function getJsonFromFile( string $name ): array {
		$text = $this->getTextFromFile( $name );
		return json_decode( $text, JSON_OBJECT_AS_ARRAY );
	}

	public function provideRequests() {
		$profileVersion = '2.4.0';
		$wikitextProfileUri = 'https://www.mediawiki.org/wiki/Specs/wikitext/1.0.0';
		$htmlProfileUri = 'https://www.mediawiki.org/wiki/Specs/HTML/' . $profileVersion;
		$dataParsoidProfileUri = 'https://www.mediawiki.org/wiki/Specs/data-parsoid/' . $profileVersion;

		$wikiTextContentType = "text/plain; charset=utf-8; profile=\"$wikitextProfileUri\"";
		$htmlContentType = "text/html;profile=\"$htmlProfileUri\"";
		$dataParsoidContentType = "application/json;profile=\"$dataParsoidProfileUri\"";

		$htmlHeaders = [
			'content-type' => $htmlContentType,
		];

		// NOTE: profile version 999 is a placeholder for a future feature, see T78676
		$htmlContentType999 = 'text/html;profile="https://www.mediawiki.org/wiki/Specs/HTML/999.0.0"';
		$htmlHeaders999 = [
			'content-type' => $htmlContentType999,
		];

		// should convert html to wikitext ///////////////////////////////////
		$html = $this->getTextFromFile( 'MainPage-data-parsoid.html' );
		$expectedText = [
			'MediaWiki has been successfully installed',
			'== Getting started ==',
		];

		$params = [];
		$body = [ 'html' => $html ];
		yield 'should convert html to wikitext' => [
			$body,
			$params,
			$expectedText,
		];

		// should load original wikitext by revision id ////////////////////
		$params = [
			'oldid' => 1, // will be replaced by the actual revid
		];
		$body = [ 'html' => $html ];
		yield 'should load original wikitext by revision id' => [
			$body,
			$params,
			$expectedText,
		];

		// should accept original wikitext in body ////////////////////
		$originalWikitext = $this->getTextFromFile( 'OriginalMainPage.wikitext' );
		$params = [];
		$body = [
			'html' => $html,
			'original' => [
				'wikitext' => [
					'headers' => [
						'content-type' => $wikiTextContentType,
					],
					'body' => $originalWikitext,
				]
			]
		];
		yield 'should accept original wikitext in body' => [
			$body,
			$params,
			$expectedText, // TODO: ensure it's actually used!
		];

		// should use original html for selser (default) //////////////////////
		$originalDataParsoid = $this->getJsonFromFile( 'MainPage-original.data-parsoid' );
		$params = [
			'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
		];
		$body = [
			'html' => $html,
			'original' => [
				'html' => [
					'headers' => $htmlHeaders,
					'body' => $this->getTextFromFile( 'MainPage-original.html' ),
				],
				'data-parsoid' => [
					'headers' => [
						'content-type' => $dataParsoidContentType,
					],
					'body' => $originalDataParsoid
				]
			]
		];
		yield 'should use original html for selser (default)' => [
			$body,
			$params,
			$expectedText,
		];

		// should use original html for selser (1.1.1, meta) ///////////////////
		$params = [];
		$body = [
			'html' => $html,
			'original' => [
				'html' => [
					'headers' => [
						// XXX: If this is required anyway, how do we know we are using the
						//      version given in the HTML?
						'content-type' => 'text/html; profile="mediawiki.org/specs/html/1.1.1"',
					],
					'body' => $this->getTextFromFile( 'MainPage-data-parsoid-1.1.1.html' ),
				],
				'data-parsoid' => [
					'headers' => [
						'content-type' => $dataParsoidContentType,
					],
					'body' => $originalDataParsoid
				]
			]
		];
		yield 'should use original html for selser (1.1.1, meta)' => [
			$body,
			$params,
			$expectedText,
		];

		// should accept original html for selser (1.1.1, headers) ////////////
		$params = [
			'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
		];
		$body = [
			'html' => $html,
			'original' => [
				'html' => [
					'headers' => [
						// Set the schema version to 1.1.1!
						'content-type' => 'text/html; profile="mediawiki.org/specs/html/1.1.1"',
					],
					// No schema version in HTML
					'body' => $this->getTextFromFile( 'MainPage-original.html' ),
				],
				'data-parsoid' => [
					'headers' => [
						'content-type' => $dataParsoidContentType,
					],
					'body' => $originalDataParsoid
				]
			]
		];
		yield 'should use original html for selser (1.1.1, headers)' => [
			$body,
			$params,
			$expectedText,
		];

		// Return original wikitext when HTML doesn't change ////////////////////////////
		// New and old html are identical, which should produce no diffs
		// and reuse the original wikitext.
		$html = '<html><body id="mwAA"><div id="mwBB">Selser test</div></body></html>';
		$dataParsoid = [
			'ids' => [
				'mwAA' => [],
				'mwBB' => [ 'autoInsertedEnd' => true, 'stx' => 'html' ]
			]
		];

		$params = [
			'oldid' => 1, // Will be replaced by the revision ID of the default test page
		];
		$body = [
			'html' => $html,
			'original' => [
				'html' => [
					'headers' => $htmlHeaders,
					// original HTML is the same as the new HTML
					'body' => $html
				],
				'data-parsoid' => [
					'body' => $dataParsoid,
				]
			]
		];
		yield 'should use selser, return original wikitext because the HTML didn\'t change' => [
			$body,
			$params,
			null, // Returns original wikitext, because HTML didn't change.
		];

		// Should fall back to non-selective serialization. //////////////////
		// Without the original wikitext, use non-selective serialization.
		$params = [
			// No wikitext, no revid/oldid
			'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
		];
		$body = [
			'html' => $html,
			'original' => [
				'html' => [
					'headers' => $htmlHeaders,
					// original HTML is the same as the new HTML
					'body' => $html
				],
				'data-parsoid' => [
					'body' => $dataParsoid,
				]
			]
		];
		yield 'Should fallback to non-selective serialization' => [
			$body,
			$params,
			[ '<div>Selser test' ],
		];

		// should apply data-parsoid to duplicated ids /////////////////////////
		$html = '<html><body id="mwAA"><div id="mwBB">data-parsoid test</div>' .
			'<div id="mwBB">data-parsoid test</div></body></html>';
		$originalHtml = '<html><body id="mwAA"><div id="mwBB">data-parsoid test</div></body></html>';

		$params = [];
		$body = [
			'html' => $html,
			'original' => [
				'html' => [
					'headers' => $htmlHeaders,
					'body' => $originalHtml
				],
				'data-parsoid' => [
					'body' => $dataParsoid,
				]
			]
		];
		yield 'should apply data-parsoid to duplicated ids' => [
			$body,
			$params,
			[ '<div>data-parsoid test<div>data-parsoid test' ],
		];

		// should ignore data-parsoid if the input format is given but not pagebundle //////////////
		$html = '<html><body id="mwAA"><div id="mwBB">data-parsoid test</div>' .
			'<div id="mwBB">data-parsoid test</div></body></html>';
		$originalHtml = '<html><body id="mwAA"><div id="mwBB">data-parsoid test</div></body></html>';

		$params = [
			'from' => ParsoidFormatHelper::FORMAT_HTML,
		];
		$body = [
			'html' => $html,
			'original' => [
				'html' => [
					'headers' => $htmlHeaders,
					'body' => $originalHtml
				],
				'data-parsoid' => [
					// This has 'autoInsertedEnd' => true, which would cause
					// closing </div> tags to be omitted.
					'body' => $dataParsoid,
				]
			]
		];
		yield 'should ignore data-parsoid if the input format is not pagebundle' => [
			$body,
			$params,
			[ '<div>data-parsoid test</div><div>data-parsoid test</div>' ],
		];

		// should apply original data-mw ///////////////////////////////////////
		$html = '<p about="#mwt1" typeof="mw:Transclusion" id="mwAQ">hi</p>';
		$originalHtml = '<p about="#mwt1" typeof="mw:Transclusion" id="mwAQ">ho</p>';
		$dataParsoid = [ 'ids' => [ 'mwAQ' => [ 'pi' => [ [ [ 'k' => '1' ] ] ] ] ] ];
		$dataMediaWiki = [
			'ids' => [
				'mwAQ' => [
					'parts' => [ [
						'template' => [
							'target' => [ 'wt' => '1x', 'href' => './Template:1x' ],
							'params' => [ '1' => [ 'wt' => 'hi' ] ],
							'i' => 0
						]
					] ]
				]
			]
		];
		$params = [];
		$body = [
			'html' => $html,
			'original' => [
				'html' => [
					'headers' => $htmlHeaders,
					'body' => $originalHtml,
				],
				'data-parsoid' => [
					'body' => $dataParsoid,
				],
				'data-mw' => [
					'body' => $dataMediaWiki,
				],
			],
		];
		yield 'should apply original data-mw' => [
			$body,
			$params,
			[ '{{1x|hi}}' ],
		];

		// should give precedence to inline data-mw over original ////////
		$html = '<p about="#mwt1" typeof="mw:Transclusion" data-mw=\'{"parts":[{"template":{"target":{"wt":"1x","href":"./Template:1x"},"params":{"1":{"wt":"hi"}},"i":0}}]}\' id="mwAQ">hi</p>';
		$originalHtml = '<p about="#mwt1" typeof="mw:Transclusion" id="mwAQ">ho</p>';
		$dataParsoid = [ 'ids' => [ 'mwAQ' => [ 'pi' => [ [ [ 'k' => '1' ] ] ] ] ] ];
		$dataMediaWiki = [ 'ids' => [ 'mwAQ' => [] ] ]; // Missing data-mw.parts!
		$params = [];
		$body = [
			'html' => $html,
			'original' => [
				'html' => [
					'headers' => $htmlHeaders,
					'body' => $originalHtml
				],
				'data-parsoid' => [
					'body' => $dataParsoid,
				],
				'data-mw' => [
					'body' => $dataMediaWiki,
				],
			]
		];
		yield 'should give precedence to inline data-mw over original' => [
			$body,
			$params,
			[ '{{1x|hi}}' ],
		];

		// should not apply original data-mw if modified is supplied ///////////
		$html = '<p about="#mwt1" typeof="mw:Transclusion" id="mwAQ">hi</p>';
		$originalHtml = '<p about="#mwt1" typeof="mw:Transclusion" id="mwAQ">ho</p>';
		$dataParsoid = [ 'ids' => [ 'mwAQ' => [ 'pi' => [ [ [ 'k' => '1' ] ] ] ] ] ];
		$dataMediaWiki = [ 'ids' => [ 'mwAQ' => [] ] ]; // Missing data-mw.parts!
		$dataMediaWikiModified = [
			'ids' => [
				'mwAQ' => [
					'parts' => [ [
						'template' => [
							'target' => [ 'wt' => '1x', 'href' => './Template:1x' ],
							'params' => [ '1' => [ 'wt' => 'hi' ] ],
							'i' => 0
						]
					] ]
				]
			]
		];
		$params = [];
		$body = [
			'html' => $html,
			'data-mw' => [ // modified data
				'body' => $dataMediaWikiModified,
			],
			'original' => [
				'html' => [
					'headers' => $htmlHeaders999,
					'body' => $originalHtml
				],
				'data-parsoid' => [
					'body' => $dataParsoid,
				],
				'data-mw' => [ // original data
					'body' => $dataMediaWiki,
				],
			]
		];
		yield 'should not apply original data-mw if modified is supplied' => [
			$body,
			$params,
			[ '{{1x|hi}}' ],
		];

		// should apply original data-mw when modified is absent (captions 1) ///////////
		$html = $this->getTextFromFile( 'Image.html' );
		$dataParsoid = [ 'ids' => [
			'mwAg' => [ 'optList' => [ [ 'ck' => 'caption', 'ak' => 'Testing 123' ] ] ],
			'mwAw' => [ 'a' => [ 'href' => './File:Foobar.jpg' ], 'sa' => [] ],
			'mwBA' => [
				'a' => [ 'resource' => './File:Foobar.jpg', 'height' => '28', 'width' => '240' ],
				'sa' => [ 'resource' => 'File:Foobar.jpg' ]
			]
		] ];
		$dataMediaWiki = [ 'ids' => [ 'mwAg' => [ 'caption' => 'Testing 123' ] ] ];

		$params = [];
		$body = [
			'html' => $html,
			'original' => [
				'data-parsoid' => [
					'body' => $dataParsoid,
				],
				'data-mw' => [ // original data
					'body' => $dataMediaWiki,
				],
				'html' => [
					'headers' => $htmlHeaders999,
					'body' => $html
				],
			]
		];
		yield 'should apply original data-mw when modified is absent (captions 1)' => [
			$body,
			$params,
			[ '[[File:Foobar.jpg|Testing 123]]' ],
		];

		// should give precedence to inline data-mw over modified (captions 2) /////////////
		$htmlModified = $this->getTextFromFile( 'Image-data-mw.html' );
		$dataMediaWikiModified = [
			'ids' => [
				'mwAg' => [ 'caption' => 'Testing 123' ]
			]
		];

		$params = [];
		$body = [
			'html' => $htmlModified, // modified HTML
			'data-mw' => [
				'body' => $dataMediaWikiModified,
			],
			'original' => [
				'data-parsoid' => [
					'body' => $dataParsoid,
				],
				'data-mw' => [ // original data
					'body' => $dataMediaWiki,
				],
				'html' => [
					'headers' => $htmlHeaders999,
					'body' => $html
				],
			]
		];
		yield 'should give precedence to inline data-mw over modified (captions 2)' => [
			$body,
			$params,
			[ '[[File:Foobar.jpg]]' ],
		];

		// should give precedence to modified data-mw over original (captions 3) /////////////
		$dataMediaWikiModified = [
			'ids' => [
				'mwAg' => []
			]
		];

		$params = [];
		$body = [
			'html' => $html,
			'data-mw' => [
				'body' => $dataMediaWikiModified,
			],
			'original' => [
				'data-parsoid' => [
					'body' => $dataParsoid,
				],
				'data-mw' => [ // original data
					'body' => $dataMediaWiki,
				],
				'html' => [
					'headers' => $htmlHeaders999,
					'body' => $html
				],
			]
		];
		yield 'should give precedence to modified data-mw over original (captions 3)' => [
			$body,
			$params,
			[ '[[File:Foobar.jpg]]' ],
		];

		// should apply extra normalizations ///////////////////
		$htmlModified = 'Foo<h2></h2>Bar';
		$params = [
			'opts' => [
				'original' => []
			],
		];
		$body = [ 'html' => $htmlModified ]; // modified HTML
		yield 'should apply extra normalizations' => [
			$body,
			$params,
			[ 'FooBar' ], // empty tag was stripped
		];

		// should apply version downgrade ///////////
		$htmlOfMinimal = $this->getTextFromFile( 'Minimal.html' ); // Uses profile version 2.4.0
		$params = [
			'from' => ParsoidFormatHelper::FORMAT_PAGEBUNDLE,
		];
		$body = [
			'html' => $htmlOfMinimal,
			'original' => [
				'html' => [
					'headers' => [
						// Specify newer profile version for original HTML
						'content-type' => 'text/html;profile="https://www.mediawiki.org/wiki/Specs/HTML/999.0.0"'
					],
					// The profile version given inline in the original HTML doesn't matter, it's ignored
					'body' => $htmlOfMinimal,
				],
				'data-parsoid' => [ 'body' => [ 'ids' => [] ] ],
				'data-mw' => [ 'body' => [ 'ids' => [] ] ], // required by version 999.0.0
			]
		];
		yield 'should apply version downgrade' => [
			$body,
			$params,
			[ '123' ]
		];

		// should not apply version downgrade if versions are the same ///////////
		$htmlOfMinimal = $this->getTextFromFile( 'Minimal.html' ); // Uses profile version 2.4.0
		$params = [];
		$body = [
			'html' => $htmlOfMinimal,
			'original' => [
				'html' => [
					'headers' => [
						// Specify the exact same version specified inline in Minimal.html 2.4.0
						'content-type' => 'text/html;profile="https://www.mediawiki.org/wiki/Specs/HTML/2.4.0"'
					],
					// The profile version given inline in the original HTML doesn't matter, it's ignored
					'body' => $htmlOfMinimal,
				],
				'data-parsoid' => [ 'body' => [ 'ids' => [] ] ],
			]
		];
		yield 'should not apply version downgrade if versions are the same' => [
			$body,
			$params,
			[ '123' ]
		];

		// should convert html to json ///////////////////////////////////
		$html = $this->getTextFromFile( 'JsonConfig.html' );
		$expectedText = [
			'{"a":4,"b":3}',
		];

		$params = [
			'contentmodel' => CONTENT_MODEL_JSON,
		];
		$body = [ 'html' => $html ];
		yield 'should convert html to json' => [
			$body,
			$params,
			$expectedText,
			[ 'content-type' => 'application/json' ],
		];

		// page bundle input should work with no original data present  ///////////
		$htmlOfMinimal = $this->getTextFromFile( 'Minimal.html' ); // Uses profile version 2.4.0
		$params = [];
		$body = [
			'html' => $htmlOfMinimal,
			'original' => [],
		];
		yield 'page bundle input should work with no original data present' => [
			$body,
			$params,
			[ '123' ]
		];
	}

	private function createResponse() {
		$responseFactory = new ResponseFactory( [ new TextFormatter( 'qqx' ) ] );
		$response = $responseFactory->create();
		return $response;
	}

	/**
	 * @param array $body
	 * @param array $params
	 * @param string|string[]|null $expectedText Null means use the original content.
	 * @param array $expectedHeaders
	 * @dataProvider provideRequests()
	 * @covers \MediaWiki\Rest\Handler\Helper\HtmlInputTransformHelper
	 * @covers \MediaWiki\Parser\Parsoid\HtmlToContentTransform
	 */
	public function testResponse( $body, $params, $expectedText, array $expectedHeaders = [] ) {
		if ( !empty( $params['oldid'] ) ) {
			// If an oldid is set, run the test with an actual existing revision ID
			$originalContent = __METHOD__ . ' original content';
			$page = $this->getNonexistingTestPage();
			$this->editPage( $page, new WikitextContent( $originalContent ) );
			$page = $page->getTitle();
			$params['oldid'] = $page->getLatestRevID();
		} else {
			$page = PageIdentityValue::localIdentity( 7, NS_MAIN, $body['pageName'] ?? 'HtmlInputTransformHelperTest' );
			$originalContent = '';
		}

		$statsCache = new StatsCache();
		$statsdFactory = new BufferingStatsdDataFactory( '' );
		$stats = new StatsFactory( $statsCache, new NullEmitter(), new NullLogger() );
		$stats = $stats->withStatsdDataFactory( $statsdFactory );

		// TODO: find a way to test $pageLanguage
		$helper = $this->newHelper( [], $stats, $page, $body, $params );

		$response = $this->createResponse();
		$helper->putContent( $response );

		foreach ( $expectedHeaders as $name => $value ) {
			$this->assertSame( $value, $response->getHeaderLine( $name ) );
		}

		$body = $response->getBody();
		$body->rewind();
		$text = $body->getContents();

		$expectedText ??= $originalContent;
		foreach ( (array)$expectedText as $exp ) {
			$this->assertStringContainsString( $exp, $text );
		}

		// Ensure that exactly one key with the given prefix is set.
		// This ensures that the number of keys set always adds up to 100%,
		// for any set of keys under this prefix.
		$this->assertMetricsCount( 1, $statsdFactory, 'html_input_transform.original_html.' );
	}

	private function assertMetricsCount( $expected, BufferingStatsdDataFactory $stats, $prefix = '' ) {
		$keys = [];
		foreach ( $stats->getData() as $datum ) {
			if ( str_starts_with( $datum->getKey(), $prefix ) ) {
				$keys[] = $datum->getKey();
			}
		}

		$this->addToAssertionCount( 1 );
		if ( count( $keys ) !== $expected ) {
			$this->fail(
				"Failed to assert that the number of metrics keys starting with '$prefix' is $expected. Keys: \n\t"
				. implode( "\n\t", $keys )
			);
		}
	}

	public function provideOriginal() {
		$unchangedPB = new PageBundle(
			$this->getTextFromFile( 'MainPage-original.html' ),
			$this->getJsonFromFile( 'MainPage-original.data-parsoid' ),
			null,
			Parsoid::defaultHTMLVersion()
		);

		$originalContent = new WikitextContent( 'Goats are great!' );
		$selserContext = new SelserContext( $unchangedPB, 0, $originalContent );

		$unchangedPO = PageBundleParserOutputConverter::parserOutputFromPageBundle( $unchangedPB );

		$renderID = new ParsoidRenderID( 0, 'testing' );

		yield 'no original data' => [
			$selserContext,
			null,
			null,
			[
				'MediaWiki has been successfully installed',
				'== Getting started ==',
			]
		];

		// should load original wikitext by revision id ////////////////////
		yield 'should load original wikitext by revision id' => [
			$selserContext,
			1, // will be replaced by the actual revid
			$unchangedPB, // Expect selser, since HTML didn't change.
			null, // Selser should preserve the original content.
		];

		// should use wikitext from fake revision ////////////////////
		$page = PageIdentityValue::localIdentity( 7, NS_MAIN, 'HtmlInputTransformHelperTest' );
		$rev = new MutableRevisionRecord( $page );
		$rev->setContent( SlotRecord::MAIN, new WikitextContent( 'Goats are great!' ) );

		yield 'should use wikitext from fake revision' => [
			$selserContext,
			$rev,
			$unchangedPO, // Expect selser, since HTML didn't change.
			'Goats are great!', // Text from the fake revision. Selser should preserve it.
		];

		// should get original HTML from stash ////////////////////
		yield 'should get original HTML from stash' => [
			$selserContext,
			$rev,
			$renderID, // Expect selser, since HTML didn't change.
			'Goats are great!', // Text from the fake revision. Selser should preserve it.
		];
	}

	/**
	 * @dataProvider provideOriginal()
	 *
	 * @param SelserContext|null $stashed
	 * @param RevisionRecord|int|null $rev
	 * @param ParsoidRenderID|PageBundle|ParserOutput|null $originalRendering
	 * @param string|string[]|null $expectedText Null means use the original content
	 *
	 * @covers \MediaWiki\Rest\Handler\Helper\HtmlInputTransformHelper::setOriginal
	 */
	public function testSetOriginal( ?SelserContext $stashed, $rev, $originalRendering, $expectedText ) {
		if ( is_int( $rev ) && $rev > 0 ) {
			// If a revision ID is given, run the test with an actual existing revision ID
			$originalContent = __METHOD__ . ' original content';
			$page = $this->getNonexistingTestPage();
			$this->editPage( $page, new WikitextContent( $originalContent ) );
			$page = $page->getTitle();
			$revId = $page->getLatestRevID() ?: 0;
			$rev = $revId;
		} elseif ( $rev instanceof RevisionRecord ) {
			$originalContentObj = $rev->getContent( SlotRecord::MAIN );
			if ( !$originalContentObj instanceof TextContent ) {
				throw new LogicException( 'Not implemented' );
			}
			$originalContent = $originalContentObj->getText();
			$page = $rev->getPage();
			$revId = $rev->getId() ?: 0;
		} else {
			$originalContent = '';
			$page = PageIdentityValue::localIdentity( 7, NS_MAIN, 'HtmlInputTransformHelperTest' );
			$revId = 0;
		}

		if ( $stashed ) {
			$renderID = new ParsoidRenderID( $revId, 'testing' );
			$stash = $this->getServiceContainer()->getParsoidOutputStash();
			$stash->set( $renderID, $stashed );
		}

		$html = $this->getTextFromFile( 'MainPage-original.html' );

		$params = [];
		$body = [
			'html' => $html
		];

		$statsCache = new StatsCache();
		$statsdFactory = new BufferingStatsdDataFactory( '' );
		$stats = new StatsFactory( $statsCache, new NullEmitter(), new NullLogger() );
		$stats = $stats->withStatsdDataFactory( $statsdFactory );

		$helper = $this->newHelper( [], $stats, $page, $body, $params );
		$helper->setOriginal( $rev, $originalRendering );

		$response = $this->createResponse();
		$helper->putContent( $response );

		$body = $response->getBody();
		$body->rewind();
		$text = $body->getContents();

		$expectedText ??= $originalContent;
		foreach ( (array)$expectedText as $exp ) {
			$this->assertStringContainsString( $exp, $text );
		}

		// Ensure that exactly one key with the given prefix is set.
		// This ensures that the number of keys set always adds up to 100%,
		// for any set of keys under this prefix.
		if ( $rev || $originalRendering ) {
			$this->assertMetricsCount( 1, $statsdFactory, 'html_input_transform.original_html.given' );
		} else {
			$this->assertMetricsCount( 1, $statsdFactory, 'html_input_transform.original_html.not_given' );
		}
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\HtmlInputTransformHelper::getTransform
	 */
	public function testGetTransform() {
		$page = PageIdentityValue::localIdentity( 7, NS_MAIN, 'HtmlInputTransformHelperTest' );
		$html = '<p>kittens are cute</p>';

		$params = [];
		$body = [
			'html' => $html
		];

		$helper = $this->newHelper( [], StatsFactory::newNull(), $page, $body, $params );

		$transform = $helper->getTransform();

		$this->assertStringContainsString( 'kittens', ContentUtils::toXML( $transform->getModifiedDocument() ) );
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\HtmlInputTransformHelper
	 * @covers \MediaWiki\Parser\Parsoid\HtmlToContentTransform
	 */
	public function testResponseForFakeRevision() {
		$wikitext = 'Unsaved Revision Content';

		$html = $this->getTextFromFile( 'Minimal.html' );
		$page = PageIdentityValue::localIdentity( 7, NS_MAIN, $body['pageName'] ?? 'HtmlInputTransformHelperTest' );

		// Create a fake revision. Since the HTML didn't change, we expect to get back the content
		// we defined for this revision.
		$revision = new MutableRevisionRecord( $page );
		$revision->setContent( SlotRecord::MAIN, new WikitextContent( $wikitext ) );

		$params = [];
		$body = [
			'html' => $html,
			'original' => [
				'html' => [
					'headers' => [ 'content-type' => 'text/html;profile="https://www.mediawiki.org/wiki/Specs/HTML/2.4.0"' ],
					// original HTML is the same as the new HTML
					'body' => $html
				],
			]
		];

		$page = PageIdentityValue::localIdentity( 7, NS_MAIN, $body['pageName'] ?? 'HtmlInputTransformHelperTest' );

		$helper = $this->newHelper( [], StatsFactory::newNull(), $page, $body, $params, $revision );

		$response = $this->createResponse();
		$helper->putContent( $response );

		$body = $response->getBody();
		$body->rewind();

		// Since the HTML didn't change, we expect to get back the content of the fake revision.
		$this->assertSame( $wikitext, $body->getContents() );
	}

	public function testResponseWithRenderIdForExistingRevision() {
		$profileVersion = '2.4.0';
		$htmlProfileUri = 'https://www.mediawiki.org/wiki/Specs/HTML/' . $profileVersion;
		$htmlContentType = "text/html;profile=\"$htmlProfileUri\"";

		$htmlHeaders = [
			'content-type' => $htmlContentType,
		];

		$page = $this->getExistingTestPage();
		$oldWikitext = $page->getContent()->serialize();

		$html = $this->getTextFromFile( 'MainPage-original.html' );
		$dataParsoid = $this->getJsonFromFile( 'MainPage-original.data-parsoid' );

		$pb = new PageBundle(
			$html,
			$dataParsoid,
			[],
			$profileVersion,
			$htmlHeaders,
			CONTENT_MODEL_WIKITEXT
		);

		$eTag = '"' . $page->getLatest() . '/just-a-test/edit"';

		// Load the original data based on the ETag
		$body = [ 'html' => $html, 'original' => [ 'renderid' => $eTag ] ];
		$params = [];

		$stash = $this->getServiceContainer()->getParsoidOutputStash();
		$stash->set(
			ParsoidRenderID::newFromETag( $eTag ),
			new SelserContext( $pb, $page->getLatest() ),
		);

		$helper = $this->newHelper( [], StatsFactory::newNull(), $page, $body, $params );

		$content = $helper->getContent();

		// Assert that we get back the old wikitext, not wikitext derived from the HTML.
		// Since the supplied HTML is the same as the HTML in the stash, selser should
		// decide that there is nothing to do and return the wikitext unchanged.
		$this->assertSame( $oldWikitext, $content->serialize() );
	}

	public function testResponseWithRenderIdForUnsavedWikitext() {
		$profileVersion = '2.4.0';
		$htmlProfileUri = 'https://www.mediawiki.org/wiki/Specs/HTML/' . $profileVersion;
		$htmlContentType = "text/html;profile=\"$htmlProfileUri\"";

		$htmlHeaders = [
			'content-type' => $htmlContentType,
		];

		$page = $this->getNonexistingTestPage();

		$html = $this->getTextFromFile( 'MainPage-original.html' );
		$dataParsoid = $this->getJsonFromFile( 'MainPage-original.data-parsoid' );
		$oldWikitext = 'Fake old wikitext';

		$content = new WikitextContent( $oldWikitext );
		$pb = new PageBundle(
			$html,
			$dataParsoid,
			[],
			$profileVersion,
			$htmlHeaders,
			CONTENT_MODEL_WIKITEXT
		);

		// NOTE: Using 0 as the prefix in the ETag indicates that the content does
		// not correspond to a saved revision. Since we don't have a revision
		// ID that we could use to load the wikitext from the database,
		// the wikitext should be taken from the stash.
		// That is the behavior asserted by this test case.
		$eTag = '"0/just-a-test/edit"';

		// Load the original data based on the ETag
		$body = [ 'html' => $html, 'original' => [ 'renderid' => $eTag ] ];
		$params = [];

		$stash = $this->getServiceContainer()->getParsoidOutputStash();
		$stash->set(
			ParsoidRenderID::newFromETag( $eTag ),
			new SelserContext( $pb, 0, $content )
		);

		$helper = $this->newHelper( [], StatsFactory::newNull(), $page, $body, $params );

		$content = $helper->getContent();

		// Assert that we get back the old wikitext, not wikitext derived from the HTML.
		// Since the supplied HTML is the same as the HTML in the stash, selser should
		// decide that there is nothing to do and return the wikitext unchanged.
		$this->assertSame( $oldWikitext, $content->serialize() );
	}

	public function testETagWithBadUUIDFails() {
		$page = $this->getExistingTestPage();
		$html = 'whatever';

		// Call getParserOutput() to make sure a rendering is in the ParserCache.
		// Even though we find a rendering, it should be discarded because it doesn't match
		// the ETag.
		$access = $this->getServiceContainer()->getParserOutputAccess();
		$pageLookup = $this->getServiceContainer()->getPageStore();
		$popt = ParserOptions::newFromAnon();
		$popt->setUseParsoid();
		$access->getParserOutput( $pageLookup->getPageByReference( $page ), $popt )->getValue();

		$revid = $page->getLatest();
		$eTag = "\"$revid/nope-nope-nope\"";

		$body = [ 'html' => $html, 'original' => [ 'renderid' => $eTag ] ];
		$params = [];

		$this->expectException( HttpException::class );
		$this->expectExceptionCode( 412 );
		$helper = $this->newHelper( [], StatsFactory::newNull(), $page, $body, $params );
		$helper->getContent();
	}

	public function testETagWithBadRevIDFails() {
		$page = $this->getExistingTestPage();
		$html = 'whatever';

		// Non-Existing revision
		$eTag = "\"1111111/nope-nope-nope\"";

		$body = [ 'html' => $html, 'original' => [ 'renderid' => $eTag ] ];
		$params = [];

		$this->expectException( HttpException::class );
		$this->expectExceptionCode( 412 );
		$helper = $this->newHelper( [], StatsFactory::newNull(), $page, $body, $params );
		$helper->getContent();
	}

	public function testResponseWithRenderIDFallbackToParserCache() {
		// use wikitext that would be normalized without selser.
		$oldWikitext = '<p >testing</P>';
		$rev = $this->editPage( __METHOD__, $oldWikitext )->value['revision-record'];
		$page = $rev->getPage();

		$access = $this->getServiceContainer()->getParserOutputAccess();
		$pageLookup = $this->getServiceContainer()->getPageStore();

		$popt = ParserOptions::newFromAnon();
		$popt->setUseParsoid();
		$pout = $access->getParserOutput( $pageLookup->getPageByReference( $page ), $popt )->getValue();

		$key = ParsoidRenderID::newFromParserOutput( $pout )->getKey();
		$html = $pout->getRawText();

		// Load the original data based on the ETag
		$body = [ 'html' => $html, 'original' => [ 'renderid' => $key ] ];
		$params = [];

		// We are asking for a stash key that is not in the stash.
		// However, a rendering with the corresponding key is in the ParserCache.
		// Because of this, the code below will not throw to trigger a 412 response.
		$helper = $this->newHelper( [], StatsFactory::newNull(), $page, $body, $params );
		$content = $helper->getContent();

		// The wikitext should not have been normalized by re-serialization
		$this->assertSame( $oldWikitext, $content->serialize() );
	}

	public function testResponseWithRevisionIDFallbackToRendering() {
		// use wikitext that would be normalized without selser.
		$oldWikitext = '<p >testing</P>';
		$rev = $this->editPage( __METHOD__, $oldWikitext )->value['revision-record'];
		$page = $rev->getPage();

		$access = $this->getServiceContainer()->getParserOutputAccess();
		$pageLookup = $this->getServiceContainer()->getPageStore();

		$popt = ParserOptions::newFromAnon();
		$popt->setUseParsoid();
		$pout = $access->getParserOutput( $pageLookup->getPageByReference( $page ), $popt )->getValue();
		$html = $pout->getRawText();

		// Load the original data based on the ETag
		$body = [ 'html' => $html, 'original' => [ 'revid' => $rev->getId() ] ];
		$params = [];

		// We are asking for a stash key that is not in the stash.
		// However, a rendering with the corresponding key is in the ParserCache.
		// Because of this, the code below will not trigger a 412 response.
		$helper = $this->newHelper( [], StatsFactory::newNull(), $page, $body, $params );
		$content = $helper->getContent();

		// The wikitext should not have been normalized by re-serialization
		$this->assertSame( $oldWikitext, $content->serialize() );
	}

	public static function provideHandlesParsoidError() {
		yield 'ClientError' => [
			new ClientError( 'TEST_TEST' ),
			new LocalizedHttpException(
				new MessageValue( 'rest-html-backend-error' ),
				400,
				[
					'reason' => 'TEST_TEST'
				]
			)
		];
		yield 'ResourceLimitExceededException' => [
			new ResourceLimitExceededException( 'TEST_TEST' ),
			new LocalizedHttpException(
				new MessageValue( 'rest-resource-limit-exceeded' ),
				413,
				[
					'reason' => 'TEST_TEST'
				]
			)
		];
	}

	/**
	 * @dataProvider provideHandlesParsoidError
	 */
	public function testHandlesParsoidError(
		Exception $parsoidException,
		Exception $expectedException
	) {
		$page = $this->getExistingTestPage( __METHOD__ );

		$body = [ 'html' => 'hi', ];
		$params = [];

		$helper = $this->newHelper( [
			'htmlToContent' => static function () use ( $parsoidException ) {
				throw $parsoidException;
			}
		], StatsFactory::newNull(), $page, $body, $params );

		$this->expectExceptionObject( $expectedException );
		$helper->getContent();
	}

	public function testHandlesInvalidRenderID(): void {
		$page = $this->getExistingTestPage( __METHOD__ );

		$body = [ 'html' => 'hi', 'original' => [ 'renderid' => 'foo' ] ];
		$params = [];

		$this->expectExceptionObject( new LocalizedHttpException(
			new MessageValue( 'rest-parsoid-bad-render-id', [ 'foo' ] ),
			400
		) );

		$this->newHelper( [], StatsFactory::newNull(), $page, $body, $params );
	}

	private function newHtmlToContentTransform( $html, $methodOverrides = [] ): HtmlToContentTransform {
		$transform = $this->getMockBuilder( HtmlToContentTransform::class )
			->onlyMethods( array_keys( $methodOverrides ) )
			->setConstructorArgs( [
				$html,
				$this->getExistingTestPage(),
				new Parsoid(
					$this->getServiceContainer()->getParsoidSiteConfig(),
					$this->getServiceContainer()->getParsoidDataAccess()
				),
				MainConfigSchema::getDefaultValue( MainConfigNames::ParsoidSettings ),
				$this->getServiceContainer()->getParsoidPageConfigFactory(),
				$this->getServiceContainer()->getContentHandlerFactory()
			] )
			->getMock();

		foreach ( $methodOverrides as $method => $callback ) {
			$transform->method( $method )->willReturnCallback( $callback );
		}

		return $transform;
	}

}
PK       ! #~  ~  3  Rest/Handler/Helper/HtmlMessageOutputHelperTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler\Helper;

use MediaWiki\Rest\Handler\Helper\HtmlMessageOutputHelper;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Rest\Handler\Helper\HtmlMessageOutputHelper
 * @group Database
 */
class HtmlMessageOutputHelperTest extends MediaWikiIntegrationTestCase {
	private function newHelper( $page ): HtmlMessageOutputHelper {
		return new HtmlMessageOutputHelper( $page );
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\HtmlMessageOutputHelper::init
	 * @covers \MediaWiki\Rest\Handler\Helper\HtmlMessageOutputHelper::getHtml
	 */
	public function testGetHtml() {
		$page = $this->getNonexistingTestPage( 'MediaWiki:Logouttext' );

		$helper = $this->newHelper( $page );

		$this->assertSame( 0, $page->getLatest() );

		$htmlresult = $helper->getHtml()->getRawText();

		$this->assertStringContainsString( 'You are now logged out', $htmlresult );
		// Check that we have a full HTML document in English
		$this->assertStringContainsString( '<html', $htmlresult );
		$this->assertStringContainsString( 'content="en"', $htmlresult );
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\HtmlMessageOutputHelper::init
	 * @covers \MediaWiki\Rest\Handler\Helper\HtmlMessageOutputHelper::getETag
	 */
	public function testGetETag() {
		$page = $this->getNonexistingTestPage( 'MediaWiki:Logouttext' );

		$helper = $this->newHelper( $page );

		$etag = $helper->getETag();

		$this->assertStringContainsString( '"message/', $etag );
	}

	/**
	 * @covers \MediaWiki\Rest\Handler\Helper\HtmlMessageOutputHelper::init
	 * @covers \MediaWiki\Rest\Handler\Helper\HtmlMessageOutputHelper::getHtml
	 */
	public function testGetHtmlWithLanguageCode() {
		$page = $this->getNonexistingTestPage( 'MediaWiki:Logouttext/de' );

		$helper = $this->newHelper( $page );

		$this->assertSame( 0, $page->getLatest() );

		$htmlresult = $helper->getHtml()->getRawText();

		$this->assertStringContainsString( 'Du bist nun abgemeldet', $htmlresult );
		// Check that we have a full HTML document in English
		$this->assertStringContainsString( '<html', $htmlresult );
		$this->assertStringContainsString( 'content="de"', $htmlresult );
	}
}
PK       ! "P  P  "  Rest/Handler/UpdateHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use MediaWiki\Api\ApiUsageException;
use MediaWiki\Config\HashConfig;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Content\WikitextContentHandler;
use MediaWiki\Json\FormatJson;
use MediaWiki\Languages\LanguageNameUtils;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\MainConfigNames;
use MediaWiki\Message\Message;
use MediaWiki\Parser\MagicWordFactory;
use MediaWiki\Parser\ParserFactory;
use MediaWiki\Parser\Parsoid\ParsoidParserFactory;
use MediaWiki\Rest\Handler\UpdateHandler;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\RequestData;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Session\Token;
use MediaWiki\Status\Status;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleFactory;
use MediaWikiLangTestCase;
use MockTitleTrait;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\Message\DataMessageValue;
use Wikimedia\Message\MessageValue;
use Wikimedia\Message\ParamType;
use Wikimedia\Message\ScalarParam;
use Wikimedia\UUID\GlobalIdGenerator;

/**
 * @group Database
 * @covers \MediaWiki\Rest\Handler\UpdateHandler
 */
class UpdateHandlerTest extends MediaWikiLangTestCase {
	use ActionModuleBasedHandlerTestTrait;
	use DummyServicesTrait;
	use MockTitleTrait;

	private function newHandler( $resultData, $throwException = null, $csrfSafe = false ) {
		$config = new HashConfig( [
			MainConfigNames::RightsUrl => 'https://creativecommons.org/licenses/by-sa/4.0/',
			MainConfigNames::RightsText => 'CC-BY-SA 4.0'
		] );

		$wikitextContentHandler = new WikitextContentHandler(
			CONTENT_MODEL_WIKITEXT,
			$this->createMock( TitleFactory::class ),
			$this->createMock( ParserFactory::class ),
			$this->createMock( GlobalIdGenerator::class ),
			$this->createMock( LanguageNameUtils::class ),
			$this->createMock( LinkRenderer::class ),
			$this->createMock( MagicWordFactory::class ),
			$this->createMock( ParsoidParserFactory::class )
		);

		// Only wikitext is defined, returns specific handler instance
		$contentHandlerFactory = $this->getDummyContentHandlerFactory(
			[ CONTENT_MODEL_WIKITEXT => $wikitextContentHandler ]
		);

		$titleCodec = $this->getDummyMediaWikiTitleCodec();

		/** @var RevisionLookup|MockObject $revisionLookup */
		$revisionLookup = $this->createNoOpMock(
			RevisionLookup::class,
			[ 'getRevisionById', 'getRevisionByTitle' ]
		);
		$revisionLookup->method( 'getRevisionById' )
			->willReturnCallback( function ( $id ) {
				$title = $this->makeMockTitle( __CLASS__ );
				$rev = new MutableRevisionRecord( $title );
				$rev->setId( $id );
				$rev->setContent( SlotRecord::MAIN, new WikitextContent( "Content of revision $id" ) );
				$rev->setTimestamp( '2020-01-01T01:02:03Z' );
				return $rev;
			} );
		$revisionLookup->method( 'getRevisionByTitle' )
			->willReturnCallback( static function ( $title ) {
				$rev = new MutableRevisionRecord( Title::castFromLinkTarget( $title ) );
				$rev->setId( 1234 );
				$rev->setContent( SlotRecord::MAIN, new WikitextContent( "Current content of $title" ) );
				$rev->setTimestamp( '2020-01-01T01:02:03Z' );
				return $rev;
			} );

		$handler = new UpdateHandler(
			$config,
			$contentHandlerFactory,
			$titleCodec,
			$titleCodec,
			$revisionLookup
		);

		$apiMain = $this->getApiMain( $csrfSafe );
		$dummyModule = $this->getDummyApiModule( $apiMain, 'edit', $resultData, $throwException );

		$handler->setApiMain( $apiMain );
		$handler->overrideActionModule(
			'edit',
			'action',
			$dummyModule
		);

		return $handler;
	}

	public static function provideExecute() {
		$token = strval( new Token( 'TOKEN', '' ) );

		yield "create with token" => [
			[ // Request data received by UpdateHandler
				'method' => 'PUT',
				'pathParams' => [ 'title' => 'Foo' ],
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'token' => $token,
					'source' => 'Lorem Ipsum',
					'comment' => 'Testing'
				] ),
			],
			[ // Fake request expected to be passed into ApiEditPage
				'title' => 'Foo',
				'text' => 'Lorem Ipsum',
				'summary' => 'Testing',
				'createonly' => '1',
				'token' => $token,
			],
			[ // Mock response returned by ApiEditPage
				"edit" => [
					"new" => true,
					"result" => "Success",
					"pageid" => 94542,
					"title" => "Foo",
					"contentmodel" => "wikitext",
					"oldrevid" => 0,
					"newrevid" => 371707,
					"newtimestamp" => "2018-12-18T16:59:42Z",
				]
			],
			[ // Response expected to be generated by UpdateHandler
				'id' => 94542,
				'title' => 'Foo',
				'key' => 'Foo',
				'content_model' => 'wikitext',
				'latest' => [
					'id' => 371707,
					'timestamp' => "2018-12-18T16:59:42Z"
				],
				'license' => [
					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
					'title' => 'CC-BY-SA 4.0'
				],
				'source' => 'Content of revision 371707'
			],
			false,
			true,
		];

		yield "create with model" => [
			[ // Request data received by UpdateHandler
				'method' => 'POST',
				'pathParams' => [ 'title' => 'Foo' ],
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'token' => $token,
					'source' => 'Lorem Ipsum',
					'comment' => 'Testing',
					'content_model' => CONTENT_MODEL_WIKITEXT,
				] ),
			],
			[ // Fake request expected to be passed into ApiEditPage
				'title' => 'Foo',
				'text' => 'Lorem Ipsum',
				'summary' => 'Testing',
				'contentmodel' => 'wikitext',
				'createonly' => '1',
				'token' => $token,
			],
			[ // Mock response returned by ApiEditPage
				"edit" => [
					"new" => true,
					"result" => "Success",
					"pageid" => 94542,
					"title" => "Foo",
					"contentmodel" => "wikitext",
					"oldrevid" => 0,
					"newrevid" => 371707,
					"newtimestamp" => "2018-12-18T16:59:42Z",
				]
			],
			[ // Response expected to be generated by UpdateHandler
				'id' => 94542,
				'title' => 'Foo',
				'key' => 'Foo',
				'content_model' => 'wikitext',
				'latest' => [
					'id' => 371707,
					'timestamp' => "2018-12-18T16:59:42Z"
				],
				'license' => [
					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
					'title' => 'CC-BY-SA 4.0'
				],
				'source' => 'Content of revision 371707'
			],
			false,
			true,
		];

		yield "update with token" => [
			[ // Request data received by UpdateHandler
				'method' => 'PUT',
				'pathParams' => [ 'title' => 'foo bar' ],
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'token' => $token,
					'source' => 'Lorem Ipsum',
					'comment' => 'Testing',
					'latest' => [ 'id' => 789123 ],
				] ),
			],
			[ // Fake request expected to be passed into ApiEditPage
				'title' => 'foo bar',
				'text' => 'Lorem Ipsum',
				'summary' => 'Testing',
				'nocreate' => '1',
				'baserevid' => '789123',
				'token' => $token,
			],
			[ // Mock response returned by ApiEditPage
				"edit" => [
					"result" => "Success",
					"pageid" => 94542,
					"title" => "Foo_bar",
					"contentmodel" => "wikitext",
					"oldrevid" => 371705,
					"newrevid" => 371707,
					"newtimestamp" => "2018-12-18T16:59:42Z",
				]
			],
			[ // Response expected to be generated by UpdateHandler
				'id' => 94542,
				'content_model' => 'wikitext',
				'title' => 'Foo bar',
				'key' => 'Foo_bar',
				'latest' => [
					'id' => 371707,
					'timestamp' => "2018-12-18T16:59:42Z"
				],
				'license' => [
					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
					'title' => 'CC-BY-SA 4.0'
				],
				'source' => 'Content of revision 371707'
			],
			false,
			true,
		];

		yield "update with model" => [
			[ // Request data received by UpdateHandler
				'method' => 'POST',
				'pathParams' => [ 'title' => 'foo bar' ],
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'source' => 'Lorem Ipsum',
					'comment' => 'Testing',
					'content_model' => CONTENT_MODEL_WIKITEXT,
					'latest' => [ 'id' => 789123 ],
				] ),
			],
			[ // Fake request expected to be passed into ApiEditPage
				'title' => 'foo bar',
				'text' => 'Lorem Ipsum',
				'summary' => 'Testing',
				'contentmodel' => 'wikitext',
				'nocreate' => '1',
				'baserevid' => '789123',
				'token' => '+\\',
			],
			[ // Mock response returned by ApiEditPage
				"edit" => [
					"result" => "Success",
					"pageid" => 94542,
					"title" => "Foo_bar",
					"contentmodel" => "wikitext",
					"oldrevid" => 371705,
					"newrevid" => 371707,
					"newtimestamp" => "2018-12-18T16:59:42Z",
				]
			],
			[ // Response expected to be generated by UpdateHandler
				'id' => 94542,
				'content_model' => 'wikitext',
				'title' => 'Foo bar',
				'key' => 'Foo_bar',
				'latest' => [
					'id' => 371707,
					'timestamp' => "2018-12-18T16:59:42Z"
				],
				'license' => [
					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
					'title' => 'CC-BY-SA 4.0'
				],
				'source' => 'Content of revision 371707'
			],
			true,
			false,
		];

		yield "update without token" => [
			[ // Request data received by UpdateHandler
				'method' => 'PUT',
				'pathParams' => [ 'title' => 'Foo' ],
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'source' => 'Lorem Ipsum',
					'comment' => 'Testing',
					'content_model' => CONTENT_MODEL_WIKITEXT,
					'latest' => [ 'id' => 789123 ],
				] ),
			],
			[ // Fake request expected to be passed into ApiEditPage
				'title' => 'Foo',
				'text' => 'Lorem Ipsum',
				'summary' => 'Testing',
				'contentmodel' => 'wikitext',
				'nocreate' => '1',
				'baserevid' => '789123',
				'token' => '+\\', // use known-good token for current user (anon)
			],
			[ // Mock response returned by ApiEditPage
				"edit" => [
					"result" => "Success",
					"pageid" => 94542,
					"title" => "Foo",
					"contentmodel" => "wikitext",
					"oldrevid" => 371705,
					"newrevid" => 371707,
					"newtimestamp" => "2018-12-18T16:59:42Z",
				]
			],
			[ // Response expected to be generated by UpdateHandler
				'id' => 94542,
				'content_model' => 'wikitext',
				'latest' => [
					'id' => 371707,
					'timestamp' => "2018-12-18T16:59:42Z"
				],
				'license' => [
					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
					'title' => 'CC-BY-SA 4.0'
				],
			],
			true,
			false,
		];

		yield "null-edit (unchanged)" => [
			[ // Request data received by UpdateHandler
				'method' => 'PUT',
				'pathParams' => [ 'title' => 'Foo' ],
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'source' => 'Lorem Ipsum',
					'comment' => 'Testing',
					'content_model' => CONTENT_MODEL_WIKITEXT,
					'latest' => [ 'id' => 789123 ],
				] ),
			],
			[ // Fake request expected to be passed into ApiEditPage
				'title' => 'Foo',
				'text' => 'Lorem Ipsum',
				'summary' => 'Testing',
				'contentmodel' => 'wikitext',
				'nocreate' => '1',
				'baserevid' => '789123',
				'token' => '+\\', // use known-good token for current user (anon)
			],
			[ // Mock response returned by ApiEditPage
				"edit" => [
					"result" => "Success",
					"pageid" => 94542,
					"title" => "Foo",
					"contentmodel" => "wikitext",
					"nochange" => "", // null-edit!
				]
			],
			[ // Response expected to be generated by UpdateHandler
				'id' => 94542,
				'content_model' => 'wikitext',
				'latest' => [
					'id' => 1234, // ID of current rev, as defined in newHandler()
					'timestamp' => '2020-01-01T01:02:03Z' // see fake RevisionStore in newHandler()
				],
				'license' => [
					'url' => 'https://creativecommons.org/licenses/by-sa/4.0/',
					'title' => 'CC-BY-SA 4.0'
				],
			],
			true,
			false,
		];
	}

	/**
	 * @dataProvider provideExecute
	 */
	public function testExecute(
		$requestData,
		$expectedActionParams,
		$actionResult,
		$expectedResponse,
		$csrfSafe,
		$hasToken
	) {
		$request = new RequestData( $requestData );

		$handler = $this->newHandler( $actionResult, null, $csrfSafe );

		$session = $this->getSession( $csrfSafe );

		$session->method( 'hasToken' )->willReturn( $hasToken );

		$session->method( 'getToken' )->willReturn( new Token( 'TOKEN', '' ) );

		$responseData = $this->executeHandlerAndGetBodyData(
			$handler, $request, [], [], [], [], null, $session
		);

		// Check parameters passed to ApiEditPage by UpdateHandler based on $requestData
		foreach ( $expectedActionParams as $key => $value ) {
			$this->assertSame(
				$value,
				$handler->getApiMain()->getVal( $key ),
				"ApiEditPage param: $key"
			);
		}

		// Check response that UpdateHandler created after receiving $actionResult from ApiEditPage
		foreach ( $expectedResponse as $key => $value ) {
			$this->assertArrayHasKey( $key, $responseData );
			$this->assertSame(
				$value,
				$responseData[ $key ],
				"UpdateHandler response field: $key"
			);
		}
	}

	public static function provideBodyValidation() {
		yield "missing source field" => [
			[ // Request data received by UpdateHandler
				'method' => 'PUT',
				'pathParams' => [ 'title' => 'Foo' ],
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'token' => 'TOKEN',
					'comment' => 'Testing',
					'content_model' => CONTENT_MODEL_WIKITEXT,
				] ),
			],
			MessageValue::new( 'rest-body-validation-error', [
				DataMessageValue::new( 'paramvalidator-missingparam', [], 'missingparam' )
					->plaintextParams( 'source' )
			] ),
		];
		yield "missing comment field" => [
			[ // Request data received by UpdateHandler
				'method' => 'PUT',
				'pathParams' => [ 'title' => 'Foo' ],
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'token' => 'TOKEN',
					'source' => 'Lorem Ipsum',
					'content_model' => CONTENT_MODEL_WIKITEXT,
				] ),
			],
			MessageValue::new( 'rest-body-validation-error', [
				DataMessageValue::new( 'paramvalidator-missingparam', [], 'missingparam' )
					->plaintextParams( 'comment' )
			] ),
		];
	}

	/**
	 * @dataProvider provideBodyValidation
	 */
	public function testBodyValidation( array $requestData, MessageValue $expectedMessage ) {
		$request = new RequestData( $requestData );

		$handler = $this->newHandler( [] );

		$exception = $this->executeHandlerAndGetHttpException( $handler, $request );

		$this->assertSame( 400, $exception->getCode(), 'HTTP status' );
		$this->assertInstanceOf( LocalizedHttpException::class, $exception );

		/** @var LocalizedHttpException $exception */
		$this->assertEquals( $expectedMessage, $exception->getMessageValue() );
	}

	public static function provideHeaderValidation() {
		yield "bad content type" => [
			[ // Request data received by UpdateHandler
				'method' => 'PUT',
				'pathParams' => [ 'title' => 'Foo' ],
				'headers' => [
					'Content-Type' => 'text/plain',
				],
				'bodyContents' => json_encode( [
					'token' => 'TOKEN',
					'source' => 'Lorem Ipsum',
					'comment' => 'Testing',
					'content_model' => CONTENT_MODEL_WIKITEXT,
				] ),
			],
			415
		];
	}

	/**
	 * @dataProvider provideHeaderValidation
	 */
	public function testHeaderValidation( array $requestData, $expectedStatus ) {
		$request = new RequestData( $requestData );

		$handler = $this->newHandler( [] );

		$exception = $this->executeHandlerAndGetHttpException( $handler, $request );

		$this->assertSame( $expectedStatus, $exception->getCode(), 'HTTP status' );
	}

	/**
	 * FIXME: Can't access MW services in a dataProvider.
	 */
	public static function provideErrorMapping() {
		yield "missingtitle" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-missingtitle' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-missingtitle' ), 404 ),
		];
		yield "protectedpage" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-protectedpage' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-protectedpage' ), 403 ),
		];
		yield "articleexists" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-articleexists' ) ),
			new LocalizedHttpException(
				new MessageValue( 'rest-update-cannot-create-page', [ 'Foo' ] ),
				409
			),
		];
		yield "editconflict" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-editconflict' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-editconflict' ), 409 ),
		];
		yield "ratelimited" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-ratelimited' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-ratelimited' ), 429 ),
		];
		yield "badtoken" => [
			new ApiUsageException(
				null,
				Status::newFatal( 'apierror-badtoken', Message::plaintextParam( 'BAD' ) )
			),
			new LocalizedHttpException(
				new MessageValue(
					'apierror-badtoken',
					[ new ScalarParam( ParamType::PLAINTEXT, 'BAD' ) ]
				), 403
			),
		];

		// Unmapped errors should be passed through with a status 400.
		yield "no-direct-editing" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-no-direct-editing' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-no-direct-editing' ), 400 ),
		];
		yield "badformat" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-badformat' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-badformat' ), 400 ),
		];
		yield "emptypage" => [
			new ApiUsageException( null, Status::newFatal( 'apierror-emptypage' ) ),
			new LocalizedHttpException( new MessageValue( 'apierror-emptypage' ), 400 ),
		];
	}

	public function testErrorMapping() {
		foreach ( $this->provideErrorMapping() as $expected ) {
			$apiUsageException = $expected[0];
			$expectedHttpException = $expected[1];
			$requestData = [ // Request data received by UpdateHandler
				'method' => 'POST',
				'pathParams' => [ 'title' => 'Foo' ],
				'headers' => [
					'Content-Type' => 'application/json',
				],
				'bodyContents' => json_encode( [
					'source' => 'Lorem Ipsum',
					'comment' => 'Testing',
					'content_model' => CONTENT_MODEL_WIKITEXT,
				] ),
			];
			$request = new RequestData( $requestData );

			$handler = $this->newHandler( [], $apiUsageException );

			$exception = $this->executeHandlerAndGetHttpException( $handler, $request );

			$this->assertSame( $expectedHttpException->getMessage(), $exception->getMessage() );
			$this->assertSame( $expectedHttpException->getCode(), $exception->getCode(), 'HTTP status' );

			$errorData = $exception->getErrorData();
			if ( $expectedHttpException->getErrorData() ) {
				foreach ( $expectedHttpException->getErrorData() as $key => $value ) {
					$this->assertSame( $value, $errorData[$key], 'Error data key $key' );
				}
			}

			if ( $expectedHttpException instanceof LocalizedHttpException ) {
				/** @var LocalizedHttpException $exception */
				$this->assertEquals(
					$expectedHttpException->getMessageValue(),
					$exception->getMessageValue()
				);
			}
		}
	}

	public function testConflictOutput() {
		$requestData = [ // Request data received by UpdateHandler
			'method' => 'POST',
			'pathParams' => [ 'title' => 'Foo' ],
			'headers' => [
				'Content-Type' => 'application/json',
			],
			'bodyContents' => json_encode( [
				'latest' => [
					'id' => 17,
				],
				'source' => 'Lorem Ipsum',
				'comment' => 'Testing'
			] ),
		];
		$request = new RequestData( $requestData );

		$apiUsageException = new ApiUsageException( null, Status::newFatal( 'apierror-editconflict' ) );
		$handler = $this->newHandler( [], $apiUsageException );
		$handler->setJsonDiffFunction( [ $this, 'fakeJsonDiff' ] );

		$exception = $this->executeHandlerAndGetHttpException( $handler, $request );

		$this->assertSame( 409, $exception->getCode(), 'HTTP status' );

		$expectedData = [
			'local' => [
				'from' => 'Content of revision 17',
				'to' => 'Lorem Ipsum',
			],
			'remote' => [
				'from' => 'Content of revision 17',
				'to' => 'Current content of 0:Foo',
			],
			'base' => 17,
			'current' => 1234
		];

		$errorData = $exception->getErrorData();
		foreach ( $expectedData as $key => $value ) {
			$this->assertSame( $value, $errorData[$key], "Error data key $key" );
		}
	}

	public function fakeJsonDiff( $fromText, $toText ) {
		return FormatJson::encode( [
			'from' => $fromText,
			'to' => $toText
		] );
	}

}
PK       ! $<z  z  $  Rest/Handler/RedirectHandlerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Rest\Handler;

use MediaWiki\Rest\Handler\RedirectHandler;
use MediaWiki\Rest\RequestData;
use MediaWiki\Rest\RouteDefinitionException;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\Rest\Handler\RedirectHandler
 */
class RedirectHandlerTest extends MediaWikiIntegrationTestCase {
	use HandlerTestTrait;

	public function redirectConfigProvider() {
		return [
			[
				[ 'path' => '/v1/other/path/{param}', 'code' => 301 ],
				301,
				'/rest/v1/other/path/value'
			],
			[
				[ 'path' => '/v1/other/path/{param}' ],
				308,
				'/rest/v1/other/path/value'
			],

			// Add more test cases
		];
	}

	public function provideFailure() {
		return [
			[
				[ 'path' => '', 'code' => 308 ],
				RouteDefinitionException::class
			],
			[
				[ 'code' => 308 ],
				RouteDefinitionException::class
			],
			// Add more test cases
		];
	}

	/**
	 * @dataProvider redirectConfigProvider
	 */
	public function testExecute( $redirectConfig, $expectedCode, $expectedLocation ) {
		$request = new RequestData( [ 'pathParams' => [ 'param' => 'value' ] ] );
		$handler = new RedirectHandler();

		// Execute the handler with configuration
		$response = $this->executeHandler( $handler, $request, [ 'redirect' => $redirectConfig ] );

		// Assertions for the response
		$this->assertEquals( $expectedCode, $response->getStatusCode() ); // Check status code
		$this->assertEquals( $expectedLocation, $response->getHeaderLine( 'Location' ) ); // Check Location header
	}

	/**
	 * @dataProvider provideFailure
	 */
	public function testFailure( $redirectConfig, $expectedException ) {
		$request = new RequestData( [ 'pathParams' => [ 'param' => 'value' ] ] );
		$handler = new RedirectHandler();

		$this->expectException( $expectedException );

		// Execute the handler with configuration
		$this->executeHandler( $handler, $request, [ 'redirect' => $redirectConfig ] );
	}
}
PK       ! Pހ8  8    Storage/UndoIntegrationTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Storage;

use Article;
use McrUndoAction;
use MediaWiki\Content\WikitextContent;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\EditPage\EditPage;
use MediaWiki\Output\OutputPage;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Revision\RevisionStoreRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\EditResult;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use WikiPage;

/**
 * Integration tests for undos.
 * TODO: This should also test edits with multiple slots.
 *
 * @covers \McrUndoAction
 * @covers \WikiPage
 * @covers \MediaWiki\EditPage\EditPage
 *
 * @group Database
 * @group medium
 */
class UndoIntegrationTest extends MediaWikiIntegrationTestCase {

	private const PAGE_NAME = 'McrUndoTestPage';

	/**
	 * Creates a new McrUndoAction object for testing.
	 *
	 * @param IContextSource $context
	 * @param Article $article
	 * @param array $params POST/GET parameters passed to the action on submit
	 *
	 * @return McrUndoAction
	 */
	private function makeNewMcrUndoAction(
		IContextSource $context,
		Article $article,
		array $params
	): McrUndoAction {
		$request = new FauxRequest( $params );
		$request->setVal( 'wpSave', '' );
		$context->setRequest( $request );

		$outputPage = $this->createMock( OutputPage::class );
		$context->setOutput( $outputPage );
		$context->setUser( $this->getTestSysop()->getUser() );

		$services = $this->getServiceContainer();
		$revisionRenderer = $services->getRevisionRenderer();
		$revisionLookup = $services->getRevisionLookup();
		$readOnlyMode = $services->getReadOnlyMode();
		$commentFormatter = $services->getCommentFormatter();
		$config = $services->getMainConfig();
		return new class(
			$article,
			$context,
			$readOnlyMode,
			$revisionLookup,
			$revisionRenderer,
			$commentFormatter,
			$config
		) extends McrUndoAction {
			public function show() {
				// Instead of trying to actually display anything, just initialize the class.
				$this->checkCanExecute( $this->getUser() );
			}
		};
	}

	/**
	 * Convenience function for setting up a test page and filling it with edits.
	 * @param string[] $revisions
	 *
	 * @return array
	 */
	private function setUpPageForTesting( array $revisions ): array {
		$this->getExistingTestPage( self::PAGE_NAME );
		$revisionIds = [];
		foreach ( $revisions as $revisionContent ) {
			$revisionIds[] = $this->editPage( self::PAGE_NAME, $revisionContent )
				->value['revision-record']->getId();
		}
		$revisionIds['false'] = false;
		return $revisionIds;
	}

	/**
	 * @param string $newContent
	 * @param array $revisionIds
	 * @param bool $isExactRevert
	 * @param int|string $oldestRevertedRevIndex
	 * @param int|string $newestRevertedRevIndex
	 * @param int|string $originalRevIndex
	 */
	private function setPageSaveCompleteHook(
		string $newContent,
		array $revisionIds,
		bool $isExactRevert,
		$oldestRevertedRevIndex,
		$newestRevertedRevIndex,
		$originalRevIndex
	) {
		// set up a temporary hook with asserts
		$this->setTemporaryHook(
			'PageSaveComplete',
			function (
				WikiPage $wikiPage,
				User $user,
				string $summary,
				int $flags,
				RevisionStoreRecord $revisionRecord,
				EditResult $editResult
			) use (
				$newContent,
				$revisionIds,
				$isExactRevert,
				$oldestRevertedRevIndex,
				$newestRevertedRevIndex,
				$originalRevIndex
			) {
				$this->assertTrue(
					$editResult->isRevert(),
					'EditResult::isRevert()'
				);
				$this->assertSame(
					EditResult::REVERT_UNDO,
					$editResult->getRevertMethod(),
					'EditResult::getRevertMethod()'
				);
				$this->assertArrayEquals( [ 'mw-undo' ],
					$editResult->getRevertTags(),
					false,
					false,
					'EditResult::getRevertTags()'
				);
				$this->assertSame(
					$isExactRevert,
					$editResult->isExactRevert(),
					'EditResult::isExactRevert()'
				);
				$this->assertSame(
					$revisionIds[$oldestRevertedRevIndex],
					$editResult->getOldestRevertedRevisionId(),
					'EditResult::getOldestRevertedRevisionId()'
				);
				$this->assertSame(
					$revisionIds[$newestRevertedRevIndex],
					$editResult->getNewestRevertedRevisionId(),
					'EditResult::getNewestRevertedRevisionId()'
				);
				$this->assertSame(
					$revisionIds[$originalRevIndex],
					$editResult->getOriginalRevisionId(),
					'EditResult::getOriginalRevisionId()'
				);
				$mainContent = $revisionRecord->getContent( SlotRecord::MAIN );
				/** @var WikitextContent $mainContent */
				$this->assertSame(
					$newContent,
					$mainContent->getText(),
					'RevisionRecord::getContent()'
				);
			}
		);
	}

	/**
	 * Provides test cases for well-formed undos.
	 *
	 * @return array[]
	 */
	public static function provideUndos() {
		return [
			'undoing a single revision' => [
				[ '1', '2' ],
				'1',
				0,
				1,
				true,
				1,
				1,
				0
			],
			'undoing multiple revisions' => [
				[ '1', '2', '3', '4' ],
				'1',
				0,
				3,
				true,
				1,
				3,
				0
			],
			'undoing an intermittent revision' => [
				[
					"line 1\n\nline 2\n\nline3",
					"line 1\n\nvandalism\n\nline3",
					"line 1\n\nvandalism\n\nline3 more content"
				],
				"line 1\n\nline 2\n\nline3 more content",
				0,
				1,
				false,
				1,
				1,
				'false'
			],
			'undoing multiple intermittent revisions' => [
				[
					"line 1\n\nline 2\n\nline3",
					"line 1\n\nvandalism\n\nline3",
					"line 1\n\nmore vandalism\n\nline3",
					"line 1\n\nmore vandalism\n\nline3 content"
				],
				"line 1\n\nline 2\n\nline3 content",
				0,
				2,
				false,
				1,
				2,
				'false'
			]
		];
	}

	/**
	 * Provides test cases of undos with incomplete parameters.
	 * This should be handled well by EditPage and WikiPage.
	 * McrUndoAction just refuses to do anything.
	 *
	 * @return array[]
	 */
	public static function provideIncompleteUndos() {
		return [
			'undoing a revision without undoafter param' => [
				[ '1', '2' ],
				'1',
				'false',
				1,
				true,
				1,
				1,
				0
			],
			'undoing an intermittent revision without undoafter param' => [
				[
					"line 1\n\nline 2\n\nline3",
					"line 1\n\nvandalism\n\nline3",
					"line 1\n\nvandalism\n\nline3 more content"
				],
				"line 1\n\nline 2\n\nline3 more content",
				'false',
				1,
				false,
				1,
				1,
				'false'
			]
		];
	}

	/**
	 * Test how McrUndoAction cooperates with the PageUpdater by looking at values provided
	 * by the PageSaveComplete hook.
	 *
	 * @dataProvider provideUndos
	 *
	 * @param string[] $revisions
	 * @param string $newContent
	 * @param int|string $undoafterIndex
	 * @param int|string $undoIndex
	 * @param bool $isExactRevert
	 * @param int|string $oldestRevertedRevIndex
	 * @param int|string $newestRevertedRevIndex
	 * @param int|string $originalRevIndex
	 */
	public function testMcrUndoAction(
		array $revisions,
		string $newContent,
		$undoafterIndex,
		$undoIndex,
		bool $isExactRevert,
		$oldestRevertedRevIndex,
		$newestRevertedRevIndex,
		$originalRevIndex
	) {
		$this->markTestSkippedIfNoDiff3();

		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser( $this->getTestUser()->getUser() );
		$revisionIds = $this->setUpPageForTesting( $revisions );
		$article = Article::newFromTitle( Title::newFromText( self::PAGE_NAME ), $context );

		$mcrUndoAction = $this->makeNewMcrUndoAction(
			$context,
			$article,
			[
				'undoafter' => $revisionIds[$undoafterIndex],
				'undo' => $revisionIds[$undoIndex]
			]
		);
		// This should initialize the action properly.
		$mcrUndoAction->show();

		// Set the hook and submit the request
		$this->setPageSaveCompleteHook(
			$newContent,
			$revisionIds,
			$isExactRevert,
			$oldestRevertedRevIndex,
			$newestRevertedRevIndex,
			$originalRevIndex
		);
		$mcrUndoAction->onSubmit( [] );
	}

	/**
	 * Test how WikiPage cooperates with the PageUpdater by looking at values
	 * provided by the PageSaveComplete hook.
	 *
	 * @dataProvider provideUndos
	 * @dataProvider provideIncompleteUndos
	 *
	 * @param string[] $revisions
	 * @param string $newContent
	 * @param int|string $undoafterIndex
	 * @param int|string $undoIndex
	 * @param bool $isExactRevert
	 * @param int|string $oldestRevertedRevIndex
	 * @param int|string $newestRevertedRevIndex
	 * @param int|string $originalRevIndex
	 */
	public function testWikiPage(
		array $revisions,
		string $newContent,
		$undoafterIndex,
		$undoIndex,
		bool $isExactRevert,
		$oldestRevertedRevIndex,
		$newestRevertedRevIndex,
		$originalRevIndex
	) {
		$revisionIds = $this->setUpPageForTesting( $revisions );

		// Set the hook with asserts
		$this->setPageSaveCompleteHook(
			$newContent,
			$revisionIds,
			$isExactRevert,
			$oldestRevertedRevIndex,
			$newestRevertedRevIndex,
			$originalRevIndex
		);

		$wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( self::PAGE_NAME ) );
		$wikiPage->doUserEditContent(
			new WikitextContent( $newContent ),
			$this->getTestSysop()->getUser(),
			'',
			0,
			$revisionIds[$undoafterIndex],
			[],
			$revisionIds[$undoIndex]
		);
	}

	/**
	 * Test how EditPage and WikiPage work together and with the PageUpdater by looking
	 * at values provided by the PageSaveComplete hook.
	 *
	 * @dataProvider provideUndos
	 * @dataProvider provideIncompleteUndos
	 *
	 * @param string[] $revisions
	 * @param string $newContent
	 * @param int|string $undoafterIndex
	 * @param int|string $undoIndex
	 * @param bool $isExactRevert
	 * @param int|string $oldestRevertedRevIndex
	 * @param int|string $newestRevertedRevIndex
	 * @param int|string $originalRevIndex
	 */
	public function testEditPage(
		array $revisions,
		string $newContent,
		$undoafterIndex,
		$undoIndex,
		bool $isExactRevert,
		$oldestRevertedRevIndex,
		$newestRevertedRevIndex,
		$originalRevIndex
	) {
		$this->markTestSkippedIfNoDiff3();

		$revisionIds = $this->setUpPageForTesting( $revisions );
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser( $this->getTestUser()->getUser() );
		$article = Article::newFromTitle( Title::newFromText( self::PAGE_NAME ), $context );

		// Set the hook with asserts
		$this->setPageSaveCompleteHook(
			$newContent,
			$revisionIds,
			$isExactRevert,
			$oldestRevertedRevIndex,
			$newestRevertedRevIndex,
			$originalRevIndex
		);

		$request = new FauxRequest(
			[
				// We kind of let EditPage cheat here by providing the content of the page
				// after the undo, but automatic conflict resolution is not the point of
				// this test anyway.
				'wpTextbox1' => $newContent,
				'wpEditToken' => $this->getTestSysop()->getUser()->getEditToken(),
				// These two parameters are the important ones here
				'wpUndidRevision' => $revisionIds[$undoIndex],
				'wpUndoAfter' => $revisionIds[$undoafterIndex],
				'wpStarttime' => wfTimestampNow(),
				'wpUnicodeCheck' => EditPage::UNICODE_CHECK,
				'model' => CONTENT_MODEL_WIKITEXT,
				'format' => CONTENT_FORMAT_WIKITEXT,
			],
			true
		);

		$editPage = new EditPage( $article );
		$editPage->importFormData( $request );
		$editPage->attemptSave( $result );
	}

	/**
	 * Test the case where the user undoes some edits, but applies additional changes before
	 * saving. EditPage should detect that and not mark such an edit as a revert.
	 */
	public function testDirtyUndo() {
		$revisionIds = $this->setUpPageForTesting( [
			"line 1\n\nline 2\n\nline3",
			"line 1\n\nvandalism\n\nline3",
			"line 1\n\nvandalism\n\nline3 more content"
		] );
		$context = new DerivativeContext( RequestContext::getMain() );
		$context->setUser( $this->getTestUser()->getUser() );
		$article = Article::newFromTitle( Title::newFromText( self::PAGE_NAME ), $context );

		// set up a temporary hook with asserts
		$this->setTemporaryHook(
			'PageSaveComplete',
			function (
				WikiPage $wikiPage,
				User $user,
				string $summary,
				int $flags,
				RevisionStoreRecord $revisionRecord,
				EditResult $editResult
			) {
				// Just ensuring that the edit was not marked as a revert should be enough
				$this->assertFalse(
					$editResult->isRevert(),
					'EditResult::isRevert()'
				);
			}
		);

		$request = new FauxRequest(
			[
				// We emulate the user applying additional changes on top of the undo.
				'wpTextbox1' => "line 1\n\nline 2\n\nline3 more content\n\neven more",
				'wpEditToken' => $this->getTestSysop()->getUser()->getEditToken(),
				'wpUndidRevision' => $revisionIds[1],
				'wpUndoAfter' => $revisionIds[0],
				'wpStarttime' => wfTimestampNow(),
				'wpUnicodeCheck' => EditPage::UNICODE_CHECK,
				'model' => CONTENT_MODEL_WIKITEXT,
				'format' => CONTENT_FORMAT_WIKITEXT,
			],
			true
		);

		$editPage = new EditPage( $article );
		$editPage->importFormData( $request );
		$editPage->attemptSave( $result );
	}

	/**
	 * Test whether EditPage correctly handles situations where an undo is impossible.
	 * Ensures T262463 is fixed.
	 */
	public function testImpossibleUndo() {
		$revisionIds = $this->setUpPageForTesting( [
			"line 1\n\nline 2\n\nline3",
			"line 1\n\nvandalism\n\nline3",
			"line 1\n\nvandalism good content\n\nline3 more content"
		] );

		$context = RequestContext::getMain();
		$article = Article::newFromTitle( Title::newFromText( self::PAGE_NAME ), $context );

		// set up a temporary hook with asserts
		$this->setTemporaryHook(
			'PageSaveComplete',
			function (
				WikiPage $wikiPage,
				User $user,
				string $summary,
				int $flags,
				RevisionStoreRecord $revisionRecord,
				EditResult $editResult
			) {
				$this->assertFalse(
					$editResult->isRevert(),
					'EditResult::isRevert()'
				);
				$this->assertTrue(
					$editResult->isNullEdit(),
					'EditResult::isNullEdit()'
				);
			}
		);

		$request = new FauxRequest(
			[
				// We leave the "top" content in the textbox, as the undo should have failed
				'wpTextbox1' => "line 1\n\nvandalism good content\n\nline3 more content",
				'wpEditToken' => $this->getTestSysop()->getUser()->getEditToken(),
				'wpUndidRevision' => $revisionIds[1],
				'wpUndoAfter' => $revisionIds[0],
				'wpStarttime' => wfTimestampNow(),
				'wpUnicodeCheck' => EditPage::UNICODE_CHECK,
				'model' => CONTENT_MODEL_WIKITEXT,
				'format' => CONTENT_FORMAT_WIKITEXT,
			],
			true
		);

		$editPage = new EditPage( $article );
		$editPage->importFormData( $request );
		$editPage->attemptSave( $result );
	}
}
PK       ! 
y`O$  O$  #  Storage/EditResultBuilderDbTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Storage;

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Content\WikitextContent;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Storage\EditResult;
use MediaWiki\Storage\EditResultBuilder;
use MediaWikiIntegrationTestCase;
use MockTitleTrait;
use Wikimedia\Rdbms\IDatabase;
use WikiPage;

/**
 * @covers \MediaWiki\Storage\EditResultBuilder
 * @group Database
 * @see EditResultBuilderTest for non-DB tests
 */
class EditResultBuilderDbTest extends MediaWikiIntegrationTestCase {
	use MockTitleTrait;

	private const PAGE_NAME = 'ManualRevertTestPage';
	private const CONTENT_A = 'Aaa.';
	private const CONTENT_B = 'Bbb.';
	private const CONTENT_C = 'Ccc.';

	/** @var WikiPage */
	private $wikiPage;

	/** @var RevisionRecord[] */
	private $revisions;

	/**
	 * We track the top revision of the test page on our own to avoid having to update the
	 * page table in the DB.
	 *
	 * @var RevisionRecord
	 */
	private $latestTestRevision = null;

	/** @var RevisionStore */
	private $revisionStore;

	/** @var IDatabase */
	private $dbw;

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

		$services = $this->getServiceContainer();
		$this->revisionStore = $services->getRevisionStore();
		$this->dbw = $this->getDb();

		$this->wikiPage = $this->getExistingTestPage( self::PAGE_NAME );
		$this->revisions = [];
		$this->revisions['C1'] = $this->insertRevisionToTestPage(
			self::CONTENT_C,
			'20050101210030'
		);
		$this->revisions['A1'] = $this->insertRevisionToTestPage(
			self::CONTENT_A,
			'20050101210037'
		);
		$this->revisions['B1'] = $this->insertRevisionToTestPage(
			self::CONTENT_B,
			'20050101210038'
		);
		$this->revisions['C2'] = $this->insertRevisionToTestPage(
			self::CONTENT_C,
			'20050101210039'
		);
		$this->revisions['A2'] = $this->insertRevisionToTestPage(
			self::CONTENT_A,
			'20050101210040'
		);
		$this->revisions['A3'] = $this->insertRevisionToTestPage(
			self::CONTENT_A,
			'20050101210040' // same timestamp to try to confuse the query
		);
		$this->revisions['A4'] = $this->insertRevisionToTestPage(
			self::CONTENT_A,
			'20050101210040'
		);
		$this->revisions['B2'] = $this->insertRevisionToTestPage(
			self::CONTENT_B,
			'20050101210041'
		);
	}

	private function getLatestTestRevision(): RevisionRecord {
		return $this->latestTestRevision ??
			$this->revisionStore->getRevisionByPageId( $this->wikiPage->getId() );
	}

	/**
	 * Inserts a new revision of the test page to the DB with specified content.
	 *
	 * We do not use MediaWikiIntegrationTestCase::editPage() on purpose, it can lead to all
	 * kinds of issues, the most significant being that it ultimately calls the code we wish
	 * to test here.
	 *
	 * @param string $content
	 *
	 * @param string $timestamp
	 *
	 * @return RevisionRecord
	 */
	private function insertRevisionToTestPage(
		string $content,
		string $timestamp
	): RevisionRecord {
		$revisionRecord = $this->getNewRevisionForTestPage( $content );
		$revisionRecord->setUser( $this->getTestUser()->getUser() );
		$revisionRecord->setTimestamp( $timestamp );
		$revisionRecord->setComment( CommentStoreComment::newUnsavedComment( '' ) );

		$this->latestTestRevision = $this->revisionStore->insertRevisionOn(
			$revisionRecord,
			$this->dbw
		);
		return $this->latestTestRevision;
	}

	/**
	 * Returns a next in sequence revision of the test page with specified content.
	 *
	 * @param string $content
	 *
	 * @return MutableRevisionRecord
	 */
	private function getNewRevisionForTestPage(
		string $content
	): MutableRevisionRecord {
		$parentRevision = $this->getLatestTestRevision();

		$revision = new MutableRevisionRecord( $this->wikiPage->getTitle() );
		$revision->setParentId( $parentRevision->getId() );
		$revision->setPageId( $this->wikiPage->getId() );
		$revision->setContent(
			SlotRecord::MAIN,
			new WikitextContent( $content )
		);

		return $revision;
	}

	public static function provideManualReverts(): array {
		return [
			'reverting a single edit' => [
				self::CONTENT_A,
				'A4',
				'B2',
				'B2'
			],
			'reverting multiple edits' => [
				self::CONTENT_C,
				'C2',
				'A2',
				'B2'
			]
		];
	}

	/**
	 * @dataProvider provideManualReverts
	 * @covers \MediaWiki\Storage\EditResultBuilder::detectManualRevert
	 *
	 * @param string $content
	 * @param string $expectedOriginalRevKey
	 * @param string $expectedOldestRevertedRevKey
	 * @param string $expectedNewestRevertedRevKey
	 */
	public function testManualRevert(
		string $content,
		string $expectedOriginalRevKey,
		string $expectedOldestRevertedRevKey,
		string $expectedNewestRevertedRevKey
	) {
		$erb = $this->getEditResultBuilder();
		$newRevision = $this->getNewRevisionForTestPage( $content );
		// we will fool the EditResultBuilder into thinking this is a saved revision
		$newRevision->setId( 12345 );
		$erb->setRevisionRecord( $newRevision );

		$er = $erb->buildEditResult();

		// first some basic tests we can do without revision magic
		$this->assertTrue(
			$er->isRevert(),
			'EditResult::isRevert()'
		);
		$this->assertTrue(
			$er->isExactRevert(),
			'EditResult::isExactRevert()'
		);
		$this->assertSame(
			EditResult::REVERT_MANUAL,
			$er->getRevertMethod(),
			'EditResult::getRevertMethod()'
		);
		$this->assertNotFalse(
			$er->getOriginalRevisionId(),
			'EditResult::getOriginalRevisionId()'
		);
		$this->assertNotNull(
			$er->getOldestRevertedRevisionId(),
			'EditResult::getOldestRevertedRevisionId()'
		);
		$this->assertNotNull(
			$er->getNewestRevertedRevisionId(),
			'EditResult::getNewestRevertedRevisionId()'
		);
		$this->assertArrayEquals(
			[ 'mw-manual-revert' ],
			$er->getRevertTags(),
			'EditResult::getRevertTags()'
		);

		// test the original revision referenced by this EditResult
		$originalRev = $this->revisionStore->getRevisionById(
			$er->getOriginalRevisionId()
		);
		$this->assertSame(
			$newRevision->getSha1(),
			$originalRev->getSha1(),
			"original revision's SHA1 matches new revision's SHA1"
		);
		$expectedOriginalRev = $this->revisions[$expectedOriginalRevKey];
		$this->assertSame(
			$expectedOriginalRev->getId(),
			$originalRev->getId(),
			"original revision's ID"
		);

		// test the oldest reverted revision
		$oldestRevertedRev = $this->revisionStore->getRevisionById(
			$er->getOldestRevertedRevisionId()
		);
		$expectedOldestRevertedRev = $this->revisions[$expectedOldestRevertedRevKey];
		$this->assertSame(
			$expectedOldestRevertedRev->getId(),
			$oldestRevertedRev->getId(),
			"oldest reverted revision's ID"
		);

		// test the newest reverted revision
		$newestRevertedRev = $this->revisionStore->getRevisionById(
			$er->getNewestRevertedRevisionId()
		);
		$expectedNewestRevertedRev = $this->revisions[$expectedNewestRevertedRevKey];
		$this->assertSame(
			$expectedNewestRevertedRev->getId(),
			$newestRevertedRev->getId(),
			"newest reverted revision's ID"
		);
	}

	public static function provideNotManualReverts(): array {
		return [
			'edit not changing anything' => [
				self::CONTENT_B,
				15
			],
			'revert outside search radius' => [
				self::CONTENT_C,
				3
			],
			'normal edit' => [
				'Some text.',
				15
			]
		];
	}

	/**
	 * @dataProvider provideNotManualReverts
	 * @covers \MediaWiki\Storage\EditResultBuilder::detectManualRevert
	 *
	 * @param string $content
	 * @param int $searchRadius
	 */
	public function testNotManualRevert(
		string $content,
		int $searchRadius
	) {
		$erb = $this->getEditResultBuilder( $searchRadius );
		$parentRevision = $this->getLatestTestRevision();
		$newRevision = $this->getNewRevisionForTestPage( $content );
		// we will fool the EditResultBuilder into thinking this is a saved revision
		$newRevision->setId( 12345 );
		$erb->setRevisionRecord( $newRevision );
		// emulate WikiPage's behaviour for null edits
		if ( $newRevision->getSha1() === $parentRevision->getSha1() ) {
			$erb->setOriginalRevision( $parentRevision );
		}

		$er = $erb->buildEditResult();

		$this->assertFalse( $er->isRevert(), 'EditResult::isRevert()' );
		$this->assertFalse( $er->isExactRevert(), 'EditResult::isExactRevert()' );
		$this->assertNull( $er->getRevertMethod(), 'EditResult::getRevertMethod()' );
		$this->assertNull(
			$er->getOldestRevertedRevisionId(),
			'EditResult::getOldestRevertedRevisionId()'
		);
		$this->assertNull(
			$er->getNewestRevertedRevisionId(),
			'EditResult::getNewestRevertedRevisionId()'
		);
		$this->assertArrayEquals( [], $er->getRevertTags(), 'EditResult::getRevertTags()' );
	}

	/**
	 * Convenience function for creating a new EditResultBuilder object.
	 *
	 * @param int $manualRevertSearchRadius
	 *
	 * @return EditResultBuilder
	 */
	private function getEditResultBuilder( int $manualRevertSearchRadius = 15 ) {
		$options = new ServiceOptions(
			EditResultBuilder::CONSTRUCTOR_OPTIONS,
			[ MainConfigNames::ManualRevertSearchRadius => $manualRevertSearchRadius ]
		);

		return new EditResultBuilder(
			$this->getServiceContainer()->getRevisionStore(),
			$this->getServiceContainer()->getChangeTagsStore()->listSoftwareDefinedTags(),
			$options
		);
	}
}
PK       ! 8)  8)  ,  Storage/RevertedTagUpdateIntegrationTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Storage;

use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Json\FormatJson;
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 {

	/**
	 * 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->overrideConfigValues( [ 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 = $this->getDb();
		foreach ( $revisionIds as $revisionId ) {
			$this->assertNotContains(
				'mw-reverted',
				$this->getServiceContainer()->getChangeTagsStore()->getTags( $dbw, null, $revisionId ),
				'ChangeTagsStore->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 = $this->getDb();
		// for each reverted revision
		foreach ( $revisionIds as $revisionId ) {
			$this->assertContains(
				'mw-reverted',
				$this->getServiceContainer()->getChangeTagsStore()->getTags( $dbw, null, $revisionId ),
				'ChangeTagsStore->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'
			);
		}
	}
}
PK       ! x*       logging/LogPageTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Log;

use DatabaseLogEntry;
use LogPage;
use MediaWiki\MainConfigNames;
use MediaWiki\User\UserIdentityValue;
use MockTitleTrait;

/**
 * @group Database
 * @coversDefaultClass \LogPage
 */
class LogPageTest extends \MediaWikiIntegrationTestCase {
	use MockTitleTrait;

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

		$this->overrideConfigValues( [
			MainConfigNames::LogNames => [
				'test_test' => 'testing-log-message'
			],
			MainConfigNames::LogHeaders => [
				'test_test' => 'testing-log-header'
			],
			MainConfigNames::LogRestrictions => [
				'test_test' => 'testing-log-restriction'
			]
		] );
	}

	/**
	 * @covers ::__construct
	 * @covers ::getName
	 * @covers ::getDescription
	 * @covers ::getRestriction
	 * @covers ::isRestricted
	 */
	public function testConstruct() {
		$logPage = new LogPage( 'test_test' );
		$this->assertSame( 'testing-log-message', $logPage->getName()->getKey() );
		$this->assertSame( 'testing-log-header', $logPage->getDescription()->getKey() );
		$this->assertSame( 'testing-log-restriction', $logPage->getRestriction() );
		$this->assertTrue( $logPage->isRestricted() );
	}

	/**
	 * @covers ::addEntry
	 * @covers ::getComment
	 * @covers ::getRcComment
	 * @covers ::getRcCommentIRC
	 */
	public function testAddEntrySetsProperties() {
		$logPage = new LogPage( 'test_test' );
		$user = new UserIdentityValue( 1, 'Bar' );
		$logPage->addEntry(
			'test_action',
			$this->makeMockTitle( __METHOD__ ),
			'testing_comment',
			[ 'param_one', 'param_two' ],
			$user
		);
		$this->assertSame( 'testing_comment', $logPage->getComment() );
		$this->assertStringContainsString( 'testing_comment', $logPage->getRcComment() );
		$this->assertStringContainsString( 'testing_comment', $logPage->getRcCommentIRC() );
	}

	/**
	 * @covers ::addEntry
	 */
	public function testAddEntrySave() {
		$logPage = new LogPage( 'test_test' );
		$user = new UserIdentityValue( 1, 'Foo' );
		$title = $this->makeMockTitle( __METHOD__ );
		$id = $logPage->addEntry(
			'test_action',
			$title,
			'testing_comment',
			[ 'param_one', 'param_two' ],
			$user
		);

		$savedLogEntry = DatabaseLogEntry::newFromId( $id, $this->getDb() );
		$this->assertNotNull( $savedLogEntry );
		$this->assertSame( 'test_test', $savedLogEntry->getType() );
		$this->assertSame( 'test_action', $savedLogEntry->getSubtype() );
		$this->assertSame( 'testing_comment', $savedLogEntry->getComment() );
		$this->assertArrayEquals( [ 'param_one', 'param_two' ], $savedLogEntry->getParameters() );
		$this->assertTrue( $title->equals( $savedLogEntry->getTarget() ) );
		$this->assertTrue( $user->equals( $savedLogEntry->getPerformerIdentity() ) );
	}

	/**
	 * @covers ::actionText
	 */
	public function testUnknownAction() {
		$title = $this->makeMockTitle( 'Test Title' );
		$text = LogPage::actionText( 'unknown', 'action', $title, null, [ 'discarded' ] );
		$this->assertSame( 'performed unknown action &quot;unknown/action&quot; on [[Test Title]]', $text );
	}
}
PK       ! Vxi  i    user/ActorStoreTestBase.phpnu Iw        <?php

namespace MediaWiki\Tests\User;

use MediaWiki\User\ActorStore;
use MediaWiki\User\UserIdentity;
use MediaWikiIntegrationTestCase;
use Psr\Log\NullLogger;
use Wikimedia\Rdbms\ILoadBalancer;

/**
 * Base class with utilities for testing database access to actor table.
 *
 */
abstract class ActorStoreTestBase extends MediaWikiIntegrationTestCase {
	protected const IP = '2600:1004:B14A:5DDD:3EBE:BBA4:BFBA:F37E';
	/** The user IDs set in addDBData() */
	protected const TEST_USERS = [
		'registered' => [ 'actor_id' => '42', 'actor_user' => '24', 'actor_name' => 'TestUser' ],
		'anon' => [ 'actor_id' => '43', 'actor_user' => null, 'actor_name' => self::IP ],
		'another registered' => [ 'actor_id' => '44', 'actor_user' => '25', 'actor_name' => 'TestUser1' ],
		'external' => [ 'actor_id' => '45', 'actor_user' => null, 'actor_name' => 'acme>TestUser' ],
		'user name 0' => [ 'actor_id' => '46', 'actor_user' => '26', 'actor_name' => '0' ],
	];

	public function addDBData() {
		foreach ( self::TEST_USERS as $description => $row ) {
			$this->getDb()->newInsertQueryBuilder()
				->insertInto( 'actor' )
				->ignore()
				->row( $row )
				->caller( __METHOD__ )
				->execute();
			$this->assertSame( 1, $this->getDb()->affectedRows(), "Must create {$description} actor" );
		}
	}

	/**
	 * @param string|false $wikiId
	 * @return ActorStore
	 */
	protected function getStore( $wikiId = UserIdentity::LOCAL ): ActorStore {
		return $this->getServiceContainer()->getActorStoreFactory()->getActorStore( $wikiId );
	}

	/**
	 * @param string|false $wikiId
	 * @return ActorStore
	 */
	protected function getStoreForImport( $wikiId = UserIdentity::LOCAL ): ActorStore {
		return $this->getServiceContainer()->getActorStoreFactory()->getActorStoreForImport( $wikiId );
	}

	/**
	 * Execute the $callback passing it an ActorStore for $wikiId,
	 * making sure no queries are made to local DB.
	 * @param string|false $wikiId
	 * @param callable $callback ( ActorStore $store, IDatababase $db )
	 */
	protected function executeWithForeignStore( $wikiId, callable $callback ) {
		$dbLoadBalancer = $this->getServiceContainer()->getDBLoadBalancer();
		$dbLoadBalancer->setDomainAliases( [ $wikiId => $dbLoadBalancer->getLocalDomainID() ] );

		$foreignLB = $this->getServiceContainer()
			->getDBLoadBalancerFactory()
			->getMainLB( $wikiId );
		$foreignLB->setDomainAliases( [ $wikiId => $dbLoadBalancer->getLocalDomainID() ] );
		$foreignDB = $foreignLB->getConnection( DB_PRIMARY );

		$store = new ActorStore(
			$dbLoadBalancer,
			$this->getServiceContainer()->getUserNameUtils(),
			$this->getServiceContainer()->getTempUserConfig(),
			new NullLogger(),
			$this->getServiceContainer()->getHideUserUtils(),
			$wikiId
		);

		// Redefine the DBLoadBalancer service to verify we don't attempt to resolve its IDs via wfGetDB()
		$localLoadBalancerMock = $this->createNoOpMock( ILoadBalancer::class );
		try {
			$this->setService( 'DBLoadBalancer', $localLoadBalancerMock );
			$callback( $store, $foreignDB );
		} finally {
			// Restore the original loadBalancer.
			$this->setService( 'DBLoadBalancer', $dbLoadBalancer );
		}
	}

	/**
	 * Check whether two actors are the same in the context of $wikiId
	 * @param UserIdentity $expected
	 * @param UserIdentity $actor
	 * @param string|false $wikiId
	 */
	protected function assertSameActors(
		UserIdentity $expected,
		UserIdentity $actor,
		$wikiId = UserIdentity::LOCAL
	) {
		$actor->assertWiki( $wikiId );
		$this->assertSame( $expected->getId( $wikiId ), $actor->getId( $wikiId ) );
		$this->assertSame( $expected->getName(), $actor->getName() );
		$this->assertSame( $expected->getWikiId(), $actor->getWikiId() );
	}
}
PK       !  j1    *  user/Options/UserOptionsLookupTestBase.phpnu Iw        <?php

use MediaWiki\Config\HashConfig;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Language\LanguageCode;
use MediaWiki\MainConfigNames;
use MediaWiki\User\Options\DefaultOptionsLookup;
use MediaWiki\User\Options\UserOptionsLookup;
use MediaWiki\User\Registration\IUserRegistrationProvider;
use MediaWiki\User\Registration\LocalUserRegistrationProvider;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;

/**
 * @covers \MediaWiki\User\Options\DefaultOptionsLookup
 * @covers \MediaWiki\User\Options\UserOptionsManager
 * @covers \MediaWiki\User\Options\UserOptionsLookup
 */
abstract class UserOptionsLookupTestBase extends MediaWikiIntegrationTestCase {

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

		$this->overrideConfigValues( [
			MainConfigNames::UserRegistrationProviders => [
				// Redefine the LocalUserRegistrationProvider with a mock provider
				LocalUserRegistrationProvider::TYPE => [
					'factory' => static function () {
						return new class implements IUserRegistrationProvider {
							/**
							 * @inheritDoc
							 */
							public function fetchRegistration( UserIdentity $user ) {
								switch ( $user->getId() ) {
									case 1:
										return null;
									case 2:
										return '20231220160000';
									case 3:
										return '20230101000000';
									default:
										return false;
								}
							}
						};
					}
				]
			],
			MainConfigNames::ConditionalUserOptions => [
				'conditional_option' => [
					[
						true,
						[ CUDCOND_AFTER, '20230624000000' ]
					]
				]
			]
		] );
	}

	protected function getAnon(
		string $name = '127.0.0.1'
	): UserIdentity {
		return new UserIdentityValue( 0, $name );
	}

	abstract protected function getLookup(
		string $langCode = 'qqq',
		array $defaultOptionsOverrides = []
	): UserOptionsLookup;

	protected function getDefaultManager(
		string $langCode = 'qqq',
		array $defaultOptionsOverrides = []
	): DefaultOptionsLookup {
		$lang = new LanguageCode( $langCode );
		return new DefaultOptionsLookup(
			new ServiceOptions(
				DefaultOptionsLookup::CONSTRUCTOR_OPTIONS,
				new HashConfig( [
					MainConfigNames::DefaultSkin => 'test',
					MainConfigNames::DefaultUserOptions => array_merge( [
						'conditional_option' => false,
						'default_string_option' => 'string_value',
						'default_int_option' => 1,
						'default_bool_option' => true
					], $defaultOptionsOverrides ),
					MainConfigNames::NamespacesToBeSearchedDefault => [
						NS_MAIN => true,
						NS_TALK => true,
						NS_MEDIAWIKI => false,
					]
				] )
			),
			$lang,
			$this->getServiceContainer()->getHookContainer(),
			$this->getServiceContainer()->getNamespaceInfo(),
			$this->getServiceContainer()->get( '_ConditionalDefaultsLookup' ),
			!$this->needsDB()
		);
	}

	/**
	 * @return array[]
	 */
	public static function provideConditionalDefaults() {
		// NOTE: Definition of user_registration timestamp is in ::setUp(); search for
		// a IUserRegistrationProvider implementation.
		return [
			'user_registration null' => [ false, 'conditional_option', 1 ],
			'user_registration recent' => [ true, 'conditional_option', 2 ],
			'user_registration old' => [ false, 'conditional_option', 3 ],
		];
	}

	/**
	 * @covers \MediaWiki\User\Options\DefaultOptionsLookup::getDefaultOption
	 * @covers \MediaWiki\User\Options\UserOptionsManager::getDefaultOption
	 * @dataProvider provideConditionalDefaults
	 */
	public function testGetConditionalDefaults( bool $expected, string $property, int $userId ) {
		$this->assertSame(
			$expected,
			$this->getLookup()->getDefaultOption(
				$property,
				new UserIdentityValue( $userId, 'Admin' )
			)
		);
	}

	/**
	 * @covers \MediaWiki\User\Options\DefaultOptionsLookup::getDefaultOptions
	 * @covers \MediaWiki\User\Options\UserOptionsManager::getDefaultOptions
	 */
	public function testGetDefaultOptions() {
		$options = $this->getLookup()->getDefaultOptions();
		$this->assertSame( 'string_value', $options['default_string_option'] );
		$this->assertSame( 1, $options['default_int_option'] );
		$this->assertSame( true, $options['default_bool_option'] );
	}

	/**
	 * @covers \MediaWiki\User\Options\DefaultOptionsLookup::getDefaultOption
	 * @covers \MediaWiki\User\Options\UserOptionsManager::getDefaultOption
	 */
	public function testGetDefaultOption() {
		$manager = $this->getLookup();
		$this->assertSame( 'string_value', $manager->getDefaultOption( 'default_string_option' ) );
		$this->assertSame( 1, $manager->getDefaultOption( 'default_int_option' ) );
		$this->assertSame( true, $manager->getDefaultOption( 'default_bool_option' ) );
	}

	/**
	 * @covers \MediaWiki\User\Options\DefaultOptionsLookup::getOptions
	 * @covers \MediaWiki\User\Options\UserOptionsManager::getOptions
	 */
	public function testGetOptions() {
		$options = $this->getLookup()->getOptions( $this->getAnon() );
		$this->assertSame( 'string_value', $options['default_string_option'] );
		$this->assertSame( 1, $options['default_int_option'] );
		$this->assertSame( true, $options['default_bool_option'] );
	}

	/**
	 * @covers \MediaWiki\User\Options\DefaultOptionsLookup::getOption
	 * @covers \MediaWiki\User\Options\UserOptionsManager::getOption
	 */
	public function testGetOptionDefault() {
		$manager = $this->getLookup();
		$this->assertSame( 'string_value',
			$manager->getOption( $this->getAnon(), 'default_string_option' ) );
		$this->assertSame( 1, $manager->getOption( $this->getAnon(), 'default_int_option' ) );
		$this->assertSame( true, $manager->getOption( $this->getAnon(), 'default_bool_option' ) );
	}

	/**
	 * @covers \MediaWiki\User\Options\DefaultOptionsLookup::getOption
	 * @covers \MediaWiki\User\Options\UserOptionsManager::getOption
	 */
	public function testGetOptionDefaultNotExist() {
		$this->assertNull( $this->getLookup()
			->getOption( $this->getAnon(), 'this_option_does_not_exist' ) );
	}

	/**
	 * @covers \MediaWiki\User\Options\DefaultOptionsLookup::getOption
	 * @covers \MediaWiki\User\Options\UserOptionsManager::getOption
	 */
	public function testGetOptionDefaultNotExistDefaultOverride() {
		$this->assertSame( 'override', $this->getLookup()
			->getOption( $this->getAnon(), 'this_option_does_not_exist', 'override' ) );
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsLookup::getIntOption
	 */
	public function testGetIntOption() {
		$this->assertSame(
			2,
			$this->getLookup( 'qqq', [ 'default_int_option' => '2' ] )
				->getIntOption( $this->getAnon(), 'default_int_option' )
		);
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsLookup::getBoolOption
	 */
	public function testGetBoolOption() {
		$this->assertSame(
			true,
			$this->getLookup( 'qqq', [ 'default_bool_option' => 'true' ] )
				->getBoolOption( $this->getAnon(), 'default_bool_option' )
		);
	}
}
PK       ! BkAI  I  '  user/Options/UserOptionsManagerTest.phpnu Iw        <?php

use MediaWiki\Config\HashConfig;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\MainConfigNames;
use MediaWiki\User\Options\UserOptionsLookup;
use MediaWiki\User\Options\UserOptionsManager;
use MediaWiki\User\Options\UserOptionsStore;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use Psr\Log\NullLogger;
use Wikimedia\Rdbms\DeleteQueryBuilder;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IDBAccessObject;
use Wikimedia\Rdbms\InsertQueryBuilder;
use Wikimedia\Rdbms\SelectQueryBuilder;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @group Database
 * @covers \MediaWiki\User\Options\UserOptionsManager
 */
class UserOptionsManagerTest extends UserOptionsLookupTestBase {

	/**
	 * @param array $overrides supported keys:
	 *  - 'language' - string language code
	 *  - 'defaults' - array default preferences
	 *  - 'dbp' - IConnectionProvider
	 *  - 'hookContainer' - HookContainer
	 * @return UserOptionsManager
	 */
	private function getManager( array $overrides = [] ) {
		$services = $this->getServiceContainer();
		return new UserOptionsManager(
			new ServiceOptions(
				UserOptionsManager::CONSTRUCTOR_OPTIONS,
				new HashConfig( [
					MainConfigNames::HiddenPrefs => [ 'hidden_user_option' ],
					MainConfigNames::LocalTZoffset => 0,
				] )
			),
			$this->getDefaultManager(
				$overrides['language'] ?? 'qqq',
				$overrides['defaults'] ?? []
			),
			$services->getLanguageConverterFactory(),
			$overrides['dbp'] ?? $services->getConnectionProvider(),
			new NullLogger(),
			$overrides['hookContainer'] ?? $services->getHookContainer(),
			$services->getUserFactory(),
			$services->getUserNameUtils(),
			$services->getObjectFactory(),
			[]
		);
	}

	protected function getLookup(
		string $langCode = 'qqq',
		array $defaultOptionsOverrides = []
	): UserOptionsLookup {
		return $this->getManager( [
			'language' => $langCode,
			'defaults' => $defaultOptionsOverrides,
		] );
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsManager::getOption
	 */
	public function testGetOptionsExcludeDefaults() {
		$manager = $this->getManager( [ 'defaults' => [
			'null_vs_false' => null,
			'null_vs_string' => null,
			'false_vs_int' => false,
			'false_vs_string' => false,
			'int_vs_string' => 0,
			'true_vs_int' => true,
			'true_vs_string' => true,
		] ] );
		// TODO: Why is this testing an anon user when they can't have preferences?
		$user = $this->getAnon();
		$manager->setOption( $user, 'null_vs_false', false );
		$manager->setOption( $user, 'null_vs_string', '' );
		$manager->setOption( $user, 'false_vs_int', 0 );
		$manager->setOption( $user, 'false_vs_string', '0' );
		$manager->setOption( $user, 'int_vs_string', '0' );
		$manager->setOption( $user, 'true_vs_int', 1 );
		$manager->setOption( $user, 'true_vs_string', '1' );
		$manager->setOption( $user, 'new_option', 'new_value' );
		$expected = [
			// Note that the old, relaxed array_diff-approach considered null equal to false and ""
			'null_vs_false' => false,
			'null_vs_string' => '',
			'language' => 'en',
			'variant' => 'en',
			'new_option' => 'new_value',
		];
		$this->assertSame( $expected, $manager->getOptions( $user, UserOptionsManager::EXCLUDE_DEFAULTS ) );
	}

	/**
	 * @param bool $expected
	 * @param string $property
	 * @param int $userId
	 * @dataProvider provideConditionalDefaults
	 */
	public function testGetConditionalOption( bool $expected, string $property, int $userId ) {
		$this->assertSame(
			$expected,
			$this->getLookup()->getOption(
				new UserIdentityValue( $userId, 'Admin' ),
				$property
			)
		);
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsManager::getOption
	 */
	public function testGetOptionHiddenPref() {
		// TODO: Why is this testing an anon user when they can't have preferences?
		$user = $this->getAnon();
		$manager = $this->getManager();
		$manager->setOption( $user, 'hidden_user_option', 'hidden_value' );
		$this->assertNull( $manager->getOption( $user, 'hidden_user_option' ) );
		$this->assertSame( 'hidden_value',
			$manager->getOption( $user, 'hidden_user_option', null, true ) );
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsManager::setOption
	 */
	public function testSetOptionNullIsDefault() {
		// TODO: Why is this testing an anon user when they can't have preferences?
		$user = $this->getAnon();
		$manager = $this->getManager();
		$manager->setOption( $user, 'default_string_option', 'override_value' );
		$this->assertSame( 'override_value', $manager->getOption( $user, 'default_string_option' ) );
		$manager->setOption( $user, 'default_string_option', null );
		$this->assertSame( 'string_value', $manager->getOption( $user, 'default_string_option' ) );
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsManager::getOption
	 * @covers \MediaWiki\User\Options\UserOptionsManager::setOption
	 * @covers \MediaWiki\User\Options\UserOptionsManager::saveOptions
	 */
	public function testGetSetSave() {
		$user = $this->getTestUser()->getUser();
		$manager = $this->getManager();
		$this->assertSame( [], $manager->getOptions( $user, UserOptionsManager::EXCLUDE_DEFAULTS ) );
		$manager->setOption( $user, 'string_option', 'user_value' );
		$manager->setOption( $user, 'int_option', 42 );
		$manager->setOption( $user, 'bool_option', true );
		$this->assertSame( 'user_value', $manager->getOption( $user, 'string_option' ) );
		$this->assertSame( 42, $manager->getIntOption( $user, 'int_option' ) );
		$this->assertSame( true, $manager->getBoolOption( $user, 'bool_option' ) );
		$manager->saveOptions( $user );
		$this->assertSame( 'user_value', $manager->getOption( $user, 'string_option' ) );
		$this->assertSame( 42, $manager->getIntOption( $user, 'int_option' ) );
		$this->assertSame( true, $manager->getBoolOption( $user, 'bool_option' ) );
		$manager = $this->getManager();
		$this->assertSame( 'user_value', $manager->getOption( $user, 'string_option' ) );
		$this->assertSame( 42, $manager->getIntOption( $user, 'int_option' ) );
		$this->assertSame( true, $manager->getBoolOption( $user, 'bool_option' ) );
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsManager::loadUserOptions
	 */
	public function testLoadUserOptionsHook() {
		$user = UserIdentityValue::newRegistered( 42, 'Test' );
		$manager = $this->getManager( [
			'hookContainer' => $this->createHookContainer( [
				'LoadUserOptions' => function ( UserIdentity $hookUser, array &$options ) use ( $user ) {
					$this->assertTrue( $hookUser->equals( $user ) );
					$options['from_hook'] = 'value_from_hook';
				}
			] )
		] );
		$this->assertSame( 'value_from_hook', $manager->getOption( $user, 'from_hook' ) );
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsManager::saveOptions
	 */
	public function testSaveUserOptionsHookAbort() {
		$manager = $this->getManager( [
			'hookContainer' => $this->createHookContainer( [
				'SaveUserOptions' => static function () {
					return false;
				}
			] )
		] );
		$user = UserIdentityValue::newRegistered( 42, 'Test' );
		$manager->setOption( $user, 'will_be_aborted_by_hook', 'value' );
		$manager->saveOptions( $user );
		$this->assertNull( $this->getManager()->getOption( $user, 'will_be_aborted_by_hook' ) );
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsManager::saveOptions
	 */
	public function testSaveUserOptionsHookModify() {
		$user = UserIdentityValue::newRegistered( 42, 'Test' );
		$manager = $this->getManager( [
			'defaults' => [
				'reset_to_default_by_hook' => 'default',
			],
			'hookContainer' => $this->createHookContainer( [
				'SaveUserOptions' => function ( UserIdentity $hookUser, array &$modifiedOptions ) use ( $user ) {
					$this->assertTrue( $user->equals( $hookUser ) );
					$modifiedOptions['reset_to_default_by_hook'] = null;
					unset( $modifiedOptions['blocked_by_hook'] );
					$modifiedOptions['new_from_hook'] = 'value_from_hook';
				}
			] ),
		] );
		$manager->setOption( $user, 'reset_to_default_by_hook', 'not default' );
		$manager->setOption( $user, 'blocked_by_hook', 'blocked value' );
		$manager->saveOptions( $user );
		$this->assertSame( 'value_from_hook', $manager->getOption( $user, 'new_from_hook' ) );
		$this->assertSame( 'default', $manager->getOption( $user, 'reset_to_default_by_hook' ) );
		$this->assertNull( $manager->getOption( $user, 'blocked_by_hook' ) );
		$manager->clearUserOptionsCache( $user );
		$this->assertSame( 'value_from_hook', $manager->getOption( $user, 'new_from_hook' ) );
		$this->assertSame( 'default', $manager->getOption( $user, 'reset_to_default_by_hook' ) );
		$this->assertNull( $manager->getOption( $user, 'blocked_by_hook' ) );
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsManager::saveOptions
	 */
	public function testSaveUserOptionsHookOriginal() {
		$user = UserIdentityValue::newRegistered( 42, 'Test' );
		$manager = $this->getManager( [
			'language' => 'ja',
			'hookContainer' => $this->createHookContainer( [
				'SaveUserOptions' => function (
					UserIdentity $hookUser,
					array &$modifiedOptions,
					array $originalOptions
				) use ( $user ) {
					if ( $hookUser->equals( $user ) ) {
						$this->assertSame( 'ja', $originalOptions['language'] );
						$this->assertSame( 'ru', $modifiedOptions['language'] );
						$modifiedOptions['language'] = 'tr';
					}
					return true;
				}
			] ),
		] );
		$manager->setOption( $user, 'language', 'ru' );
		$manager->saveOptions( $user );
		$this->assertSame( 'tr', $manager->getOption( $user, 'language' ) );
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsManager::loadUserOptions
	 */
	public function testInfiniteRecursionOnLoadUserOptionsHook() {
		$user = UserIdentityValue::newRegistered( 42, 'Test' );
		$manager = $this->getManager( [
			'hookContainer' => $this->createHookContainer( [
				'LoadUserOptions' => function ( UserIdentity $hookUser ) use ( $user, &$manager, &$recursionCounter ) {
					if ( $hookUser->equals( $user ) ) {
						$recursionCounter += 1;
						$this->assertSame( 1, $recursionCounter );
						$manager->loadUserOptions( $hookUser );
					}
				}

			] )
		] );
		$recursionCounter = 0;
		$manager->loadUserOptions( $user, IDBAccessObject::READ_LATEST );
		$this->assertSame( 1, $recursionCounter );
	}

	public function testSaveOptionsForAnonUser() {
		$this->expectException( InvalidArgumentException::class );
		$this->getManager()->saveOptions( $this->getAnon() );
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsManager::resetOptionsByName
	 */
	public function testUserOptionsSaveAfterReset() {
		$user = $this->getTestUser()->getUser();
		$manager = $this->getManager();
		$manager->setOption( $user, 'test_option', 'test_value' );
		$manager->saveOptions( $user );
		$manager->clearUserOptionsCache( $user );
		$this->assertSame( 'test_value', $manager->getOption( $user, 'test_option' ) );
		$optionNames = array_keys( $manager->getOptions( $user ) );
		$manager->resetOptionsByName( $user, $optionNames );
		$this->assertNull( $manager->getOption( $user, 'test_option' ) );
		$manager->saveOptions( $user );
		$manager->clearUserOptionsCache( $user );
		$this->assertNull( $manager->getOption( $user, 'test_option' ) );
	}

	public function testOptionsForUpdateNotRefetchedBeforeInsert() {
		$mockDb = $this->createMock( IDatabase::class );
		$mockDb->expects( $this->once() ) // This is critical what we are testing
			->method( 'select' )
			->willReturn( new FakeResultWrapper( [
				[
					'up_value' => 'blabla',
					'up_property' => 'test_option',
				]
			] ) );
		$mockDb->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $mockDb ) );
		$mockDb->method( 'newInsertQueryBuilder' )->willReturnCallback( static fn () => new InsertQueryBuilder( $mockDb ) );
		$mockDbProvider = $this->createMock( IConnectionProvider::class );
		$mockDbProvider
			->method( 'getPrimaryDatabase' )
			->willReturn( $mockDb );
		$user = $this->getTestUser()->getUser();
		$manager = $this->getManager( [
			'dbp' => $mockDbProvider,
		] );
		$manager->getOption(
			$user,
			'test_option',
			null,
			false,
			IDBAccessObject::READ_LOCKING
		);
		$manager->getOption( $user, 'test_option2' );
		$manager->setOption( $user, 'test_option', 'test_value' );
		$manager->setOption( $user, 'test_option2', 'test_value2' );
		$manager->saveOptions( $user );
	}

	public function testOptionsNoDeleteSetDefaultValue() {
		$mockDb = $this->createMock( IDatabase::class );
		$mockDb->expects( $this->once() )
			->method( 'select' )
			->willReturn( new FakeResultWrapper( [
				[
					'up_value' => 'unchanged',
					'up_property' => 'unchanged_option',
				]
			] ) );
		$mockDb->expects( $this->never() ) // This is critical what we are testing
			->method( 'delete' );
		$mockDb->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $mockDb ) );
		$mockDbProvider = $this->createMock( IConnectionProvider::class );
		$mockDbProvider
			->method( 'getPrimaryDatabase' )
			->willReturn( $mockDb );
		$mockDbProvider
			->method( 'getReplicaDatabase' )
			->willReturn( $mockDb );
		$user = $this->getTestUser()->getUser();
		$manager = $this->getManager( [
			'dbp' => $mockDbProvider,
			'defaults' => [
				'set_default' => 'default',
				'set_default_null' => null,
			]
		] );
		// Resetting an option with default value to the default value does not trigger delete
		$manager->setOption( $user, 'set_default', 'default' );
		$manager->setOption( $user, 'set_default_null', null );
		$manager->saveOptions( $user );
	}

	public function testOptionsDeleteSetDefaultValue() {
		$user = $this->getTestUser()->getUser();
		$mockDb = $this->createMock( IDatabase::class );
		$mockDb
			->method( 'newDeleteQueryBuilder' )
			->willReturnCallback( static fn () => new DeleteQueryBuilder( $mockDb ) );
		$mockDb->expects( $this->once() )
			->method( 'select' )
			->willReturn( new FakeResultWrapper( [
				[
					'up_property' => 'unchanged',
					'up_value' => 'unchanged_option',
				],
				[
					'up_property' => 'set_default',
					'up_value' => 'non_default',
				],
				[
					'up_property' => 'set_default_null',
					'up_value' => 'not_null',
				],
				[
					'up_property' => 'set_default_not_null',
					'up_value' => null,
				]
			] ) );
		$mockDb->expects( $this->once() ) // This is critical what we are testing
			->method( 'delete' )
			->with(
				'user_properties',
				[
					'up_user' => $user->getId(),
					'up_property' => [ 'set_default', 'set_default_null', 'set_default_not_null' ]
				]
			);
		$mockDb->method( 'newSelectQueryBuilder' )->willReturnCallback( static fn () => new SelectQueryBuilder( $mockDb ) );
		$mockDbProvider = $this->createMock( IConnectionProvider::class );
		$mockDbProvider
			->method( 'getPrimaryDatabase' )
			->willReturn( $mockDb );
		$mockDbProvider
			->method( 'getReplicaDatabase' )
			->willReturn( $mockDb );
		$manager = $this->getManager( [
			'dbp' => $mockDbProvider,
			'defaults' => [
				'set_default' => 'default',
				'set_default_null' => null,
				'set_default_not_null' => 'not_null',
			]
		] );
		// Set every of the options to its default value must trigger a delete for each option
		$manager->setOption( $user, 'set_default', 'default' );
		$manager->setOption( $user, 'set_default_null', null );
		$manager->setOption( $user, 'set_default_not_null', 'not_null' );
		$manager->saveOptions( $user );
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsManager::saveOptionsInternal
	 */
	public function testOptionsInsertFromDefaultValue() {
		$user = $this->getTestUser()->getUser();
		$mockDb = $this->createMock( IDatabase::class );
		$mockDb
			->method( 'newSelectQueryBuilder' )
			->willReturnCallback( static fn () => new SelectQueryBuilder( $mockDb ) );
		$mockDb
			->method( 'newInsertQueryBuilder' )
			->willReturnCallback( static fn () => new InsertQueryBuilder( $mockDb ) );
		$mockDb
			->method( 'select' )
			->willReturn( new FakeResultWrapper( [] ) );

		// This is critical what we are testing
		$mockDb->expects( $this->once() )
			->method( 'insert' )
			->with(
				'user_properties',
				[
					[
						'up_user' => $user->getId(),
						'up_property' => 'set_empty',
						'up_value' => '',
					]
				]
			);

		$mockDbProvider = $this->createMock( IConnectionProvider::class );
		$mockDbProvider
			->method( 'getPrimaryDatabase' )
			->willReturn( $mockDb );
		$mockDbProvider
			->method( 'getReplicaDatabase' )
			->willReturn( $mockDb );
		$manager = $this->getManager( [
			'dbp' => $mockDbProvider,
			'defaults' => [
				'set_empty' => 123,
			]
		] );

		$manager->setOption( $user, 'set_empty', '' );
		$this->assertSame( '', $manager->getOption( $user, 'set_empty' ) );
		$manager->saveOptions( $user );
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsManager::saveOptions
	 */
	public function testUpdatesUserTouched() {
		$user = $this->getTestUser()->getUser();
		$userTouched = $user->getDBTouched();
		$newTouched = ConvertibleTimestamp::convert(
			TS_MW,
			intval( ConvertibleTimestamp::convert( TS_UNIX, $userTouched ) ) + 100
		);
		ConvertibleTimestamp::setFakeTime( $newTouched );

		$manager = $this->getManager();
		$manager->setOption( $user, 'test_option', 'test_value' );
		$manager->saveOptions( $user );
		$this->assertSame( $newTouched, $user->getDBTouched() );
		$user->clearInstanceCache();
		$this->assertSame( $newTouched, $user->getDBTouched() );
	}

	/**
	 * @covers \MediaWiki\User\Options\UserOptionsManager
	 */
	public function testNoLocalAccountOptionsStore() {
		$user = new UserIdentityValue( 0, 'NoLocalAccountUsername' );
		$store = $this->getMockBuilder( UserOptionsStore::class )->getMock();
		$store->expects( $this->once() )
			->method( 'fetch' )
			->with( $user )
			->willReturn( [ 'NoLocalAccountPreference' => '1' ] );
		$store->expects( $this->once() )
			->method( 'store' )
			->with( $user, [ 'NoLocalAccountPreference' => '2' ] )
			->willReturn( true );
		$services = $this->getServiceContainer();
		$manager = new UserOptionsManager(
			new ServiceOptions( UserOptionsManager::CONSTRUCTOR_OPTIONS, $services->getMainConfig() ),
			$services->get( '_DefaultOptionsLookup' ),
			$services->getLanguageConverterFactory(),
			$services->getConnectionProvider(),
			new NullLogger(),
			$services->getHookContainer(),
			$services->getUserFactory(),
			$services->getUserNameUtils(),
			$services->getObjectFactory(),
			[
				'NoLocalAccountStore' => [
					'factory' => fn () => $store,
				],
			]
		);
		$this->assertSame( '1', $manager->getOption( $user, 'NoLocalAccountPreference' ) );
		$manager->setOption( $user, 'NoLocalAccountPreference', '2', UserOptionsManager::GLOBAL_UPDATE );
		$this->assertTrue( $manager->saveOptionsInternal( $user ) );
	}
}
PK       ! .    )  user/Options/DefaultOptionsLookupTest.phpnu Iw        <?php

use MediaWiki\User\Options\DefaultOptionsLookup;
use MediaWiki\User\Options\UserOptionsLookup;

/**
 * @covers \MediaWiki\User\Options\DefaultOptionsLookup
 */
class DefaultOptionsLookupTest extends UserOptionsLookupTestBase {
	protected function getLookup(
		string $langCode = 'qqq',
		array $defaultOptionsOverrides = []
	): UserOptionsLookup {
		return $this->getDefaultManager( $langCode, $defaultOptionsOverrides );
	}

	/**
	 * @covers \MediaWiki\User\Options\DefaultOptionsLookup::getOption
	 */
	public function testGetOptionsExcludeDefaults() {
		$this->assertSame( [], $this->getLookup()
			->getOptions( $this->getAnon(), DefaultOptionsLookup::EXCLUDE_DEFAULTS ) );
	}

	/**
	 * @covers \MediaWiki\User\Options\DefaultOptionsLookup::getDefaultOptions
	 */
	public function testGetDefaultOptionsHook() {
		$this->setTemporaryHook( 'UserGetDefaultOptions', static function ( &$options ) {
			$options['from_hook'] = 'value_from_hook';
		} );
		$this->assertSame( 'value_from_hook', $this->getLookup()->getDefaultOption( 'from_hook' ) );
	}

	/**
	 * @covers \MediaWiki\User\Options\DefaultOptionsLookup::getDefaultOptions
	 */
	public function testSearchNS() {
		$lookup = $this->getLookup();
		$this->assertSame( 1, $lookup->getDefaultOption( 'searchNs0' ) );
		$this->assertSame( 0, $lookup->getDefaultOption( 'searchNs8' ) );
		$this->assertSame( 0, $lookup->getDefaultOption( 'searchNs9' ) );
		// Special namespace is not searchable and does not have a default
		$this->assertNull( $lookup->getDefaultOption( 'searchNs-1' ) );
	}

	/**
	 * @covers \MediaWiki\User\Options\DefaultOptionsLookup::getDefaultOptions
	 */
	public function testLangVariantOptions() {
		$managerZh = $this->getLookup( 'zh' );
		$this->assertSame( 'zh', $managerZh->getDefaultOption( 'language' ) );
		$this->assertSame( 'gan', $managerZh->getDefaultOption( 'variant-gan' ) );
		$this->assertSame( 'zh', $managerZh->getDefaultOption( 'variant' ) );
	}
}
PK       ! )j&  &  #  user/UserSelectQueryBuilderTest.phpnu Iw        <?php

namespace MediaWiki\Tests\User;

use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\User\UserSelectQueryBuilder;
use Wikimedia\Rdbms\SelectQueryBuilder;

/**
 * @group Database
 * @covers \MediaWiki\User\UserSelectQueryBuilder
 * @coversDefaultClass \MediaWiki\User\UserSelectQueryBuilder
 */
class UserSelectQueryBuilderTest extends ActorStoreTestBase {

	use TempUserTestTrait;

	public static function provideFetchUserIdentitiesByNamePrefix() {
		yield 'nothing found' => [
			'z_z_Z_Z_z_Z_z_z', // $prefix
			[ 'limit' => 100 ], // $options
			[], // $expected
		];
		yield 'default parameters' => [
			'Test', // $prefix
			[ 'limit' => 100 ], // $options
			[
				new UserIdentityValue( 24, 'TestUser' ),
				new UserIdentityValue( 25, 'TestUser1' ),
			], // $expected
		];
		yield 'limited' => [
			'Test', // $prefix
			[ 'limit' => 1 ], // $options
			[
				new UserIdentityValue( 24, 'TestUser' ),
			], // $expected
		];
		yield 'sorted' => [
			'Test', // $prefix
			[
				'sort' => UserSelectQueryBuilder::SORT_DESC,
				'limit' => 100,
			], // $options
			[
				new UserIdentityValue( 25, 'TestUser1' ),
				new UserIdentityValue( 24, 'TestUser' ),
			], // $expected
		];
	}

	/**
	 * @dataProvider provideFetchUserIdentitiesByNamePrefix
	 */
	public function testFetchUserIdentitiesByNamePrefix( string $prefix, array $options, array $expected ) {
		$queryBuilder = $this->getStore()
			->newSelectQueryBuilder()
			->limit( $options['limit'] )
			->whereUserNamePrefix( $prefix )
			->caller( __METHOD__ )
			->orderByName( $options['sort'] ?? SelectQueryBuilder::SORT_ASC );
		$actors = iterator_to_array( $queryBuilder->fetchUserIdentities() );
		$this->assertSameSize( $expected, $actors );
		foreach ( $expected as $idx => $expectedActor ) {
			$this->assertSameActors( $expectedActor, $actors[$idx] );
		}
	}

	public static function provideFetchUserIdentitiesByUserIds() {
		yield 'default parameters' => [
			[ 24, 25 ], // ids
			[], // $options
			[
				new UserIdentityValue( 24, 'TestUser' ),
				new UserIdentityValue( 25, 'TestUser1' ),
			], // $expected
		];
		yield 'sorted' => [
			[ 24, 25 ], // ids
			[ 'sort' => UserSelectQueryBuilder::SORT_DESC ], // $options
			[
				new UserIdentityValue( 25, 'TestUser1' ),
				new UserIdentityValue( 24, 'TestUser' ),
			], // $expected
		];
	}

	/**
	 * @dataProvider provideFetchUserIdentitiesByUserIds
	 */
	public function testFetchUserIdentitiesByUserIds( array $ids, array $options, array $expected ) {
		$actors = iterator_to_array(
			$this->getStore()
				->newSelectQueryBuilder()
				->whereUserIds( $ids )
				->caller( __METHOD__ )
				->orderByUserId( $options['sort'] ?? SelectQueryBuilder::SORT_ASC )
				->fetchUserIdentities()
		);
		$this->assertSameSize( $expected, $actors );
		foreach ( $expected as $idx => $expectedActor ) {
			$this->assertSameActors( $expectedActor, $actors[$idx] );
		}
	}

	public static function provideFetchUserIdentitiesByNames() {
		yield 'default parameters' => [
			[ 'TestUser', 'TestUser1' ], // $names
			[], // $options
			[
				new UserIdentityValue( 24, 'TestUser' ),
				new UserIdentityValue( 25, 'TestUser1' ),
			], // $expected
		];
		yield 'sorted' => [
			[ 'TestUser', 'TestUser1' ], // $names
			[ 'sort' => UserSelectQueryBuilder::SORT_DESC ], // $options
			[
				new UserIdentityValue( 25, 'TestUser1' ),
				new UserIdentityValue( 24, 'TestUser' ),
			], // $expected
		];
		yield 'with IPs' => [
			[ self::IP ], // $names
			[], // $options
			[
				new UserIdentityValue( 0, self::IP ),
			], // $expected
		];
		yield 'with IPs, normalization' => [
			[ strtolower( self::IP ), self::IP ], // $names
			[], // $options
			[
				new UserIdentityValue( 0, self::IP ),
			], // $expected
		];
	}

	/**
	 * @dataProvider provideFetchUserIdentitiesByNames
	 */
	public function testFetchUserIdentitiesByNames( array $names, array $options, array $expected ) {
		$actors = iterator_to_array(
			$this->getStore()
				->newSelectQueryBuilder()
				->whereUserNames( $names )
				->caller( __METHOD__ )
				->orderByUserId( $options['sort'] ?? SelectQueryBuilder::SORT_ASC )
				->fetchUserIdentities()
		);
		$this->assertSameSize( $expected, $actors );
		foreach ( $expected as $idx => $expectedActor ) {
			$this->assertSameActors( $expectedActor, $actors[$idx] );
		}
	}

	/**
	 * @covers ::fetchUserIdentity
	 */
	public function testFetchUserIdentity() {
		$this->assertSameActors(
			new UserIdentityValue( 24, 'TestUser' ),
			$this->getStore()
				->newSelectQueryBuilder()
				->whereUserIds( 24 )
				->fetchUserIdentity()
		);
	}

	/**
	 * @covers ::fetchUserNames
	 */
	public function testFetchUserNames() {
		$this->assertArrayEquals(
			[ 'TestUser', 'TestUser1' ],
			$this->getStore()
				->newSelectQueryBuilder()
				->conds( [ 'actor_id' => [ 42, 44 ] ] )
				->fetchUserNames()
		);
	}

	/**
	 * @covers ::registered
	 */
	public function testRegistered() {
		$actors = iterator_to_array(
			$this->getStore()
				->newSelectQueryBuilder()
				->conds( [ 'actor_id' => [ 42, 43 ] ] )
				->registered()
				->fetchUserIdentities()
		);
		$this->assertCount( 1, $actors );
		$this->assertSameActors(
			new UserIdentityValue( 24, 'TestUser' ),
			$actors[0]
		);
	}

	/**
	 * @covers ::anon
	 */
	public function testAnon() {
		$actors = iterator_to_array(
			$this->getStore()
				->newSelectQueryBuilder()
				->limit( 100 )
				->whereUserNamePrefix( '' )
				->anon()
				->fetchUserIdentities()
		);
		$this->assertCount( 2, $actors );
		$this->assertSameActors(
			new UserIdentityValue( 0, self::IP ),
			$actors[0]
		);
	}

	/**
	 * @covers ::anon
	 * @covers ::named
	 * @dataProvider provideNamedAndTempMethodNames
	 */
	public function testNamedAndTempWhenTempUserAutoCreateDisabled( $methodName ) {
		$this->enableAutoCreateTempUser();
		// Add a temporary accounts for the test
		$tempUserCreateStatus = $this->getServiceContainer()->getTempUserCreator()
			->create( null, new FauxRequest() );
		$this->assertStatusOK( $tempUserCreateStatus );
		$tempUser = $tempUserCreateStatus->getUser();

		$this->resetServices();
		$this->disableAutoCreateTempUser();

		$actors = iterator_to_array(
			$this->getStore()
				->newSelectQueryBuilder()
				->limit( 100 )
				->whereUserNamePrefix( '' )
				->$methodName()
				->fetchUserIdentities()
		);
		if ( $methodName === 'temp' ) {
			$this->assertCount( 0, $actors );
		} else {
			// With temp users disabled, the temp user is treated as a normal user and included.
			$this->assertArrayEquals(
				array_merge(
					array_map( fn ( $userRow ) => $userRow['actor_user'], self::TEST_USERS ),
					[ $tempUser->getId() ]
				),
				array_map( fn ( $actor ) => $actor->getId(), $actors )
			);
		}
	}

	public static function provideNamedAndTempMethodNames() {
		return [
			'::temp' => [ 'temp' ],
			'::named' => [ 'named' ],
		];
	}

	/**
	 * @covers ::named
	 */
	public function testNamed() {
		$this->enableAutoCreateTempUser();
		// Add a temporary accounts for the test
		$tempUserCreateStatus = $this->getServiceContainer()->getTempUserCreator()
			->create( null, new FauxRequest() );
		$this->assertStatusOK( $tempUserCreateStatus );
		$tempUser = $tempUserCreateStatus->getUser();
		$actors = iterator_to_array(
			$this->getStore()
				->newSelectQueryBuilder()
				->limit( 100 )
				->whereUserNamePrefix( '' )
				->named()
				->fetchUserIdentities()
		);
		// The temp user should not appear in the results list.
		$this->assertCount( 5, $actors );
		foreach ( $actors as $actor ) {
			$this->assertNotSame( $tempUser->getId(), $actor->getId() );
		}
	}

	/**
	 * @covers ::temp
	 */
	public function testTemp() {
		$this->enableAutoCreateTempUser();
		// Add a temporary accounts for the test
		$tempUserCreateStatus = $this->getServiceContainer()->getTempUserCreator()
			->create( null, new FauxRequest() );
		$this->assertStatusOK( $tempUserCreateStatus );
		$tempUser = $tempUserCreateStatus->getUser();
		$actors = iterator_to_array(
			$this->getStore()
				->newSelectQueryBuilder()
				->limit( 100 )
				->whereUserNamePrefix( '' )
				->temp()
				->fetchUserIdentities()
		);
		// The temp user should be the only user identity returned.
		$this->assertCount( 1, $actors );
		$this->assertSameActors( $tempUser, $actors[0] );
	}

	/**
	 * @covers ::hidden
	 */
	public function testHidden() {
		$hiddenUser = $this->getMutableTestUser()->getUserIdentity();
		$normalUser = $this->getMutableTestUser()->getUserIdentity();
		$this->getServiceContainer()->getBlockUserFactory()->newBlockUser(
			$hiddenUser,
			$this->getTestSysop()->getUser(),
			'infinity',
			'Test',
			[
				'isHideUser' => true
			]
		)->placeBlockUnsafe( true );

		// hidden set to true
		$actors = iterator_to_array(
			$this->getStore()
				->newSelectQueryBuilder()
				->limit( 100 )
				->whereUserIds( [
					$hiddenUser->getId(),
					$normalUser->getId()
				] )
				->hidden( true )
				->fetchUserIdentities()
		);
		$this->assertCount( 1, $actors );
		$this->assertSameActors(
			$hiddenUser,
			$actors[0]
		);

		// hidden set to false
		$actors = iterator_to_array(
			$this->getStore()
				->newSelectQueryBuilder()
				->limit( 100 )
				->whereUserIds( [
					$hiddenUser->getId(),
					$normalUser->getId()
				] )
				->hidden( false )
				->fetchUserIdentities()
		);
		$this->assertCount( 1, $actors );
		$this->assertSameActors(
			$normalUser,
			$actors[0]
		);

		// hidden not set
		$usernames = $this->getStore()
			->newSelectQueryBuilder()
			->limit( 100 )
			->whereUserIds( [
				$hiddenUser->getId(),
				$normalUser->getId()
			] )
			->fetchUserNames();
		$this->assertCount( 2, $usernames );
		$this->assertArrayEquals(
			[ $normalUser->getName(), $hiddenUser->getName() ],
			$usernames
		);
	}
}
PK       ! WVl  l    user/ActorStoreTest.phpnu Iw        <?php

namespace MediaWiki\Tests\User;

use CannotCreateActorException;
use InvalidArgumentException;
use MediaWiki\DAO\WikiAwareEntity;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\User\ActorStore;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\User\UserSelectQueryBuilder;
use stdClass;
use Wikimedia\Assert\PreconditionException;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * @covers \MediaWiki\User\ActorStore
 * @group Database
 */
class ActorStoreTest extends ActorStoreTestBase {
	use TempUserTestTrait;

	public static function provideGetActorById() {
		yield 'getActorById, registered' => [
			42, // $argument
			new UserIdentityValue( 24, 'TestUser' ), // $expected
		];
		yield 'getActorById, anon' => [
			43, // $argument
			new UserIdentityValue( 0, self::IP ), // $expected
		];
		yield 'getActorById, non-existent' => [
			4321231, // $argument
			null, // $expected
		];
		yield 'getActorById, zero' => [
			0, // $argument
			null, // $expected
		];
	}

	/**
	 * @dataProvider provideGetActorById
	 */
	public function testGetActorById( $argument, ?UserIdentity $expected ) {
		$actor = $this->getStore()->getActorById( $argument, $this->getDb() );
		if ( $expected ) {
			$this->assertNotNull( $actor );
			$this->assertSameActors( $expected, $actor );

			// test caching
			$cachedActor = $this->getStore()->getActorById( $argument, $this->getDb() );
			$this->assertSame( $actor, $cachedActor );
		} else {
			$this->assertNull( $actor );
		}
	}

	public static function provideGetActorByMethods() {
		yield 'getUserIdentityByName, registered' => [
			'getUserIdentityByName', // $method
			'TestUser', // $argument
			new UserIdentityValue( 24, 'TestUser' ), // $expected
		];
		yield 'getUserIdentityByName, non-existent' => [
			'getUserIdentityByName', // $method
			'TestUser_I_Do_Not_Exist', // $argument
			null, // $expected
		];
		yield 'getUserIdentityByName, anon' => [
			'getUserIdentityByName', // $method
			self::IP, // $argument
			new UserIdentityValue( 0, self::IP ), // $expected
		];
		yield 'getUserIdentityByName, anon, non-canonicalized IP' => [
			'getUserIdentityByName', // $method
			strtolower( self::IP ), // $argument
			new UserIdentityValue( 0, self::IP ), // $expected
		];
		yield 'getUserIdentityByName, user name 0' => [
			'getUserIdentityByName', // $method
			'0', // $argument
			new UserIdentityValue( 26, '0' ), // $expected
		];
		yield 'getUserIdentityByName, empty' => [
			'getUserIdentityByName', // $method
			'', // $argument
			null, // $expected
		];
		yield 'getUserIdentityByName, ip range' => [
			'getUserIdentityByName', // $method
			'1.1.1.1/1', // $argument
			null, // $expected
		];
		yield 'getUserIdentityByUserId, registered' => [
			'getUserIdentityByUserId', // $method
			24, // $argument
			new UserIdentityValue( 24, 'TestUser' ), // $expected
		];
		yield 'getUserIdentityByUserId, non-exitent' => [
			'getUserIdentityByUserId', // $method
			2412312, // $argument
			null, // $expected
		];
		yield 'getUserIdentityByUserId, zero' => [
			'getUserIdentityByUserId', // $method
			0, // $argument
			null, // $expected
		];
	}

	/**
	 * @dataProvider provideGetActorByMethods
	 */
	public function testGetActorByMethods( string $method, $argument, ?UserIdentity $expected ) {
		$actor = $this->getStore()->$method( $argument );
		if ( $expected ) {
			$this->assertNotNull( $actor );
			$this->assertSameActors( $expected, $actor );

			// test caching
			$cachedActor = $this->getStore()->$method( $argument );
			$this->assertSame( $actor, $cachedActor );
		} else {
			$this->assertNull( $actor );
		}
	}

	public static function provideUserIdentityValues() {
		yield [ new UserIdentityValue( 24, 'TestUser' ) ];
		yield [ new UserIdentityValue( 0, self::IP ) ];
	}

	/**
	 * @dataProvider provideUserIdentityValues
	 * @param UserIdentity $expected
	 */
	public function testSequentialCacheRetrieval( UserIdentity $expected ) {
		if ( $expected->getId() === 0 ) {
			$this->disableAutoCreateTempUser();
		}
		// ensure UserIdentity is cached
		$actorId = $this->getStore()->findActorId( $expected, $this->getDb() );

		$cachedActorId = $this->getStore()->findActorId( $expected, $this->getDb() );
		$this->assertSame( $actorId, $cachedActorId );

		$cachedActorId = $this->getStore()->acquireActorId( $expected, $this->getDb() );
		$this->assertSame( $actorId, $cachedActorId );

		$cached = $this->getStore()->getActorById( $actorId, $this->getDb() );
		$this->assertNotNull( $cached );
		$this->assertSameActors( $expected, $cached );

		$cached = $this->getStore()->getUserIdentityByName( $expected->getName() );
		$this->assertNotNull( $cached );
		$this->assertSameActors( $expected, $cached );

		$userId = $expected->getId();
		if ( $userId ) {
			$cached = $this->getStore()->getUserIdentityByUserId( $userId );
			$this->assertNotNull( $cached );
			$this->assertSameActors( $expected, $cached );
		}
	}

	public function testGetActorByIdRealUser() {
		$user = $this->getTestUser()->getUser();
		$actor = $this->getStore()->getActorById( $user->getActorId(), $this->getDb() );
		$this->assertSameActors( $user, $actor );
	}

	public function testgetUserIdentityByNameRealUser() {
		$user = $this->getTestUser()->getUser();
		$actor = $this->getStore()->getUserIdentityByName( $user->getName() );
		$this->assertSameActors( $user, $actor );
	}

	public function testGetUserIdentityByUserIdRealUser() {
		$user = $this->getTestUser()->getUser();
		$actor = $this->getStore()->getUserIdentityByUserId( $user->getId() );
		$this->assertSameActors( $user, $actor );
	}

	public static function provideNewActorFromRow() {
		yield 'full row' => [
			UserIdentity::LOCAL, // $wikiId
			(object)[ 'actor_id' => 42, 'actor_name' => 'TestUser', 'actor_user' => 24 ], // $row
			new UserIdentityValue( 24, 'TestUser' ), // $expected
		];
		yield 'full row, strings' => [
			UserIdentity::LOCAL, // $wikiId
			(object)[ 'actor_id' => '42', 'actor_name' => 'TestUser', 'actor_user' => '24' ], // $row
			new UserIdentityValue( 24, 'TestUser' ), // $expected
		];
		yield 'null user' => [
			UserIdentity::LOCAL, // $wikiId
			(object)[ 'actor_id' => '42', 'actor_name' => 'TestUser', 'actor_user' => null ], // $row
			new UserIdentityValue( 0, 'TestUser' ), // $expected
		];
		yield 'zero user' => [
			UserIdentity::LOCAL, // $wikiId
			(object)[ 'actor_id' => '42', 'actor_name' => 'TestUser', 'actor_user' => 0 ], // $row
			new UserIdentityValue( 0, 'TestUser' ), // $expected
		];
		yield 'cross-wiki' => [
			'acmewiki', // $wikiId
			(object)[ 'actor_id' => 42, 'actor_name' => 'TestUser', 'actor_user' => 24 ], // $row
			new UserIdentityValue( 24, 'TestUser', 'acmewiki' ), // $expected
		];
		yield 'user name 0' => [
			UserIdentity::LOCAL, // $wikiId
			(object)[ 'actor_id' => '46', 'actor_name' => '0', 'actor_user' => 26 ], // $row
			new UserIdentityValue( 26, '0' ), // $expected
		];
	}

	/**
	 * @dataProvider provideNewActorFromRow
	 */
	public function testNewActorFromRow( $wikiId, stdClass $row, UserIdentity $expected ) {
		$actor = $this->getStore( $wikiId )->newActorFromRow( $row );
		$this->assertSameActors( $expected, $actor, $wikiId );
	}

	public static function provideNewActorFromRow_exception() {
		yield 'null actor' => [
			(object)[ 'actor_id' => '0', 'actor_name' => 'TestUser', 'actor_user' => 0 ], // $row
		];
		yield 'zero actor' => [
			(object)[ 'actor_id' => null, 'actor_name' => 'TestUser', 'actor_user' => 0 ], // $row
		];
		yield 'empty name' => [
			(object)[ 'actor_id' => '10', 'actor_name' => '', 'actor_user' => 15 ], // $row
		];
	}

	/**
	 * @dataProvider provideNewActorFromRow_exception
	 */
	public function testNewActorFromRow_exception( stdClass $row ) {
		$this->expectException( InvalidArgumentException::class );
		$this->getStore()->newActorFromRow( $row );
	}

	public static function provideNewActorFromRowFields() {
		yield 'full row' => [
			UserIdentity::LOCAL, // $wikiId
			42, // $actorId
			'TestUser', // $name
			24, // $userId
			new UserIdentityValue( 24, 'TestUser' ), // $expected
		];
		yield 'full row, strings' => [
			UserIdentity::LOCAL, // $wikiId
			'42', // $actorId
			'TestUser', // $name
			'24', // $userId
			new UserIdentityValue( 24, 'TestUser' ), // $expected
		];
		yield 'null user' => [
			UserIdentity::LOCAL, // $wikiId
			42, // $actorId
			'TestUser', // $name
			null, // $userId
			new UserIdentityValue( 0, 'TestUser' ), // $expected
		];
		yield 'zero user' => [
			UserIdentity::LOCAL, // $wikiId
			42, // $actorId
			'TestUser', // $name
			0, // $userId
			new UserIdentityValue( 0, 'TestUser' ), // $expected
		];
		yield 'user name 0' => [
			UserIdentity::LOCAL, // $wikiId
			46, // $actorId
			'0', // $name
			26, // $userId
			new UserIdentityValue( 26, '0' ), // $expected
		];
		yield 'cross-wiki' => [
			'acmewiki', // $wikiId
			42, // $actorId
			'TestUser', // $name
			0, // $userId
			new UserIdentityValue( 0, 'TestUser', 'acmewiki' ), // $expected
		];
	}

	/**
	 * @dataProvider provideNewActorFromRowFields
	 */
	public function testNewActorFromRowFields( $wikiId, $actorId, $name, $userId, UserIdentity $expected ) {
		$actor = $this->getStore( $wikiId )->newActorFromRowFields( $userId, $name, $actorId );
		$this->assertSameActors( $expected, $actor, $wikiId );
	}

	public static function provideNewActorFromRowFields_exception() {
		yield 'empty name' => [
			42, // $actorId
			'', // $name
			24, // $userId
		];
		yield 'null name' => [
			42, // $actorId
			null, // $name
			24, // $userId
		];
		yield 'null actor' => [
			null, // $actorId
			'TestUser', // $name
			null, // $userId
		];
		yield 'zero actor' => [
			0, // $actorId
			'TestUser', // $name
			null, // $userId
		];
	}

	/**
	 * @dataProvider provideNewActorFromRowFields_exception
	 */
	public function testNewActorFromRowFields_exception( $actorId, $name, $userId ) {
		$this->expectException( InvalidArgumentException::class );
		$this->getStore()->newActorFromRowFields( $userId, $name, $actorId );
	}

	public static function provideFindActorId() {
		yield 'anon, local' => [
			static function () {
				return new UserIdentityValue( 0, self::IP );
			}, // $actorCallback
			43, // $expected
		];
		yield 'anon, non-canonical, local' => [
			static function () {
				return new UserIdentityValue( 0, strtolower( self::IP ) );
			}, // $actorCallback
			43, // $expected
		];
		yield 'registered, local' => [
			static function () {
				return new UserIdentityValue( 24, 'TestUser' );
			}, // $actorCallback
			42, // $expected
		];
		yield 'registered, zero user name' => [
			static function () {
				return new UserIdentityValue( 26, '0' );
			}, // $actorCallback
			46, // $expected
		];
		yield 'anon, non-existent, local' => [
			static function () {
				return new UserIdentityValue( 0, '127.1.2.3' );
			}, // $actorCallback
			null, // $expected
		];
		yield 'registered, non-existent, local' => [
			static function () {
				return new UserIdentityValue( 51, 'DoNotExist' );
			}, // $actorCallback
			null, // $expected
		];
		yield 'external, local' => [
			static function () {
				return new UserIdentityValue( 0, 'acme>TestUser' );
			}, // $actorCallback
			45, // $expected
		];
		yield 'anon User, local' => [
			static function ( MediaWikiServices $serviceContainer ) {
				return $serviceContainer->getUserFactory()->newAnonymous( self::IP );
			}, // $actorCallback
			43, // $expected
		];
		yield 'anon User, non-canonical, local' => [
			static function ( MediaWikiServices $serviceContainer ) {
				return $serviceContainer->getUserFactory()->newAnonymous( strtolower( self::IP ) );
			}, // $actorCallback
			43, // $expected
		];
		yield 'anon User, non-existent, local' => [
			static function ( MediaWikiServices $serviceContainer ) {
				return $serviceContainer->getUserFactory()->newAnonymous( '127.1.2.3' );
			}, // $actorCallback
			null, // $expected
		];
		yield 'anon, foreign' => [
			static function () {
				return new UserIdentityValue( 0, self::IP, 'acmewiki' );
			}, // $actorCallback
			43, // $expected
			'acmewiki', // $wikiId
		];
		yield 'registered, foreign' => [
			static function () {
				return new UserIdentityValue( 24, 'TestUser', 'acmewiki' );
			}, // $actorCallback
			42, // $expected
			'acmewiki', // $wikiId
		];
	}

	/**
	 * @dataProvider provideFindActorId
	 */
	public function testFindActorId( callable $actorCallback, $expected, $wikiId = WikiAwareEntity::LOCAL ) {
		$actor = $actorCallback( $this->getServiceContainer() );
		if ( $wikiId ) {
			$this->executeWithForeignStore(
				$wikiId,
				function ( ActorStore $store ) use ( $expected, $actor ) {
					$this->assertSame( $expected, $store->findActorId( $actor, $this->getDb() ) );

					if ( $actor instanceof User ) {
						$this->assertSame( $expected ?: 0, $actor->getActorId() );
					}
				}
			);
		} else {
			$this->assertSame( $expected, $this->getStore()->findActorId( $actor, $this->getDb() ) );

			if ( $actor instanceof User ) {
				$this->assertSame( $expected ?: 0, $actor->getActorId() );
			}
		}
	}

	public function testFindActorId_wikiMismatch() {
		$this->markTestSkipped();
		$this->expectException( PreconditionException::class );
		$this->getStore()->findActorId(
			new UserIdentityValue( 0, self::IP, 'acmewiki' ),
			$this->getDb()
		);
	}

	public static function provideFindActorIdByName() {
		yield 'anon' => [
			self::IP, // $actorCallback
			43, // $expected
		];
		yield 'anon, non-canonical' => [
			strtolower( self::IP ), // $actorCallback
			43, // $expected
		];
		yield 'registered' => [
			'TestUser', // $actorCallback
			42, // $expected
		];
		yield 'registered, unnormalized' => [
			'testUser', // $actorCallback
			42, // $expected
		];
		yield 'registered, 0 user name' => [
			'0', // $actorCallback
			46, // $expected
		];
		yield 'external, local' => [
			'acme>TestUser',
			45, // $expected
		];
		yield 'anon, non-existent' => [
			'127.1.2.3', // $actorCallback
			null, // $expected
		];
		yield 'registered, non-existent' => [
			'DoNotExist', // $actorCallback
			null, // $expected
		];
		yield 'invalid' => [
			'#', // $actorCallback
			null, // $expected
		];
	}

	/**
	 * @dataProvider provideFindActorIdByName
	 */
	public function testFindActorIdByName( $name, $expected ) {
		$this->assertSame( $expected, $this->getStore()->findActorIdByName( $name, $this->getDb() ) );
	}

	public static function provideAcquireActorId() {
		yield 'anon' => [ static function () {
			return new UserIdentityValue( 0, '127.3.2.1' );
		} ];
		yield 'registered' => [ static function () {
			return new UserIdentityValue( 15, 'MyUser' );
		} ];
		yield 'User object' => [ static function ( $serviceContainer ) {
			return $serviceContainer->getUserFactory()->newAnonymous( '127.4.3.2' );
		} ];
	}

	/**
	 * @dataProvider provideAcquireActorId
	 */
	public function testAcquireActorId( callable $userCallback ) {
		/** @var UserIdentity $user */
		$user = $userCallback( $this->getServiceContainer() );
		if ( $user->getId() === 0 ) {
			$this->disableAutoCreateTempUser();
		}
		$actorId = $this->getStore()->acquireActorId( $user, $this->getDb() );
		$this->assertTrue( $actorId > 0 );

		if ( $user instanceof User ) {
			$this->assertSame( $actorId, $user->getActorId() );
		}
	}

	public static function provideAcquireActorId_foreign() {
		yield 'anon' => [
			'userCallback' => static function () {
				return new UserIdentityValue( 0, '127.3.2.1', 'acmewiki' );
			},
			'method' => 'acquireActorId'
		];
		yield 'registered' => [
			'userCallback' => static function () {
				return new UserIdentityValue( 15, 'MyUser', 'acmewiki' );
			},
			'method' => 'acquireActorId'
		];
		yield 'anon, new' => [
			'userCallback' => static function () {
				return new UserIdentityValue( 0, '128.9.8.7', 'acmewiki' );
			},
			'method' => 'createNewActor'
		];
		yield 'registered, new' => [
			'userCallback' => static function () {
				return new UserIdentityValue( 15, 'New MyUser', 'acmewiki' );
			},
			'method' => 'createNewActor'
		];
	}

	/**
	 * @dataProvider provideAcquireActorId_foreign
	 */
	public function testAcquireActorId_foreign( callable $userCallback, string $method ) {
		/** @var UserIdentity $user */
		$user = $userCallback( $this->getServiceContainer() );
		if ( $user->getId( 'acmewiki' ) === 0 ) {
			$this->disableAutoCreateTempUser();
		}
		$this->executeWithForeignStore(
			'acmewiki',
			function ( ActorStore $store ) use ( $user, $method ) {
				$actorId = $store->$method( $user, $this->getDb() );
				$this->assertTrue( $actorId > 0 );
				if ( $user instanceof User ) {
					$this->assertSame( $actorId, $user->getActorId() );
				}
			}
		);
	}

	/**
	 * @dataProvider provideAcquireActorId_foreign
	 */
	public function testAcquireActorId_foreign_withDBConnection( callable $userCallback, string $method ) {
		/** @var UserIdentity $user */
		$user = $userCallback( $this->getServiceContainer() );
		if ( $user->getId( 'acmewiki' ) === 0 ) {
			$this->disableAutoCreateTempUser();
		}
		$this->executeWithForeignStore(
			'acmewiki',
			function ( ActorStore $store, IDatabase $dbw ) use ( $user, $method ) {
				$actorId = $store->$method( $user, $dbw );
				$this->assertTrue( $actorId > 0 );
				if ( $user instanceof User ) {
					$this->assertSame( $actorId, $user->getActorId() );
				}
			}
		);
	}

	public static function provideAcquireActorId_canNotCreate() {
		yield 'usable name' => [
			'actor' => new UserIdentityValue( 0, 'MyFancyUser' ),
			'method' => 'acquireActorId'
		];
		yield 'empty name' => [
			'actor' => new UserIdentityValue( 15, '' ),
			'method' => 'acquireActorId'
		];
		yield 'usable name, new' => [
			'actor' => new UserIdentityValue( 0, 'MyFancyUser' ),
			'method' => 'createNewActor'
		];
		yield 'empty name, new' => [
			'actor' => new UserIdentityValue( 15, '' ),
			'method' => 'createNewActor'
		];
		yield 'usable name, replace' => [
			'actor' => new UserIdentityValue( 0, 'MyFancyUser' ),
			'method' => 'acquireSystemActorId'
		];
		yield 'empty name, replace' => [
			'actor' => new UserIdentityValue( 15, '' ),
			'method' => 'acquireSystemActorId'
		];
		yield 'existing non-anon, replace' => [
			'actor' => new UserIdentityValue( 25, 'TestUser' ),
			'method' => 'acquireSystemActorId'
		];
	}

	/**
	 * @dataProvider provideAcquireActorId_canNotCreate
	 */
	public function testAcquireActorId_canNotCreate( UserIdentityValue $actor, string $method ) {
		// We rely on DB to protect against duplicates when inserting new actor
		$this->setNullLogger( 'rdbms' );
		$this->expectException( CannotCreateActorException::class );
		$this->getStore()->$method( $actor, $this->getDb() );
	}

	public function testAcquireNowActorId_existing() {
		// We rely on DB to protect against duplicates when inserting new actor
		$this->setNullLogger( 'rdbms' );
		$this->expectException( CannotCreateActorException::class );
		$this->getStore()->createNewActor( new UserIdentityValue( 24, 'TestUser' ), $this->getDb() );
	}

	public function testAcquireActorId_autocreateTempIP() {
		$this->enableAutoCreateTempUser();
		$this->expectException( CannotCreateActorException::class );
		$this->getStore()->createNewActor( new UserIdentityValue( 0, '127.3.2.1' ), $this->getDb() );
	}

	public function testAcquireActorId_autocreateTempIPallowed() {
		$this->enableAutoCreateTempUser();
		$actorId = $this->getStoreForImport()->createNewActor( new UserIdentityValue( 0, '127.3.2.1' ), $this->getDb() );
		$this->assertTrue( $actorId > 0 );
	}

	public static function provideAcquireActorId_existing() {
		yield 'anon' => [
			new UserIdentityValue( 0, self::IP ), // $actor
			43, // $expected
		];
		yield 'registered' => [
			new UserIdentityValue( 24, 'TestUser' ), // $actor
			42, // $expected
		];
		yield 'registered, 0 user name' => [
			new UserIdentityValue( 26, '0' ), // $actor
			46, // $expected
		];
	}

	/**
	 * @dataProvider provideAcquireActorId_existing
	 */
	public function testAcquireActorId_existing( UserIdentityValue $actor, int $expected ) {
		if ( $actor->getId() === 0 ) {
			$this->disableAutoCreateTempUser();
		}
		$this->assertSame( $expected, $this->getStore()->acquireActorId( $actor, $this->getDb() ) );
	}

	public function testAcquireActorId_domain_mismatch() {
		$this->expectException( InvalidArgumentException::class );
		$this->getStore( 'fancywiki' )->acquireActorId(
			new UserIdentityValue( 15, 'Test', 'fancywiki' ),
			$this->getDb()
		);
	}

	public function testcreateNewActor_domain_mismatch() {
		$this->expectException( InvalidArgumentException::class );
		$this->getStore( 'fancywiki' )->createNewActor(
			new UserIdentityValue( 15, 'Test', 'fancywiki' ),
			$this->getDb()
		);
	}

	public function testAcquireSystemActorId_domain_mismatch() {
		$this->expectException( InvalidArgumentException::class );
		$this->getStore( 'fancywiki' )->acquireSystemActorId(
			new UserIdentityValue( 15, 'Test', 'fancywiki' ),
			$this->getDb()
		);
	}

	public function testAcquireActorId_wikiMismatch() {
		$this->markTestSkipped();
		$this->expectException( PreconditionException::class );
		$this->getStore()->acquireActorId(
			new UserIdentityValue( 0, self::IP, 'acmewiki' ),
			$this->getDb()
		);
	}

	public function testAcquireActorId_clearCacheOnRollback() {
		$this->disableAutoCreateTempUser();
		$rolledBackActor = new UserIdentityValue( 0, '127.0.0.10' );
		$store = $this->getStore();
		$this->getDb()->startAtomic( __METHOD__ );
		$rolledBackActorId = $store->acquireActorId( $rolledBackActor, $this->getDb() );
		$this->assertTrue( $rolledBackActorId > 0 );
		$foundActorId = $store->findActorId( $rolledBackActor, $this->getDb() );
		$this->assertSame( $rolledBackActorId, $foundActorId );
		$this->getDb()->rollback( __METHOD__ );

		// Insert some other user identity using another store
		// so that we take over the rolled back actor ID.
		$anotherActor = new UserIdentityValue( 0, '127.0.0.11' );
		$anotherActorId = $this->getStore()->acquireActorId( $anotherActor, $this->getDb() );

		// Make sure no actor ID associated with rolled back actor.
		$foundActorIdAfterRollback = $store->findActorId( $rolledBackActor, $this->getDb() );
		$this->assertNull( $foundActorIdAfterRollback );

		// Make sure we can acquire new actor ID for the rolled back actor
		$newActorId = $store->acquireActorId( $rolledBackActor, $this->getDb() );
		$this->assertTrue( $newActorId > 0 );
		$this->assertNotSame( $newActorId, $rolledBackActorId );

		// Make sure we find correct actor by rolled back actor ID
		$this->assertSameActors( $anotherActor, $store->getActorById( $anotherActorId, $this->getDb() ) );
	}

	public function testUserRenameUpdatesActor() {
		$user = $this->getMutableTestUser()->getUser();
		$oldName = $user->getName();

		$store = $this->getStore();
		$actorId = $store->findActorId( $user, $this->getDb() );
		$this->assertTrue( $actorId > 0 );

		$user->setName( 'NewName' );
		$user->saveSettings();

		$this->assertSameActors( $user, $store->getActorById( $actorId, $this->getDb() ) );
		$this->assertSameActors( $user, $store->getUserIdentityByName( 'NewName' ) );
		$this->assertSameActors( $user, $store->getUserIdentityByUserId( $user->getId() ) );
		$this->assertNull( $store->getUserIdentityByName( $oldName ) );
	}

	public function testAcquireSystemActorId() {
		$this->disableAutoCreateTempUser();
		$store = $this->getStore();
		$originalActor = new UserIdentityValue( 0, '129.0.0.1' );
		$actorId = $store->createNewActor( $originalActor, $this->getDb() );
		$this->assertTrue( $actorId > 0, 'Acquired new actor ID' );

		$updatedActor = new UserIdentityValue( 56789, '129.0.0.1' );
		$this->assertSame( $actorId, $store->acquireSystemActorId( $updatedActor, $this->getDb() ) );
		$this->assertSame( 56789, $store->getActorById( $actorId, $this->getDb() )->getId() );
		// Try with another store to verify not just cache was updated
		$this->assertSame( 56789, $this->getStore()->getActorById( $actorId, $this->getDb() )->getId() );
	}

	public function testAcquireSystemActorId_replaceReserved() {
		$this->overrideConfigValue(
			MainConfigNames::ReservedUsernames,
			[ 'RESERVED' ]
		);
		$store = $this->getStore();
		$originalActor = new UserIdentityValue( 0, 'RESERVED' );
		$actorId = $store->createNewActor( $originalActor, $this->getDb() );
		$this->assertTrue( $actorId > 0, 'Acquired new actor ID' );

		$updatedActor = new UserIdentityValue( 80, 'RESERVED' );
		$this->assertSame( $actorId, $store->acquireSystemActorId( $updatedActor, $this->getDb() ) );
		$this->assertSame( 80, $store->getActorById( $actorId, $this->getDb() )->getId() );
		// Try with another store to verify not just cache was updated
		$this->assertSame( 80, $this->getStore()->getActorById( $actorId, $this->getDb() )->getId() );
	}

	public function testAcquireSystemActorId_assignsNew() {
		$store = $this->getStore();

		$newActor = new UserIdentityValue( 456789, 'Foo' );
		$newActorId = $store->acquireSystemActorId( $newActor, $this->getDb() );
		$this->assertTrue( $newActorId > 0 );
		$this->assertSame( 456789, $store->getActorById( $newActorId, $this->getDb() )->getId() );
		// Try with another store to verify not just cache was updated
		$this->assertSame( 456789, $this->getStore()->getActorById( $newActorId, $this->getDb() )->getId() );
	}

	public function testDelete() {
		$store = $this->getStore();
		$actor = new UserIdentityValue( 9999, 'DeleteTest' );
		$actorId = $store->acquireActorId( $actor, $this->getDb() );
		$this->assertTrue( $store->deleteActor( $actor, $this->getDb() ) );

		$this->assertNull( $store->findActorId( $actor, $this->getDb() ) );
		$this->assertNull( $store->getUserIdentityByUserId( $actor->getId() ) );
		$this->assertNull( $store->getUserIdentityByName( $actor->getName() ) );
		$this->assertNull( $store->getActorById( $actorId, $this->getDb() ) );
	}

	public function testDeleteDoesNotExist() {
		$this->assertFalse(
			$this->getStore()->deleteActor( new UserIdentityValue( 9998, 'DoesNotExist' ), $this->getDb() )
		);
	}

	public function testGetUnknownActor() {
		$store = $this->getStore();
		$unknownActor = $store->getUnknownActor();
		$this->assertInstanceOf( UserIdentity::class, $unknownActor );
		$this->assertSame( ActorStore::UNKNOWN_USER_NAME, $unknownActor->getName() );
		$this->assertSameActors( $unknownActor, $store->getUnknownActor() );
	}

	public static function provideNormalizeUserName() {
		yield [ strtolower( self::IP ), self::IP ];
		yield [ 'acme>test', 'acme>test' ];
		yield [ 'test_this', 'Test this' ];
		yield [ 'foo#bar', null ];
		yield [ 'foo|bar', null ];
		yield [ '_', null ];
		yield [ 'test', 'Test' ];
		yield [ '', null ];
		yield [ '0', '0' ];
	}

	/**
	 * @dataProvider provideNormalizeUserName
	 */
	public function testNormalizeUserName( $name, $expected ) {
		$store = $this->getStore();
		$this->assertSame( $expected, $store->normalizeUserName( $name ) );
	}

	public function testNewSelectQueryBuilderWithoutDB() {
		$store = $this->getStore();
		$queryBuilder = $store->newSelectQueryBuilder();
		$this->assertInstanceOf( UserSelectQueryBuilder::class, $queryBuilder );
	}

	public function testNewSelectQueryBuilderWithDB() {
		$store = $this->getStore();
		$queryBuilder = $store->newSelectQueryBuilder( $this->getDb() );
		$this->assertInstanceOf( UserSelectQueryBuilder::class, $queryBuilder );
	}

	public function testNewSelectQueryBuilderWithQueryFlags() {
		$store = $this->getStore();
		$queryBuilder = $store->newSelectQueryBuilder( IDBAccessObject::READ_NORMAL );
		$this->assertInstanceOf( UserSelectQueryBuilder::class, $queryBuilder );
	}
}
PK       ! I~M'  '  %  user/TempUser/TempUserCreatorTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\User\TempUser;

use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\Throttler;
use MediaWiki\MainConfigNames;
use MediaWiki\Registration\ExtensionRegistry;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Session\Session;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\User\CentralId\CentralIdLookup;
use MediaWiki\User\TempUser\RealTempUserConfig;
use MediaWiki\User\TempUser\SerialMapping;
use MediaWiki\User\TempUser\SerialProvider;
use MediaWiki\User\TempUser\TempUserCreator;
use MediaWiki\User\UserFactory;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @group Database
 * @covers \MediaWiki\User\TempUser\DBSerialProvider
 * @covers \MediaWiki\User\TempUser\LocalSerialProvider
 * @covers \MediaWiki\User\TempUser\TempUserCreator
 * @covers \MediaWiki\User\TempUser\CreateStatus
 */
class TempUserCreatorTest extends \MediaWikiIntegrationTestCase {

	use TempUserTestTrait;

	public function testCreate() {
		$this->enableAutoCreateTempUser( [
			'serialProvider' => [ 'type' => 'local', 'useYear' => false ],
			'matchPattern' => '~$1',
		] );
		$tuc = $this->getServiceContainer()->getTempUserCreator();
		$this->assertTrue( $tuc->isAutoCreateAction( 'edit' ) );
		$this->assertTrue( $tuc->isTempName( '~1' ) );

		// Create a temporary account
		$status = $tuc->create( null, new FauxRequest() );
		$this->assertSame( '~1', $status->getUser()->getName() );
		$this->assertSame(
			1,
			$this->getDb()->newSelectQueryBuilder()
				->from( 'logging' )
				->join( 'actor', null, 'log_actor=actor_id' )
				->where( [ 'actor_name' => '~1', 'log_action' => 'autocreate' ] )
				->fetchRowCount(),
			'A logging entry indicating the autocreation of ~1 was expected.'
		);

		// Repeat the test to verify that the serial number increments
		$status = $tuc->create( null, new FauxRequest() );
		$this->assertSame( '~2', $status->getUser()->getName() );
		$this->assertSame(
			1,
			$this->getDb()->newSelectQueryBuilder()
				->from( 'logging' )
				->join( 'actor', null, 'log_actor=actor_id' )
				->where( [ 'actor_name' => '~2', 'log_action' => 'autocreate' ] )
				->fetchRowCount(),
			'A logging entry indicating the autocreation of ~2 was expected.'
		);
	}

	private function getTempUserCreatorUnit() {
		$scope1 = ExtensionRegistry::getInstance()->setAttributeForTest(
			'TempUserSerialProviders',
			[
				'test' => [
					'factory' => static function () {
						return new class implements SerialProvider {
							public function acquireIndex( int $year = 0 ): int {
								return 1;
							}
						};
					}
				],
			]
		);
		$scope2 = ExtensionRegistry::getInstance()->setAttributeForTest(
			'TempUserSerialMappings',
			[
				'test' => [
					'factory' => static function () {
						return new class implements SerialMapping {
							public function getSerialIdForIndex( int $index ): string {
								$index--;
								$adjective = (int)( $index / 2 );
								$animal = $index % 2;
								return [ 'active' ][$adjective] . ' ' . [ 'aardvark' ][$animal];
							}
						};
					}
				]
			]
		);
		$config = new RealTempUserConfig( [
			'enabled' => true,
			'expireAfterDays' => null,
			'actions' => [ 'edit' ],
			'genPattern' => '*Unregistered $1',
			'matchPattern' => '*$1',
			'serialProvider' => [ 'type' => 'test' ],
			'serialMapping' => [ 'type' => 'test' ],
		] );
		$creator = new TempUserCreator(
			$config,
			$this->createSimpleObjectFactory(),
			$this->createMock( UserFactory::class ),
			$this->createMock( AuthManager::class ),
			$this->createMock( CentralIdLookup::class ),
			$this->createMock( Throttler::class ),
			$this->createMock( Throttler::class )
		);
		return [ $creator, [ $scope1, $scope2 ] ];
	}

	public function testAcquireName_unit() {
		[ $creator, $scope ] = $this->getTempUserCreatorUnit();
		/** @var TempUserCreator $creator */
		$creator = TestingAccessWrapper::newFromObject( $creator );
		$this->assertSame(
			'*Unregistered active aardvark',
			$creator->acquireName( '127.0.0.1' )
		);
	}

	public function testAcquireName_db() {
		$this->enableAutoCreateTempUser( [
			'serialProvider' => [ 'type' => 'local', 'useYear' => false ],
			'matchPattern' => '~$1',
		] );
		$tuc = TestingAccessWrapper::newFromObject(
			$this->getServiceContainer()->getTempUserCreator()
		);
		$this->assertSame( '~1', $tuc->acquireName( '127.0.0.1' ) );
		$this->assertSame( '~2', $tuc->acquireName( '127.0.0.1' ) );
	}

	public function testAcquireName_dbWithYear() {
		$this->enableAutoCreateTempUser( [ 'serialProvider' => [ 'type' => 'local', 'useYear' => true ] ] );

		ConvertibleTimestamp::setFakeTime( '20000101000000' );
		$tuc = TestingAccessWrapper::newFromObject(
			$this->getServiceContainer()->getTempUserCreator()
		);
		$this->assertSame( '~2000-1', $tuc->acquireName( '127.0.0.1' ) );
		$this->assertSame( '~2000-2', $tuc->acquireName( '127.0.0.1' ) );

		ConvertibleTimestamp::setFakeTime( '20010101000000' );
		$this->assertSame( '~2001-1', $tuc->acquireName( '127.0.0.1' ) );
	}

	public function testAcquireNameOnDuplicate_db() {
		ConvertibleTimestamp::setFakeTime( '20000101000000' );
		$this->enableAutoCreateTempUser();
		$tuc = TestingAccessWrapper::newFromObject(
			$this->getServiceContainer()->getTempUserCreator()
		);
		// Create a temporary account
		$this->assertSame( '~2000-1', $tuc->create( null, new FauxRequest() )->value->getName() );
		// Reset the user_autocreate_serial table
		$this->truncateTable( 'user_autocreate_serial' );
		// Because user_autocreate_serial was truncated, the ::acquireName method should
		// return null as the code attempts to return a temporary account that already exists.
		$this->assertSame( null, $tuc->acquireName( '127.0.0.1' ) );
	}

	public function testCreateOnDuplicate_db() {
		ConvertibleTimestamp::setFakeTime( '20000101000000' );
		$this->enableAutoCreateTempUser();
		$tuc = $this->getServiceContainer()->getTempUserCreator();
		// Create a temporary account
		$this->assertSame( '~2000-1', $tuc->create( null, new FauxRequest() )->value->getName() );
		// Create a temporary account with an existing temporary account username.
		$secondCreateStatus = $tuc->create( '~2000-1', new FauxRequest() );
		$this->assertStatusError( 'temp-user-unable-to-acquire', $secondCreateStatus );
		// Assert that only one log entry for autocreation exists for ~2000-1, as the second call should have not
		// created a new log entry.
		$this->assertSame(
			1,
			$this->getDb()->newSelectQueryBuilder()
				->from( 'logging' )
				->join( 'actor', null, 'log_actor=actor_id' )
				->where( [ 'actor_name' => '~2000-1', 'log_action' => 'autocreate' ] )
				->fetchRowCount(),
			'Only one logging entry indicating the autocreation of ~2000-1 was expected.'
		);
	}

	public function testCreateOnInvalidUsername() {
		$this->enableAutoCreateTempUser();
		$tuc = $this->getServiceContainer()->getTempUserCreator();
		// Attempt to create the temporary account with an invalid username.
		$secondCreateStatus = $tuc->create( 'Template:InvalidUsername#test', new FauxRequest() );
		$this->assertStatusError( 'internalerror_info', $secondCreateStatus );
	}

	public function testAcquireNameThrottled() {
		ConvertibleTimestamp::setFakeTime( '20000101000000' );
		$this->enableAutoCreateTempUser();
		$this->overrideConfigValue(
			MainConfigNames::TempAccountNameAcquisitionThrottle,
			[
				'count' => 1,
				'seconds' => 30 * 86400,
			]
		);
		$tuc = TestingAccessWrapper::newFromObject(
			$this->getServiceContainer()->getTempUserCreator()
		);
		// Create a temporary account
		$this->assertSame( '~2000-1', $tuc->create( null, new FauxRequest() )->value->getName() );
		// Attempt again; name acquisition should be limited
		$this->assertStatusError( 'temp-user-unable-to-acquire', $tuc->create( null, new FauxRequest() ) );
	}

	public function testAcquireAndStashName() {
		/** @var TempUserCreator $creator */
		[ $creator, $scope ] = $this->getTempUserCreatorUnit();

		$session = new class extends Session {
			private $data = [];

			public function __construct() {
			}

			public function __destruct() {
			}

			public function get( $key, $default = null ) {
				return array_key_exists( $key, $this->data ) ? $this->data[$key] : $default;
			}

			public function set( $key, $value ) {
				$this->data[$key] = $value;
			}

			public function save() {
			}

			public function getRequest() {
				return new FauxRequest();
			}
		};

		$name = $creator->acquireAndStashName( $session );
		$this->assertSame( '*Unregistered active aardvark', $name );
		$name = $creator->acquireAndStashName( $session );
		$this->assertSame( '*Unregistered active aardvark', $name );
	}

	public function testRateLimit() {
		$this->enableAutoCreateTempUser( [
			'serialProvider' => [ 'type' => 'local', 'useYear' => false ],
			'matchPattern' => '~$1',
		] );
		$this->overrideConfigValues( [
			MainConfigNames::AccountCreationThrottle => [
				'count' => 10,
				'seconds' => 86400
			],
			MainConfigNames::TempAccountCreationThrottle => [
				'count' => 1,
				'seconds' => 86400
			],
		] );
		$tuc = $this->getServiceContainer()->getTempUserCreator();
		$status = $tuc->create( null, new FauxRequest() );
		$this->assertSame( '~1', $status->getUser()->getName() );

		// Repeat creating a temporary account, and verify that this fails due to the rate limit.
		$status = $tuc->create( null, new FauxRequest() );
		// TODO: Use new message key (T357777, T357802)
		$this->assertStatusError( 'acct_creation_throttle_hit', $status );
		// If the temporary account creation failed due to the rate limit, then no log entry should have been created.
		$this->assertSame(
			0,
			$this->getDb()->newSelectQueryBuilder()
				->from( 'logging' )
				->join( 'actor', null, 'log_actor=actor_id' )
				->where( [ 'actor_name' => '~2', 'log_action' => 'autocreate' ] )
				->fetchRowCount(),
			'A logging entry indicating the autocreation of ~2 was not expected.'
		);
	}
}
PK       ! kǣ    (  user/TempUser/RealTempUserConfigTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Integration\User\TempUser;

use MediaWiki\Permissions\SimpleAuthority;
use MediaWiki\Tests\MockDatabase;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\User\TempUser\Pattern;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\Rdbms\IExpression;
use Wikimedia\TestingAccessWrapper;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @covers \MediaWiki\User\TempUser\RealTempUserConfig
 * @group Database
 */
class RealTempUserConfigTest extends \MediaWikiIntegrationTestCase {

	use TempUserTestTrait;

	public static function provideIsAutoCreateAction() {
		return [
			'disabled' => [
				'enabled' => false,
				'configOverrides' => [],
				'action' => 'edit',
				'expected' => false
			],
			'disabled by action' => [
				'enabled' => true,
				'configOverrides' => [ 'actions' => [] ],
				'action' => 'edit',
				'expected' => false
			],
			'enabled' => [
				'enabled' => true,
				'configOverrides' => [],
				'action' => 'edit',
				'expected' => true
			],
			// Create isn't an action in the ActionFactory sense, but is is an
			// action in PermissionManager
			'create' => [
				'enabled' => true,
				'configOverrides' => [],
				'action' => 'create',
				'expected' => true
			],
			'unknown action' => [
				'enabled' => true,
				'configOverrides' => [],
				'action' => 'foo',
				'expected' => false
			],
		];
	}

	/**
	 * @dataProvider provideIsAutoCreateAction
	 * @param bool $enabled
	 * @param array $configOverrides
	 * @param string $action
	 * @param bool $expected
	 */
	public function testIsAutoCreateAction( $enabled, $configOverrides, $action, $expected ) {
		if ( $enabled ) {
			$this->enableAutoCreateTempUser( $configOverrides );
		} else {
			$this->disableAutoCreateTempUser( $configOverrides );
		}
		$tuc = $this->getServiceContainer()->getTempUserConfig();
		$this->assertSame( $expected, $tuc->isAutoCreateAction( $action ) );
	}

	public static function provideShouldAutoCreate() {
		return [
			'enabled' => [
				'enabled' => true,
				'id' => 0,
				'rights' => [ 'createaccount' ],
				'action' => 'edit',
				'expected' => true
			],
			'disabled by config' => [
				'enabled' => false,
				'id' => 0,
				'rights' => [ 'createaccount' ],
				'action' => 'edit',
				'expected' => false
			],
			'logged in' => [
				'enabled' => true,
				'id' => 1,
				'rights' => [ 'createaccount' ],
				'action' => 'edit',
				'expected' => false
			],
			'no createaccount right' => [
				'enabled' => true,
				'id' => 0,
				'rights' => [ 'edit' ],
				'action' => 'edit',
				'expected' => false
			],
			'wrong action' => [
				'enabled' => true,
				'id' => 0,
				'rights' => [ 'createaccount' ],
				'action' => 'upload',
				'expected' => false
			],
		];
	}

	/**
	 * @dataProvider provideShouldAutoCreate
	 * @param bool $enabled
	 * @param int $id
	 * @param string[] $rights
	 * @param string $action
	 * @param bool $expected
	 */
	public function testShouldAutoCreate( $enabled, $id, $rights, $action, $expected ) {
		if ( $enabled ) {
			$this->enableAutoCreateTempUser();
		} else {
			$this->disableAutoCreateTempUser();
		}
		$tuc = $this->getServiceContainer()->getTempUserConfig();
		$user = new SimpleAuthority(
			new UserIdentityValue( $id, $id ? 'Test' : '127.0.0.1' ),
			$rights
		);
		$this->assertSame( $expected, $tuc->shouldAutoCreate( $user, $action ) );
	}

	public static function provideIsTempName() {
		return [
			'disabled' => [
				'enabled' => false,
				'name' => '~Some user',
				'expected' => false,
			],
			'default mismatch' => [
				'enabled' => true,
				'name' => 'Test',
				'expected' => false,
			],
			'default match' => [
				'enabled' => true,
				'name' => '~Some user',
				'expected' => true,
			],
		];
	}

	/**
	 * @dataProvider provideIsTempName
	 * @param bool $enabled
	 * @param string $name
	 * @param bool $expected
	 */
	public function testIsTempName( $enabled, $name, $expected ) {
		if ( $enabled ) {
			$this->enableAutoCreateTempUser();
		} else {
			$this->disableAutoCreateTempUser();
		}
		$tuc = $this->getServiceContainer()->getTempUserConfig();
		$this->assertSame( $expected, $tuc->isTempName( $name ) );
	}

	/** @dataProvider provideGetPlaceholderName */
	public function testGetPlaceholderName( $useYear, $expected ) {
		$this->enableAutoCreateTempUser( [ 'serialProvider' => [ 'type' => 'local', 'useYear' => $useYear ] ] );
		ConvertibleTimestamp::setFakeTime( '20210101000000' );
		$this->assertSame( $expected, $this->getServiceContainer()->getTempUserConfig()->getPlaceholderName() );
	}

	public static function provideGetPlaceholderName() {
		return [
			'no year' => [ false, '~*' ],
			'with year' => [ true, '~2021-*' ],
		];
	}

	public static function provideIsReservedName() {
		return [
			'no matchPattern when disabled' => [
				'enabled' => false,
				'configOverrides' => [ 'reservedPattern' => null ],
				'name' => '~39',
				'expected' => false,
			],
			'matchPattern match' => [
				'enabled' => true,
				'configOverrides' => [],
				'name' => '~39',
				'expected' => true,
			],
			'genPattern match' => [
				'enabled' => true,
				'configOverrides' => [ 'matchPattern' => null ],
				'name' => '~39',
				'expected' => true,
			],
			'reservedPattern match with enabled=false' => [
				'enabled' => false,
				'configOverrides' => [ 'reservedPattern' => '~$1' ],
				'name' => '~Foo*',
				'expected' => true
			]
		];
	}

	/**
	 * @dataProvider provideIsReservedName
	 * @param bool $enabled
	 * @param array $configOverrides
	 * @param string $name
	 * @param bool $expected
	 */
	public function testIsReservedName( $enabled, $configOverrides, $name, $expected ) {
		if ( $enabled ) {
			$this->enableAutoCreateTempUser( $configOverrides );
		} else {
			$this->disableAutoCreateTempUser( $configOverrides );
		}
		$tuc = $this->getServiceContainer()->getTempUserConfig();
		$this->assertSame( $expected, $tuc->isReservedName( $name ) );
	}

	public function testGetMatchPatterns() {
		$this->enableAutoCreateTempUser( [ 'matchPattern' => [ '*$1', '~$1' ] ] );
		$tuc = $this->getServiceContainer()->getTempUserConfig();
		$this->assertCount( 2, $tuc->getMatchPatterns() );
		$actualPatterns = array_map( static function ( Pattern $pattern ) {
			return TestingAccessWrapper::newFromObject( $pattern )->pattern;
		}, $tuc->getMatchPatterns() );
		$this->assertArrayEquals( [ '*$1', '~$1' ], $actualPatterns );
	}

	public function testGetMatchPattern() {
		$this->hideDeprecated( 'MediaWiki\User\TempUser\RealTempUserConfig::getMatchPattern' );
		$this->enableAutoCreateTempUser( [ 'matchPattern' => [ '*$1', '~$1' ] ] );
		$tuc = $this->getServiceContainer()->getTempUserConfig();
		$this->assertSame( '*$1', TestingAccessWrapper::newFromObject( $tuc->getMatchPattern() )->pattern );
	}

	public function testGetMatchCondition() {
		$db = new MockDatabase;

		$this->enableAutoCreateTempUser( [ 'matchPattern' => [ '*$1', '~$1' ] ] );
		$tuc = $this->getServiceContainer()->getTempUserConfig();

		$this->assertEquals(
			"(foo LIKE '*%' ESCAPE '`' OR foo LIKE '~%' ESCAPE '`')",
			$tuc->getMatchCondition( $db, 'foo', IExpression::LIKE )->toSql( $db ),
			'LIKE allows any of the patterns'
		);

		$this->assertEquals(
			"(foo NOT LIKE '*%' ESCAPE '`' AND foo NOT LIKE '~%' ESCAPE '`')",
			$tuc->getMatchCondition( $db, 'foo', IExpression::NOT_LIKE )->toSql( $db ),
			'NOT LIKE disallows all of the patterns'
		);

		$this->enableAutoCreateTempUser( [ 'matchPattern' => [ '*$1' ] ] );
		$tuc = $this->getServiceContainer()->getTempUserConfig();

		$this->assertEquals(
			"foo LIKE '*%' ESCAPE '`'",
			$tuc->getMatchCondition( $db, 'foo', IExpression::LIKE )->toSql( $db ),
			'With a single pattern, parentheses are omitted'
		);

		$this->assertEquals(
			"foo NOT LIKE '*%' ESCAPE '`'",
			$tuc->getMatchCondition( $db, 'foo', IExpression::NOT_LIKE )->toSql( $db ),
			'With a single pattern, parentheses are omitted'
		);
	}
}
PK       ! pOc
  
  #  user/TempUser/TempUserTestTrait.phpnu Iw        <?php

namespace MediaWiki\Tests\User\TempUser;

use MediaWiki\MainConfigNames;

/**
 * Helper trait for defining the temporary user configuration settings for tests.
 * This can only be used for test classes that extend MediaWikiIntegrationTestCase.
 *
 * @stable to use
 * @since 1.42
 */
trait TempUserTestTrait {

	/**
	 * Array of default configuration to use in tests.
	 *
	 * @return array
	 */
	private function getTempAccountConfigTestDefaults(): array {
		return [
			'expireAfterDays' => 90,
			'notifyBeforeExpirationDays' => 10,
			'actions' => [ 'edit' ],
			'genPattern' => '~$1',
			'reservedPattern' => '~$1',
			'serialProvider' => [ 'type' => 'local', 'useYear' => true ],
			'serialMapping' => [ 'type' => 'plain-numeric' ],
		];
	}

	/**
	 * Loads configuration that enables the automatic creation of temporary accounts using the defaults
	 * for the generation pattern and match pattern.
	 *
	 * @param array $configOverrides Specify overrides to the default wgAutoCreateTempUser configuration
	 *   setting (all values are the default except 'enabled' which is set to true).
	 * @since 1.42
	 */
	protected function enableAutoCreateTempUser( array $configOverrides = [] ): void {
		$configOverrides['enabled'] = true;
		$this->overrideConfigValue(
			MainConfigNames::TempAccountNameAcquisitionThrottle,
			[ 'count' => 0, 'seconds' => 86400 ]
		);
		$this->overrideConfigValue(
			MainConfigNames::AutoCreateTempUser,
			array_merge( $this->getTempAccountConfigTestDefaults(), $configOverrides )
		);
		$this->setGroupPermissions( '*', 'createaccount', true );
	}

	/**
	 * Disables the automatic creation of temporary accounts for the test.
	 *
	 * This is done to avoid exceptions when a test or the code being tested creates an actor for an IP address.
	 *
	 * @param array $configOverrides Specify overrides to the default wgAutoCreateTempUser configuration
	 *     setting (all values are the default except 'enabled' which is set to false).
	 * @since 1.42
	 */
	protected function disableAutoCreateTempUser( array $configOverrides = [] ): void {
		$configOverrides['enabled'] = false;
		$this->overrideConfigValue(
			MainConfigNames::AutoCreateTempUser,
			array_merge( $this->getTempAccountConfigTestDefaults(), $configOverrides )
		);
	}

	/**
	 * Defined to ensure that the class has the overrideConfigValue method that we can use.
	 *
	 * @see \MediaWikiIntegrationTestCase::overrideConfigValue
	 *
	 * @param string $key
	 * @param mixed $value
	 */
	abstract protected function overrideConfigValue( string $key, $value );
}
PK       ! ~4F,  ,    user/UserFactoryTest.phpnu Iw        <?php

use MediaWiki\Permissions\UltimateAuthority;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentityValue;
use Wikimedia\IPUtils;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IDBAccessObject;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Rdbms\LBFactory;
use Wikimedia\Rdbms\UpdateQueryBuilder;

/**
 * @covers \MediaWiki\User\UserFactory
 * @group Database
 *
 * @author DannyS712
 */
class UserFactoryTest extends MediaWikiIntegrationTestCase {
	use TempUserTestTrait;

	private function getUserFactory() {
		return $this->getServiceContainer()->getUserFactory();
	}

	public function testNewFromName() {
		$name = 'UserFactoryTest1';

		$factory = $this->getUserFactory();
		$user = $factory->newFromName( $name );

		$this->assertInstanceOf( User::class, $user );
		$this->assertSame( $name, $user->getName() );
	}

	public function testNewAnonymous() {
		$factory = $this->getUserFactory();
		$anon = $factory->newAnonymous();
		$this->assertInstanceOf( User::class, $anon );

		$currentIp = IPUtils::sanitizeIP( $anon->getRequest()->getIP() );

		// Unspecified name defaults to current user's IP address
		$this->assertSame( $currentIp, $anon->getName() );

		// FIXME: should be a query count performance assertion instead of this hack
		$this->getServiceContainer()->disableService( 'DBLoadBalancer' );

		$name = '192.0.2.0';
		$anonIpSpecified = $factory->newAnonymous( $name );
		$this->assertSame( $name, $anonIpSpecified->getName() );
		$anonIpSpecified->load(); // no queries expected
	}

	public function testNewFromId() {
		$id = 98257;

		$factory = $this->getUserFactory();
		$user = $factory->newFromId( $id );

		$this->assertInstanceOf( User::class, $user );
		$this->assertSame( $id, $user->getId() );
	}

	public function testNewFromIdentity() {
		$identity = new UserIdentityValue( 98257, __METHOD__ );

		$factory = $this->getUserFactory();
		$user = $factory->newFromUserIdentity( $identity );

		$this->assertInstanceOf( User::class, $user );
		$this->assertSame( $identity->getId(), $user->getId() );
		$this->assertSame( $identity->getName(), $user->getName() );

		// make sure instance caching happens
		$this->assertSame( $user, $factory->newFromUserIdentity( $identity ) );

		// make sure instance caching distingushes between IDs
		$otherIdentity = new UserIdentityValue( 33245, __METHOD__ );
		$this->assertNotSame( $user, $factory->newFromUserIdentity( $otherIdentity ) );
	}

	public function testNewFromActorId() {
		$name = 'UserFactoryTest2';

		$factory = $this->getUserFactory();
		$user1 = $factory->newFromName( $name );
		$user1->addToDatabase();

		$actorId = $user1->getActorId();
		$this->assertGreaterThan(
			0,
			$actorId,
			'Valid actor id for a user'
		);

		$user2 = $factory->newFromActorId( $actorId );
		$this->assertInstanceOf( User::class, $user2 );
		$this->assertSame( $actorId, $user2->getActorId() );
	}

	public function testNewFromUserIdentity() {
		$id = 23560;
		$name = 'UserFactoryTest3';

		$userIdentity = new UserIdentityValue( $id, $name );
		$factory = $this->getUserFactory();

		$user1 = $factory->newFromUserIdentity( $userIdentity );
		$this->assertInstanceOf( User::class, $user1 );
		$this->assertSame( $id, $user1->getId() );
		$this->assertSame( $name, $user1->getName() );

		$user2 = $factory->newFromUserIdentity( $user1 );
		$this->assertInstanceOf( User::class, $user1 );
		$this->assertSame( $user1, $user2 );
	}

	public function testNewFromAnyId() {
		$name = 'UserFactoryTest4';

		$factory = $this->getUserFactory();
		$user1 = $factory->newFromName( $name );
		$user1->addToDatabase();

		$id = $user1->getId();
		$this->assertGreaterThan(
			0,
			$id,
			'Valid user'
		);
		$actorId = $user1->getActorId();
		$this->assertGreaterThan(
			0,
			$actorId,
			'Valid actor id for a user'
		);

		$user2 = $factory->newFromAnyId( $id, null, null );
		$this->assertInstanceOf( User::class, $user2 );
		$this->assertSame( $id, $user2->getId() );

		$user3 = $factory->newFromAnyId( null, $name, null );
		$this->assertInstanceOf( User::class, $user3 );
		$this->assertSame( $name, $user3->getName() );

		$user4 = $factory->newFromAnyId( null, null, $actorId );
		$this->assertInstanceOf( User::class, $user4 );
		$this->assertSame( $actorId, $user4->getActorId() );
	}

	public function testNewFromConfirmationCode() {
		$fakeCode = 'NotARealConfirmationCode';

		$factory = $this->getUserFactory();

		$user1 = $factory->newFromConfirmationCode( $fakeCode );
		$this->assertNull(
			$user1,
			'Invalid confirmation codes result in null users when reading from replicas'
		);

		$user2 = $factory->newFromConfirmationCode( $fakeCode, IDBAccessObject::READ_LATEST );
		$this->assertNull(
			$user2,
			'Invalid confirmation codes result in null users when reading from master'
		);
	}

	// Copied from UserTest
	public function testNewFromRow() {
		// TODO: Create real tests here for loadFromRow
		$row = (object)[];
		$user = $this->getUserFactory()->newFromRow( $row );
		$this->assertInstanceOf( User::class, $user, 'newFromRow returns a user object' );
	}

	public function testNewFromRow_bad() {
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( '$row must be an object' );
		$this->getUserFactory()->newFromRow( [] );
	}

	/**
	 * @covers \MediaWiki\User\UserFactory::newFromAuthority
	 */
	public function testNewFromAuthority() {
		$authority = new UltimateAuthority( new UserIdentityValue( 42, 'Test' ) );
		$user = $this->getUserFactory()->newFromAuthority( $authority );
		$this->assertSame( 42, $user->getId() );
		$this->assertSame( 'Test', $user->getName() );
	}

	public function testNewTempPlaceholder() {
		$this->enableAutoCreateTempUser();
		$user = $this->getUserFactory()->newTempPlaceholder();
		$this->assertTrue( $user->isTemp() );
		$this->assertFalse( $user->isRegistered() );
		$this->assertFalse( $user->isNamed() );
		$this->assertSame( 0, $user->getId() );
	}

	public function testNewUnsavedTempUser() {
		$this->enableAutoCreateTempUser();
		$user = $this->getUserFactory()->newUnsavedTempUser( '~1234' );
		$this->assertTrue( $user->isTemp() );
		$this->assertFalse( $user->isNamed() );
	}

	public function testInvalidateCacheLocal() {
		$userMock = $this->createMock( User::class );
		$userMock->method( 'isRegistered' )->willReturn( true );
		$userMock->method( 'getWikiId' )->willReturn( User::LOCAL );
		$userMock->expects( $this->once() )->method( 'invalidateCache' );

		$this->getUserFactory()->invalidateCache( $userMock );
	}

	public function testInvalidateCacheCrossWiki() {
		$dbMock = $this->createMock( IDatabase::class );
		$dbMock->method( 'timestamp' )->willReturn( 'timestamp' );
		$dbMock->expects( $this->once() )
			->method( 'newUpdateQueryBuilder' )
			->willReturn( new UpdateQueryBuilder( $dbMock ) );
		$dbMock->expects( $this->once() )
			->method( 'update' )
			->with(
				'user',
				[ 'user_touched' => 'timestamp' ],
				[ 'user_id' => 123 ]
			);

		$lbMock = $this->createMock( ILoadBalancer::class );
		$lbMock->method( 'getConnection' )->willReturn( $dbMock );

		$lbFactoryMock = $this->createMock( LBFactory::class );
		$lbFactoryMock->method( 'getMainLB' )->willReturn( $lbMock );
		$this->setService( 'DBLoadBalancerFactory', $lbFactoryMock );

		$user = new UserIdentityValue( 123, 'UserIdentityCacheUpdaterTest', 'meta' );
		$this->getUserFactory()->invalidateCache( $user );
	}

}
PK       ! w    ;  user/Registration/UserRegistrationLookupIntegrationTest.phpnu Iw        <?php

namespace MediaWiki\Tests\User\Registration;

use MediaWiki\Config\ConfigException;
use MediaWiki\MainConfigNames;
use MediaWiki\User\Registration\IUserRegistrationProvider;
use MediaWiki\User\Registration\UserRegistrationLookup;
use MediaWiki\User\UserIdentity;
use MediaWikiIntegrationTestCase;

/**
 * @covers \MediaWiki\User\Registration\UserRegistrationLookup
 * @group Database
 */
class UserRegistrationLookupIntegrationTest extends MediaWikiIntegrationTestCase {
	public function testLocalRequired() {
		$this->expectException( ConfigException::class );

		$this->overrideConfigValue( MainConfigNames::UserRegistrationProviders, [] );
		$this->assertInstanceOf(
			UserRegistrationLookup::class,
			$this->getServiceContainer()->getUserRegistrationLookup()
		);
	}

	public function testLocal() {
		$user = $this->getMutableTestUser()->getUser();
		$dbw = $this->getDb();
		$dbw->newUpdateQueryBuilder()
			->update( 'user' )
			->set( [ 'user_registration' => $dbw->timestamp( '20050101000000' ) ] )
			->where( [ 'user_id' => $user->getId() ] )
			->caller( __METHOD__ )
			->execute();

		$this->assertSame(
			'20050101000000',
			$this->getServiceContainer()->getUserRegistrationLookup()->getRegistration(
				$this->getServiceContainer()->getUserFactory()->newFromName( $user->getName() )
			)
		);
	}

	public function testCustom() {
		$providers = $this->getConfVar( MainConfigNames::UserRegistrationProviders );
		$providers['test-foo'] = [
			'factory' => static function () {
				return new class implements IUserRegistrationProvider {
					/**
					 * @inheritDoc
					 */
					public function fetchRegistration( UserIdentity $user ) {
						return '20230101000000';
					}
				};
			}
		];
		$this->overrideConfigValue( MainConfigNames::UserRegistrationProviders, $providers );

		$user = $this->getTestUser()->getUser();
		$this->assertSame(
			'20230101000000',
			$this->getServiceContainer()->getUserRegistrationLookup()->getRegistration(
				$user,
				'test-foo'
			)
		);
	}
}
PK       ! js    !  StubObject/StubGlobalUserTest.phpnu Iw        <?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
 */

// phpcs:disable MediaWiki.Usage.DeprecatedGlobalVariables.Deprecated$wgUser
use MediaWiki\StubObject\StubGlobalUser;
use MediaWiki\User\User;

/**
 * Tests the MediaWiki\StubObject\StubGlobalUser, including magic support for __get() and __set()
 *
 * @author DannyS712
 *
 * @covers \MediaWiki\StubObject\StubGlobalUser
 */
class StubGlobalUserTest extends MediaWikiIntegrationTestCase {

	/** @var int */
	private $oldErrorLevel;

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

		// Make sure deprecation notices are seen
		$this->oldErrorLevel = error_reporting( -1 );

		// Using User::newFromRow() to avoid needing any integration
		$userFields = [
			'user_id' => 12345,
		];
		$realUser = User::newFromRow( (object)$userFields );
		StubGlobalUser::setUser( $realUser );
	}

	protected function tearDown(): void {
		error_reporting( $this->oldErrorLevel );
		parent::tearDown();
	}

	public function testRealUser() {
		// Should not be emitting deprecation warnings
		global $wgUser;

		$this->assertInstanceOf( StubGlobalUser::class, $wgUser );

		$real = StubGlobalUser::getRealUser( $wgUser );
		$this->assertInstanceOf( User::class, $real );

		$real2 = StubGlobalUser::getRealUser( $real );
		$this->assertSame( $real, $real2 );
	}

	public function testRealUser_exception() {
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage(
			'$globalUser must be a User (or MediaWiki\StubObject\StubGlobalUser), got int'
		);
		StubGlobalUser::getRealUser( 12345 );
	}

	public function testMagicCall() {
		$this->expectDeprecationAndContinue( '/Use of \$wgUser was deprecated in MediaWiki 1\.35/' );
		$this->expectDeprecationAndContinue( '/\$wgUser reassignment detected/' );

		global $wgUser;
		$this->assertInstanceOf(
			StubGlobalUser::class,
			$wgUser,
			'Check: $wgUser should be a MediaWiki\StubObject\StubGlobalUser at the start of the test'
		);
		$this->assertSame(
			12345,
			$wgUser->getId(),
			'__call() based on id set in ::setUp()'
		);
		$this->assertInstanceOf(
			User::class,
			$wgUser,
			'__call() resulted in unstubbing'
		);
	}

	public function testGetMagic() {
		$this->expectDeprecationAndContinue( '/Use of \$wgUser was deprecated in MediaWiki 1\.35/' );
		$this->expectDeprecationAndContinue( '/\$wgUser reassignment detected/' );

		global $wgUser;
		$this->assertInstanceOf(
			StubGlobalUser::class,
			$wgUser,
			'Check: $wgUser should be a MediaWiki\StubObject\StubGlobalUser at the start of the test'
		);
		$this->assertSame(
			12345,
			$wgUser->mId,
			'__get() based on id set in ::setUp()'
		);
		$this->assertInstanceOf(
			User::class,
			$wgUser,
			'__get() resulted in unstubbing'
		);
	}

	public function testSetMagic() {
		// This test is why we need MediaWiki\StubObject\StubGlobalUser::_unstub to override MediaWiki\StubObject\StubObject::_unstub
		// and not try to detect and throw exceptions in unstub loops - for some reason it
		// thinks this creates a loop.

		$this->expectDeprecationAndContinue( '/Use of \$wgUser was deprecated in MediaWiki 1\.35/' );
		$this->expectDeprecationAndContinue( '/\$wgUser reassignment detected/' );

		global $wgUser;
		$this->assertInstanceOf(
			StubGlobalUser::class,
			$wgUser,
			'Check: $wgUser should be a MediaWiki\StubObject\StubGlobalUser at the start of the test'
		);
		$wgUser->mId = 2000;
		$this->assertInstanceOf(
			User::class,
			$wgUser,
			'__set() resulted in unstubbing'
		);
		$this->assertSame(
			2000,
			$wgUser->mId,
			'__set() call worked'
		);
	}

	public function testDeprecationEmittedWhenReassigned() {
		$this->expectDeprecationAndContinue( '/\$wgUser reassignment detected/' );
		global $wgUser;
		$wgUser = new User;
	}

	/**
	 * @doesNotPerformAssertions
	 */
	public function testReassignmentWithRestoring() {
		global $wgUser;
		$oldUser = $wgUser;
		$wgUser = new User;
		$wgUser = $oldUser;
	}

	/**
	 * @doesNotPerformAssertions
	 */
	public function testSetUserNoDeprecation() {
		StubGlobalUser::setUser( new User );
	}
}
PK       ! ;/  /    StubObject/StubObjectTest.phpnu Iw        <?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\StubObject\StubObject;

/**
 * Testing the magic for __get(), __set(), and __call() for our
 * example global, $wgDummy, which would be an instance
 * of DemoStubbed but is wrapped in a MediaWiki\StubObject\StubObject
 * @author DannyS712
 *
 * @covers \MediaWiki\StubObject\StubObject
 */
class StubObjectTest extends MediaWikiIntegrationTestCase {

	/** @var int */
	private $oldErrorLevel;

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

		// Make sure deprecation notices are seen
		$this->oldErrorLevel = error_reporting( -1 );

		global $wgDummy;
		$wgDummy = new StubObject(
			'wgDummy',
			[ __CLASS__, 'factory' ]
		);
	}

	protected function tearDown(): void {
		error_reporting( $this->oldErrorLevel );
		parent::tearDown();
	}

	/**
	 * Static factory method for creating the underlying global, which is
	 * a DemoStubbed with the starting value of 5
	 *
	 * @return DemoStubbed
	 */
	public static function factory(): DemoStubbed {
		return new DemoStubbed( 5 );
	}

	public function testCallMagic() {
		global $wgDummy;
		$this->assertInstanceOf(
			StubObject::class,
			$wgDummy,
			'Global starts as stub object'
		);
		$this->assertSame(
			5,
			$wgDummy->getNum(),
			'__call() based on id set in ::setUp()'
		);
		$this->assertInstanceOf(
			DemoStubbed::class,
			$wgDummy,
			'__call() resulted in unstubbing'
		);
	}

	public function testGetMagic() {
		// MediaWiki\StubObject\StubObject::__get() returning DemoStubbed::$num
		global $wgDummy;
		$this->assertInstanceOf(
			StubObject::class,
			$wgDummy,
			'Global starts as stub object'
		);
		$this->assertSame(
			5,
			$wgDummy->num,
			'__get() based on id set in ::setUp()'
		);
		$this->assertInstanceOf(
			DemoStubbed::class,
			$wgDummy,
			'__get() resulted in unstubbing'
		);
	}

	public function testGetMagic_virtual() {
		// MediaWiki\StubObject\StubObject::__get() calling DemoStubbed::__get()
		global $wgDummy;
		$this->assertInstanceOf(
			StubObject::class,
			$wgDummy,
			'Global starts as stub object'
		);
		$this->assertSame(
			10,
			$wgDummy->doubleNum,
			'__get() a virtual property based on id set in ::setUp()'
		);
		$this->assertInstanceOf(
			DemoStubbed::class,
			$wgDummy,
			'__get() resulted in unstubbing'
		);
	}

	public function testSetMagic() {
		// MediaWiki\StubObject\StubObject::__set() changing DemoStubbed::$num
		global $wgDummy;
		$this->assertInstanceOf(
			StubObject::class,
			$wgDummy,
			'Global starts as stub object'
		);
		$wgDummy->num = 100;
		$this->assertInstanceOf(
			DemoStubbed::class,
			$wgDummy,
			'__set() resulted in unstubbing'
		);
		$this->assertSame(
			100,
			$wgDummy->num,
			'__set() changed the value'
		);
	}

	public function testSetMagic_virtual() {
		// MediaWiki\StubObject\StubObject::__set() calling DemoStubbed::__set()
		global $wgDummy;
		$this->assertInstanceOf(
			StubObject::class,
			$wgDummy,
			'Global starts as stub object'
		);
		$wgDummy->doubleNum = 100;
		$this->assertInstanceOf(
			DemoStubbed::class,
			$wgDummy,
			'__set() resulted in unstubbing'
		);
		$this->assertSame(
			50,
			$wgDummy->num,
			'__set() changed the value'
		);
	}
}

/**
 * This is the object that we are stubbing so we can test the various magic methods
 */
class DemoStubbed {

	/** @var int */
	public $num;

	/**
	 * @param int $num
	 */
	public function __construct( int $num ) {
		$this->num = $num;
	}

	/**
	 * @return int
	 */
	public function getNum(): int {
		return $this->num;
	}

	/**
	 * Magic handling for retrieving fake property "doubleNum"
	 *
	 * @param string $field
	 * @return mixed
	 */
	public function __get( $field ) {
		if ( $field === 'doubleNum' ) {
			return ( 2 * $this->num );
		}
		trigger_error( 'Inaccessible property via __get(): ' . $field, E_USER_NOTICE );
	}

	/**
	 * Magic handling for setting fake property "doubleNum"
	 *
	 * @param string $field
	 * @param mixed $value
	 */
	public function __set( $field, $value ) {
		if ( $field === 'doubleNum' ) {
			$this->num = (int)( $value / 2 );
			return;
		}
		trigger_error( 'Inaccessible property via __set(): ' . $field, E_USER_NOTICE );
	}

}
PK       ! NhZv  v    utils/MWFilePropsTest.phpnu Iw        <?php

use Wikimedia\FileBackend\FileBackend;

/**
 * @covers \MWFileProps
 */
class MWFilePropsTest extends MediaWikiIntegrationTestCase {
	public static function provideGetPropsFromPath() {
		return [
			'nonexistent.png' => [ 'nonexistent.png', [
				'fileExists' => false,
				'size' => 0,
				'file-mime' => null,
				'major_mime' => null,
				'minor_mime' => null,
				'mime' => null,
				'sha1' => '',
				'metadata' => [],
				'width' => 0,
				'height' => 0,
				'bits' => 0,
				'media_type' => 'UNKNOWN',
			] ],
			'zip-kind-of-valid.png' => [ 'zip-kind-of-valid.png', [
				'fileExists' => true,
				'size' => 189,
				'file-mime' => 'application/zip',
				'major_mime' => 'application',
				'minor_mime' => 'zip',
				'mime' => 'application/zip',
				'sha1' => 'rt7k3bexfau9i8jd5z41oxi3fqz7psb',
				'metadata' => [],
				'width' => 0,
				'height' => 0,
				'bits' => 0,
				'media_type' => 'ARCHIVE',
			] ],
			'tinyrgb.jpg' => [ 'tinyrgb.jpg', [
				'width' => 120,
				'height' => 78,
				'bits' => 8,
				'fileExists' => true,
				'size' => 5118,
				'file-mime' => 'image/jpeg',
				'major_mime' => 'image',
				'minor_mime' => 'jpeg',
				'mime' => 'image/jpeg',
				'sha1' => 'iqrl77mbbzax718nogdpirzfodf7meh',
				'metadata' => [
					'JPEGFileComment' => [
						'File source: http://127.0.0.1:8080/wiki/File:Srgb_copy.jpg',
					],
					'MEDIAWIKI_EXIF_VERSION' => 2,
				],
				'media_type' => 'BITMAP',
			] ],
		];
	}

	/**
	 * @dataProvider provideGetPropsFromPath
	 * @param string $relPath
	 * @param array $expected
	 */
	public function testGetPropsFromPath( $relPath, $expected ) {
		$mwfp = new MWFileProps(
			$this->getServiceContainer()->getMimeAnalyzer()
		);
		$path = __DIR__ . '/../../../data/media/' . $relPath;

		$props = $mwfp->getPropsFromPath( $path, FileBackend::extensionFromPath( $path ) );
		$this->assertArrayEquals( $expected, $props, false, true );
	}
}
PK       ! LW    )  cache/HtmlCacheUpdaterIntegrationTest.phpnu Iw        <?php

use MediaWiki\Cache\HTMLCacheUpdater;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\StaticHookRegistry;
use MediaWiki\Page\PageReference;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Title\Title;
use MediaWiki\Title\TitleArrayFromResult;
use Wikimedia\EventRelayer\EventRelayer;
use Wikimedia\EventRelayer\EventRelayerGroup;
use Wikimedia\Rdbms\FakeResultWrapper;

/**
 * @group Cache
 */
class HtmlCacheUpdaterIntegrationTest extends MediaWikiIntegrationTestCase {

	/**
	 * @return HTMLCacheUpdater
	 * @throws Exception
	 */
	private function newHtmlCacheUpdater(): HTMLCacheUpdater {
		$updater = new HTMLCacheUpdater(
			new HookContainer(
				new StaticHookRegistry(),
				$this->getServiceContainer()->getObjectFactory()
			),
			$this->getServiceContainer()->getTitleFactory(),
			1,
			false,
			3
		);
		return $updater;
	}

	private function getEventRelayGroup( array $expected ) {
		if ( !$expected ) {
			$relayer = $this->createNoOpMock( EventRelayer::class );
		} else {
			$relayer = $this->getMockBuilder( EventRelayer::class )
				->disableOriginalConstructor()
				->onlyMethods( [ 'doNotify' ] )
				->getMock();

			$relayer->method( 'doNotify' )->willReturnCallback(
				function ( $channel, array $events ) use ( $expected ) {
					$this->assertSame( 'cdn-url-purges', $channel );

					$this->assertSameSize( $expected, $events );
					foreach ( $expected as $i => $url ) {
						$event = $events[$i];
						$this->assertStringContainsString( $url, $event['url'] );
					}
				}
			);
		}

		$group = $this->createNoOpMock( EventRelayerGroup::class, [ 'getRelayer' ] );
		$group->method( 'getRelayer' )->willReturn( $relayer );

		return $group;
	}

	public static function providePurgeTitleUrls() {
		yield [ [], [] ];

		yield [
			new PageReferenceValue( NS_MAIN, 'Test', PageReference::LOCAL ),
			[ 'Test', '?title=Test&action=history' ]
		];

		yield [
			[
				new PageReferenceValue( NS_MAIN, 'Test1', PageReference::LOCAL ),
				new PageReferenceValue( NS_MAIN, 'Test2', PageReference::LOCAL ),
				new PageReferenceValue( NS_SPECIAL, 'Nope', PageReference::LOCAL ),
				Title::makeTitle( NS_MAIN, '', 'Nope' ),
				Title::makeTitle( NS_MAIN, 'Foo', '', 'nope' ),
			],
			[
				'Test1', '?title=Test1&action=history',
				'Test2', '?title=Test2&action=history'
			]
		];

		yield [
			new TitleArrayFromResult( new FakeResultWrapper( [
				(object)[
					'page_id' => 1,
					'page_namespace' => NS_MAIN,
					'page_title' => 'Test',
				]
			] ) ),
			[ 'Test', '?title=Test&action=history' ]
		];
	}

	/**
	 * @dataProvider providePurgeTitleUrls
	 * @covers \MediaWiki\Cache\HTMLCacheUpdater::purgeTitleUrls
	 */
	public function testPurgeTitleUrls( $pages, $expected ) {
		$this->setService( 'EventRelayerGroup', $this->getEventRelayGroup( $expected ) );

		$updater = $this->newHtmlCacheUpdater();
		$updater->purgeTitleUrls( $pages );
	}

	/**
	 * @covers \MediaWiki\Cache\HTMLCacheUpdater::purgeUrls
	 */
	public function testPurgeUrls() {
		$urls = [ 'https://acme.test/wiki/Foo', 'https://acme.test/wiki/Bar', ];
		$this->setService( 'EventRelayerGroup', $this->getEventRelayGroup( $urls ) );

		$updater = $this->newHtmlCacheUpdater();
		$updater->purgeUrls( $urls );
	}

}
PK       ! }      ExtensionServicesTestBase.phpnu Iw        <?php

declare( strict_types=1 );

namespace MediaWiki\Tests;

use MediaWikiIntegrationTestCase;
use Psr\Container\ContainerInterface;
use ReflectionClass;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionType;

/**
 * Base class for testing ExtensionServices classes.
 *
 * Such classes are used in many extensions to access services more easily.
 * They usually have one method like this for each service they register:
 *
 * ```php
 * public static function getService1( ContainerInterface $services = null ): Service1 {
 * 	return ( $services ?: MediaWikiServices::getInstance() )
 * 		->get( 'ExtensionName.Service1' );
 * }
 * ```
 *
 * To test an ExtensionServices class,
 * create a subclass of this test base class and specify $className and $serviceNamePrefix.
 *
 * @license GPL-2.0-or-later
 */
abstract class ExtensionServicesTestBase extends MediaWikiIntegrationTestCase {

	/**
	 * @var string The name of the ExtensionServices class.
	 * (A fully qualified name, usually specified via ::class syntax.)
	 */
	protected string $className;

	/**
	 * @var string The prefix of the services in the service wiring.
	 * Usually something like 'ExtensionName.'.
	 * @see ExtensionJsonTestBase::$serviceNamePrefix
	 */
	protected string $serviceNamePrefix;

	/**
	 * @var string[] An optional list of service names that,
	 * despite starting with the {@link self::$serviceNamePrefix},
	 * have no corresponding getter method on the ExtensionServices class.
	 * This can be used to temporarily support the old name of a renamed service
	 * for backwards compatibility with other extensions.
	 */
	protected array $serviceNamesWithoutMethods = [];

	/** @dataProvider provideMethods */
	public function testMethodSignature( ReflectionMethod $method ): void {
		$this->assertTrue( $method->isPublic(),
			'service accessor must be public' );
		$this->assertTrue( $method->isStatic(),
			'service accessor must be static' );
		$this->assertStringStartsWith( 'get', $method->getName(),
			'service accessor must be a getter' );
		$this->assertTrue( $method->hasReturnType(),
			'service accessor must declare return type' );
	}

	/** @dataProvider provideMethods */
	public function testMethodWithDefaultServiceContainer( ReflectionMethod $method ): void {
		$methodName = $method->getName();
		$serviceName = $this->serviceNamePrefix . substr( $methodName, strlen( 'get' ) );
		$expectedService = $this->createValue( $method->getReturnType() );
		$this->setService( $serviceName, $expectedService );

		$actualService = $this->className::$methodName();

		$this->assertSame( $expectedService, $actualService,
			'should return service from MediaWikiServices' );
	}

	/** @dataProvider provideMethods */
	public function testMethodWithCustomServiceContainer( ReflectionMethod $method ): void {
		$methodName = $method->getName();
		$serviceName = $this->serviceNamePrefix . substr( $methodName, strlen( 'get' ) );
		$expectedService = $this->createValue( $method->getReturnType() );
		$services = $this->createMock( ContainerInterface::class );
		$services->expects( $this->once() )
			->method( 'get' )
			->with( $serviceName )
			->willReturn( $expectedService );

		$actualService = $this->className::$methodName( $services );

		$this->assertSame( $expectedService, $actualService,
			'should return service from injected container' );
	}

	public function provideMethods(): iterable {
		$reflectionClass = new ReflectionClass( $this->className );
		$methods = $reflectionClass->getMethods();

		foreach ( $methods as $method ) {
			if ( $method->isConstructor() ) {
				continue;
			}
			yield $method->getName() => [ $method ];
		}
	}

	private function createValue( ReflectionType $type ) {
		// (in PHP 8.0, account for $type being a ReflectionUnionType here)
		$this->assertInstanceOf( ReflectionNamedType::class, $type );
		/** @var ReflectionNamedType $type */
		if ( $type->allowsNull() ) {
			return null;
		}
		if ( $type->isBuiltin() ) {
			switch ( $type->getName() ) {
				case 'bool':
					return true;
				case 'int':
					return 0;
				case 'float':
					return 0.0;
				case 'string':
					return '';
				case 'array':
				case 'iterable':
					return [];
				case 'callable':
					return 'is_null';
				default:
					$this->fail( "unknown builtin type {$type->getName()}" );
			}
		}
		return $this->createMock( $type->getName() );
	}

	public function testMethodsExist(): void {
		if ( $this->serviceNamePrefix === '' ) {
			return;
		}

		$reflectionClass = new ReflectionClass( $this->className );
		foreach ( $this->getServiceContainer()->getServiceNames() as $serviceName ) {
			if ( in_array( $serviceName, $this->serviceNamesWithoutMethods, true ) ) {
				continue;
			}
			if ( str_starts_with( $serviceName, $this->serviceNamePrefix ) ) {
				$serviceNameSuffix = substr( $serviceName, strlen( $this->serviceNamePrefix ) );
				$_ = $reflectionClass->getMethod( 'get' . $serviceNameSuffix ); // should not throw
			}
		}

		$this->assertTrue( true, 'test did not throw' );
	}

}
PK       ! V&>	  	    mail/EmailerTest.phpnu Iw        <?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
 */

namespace MediaWiki\Tests\Integration\Mail;

use MailAddress;
use MediaWiki\Mail\Emailer;
use MediaWikiIntegrationTestCase;

/**
 * @group Mail
 * @covers \MediaWiki\Mail\Emailer
 * @covers \UserMailer
 */
class EmailerTest extends MediaWikiIntegrationTestCase {

	/**
	 * Tests the send method with a good address and good parameters.
	 * @dataProvider provideSend
	 */
	public function testSend( $to, MailAddress $from, string $subject, string $bodyText, ?string $bodyHtml = null,
		array $options = [] ) {
		$emailer = new Emailer();
		$status = $emailer->send( $to, $from, $subject, $bodyText, $bodyHtml, $options );
		// The test is successful if the status is good.
		$this->assertTrue( $status->isGood() );
	}

	public function testSendWithBadAddress() {
		// Create a new Emailer object.
		$emailer = new Emailer();
		// Send an email.
		$status = $emailer->send(
			new MailAddress( ' ', 'Sender', 'Real name' ),
			new MailAddress( ' ', 'Recipient', 'Real name' ),
			'',
			'',
		);
		// The test is successful if the status is not good.
		$this->assertFalse( $status->isGood() );
	}

	public function provideSend(): array {
		$from = new MailAddress( 'foo@example.com', 'UserName', 'Real name' );
		$to = new MailAddress( 'bar@example.com', 'UserName', 'Real name' );
		$bodyHtml = '<p>Hello, World!</p>';
		return [
			[ $to, $from, 'Test subject', 'Hello, World!' ],
			[ $to, $from, 'Test subject', 'Hello, World!', $bodyHtml ],
			[ $to, $from, 'Test subject', 'Hello, World!', $bodyHtml, [ 'cc' => [ $from ] ] ],
		];
	}
}
PK       ! [       db/DatabaseSqliteUpgradeTest.phpnu Iw        <?php

use MediaWiki\Installer\DatabaseUpdater;
use MediaWiki\Maintenance\FakeMaintenance;
use Psr\Log\NullLogger;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\DatabaseSqlite;
use Wikimedia\Rdbms\TransactionProfiler;

/**
 * @group sqlite
 * @group Database
 * @group medium
 */
class DatabaseSqliteUpgradeTest extends \MediaWikiIntegrationTestCase {
	/** @var DatabaseSqlite */
	protected $db;

	/** @var array|null */
	protected $currentSchema;

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

		if ( !Sqlite::isPresent() ) {
			$this->markTestSkipped( 'No SQLite support detected' );
		}
		$this->db = $this->newMockDb();
		if ( version_compare( $this->getDb()->getServerVersion(), '3.6.0', '<' ) ) {
			$this->markTestSkipped( "SQLite at least 3.6 required, {$this->getDb()->getServerVersion()} found" );
		}
	}

	/**
	 * @param string|null $version
	 * @return \PHPUnit\Framework\MockObject\MockObject|DatabaseSqlite
	 */
	private function newMockDb( $version = null ) {
		$mock = $this->getMockBuilder( DatabaseSqlite::class )
			->setConstructorArgs( [ [
				'dbFilePath' => ':memory:',
				'dbname' => 'Foo',
				'schema' => null,
				'host' => false,
				'user' => false,
				'password' => false,
				'tablePrefix' => '',
				'cliMode' => true,
				'agent' => 'unit-tests',
				'serverName' => null,
				'flags' => DBO_DEFAULT,
				'variables' => [ 'synchronous' => 'NORMAL', 'temp_store' => 'MEMORY' ],
				'profiler' => null,
				'topologyRole' => Database::ROLE_STREAMING_MASTER,
				'trxProfiler' => new TransactionProfiler(),
				'errorLogger' => null,
				'deprecationLogger' => new NullLogger(),
				'srvCache' => new HashBagOStuff(),
			] ] )->onlyMethods( array_merge(
				[ 'query' ],
				$version ? [ 'getServerVersion' ] : []
			) )->getMock();

		$mock->initConnection();

		$mock->method( 'query' )->willReturn( true );

		if ( $version ) {
			$mock->method( 'getServerVersion' )->willReturn( $version );
		}

		return $mock;
	}

	/**
	 * @coversNothing
	 */
	public function testEntireSchema() {
		$result = Sqlite::checkSqlSyntax( dirname( __FILE__, 6 ) . "/maintenance/tables.sql" );
		if ( $result !== true ) {
			$this->fail( $result );
		}
		$this->assertTrue( true ); // avoid test being marked as incomplete due to lack of assertions
	}

	/**
	 * Runs upgrades of older databases and compares results with current schema
	 * @coversNothing
	 * @param string $version
	 * @dataProvider provideSupportedVersions
	 */
	public function testUpgradeFromVersion( string $version ) {
		$currentSchema =& $this->currentSchema;
		if ( $currentSchema === null ) {
			$currentDB = $this->initAndUpgradeTestDB( null );
			$currentSchema = [];
			foreach ( $this->getTables( $currentDB ) as $table ) {
				$currentSchema[$table] = [
					'columns' => $this->getColumns( $currentDB, $table ),
					'indexes' => $this->getIndexes( $currentDB, $table )
				];
			}
			$currentDB->close();
		}

		// Mismatches for these columns we can safely ignore
		$ignoredColumns = [];

		$versions = "upgrading from $version to " . MW_VERSION;
		$db = $this->initAndUpgradeTestDB( $version );
		$tables = $this->getTables( $db );

		$this->assertEquals( array_keys( $currentSchema ), $tables, "Different tables $versions" );

		foreach ( $tables as $table ) {
			$cols = $this->getColumns( $db, $table );
			$this->assertEquals(
				array_keys( $currentSchema[$table]['columns'] ),
				array_keys( $cols ),
				"Mismatching columns for table \"$table\" $versions"
			);

			foreach ( $currentSchema[$table]['columns'] as $name => $column ) {
				$fullName = "$table.$name";
				$this->assertEquals(
					(bool)$column->pk,
					(bool)$cols[$name]->pk,
					"PRIMARY KEY status does not match for column $fullName $versions"
				);
				if ( !in_array( $fullName, $ignoredColumns ) ) {
					$this->assertEquals(
						(bool)$column->notnull,
						(bool)$cols[$name]->notnull,
						"NOT NULL status does not match for column $fullName $versions"
					);
					if ( $cols[$name]->dflt_value === 'NULL' ) {
						$cols[$name]->dflt_value = null;
					}
					if ( $column->dflt_value === 'NULL' ) {
						$column->dflt_value = null;
					}
					$this->assertEquals(
						$column->dflt_value,
						$cols[$name]->dflt_value,
						"Default values does not match for column $fullName $versions"
					);
				}
			}

			$indexes = $this->getIndexes( $db, $table );
			$this->assertEquals(
				array_keys( $currentSchema[$table]['indexes'] ),
				array_keys( $indexes ),
				"mismatching indexes for table \"$table\" $versions"
			);
		}
	}

	public static function provideSupportedVersions() {
		return [
			[ '1.36' ],
			[ '1.37' ],
			[ '1.38' ],
			[ '1.39' ],
			[ '1.40' ],
			[ '1.41' ],
			[ '1.42' ],
		];
	}

	private function initAndUpgradeTestDB( $version = null ) {
		static $maint = null;

		if ( $maint === null ) {
			$maint = new FakeMaintenance();
			$maint->loadParamsAndArgs( null, [ 'quiet' => 1 ] );
		}

		$p = [ 'variables' => [ 'synchronous' => 'NORMAL', 'temp_store' => 'MEMORY' ] ];
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:', $p );
		if ( $version !== null ) {
			$db->sourceFile( dirname( __FILE__, 6 ) . "/tests/phpunit/data/db/sqlite/tables-$version.sql" );
			$updater = DatabaseUpdater::newForDB( $db, false, $maint );
			$updater->doUpdates( [ 'core' ] );
		} else {
			$db->sourceFile( dirname( __FILE__, 6 ) . "/maintenance/tables.sql" );
			$db->sourceFile( dirname( __FILE__, 6 ) . "/maintenance/sqlite/tables-generated.sql" );
		}

		return $db;
	}

	private function getTables( $db ) {
		$list = array_diff(
			$db->listTables(),
			[
				'external_user', // removed from core in 1.22
				'math', // moved out of core in 1.18
				'trackbacks', // removed from core in 1.19
				'searchindex',
				'searchindex_content',
				'searchindex_segments',
				'searchindex_segdir',
			]
		);
		sort( $list );

		return $list;
	}

	private function getColumns( $db, $table ) {
		$cols = [];
		$res = $db->query( "PRAGMA table_info($table)" );
		$this->assertNotNull( $res );
		foreach ( $res as $col ) {
			$cols[$col->name] = $col;
		}
		ksort( $cols );

		return $cols;
	}

	private function getIndexes( $db, $table ) {
		$indexes = [];
		$res = $db->query( "PRAGMA index_list($table)" );
		$this->assertNotNull( $res );
		foreach ( $res as $index ) {
			$res2 = $db->query( "PRAGMA index_info({$index->name})" );
			$this->assertNotNull( $res2 );
			$index->columns = [];
			foreach ( $res2 as $col ) {
				$index->columns[] = $col;
			}
			$indexes[$index->name] = $index;
		}
		ksort( $indexes );

		return $indexes;
	}
}
PK       ! (\  \    db/DatabaseMysqlTest.phpnu Iw        <?php

use MediaWiki\MediaWikiServices;
use Wikimedia\Rdbms\ChangedTablesTracker;
use Wikimedia\Rdbms\DatabaseMySQL;
use Wikimedia\Rdbms\DBError;
use Wikimedia\Rdbms\DBQueryDisconnectedError;
use Wikimedia\Rdbms\DBQueryError;
use Wikimedia\Rdbms\DBQueryTimeoutError;
use Wikimedia\Rdbms\DBSessionStateError;
use Wikimedia\Rdbms\DBTransactionStateError;
use Wikimedia\Rdbms\DBUnexpectedError;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\Platform\ISQLPlatform;
use Wikimedia\Rdbms\Platform\SQLPlatform;
use Wikimedia\Rdbms\Query;
use Wikimedia\Rdbms\TransactionManager;

/**
 * @covers \Wikimedia\Rdbms\Database
 * @covers \Wikimedia\Rdbms\DatabaseMySQL
 * @covers \Wikimedia\Rdbms\Platform\MySQLPlatform
 * @group mysql
 * @group Database
 * @group medium
 * @requires extension mysqli
 */
class DatabaseMysqlTest extends \MediaWikiIntegrationTestCase {
	/** @var DatabaseMySQL */
	protected $conn;

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

		$lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
		if ( $lb->getServerType( 0 ) !== 'mysql' ) {
			$this->markTestSkipped( 'No MySQL $wgLBFactoryConf config detected' );
		}

		$this->conn = $this->newConnection();
		// FIXME: Tables used by this test aren't parsed correctly, see T344510.
		ChangedTablesTracker::stopTracking();
	}

	protected function tearDown(): void {
		if ( $this->conn ) {
			$this->conn->close( __METHOD__ );
			ChangedTablesTracker::startTracking();
		}

		parent::tearDown();
	}

	public function testQueryTimeout() {
		try {
			$res = $this->conn->query(
				'SET STATEMENT max_statement_time=0.001 FOR SELECT sleep(1) FROM dual',
				__METHOD__
			);
			// if the query did not time out, there should be a single row where sleep() returned 1
			$this->assertSame( 1, $res->numRows() );
			$row = $res->fetchRow();
			$this->assertSame( '1', (string)reset( $row ) );
		} catch ( DBQueryTimeoutError $e ) {
			$this->assertInstanceOf( DBQueryTimeoutError::class, $e );
		}

		$row = $this->conn->query( 'SELECT "x" AS v', __METHOD__ )->fetchObject();
		$this->assertSame( 'x', $row->v, "Still connected/usable" );
	}

	public function testConnectionKill() {
		try {
			$this->conn->query( 'KILL (SELECT connection_id())', __METHOD__ );
			$this->fail( "No DBQueryDisconnectedError caught" );
		} catch ( DBQueryDisconnectedError $e ) {
			$this->assertInstanceOf( DBQueryDisconnectedError::class, $e );
		}

		$row = $this->conn->query( 'SELECT "x" AS v', __METHOD__ )->fetchObject();
		$this->assertSame( 'x', $row->v, "Recovered" );
	}

	public function testConnectionLossQuery() {
		$row = $this->conn->query( 'SELECT connection_id() AS id', __METHOD__ )->fetchObject();
		$encId = intval( $row->id );

		$adminConn = $this->newConnection();
		$adminConn->query( "KILL $encId", __METHOD__ );

		$row = $this->conn->query( 'SELECT "x" AS v', __METHOD__ )->fetchObject();
		$this->assertSame( 'x', $row->v, "Recovered" );

		$this->conn->startAtomic( __METHOD__ );
		$this->assertSame( 1, $this->conn->trxLevel(), "Transaction exists" );
		$row = $this->conn->query( 'SELECT connection_id() AS id', __METHOD__ )->fetchObject();
		$encId = intval( $row->id );

		$adminConn->query( "KILL $encId", __METHOD__ );
		try {
			$this->conn->query( 'SELECT 1', __METHOD__ );
			$this->fail( "No DBQueryDisconnectedError caught" );
		} catch ( DBQueryDisconnectedError $e ) {
			$this->assertTrue( $this->conn->isOpen(), "Reconnected" );
			try {
				$this->conn->endAtomic( __METHOD__ );
				$this->fail( "No DBUnexpectedError caught" );
			} catch ( DBUnexpectedError $e ) {
				$this->assertInstanceOf( DBUnexpectedError::class, $e );
			}

			$this->assertSame( TransactionManager::STATUS_TRX_ERROR, $this->conn->trxStatus() );
			try {
				$this->conn->query( 'SELECT "x" AS v', __METHOD__ )->fetchObject();
				$this->fail( "No DBTransactionStateError caught" );
			} catch ( DBTransactionStateError $e ) {
				$this->assertInstanceOf( DBTransactionStateError::class, $e );
			}

			$this->assertSame( 0, $this->conn->trxLevel(), "Transaction lost" );
			$this->conn->rollback( __METHOD__ );
			$this->assertSame( 0, $this->conn->trxLevel(), "No transaction" );

			$row = $this->conn->query( 'SELECT "x" AS v', __METHOD__ )->fetchObject();
			$this->assertSame( 'x', $row->v, "Recovered" );
		}

		$this->conn->lock( 'session_lock_' . mt_rand(), __METHOD__, 0 );
		$row = $this->conn->query( 'SELECT connection_id() AS id', __METHOD__ )->fetchObject();
		$encId = intval( $row->id );
		$adminConn->query( "KILL $encId", __METHOD__ );
		try {
			$this->conn->query( 'SELECT 1', __METHOD__ );
			$this->fail( "No DBQueryDisconnectedError caught" );
		} catch ( DBQueryDisconnectedError $e ) {
			$this->assertInstanceOf( DBQueryDisconnectedError::class, $e );
		}

		$this->assertTrue( $this->conn->isOpen(), "Reconnected" );
		try {
			$this->conn->query( 'SELECT "x" AS v', __METHOD__ )->fetchObject();
			$this->fail( "No DBSessionStateError caught" );
		} catch ( DBSessionStateError $e ) {
			$this->assertInstanceOf( DBSessionStateError::class, $e );
		}

		$this->assertTrue( $this->conn->isOpen(), "Connection remains" );
		$this->conn->rollback( __METHOD__ );
		$this->conn->flushSession( __METHOD__ );

		$row = $this->conn->query( 'SELECT "x" AS v', __METHOD__ )->fetchObject();
		$this->assertSame( 'x', $row->v, "Recovered" );

		$adminConn->close( __METHOD__ );
	}

	public function testConnectionLossSnapshotFlush() {
		$fakeWriteQuery = new Query( 'SELECT 1', SQLPlatform::QUERY_CHANGE_ROWS, 'SELECT' );

		$this->conn->begin( __METHOD__, IDatabase::TRANSACTION_INTERNAL );
		$row = $this->conn->query( 'SELECT connection_id() AS id', __METHOD__ )->fetchObject();
		$encId = intval( $row->id );

		$adminConn = $this->newConnection();
		$adminConn->query( "KILL $encId", __METHOD__ );

		$this->conn->flushSnapshot( __METHOD__ );
		$this->assertSame( 0, $this->conn->trxLevel(), "Lost connection during snapshot flush" );
		$this->assertSame( TransactionManager::STATUS_TRX_NONE, $this->conn->trxStatus() );

		$this->conn->begin( __METHOD__, IDatabase::TRANSACTION_INTERNAL );
		$this->conn->query( $fakeWriteQuery, __METHOD__ );

		$row = $this->conn->query( 'SELECT connection_id() AS id', __METHOD__ )->fetchObject();
		$encId = intval( $row->id );
		$adminConn->query( "KILL $encId", __METHOD__ );

		try {
			$this->conn->query( $fakeWriteQuery, __METHOD__ );
			$this->fail( "Error thrown due to connection loss with pending writes" );
		} catch ( DBError $e ) {
			$this->assertInstanceOf( DBQueryDisconnectedError::class, $e );
			$this->assertSame( TransactionManager::STATUS_TRX_ERROR, $this->conn->trxStatus() );
		}

		try {
			$this->conn->query( 'SELECT 1', __METHOD__ );
			$this->fail( "Error thrown due to unacknowledged transaction error" );
		} catch ( DBError $e ) {
			$this->assertInstanceOf( DBTransactionStateError::class, $e );
			$this->assertSame( TransactionManager::STATUS_TRX_ERROR, $this->conn->trxStatus() );
		}

		$this->conn->flushSnapshot( __METHOD__ );
		$this->assertSame( 0, $this->conn->trxLevel(), "Snapshot flush after lost writes" );
		$this->assertSame( TransactionManager::STATUS_TRX_NONE, $this->conn->trxStatus() );

		// Get a lock outside of any transaction
		$unlocker = $this->conn->getScopedLockAndFlush( 'testing-key', __METHOD__, 0 );
		// Start transaction after getting the lock
		$this->conn->begin( __METHOD__, IDatabase::TRANSACTION_INTERNAL );

		$row = $this->conn->query( 'SELECT connection_id() AS id', __METHOD__ )->fetchObject();
		$encId = intval( $row->id );
		$adminConn->query( "KILL $encId", __METHOD__ );

		try {
			$this->conn->query( 'SELECT 1', __METHOD__ );
			$this->fail( "Error thrown due to connection loss with locks" );
		} catch ( DBError $e ) {
			$this->assertSame( TransactionManager::STATUS_TRX_ERROR, $this->conn->trxStatus() );
		}

		$this->conn->flushSnapshot( __METHOD__ );
		$this->assertSame( 0, $this->conn->trxLevel(), "Snapshot flush with lost lock" );

		try {
			$this->conn->query( 'SELECT 1', __METHOD__ );
			$this->fail( "Error thrown due to unacknowledged session error" );
		} catch ( DBError $e ) {
			$this->assertInstanceOf( DBSessionStateError::class, $e );
		}

		$adminConn->close( __METHOD__ );
	}

	public function testConnectionLossScopedLock() {
		$row = $this->conn->query( 'SELECT connection_id() AS id', __METHOD__ )->fetchObject();
		$encId = intval( $row->id );

		try {
			( function () use ( $encId ) {
				$unlocker = $this->conn->getScopedLockAndFlush( 'x', 'fn', 1 );

				$adminConn = $this->newConnection();
				$adminConn->query( "KILL $encId" );

				$this->conn->query( "SELECT 1" );
			} )();
			$this->fail( "Expected DBQueryDisconnectedError" );
		} catch ( DBQueryDisconnectedError $e ) {
			// This should report the explicit query that failed,
			// instead of the (later) implicit query from the $unlocker deref.
			$this->assertStringContainsString( "SELECT 1", $e->getMessage() );
		}

		$this->conn->rollback( __METHOD__ );
		$this->conn->unlock( 'x', __METHOD__ );
	}

	public function testTransactionError() {
		$this->assertSame( TransactionManager::STATUS_TRX_NONE, $this->conn->trxStatus() );

		$this->conn->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
		$this->assertSame( TransactionManager::STATUS_TRX_OK, $this->conn->trxStatus() );
		$row = $this->conn->query( 'SELECT connection_id() AS id', __METHOD__ )->fetchObject();
		$encId = intval( $row->id );

		try {
			$this->conn->lock( 'trx_lock_' . mt_rand(), __METHOD__, 0 );
			$this->assertSame( TransactionManager::STATUS_TRX_OK, $this->conn->trxStatus() );
			$this->conn->query( "SELECT invalid query()", __METHOD__ );
			$this->fail( "No DBQueryError caught" );
		} catch ( DBQueryError $e ) {
			$this->assertInstanceOf( DBQueryError::class, $e );
		}

		$this->assertSame( TransactionManager::STATUS_TRX_ERROR, $this->conn->trxStatus() );
		try {
			$this->conn->query( 'SELECT "x" AS v', __METHOD__ )->fetchObject();
			$this->fail( "No DBTransactionStateError caught" );
		} catch ( DBTransactionStateError $e ) {
			$this->assertInstanceOf( DBTransactionStateError::class, $e );
			$this->assertSame( TransactionManager::STATUS_TRX_ERROR, $this->conn->trxStatus() );
			$this->assertSame( 1, $this->conn->trxLevel(), "Transaction remains" );
			$this->assertTrue( $this->conn->isOpen(), "Connection remains" );
		}

		$adminConn = $this->newConnection();
		$adminConn->query( "KILL $encId", __METHOD__ );

		$this->assertSame( TransactionManager::STATUS_TRX_ERROR, $this->conn->trxStatus() );
		try {
			$this->conn->query( 'SELECT 1', __METHOD__ );
			$this->fail( "No DBTransactionStateError caught" );
		} catch ( DBTransactionStateError $e ) {
			$this->assertInstanceOf( DBTransactionStateError::class, $e );
		}

		$this->assertSame(
			1,
			$this->conn->trxLevel(),
			"Transaction loss not yet detected (due to STATUS_TRX_ERROR)"
		);
		$this->assertTrue(
			$this->conn->isOpen(),
			"Connection loss not detected (due to STATUS_TRX_ERROR)"
		);

		$this->conn->cancelAtomic( __METHOD__ );
		$this->assertSame( 0, $this->conn->trxLevel(), "No transaction remains" );
		$this->assertTrue( $this->conn->isOpen(), "Reconnected" );

		$row = $this->conn->query( 'SELECT "x" AS v', __METHOD__ )->fetchObject();
		$this->assertSame( 'x', $row->v, "Recovered" );

		$this->conn->rollback( __METHOD__ );

		$adminConn->close( __METHOD__ );
	}

	private function newConnection(): DatabaseMySQL {
		$lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
		$dbFactory = MediaWikiServices::getInstance()->getDatabaseFactory();
		/** @var DatabaseMySQL $conn */
		$conn = $dbFactory->create(
			'mysql',
			array_merge(
				$lb->getServerInfo( 0 ),
				[
					'dbname' => null,
					'schema' => null,
					'tablePrefix' => '',
				]
			)
		);

		return $conn;
	}

	public function testInsertIdAfterInsert() {
		$dTable = $this->createDestTable();

		$rows = [ [ 'k' => 'Luca', 'v' => mt_rand( 1, 100 ), 't' => time() ] ];

		$this->conn->insert( $dTable, $rows, __METHOD__ );
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 1, $this->conn->insertId() );

		$this->assertSame( 1, (int)$this->conn->selectField( $dTable, 'n', [ 'k' => 'Luca' ] ) );
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 0, $this->conn->insertId() );

		$this->dropDestTable();
	}

	public function testInsertIdAfterInsertIgnore() {
		$dTable = $this->createDestTable();

		$rows = [ [ 'k' => 'Luca', 'v' => mt_rand( 1, 100 ), 't' => time() ] ];

		$this->conn->insert( $dTable, $rows, __METHOD__, 'IGNORE' );
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 1, $this->conn->insertId() );
		$this->assertSame( 1, (int)$this->conn->selectField( $dTable, 'n', [ 'k' => 'Luca' ] ) );

		$this->conn->insert( $dTable, $rows, __METHOD__, 'IGNORE' );
		$this->assertSame( 0, $this->conn->affectedRows() );
		$this->assertSame( 0, $this->conn->insertId() );

		$this->assertSame( 1, (int)$this->conn->selectField( $dTable, 'n', [ 'k' => 'Luca' ] ) );
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 0, $this->conn->insertId() );

		$this->dropDestTable();
	}

	public function testInsertIdAfterReplace() {
		$dTable = $this->createDestTable();

		$rows = [ [ 'k' => 'Luca', 'v' => mt_rand( 1, 100 ), 't' => time() ] ];

		$this->conn->replace( $dTable, 'k', $rows, __METHOD__ );
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 1, $this->conn->insertId() );
		$this->assertSame( 1, (int)$this->conn->selectField( $dTable, 'n', [ 'k' => 'Luca' ] ) );

		$this->conn->replace( $dTable, 'k', $rows, __METHOD__ );
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 2, $this->conn->insertId() );

		$this->assertSame( 2, (int)$this->conn->selectField( $dTable, 'n', [ 'k' => 'Luca' ] ) );
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 0, $this->conn->insertId() );

		$this->dropDestTable();
	}

	public function testInsertIdAfterUpsert() {
		$dTable = $this->createDestTable();

		$rows = [ [ 'k' => 'Luca', 'v' => mt_rand( 1, 100 ), 't' => time() ] ];
		$set = [
			'v = ' . $this->conn->buildExcludedValue( 'v' ),
			't = ' . $this->conn->buildExcludedValue( 't' ) . ' + 1'
		];

		$this->conn->upsert( $dTable, $rows, 'k', $set, __METHOD__ );
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 1, $this->conn->insertId() );
		$this->assertSame( 1, (int)$this->conn->selectField( $dTable, 'n', [ 'k' => 'Luca' ] ) );

		$this->conn->upsert( $dTable, $rows, 'k', $set, __METHOD__ );
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 1, $this->conn->insertId() );

		$this->assertSame( 1, (int)$this->conn->selectField( $dTable, 'n', [ 'k' => 'Luca' ] ) );
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 0, $this->conn->insertId() );

		$this->dropDestTable();
	}

	public function testInsertIdAfterInsertSelect() {
		$sTable = $this->createSourceTable();
		$dTable = $this->createDestTable();

		$rows = [ [ 'sk' => 'Luca', 'sv' => mt_rand( 1, 100 ), 'st' => time() ] ];
		$this->conn->insert( $sTable, $rows, __METHOD__, 'IGNORE' );
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 1, $this->conn->insertId() );
		$this->assertSame( 1, (int)$this->conn->selectField( $sTable, 'sn', [ 'sk' => 'Luca' ] ) );

		$this->conn->insertSelect(
			$dTable,
			$sTable,
			[ 'k' => 'sk', 'v' => 'sv', 't' => 'st' ],
			[ 'sk' => 'Luca' ],
			__METHOD__,
			'IGNORE'
		);
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 1, $this->conn->insertId() );

		$this->assertSame( 1, (int)$this->conn->selectField( $dTable, 'n', [ 'k' => 'Luca' ] ) );
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 0, $this->conn->insertId() );

		$this->dropSourceTable();
		$this->dropDestTable();
	}

	public function testInsertIdAfterInsertSelectIgnore() {
		$sTable = $this->createSourceTable();
		$dTable = $this->createDestTable();

		$rows = [ [ 'sk' => 'Luca', 'sv' => mt_rand( 1, 100 ), 'st' => time() ] ];
		$this->conn->insert( $sTable, $rows, __METHOD__, 'IGNORE' );
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 1, $this->conn->insertId() );
		$this->assertSame( 1, (int)$this->conn->selectField( $sTable, 'sn', [ 'sk' => 'Luca' ] ) );

		$this->conn->insertSelect(
			$dTable,
			$sTable,
			[ 'k' => 'sk', 'v' => 'sv', 't' => 'st' ],
			[ 'sk' => 'Luca' ],
			__METHOD__,
			'IGNORE'
		);
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 1, $this->conn->insertId() );
		$this->assertSame( 1, (int)$this->conn->selectField( $dTable, 'n', [ 'k' => 'Luca' ] ) );

		$this->conn->insertSelect(
			$dTable,
			$sTable,
			[ 'k' => 'sk', 'v' => 'sv', 't' => 'st' ],
			[ 'sk' => 'Luca' ],
			__METHOD__,
			'IGNORE'
		);
		$this->assertSame( 0, $this->conn->affectedRows() );
		$this->assertSame( 0, $this->conn->insertId() );

		$this->assertSame( 1, (int)$this->conn->selectField( $dTable, 'n', [ 'k' => 'Luca' ] ) );
		$this->assertSame( 1, $this->conn->affectedRows() );
		$this->assertSame( 0, $this->conn->insertId() );

		$this->dropSourceTable();
		$this->dropDestTable();
	}

	/**
	 * @coversNothing
	 */
	public function testAffectedRowsAfterUpdateIgnore() {
		$sTable = $this->createSourceTable();

		$rows = [ [ 'sk' => 'Luca', 'sv' => mt_rand( 1, 100 ), 'st' => time() ],
			[ 'sk' => 'Test', 'sv' => mt_rand( 1, 100 ), 'st' => time() ] ];
		$this->conn->insert( $sTable, $rows, __METHOD__, 'IGNORE' );
		$this->assertSame( 2, $this->conn->affectedRows(), 'Test inserted' );

		// Test changing something
		$this->conn->update(
			$sTable,
			[ 'sk' => 'TestRow' ],
			[ 'sk' => 'Test' ],
			__METHOD__,
			[ 'IGNORE' ]
		);
		$this->assertSame( 1, $this->conn->affectedRows(), 'Updated' );

		// Test changing nothing
		$this->conn->update(
			$sTable,
			[ 'sk' => 'TestRow' ],
			[ 'sk' => 'TestRow' ],
			__METHOD__,
			[ 'IGNORE' ]
		);
		$this->assertSame( 1, $this->conn->affectedRows(), 'Nothing changed' );

		// Test nothing found
		$this->conn->update(
			$sTable,
			[ 'sk' => 'TestExistingRow' ],
			[ 'sk' => 'TestNonexistingRow' ],
			__METHOD__,
			[ 'IGNORE' ]
		);
		$this->assertSame( 0, $this->conn->affectedRows(), 'Not found' );

		// Test key conflict on the unique sk field
		$this->conn->update(
			$sTable,
			[ 'sk' => 'TestRow' ],
			[ 'sk' => 'Luca' ],
			__METHOD__,
			[ 'IGNORE' ]
		);
		$this->assertSame( 1, $this->conn->affectedRows(), 'Key conflict, nothing changed on database' );
	}

	public function testFieldAndIndexInfo() {
		global $wgDBname;

		$this->conn->selectDomain( $wgDBname );
		$this->conn->query(
			"CREATE TEMPORARY TABLE tmp_schema_tbl (" .
			"n integer not null auto_increment, " .
			"k varchar(255), " .
			"v integer, " .
			"t integer," .
			"PRIMARY KEY (n)," .
			"UNIQUE INDEX k (k)," .
			"INDEX t (t)" .
			")"
		);

		$this->assertTrue( $this->conn->fieldExists( 'tmp_schema_tbl', 'n' ) );
		$this->assertTrue( $this->conn->fieldExists( 'tmp_schema_tbl', 'k' ) );
		$this->assertTrue( $this->conn->fieldExists( 'tmp_schema_tbl', 'v' ) );
		$this->assertTrue( $this->conn->fieldExists( 'tmp_schema_tbl', 't' ) );
		$this->assertFalse( $this->conn->fieldExists( 'tmp_schema_tbl', 'x' ) );

		$this->assertTrue( $this->conn->indexExists( 'tmp_schema_tbl', 'k' ) );
		$this->assertTrue( $this->conn->indexExists( 'tmp_schema_tbl', 't' ) );
		$this->assertFalse( $this->conn->indexExists( 'tmp_schema_tbl', 'x' ) );
		$this->assertTrue( $this->conn->indexExists( 'tmp_schema_tbl', 'PRIMARY' ) );

		$this->assertTrue( $this->conn->indexUnique( 'tmp_schema_tbl', 'k' ) );
		$this->assertFalse( $this->conn->indexUnique( 'tmp_schema_tbl', 't' ) );
		$this->assertNull( $this->conn->indexUnique( 'tmp_schema_tbl', 'x' ) );
		$this->assertTrue( $this->conn->indexUnique( 'tmp_schema_tbl', 'PRIMARY' ) );
	}

	private function createSourceTable() {
		global $wgDBname;

		$this->conn->query( "DROP TABLE IF EXISTS `$wgDBname`.`tmp_src_tbl`" );
		$this->conn->query(
			"CREATE TEMPORARY TABLE `$wgDBname`.`tmp_src_tbl` (" .
			"sn integer not null unique key auto_increment, " .
			"sk varchar(255) unique, " .
			"sv integer, " .
			"st integer" .
			")"
		);

		return "$wgDBname.tmp_src_tbl";
	}

	private function createDestTable() {
		global $wgDBname;

		$this->conn->query( "DROP TABLE IF EXISTS `$wgDBname`.`tmp_dst_tbl`" );
		$this->conn->query(
			"CREATE TEMPORARY TABLE `$wgDBname`.`tmp_dst_tbl` (" .
			"n integer not null unique key auto_increment, " .
			"k varchar(255) unique, " .
			"v integer, " .
			"t integer" .
			")"
		);

		return "$wgDBname.tmp_dst_tbl";
	}

	private function dropSourceTable() {
		global $wgDBname;

		$this->conn->query( "DROP TEMPORARY TABLE IF EXISTS `$wgDBname`.`tmp_src_tbl`" );
	}

	private function dropDestTable() {
		global $wgDBname;

		$this->conn->query( "DROP TEMPORARY TABLE IF EXISTS `$wgDBname`.`tmp_dst_tbl`" );
	}

	/**
	 * Insert a null value into a field that is not nullable using INSERT IGNORE
	 */
	public function testInsertIgnoreNull() {
		$this->expectException( DBQueryError::class );
		$this->conn->newInsertQueryBuilder()
			->insertInto( 'log_search' )
			->ignore()
			->row( [ 'ls_field' => 'test', 'ls_value' => null, 'ls_log_id' => 1 ] )
			->execute();
	}

	/**
	 * @covers \Wikimedia\Rdbms\DatabaseMySQL::query
	 * @covers \Wikimedia\Rdbms\DatabaseMySQL::dropTable
	 */
	public function testTempTableDomains() {
		global $wgDBname;

		$table = 'temp_test_tbl';

		$this->conn->selectDomain( $wgDBname . '-xxx1_' );
		$this->assertFalse( $this->conn->tableExists( $table ) );

		$query = new Query(
			"CREATE TEMPORARY TABLE " . $this->conn->tableName( $table ) .
			" (n integer not null unique key)",
			ISQLPlatform::QUERY_CHANGE_SCHEMA,
			"CREATE TEMPORARY",
			$table
		);
		$this->conn->query( $query, __METHOD__ );
		$this->assertTrue( $this->conn->tableExists( $table ) );

		$this->conn->selectDomain( $wgDBname . '-xxx2_' );
		$this->assertFalse( $this->conn->tableExists( $table ) );

		$query = new Query(
			"CREATE TEMPORARY TABLE " . $this->conn->tableName( $table ) .
			" (n integer not null unique key)",
			ISQLPlatform::QUERY_CHANGE_SCHEMA,
			"CREATE TEMPORARY",
			$table
		);
		$this->conn->query( $query, __METHOD__ );
		$this->assertTrue( $this->conn->tableExists( $table ) );

		$this->conn->selectDomain( $wgDBname . '-xxx1_' );
		$this->conn->dropTable( $table );
		$this->assertFalse( $this->conn->tableExists( $table ) );

		$this->conn->selectDomain( $wgDBname . '-xxx2_' );
		$this->conn->dropTable( $table );
		$this->assertFalse( $this->conn->tableExists( $table ) );
	}

	/**
	 * @covers \Wikimedia\Rdbms\DatabaseMySQL::query
	 * @covers \Wikimedia\Rdbms\DatabaseMySQL::dropTable
	 */
	public function testTempTableDomainsTableWithDot() {
		global $wgDBname;

		$table = '.temp_test_tbl';

		$this->conn->selectDomain( $wgDBname . '-xxx1_' );
		$this->assertFalse( $this->conn->tableExists( $table ) );

		$query = new Query(
			"CREATE TEMPORARY TABLE " . $this->conn->tableName( $table ) .
			" (n integer not null unique key)",
			ISQLPlatform::QUERY_CHANGE_SCHEMA,
			"CREATE TEMPORARY",
			$table
		);
		$this->conn->query( $query, __METHOD__ );
		$this->assertTrue( $this->conn->tableExists( $table ) );

		$this->conn->selectDomain( $wgDBname . '-xxx2_' );
		$this->assertTrue( $this->conn->tableExists( $table ) );

		$this->conn->dropTable( $table );
		$this->assertFalse( $this->conn->tableExists( $table ) );

		$this->conn->selectDomain( $wgDBname . '-xxx1_' );
		$this->assertFalse( $this->conn->tableExists( $table ) );
	}
}
PK       ! rQ6  Q6    db/DatabasePostgresTest.phpnu Iw        <?php

use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\DatabasePostgres;
use Wikimedia\Rdbms\DBQueryError;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\ScopedCallback;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \Wikimedia\Rdbms\Database
 * @covers \Wikimedia\Rdbms\DatabasePostgres
 * @covers \Wikimedia\Rdbms\Platform\PostgresPlatform
 * @group Database
 */
class DatabasePostgresTest extends MediaWikiIntegrationTestCase {
	private const SRC_TABLE = 'tmp_src_tbl';
	private const DST_TABLE = 'tmp_dst_tbl';

	protected function setUp(): void {
		parent::setUp();
		if ( !$this->getDb() instanceof DatabasePostgres ) {
			$this->markTestSkipped( 'Not PostgreSQL' );
		}
	}

	public function addDBDataOnce() {
		if ( $this->getDb() instanceof DatabasePostgres ) {
			$this->createSourceTable();
			$this->createDestTable();
		}
	}

	private function doTestInsertIgnore() {
		$fname = __METHOD__;
		$reset = new ScopedCallback( function () use ( $fname ) {
			if ( $this->getDb()->explicitTrxActive() ) {
				$this->getDb()->rollback( $fname );
			}
			$this->getDb()->query( 'DROP TABLE IF EXISTS ' . $this->getDb()->tableName( 'foo' ), $fname );
		} );

		$this->getDb()->query(
			"CREATE TEMPORARY TABLE {$this->getDb()->tableName( 'foo' )} (i INTEGER NOT NULL PRIMARY KEY)",
			__METHOD__
		);
		$this->getDb()->insert( 'foo', [ [ 'i' => 1 ], [ 'i' => 2 ] ], __METHOD__ );

		// Normal INSERT IGNORE
		$this->getDb()->begin( __METHOD__ );
		$this->getDb()->insert(
			'foo', [ [ 'i' => 3 ], [ 'i' => 2 ], [ 'i' => 5 ] ], __METHOD__, [ 'IGNORE' ]
		);
		$this->assertSame( 2, $this->getDb()->affectedRows() );
		$this->assertSame(
			[ '1', '2', '3', '5' ],
			$this->getDb()->newSelectQueryBuilder()
				->select( 'i' )
				->from( 'foo' )
				->orderBy( 'i' )
				->caller( __METHOD__ )->fetchFieldValues()
		);
		$this->getDb()->rollback( __METHOD__ );

		// INSERT IGNORE doesn't ignore stuff like NOT NULL violations
		$this->getDb()->begin( __METHOD__ );
		$this->getDb()->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
		try {
			$this->getDb()->insert(
				'foo', [ [ 'i' => 7 ], [ 'i' => null ] ], __METHOD__, [ 'IGNORE' ]
			);
			$this->getDb()->endAtomic( __METHOD__ );
			$this->fail( 'Expected exception not thrown' );
		} catch ( DBQueryError $e ) {
			$this->assertSame( 0, $this->getDb()->affectedRows() );
			$this->getDb()->cancelAtomic( __METHOD__ );
		}
		$this->assertSame(
			[ '1', '2' ],
			$this->getDb()->newSelectQueryBuilder()
				->select( 'i' )
				->from( 'foo' )
				->orderBy( 'i' )
				->caller( __METHOD__ )->fetchFieldValues()
		);
		$this->getDb()->rollback( __METHOD__ );
	}

	/**
	 * FIXME: See https://phabricator.wikimedia.org/T259084.
	 * @group Broken
	 */
	public function testInsertIgnoreOld() {
		if ( $this->getDb()->getServerVersion() < 9.5 ) {
			$this->doTestInsertIgnore();
		} else {
			// Hack version to make it take the old code path
			$w = TestingAccessWrapper::newFromObject( $this->getDb() );
			$oldVer = $w->numericVersion;
			$w->numericVersion = 9.4;
			try {
				$this->doTestInsertIgnore();
			} finally {
				$w->numericVersion = $oldVer;
			}
		}
	}

	/**
	 * FIXME: See https://phabricator.wikimedia.org/T259084.
	 * @group Broken
	 */
	public function testInsertIgnoreNew() {
		if ( $this->getDb()->getServerVersion() < 9.5 ) {
			$this->markTestSkipped( 'PostgreSQL version is ' . $this->getDb()->getServerVersion() );
		}

		$this->doTestInsertIgnore();
	}

	private function doTestInsertSelectIgnore() {
		$fname = __METHOD__;
		$reset = new ScopedCallback( function () use ( $fname ) {
			if ( $this->getDb()->explicitTrxActive() ) {
				$this->getDb()->rollback( $fname );
			}
			$this->getDb()->query( 'DROP TABLE IF EXISTS ' . $this->getDb()->tableName( 'foo' ), $fname );
			$this->getDb()->query( 'DROP TABLE IF EXISTS ' . $this->getDb()->tableName( 'bar' ), $fname );
		} );

		$this->getDb()->query(
			"CREATE TEMPORARY TABLE {$this->getDb()->tableName( 'foo' )} (i INTEGER)",
			__METHOD__
		);
		$this->getDb()->query(
			"CREATE TEMPORARY TABLE {$this->getDb()->tableName( 'bar' )} (i INTEGER NOT NULL PRIMARY KEY)",
			__METHOD__
		);
		$this->getDb()->insert( 'bar', [ [ 'i' => 1 ], [ 'i' => 2 ] ], __METHOD__ );

		// Normal INSERT IGNORE
		$this->getDb()->begin( __METHOD__ );
		$this->getDb()->insert( 'foo', [ [ 'i' => 3 ], [ 'i' => 2 ], [ 'i' => 5 ] ], __METHOD__ );
		$this->getDb()->insertSelect( 'bar', 'foo', [ 'i' => 'i' ], [], __METHOD__, [ 'IGNORE' ] );
		$this->assertSame( 2, $this->getDb()->affectedRows() );
		$this->assertSame(
			[ '1', '2', '3', '5' ],
			$this->getDb()->newSelectQueryBuilder()
				->select( 'i' )
				->from( 'bar' )
				->orderBy( 'i' )
				->caller( __METHOD__ )->fetchFieldValues()
		);
		$this->getDb()->rollback( __METHOD__ );

		// INSERT IGNORE doesn't ignore stuff like NOT NULL violations
		$this->getDb()->begin( __METHOD__ );
		$this->getDb()->insert( 'foo', [ [ 'i' => 7 ], [ 'i' => null ] ], __METHOD__ );
		$this->getDb()->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
		try {
			$this->getDb()->insertSelect( 'bar', 'foo', [ 'i' => 'i' ], [], __METHOD__, [ 'IGNORE' ] );
			$this->getDb()->endAtomic( __METHOD__ );
			$this->fail( 'Expected exception not thrown' );
		} catch ( DBQueryError $e ) {
			$this->assertSame( 0, $this->getDb()->affectedRows() );
			$this->getDb()->cancelAtomic( __METHOD__ );
		}
		$this->assertSame(
			[ '1', '2' ],
			$this->getDb()->newSelectQueryBuilder()
				->select( 'i' )
				->from( 'bar' )
				->orderBy( 'i' )
				->caller( __METHOD__ )->fetchFieldValues()
		);
		$this->getDb()->rollback( __METHOD__ );
	}

	/**
	 * FIXME: See https://phabricator.wikimedia.org/T259084.
	 * @group Broken
	 */
	public function testInsertSelectIgnoreOld() {
		if ( $this->getDb()->getServerVersion() < 9.5 ) {
			$this->doTestInsertSelectIgnore();
		} else {
			// Hack version to make it take the old code path
			$w = TestingAccessWrapper::newFromObject( $this->getDb() );
			$oldVer = $w->numericVersion;
			$w->numericVersion = 9.4;
			try {
				$this->doTestInsertSelectIgnore();
			} finally {
				$w->numericVersion = $oldVer;
			}
		}
	}

	/**
	 * FIXME: See https://phabricator.wikimedia.org/T259084.
	 * @group Broken
	 */
	public function testInsertSelectIgnoreNew() {
		if ( $this->getDb()->getServerVersion() < 9.5 ) {
			$this->markTestSkipped( 'PostgreSQL version is ' . $this->getDb()->getServerVersion() );
		}

		$this->doTestInsertSelectIgnore();
	}

	public function testAttributes() {
		$dbFactory = $this->getServiceContainer()->getDatabaseFactory();
		$this->assertTrue(
			$dbFactory->attributesFromType( 'postgres' )[Database::ATTR_SCHEMAS_AS_TABLE_GROUPS]
		);
	}

	public function testInsertIdAfterInsert() {
		$rows = [ [ 'k' => 'Luca', 'v' => mt_rand( 1, 100 ), 't' => time() ] ];

		$this->getDb()->insert( self::DST_TABLE, $rows, __METHOD__ );
		$this->assertSame( 1, $this->getDb()->affectedRows() );
		$this->assertSame( 1, $this->getDb()->insertId() );

		$this->assertNWhereKEqualsLuca( 1, self::DST_TABLE );
		$this->assertSame( 1, $this->getDb()->affectedRows() );
	}

	public function testInsertIdAfterInsertIgnore() {
		$rows = [ [ 'k' => 'Luca', 'v' => mt_rand( 1, 100 ), 't' => time() ] ];

		$this->getDb()->insert( self::DST_TABLE, $rows, __METHOD__, 'IGNORE' );
		$this->assertSame( 1, $this->getDb()->affectedRows() );
		$this->assertSame( 1, $this->getDb()->insertId() );
		$this->assertNWhereKEqualsLuca( 1, self::DST_TABLE );

		$this->getDb()->insert( self::DST_TABLE, $rows, __METHOD__, 'IGNORE' );
		$this->assertSame( 0, $this->getDb()->affectedRows() );
		$this->assertSame( 0, $this->getDb()->insertId() );

		$this->assertNWhereKEqualsLuca( 1, self::DST_TABLE );
		$this->assertSame( 1, $this->getDb()->affectedRows() );
	}

	public function testInsertIdAfterReplace() {
		$rows = [ [ 'k' => 'Luca', 'v' => mt_rand( 1, 100 ), 't' => time() ] ];

		$this->getDb()->replace( self::DST_TABLE, 'k', $rows, __METHOD__ );
		$this->assertSame( 1, $this->getDb()->affectedRows() );
		$this->assertSame( 1, $this->getDb()->insertId() );
		$this->assertNWhereKEqualsLuca( 1, self::DST_TABLE );

		$this->getDb()->replace( self::DST_TABLE, 'k', $rows, __METHOD__ );
		$this->assertSame( 1, $this->getDb()->affectedRows() );
		$this->assertSame( 2, $this->getDb()->insertId() );

		$this->assertNWhereKEqualsLuca( 2, self::DST_TABLE );
		$this->assertSame( 1, $this->getDb()->affectedRows() );
	}

	public function testInsertIdAfterUpsert() {
		$rows = [ [ 'k' => 'Luca', 'v' => mt_rand( 1, 100 ), 't' => time() ] ];
		$set = [
			'v = ' . $this->getDb()->buildExcludedValue( 'v' ),
			't = ' . $this->getDb()->buildExcludedValue( 't' ) . ' + 1'
		];

		$this->getDb()->upsert( self::DST_TABLE, $rows, 'k', $set, __METHOD__ );
		$this->assertSame( 1, $this->getDb()->affectedRows() );
		$this->assertSame( 1, $this->getDb()->insertId() );
		$this->assertNWhereKEqualsLuca( 1, self::DST_TABLE );

		$this->getDb()->upsert( self::DST_TABLE, $rows, 'k', $set, __METHOD__ );
		$this->assertSame( 1, $this->getDb()->affectedRows() );
		$this->assertSame( 1, $this->getDb()->insertId() );

		$this->assertNWhereKEqualsLuca( 1, self::DST_TABLE );
		$this->assertSame( 1, $this->getDb()->affectedRows() );
	}

	public function testInsertIdAfterInsertSelect() {
		$rows = [ [ 'sk' => 'Luca', 'sv' => mt_rand( 1, 100 ), 'st' => time() ] ];
		$this->getDb()->insert( self::SRC_TABLE, $rows, __METHOD__, 'IGNORE' );
		$this->assertSame( 1, $this->getDb()->affectedRows() );
		$this->assertSame( 1, $this->getDb()->insertId() );
		$this->assertSame( 1, (int)$this->getDb()->newSelectQueryBuilder()
			->select( 'sn' )
			->from( self::SRC_TABLE )
			->where( [ 'sk' => 'Luca' ] )
			->fetchField() );

		$this->getDb()->insertSelect(
			self::DST_TABLE,
			self::SRC_TABLE,
			[ 'k' => 'sk', 'v' => 'sv', 't' => 'st' ],
			[ 'sk' => 'Luca' ],
			__METHOD__,
			'IGNORE'
		);
		$this->assertSame( 1, $this->getDb()->affectedRows() );
		$this->assertSame( 1, $this->getDb()->insertId() );

		$this->assertNWhereKEqualsLuca( 1, self::DST_TABLE );
		$this->assertSame( 1, $this->getDb()->affectedRows() );
	}

	public function testInsertIdAfterInsertSelectIgnore() {
		$rows = [ [ 'sk' => 'Luca', 'sv' => mt_rand( 1, 100 ), 'st' => time() ] ];
		$this->getDb()->insert( self::SRC_TABLE, $rows, __METHOD__, 'IGNORE' );
		$this->assertSame( 1, $this->getDb()->affectedRows() );
		$this->assertSame( 1, $this->getDb()->insertId() );
		$this->assertSame( 1, (int)$this->getDb()->newSelectQueryBuilder()
			->select( 'sn' )
			->from( self::SRC_TABLE )
			->where( [ 'sk' => 'Luca' ] )
			->fetchField() );

		$this->getDb()->insertSelect(
			self::DST_TABLE,
			self::SRC_TABLE,
			[ 'k' => 'sk', 'v' => 'sv', 't' => 'st' ],
			[ 'sk' => 'Luca' ],
			__METHOD__,
			'IGNORE'
		);
		$this->assertSame( 1, $this->getDb()->affectedRows() );
		$this->assertSame( 1, $this->getDb()->insertId() );
		$this->assertNWhereKEqualsLuca( 1, self::DST_TABLE );

		$this->getDb()->insertSelect(
			self::DST_TABLE,
			self::SRC_TABLE,
			[ 'k' => 'sk', 'v' => 'sv', 't' => 'st' ],
			[ 'sk' => 'Luca' ],
			__METHOD__,
			'IGNORE'
		);
		$this->assertSame( 0, $this->getDb()->affectedRows() );
		$this->assertSame( 0, $this->getDb()->insertId() );

		$this->assertNWhereKEqualsLuca( 1, self::DST_TABLE );
		$this->assertSame( 1, $this->getDb()->affectedRows() );
	}

	public function testFieldAndIndexInfo() {
		global $wgDBname;

		$this->db->selectDomain( $wgDBname );
		$this->db->query(
			"CREATE TEMPORARY TABLE tmp_schema_tbl (" .
			"n serial not null, " .
			"k text not null, " .
			"v integer, " .
			"t integer, " .
			"PRIMARY KEY(n)" .
			")"
		);
		$this->db->query( "CREATE UNIQUE INDEX k ON tmp_schema_tbl (k)" );
		$this->db->query( "CREATE INDEX t ON tmp_schema_tbl (t)" );

		$this->assertTrue( $this->db->fieldExists( 'tmp_schema_tbl', 'n' ) );
		$this->assertTrue( $this->db->fieldExists( 'tmp_schema_tbl', 'k' ) );
		$this->assertTrue( $this->db->fieldExists( 'tmp_schema_tbl', 'v' ) );
		$this->assertTrue( $this->db->fieldExists( 'tmp_schema_tbl', 't' ) );
		$this->assertFalse( $this->db->fieldExists( 'tmp_schema_tbl', 'x' ) );

		$this->assertTrue( $this->db->indexExists( 'tmp_schema_tbl', 'k' ) );
		$this->assertTrue( $this->db->indexExists( 'tmp_schema_tbl', 't' ) );
		$this->assertFalse( $this->db->indexExists( 'tmp_schema_tbl', 'x' ) );
		$this->assertFalse( $this->db->indexExists( 'tmp_schema_tbl', 'PRIMARY' ) );
		$this->assertTrue( $this->db->indexExists( 'tmp_schema_tbl', 'tmp_schema_tbl_pkey' ) );

		$this->assertTrue( $this->db->indexUnique( 'tmp_schema_tbl', 'k' ) );
		$this->assertFalse( $this->db->indexUnique( 'tmp_schema_tbl', 't' ) );
		$this->assertNull( $this->db->indexUnique( 'tmp_schema_tbl', 'x' ) );
		$this->assertNull( $this->db->indexUnique( 'tmp_schema_tbl', 'PRIMARY' ) );
		$this->assertTrue( $this->db->indexExists( 'tmp_schema_tbl', 'tmp_schema_tbl_pkey' ) );
	}

	private function assertNWhereKEqualsLuca( $expected, $table ) {
		$this->assertSame( $expected, (int)$this->getDb()->newSelectQueryBuilder()
			->select( 'n' )
			->from( $table )
			->where( [ 'k' => 'Luca' ] )
			->fetchField() );
	}

	private function createSourceTable() {
		$encTable = $this->getDb()->tableName( 'tmp_src_tbl' );

		$this->getDb()->query( "DROP TABLE IF EXISTS $encTable" );
		$this->getDb()->query(
			"CREATE TEMPORARY TABLE $encTable (" .
			"sn serial not null, " .
			"sk text unique not null, " .
			"sv integer, " .
			"st integer, " .
			"PRIMARY KEY(sn)" .
			")"
		);
	}

	private function createDestTable() {
		$encTable = $this->getDb()->tableName( 'tmp_dst_tbl' );

		$this->getDb()->query( "DROP TABLE IF EXISTS $encTable" );
		$this->getDb()->query(
			"CREATE TEMPORARY TABLE $encTable (" .
			"n serial not null, " .
			"k text unique not null, " .
			"v integer, " .
			"t integer, " .
			"PRIMARY KEY(n)" .
			")"
		);
	}
}
PK       ! XH  H    db/DatabaseSqliteTest.phpnu Iw        <?php

use Psr\Log\NullLogger;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\Rdbms\Blob;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\DatabaseSqlite;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IResultWrapper;
use Wikimedia\Rdbms\Query;
use Wikimedia\Rdbms\ResultWrapper;
use Wikimedia\Rdbms\TransactionProfiler;

/**
 * @covers \Wikimedia\Rdbms\Database
 * @covers \Wikimedia\Rdbms\DatabaseSqlite
 * @covers \Wikimedia\Rdbms\Platform\SqlitePlatform
 * @group sqlite
 * @group Database
 * @group medium
 */
class DatabaseSqliteTest extends \MediaWikiIntegrationTestCase {
	/** @var DatabaseSqlite */
	protected $db;

	/** @var array|null */
	protected $currentTableInfo;

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

		if ( !Sqlite::isPresent() ) {
			$this->markTestSkipped( 'No SQLite support detected' );
		}
		$this->db = $this->newMockDb();
		if ( version_compare( $this->getDb()->getServerVersion(), '3.6.0', '<' ) ) {
			$this->markTestSkipped( "SQLite at least 3.6 required, {$this->getDb()->getServerVersion()} found" );
		}
	}

	/**
	 * @param string|null $version
	 * @param string|null &$sqlDump
	 * @return \PHPUnit\Framework\MockObject\MockObject|DatabaseSqlite
	 */
	private function newMockDb( $version = null, &$sqlDump = null ) {
		$mock = $this->getMockBuilder( DatabaseSqlite::class )
			->setConstructorArgs( [ [
				'dbFilePath' => ':memory:',
				'dbname' => 'Foo',
				'schema' => null,
				'host' => false,
				'user' => false,
				'password' => false,
				'tablePrefix' => '',
				'cliMode' => true,
				'agent' => 'unit-tests',
				'serverName' => null,
				'flags' => DBO_DEFAULT,
				'variables' => [ 'synchronous' => 'NORMAL', 'temp_store' => 'MEMORY' ],
				'profiler' => null,
				'topologyRole' => Database::ROLE_STREAMING_MASTER,
				'trxProfiler' => new TransactionProfiler(),
				'errorLogger' => null,
				'deprecationLogger' => new NullLogger(),
				'srvCache' => new HashBagOStuff(),
			] ] )->onlyMethods( array_merge(
				[ 'query' ],
				$version ? [ 'getServerVersion' ] : []
			) )->getMock();

		$mock->initConnection();

		$sqlDump = '';
		$mock->method( 'query' )->willReturnCallback( static function ( $sql ) use ( &$sqlDump ) {
			if ( $sql instanceof Query ) {
				$sql = $sql->getSQL();
			}
			$sqlDump .= "$sql;";

			return true;
		} );

		if ( $version ) {
			$mock->method( 'getServerVersion' )->willReturn( $version );
		}

		return $mock;
	}

	private function assertResultIs( $expected, $res ) {
		$this->assertNotNull( $res );
		$i = 0;
		foreach ( $res as $row ) {
			foreach ( $expected[$i] as $key => $value ) {
				$this->assertTrue( isset( $row->$key ) );
				$this->assertEquals( $value, $row->$key );
			}
			$i++;
		}
		$this->assertEquals( count( $expected ), $i, 'Unexpected number of rows' );
	}

	public static function provideAddQuotes() {
		return [
			[ // #0: empty
				'', "''"
			],
			[ // #1: simple
				'foo bar', "'foo bar'"
			],
			[ // #2: including quote
				'foo\'bar', "'foo''bar'"
			],
			// #3: including \0 (must be represented as hex, per https://bugs.php.net/bug.php?id=63419)
			[
				"x\0y",
				"x'780079'",
			],
			[ // #4: blob object (must be represented as hex)
				new Blob( "hello" ),
				"x'68656c6c6f'",
			],
			[ // #5: null
				null,
				"''",
			],
		];
	}

	/**
	 * @dataProvider provideAddQuotes()
	 */
	public function testAddQuotes( $value, $expected ) {
		// check quoting
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
		$this->assertEquals( $expected, $db->addQuotes( $value ), 'string not quoted as expected' );

		// ok, quoting works as expected, now try a round trip.
		$re = $db->query( 'select ' . $db->addQuotes( $value ) );

		$this->assertInstanceOf( IResultWrapper::class, $re, 'query failed' );

		$row = $re->fetchRow();
		if ( $row ) {
			if ( $value instanceof Blob ) {
				$value = $value->fetch();
			}

			$this->assertEquals( $value, $row[0], 'string mangled by the database' );
		} else {
			$this->fail( 'query returned no result' );
		}
	}

	public function testDuplicateTableStructure() {
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
		$db->query( 'CREATE TABLE foo(foo, barfoo)' );
		$db->query( 'CREATE INDEX index1 ON foo(foo)' );
		$db->query( 'CREATE UNIQUE INDEX index2 ON foo(barfoo)' );

		$db->duplicateTableStructure( 'foo', 'bar' );
		$this->assertEquals( 'CREATE TABLE "bar"(foo, barfoo)',
			$db->newSelectQueryBuilder()
				->select( 'sql' )
				->from( 'sqlite_master' )
				->where( [ 'name' => 'bar' ] )
				->caller( __METHOD__ )->fetchField(),
			'Normal table duplication'
		);
		$indexList = $db->query( 'PRAGMA INDEX_LIST("bar")' );
		$index = $indexList->fetchObject();
		$this->assertEquals( 'bar_index1', $index->name );
		$this->assertSame( '0', (string)$index->unique );
		$index = $indexList->fetchObject();
		$this->assertEquals( 'bar_index2', $index->name );
		$this->assertSame( '1', (string)$index->unique );

		$db->duplicateTableStructure( 'foo', 'baz', true );
		$this->assertEquals( 'CREATE TABLE "baz"(foo, barfoo)',
			$db->newSelectQueryBuilder()
				->select( 'sql' )
				->from( 'sqlite_temp_master' )
				->where( [ 'name' => 'baz' ] )
				->caller( __METHOD__ )->fetchField(),
			'Creation of temporary duplicate'
		);
		$indexList = $db->query( 'PRAGMA INDEX_LIST("baz")' );
		$index = $indexList->fetchObject();
		$this->assertEquals( 'baz_index1', $index->name );
		$this->assertSame( '0', (string)$index->unique );
		$index = $indexList->fetchObject();
		$this->assertEquals( 'baz_index2', $index->name );
		$this->assertSame( '1', (string)$index->unique );
		$this->assertSame( '0',
			(string)$db->newSelectQueryBuilder()
				->select( 'COUNT(*)' )
				->from( 'sqlite_master' )
				->where( [ 'name' => 'baz' ] )
				->caller( __METHOD__ )->fetchField(),
			'Create a temporary duplicate only'
		);
	}

	public function testDuplicateTableStructureVirtual() {
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
		if ( $db->getFulltextSearchModule() != 'FTS3' ) {
			$this->markTestSkipped( 'FTS3 not supported, cannot create virtual tables' );
		}
		$db->query( 'CREATE VIRTUAL TABLE "foo" USING FTS3(foobar)' );

		$db->duplicateTableStructure( 'foo', 'bar' );
		$this->assertEquals( 'CREATE VIRTUAL TABLE "bar" USING FTS3(foobar)',
			$db->newSelectQueryBuilder()
				->select( 'sql' )
				->from( 'sqlite_master' )
				->where( [ 'name' => 'bar' ] )
				->caller( __METHOD__ )->fetchField(),
			'Duplication of virtual tables'
		);

		$db->duplicateTableStructure( 'foo', 'baz', true );
		$this->assertEquals( 'CREATE VIRTUAL TABLE "baz" USING FTS3(foobar)',
			$db->newSelectQueryBuilder()
				->select( 'sql' )
				->from( 'sqlite_master' )
				->where( [ 'name' => 'baz' ] )
				->caller( __METHOD__ )->fetchField(),
			"Can't create temporary virtual tables, should fall back to non-temporary duplication"
		);
	}

	public function testDeleteJoin() {
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
		$db->query( 'CREATE TABLE a (a_1)', __METHOD__ );
		$db->query( 'CREATE TABLE b (b_1, b_2)', __METHOD__ );
		$db->insert( 'a', [
			[ 'a_1' => 1 ],
			[ 'a_1' => 2 ],
			[ 'a_1' => 3 ],
		],
			__METHOD__
		);
		$db->insert( 'b', [
			[ 'b_1' => 2, 'b_2' => 'a' ],
			[ 'b_1' => 3, 'b_2' => 'b' ],
		],
			__METHOD__
		);
		$db->deleteJoin( 'a', 'b', 'a_1', 'b_1', [ 'b_2' => 'a' ], __METHOD__ );
		$res = $db->query( "SELECT * FROM a", __METHOD__ );
		$this->assertResultIs( [
			[ 'a_1' => 1 ],
			[ 'a_1' => 3 ],
		],
			$res
		);
	}

	/**
	 * @coversNothing
	 */
	public function testEntireSchema() {
		global $IP;

		$result = Sqlite::checkSqlSyntax( "$IP/maintenance/sqlite/tables-generated.sql" );

		$this->assertTrue( $result, $result );
	}

	public function testInsertIdType() {
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );

		$databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ );
		$this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Database creation" );

		$db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ );
		$this->assertIsInt( $db->insertId(), "Actual typecheck" );
		$this->assertTrue( $db->close(), "closing database" );
	}

	public function testInsertAffectedRows() {
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
		$db->query( 'CREATE TABLE testInsertAffectedRows ( foo )', __METHOD__ );

		$db->insert(
			'testInsertAffectedRows',
			[
				[ 'foo' => 10 ],
				[ 'foo' => 12 ],
				[ 'foo' => 1555 ],
			],
			__METHOD__
		);

		$this->assertSame( 3, $db->affectedRows() );
		$this->assertTrue( $db->close(), "closing database" );
	}

	/**
	 * @coversNothing
	 */
	public function testCaseInsensitiveLike() {
		// TODO: Test this for all databases
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
		$res = $db->query( 'SELECT "a" LIKE "A" AS a' );
		$row = $res->fetchRow();
		$this->assertFalse( (bool)$row['a'] );
	}

	public function testToString() {
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );

		$toString = (string)$db;

		$this->assertStringContainsString( 'sqlite object', $toString );
	}

	public function testsAttributes() {
		$dbFactory = $this->getServiceContainer()->getDatabaseFactory();
		$this->assertTrue( $dbFactory->attributesFromType( 'sqlite' )[Database::ATTR_DB_LEVEL_LOCKING] );
	}

	/**
	 * @param string $version
	 * @param string $table
	 * @param array $rows
	 * @param string $expectedSql
	 * @dataProvider provideNativeInserts
	 */
	public function testNativeInsertSupport( $version, $table, $rows, $expectedSql ) {
		$sqlDump = '';
		$db = $this->newMockDb( $version, $sqlDump );
		$db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ );

		$sqlDump = '';
		$db->insert( $table, $rows, __METHOD__ );
		$this->assertEquals( $expectedSql, $sqlDump );
	}

	public static function provideNativeInserts() {
		return [
			[
				'3.8.0',
				'a',
				[ 'a_1' => 1 ],
				'INSERT INTO "a" (a_1) VALUES (1);'
			],
			[
				'3.8.0',
				'a',
				[
					[ 'a_1' => 2 ],
					[ 'a_1' => 3 ]
				],
				'INSERT INTO "a" (a_1) VALUES (2),(3);'
			],
		];
	}

	/**
	 * @param string $version
	 * @param string $table
	 * @param array $ukeys
	 * @param array $rows
	 * @param string $expectedSql
	 * @dataProvider provideNativeReplaces
	 */
	public function testNativeReplaceSupport( $version, $table, $ukeys, $rows, $expectedSql ) {
		$sqlDump = '';
		$db = $this->newMockDb( $version, $sqlDump );
		$db->query( 'CREATE TABLE a ( a_1 PRIMARY KEY, a_2 )', __METHOD__ );

		$sqlDump = '';
		$db->replace( $table, $ukeys, $rows, __METHOD__ );
		$this->assertEquals( $expectedSql, $sqlDump );
	}

	public static function provideNativeReplaces() {
		return [
			[
				'3.8.0',
				'a',
				[ 'a_1' ],
				[ 'a_1' => 1, 'a_2' => 'x' ],
				'REPLACE INTO "a" (a_1,a_2) VALUES (1,\'x\');'
			],
			[
				'3.8.0',
				'a',
				[ 'a_1' ],
				[
					[ 'a_1' => 2, 'a_2' => 'x' ],
					[ 'a_1' => 3, 'a_2' => 'y' ]
				],
				'REPLACE INTO "a" (a_1,a_2) VALUES (2,\'x\'),(3,\'y\');'
			],
		];
	}

	public function testInsertIdAfterInsert() {
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
		$dTable = $this->createDestTable( $db );

		$rows = [ [ 'k' => 'Luca', 'v' => mt_rand( 1, 100 ), 't' => time() ] ];

		$db->insert( $dTable, $rows, __METHOD__ );
		$this->assertSame( 1, $db->affectedRows() );
		$this->assertSame( 1, $db->insertId() );

		$this->assertNWhereKEqualsLuca( 1, $dTable, $db );
		$this->assertSame( 0, $db->affectedRows() );
		$this->assertSame( 0, $db->insertId() );
	}

	public function testInsertIdAfterInsertIgnore() {
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
		$dTable = $this->createDestTable( $db );

		$rows = [ [ 'k' => 'Luca', 'v' => mt_rand( 1, 100 ), 't' => time() ] ];

		$db->insert( $dTable, $rows, __METHOD__, 'IGNORE' );
		$this->assertSame( 1, $db->affectedRows() );
		$this->assertSame( 1, $db->insertId() );
		$this->assertNWhereKEqualsLuca( 1, $dTable, $db );

		$db->insert( $dTable, $rows, __METHOD__, 'IGNORE' );
		$this->assertSame( 0, $db->affectedRows() );
		$this->assertSame( 0, $db->insertId() );

		$this->assertNWhereKEqualsLuca( 1, $dTable, $db );
		$this->assertSame( 0, $db->affectedRows() );
		$this->assertSame( 0, $db->insertId() );
	}

	public function testInsertIdAfterReplace() {
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
		$dTable = $this->createDestTable( $db );

		$rows = [ [ 'k' => 'Luca', 'v' => mt_rand( 1, 100 ), 't' => time() ] ];

		$db->replace( $dTable, 'k', $rows, __METHOD__ );
		$this->assertSame( 1, $db->affectedRows() );
		$this->assertSame( 1, $db->insertId() );
		$this->assertNWhereKEqualsLuca( 1, $dTable, $db );

		$db->replace( $dTable, 'k', $rows, __METHOD__ );
		$this->assertSame( 1, $db->affectedRows() );
		$this->assertSame( 2, $db->insertId() );

		$this->assertNWhereKEqualsLuca( 2, $dTable, $db );
		$this->assertSame( 0, $db->affectedRows() );
		$this->assertSame( 0, $db->insertId() );
	}

	public function testInsertIdAfterUpsert() {
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
		$dTable = $this->createDestTable( $db );

		$rows = [ [ 'k' => 'Luca', 'v' => mt_rand( 1, 100 ), 't' => time() ] ];
		$otherRows = [ [ 'k' => 'Skylar', 'v' => mt_rand( 1, 100 ), 't' => time() ] ];
		$set = [
			'v = ' . $db->buildExcludedValue( 'v' ),
			't = ' . $db->buildExcludedValue( 't' ) . ' + 1'
		];

		$db->upsert( $dTable, $rows, 'k', $set, __METHOD__ );
		$this->assertSame( 1, $db->affectedRows() );
		$this->assertSame( 1, $db->insertId() );
		$this->assertNWhereKEqualsLuca( 1, $dTable, $db );

		$db->upsert( $dTable, $otherRows, 'k', $set, __METHOD__ );
		$this->assertSame( 1, $db->affectedRows() );
		$this->assertSame( 2, $db->insertId() );

		$db->upsert( $dTable, $rows, 'k', $set, __METHOD__ );
		$this->assertSame( 1, $db->affectedRows() );
		$this->assertSame( 1, $db->insertId() );

		$this->assertNWhereKEqualsLuca( 1, $dTable, $db );
		$this->assertSame( 0, $db->affectedRows() );
		$this->assertSame( 0, $db->insertId() );
	}

	public function testInsertIdAfterInsertSelect() {
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
		$sTable = $this->createSourceTable( $db );
		$dTable = $this->createDestTable( $db );

		$rows = [ [ 'sk' => 'Luca', 'sv' => mt_rand( 1, 100 ), 'st' => time() ] ];
		$db->insert( $sTable, $rows, __METHOD__, 'IGNORE' );
		$this->assertSame( 1, $db->affectedRows() );
		$this->assertSame( 1, $db->insertId() );
		$this->assertSame( 1, (int)$db->newSelectQueryBuilder()
			->select( 'sn' )
			->from( $sTable )
			->where( [ 'sk' => 'Luca' ] )
			->fetchField() );

		$db->insertSelect(
			$dTable,
			$sTable,
			[ 'k' => 'sk', 'v' => 'sv', 't' => 'st' ],
			[ 'sk' => 'Luca' ],
			__METHOD__,
			'IGNORE'
		);
		$this->assertSame( 1, $db->affectedRows() );
		$this->assertSame( 1, $db->insertId() );

		$this->assertNWhereKEqualsLuca( 1, $dTable, $db );
		$this->assertSame( 0, $db->affectedRows() );
		$this->assertSame( 0, $db->insertId() );
	}

	public function testInsertIdAfterInsertSelectIgnore() {
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
		$sTable = $this->createSourceTable( $db );
		$dTable = $this->createDestTable( $db );

		$rows = [ [ 'sk' => 'Luca', 'sv' => mt_rand( 1, 100 ), 'st' => time() ] ];
		$db->insert( $sTable, $rows, __METHOD__, 'IGNORE' );
		$this->assertSame( 1, $db->affectedRows() );
		$this->assertSame( 1, $db->insertId() );
		$this->assertSame( 1, (int)$db->newSelectQueryBuilder()
			->select( 'sn' )
			->from( $sTable )
			->where( [ 'sk' => 'Luca' ] )
			->fetchField() );

		$db->insertSelect(
			$dTable,
			$sTable,
			[ 'k' => 'sk', 'v' => 'sv', 't' => 'st' ],
			[ 'sk' => 'Luca' ],
			__METHOD__,
			'IGNORE'
		);
		$this->assertSame( 1, $db->affectedRows() );
		$this->assertSame( 1, $db->insertId() );
		$this->assertNWhereKEqualsLuca( 1, $dTable, $db );

		$db->insertSelect(
			$dTable,
			$sTable,
			[ 'k' => 'sk', 'v' => 'sv', 't' => 'st' ],
			[ 'sk' => 'Luca' ],
			__METHOD__,
			'IGNORE'
		);
		$this->assertSame( 0, $db->affectedRows() );
		$this->assertSame( 0, $db->insertId() );

		$this->assertNWhereKEqualsLuca( 1, $dTable, $db );
		$this->assertSame( 0, $db->affectedRows() );
		$this->assertSame( 0, $db->insertId() );
	}

	public function testFieldAndIndexInfo() {
		$db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
		$db->query(
			"CREATE TABLE tmp_schema_tbl (" .
			"n integer not null primary key autoincrement, " .
			"k text, " .
			"v integer, " .
			"t integer" .
			")"
		);
		$db->query( "CREATE UNIQUE INDEX tmp_schema_tbl_k ON tmp_schema_tbl (k)" );
		$db->query( "CREATE INDEX tmp_schema_tbl_t ON tmp_schema_tbl (t)" );

		$this->assertTrue( $db->fieldExists( 'tmp_schema_tbl', 'n' ) );
		$this->assertTrue( $db->fieldExists( 'tmp_schema_tbl', 'k' ) );
		$this->assertTrue( $db->fieldExists( 'tmp_schema_tbl', 'v' ) );
		$this->assertTrue( $db->fieldExists( 'tmp_schema_tbl', 't' ) );
		$this->assertFalse( $db->fieldExists( 'tmp_schema_tbl', 'x' ) );

		$this->assertTrue( $db->indexExists( 'tmp_schema_tbl', 'tmp_schema_tbl_k' ) );
		$this->assertTrue( $db->indexExists( 'tmp_schema_tbl', 'tmp_schema_tbl_t' ) );
		$this->assertFalse( $db->indexExists( 'tmp_schema_tbl', 'tmp_schema_tbl_x' ) );
		$this->assertFalse( $db->indexExists( 'tmp_schema_tbl', 'PRIMARY' ) );

		$this->assertTrue( $db->indexUnique( 'tmp_schema_tbl', 'tmp_schema_tbl_k' ) );
		$this->assertFalse( $db->indexUnique( 'tmp_schema_tbl', 'tmp_schema_tbl_t' ) );
		$this->assertNull( $db->indexUnique( 'tmp_schema_tbl', 'tmp_schema_tbl_x' ) );
		$this->assertNull( $db->indexUnique( 'tmp_schema_tbl', 'PRIMARY' ) );
	}

	private function createSourceTable( IDatabase $db ) {
		$db->query( "DROP TABLE IF EXISTS tmp_src_tbl" );
		$db->query(
			"CREATE TABLE tmp_src_tbl (" .
			"sn integer not null primary key autoincrement, " .
			"sk text, " .
			"sv integer, " .
			"st integer" .
			")"
		);
		$db->query( "CREATE UNIQUE INDEX tmp_src_tbl_sk ON tmp_src_tbl (sk)" );

		return "tmp_src_tbl";
	}

	private function createDestTable( IDatabase $db ) {
		$db->query( "DROP TABLE IF EXISTS tmp_dst_tbl" );
		$db->query(
			"CREATE TABLE tmp_dst_tbl (" .
			"n integer not null primary key autoincrement, " .
			"k text, " .
			"v integer, " .
			"t integer" .
			")"
		);
		$db->query( "CREATE UNIQUE INDEX tmp_dst_tbl_k ON tmp_dst_tbl (k)" );

		return "tmp_dst_tbl";
	}

	private function assertNWhereKEqualsLuca( $expected, $table, $db ) {
		$this->assertSame( $expected, (int)$db->newSelectQueryBuilder()
			->select( 'n' )
			->from( $table )
			->where( [ 'k' => 'Luca' ] )
			->fetchField() );
	}
}
PK       ! *ݭ    8  diff/DifferenceEngineSlotDiffRendererIntegrationTest.phpnu Iw        <?php

use MediaWiki\MainConfigNames;

/**
 * @group small
 */
class DifferenceEngineSlotDiffRendererIntegrationTest extends \MediaWikiIntegrationTestCase {

	/**
	 * @covers \DifferenceEngineSlotDiffRenderer::getExtraCacheKeys
	 */
	public function testGetExtraCacheKeys_noExternalDiffEngineConfigured() {
		$this->overrideConfigValues( [
			MainConfigNames::DiffEngine => null,
			MainConfigNames::ExternalDiffEngine => null,
		] );

		$differenceEngine = new CustomDifferenceEngine();
		$slotDiffRenderer = new DifferenceEngineSlotDiffRenderer( $differenceEngine );
		$extraCacheKeys = $slotDiffRenderer->getExtraCacheKeys();
		$this->assertSame( [ 'foo' ], $extraCacheKeys );
	}
}
PK       ! x
  
  !  revisionlist/RevisionListTest.phpnu Iw        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\RevisionList\RevisionItem;
use MediaWiki\RevisionList\RevisionList;

/**
 * @covers \MediaWiki\RevisionList\RevisionList
 * @covers \MediaWiki\RevisionList\RevisionListBase
 * @covers \MediaWiki\RevisionList\RevisionItem
 * @covers \MediaWiki\RevisionList\RevisionItemBase
 * @group Database
 *
 * @author DannyS712
 */
class RevisionListTest extends MediaWikiIntegrationTestCase {

	public function testGetType() {
		$context = new RequestContext();
		$page = new PageIdentityValue( 123, NS_MAIN, __METHOD__, PageIdentity::LOCAL );
		$revisionList = new RevisionList( $context, $page );

		$this->assertSame(
			'revision',
			$revisionList->getType()
		);
	}

	public function testNewItem() {
		// Need a row that is valid for RevisionFactory::newRevisionFromRow
		$wikiPage = $this->getExistingTestPage( __METHOD__ );
		$currentRevId = $wikiPage->getRevisionRecord()->getId();

		$queryBuilder = $this->getServiceContainer()->getRevisionStore()->newSelectQueryBuilder( $this->getDb() )
			->joinComment()
			->joinPage()
			->joinUser()
			->where( [ 'rev_id' => $currentRevId ] );
		$row = $queryBuilder->caller( __METHOD__ )->fetchRow();

		$context = new RequestContext();
		$context->setUser( $this->getTestSysop()->getUser() );

		$page = new PageIdentityValue( 123, NS_MAIN, __METHOD__, PageIdentity::LOCAL );
		$revisionList = new RevisionList( $context, $page );

		$revisionItem = $revisionList->newItem( $row );
		$this->assertInstanceOf( RevisionItem::class, $revisionItem );

		// Tests for RevisionItem getters
		$this->assertSame( 'rev_id', $revisionItem->getIdField() );
		$this->assertSame( 'rev_timestamp', $revisionItem->getTimestampField() );
		$this->assertSame( 'rev_user', $revisionItem->getAuthorIdField() );
		$this->assertSame( 'rev_user_text', $revisionItem->getAuthorNameField() );

		// Tests for RevisionItemBase getters that are not overridden
		$this->assertSame( $currentRevId, $revisionItem->getId() );
		$this->assertSame( intval( $row->rev_user ), $revisionItem->getAuthorId() );
		$this->assertSame( strval( $row->rev_user_text ), $revisionItem->getAuthorName() );
		$this->assertSame(
			wfTimestamp( TS_MW, $row->rev_timestamp ),
			$revisionItem->getTimestamp()
		);

		// Text of the latest revision cannot be deleted, so it is always viewable
		$this->assertTrue( $revisionItem->canView() );
		$this->assertTrue( $revisionItem->canViewContent() );
		$this->assertFalse( $revisionItem->isDeleted() );
	}

}
PK       ! 2F    #  poolcounter/PoolCounterWorkTest.phpnu Iw        <?php
/**
 * Provides of semaphore semantics for restricting the number
 * of workers that may be concurrently performing the same task.
 *
 * 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\PoolCounter\PoolCounter;
use MediaWiki\PoolCounter\PoolCounterWork;
use MediaWiki\Status\Status;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\MockObject\Stub\Stub;
use Psr\Log\LoggerInterface;
use Wikimedia\TestingAccessWrapper;

/**
 * @group Concurrency
 * @coversDefaultClass \MediaWiki\PoolCounter\PoolCounterWork
 */
class PoolCounterWorkTest extends MediaWikiIntegrationTestCase {

	/**
	 * @param MockObject $obj
	 * @param array<string,Stub> $configMethods Method names mapped to return values
	 * @return MockObject
	 */
	private function configureMock( $obj, $configMethods ) {
		$obj->expects( $this->never() )
			->method( $this->anythingBut( ...array_keys( $configMethods ) ) );
		foreach ( $configMethods as $method => $value ) {
			$obj->expects( $this->once() )
				->method( $method )
				->will( $value );
		}
		return $obj;
	}

	private function configureFixture(
		array $workerMethods,
		array $poolCounterMethods,
		array $loggerMethods
	) {
		$logger = $this->createMock( LoggerInterface::class );
		$this->configureMock( $logger, $loggerMethods );

		$poolCounter = $this->getMockBuilder( PoolCounter::class )
			->disableOriginalConstructor()
			->getMockForAbstractClass();
		$poolCounter->setLogger( $logger );

		$this->configureMock( $poolCounter, $poolCounterMethods );

		$worker = $this->getMockBuilder( PoolCounterWork::class )
			->setConstructorArgs( [ 'PoolType', 'PoolKey', $poolCounter ] )
			->onlyMethods( array_keys( $workerMethods ) )
			->getMockForAbstractClass();

		$this->configureMock( $worker, $workerMethods );

		return TestingAccessWrapper::newFromObject( $worker );
	}

	public function provideExecuteFlow() {
		yield "Not Cacheable and not skipCache should call acquireForMe" => [
			false, false,
			[
				'acquireForMe' => $this->returnValue( Status::newGood( 'StupidAction' ) )
			], [], [
				'info' => $this->returnValue( null )
			],
			false
		];

		yield "Not Cacheable and skipCache should call acquireForMe" => [
			false, true,
			[
				'acquireForMe' => $this->returnValue( Status::newGood( 'StupidAction' ) )
			], [], [
				'info' => $this->returnValue( null )
			],
			false
		];

		yield "Cacheable and not skipCache should call acquireForAnyone" => [
			true, false,
			[
				'acquireForAnyone' => $this->returnValue( Status::newGood( 'StupidAction' ) )
			], [], [
				'info' => $this->returnValue( null )
			],
			false
		];

		yield "Cacheable and skipCache should call acquireForMe" => [
			true, true,
			[
				'acquireForMe' => $this->returnValue( Status::newGood( 'StupidAction' ) )
			], [], [
				'info' => $this->returnValue( null )
			],
			false
		];

		yield 'If Fatal error call doWork and log error' => [
			false, false,
			[
				'acquireForMe' => $this->returnValue( Status::newFatal( 'apierror' ) )
			], [
				'doWork' => $this->returnValue( "SomeResults" )
			], [
				'info' => $this->returnValue( null )
			],
			"SomeResults"
		];

		foreach ( [
			'LOCK_HELD' => PoolCounter::LOCK_HELD,
			'LOCKED' => PoolCounter::LOCKED
		] as $name => $acquireResult ) {
			yield 'If ' . $name . ' call doWork and return' => [
				false, false,
				[
					'acquireForMe' => $this->returnValue( Status::newGood( $acquireResult ) ),
					'release' => $this->returnValue( null )
				], [
					'doWork' => $this->returnValue( "SomeResults" )
				], [],
				"SomeResults"
			];
		}

		yield 'If DONE and not chacheable call getCachedWork and return if OK' => [
			false, false,
			[
				'acquireForMe' => $this->returnValue( Status::newGood( PoolCounter::DONE ) ),
			], [
				'getCachedWork' => $this->returnValue( "SomeResults" )
			], [],
			"SomeResults"
		];

		yield 'If DONE and chacheable call getCachedWork and return if OK' => [
			true, false,
			[
				'acquireForAnyone' => $this->returnValue( Status::newGood( PoolCounter::DONE ) ),
			], [
				'getCachedWork' => $this->returnValue( "SomeResults" )
			], [],
			"SomeResults"
		];

		yield 'If DONE and chacheable call getCachedWork and repeat if not OK' => [
			true, false,
			[
				'acquireForAnyone' => $this->returnValue( Status::newGood( PoolCounter::DONE ) ),
				'acquireForMe' => $this->returnValue( Status::newGood( PoolCounter::LOCKED ) ),
				'release' => $this->returnValue( '' )
			], [
				'getCachedWork' => $this->returnValue( false ),
				'doWork' => $this->returnValue( 'SomeResults' )
			], [],
			"SomeResults"
		];

		foreach ( [
			'QUEUE_FULL' => PoolCounter::QUEUE_FULL,
			'TIMEOUT' => PoolCounter::TIMEOUT
		] as $name => $acquireResult ) {
			yield 'If ' . $name . ' and not chacheable call fallback and return if OK' => [
				false, false,
				[
					'acquireForMe' => $this->returnValue( Status::newGood( $acquireResult ) ),
				], [
					'fallback' => $this->returnValue( "SomeResults" )
				], [],
				"SomeResults"
			];

			yield 'If ' . $name . ' and not chacheable call fallback and log if not OK' => [
				false, false,
				[
					'acquireForMe' => $this->returnValue( Status::newGood( $acquireResult ) ),
				], [
				], [
					'info' => $this->returnValue( null )
				],
				false
			];
		}
	}

	/**
	 * @dataProvider provideExecuteFlow
	 * @covers ::execute
	 */
	public function testExecuteFlow(
		bool $cacheable,
		bool $skipCache,
		array $poolCounterMethods,
		array $workerMethods,
		array $loggerMethods,
		$expectedResult
	) {
		$worker = $this->configureFixture(
			$workerMethods,
			$poolCounterMethods,
			$loggerMethods
		);

		$worker->cacheable = $cacheable;
		$this->assertSame( $expectedResult, $worker->execute( $skipCache ) );
	}

	/**
	 * @covers ::execute
	 */
	public function testDoWorkRaiseException() {
		$expectedException = new RuntimeException( __METHOD__ );
		$worker = $this->configureFixture(
			[ 'doWork' => $this->throwException( $expectedException ) ],
			[
				'acquireForMe' => $this->returnValue( Status::newGood( PoolCounter::LOCK_HELD ) ),
				'release' => $this->returnValue( '' )
			],
			[]
		);

		$this->expectExceptionObject( $expectedException );
		$worker->execute();
	}

	/**
	 * @covers ::getCachedWork
	 * @covers ::error
	 * @covers ::fallback
	 * @covers ::__construct
	 */
	public function testDefaults() {
		$worker = $this->configureFixture( [], [], [] );
		$this->assertFalse( $worker->cacheable );
		$this->assertFalse( $worker->getCachedWork() );
		$this->assertFalse( $worker->error( Status::newFatal( 'apierror' ) ) );
		$this->assertFalse( $worker->fallback( false ) );
	}
}
PK       ! X    0  poolcounter/PoolCounterConnectionManagerTest.phpnu Iw        <?php

use MediaWiki\PoolCounter\PoolCounterConnectionManager;

/**
 * @covers \MediaWiki\PoolCounter\PoolCounterConnectionManager
 * @group Database
 */
class PoolCounterConnectionManagerTest extends MediaWikiIntegrationTestCase {

	public static function provideServersConfig() {
		// supplied hostname, expected host, expected port
		return [
			'Correct IPv4' => [
				'127.0.0.1', '127.0.0.1', 7531
			],
			'Bracketless IPv6' => [
				'::1', '[::1]', 7531
			],
			'Bracketed IPv6' => [
				'[::1]', '[::1]', 7531
			],
			'IPv4 with port' => [
				'127.0.0.1:123', '127.0.0.1', 123
			],
			'IPv6 with port' => [
				'[::1]:123', '[::1]', 123,
			],
		];
	}

	/**
	 * Tests whether the hostname supplied is correct. Tests ipv4 and ipv6.
	 *
	 * @covers \MediaWiki\PoolCounter\PoolCounterConnectionManager::get
	 * @dataProvider provideServersConfig
	 */
	public function testGetServersConfig( $suppliedHostname, $expectedHost, $expectedPort ) {
		$pcm = new PoolCounterConnectionManager( [ 'servers' => [ $suppliedHostname ] ] );
		$pcm->get( 'test' );

		$this->assertEquals( $expectedHost, $pcm->host );
		$this->assertSame( $expectedPort, $pcm->port );
	}

}
PK       ! 
  
  $  block/BlockPermissionCheckerTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Block;

use MediaWiki\Block\DatabaseBlock;
use MediaWiki\MainConfigNames;
use MediaWikiIntegrationTestCase;

/**
 * @group Blocking
 * @group Database
 * @coversDefaultClass \MediaWiki\Block\BlockPermissionChecker
 */
class BlockPermissionCheckerTest extends MediaWikiIntegrationTestCase {

	/**
	 * Moved from tests for old SpecialBlock::checkUnblockSelf
	 *
	 * @covers ::checkBlockPermissions
	 * @dataProvider provideCheckUnblockSelf
	 */
	public function testCheckUnblockSelf(
		$blockedUser,
		$blockPerformer,
		$adjustPerformer,
		$adjustTarget,
		$sitewide,
		$expectedResult,
		$reason
	) {
		$this->overrideConfigValue( MainConfigNames::BlockDisablesLogin, false );
		$this->setGroupPermissions( 'sysop', 'unblockself', true );
		$this->setGroupPermissions( 'user', 'block', true );
		// Getting errors about creating users in db in provider.
		// Need to use mutable to ensure different named testusers.
		$users = [
			'u1' => $this->getMutableTestUser( 'sysop' )->getUser(),
			'u2' => $this->getMutableTestUser( 'sysop' )->getUser(),
			'u3' => $this->getMutableTestUser( 'sysop' )->getUser(),
			'u4' => $this->getMutableTestUser( 'sysop' )->getUser(),
			'nonsysop' => $this->getTestUser()->getUser()
		];
		foreach ( [ 'blockedUser', 'blockPerformer', 'adjustPerformer', 'adjustTarget' ] as $var ) {
			$$var = $users[$$var];
		}

		$block = new DatabaseBlock( [
			'address' => $blockedUser,
			'by' => $blockPerformer,
			'expiry' => 'infinity',
			'sitewide' => $sitewide,
			'enableAutoblock' => true,
		] );

		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );

		$this->assertSame(
			$expectedResult,
			$this->getServiceContainer()
				->getBlockPermissionCheckerFactory()
				->newBlockPermissionChecker( $adjustTarget, $adjustPerformer )
				->checkBlockPermissions(),
			$reason
		);
	}

	public static function provideCheckUnblockSelf() {
		// 'blockedUser', 'blockPerformer', 'adjustPerformer', 'adjustTarget'
		return [
			[ 'u1', 'u2', 'u3', 'u4', 1, true, 'Unrelated users' ],
			[ 'u1', 'u2', 'u1', 'u4', 1, 'ipbblocked', 'Block unrelated while blocked' ],
			[ 'u1', 'u2', 'u1', 'u4', 0, true, 'Block unrelated while partial blocked' ],
			[ 'u1', 'u2', 'u1', 'u1', 1, true, 'Has unblockself' ],
			[ 'nonsysop', 'u2', 'nonsysop', 'nonsysop', 1, 'ipbnounblockself', 'no unblockself' ],
			[
				'nonsysop', 'nonsysop', 'nonsysop', 'nonsysop', 1, true,
				'no unblockself but can de-selfblock'
			],
			[ 'u1', 'u2', 'u1', 'u2', 1, true, 'Can block user who blocked' ],
		];
	}
}
PK       ! pB      block/BlockUserTest.phpnu Iw        <?php

use MediaWiki\Block\BlockUserFactory;
use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\Restriction\PageRestriction;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\User\User;

/**
 * @group Blocking
 * @group Database
 */
class BlockUserTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;

	private User $user;
	private BlockUserFactory $blockUserFactory;

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

		// Prepare users
		$this->user = $this->getTestUser()->getUser();

		// Prepare factory
		$this->blockUserFactory = $this->getServiceContainer()->getBlockUserFactory();
	}

	/**
	 * @covers \MediaWiki\Block\BlockUser::placeBlock
	 */
	public function testValidTarget() {
		$status = $this->blockUserFactory->newBlockUser(
			$this->user,
			$this->mockRegisteredUltimateAuthority(),
			'infinity',
			'test block'
		)->placeBlock();
		$this->assertStatusGood( $status );
		$block = $this->user->getBlock();
		$this->assertSame( 'test block', $block->getReasonComment()->text );
		$this->assertInstanceOf( DatabaseBlock::class, $block );
		$this->assertFalse( $block->getHideName() );
		$this->assertFalse( $block->isCreateAccountBlocked() );
		$this->assertTrue( $block->isUsertalkEditAllowed() );
		$this->assertFalse( $block->isEmailBlocked() );
		$this->assertTrue( $block->isAutoblocking() );
	}

	/**
	 * @covers \MediaWiki\Block\BlockUser::placeBlock
	 */
	public function testHideUser() {
		$status = $this->blockUserFactory->newBlockUser(
			$this->user,
			$this->getTestUser( [ 'sysop', 'suppress' ] )->getUser(),
			'infinity',
			'test hideuser',
			[
				'isHideUser' => true
			]
		)->placeBlock();
		$this->assertStatusGood( $status );
		$block = $this->user->getBlock();
		$this->assertInstanceOf( DatabaseBlock::class, $block );
		$this->assertSame( 'test hideuser', $block->getReasonComment()->text );
		$this->assertTrue( $block->getHideName() );
	}

	/**
	 * @covers \MediaWiki\Block\BlockUser::placeBlock
	 */
	public function testExistingPage() {
		$this->getExistingTestPage( 'Existing Page' );
		$pageRestriction = PageRestriction::class;
		$page = $pageRestriction::newFromTitle( 'Existing Page' );
		$status = $this->blockUserFactory->newBlockUser(
			$this->user,
			$this->getTestUser( [ 'sysop', 'suppress' ] )->getUser(),
			'infinity',
			'test existingpage',
			[],
			[ $page ]
		)->placeBlock();
		$this->assertStatusGood( $status );
		$block = $this->user->getBlock();
		$this->assertInstanceOf( DatabaseBlock::class, $block );
		$this->assertSame( 'test existingpage', $block->getReasonComment()->text );
	}

	/**
	 * @covers \MediaWiki\Block\BlockUser::placeBlock
	 */
	public function testNonexistentPage() {
		$pageRestriction = PageRestriction::class;
		$page = $pageRestriction::newFromTitle( 'nonexistent' );
		$status = $this->blockUserFactory->newBlockUser(
			$this->user,
			$this->getTestUser( [ 'sysop', 'suppress' ] )->getUser(),
			'infinity',
			'test nonexistentpage',
			[],
			[ $page ]
		)->placeBlock();
		$this->assertStatusError( 'cant-block-nonexistent-page', $status );
	}

	/**
	 * @covers \MediaWiki\Block\BlockUser::placeBlockInternal
	 */
	public function testReblock() {
		$blockStatus = $this->blockUserFactory->newBlockUser(
			$this->user,
			$this->mockRegisteredUltimateAuthority(),
			'infinity',
			'test block'
		)->placeBlockUnsafe();
		$this->assertStatusGood( $blockStatus );
		$priorBlock = $this->user->getBlock();
		$this->assertInstanceOf( DatabaseBlock::class, $priorBlock );
		$this->assertSame( 'test block', $priorBlock->getReasonComment()->text );

		$blockId = $priorBlock->getId();

		$reblockStatus = $this->blockUserFactory->newBlockUser(
			$this->user,
			$this->mockAnonUltimateAuthority(),
			'infinity',
			'test reblock'
		)->placeBlockUnsafe( /*reblock=*/false );
		$this->assertStatusError( 'ipb_already_blocked', $reblockStatus );

		$this->user->clearInstanceCache();
		$block = $this->user->getBlock();
		$this->assertInstanceOf( DatabaseBlock::class, $block );
		$this->assertSame( $blockId, $block->getId() );

		$reblockStatus = $this->blockUserFactory->newBlockUser(
			$this->user,
			$this->mockRegisteredUltimateAuthority(),
			'infinity',
			'test reblock'
		)->placeBlockUnsafe( /*reblock=*/true );
		$this->assertStatusGood( $reblockStatus );

		$this->user->clearInstanceCache();
		$block = $this->user->getBlock();
		$this->assertInstanceOf( DatabaseBlock::class, $block );
		$this->assertSame( 'test reblock', $block->getReasonComment()->text );
	}

	/**
	 * @covers \MediaWiki\Block\BlockUser::placeBlockInternal
	 */
	public function testPostHook() {
		$hookBlock = false;
		$hookPriorBlock = false;
		$this->setTemporaryHook(
			'BlockIpComplete',
			static function ( $block, $legacyUser, $priorBlock )
			use ( &$hookBlock, &$hookPriorBlock )
			{
				$hookBlock = $block;
				$hookPriorBlock = $priorBlock;
			}
		);

		$blockStatus = $this->blockUserFactory->newBlockUser(
			$this->user,
			$this->mockRegisteredUltimateAuthority(),
			'infinity',
			'test block'
		)->placeBlockUnsafe();
		$this->assertStatusGood( $blockStatus );
		$priorBlock = $this->user->getBlock();
		$this->assertInstanceOf( DatabaseBlock::class, $priorBlock );
		$this->assertSame( $priorBlock->getId(), $hookBlock->getId() );
		$this->assertNull( $hookPriorBlock );

		$hookBlock = false;
		$hookPriorBlock = false;
		$reblockStatus = $this->blockUserFactory->newBlockUser(
			$this->user,
			$this->mockRegisteredUltimateAuthority(),
			'infinity',
			'test reblock'
		)->placeBlockUnsafe( /*reblock=*/true );
		$this->assertStatusGood( $reblockStatus );

		$this->user->clearInstanceCache();
		$newBlock = $this->user->getBlock();
		$this->assertInstanceOf( DatabaseBlock::class, $newBlock );
		$this->assertSame( $newBlock->getId(), $hookBlock->getId() );
		$this->assertSame( $priorBlock->getId(), $hookPriorBlock->getId() );
	}

	/**
	 * @covers \MediaWiki\Block\BlockUser::placeBlockInternal
	 */
	public function testIPBlockAllowedAutoblockPreserved() {
		$blockStatus = $this->blockUserFactory->newBlockUser(
			$this->user,
			$this->mockRegisteredUltimateAuthority(),
			'infinity',
			'test block with autoblocking',
			[ 'isAutoblocking' => true ]
		)->placeBlockUnsafe();
		$this->assertStatusGood( $blockStatus );
		$block = $blockStatus->getValue();

		$target = '1.2.3.4';
		$autoBlockId = $block->doAutoblock( $target );
		$this->assertNotFalse( $autoBlockId );

		$hookPriorBlock = false;
		$this->setTemporaryHook(
			'BlockIpComplete',
			static function ( $block, $legacyUser, $priorBlock )
			use ( &$hookPriorBlock )
			{
				$hookPriorBlock = $priorBlock;
			}
		);

		$IPBlockStatus = $this->blockUserFactory->newBlockUser(
			$target,
			$this->mockRegisteredUltimateAuthority(),
			'infinity',
			'test IP block'
		)->placeBlockUnsafe();
		$this->assertStatusGood( $IPBlockStatus );
		$IPBlock = $IPBlockStatus->getValue();
		$this->assertInstanceOf( DatabaseBlock::class, $IPBlock );
		$this->assertNull( $hookPriorBlock );

		$blockIds = array_map(
			static function ( DatabaseBlock $block ) {
				return $block->getId();
			},
			$this->getServiceContainer()->getDatabaseBlockStore()
				->newListFromTarget( $target, null, /*fromPrimary=*/true )
		);
		$this->assertContains( $autoBlockId, $blockIds );
		$this->assertContains( $IPBlock->getId(), $blockIds );
	}

}
PK       ! C7Y  Y     block/DatabaseBlockStoreTest.phpnu Iw        <?php

use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\DatabaseBlockStore;
use MediaWiki\Block\Restriction\NamespaceRestriction;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\MainConfigNames;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\User\User;
use Psr\Log\NullLogger;
use Wikimedia\IPUtils;

/**
 * Integration tests for DatabaseBlockStore.
 *
 * @author DannyS712
 * @group Blocking
 * @group Database
 * @covers \MediaWiki\Block\DatabaseBlockStore
 * @coversDefaultClass \MediaWiki\Block\DatabaseBlockStore
 */
class DatabaseBlockStoreTest extends MediaWikiIntegrationTestCase {
	use DummyServicesTrait;

	private User $sysop;
	private int $expiredBlockId = 11111;
	private int $unexpiredBlockId = 22222;
	private int $autoblockId = 33333;

	/**
	 * @param array $options
	 * - config: Override the ServiceOptions config
	 * - constructorArgs: Override the constructor arguments
	 * @return DatabaseBlockStore
	 */
	private function getStore( array $options = [] ): DatabaseBlockStore {
		$overrideConfig = $options['config'] ?? [];
		$overrideConstructorArgs = $options['constructorArgs'] ?? [];

		$defaultConfig = [
			MainConfigNames::AutoblockExpiry => 86400,
			MainConfigNames::BlockCIDRLimit => [ 'IPv4' => 16, 'IPv6' => 19 ],
			MainConfigNames::BlockDisablesLogin => false,
			MainConfigNames::PutIPinRC => true,
			MainConfigNames::UpdateRowsPerQuery => 10,
		];
		$config = array_merge( $defaultConfig, $overrideConfig );

		// This ensures continuation after hooks
		$hookContainer = $this->createMock( HookContainer::class );
		$hookContainer->method( 'run' )
			->willReturn( true );

		// Most tests need read only to be false
		$readOnlyMode = $this->getDummyReadOnlyMode( false );

		$services = $this->getServiceContainer();
		$defaultConstructorArgs = [
			'serviceOptions' => new ServiceOptions(
				DatabaseBlockStore::CONSTRUCTOR_OPTIONS,
				$config
			),
			'logger' => new NullLogger(),
			'actorStoreFactory' => $services->getActorStoreFactory(),
			'blockRestrictionStore' => $services->getBlockRestrictionStore(),
			'commentStore' => $services->getCommentStore(),
			'hookContainer' => $hookContainer,
			'dbProvider' => $services->getDBLoadBalancerFactory(),
			'readOnlyMode' => $readOnlyMode,
			'userFactory' => $services->getUserFactory(),
			'tempUserConfig' => $services->getTempUserConfig(),
			'blockUtils' => $services->getBlockUtils(),
			'autoblockExemptionList' => $services->getAutoblockExemptionList(),
		];
		$constructorArgs = array_merge( $defaultConstructorArgs, $overrideConstructorArgs );

		return new DatabaseBlockStore( ...array_values( $constructorArgs ) );
	}

	/**
	 * @param array $options
	 * - target: The intended target, an unblocked user by default
	 * - autoblock: Whether this block is autoblocking
	 * @return DatabaseBlock
	 */
	private function getBlock( array $options = [] ): DatabaseBlock {
		$target = $options['target'] ?? $this->getTestUser()->getUser();
		$autoblock = $options['autoblock'] ?? false;

		return new DatabaseBlock( [
			'by' => $this->sysop,
			'address' => $target,
			'enableAutoblock' => $autoblock,
		] );
	}

	/**
	 * Check that an autoblock corresponds to a parent block. The following are not
	 * required to be equal, so are not tested:
	 * - target
	 * - type
	 * - expiry
	 * - autoblocking
	 *
	 * @param DatabaseBlock $block
	 * @param DatabaseBlock $autoblock
	 */
	private function assertAutoblockEqualsBlock(
		DatabaseBlock $block,
		DatabaseBlock $autoblock
	) {
		$this->assertSame( $autoblock->getParentBlockId(), $block->getId() );
		$this->assertSame( $autoblock->isHardblock(), $block->isHardblock() );
		$this->assertSame( $autoblock->isCreateAccountBlocked(), $block->isCreateAccountBlocked() );
		$this->assertSame( $autoblock->getHideName(), $block->getHideName() );
		$this->assertSame( $autoblock->isEmailBlocked(), $block->isEmailBlocked() );
		$this->assertSame( $autoblock->isUsertalkEditAllowed(), $block->isUsertalkEditAllowed() );
		$this->assertSame( $autoblock->isSitewide(), $block->isSitewide() );
		$this->assertSame(
			$autoblock->getReasonComment()->text,
			wfMessage( 'autoblocker', $block->getTargetName(), $block->getReasonComment()->text )->text()
		);

		$restrictionStore = $this->getServiceContainer()->getBlockRestrictionStore();
		$this->assertTrue(
			$restrictionStore->equals(
				$autoblock->getRestrictions(),
				$block->getRestrictions()
			)
		);
	}

	/**
	 * @covers ::newFromID
	 * @covers ::newListFromTarget
	 * @covers ::newFromRow
	 */
	public function testNewFromID_exists() {
		$block = new DatabaseBlock( [
			'address' => '1.2.3.4',
			'by' => $this->getTestSysop()->getUser(),
		] );
		$store = $this->getStore();
		$inserted = $store->insertBlock( $block );
		$this->assertTrue(
			(bool)$inserted['id'],
			'Block inserted correctly'
		);

		$blockId = $inserted['id'];
		$newFromIdRes = $store->newFromID( $blockId );
		$this->assertInstanceOf(
			DatabaseBlock::class,
			$newFromIdRes,
			'Looking up an existing block by id'
		);

		$newListRes = $store->newListFromTarget( "#$blockId" );
		$this->assertCount(
			1,
			$newListRes,
			'newListFromTarget with a block id for an existing block'
		);
		$this->assertInstanceOf(
			DatabaseBlock::class,
			$newListRes[0],
			'DatabaseBlock returned'
		);
		$this->assertSame(
			$blockId,
			$newListRes[0]->getId(),
			'Block returned is the correct one'
		);
	}

	/**
	 * @covers ::newFromID
	 * @covers ::newListFromTarget
	 */
	public function testNewFromID_missing() {
		$store = $this->getStore();
		$missingBlockId = 9998;
		$dbRow = $this->getDb()->newSelectQueryBuilder()
			->select( '*' )
			->from( 'block' )
			->where( [ 'bl_id' => $missingBlockId ] )
			->caller( __METHOD__ )
			->fetchRow();
		$this->assertFalse(
			$dbRow,
			"Sanity check: make sure there is no block with id $missingBlockId"
		);

		$newFromIdRes = $store->newFromID( $missingBlockId );
		$this->assertNull(
			$newFromIdRes,
			'Looking up a missing block by id'
		);

		$newListRes = $store->newListFromTarget( "#$missingBlockId" );
		$this->assertCount(
			0,
			$newListRes,
			'newListFromTarget with a block id for a missing block'
		);
	}

	/**
	 * @covers ::getQueryInfo
	 */
	public function testGetQueryInfo() {
		// We don't list all of the fields that should be included, because that just
		// duplicates the function itself. Instead, check the structure and the field
		// aliases. The fact that this query info is everything needed to create a block
		// is validated by its uses within the service
		$queryInfo = $this->getStore()->getQueryInfo();
		$this->assertArrayHasKey( 'tables', $queryInfo );
		$this->assertArrayHasKey( 'fields', $queryInfo );
		$this->assertArrayHasKey( 'joins', $queryInfo );

		$this->assertIsArray( $queryInfo['fields'] );
		$this->assertArrayHasKey( 'bl_by', $queryInfo['fields'] );
		$this->assertSame( 'block_by_actor.actor_user', $queryInfo['fields']['bl_by'] );
		$this->assertArrayHasKey( 'bl_by_text', $queryInfo['fields'] );
		$this->assertSame( 'block_by_actor.actor_name', $queryInfo['fields']['bl_by_text'] );
	}

	/**
	 * @covers ::newListFromIPs
	 * @covers ::newFromRow
	 */
	public function testNewListFromIPs() {
		$block = new DatabaseBlock( [
			'address' => '1.2.3.4',
			'by' => $this->getTestSysop()->getUser(),
		] );
		$store = $this->getStore();
		$inserted = $store->insertBlock( $block );
		$this->assertTrue(
			(bool)$inserted['id'],
			'Sanity check: block inserted correctly'
		);

		// Early return of empty array if no ips in the list
		$list = $store->newListFromIPs( [], true );
		$this->assertCount(
			0,
			$list,
			'No matching blocks'
		);

		// Empty array for no match
		$list = $store->newListFromIPs(
			[ '10.1.1.1', '192.168.1.1' ],
			true
		);
		$this->assertCount(
			0,
			$list,
			'No blocks retrieved if all ips are invalid or trusted proxies'
		);

		// Actually fetching, block was inserted above
		$list = $store->newListFromIPs( [ '1.2.3.4' ], true );
		$this->assertCount(
			1,
			$list,
			'Block retrieved for the blocked ip'
		);
		$this->assertInstanceOf(
			DatabaseBlock::class,
			$list[0],
			'Sanity check: DatabaseBlock returned'
		);
		$this->assertSame(
			$inserted['id'],
			$list[0]->getId(),
			'Block returned is the correct one'
		);
	}

	public static function provideGetRangeCond() {
		// $start, $end, $expect
		$hex1 = IPUtils::toHex( '1.2.3.4' );
		$hex2 = IPUtils::toHex( '1.2.3.5' );
		yield 'IPv4 start, same end' => [
			$hex1,
			null,
			"((bt_ip_hex = '$hex1' AND bt_range_start IS NULL)"
			. " OR (bt_range_start LIKE '0102%' ESCAPE '`'"
			. " AND bt_range_start <= '$hex1'"
			. " AND bt_range_end >= '$hex1'))"
		];
		yield 'IPv4 start, different end' => [
			$hex1,
			$hex2,
			"(bt_range_start LIKE '0102%' ESCAPE '`'"
			. " AND bt_range_start <= '$hex1'"
			. " AND bt_range_end >= '$hex2')"
		];
		$hex3 = IPUtils::toHex( '2000:DEAD:BEEF:A:0:0:0:0' );
		$hex4 = IPUtils::toHex( '2000:DEAD:BEEF:A:0:0:000A:000F' );
		yield 'IPv6 start, same end' => [
			$hex3,
			null,
			"((bt_ip_hex = '$hex3' AND bt_range_start IS NULL)"
			. " OR (bt_range_start LIKE 'v6-2000%' ESCAPE '`'"
			. " AND bt_range_start <= '$hex3'"
			. " AND bt_range_end >= '$hex3'))"
		];
		yield 'IPv6 start, different end' => [
			$hex3,
			$hex4,
			"(bt_range_start LIKE 'v6-2000%' ESCAPE '`'"
			. " AND bt_range_start <= '$hex3'"
			. " AND bt_range_end >= '$hex4')"
		];
	}

	/**
	 * @dataProvider provideGetRangeCond
	 * @covers ::getRangeCond
	 * @covers ::getIpFragment
	 */
	public function testGetRangeCond( $start, $end, $expect ) {
		$this->assertSame(
			$expect,
			$this->getStore()->getRangeCond( $start, $end ) );
	}

	public static function provideGetRangeCondIntegrated() {
		return [
			'single IP block' => [ '3.3.3.3', '3.3.3.3', true ],
			'/32 range blocks single IP' => [ '3.3.3.3/32', '3.3.3.3', true ],
			'single IP block mismatch' => [ '3.3.3.3', '3.3.3.4', false ],
			'/32 range mismatch' => [ '3.3.3.3/32', '3.3.3.4', false ],
			'/24 match' => [ '3.3.3.0/24', '3.3.3.0', true ],
			'/24 mismatch' => [ '3.3.3.0/24', '3.3.4.0', false ],
			'range search exact match' => [ '3.3.3.0/24', '3.3.3.0/24', true ],
			'encompassing range match' => [ '3.3.3.0/24', '3.3.3.1/27', true ],
			'excessive range mismatch' => [ '3.3.0.0/24', '3.3.0.0/22', false ],
		];
	}

	/**
	 * Test getRangeCond() by inserting blocks and checking for matches
	 *
	 * @dataProvider provideGetRangeCondIntegrated
	 * @param string $blockTarget
	 * @param string $searchTarget
	 * @param bool $isBlocked
	 */
	public function testGetRangeCondIntegrated( $blockTarget, $searchTarget, $isBlocked ) {
		$store = $this->getStore();
		$store->insertBlock( $this->getBlock( [ 'target' => $blockTarget ] ) );
		[ $start, $end ] = IPUtils::parseRange( $searchTarget );
		$rows = $this->getDb()->newSelectQueryBuilder()
			->queryInfo( $store->getQueryInfo() )
			->where( $store->getRangeCond( $start, $end ) )
			->fetchResultSet();
		$this->assertSame( $isBlocked ? 1 : 0, $rows->numRows() );
	}

	/**
	 * @dataProvider provideInsertBlockSuccess
	 */
	public function testInsertBlockSuccess( $options ) {
		$block = $this->getBlock( $options['block'] ?? [] );
		$block->setRestrictions( [
			new NamespaceRestriction( 0, NS_MAIN ),
		] );

		$store = $this->getStore( $options['store'] ?? [] );
		$result = $store->insertBlock( $block );

		$this->assertIsArray( $result );
		$this->assertArrayHasKey( 'id', $result );
		$this->assertArrayHasKey( 'autoIds', $result );
		$this->assertCount( 0, $result['autoIds'] );

		$retrievedBlock = $store->newFromID( $result['id'] );
		$this->assertTrue( $block->equals( $retrievedBlock ) );
	}

	public static function provideInsertBlockSuccess() {
		return [
			'No conflicting block, not autoblocking' => [
				'block' => [
					'autoblock' => false,
				],
			],
			'No conflicting block, autoblocking but IP not in recent changes' => [
				[
					'block' => [
						'autoblock' => true,
					],
					'store' => [
						'constructorArgs' => [ MainConfigNames::PutIPinRC => false ],
					],
				],
			],
			'No conflicting block, autoblocking but no recent edits' => [
				'block' => [
					'autoblock' => true,
				],
			],
			'Conflicting block, expired' => [
				'block' => [
					// Blocked with expired block in addDBData
					'target' => '1.1.1.1',
				],
			],
		];
	}

	public function testInsertBlockConflict() {
		$block = $this->getBlock( [ 'target' => $this->sysop ] );

		$store = $this->getStore();
		$result = $store->insertBlock( $block );

		$this->assertFalse( $result );
		$this->assertNull( $block->getId() );
	}

	/**
	 * @dataProvider provideInsertBlockLogout
	 */
	public function testInsertBlockLogout( $options, $expectTokenEqual ) {
		$block = $this->getBlock();
		$userFactory = $this->getServiceContainer()->getUserFactory();
		$targetToken = $userFactory->newFromUserIdentity( $block->getTargetUserIdentity() )->getToken();

		$store = $this->getStore( $options );
		$store->insertBlock( $block );

		$this->assertSame(
			$expectTokenEqual,
			$targetToken === $userFactory->newFromUserIdentity( $block->getTargetUserIdentity() )->getToken()
		);
	}

	public static function provideInsertBlockLogout() {
		return [
			'Blocked user can log in' => [
				[
					'config' => [ MainConfigNames::BlockDisablesLogin => false ],
				],
				true,
			],
			'Blocked user cannot log in' => [
				[
					'config' => [ MainConfigNames::BlockDisablesLogin => true ],
				],
				false,
			],
		];
	}

	public function testInsertBlockAutoblock() {
		// This is quicker than adding a recent change for an unblocked user.
		// See addDBDataOnce documentation for more details.
		$target = $this->sysop;
		$store = $this->getStore();
		$store->deleteBlocksMatchingConds( [ 'bt_user' => $target->getId() ] );
		$block = $this->getBlock( [
			'autoblock' => true,
			'target' => $target,
		] );

		$result = $store->insertBlock( $block );

		$this->assertIsArray( $result );
		$this->assertArrayHasKey( 'autoIds', $result );
		$this->assertCount( 1, $result['autoIds'] );

		$retrievedBlock = $store->newFromID( $result['autoIds'][0] );
		$this->assertSame( $block->getId(), $retrievedBlock->getParentBlockId() );
		$this->assertAutoblockEqualsBlock( $block, $retrievedBlock );
	}

	public function testInsertBlockError() {
		$block = $this->createMock( DatabaseBlock::class );

		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( 'insert' );

		$store = $this->getStore();
		$store->insertBlock( $block );
	}

	public function testUpdateBlock() {
		$store = $this->getStore();
		$existingBlock = $store->newFromTarget( $this->sysop );

		// Insert an autoblock for T351173 regression testing
		$autoblockId = $store->doAutoblock( $existingBlock, '127.0.0.1' );

		// Modify a block option
		$existingBlock->isUsertalkEditAllowed( true );
		$newExpiry = wfTimestamp( TS_MW, time() + 1000 );
		$existingBlock->setExpiry( $newExpiry );

		$result = $store->updateBlock( $existingBlock );

		$updatedBlock = $store->newFromID( $result['id'] );
		$autoblock = $store->newFromID( $autoblockId );

		$this->assertTrue( $updatedBlock->equals( $existingBlock ) );
		$this->assertAutoblockEqualsBlock( $existingBlock, $autoblock );
		$this->assertLessThanOrEqual( $newExpiry, $autoblock->getExpiry() );
	}

	public function testUpdateBlockAddOrRemoveAutoblock() {
		$store = $this->getStore();
		// Existing block is autoblocking to begin with
		$existingBlock = $store->newFromTarget( $this->sysop );
		$existingBlock->isAutoblocking( false );

		$result = $store->updateBlock( $existingBlock );

		$updatedBlock = $store->newFromID( $result['id'] );

		$this->assertTrue( $updatedBlock->equals( $existingBlock ) );
		$this->assertCount( 0, $result['autoIds'] );

		// Test adding an autoblock in the same test run, since we need the
		// target to be the sysop (see addDBDataOnce documentation), and the
		// sysop is blocked with an autoblock between test runs.
		$existingBlock->isAutoblocking( true );
		$result = $store->updateBlock( $existingBlock );

		$updatedBlock = $store->newFromID( $result['id'] );
		$autoblock = $store->newFromID( $result['autoIds'][0] );

		$this->assertTrue( $updatedBlock->equals( $existingBlock ) );
		$this->assertAutoblockEqualsBlock( $existingBlock, $autoblock );
	}

	/**
	 * @dataProvider provideUpdateBlockRestrictions
	 */
	public function testUpdateBlockRestrictions( $expectedCount ) {
		$store = $this->getStore();
		$existingBlock = $store->newFromTarget( $this->sysop );
		$restrictions = [];
		for ( $ns = 0; $ns < $expectedCount; $ns++ ) {
			$restrictions[] = new NamespaceRestriction( $existingBlock->getId(), $ns );
		}
		$existingBlock->setRestrictions( $restrictions );

		$result = $store->updateBlock( $existingBlock );

		$retrievedBlock = $store->newFromID( $result['id'] );
		$this->assertCount(
			$expectedCount,
			$retrievedBlock->getRestrictions()
		);
	}

	public static function provideUpdateBlockRestrictions() {
		return [
			'Restrictions deleted if removed' => [ 0 ],
			'Restrictions changed if updated' => [ 2 ],
		];
	}

	public function testDeleteBlockSuccess() {
		$store = $this->getStore();
		$target = $this->sysop;
		$block = $store->newFromTarget( $target );

		$this->assertTrue( $store->deleteBlock( $block ) );
		$this->assertNull( $store->newFromTarget( $target ) );
	}

	public function testDeleteBlockFailureReadOnly() {
		$store = $this->getStore( [
			'constructorArgs' => [
				'readOnlyMode' => $this->getDummyReadOnlyMode( true )
			],
		] );
		$target = $this->sysop;
		$block = $store->newFromTarget( $target );

		$this->assertFalse( $store->deleteBlock( $block ) );
		$this->assertTrue( (bool)$store->newFromTarget( $target ) );
	}

	public function testDeleteBlockFailureNoBlockId() {
		$block = $this->createMock( DatabaseBlock::class );
		$block->method( 'getId' )
			->willReturn( null );
		$block->method( 'getWikiId' )
			->willReturn( DatabaseBlock::LOCAL );

		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( 'delete' );

		$store = $this->getStore();
		$store->deleteBlock( $block );
	}

	/**
	 * Check whether expired blocks and restrictions were removed from the database.
	 *
	 * @param int $blockId
	 * @param bool $expected Whether to expect to find any rows
	 */
	private function assertPurgeWorked( int $blockId, bool $expected ): void {
		$blockRows = (bool)$this->getDb()->newSelectQueryBuilder()
			->select( 'bl_id' )
			->from( 'block' )
			->where( [ 'bl_id' => $blockId ] )
			->fetchResultSet()->numRows();
		$blockRestrictionsRows = (bool)$this->getDb()->newSelectQueryBuilder()
			->select( 'ir_ipb_id' )
			->from( 'ipblocks_restrictions' )
			->where( [ 'ir_ipb_id' => $blockId ] )
			->fetchResultSet()->numRows();

		$this->assertSame( $expected, $blockRows );
		$this->assertSame( $expected, $blockRestrictionsRows );
	}

	public function testPurgeExpiredBlocksSuccess() {
		$store = $this->getStore();
		$store->purgeExpiredBlocks();

		$this->assertPurgeWorked( $this->expiredBlockId, false );
		$this->assertPurgeWorked( $this->unexpiredBlockId, true );
	}

	public function testPurgeExpiredBlocksFailureReadOnly() {
		$store = $this->getStore( [
			'constructorArgs' => [
				'readOnlyMode' => $this->getDummyReadOnlyMode( true ),
			],
		] );
		$store->purgeExpiredBlocks();

		$this->assertPurgeWorked( $this->expiredBlockId, true );
	}

	/**
	 * In order to autoblock a user, they must have a recent change.
	 *
	 * Make a recent change for the test sysop. This user persists between test runs,
	 * so will always have this recent change.
	 *
	 * Regular test users don't persist between test runs, because the TestUserRegistry
	 * is cleared between runs. If we tested autoblocking on a regular test user, we
	 * would need to make a recent change for each test, which is slow.
	 *
	 * Instead we always test autoblocks on the test sysop.
	 */
	public function addDBDataOnce() {
		$this->editPage(
			'DatabaseBlockStoreTest test page',
			'an edit',
			'a summary',
			NS_MAIN,
			$this->getTestSysop()->getUser()
		);
	}

	/**
	 * Three blocks are added:
	 * - an expired block with restrictions, against an IP
	 * - a current block with restrictions, against a user with recent changes
	 * - a current autoblock from the current block above
	 */
	public function addDBData() {
		$this->sysop = $this->getTestSysop()->getUser();

		// Get a comment ID. One was added in addDBDataOnce.
		$commentId = $this->getDb()->newSelectQueryBuilder()
			->select( 'comment_id' )
			->from( 'comment' )
			->caller( __METHOD__ )
			->fetchField();

		$commonBlockData = [
			'bl_by_actor' => $this->sysop->getActorId(),
			'bl_reason_id' => $commentId,
			'bl_timestamp' => $this->getDb()->timestamp( '20000101000000' ),
			'bl_anon_only' => 0,
			'bl_create_account' => 0,
			'bl_deleted' => 0,
			'bl_block_email' => 0,
			'bl_allow_usertalk' => 0,
			'bl_sitewide' => 0,
		];

		$targetRows = [
			'1.1.1.1' => [
				'bt_address' => '1.1.1.1',
				'bt_ip_hex' => IPUtils::toHex( '1.1.1.1' ),
				'bt_auto' => 0,
			],
			'sysop' => [
				'bt_user' => $this->sysop->getId(),
				'bt_user_text' => $this->sysop->getName(),
				'bt_auto' => 0,
			],
			'2.2.2.2' => [
				'bt_address' => '2.2.2.2',
				'bt_ip_hex' => IPUtils::toHex( '2.2.2.2' ),
				'bt_auto' => 1,
			]
		];
		$targetIds = [];
		foreach ( $targetRows as $i => $row ) {
			$this->getDb()->newInsertQueryBuilder()
				->insertInto( 'block_target' )
				->row( $row + [ 'bt_count' => 1 ] )
				->execute();
			$targetIds[$i] = $this->getDb()->insertId();
		}

		$blockData = [
			[
				'bl_id' => $this->expiredBlockId,
				'bl_target' => $targetIds['1.1.1.1'],
				'bl_expiry' => $this->getDb()->timestamp( '20010101000000' ),
				'bl_enable_autoblock' => 0,
				'bl_parent_block_id' => 0,
			] + $commonBlockData,
			[
				'bl_id' => $this->unexpiredBlockId,
				'bl_target' => $targetIds['sysop'],
				'bl_expiry' => $this->getDb()->getInfinity(),
				'bl_enable_autoblock' => 1,
				'bl_parent_block_id' => 0,
			] + $commonBlockData,
			[
				'bl_id' => $this->autoblockId,
				'bl_target' => $targetIds['2.2.2.2'],
				'bl_expiry' => $this->getDb()->getInfinity(),
				'bl_enable_autoblock' => 0,
				'bl_parent_block_id' => $this->unexpiredBlockId,
			] + $commonBlockData,
		];

		$restrictionData = [
			[
				'ir_ipb_id' => $this->expiredBlockId,
				'ir_type' => 1,
				'ir_value' => 1,
			],
			[
				'ir_ipb_id' => $this->unexpiredBlockId,
				'ir_type' => 2,
				'ir_value' => 2,
			],
			[
				'ir_ipb_id' => $this->autoblockId,
				'ir_type' => 2,
				'ir_value' => 2,
			],
		];

		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'block' )
			->rows( $blockData )
			->caller( __METHOD__ )
			->execute();

		$this->getDb()->newInsertQueryBuilder()
			->insertInto( 'ipblocks_restrictions' )
			->rows( $restrictionData )
			->caller( __METHOD__ )
			->execute();
	}

}
PK       ! 0a      block/UnblockUserTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Block;

use MediaWiki\Block\DatabaseBlock;
use MediaWiki\Block\UnblockUserFactory;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;

/**
 * @group Blocking
 * @group Database
 */
class UnblockUserTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;

	private User $user;
	private UnblockUserFactory $unblockUserFactory;

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

		// Prepare users
		$this->user = $this->getTestUser()->getUser();

		// Prepare factory
		$this->unblockUserFactory = $this->getServiceContainer()->getUnblockUserFactory();
	}

	/**
	 * @covers \MediaWiki\Block\UnblockUser::unblock
	 */
	public function testValidUnblock() {
		$performer = $this->mockRegisteredUltimateAuthority();
		$block = new DatabaseBlock( [
			'address' => $this->user->getName(),
			'by' => $performer->getUser()
		] );
		$this->getServiceContainer()->getDatabaseBlockStore()->insertBlock( $block );

		$this->assertInstanceOf( DatabaseBlock::class, $this->user->getBlock() );
		$status = $this->unblockUserFactory->newUnblockUser(
			$this->user,
			$performer,
			'test'
		)->unblock();
		$this->assertStatusOK( $status );
		$this->assertNotInstanceOf(
			DatabaseBlock::class,
			User::newFromName(
				$this->user->getName()
			)
			->getBlock()
		);
	}

	/**
	 * @covers \MediaWiki\Block\UnblockUser::unblockUnsafe
	 */
	public function testNotBlocked() {
		$this->user = User::newFromName( $this->user->getName() ); // Reload the user object
		$status = $this->unblockUserFactory->newUnblockUser(
			$this->user,
			$this->mockRegisteredUltimateAuthority(),
			'test'
		)->unblock();
		$this->assertStatusError( 'ipb_cant_unblock', $status );
	}
}
PK       ! Tq    /  ResourceLoader/ForeignResourceStructureTest.phpnu Iw        <?php

use MediaWiki\ResourceLoader\ForeignResourceManager;
use MediaWiki\ResourceLoader\ForeignResourceNetworkException;
use PHPUnit\Framework\TestCase;

/**
 * Verify MediaWiki core's foreign-resources.yaml.
 *
 * This test is under integration/ instead of structure/ because the latter
 * also runs in CI for skin and extension repos (T203694).
 *
 * @coversNothing
 */
class ForeignResourceStructureTest extends TestCase {

	public function testVerifyIntegrity() {
		global $IP;
		$out = '';
		$frm = new ForeignResourceManager(
			"{$IP}/resources/lib/foreign-resources.yaml",
			"{$IP}/resources/lib",
			static function ( $text ) use ( &$out ) {
				$out .= $text;
			}
		);

		// The "verify" action verifies two things:
		// 1. Mismatching SRI hashes.
		//    These throw an exception with the actual/expect values
		//    to place in foreign-resources.yaml.
		// 2. Mismatching file contents.
		//    These print messages about each mismatching file,
		//    and then we add our help text afterward for how to
		//    automatically update the file resources.

		$helpUpdate = '
	To update a foreign resource, run:
	$ php maintenance/manageForeignResources.php update <moduleName>
		';

		try {
			$this->assertTrue( $frm->run( 'verify', 'all' ), "$out\n$helpUpdate" );
		} catch ( ForeignResourceNetworkException $e ) {
			$this->markTestSkipped( 'Network error: ' . $e->getMessage() );
		}

		// Verify that the CycloneDX SBOM file is up to date. CDX serials are random, so we need
		// to hack in the correct serial.
		$cdxFile = $frm->getCdxFileLocation();
		$this->assertFileExists( $cdxFile );
		$cdxJsonString = file_get_contents( $cdxFile );
		$serial = preg_match( '/"urn:uuid:[\\da-f\\-]+"/', $cdxJsonString, $matches );
		$this->assertSame( 1, $serial );
		$expectedCdx = preg_replace( '/"urn:uuid:[\\da-f\\-]+"/', $matches[0], $frm->generateCdx() );
		$this->assertJsonStringEqualsJsonFile( $cdxFile, $expectedCdx,
			"foreign-resources.cdx.json does not match foreign-resources.yaml, "
			. "run `manageForeignResources.php make-cdx`" );
	}
}
PK       ! Bz
  z
  &  filerepo/LocalAndForeignDBRepoTest.phpnu Iw        <?php

use MediaWiki\WikiMap\WikiMap;
use Wikimedia\ObjectCache\HashBagOStuff;
use Wikimedia\ObjectCache\WANObjectCache;

class LocalAndForeignDBRepoTest extends MediaWikiIntegrationTestCase {
	/**
	 * @covers \LocalRepo::getSharedCacheKey
	 * @covers \ForeignDBViaLBRepo::getSharedCacheKey
	 */
	public function testsSharedCacheKey() {
		$wikiId = WikiMap::getCurrentWikiDbDomain()->getId();
		$wanCache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
		$localRepo = new LocalRepo( [
			'name' => 'local',
			'backend' => 'local-backend',
			'wanCache' => $wanCache
		] );
		$foreignRepo = new ForeignDBViaLBRepo( [
			'name' => 'local',
			'backend' => 'local-backend',
			'wiki' => $wikiId,
			'hasSharedCache' => true,
			'wanCache' => $wanCache
		] );
		$foreignRepo2 = new ForeignDBViaLBRepo( [
			'name' => 'local',
			'backend' => 'local-backend',
			'wiki' => 'weirdoutside-domain',
			'hasSharedCache' => true,
			'wanCache' => $wanCache
		] );

		$sharedKeyForLocal = $localRepo->getSharedCacheKey( 'class', 93 );
		$sharedKeyForForeign = $foreignRepo->getSharedCacheKey( 'class', 93 );
		$sharedKeyForForiegn2 = $foreignRepo2->getSharedCacheKey( 'class', 93 );

		$this->assertSame(
			$wanCache->makeGlobalKey( 'filerepo-class', $wikiId, 93 ),
			$sharedKeyForLocal,
			"Shared key (repo is on local domain)"
		);
		$this->assertSame(
			$sharedKeyForLocal,
			$sharedKeyForForeign,
			"Shared key (repo is on foreign domain)"
		);

		$this->assertSame(
			$wanCache->makeGlobalKey( 'filerepo-class', 'weirdoutside-domain', 93 ),
			$sharedKeyForForiegn2,
			"Shared key (repo is on a different foreign domain)"
		);
	}

	/**
	 * @covers \LocalRepo::getLocalCacheKey
	 * @covers \ForeignDBViaLBRepo::getLocalCacheKey
	 */
	public function testsLocalCacheKey() {
		$wikiId = WikiMap::getCurrentWikiDbDomain()->getId();
		$wanCache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
		$localRepo = new LocalRepo( [
			'name' => 'local',
			'backend' => 'local-backend',
			'wanCache' => $wanCache
		] );
		$foreignRepo = new ForeignDBViaLBRepo( [
			'name' => 'local',
			'backend' => 'local-backend',
			'wiki' => $wikiId,
			'hasSharedCache' => true,
			'wanCache' => $wanCache
		] );

		$nonsharedKeyForLocal = $localRepo->getLocalCacheKey( 'class', 93 );
		$nonsharedKeyForForeign = $foreignRepo->getLocalCacheKey( 'class', 93 );

		$this->assertSame(
			$wanCache->makeKey( 'filerepo-class', 'local', 93 ),
			$nonsharedKeyForLocal,
			"Non-shared key (repo is on local domain)"
		);
		$this->assertSame(
			$wanCache->makeKey( 'filerepo-class', 'local', 93 ),
			$nonsharedKeyForForeign,
			"Non-shared key (repo is on local domain)"
		);
	}
}
PK       ! <0  <0    ExtensionJsonTestBase.phpnu Iw        <?php

declare( strict_types=1 );

namespace MediaWiki\Tests;

use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiQuery;
use MediaWiki\Auth\PreAuthenticationProvider;
use MediaWiki\Auth\PrimaryAuthenticationProvider;
use MediaWiki\Auth\SecondaryAuthenticationProvider;
use MediaWiki\Content\ContentHandler;
use MediaWiki\Http\HttpRequestFactory;
use MediaWiki\MediaWikiServices;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\Api\ApiTestContext;
use MediaWikiIntegrationTestCase;
use Wikimedia\Http\MultiHttpClient;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Rdbms\LBFactory;

/**
 * Base class for testing extension.json.
 *
 * While {@link \ExtensionJsonValidationTest} tests basic validity of all
 * extension.json and skin.json files that are available,
 * individual extensions can use this class to opt into further testing
 * by adding a test class extending this base class.
 *
 * This includes tests for object factory specifications
 * (API modules, special pages, hook handlers, etc.) to ensure that:
 * * The specifications are valid,
 *   i.e. they can be created without any errors.
 *   This protects, for instance, against misconfigured services.
 *   (This works best if the constructor factory function being called
 *   declares types for the parameters it receives.)
 * * No HTTP or database connections are made during initialization.
 *   Opening connections already when an object is created,
 *   not only when it is used, is a potential performance issue.
 * * Optionally: Each specification's list of services is sorted.
 *   Prescribing an automatically testable order frees the developer
 *   from having to think about the most logical order for any services.
 *
 * @license GPL-2.0-or-later
 */
abstract class ExtensionJsonTestBase extends MediaWikiIntegrationTestCase {

	/**
	 * @var string The path to the extension.json file.
	 * Should be specified as `__DIR__ . '/.../extension.json'`.
	 */
	protected string $extensionJsonPath;

	/**
	 * @var string|null The prefix of the extension's own services in the service container.
	 * If non-null, all services lists must be sorted,
	 * and first list all services outside the extension (without the prefix),
	 * then all services within the extension (with the prefix), in alphabetical order.
	 * If null (default), the order of services lists is not tested.
	 * To require all services to be listed in alphabetical order,
	 * regardless of whether they belong to the extension or not,
	 * set this to the empty string.
	 * @see ExtensionServicesTestBase::$serviceNamePrefix
	 */
	protected ?string $serviceNamePrefix = null;

	/**
	 * @var array[] Cache for extension.json, shared between all tests.
	 * Maps {@link $extensionJsonPath} values to parsed extension.json contents.
	 */
	private static array $extensionJsonCache = [];

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

		// Factory methods should never access the database or do http requests
		// https://phabricator.wikimedia.org/T243729
		$this->disallowDBAccess();
		$this->disallowHttpAccess();
	}

	final protected function getExtensionJson(): array {
		if ( !array_key_exists( $this->extensionJsonPath, self::$extensionJsonCache ) ) {
			self::$extensionJsonCache[$this->extensionJsonPath] = json_decode(
				file_get_contents( $this->extensionJsonPath ),
				true,
				512,
				JSON_THROW_ON_ERROR
			);
		}
		return self::$extensionJsonCache[$this->extensionJsonPath];
	}

	/** @dataProvider provideHookHandlerNames */
	public function testHookHandler( string $hookHandlerName ): void {
		$specification = $this->getExtensionJson()['HookHandlers'][$hookHandlerName];
		$objectFactory = MediaWikiServices::getInstance()->getObjectFactory();
		$objectFactory->createObject( $specification, [
			'allowClassName' => true,
		] );
		$this->assertTrue( true );
	}

	public function provideHookHandlerNames(): iterable {
		foreach ( $this->getExtensionJson()['HookHandlers'] ?? [] as $hookHandlerName => $specification ) {
			yield [ $hookHandlerName ];
		}
	}

	/** @dataProvider provideContentModelIDs */
	public function testContentHandler( string $contentModelID ): void {
		$specification = $this->getExtensionJson()['ContentHandlers'][$contentModelID];
		$objectFactory = MediaWikiServices::getInstance()->getObjectFactory();
		$objectFactory->createObject( $specification, [
			'assertClass' => ContentHandler::class,
			'allowCallable' => true,
			'allowClassName' => true,
			'extraArgs' => [ $contentModelID ],
		] );
		$this->assertTrue( true );
	}

	public function provideContentModelIDs(): iterable {
		foreach ( $this->getExtensionJson()['ContentHandlers'] ?? [] as $contentModelID => $specification ) {
			yield [ $contentModelID ];
		}
	}

	/** @dataProvider provideApiModuleNames */
	public function testApiModule( string $moduleName ): void {
		$specification = $this->getExtensionJson()['APIModules'][$moduleName];
		$objectFactory = MediaWikiServices::getInstance()->getObjectFactory();
		$objectFactory->createObject( $specification, [
			'allowClassName' => true,
			'extraArgs' => [ $this->mockApiMain(), 'modulename' ],
		] );
		$this->assertTrue( true );
	}

	public function provideApiModuleNames(): iterable {
		foreach ( $this->getExtensionJson()['APIModules'] ?? [] as $moduleName => $specification ) {
			yield [ $moduleName ];
		}
	}

	/** @dataProvider provideApiQueryModuleListsAndNames */
	public function testApiQueryModule( string $moduleList, string $moduleName ): void {
		$specification = $this->getExtensionJson()[$moduleList][$moduleName];
		$objectFactory = MediaWikiServices::getInstance()->getObjectFactory();
		$objectFactory->createObject( $specification, [
			'allowClassName' => true,
			'extraArgs' => [ $this->mockApiQuery(), 'query' ],
		] );
		$this->assertTrue( true );
	}

	public function provideApiQueryModuleListsAndNames(): iterable {
		foreach ( [ 'APIListModules', 'APIMetaModules', 'APIPropModules' ] as $moduleList ) {
			foreach ( $this->getExtensionJson()[$moduleList] ?? [] as $moduleName => $specification ) {
				yield [ $moduleList, $moduleName ];
			}
		}
	}

	/** @dataProvider provideSpecialPageNames */
	public function testSpecialPage( string $specialPageName ): void {
		$specification = $this->getExtensionJson()['SpecialPages'][$specialPageName];
		$objectFactory = MediaWikiServices::getInstance()->getObjectFactory();
		$objectFactory->createObject( $specification, [
			'allowClassName' => true,
		] );
		$this->assertTrue( true );
	}

	public function provideSpecialPageNames(): iterable {
		foreach ( $this->getExtensionJson()['SpecialPages'] ?? [] as $specialPageName => $specification ) {
			yield [ $specialPageName ];
		}
	}

	/** @dataProvider provideAuthenticationProviders */
	public function testAuthenticationProviders( string $providerType, string $providerName, string $providerClass ): void {
		$specification = $this->getExtensionJson()['AuthManagerAutoConfig'][$providerType][$providerName];
		$objectFactory = MediaWikiServices::getInstance()->getObjectFactory();
		$objectFactory->createObject( $specification, [
			'assertClass' => $providerClass,
		] );
		$this->assertTrue( true );
	}

	public function provideAuthenticationProviders(): iterable {
		$config = $this->getExtensionJson()['AuthManagerAutoConfig'] ?? [];

		$types = [
			'preauth'       => PreAuthenticationProvider::class,
			'primaryauth'   => PrimaryAuthenticationProvider::class,
			'secondaryauth' => SecondaryAuthenticationProvider::class,
		];

		foreach ( $types as $providerType => $providerClass ) {
			foreach ( $config[$providerType] ?? [] as $providerName => $specification ) {
				yield [ $providerType, $providerName, $providerClass ];
			}
		}
	}

	/** @dataProvider provideSessionProviders */
	public function testSessionProviders( string $providerName ): void {
		$specification = $this->getExtensionJson()['SessionProviders'][$providerName];
		$objectFactory = MediaWikiServices::getInstance()->getObjectFactory();
		$objectFactory->createObject( $specification );
		$this->assertTrue( true );
	}

	public function provideSessionProviders(): iterable {
		foreach ( $this->getExtensionJson()['SessionProviders'] ?? [] as $providerName => $specification ) {
			yield [ $providerName ];
		}
	}

	/** @dataProvider provideServicesLists */
	public function testServicesSorted( array $services ): void {
		$sortedServices = $services;
		usort( $sortedServices, function ( $serviceA, $serviceB ) {
			$isExtensionServiceA = str_starts_with( $serviceA, $this->serviceNamePrefix );
			$isExtensionServiceB = str_starts_with( $serviceB, $this->serviceNamePrefix );
			if ( $isExtensionServiceA !== $isExtensionServiceB ) {
				return $isExtensionServiceA ? 1 : -1;
			}
			return strcmp( $serviceA, $serviceB );
		} );

		$this->assertSame( $sortedServices, $services,
			'Services should be sorted: first all MediaWiki services, ' .
			"then all {$this->serviceNamePrefix}* ones." );
	}

	public function provideServicesLists(): iterable {
		if ( $this->serviceNamePrefix === null ) {
			return; // do not test sorting
		}
		foreach ( $this->provideSpecifications() as $name => $specification ) {
			if (
				is_array( $specification ) &&
				array_key_exists( 'services', $specification )
			) {
				yield $name => [ $specification['services'] ];
			}
		}
	}

	public function provideSpecifications(): iterable {
		foreach ( $this->provideHookHandlerNames() as [ $hookHandlerName ] ) {
			yield "HookHandlers/$hookHandlerName" => $this->getExtensionJson()['HookHandlers'][$hookHandlerName];
		}

		foreach ( $this->provideContentModelIDs() as [ $contentModelID ] ) {
			yield "ContentHandlers/$contentModelID" => $this->getExtensionJson()['ContentHandlers'][$contentModelID];
		}

		foreach ( $this->provideApiModuleNames() as [ $moduleName ] ) {
			yield "APIModules/$moduleName" => $this->getExtensionJson()['APIModules'][$moduleName];
		}

		foreach ( $this->provideApiQueryModuleListsAndNames() as [ $moduleList, $moduleName ] ) {
			yield "$moduleList/$moduleName" => $this->getExtensionJson()[$moduleList][$moduleName];
		}

		foreach ( $this->provideSpecialPageNames() as [ $specialPageName ] ) {
			yield "SpecialPages/$specialPageName" => $this->getExtensionJson()['SpecialPages'][$specialPageName];
		}

		foreach ( $this->provideAuthenticationProviders() as [ $providerType, $providerName, $providerClass ] ) {
			yield "AuthManagerAutoConfig/$providerType/$providerName" => $this->getExtensionJson()['AuthManagerAutoConfig'][$providerType][$providerName];
		}

		foreach ( $this->provideSessionProviders() as [ $providerName ] ) {
			yield "SessionProviders/$providerName" => $this->getExtensionJson()['SessionProviders'][$providerName];
		}
	}

	private function disallowDBAccess() {
		$this->setService(
			'DBLoadBalancerFactory',
			function () {
				$lb = $this->createMock( ILoadBalancer::class );
				$lb->expects( $this->never() )
					->method( 'getMaintenanceConnectionRef' );
				$lb->method( 'getLocalDomainID' )
					->willReturn( 'banana' );

				// This IDatabase will fail when actually trying to do database actions
				$db = $this->createNoOpMock( IDatabase::class );
				$lb->method( 'getConnection' )
					->willReturn( $db );

				$lbFactory = $this->createMock( LBFactory::class );
				$lbFactory->method( 'getMainLB' )
					->willReturn( $lb );
				$lbFactory->method( 'getLocalDomainID' )
					->willReturn( 'banana' );

				return $lbFactory;
			}
		);
	}

	private function disallowHttpAccess() {
		$this->setService(
			'HttpRequestFactory',
			function () {
				$factory = $this->createMock( HttpRequestFactory::class );
				$factory->expects( $this->never() )
					->method( 'create' );
				$factory->expects( $this->never() )
					->method( 'request' );
				$factory->expects( $this->never() )
					->method( 'get' );
				$factory->expects( $this->never() )
					->method( 'post' );
				$factory->method( 'createMultiClient' )
					->willReturn( $this->createMock( MultiHttpClient::class ) );
				return $factory;
			}
		);
	}

	private function mockApiMain(): ApiMain {
		$request = new FauxRequest();
		$ctx = new ApiTestContext();
		$ctx = $ctx->newTestContext( $request );
		return new ApiMain( $ctx );
	}

	private function mockApiQuery(): ApiQuery {
		return $this->mockApiMain()->getModuleManager()->getModule( 'query' );
	}

}
PK       ! f1  1    page/DeletePageTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Page;

use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Content\Content;
use MediaWiki\Content\ContentHandler;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Page\DeletePage;
use MediaWiki\Page\ProperPageIdentity;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\UltimateAuthority;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWikiIntegrationTestCase;
use Wikimedia\ScopedCallback;
use WikiPage;

/**
 * @covers \MediaWiki\Page\DeletePage
 * @group Database
 * @note Permission-related tests are in \MediaWiki\Tests\Unit\Page\DeletePageTest
 */
class DeletePageTest extends MediaWikiIntegrationTestCase {

	private const PAGE_TEXT = "[[Stuart Little]]\n" .
		"{{Multiple issues}}\n" .
		"https://www.example.com/\n" .
		"[[Category:Felis catus]]";

	private function getDeletePage( ProperPageIdentity $page, Authority $deleter ): DeletePage {
		return $this->getServiceContainer()->getDeletePageFactory()->newDeletePage(
			$page,
			$deleter
		);
	}

	/**
	 * @param string $titleText
	 * @param string $content
	 * @return WikiPage
	 */
	private function createPage( string $titleText, string $content ): WikiPage {
		$ns = $this->getDefaultWikitextNS();
		$title = Title::newFromText( $titleText, $ns );
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );

		$performer = $this->getTestUser()->getAuthority();

		$content = ContentHandler::makeContent( $content, $page->getTitle(), CONTENT_MODEL_WIKITEXT );

		$updater = $page->newPageUpdater( $performer )
			->setContent( SlotRecord::MAIN, $content );

		$updater->saveRevision( CommentStoreComment::newUnsavedComment( "testing" ) );
		if ( !$updater->wasSuccessful() ) {
			$this->fail( $updater->getStatus()->getWikiText() );
		}
		DeferredUpdates::doUpdates();
		$this->assertLinksUpdateSetup( $page->getId() );

		return $page;
	}

	private function assertDeletionLogged(
		ProperPageIdentity $title,
		int $pageID,
		User $deleter,
		string $reason,
		bool $suppress,
		string $logSubtype,
		int $logID
	): void {
		$commentQuery = $this->getServiceContainer()->getCommentStore()->getJoin( 'log_comment' );
		$this->newSelectQueryBuilder()
			->select( [
				'log_type',
				'log_action',
				'log_comment' => $commentQuery['fields']['log_comment_text'],
				'log_actor',
				'log_namespace',
				'log_title',
				'log_page',
			] )
			->from( 'logging' )
			->tables( $commentQuery['tables'] )
			->where( [ 'log_id' => $logID ] )
			->joinConds( $commentQuery['joins'] )
			->assertRowValue( [
				$suppress ? 'suppress' : 'delete',
				$logSubtype,
				$reason,
				(string)$deleter->getActorId(),
				(string)$title->getNamespace(),
				$title->getDBkey(),
				$pageID,
			] );
	}

	private function assertArchiveVisibility( Title $title, bool $suppression ): void {
		if ( !$suppression ) {
			// Archived revisions are considered "deleted" only when suppressed, so we'd always get a content
			// in case of normal deletion.
			return;
		}
		$lookup = $this->getServiceContainer()->getArchivedRevisionLookup();
		$archivedRevs = $lookup->listRevisions( $title );
		if ( !$archivedRevs || $archivedRevs->numRows() !== 1 ) {
			$this->fail( 'Unexpected number of archived revisions' );
		}
		$archivedRev = $this->getServiceContainer()->getRevisionStore()
			->newRevisionFromArchiveRow( $archivedRevs->current() );

		$this->assertNotNull(
			$archivedRev->getContent( SlotRecord::MAIN, RevisionRecord::RAW ),
			"Archived content should be there"
		);

		$this->assertNull(
			$archivedRev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC ),
			"Archived content should be null after the page was suppressed for general users"
		);

		$getContentForUser = static function ( Authority $user ) use ( $archivedRev ): ?Content {
			return $archivedRev->getContent(
				SlotRecord::MAIN,
				RevisionRecord::FOR_THIS_USER,
				$user
			);
		};

		$this->assertNull(
			$getContentForUser( static::getTestUser()->getUser() ),
			"Archived content should be null after the page was suppressed for individual users"
		);

		$this->assertNull(
			$getContentForUser( static::getTestSysop()->getUser() ),
			"Archived content should be null after the page was suppressed even for a sysop"
		);

		$this->assertNotNull(
			$getContentForUser( static::getTestUser( [ 'suppress', 'sysop' ] )->getUser() ),
			"Archived content should be visible after the page was suppressed for an oversighter"
		);
	}

	private function assertPageObjectsConsistency( WikiPage $page ): void {
		$this->assertSame(
			0,
			$page->getTitle()->getArticleID(),
			"Title object should now have page id 0"
		);
		$this->assertSame( 0, $page->getId(), "WikiPage should now have page id 0" );
		$this->assertFalse(
			$page->exists(),
			"WikiPage::exists should return false after page was deleted"
		);
		$this->assertNull(
			$page->getContent(),
			"WikiPage::getContent should return null after page was deleted"
		);

		$t = Title::newFromText( $page->getTitle()->getPrefixedText() );
		$this->assertFalse(
			$t->exists(),
			"Title::exists should return false after page was deleted"
		);
	}

	private function assertLinksUpdateSetup( int $pageID ): void {
		$linkTarget = MediaWikiServices::getInstance()->getLinkTargetLookup()->getLinkTargetId(
			Title::makeTitle( NS_TEMPLATE, 'Multiple_issues' )
		);
		$this->newSelectQueryBuilder()
			->select( [ 'lt_namespace', 'lt_title' ] )
			->from( 'pagelinks' )
			->join( 'linktarget', null, 'pl_target_id=lt_id' )
			->where( [ 'pl_from' => $pageID ] )
			->assertResultSet( [ [ 0, 'Stuart_Little' ], [ NS_TEMPLATE, 'Multiple_issues' ] ] );
		$this->newSelectQueryBuilder()
			->select( 'tl_target_id' )
			->from( 'templatelinks' )
			->where( [ 'tl_from' => $pageID ] )
			->assertFieldValue( $linkTarget );
		$this->newSelectQueryBuilder()
			->select( 'cl_to' )
			->from( 'categorylinks' )
			->where( [ 'cl_from' => $pageID ] )
			->assertFieldValue( 'Felis_catus' );
		$this->newSelectQueryBuilder()
			->select( 'cat_pages' )
			->from( 'category' )
			->where( [ 'cat_title' => 'Felis_catus' ] )
			->assertFieldValue( 1 );
	}

	private function assertPageLinksUpdate( int $pageID, bool $shouldRunJobs ): void {
		if ( $shouldRunJobs ) {
			$this->runJobs();
		}

		$this->newSelectQueryBuilder()
			->select( [ 'lt_namespace', 'lt_title' ] )
			->from( 'pagelinks' )
			->join( 'linktarget', null, 'pl_target_id=lt_id' )
			->where( [ 'pl_from' => $pageID ] )
			->assertEmptyResult();
		$this->newSelectQueryBuilder()
			->select( 'tl_target_id' )
			->from( 'templatelinks' )
			->where( [ 'tl_from' => $pageID ] )
			->assertEmptyResult();
		$this->newSelectQueryBuilder()
			->select( 'cl_to' )
			->from( 'categorylinks' )
			->where( [ 'cl_from' => $pageID ] )
			->assertEmptyResult();
		$this->newSelectQueryBuilder()
			->select( 'cat_pages' )
			->from( 'category' )
			->where( [ 'cat_title' => 'Felis_catus' ] )
			->assertEmptyResult();
	}

	private function assertDeletionTags( int $logId, array $tags ): void {
		if ( !$tags ) {
			return;
		}
		$actualTags = $this->getDb()->newSelectQueryBuilder()
			->select( 'ct_tag_id' )
			->from( 'change_tag' )
			->where( [ 'ct_log_id' => $logId ] )
			->fetchFieldValues();
		$changeTagDefStore = $this->getServiceContainer()->getChangeTagDefStore();
		$expectedTags = array_map( [ $changeTagDefStore, 'acquireId' ], $tags );
		$this->assertArrayEquals( $expectedTags, array_map( 'intval', $actualTags ) );
	}

	/**
	 * @dataProvider provideDeleteUnsafe
	 */
	public function testDeleteUnsafe( bool $suppress, array $tags, bool $immediate, string $logSubtype ) {
		$teardownScope = DeferredUpdates::preventOpportunisticUpdates();
		$deleterUser = static::getTestSysop()->getUser();
		$deleter = new UltimateAuthority( $deleterUser );
		$page = $this->createPage( __METHOD__, self::PAGE_TEXT );
		$id = $page->getId();

		if ( !$immediate ) {
			// Ensure that the job queue can be used
			$this->overrideConfigValue( MainConfigNames::DeleteRevisionsBatchSize, 1 );
			$this->editPage( $page, "second revision" );
		}

		$reason = "testing deletion";
		$deletePage = $this->getDeletePage( $page, $deleter );
		$status = $deletePage
			->setSuppress( $suppress )
			->setTags( $tags )
			->forceImmediate( $immediate )
			->setLogSubtype( $logSubtype )
			->deleteUnsafe( $reason );

		$this->assertStatusGood( $status, 'Deletion should succeed' );

		DeferredUpdates::doUpdates();

		if ( $immediate ) {
			$this->assertFalse( $deletePage->deletionsWereScheduled()[DeletePage::PAGE_BASE] );
			$logIDs = $deletePage->getSuccessfulDeletionsIDs();
			$this->assertCount( 1, $logIDs );
			$logID = $logIDs[DeletePage::PAGE_BASE];
			$this->assertIsInt( $logID );
		} else {
			$this->assertTrue( $deletePage->deletionsWereScheduled()[DeletePage::PAGE_BASE] );
			$this->assertNull( $deletePage->getSuccessfulDeletionsIDs()[DeletePage::PAGE_BASE] );
			$this->runJobs();
			$logID = $this->getDb()->newSelectQueryBuilder()
				->select( 'log_id' )
				->from( 'logging' )
				->where( [ 'log_type' => $suppress ? 'suppress' : 'delete', 'log_namespace' => $page->getNamespace(), 'log_title' => $page->getDBkey() ] )
				->fetchField();
			$this->assertNotFalse( $logID, 'Should have a log ID now' );
			$logID = (int)$logID;
			// Clear caches.
			$page->getTitle()->resetArticleID( false );
			$page->clear();
		}

		$this->assertPageObjectsConsistency( $page );
		$this->assertArchiveVisibility( $page->getTitle(), $suppress );
		$this->assertDeletionLogged( $page, $id, $deleterUser, $reason, $suppress, $logSubtype, $logID );
		$this->assertDeletionTags( $logID, $tags );
		$this->assertPageLinksUpdate( $id, $immediate );

		ScopedCallback::consume( $teardownScope );
	}

	public static function provideDeleteUnsafe(): iterable {
		// Note that we're using immediate deletion as default
		yield 'standard deletion' => [ false, [], true, 'delete' ];
		yield 'suppression' => [ true, [], true, 'delete' ];
		yield 'deletion with tags' => [ false, [ 'tag-foo', 'tag-bar' ], true, 'delete' ];
		yield 'custom deletion log' => [ false, [], true, 'custom-del-log' ];
		yield 'queued deletion' => [ false, [], false, 'delete' ];
	}

	public function testDeletionHooks() {
		$deleterUser = static::getTestSysop()->getUser();
		$deleter = new UltimateAuthority( $deleterUser );

		$status = $this->editPage( __METHOD__, '#REDIRECT[[Foo]]' );
		$id = $status->getNewRevision()->getPageId();
		$wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromID( $id );

		$this->assertTrue( $wikiPage->exists(), 'WikiPage exists before deletion' );
		$this->assertTrue( $wikiPage->isRedirect(), 'WikiPage is redirect before deletion' );
		// Clear internal WikiPage state, to ensure that DeletePage loads it if it's missing
		$wikiPage->clear();

		// Set up hook handlers for testing
		$oldHookCalled = 0;
		$newHookCalled = 0;

		$this->setTemporaryHook( 'ArticleDeleteComplete', function (
			WikiPage $wikiPage, ...$unused
		) use ( &$oldHookCalled ) {
			$this->assertTrue( $wikiPage->exists(), 'WikiPage exists in ArticleDeleteComplete hook' );
			$this->assertTrue( $wikiPage->isRedirect(), 'WikiPage is redirect in ArticleDeleteComplete hook' );

			$oldHookCalled++;
		} );

		$this->setTemporaryHook( 'PageDeleteComplete', function (
			ProperPageIdentity $page, ...$unused
		) use ( &$newHookCalled ) {
			$this->assertTrue( $page->exists(), 'ProperPageIdentity exists in PageDeleteComplete hook' );

			// This works because $page is actually a WikiPage, and WikiPageFactory::newFromTitle() returns
			// the same object. Shouldn't have done that, some extension probably depends on this now…
			$wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $page );
			$this->assertTrue( $wikiPage->exists(), 'WikiPage exists in PageDeleteComplete hook' );
			$this->assertTrue( $wikiPage->isRedirect(), 'WikiPage is redirect in PageDeleteComplete hook' );

			$newHookCalled++;
		} );

		// Do the deletion
		$reason = "testing deletion";
		$deletePage = $this->getDeletePage( $wikiPage, $deleter );
		$status = $deletePage
			->forceImmediate( true )
			->deleteUnsafe( $reason );

		$this->assertStatusGood( $status, 'Deletion should succeed' );
		$this->assertSame( 1, $oldHookCalled, 'Old hook was called' );
		$this->assertSame( 1, $newHookCalled, 'New hook was called' );

		$this->assertFalse( $wikiPage->exists(), 'WikiPage does not exist after deletion' );
		$this->assertFalse( $wikiPage->isRedirect(), 'WikiPage is not a redirect after deletion' );
	}
}
PK       ! 1 @n@  n@    page/RollbackPageTest.phpnu Iw        <?php

namespace MediaWiki\Tests\Page;

use DatabaseLogEntry;
use MediaWiki\Content\JsonContent;
use MediaWiki\Content\WikitextContent;
use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\RollbackPage;
use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Tests\Unit\MockServiceDependenciesTrait;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentityValue;
use MediaWikiIntegrationTestCase;
use RecentChange;
use Wikimedia\Rdbms\ReadOnlyMode;
use WikiPage;

/**
 * @group Database
 * @covers \MediaWiki\Page\RollbackPage
 * @coversDefaultClass \MediaWiki\Page\RollbackPage
 * @method RollbackPage newServiceInstance(string $serviceClass, array $parameterOverrides)
 */
class RollbackPageTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;
	use MockServiceDependenciesTrait;
	use TempUserTestTrait;

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

		$this->overrideConfigValue( MainConfigNames::UseRCPatrol, true );
	}

	public function provideAuthorize() {
		yield 'Allowed' => [
			'authority' => $this->mockRegisteredUltimateAuthority(),
			'expect' => true,
		];
		yield 'No edit' => [
			'authority' => $this->mockRegisteredAuthorityWithoutPermissions( [ 'edit' ] ),
			'expect' => true,
		];
		yield 'No rollback' => [
			'authority' => $this->mockRegisteredAuthorityWithoutPermissions( [ 'rollback' ] ),
			'expect' => true,
		];
	}

	/**
	 * @covers ::authorizeRollback
	 * @dataProvider provideAuthorize
	 */
	public function testAuthorize( Authority $authority, bool $expect ) {
		$this->assertSame(
			$expect,
			$this->getServiceContainer()
				->getRollbackPageFactory()
				->newRollbackPage(
					new PageIdentityValue( 10, NS_MAIN, 'Test', PageIdentity::LOCAL ),
					$authority,
					new UserIdentityValue( 0, '127.0.0.1' )
				)
				->authorizeRollback()
				->isGood()
		);
	}

	public function testAuthorizeReadOnly() {
		$mockReadOnly = $this->createMock( ReadOnlyMode::class );
		$mockReadOnly->method( 'isReadOnly' )->willReturn( true );
		$rollback = $this->newServiceInstance( RollbackPage::class, [
			'readOnlyMode' => $mockReadOnly,
			'performer' => $this->mockRegisteredUltimateAuthority()
		] );
		$this->assertStatusNotOk( $rollback->authorizeRollback() );
	}

	/**
	 * @covers ::authorizeRollback
	 */
	public function testAuthorizePingLimiter() {
		$performer = $this->mockRegisteredUltimateAuthority();
		$userMock = $this->createMock( User::class );
		$userMock->method( 'pingLimiter' )
			->willReturnMap( [
				[ 'rollback', 1, false ],
				[ 'edit', 1, false ],
			] );
		$userFactoryMock = $this->createMock( UserFactory::class );
		$userFactoryMock->method( 'newFromAuthority' )
			->with( $performer )
			->willReturn( $userMock );
		$rollbackPage = $this->newServiceInstance( RollbackPage::class, [
			'performer' => $performer,
			'userFactory' => $userFactoryMock
		] );
		$this->assertStatusGood( $rollbackPage->authorizeRollback() );
	}

	public function testRollbackNotAllowed() {
		$this->assertStatusNotOk( $this->newServiceInstance( RollbackPage::class, [
			'performer' => $this->mockRegisteredNullAuthority()
		] )->rollbackIfAllowed() );
	}

	public function testRollback() {
		$admin = $this->getTestSysop()->getUser();
		$user1 = $this->getTestUser()->getUser();
		// Use the confirmed group for user2 to make sure the user is different
		$user2 = $this->getTestUser( [ 'confirmed' ] )->getUser();

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( __METHOD__ ) );
		// Make some edits
		$text = "one";
		$status1 = $this->editPage( $page, $text, "section one", NS_MAIN, $admin );
		$this->assertStatusGood( $status1, 'edit 1 success' );

		$text .= "\n\ntwo";
		$status2 = $this->editPage( $page, $text, "adding section two", NS_MAIN, $user1 );
		$this->assertStatusGood( $status2, 'edit 2 success' );

		$text .= "\n\nthree";
		$status3 = $this->editPage( $page, $text, "adding section three", NS_MAIN, $user2 );
		$this->assertStatusGood( $status3, 'edit 3 success' );

		$rev1 = $status1->getNewRevision();
		$rev2 = $status2->getNewRevision();
		$rev3 = $status3->getNewRevision();

		$revisionStore = $this->getServiceContainer()->getRevisionStore();
		/**
		 * We are having issues with doRollback spuriously failing. Apparently
		 * the last revision somehow goes missing or not committed under some
		 * circumstances. So, make sure the revisions have the correct usernames.
		 */
		$this->assertEquals(
			3,
			$revisionStore->countRevisionsByPageId( $this->getDb(), $page->getId() )
		);
		$this->assertEquals( $admin->getName(), $rev1->getUser()->getName() );
		$this->assertEquals( $user1->getName(), $rev2->getUser()->getName() );
		$this->assertEquals( $user2->getName(), $rev3->getUser()->getName() );

		$rc3 = $revisionStore->getRecentChange( $rev3 );
		$this->assertEquals(
			RecentChange::PRC_UNPATROLLED,
			$rc3->getAttribute( 'rc_patrolled' )
		);

		// Now, try the actual rollback
		$rollbackStatus = $this->getServiceContainer()
			->getRollbackPageFactory()
			->newRollbackPage( $page, $admin, $user2 )
			->rollbackIfAllowed();
		$this->assertStatusGood( $rollbackStatus );

		$this->assertEquals(
			$rev2->getSha1(),
			$page->getRevisionRecord()->getSha1(),
			"rollback did not revert to the correct revision" );
		$this->assertEquals( "one\n\ntwo", $page->getContent()->getText() );

		$rc = $revisionStore->getRecentChange( $page->getRevisionRecord() );
		$rc3 = $revisionStore->getRecentChange( $rev3 );

		$this->assertNotNull( $rc, 'RecentChanges entry' );
		$this->assertEquals(
			RecentChange::PRC_AUTOPATROLLED,
			$rc->getAttribute( 'rc_patrolled' ),
			'rc_patrolled'
		);
		$this->assertEquals(
			RecentChange::PRC_AUTOPATROLLED,
			$rc3->getAttribute( 'rc_patrolled' )
		);

		$mainSlot = $page->getRevisionRecord()->getSlot( SlotRecord::MAIN );
		$this->assertTrue( $mainSlot->isInherited(), 'isInherited' );
		$this->assertSame( $rev2->getId(), $mainSlot->getOrigin(), 'getOrigin' );
	}

	public function testRollbackFailSameContent() {
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( __METHOD__ ) );
		$admin = $this->getTestSysop()->getUser();
		$user1 = $this->getTestUser( [ 'sysop' ] )->getUser();

		[ 'revision-one' => $rev1 ] = $this->prepareForRollback( $admin, $user1, $page );

		$rollbackResult = $this->getServiceContainer()
			->getRollbackPageFactory()
			->newRollbackPage( $page, $admin, $user1 )
			->rollbackIfAllowed();
		$this->assertStatusGood( $rollbackResult );

		# now, try the rollback again
		$rollbackResult = $this->getServiceContainer()
			->getRollbackPageFactory()
			->newRollbackPage( $page, $admin, $user1 )
			->rollback();
		$this->assertStatusError( 'alreadyrolled', $rollbackResult );

		$this->assertEquals( $rev1->getSha1(), $page->getRevisionRecord()->getSha1(),
			"rollback did not revert to the correct revision" );
		$this->assertEquals( "one", $page->getContent()->getText() );
	}

	public function testRollbackFailNotExisting() {
		$rollbackStatus = $this->getServiceContainer()
			->getRollbackPageFactory()
			->newRollbackPage(
				new PageIdentityValue( 0, NS_MAIN, __METHOD__, PageIdentityValue::LOCAL ),
				$this->mockRegisteredUltimateAuthority(),
				new UserIdentityValue( 0, '127.0.0.1' )
			)
			->rollback();
		$this->assertStatusError( 'notanarticle', $rollbackStatus );
	}

	/**
	 * @param Authority $user1
	 * @param Authority $user2
	 * @param WikiPage $page
	 * @return array with info about created page:
	 *  'revision-one' => RevisionRecord
	 *  'revision-two' => RevisionRecord
	 */
	private function prepareForRollback( Authority $user1, Authority $user2, WikiPage $page ): array {
		$result = [];
		$text = "one";
		$status = $this->editPage( $page, $text, "section one", NS_MAIN, $user1 );
		$this->assertStatusGood( $status, 'edit 1 success' );
		$result['revision-one'] = $status->getNewRevision();

		$text .= "\n\ntwo";
		$status = $this->editPage( $page, $text, "adding section two", NS_MAIN, $user2 );
		$this->assertStatusGood( $status, 'edit 2 success' );
		$result['revision-two'] = $status->getNewRevision();
		return $result;
	}

	public function testRollbackTagging() {
		if ( !in_array( 'mw-rollback', $this->getServiceContainer()->getChangeTagsStore()->getSoftwareTags() ) ) {
			$this->markTestSkipped( 'Rollback tag deactivated, skipped the test.' );
		}

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( __METHOD__ ) );
		$admin = $this->getTestSysop()->getUser();
		$user1 = $this->getTestUser()->getUser();

		$this->prepareForRollback( $admin, $user1, $page );

		$rollbackResult = $this->getServiceContainer()
			->getRollbackPageFactory()
			->newRollbackPage( $page, $admin, $user1 )
			->setChangeTags( [ 'tag' ] )
			->rollbackIfAllowed();
		$this->assertStatusGood( $rollbackResult );
		$this->assertContains( 'mw-rollback', $rollbackResult->getValue()['tags'] );
		$this->assertContains( 'tag', $rollbackResult->getValue()['tags'] );
	}

	public function testRollbackBot() {
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( __METHOD__ ) );
		$admin = $this->getTestSysop()->getUser();
		$user1 = $this->getTestUser()->getUser();

		$this->prepareForRollback( $admin, $user1, $page );

		$rollbackResult = $this->getServiceContainer()
			->getRollbackPageFactory()
			->newRollbackPage( $page, $admin, $user1 )
			->markAsBot( true )
			->rollbackIfAllowed();
		$this->assertStatusGood( $rollbackResult );
		$rc = $this->getServiceContainer()->getRevisionStore()->getRecentChange( $page->getRevisionRecord() );
		$this->assertNotNull( $rc );
		$this->assertSame( '1', $rc->getAttribute( 'rc_bot' ) );
	}

	public function testRollbackBotNotAllowed() {
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( __METHOD__ ) );
		$admin = $this->mockUserAuthorityWithoutPermissions(
			$this->getTestSysop()->getUser(), [ 'markbotedits', 'bot' ] );
		$user1 = $this->getTestUser()->getUser();

		$this->prepareForRollback( $admin, $user1, $page );

		$rollbackResult = $this->getServiceContainer()
			->getRollbackPageFactory()
			->newRollbackPage( $page, $admin, $user1 )
			->markAsBot( true )
			->rollbackIfAllowed();
		$this->assertStatusGood( $rollbackResult );
		$rc = $this->getServiceContainer()->getRevisionStore()->getRecentChange( $page->getRevisionRecord() );
		$this->assertNotNull( $rc );
		$this->assertSame( '0', $rc->getAttribute( 'rc_bot' ) );
	}

	public static function provideRollbackPatrolAndBot() {
		yield 'mark as bot' => [ true ];
		yield 'do not mark as bot' => [ false ];
	}

	/**
	 * @dataProvider provideRollbackPatrolAndBot
	 */
	public function testRollbackPatrolAndBot( bool $markAsBot ) {
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( __METHOD__ ) );
		$admin = $this->getTestSysop()->getUser();
		$user1 = $this->getTestUser()->getUser();

		[
			'revision-one' => $rev1,
			'revision-two' => $rev2,
		] = $this->prepareForRollback( $admin, $user1, $page );

		$text = "one\n\ntwo\n\nthree";
		$status = $this->editPage( $page, $text, "adding section three", NS_MAIN, $user1 );
		$this->assertStatusGood( $status, 'edit 3 success' );
		$rev3 = $status->getNewRevision();

		$revisionStore = $this->getServiceContainer()->getRevisionStore();

		$rc1 = $revisionStore->getRecentChange( $rev1 );
		$this->assertEquals(
			RecentChange::PRC_AUTOPATROLLED,
			$rc1->getAttribute( 'rc_patrolled' )
		);

		// manually patrol the first reverted revision
		$rc2 = $revisionStore->getRecentChange( $rev2 );
		$rc2->reallyMarkPatrolled();

		$rc3 = $revisionStore->getRecentChange( $rev3 );
		$this->assertEquals(
			RecentChange::PRC_UNPATROLLED,
			$rc3->getAttribute( 'rc_patrolled' )
		);

		$rollbackResult = $this->getServiceContainer()
			->getRollbackPageFactory()
			->newRollbackPage( $page, $admin, $user1 )
			->markAsBot( $markAsBot )
			->rollback();
		$this->assertStatusGood( $rollbackResult );

		$rc1 = $revisionStore->getRecentChange( $rev1 );
		$this->assertEquals(
			RecentChange::PRC_AUTOPATROLLED,
			$rc1->getAttribute( 'rc_patrolled' )
		);
		$this->assertFalse( (bool)$rc1->getAttribute( 'rc_bot' ) );

		$rc2 = $revisionStore->getRecentChange( $rev2 );
		$this->assertEquals(
			RecentChange::PRC_PATROLLED,
			$rc2->getAttribute( 'rc_patrolled' )
		);
		$this->assertSame( $markAsBot, (bool)$rc2->getAttribute( 'rc_bot' ) );

		$rc3 = $revisionStore->getRecentChange( $rev3 );
		$this->assertEquals(
			RecentChange::PRC_AUTOPATROLLED,
			$rc3->getAttribute( 'rc_patrolled' )
		);
		$this->assertSame( $markAsBot, (bool)$rc3->getAttribute( 'rc_bot' ) );
	}

	public function testRollbackCustomSummary() {
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( __METHOD__ ) );
		$admin = $this->getTestSysop()->getUser();
		$user1 = $this->getTestUser()->getUser();

		$revisions = $this->prepareForRollback( $admin, $user1, $page );

		$rollbackResult = $this->getServiceContainer()
			->getRollbackPageFactory()
			->newRollbackPage( $page, $admin, $user1 )
			->setSummary( 'TEST! $1 $2 $3 $4 $5 $6' )
			->rollbackIfAllowed();
		$this->assertStatusGood( $rollbackResult );
		$targetTimestamp = $this->getServiceContainer()
			->getContentLanguage()
			->timeanddate( $revisions['revision-one']->getTimestamp() );
		$currentTimestamp = $this->getServiceContainer()
			->getContentLanguage()
			->timeanddate( $revisions['revision-two']->getTimestamp() );
		$expectedSummary = implode( ' ', [
			'TEST!',
			$admin->getName(),
			$user1->getName(),
			$revisions['revision-one']->getId(),
			$targetTimestamp,
			$revisions['revision-two']->getId(),
			$currentTimestamp
		] );
		$this->assertSame( $expectedSummary, $page->getRevisionRecord()->getComment()->text );
		$rc = $this->getServiceContainer()->getRevisionStore()->getRecentChange( $page->getRevisionRecord() );
		$this->assertNotNull( $rc );
		$this->assertSame( $expectedSummary, $rc->getAttribute( 'rc_comment' ) );
	}

	public function testRollbackChangesContentModel() {
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( __METHOD__ ) );
		$admin = $this->getTestSysop()->getUser();
		$user1 = $this->getTestUser()->getUser();

		$status1 = $this->editPage( $page, new JsonContent( '{}' ),
			"it's json", NS_MAIN, $admin );
		$this->assertStatusGood( $status1, 'edit 1 success' );

		$status1 = $this->editPage( $page, new WikitextContent( 'bla' ),
			"no, it's wikitext", NS_MAIN, $user1 );
		$this->assertStatusGood( $status1, 'edit 2 success' );

		$rollbackResult = $this->getServiceContainer()
			->getRollbackPageFactory()
			->newRollbackPage( $page, $admin, $user1 )
			->setSummary( 'TESTING' )
			->rollbackIfAllowed();
		$this->assertStatusGood( $rollbackResult );
		$logRow = DatabaseLogEntry::newSelectQueryBuilder( $this->getDb() )
			->where( [ 'log_namespace' => NS_MAIN, 'log_title' => __METHOD__, 'log_type' => 'contentmodel' ] )
			->caller( __METHOD__ )->fetchRow();
		$this->assertNotNull( $logRow );
		$this->assertSame( $admin->getUser()->getName(), $logRow->user_name );
		$this->assertSame( 'TESTING', $logRow->log_comment_text );
	}

	public function testRollbackOfIPRevisionWhenTemporaryAccountsAreEnabledT371094() {
		// Set up the test page to have one revision by a user and then the second revision performed by an IP address.
		$this->disableAutoCreateTempUser();
		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::newFromText( __METHOD__ ) );
		$admin = $this->getTestSysop()->getUser();
		$anonUser = $this->mockAnonUltimateAuthority();

		$this->prepareForRollback( $admin, $anonUser, $page );

		// Enable temporary accounts and then perform the rollback
		$this->enableAutoCreateTempUser();
		$rollbackResult = $this->getServiceContainer()
			->getRollbackPageFactory()
			->newRollbackPage( $page, $admin, $anonUser->getUser() )
			->rollbackIfAllowed();
		// Ensure that the rollback worked as expected, as previously this failed with an exception if
		// rolling back a IP revision.
		$this->assertStatusGood( $rollbackResult );
	}
}
PK       ! \	  	    MediaWikiEntryPointTest.phpnu Iw        <?php
namespace MediaWiki\Tests;

use MediaWiki\Context\RequestContext;
use MediaWiki\MediaWikiEntryPoint;
use MediaWiki\MediaWikiServices;
use MediaWiki\Request\FauxRequest;
use MediaWikiIntegrationTestCase;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \MediaWiki\MediaWikiEntryPoint
 */
class MediaWikiEntryPointTest extends MediaWikiIntegrationTestCase {

	private function newEntryPoint(): MediaWikiEntryPoint {
		$context = new RequestContext();
		$context->setRequest( new FauxRequest() );
		$environment = new MockEnvironment();
		$services = $this->createMock( MediaWikiServices::class );

		$mw = $this->getMockBuilder( MediaWikiEntryPoint::class )
			->onlyMethods( [ 'execute' ] )
			->setConstructorArgs( [ $context, $environment, $services ] )
			->getMockForAbstractClass();

		return $mw;
	}

	/**
	 * Assert that we can create, flush, and access output buffers.
	 *
	 * Note that can't test the behaviour for non-removable buffers,
	 * because then we are stuck with a non-removable buffer,
	 * and PHPUnit will choke on it.
	 *
	 * We also can't test that content gets send to the client by calling
	 * flush(), since we can't mock that function, and we can't capture
	 * its output.
	 */
	public function testOutputBuffer() {
		$oldLevel = ob_get_level();

		$mw = TestingAccessWrapper::newFromObject( $this->newEntryPoint() );
		$mw->enableOutputCapture();

		$mw->startOutputBuffer();
		$this->assertSame( 1, $mw->getOutputBufferLevel(), 'getOutputBufferLevel' );

		print 'Testing test';
		$this->assertSame( 12, $mw->getOutputBufferLength(), 'getOutputBufferLength' );

		$mw->flushOutputBuffer();

		$this->assertSame( $oldLevel, ob_get_level(), 'ob_get_level' );
		$this->assertSame( 'Testing test', $mw->drainOutputBuffer() );
	}

	/**
	 * Check that flushOutputBuffer() will not try to send output
	 * when in post-send mode.
	 */
	public function testFlushOutputBuffers_sent() {
		$mw = TestingAccessWrapper::newFromObject( $this->newEntryPoint() );
		$mw->enableOutputCapture();

		ob_start( null, 0, PHP_OUTPUT_HANDLER_STDFLAGS & ~PHP_OUTPUT_HANDLER_FLUSHABLE );
		print 'Testing test';

		$mw->enterPostSendMode();

		$this->expectPHPError(
			E_USER_NOTICE,
			static function () use ( $mw ) {
				$mw->flushOutputBuffer();
			}
		);

		$this->assertSame( '', $mw->drainOutputBuffer() );
	}

}
PK         ! ]s                    PDFEmbedHooks.phpnu [        PK         ! ZYE    	            S  Hooks.phpnu [        PK         ! LV  V              w$  SpecialNuke.phpnu Iw        PK         ! oH                z  Hooks/NukeGetNewPagesHook.phpnu Iw        PK         ! L                 }  Hooks/NukeHookRunner.phpnu Iw        PK         ! WL4l  l                Hooks/NukeDeletePageHook.phpnu Iw        PK         ! 9                ]  ServiceWiring.phpnu [        PK         ! >_i                  specials/SpecialLiveChat.phpnu [        PK         !                 ͈  specials/SpecialLiveStatus.phpnu [        PK         ! "2  "2              #  Room.phpnu [        PK         ! a3  3              }  ChatRoom.phpnu [        PK         ! ;  ;                Manager.phpnu [        PK         ! 7n    	            - Tools.phpnu [        PK         ! lP?                U0 Reactions.phpnu [        PK         ! gb}1F  1F              ? ChatData.phpnu [        PK         ! zm  m  
             Worker.phpnu [        PK         ! 
CO                1 Storage.phpnu [        PK         ! 8  8              < Connection.phpnu [        PK         ! I^                 LiveChatHooks.phpnu [        PK         ! ba                 ManagerRoom.phpnu [        PK         ! ҭA                 MessageParser.phpnu [        PK         ! \	  \	               specials/SpecialWhosOnline.phpnu [        PK         ! ~X4$0	  0	               WhosOnlineHooks.phpnu [        PK         ! Γi	  	               PagerWhosOnline.phpnu [        PK         ! 8T                O api/ApiQueryWhosOnline.phpnu [        PK         ! 	0  0              , specials/SpecialPatroller.phpnu [        PK         ! n
M  M              w8 PatrollerHooks.phpnu [        PK         ! O                ; TimelessVariablesModule.phpnu Iw        PK         ! *w  w              @ TimelessTemplate.phpnu Iw        PK         ! 77                 SkinTimeless.phpnu Iw        PK         ! u`                 BenchmarkerTest.phpnu Iw        PK         ! ٓ                 LoggedUpdateMaintenanceTest.phpnu Iw        PK         ! #L5-nV  nV               MaintenanceTest.phpnu Iw        PK         ! n";I                E defines.phpnu [        PK         ! i'v                RL app.phpnu [        PK         !                 oS framework.phpnu [        PK         ! Ex/',  ',              j` PdfHandler.phpnu Iw        PK         ! zj\%  \%              ό PdfImage.phpnu Iw        PK         ! us
  
              g Poem.phpnu Iw        PK         ! QؗN  N              3 Parsoid/PoemProcessor.phpnu Iw        PK         ! %%  %               Parsoid/Poem.phpnu Iw        PK         ! H                / SpamBlacklist.phpnu Iw        PK         ! J	  	              N ApiSpamBlacklist.phpnu Iw        PK         ! E  E               EmailBlacklist.phpnu Iw        PK         ! +,                0 SpamBlacklistLogFormatter.phpnu Iw        PK         !     *            , SpamBlacklistPreAuthenticationProvider.phpnu Iw        PK         ! cVJ/  J/              r BaseBlacklist.phpnu Iw        PK         ! l!                7 SpamRegexBatch.phpnu Iw        PK         ! .  .              M jobqueue/JobQueueTest.phpnu Iw        PK         !     *            $} jobqueue/jobs/UserEditCountInitJobTest.phpnu Iw        PK         ! %    %             jobqueue/jobs/RefreshLinksJobTest.phpnu Iw        PK         ! A  A  1             jobqueue/jobs/CategoryMembershipChangeJobTest.phpnu Iw        PK         ! ]b/    ,             jobqueue/jobs/ParsoidCachePrewarmJobTest.phpnu Iw        PK         !                 ȿ jobqueue/JobQueueMemoryTest.phpnu Iw        PK         ! 
                 jobqueue/JobFactoryTest.phpnu Iw        PK         ! a܄                 jobqueue/JobTest.phpnu Iw        PK         ! C    &             jobqueue/RefreshLinksPartitionTest.phpnu Iw        PK         ! 窲s                 jobqueue/JobRunnerTest.phpnu Iw        PK         ! pڷm                > SiteStats/SiteStatsTest.phpnu Iw        PK         ! ً    *            ) auth/UsernameAuthenticationRequestTest.phpnu Iw        PK         ! Qz  z  *             auth/PasswordAuthenticationRequestTest.phpnu Iw        PK         ! 8,  ,  -              auth/ConfirmLinkAuthenticationRequestTest.phpnu Iw        PK         ! ۞_  _  7            k( auth/LocalPasswordPrimaryAuthenticationProviderTest.phpnu Iw        PK         ! ]	  ]	  (            p auth/ButtonAuthenticationRequestTest.phpnu Iw        PK         ! bs  s  &            % auth/AuthenticationRequestTestCase.phpnu Iw        PK         ! r[  [  ;             auth/TemporaryPasswordPrimaryAuthenticationProviderTest.phpnu Iw        PK         ! }    =            % auth/EmailNotificationSecondaryAuthenticationProviderTest.phpnu Iw        PK         ! 16f"  f"  .            > auth/ThrottlePreAuthenticationProviderTest.phpnu Iw        PK         ! 9Ǆ Ǆ             a auth/AuthManagerTest.phpnu Iw        PK         ! D˭..  ..  7             auth/ConfirmLinkSecondaryAuthenticationProviderTest.phpnu Iw        PK         ! %
#  #              V	 auth/ThrottlerTest.phpnu Iw        PK         ! 'E    .            x9	 auth/AbstractPreAuthenticationProviderTest.phpnu Iw        PK         ! d    ,            ?	 auth/RememberMeAuthenticationRequestTest.phpnu Iw        PK         !     2            M	 auth/AbstractPrimaryAuthenticationProviderTest.phpnu Iw        PK         ! /  /  3            d	 auth/TemporaryPasswordAuthenticationRequestTest.phpnu Iw        PK         ! AT$    0            q	 auth/CreatedAccountAuthenticationRequestTest.phpnu Iw        PK         !     0            t	 auth/CreationReasonAuthenticationRequestTest.phpnu Iw        PK         ! 3  3  9            w	 auth/ResetPasswordSecondaryAuthenticationProviderTest.phpnu Iw        PK         ! I    7            	 auth/CheckBlocksSecondaryAuthenticationProviderTest.phpnu Iw        PK         ! ;h4  4  "            G	 auth/AuthenticationRequestTest.phpnu Iw        PK         ! U%  %  *            8	 auth/UserDataAuthenticationRequestTest.phpnu Iw        PK         ! Z_?    0            
 auth/PasswordDomainAuthenticationRequestTest.phpnu Iw        PK         ! 6o#  o#  :            #
 auth/AbstractPasswordPrimaryAuthenticationProviderTest.phpnu Iw        PK         ! `    1            F
 auth/CreateFromLoginAuthenticationRequestTest.phpnu Iw        PK         ! ~  ~  %            O
 specials/DeletedContribsPagerTest.phpnu Iw        PK         ! Å0J                 f
 specials/SpecialMovePageTest.phpnu Iw        PK         ! zU&p  &p              s
 specials/SpecialBlockTest.phpnu Iw        PK         ! fzL    ,            y
 specials/SpecialDeletedContributionsTest.phpnu Iw        PK         ! U6a  a  %            
 specials/QueryAllSpecialPagesTest.phpnu Iw        PK         ! x6  6               p
 specials/SpecialRedirectTest.phpnu Iw        PK         ! iGE  E               specials/SpecialSearchTest.phpnu Iw        PK         ! uh  h              I specials/SpecialLogTest.phpnu Iw        PK         ! +$  $  #            U specials/SpecialPreferencesTest.phpnu Iw        PK         ! p    !            ] specials/SpecialWatchlistTest.phpnu Iw        PK         ! R;s>  >              :r specials/ImageListPagerTest.phpnu Iw        PK         ! 6  6  $            ǂ specials/SpecialConfirmEmailTest.phpnu Iw        PK         ! j  j  /            Q specials/SpecialUncategorizedCategoriesTest.phpnu Iw        PK         ! S    +             specials/SpecialSearchTestMockResultSet.phpnu Iw        PK         ! 4W    %             specials/SpecialGoToInterwikiTest.phpnu Iw        PK         ! ŒR]  ]  *             specials/redirects/SpecialTalkPageTest.phpnu Iw        PK         ! ӁR    "            = specials/SpecialShortPagesTest.phpnu Iw        PK         ! y;    "            q specials/SpecialMIMESearchTest.phpnu Iw        PK         ! :	                 o specials/SpecialPageExecutor.phpnu Iw        PK         ! *Z                  specials/SpecialPageDataTest.phpnu Iw        PK         ! nŕ    "             specials/SpecialMyLanguageTest.phpnu Iw        PK         ! *S'  S'  &             specials/pagers/BlockListPagerTest.phpnu Iw        PK         ! pR    -            H specials/Contribute/ContributeFactoryTest.phpnu Iw        PK         ! \	  	  "            A specials/SpecialRenameUserTest.phpnu Iw        PK         ! e    %            % specials/SpecialRecentChangesTest.phpnu Iw        PK         !     "            D specials/SpecialContributeTest.phpnu Iw        PK         ! (M(  (  %            J specials/SpecialContributionsTest.phpnu Iw        PK         ! AM6  6              s specials/SpecialUploadTest.phpnu Iw        PK         ! N<ٔ    %            Ow specials/SpecialEditWatchlistTest.phpnu Iw        PK         ! 5|    %            8~ specials/SpecialPasswordResetTest.phpnu Iw        PK         ! j4  4               o specials/SpecialNewPagesTest.phpnu Iw        PK         ! pf1    "            B specials/SpecialUserLogoutTest.phpnu Iw        PK         ! "    %             specials/SpecialCreateAccountTest.phpnu Iw        PK         ! ܦ    #             specials/SpecialBookSourcesTest.phpnu Iw        PK         ! J    !             specials/SpecialBlankPageTest.phpnu Iw        PK         !     "             specials/SpecialUserRightsTest.phpnu Iw        PK         ! .  .               specials/ContribsPagerTest.phpnu Iw        PK         ! t^,  ,              1, specials/SpecialMuteTest.phpnu Iw        PK         ! oF                < specials/SpecialUnblockTest.phpnu Iw        PK         ! D^z                 N specials/SpecialPageTestBase.phpnu Iw        PK         ! &r  r              W Html/HtmlTest.phpnu Iw        PK         ! J	v  	v  *             specialpage/ChangesListSpecialPageTest.phpnu Iw        PK         ! i]  ]  6            MA specialpage/AbstractChangesListSpecialPageTestCase.phpnu Iw        PK         ! o    %            Q specialpage/SpecialPageTestHelper.phpnu Iw        PK         ! _o  o  '            .U specialpage/FormSpecialPageTestCase.phpnu Iw        PK         ! ?C+*  *  &            g specialpage/SpecialPageFactoryTest.phpnu Iw        PK         ! w                : specialpage/SpecialPageTest.phpnu Iw        PK         ! N/Tt  t  7             objectcache/SqlBagOStuffMultiPrimaryIntegrationTest.phpnu Iw        PK         ! \αj  j  +            h objectcache/SqlBagOStuffServerArrayTest.phpnu Iw        PK         ! mG    1            - objectcache/ObjectCacheFactoryIntegrationTest.phpnu Iw        PK         ! }4    +             objectcache/SqlBagOStuffIntegrationTest.phpnu Iw        PK         ! <X  X               AutoLoaderTest.phpnu Iw        PK         ! 9A  A               debug/TestDeprecatedClass.phpnu Iw        PK         ! :'                - debug/MWDebugTest.phpnu Iw        PK         ! |=]%  ]%               debug/DeprecationHelperTest.phpnu Iw        PK         ! =)|  |  !             debug/logger/LegacyLoggerTest.phpnu Iw        PK         !                  " debug/TestDeprecatedSubclass.phpnu Iw        PK         ! 1f                % export/ExportTest.phpnu Iw        PK         ! }=  =              _8 shell/ShellTest.phpnu Iw        PK         ! y'[z  z              E shell/CommandTest.phpnu Iw        PK         !                   a shell/bin/success_status.phpnu Iw        PK         ! ^$                  a shell/bin/stdout_stderr.phpnu Iw        PK         ! = R   R               b shell/bin/echo_333333_stars.phpnu Iw        PK         ! [                  c shell/bin/echo_args.phpnu Iw        PK         ! w+                  d shell/bin/echo_env.phpnu Iw        PK         ! M                  e shell/bin/failure_status.phpnu Iw        PK         ! :Pp   p               
f shell/bin/echo_stdin.phpnu Iw        PK         ! [a  a  '            f ParamValidator/TypeDef/TitleDefTest.phpnu Iw        PK         ! O    5            zw ParamValidator/TypeDef/TypeDefIntegrationTestCase.phpnu Iw        PK         ! dEqt  t  &            y ParamValidator/TypeDef/TagsDefTest.phpnu Iw        PK         ! e    .            N HookContainer/HookContainerIntegrationTest.phpnu Iw        PK         !  \    )            d Navigation/PagerNavigationBuilderTest.phpnu Iw        PK         ! v]W  W  !            } exception/UserNotLoggedInTest.phpnu Iw        PK         ! i                % exception/MWExceptionTest.phpnu Iw        PK         ! z  z  "            u exception/UserBlockedErrorTest.phpnu Iw        PK         ! 2o"  "              A exception/ReadOnlyErrorTest.phpnu Iw        PK         ! ׭Y  Y                exception/ThrottledErrorTest.phpnu Iw        PK         ! }!    "            [ exception/PermissionsErrorTest.phpnu Iw        PK         ! NrbWU  U              ^ exception/BadTitleErrorTest.phpnu Iw        PK         ! n    '             libs/telemetry/expected-trace-data.jsonnu Iw        PK         ! 4"U)    +            d libs/telemetry/TelemetryIntegrationTest.phpnu Iw        PK         ! ,O    1            V libs/objectcache/RESTBagOStuffIntegrationTest.phpnu Iw        PK         ! >    2             libs/objectcache/RedisBagOStuffIntegrationTest.phpnu Iw        PK         ! 喤.1  1  :             libs/objectcache/MemcachedPeclBagOStuffIntegrationTest.phpnu Iw        PK         ! kKE  E  &             libs/objectcache/BagOStuffTestBase.phpnu Iw        PK         ! ?    1            : libs/objectcache/HashBagOStuffIntegrationTest.phpnu Iw        PK         ! -    &            %< libs/objectcache/APCUBagOStuffTest.phpnu Iw        PK         !     9            a> libs/objectcache/MemcachedPhpBagOStuffIntegrationTest.phpnu Iw        PK         ! ty$  $  ,            @ libs/objectcache/MultiWriteBagOStuffTest.phpnu Iw        PK         ! fd~8  ~8  !            e libs/http/MultiHttpClientTest.phpnu Iw        PK         ! }>cs.  s.  -             libs/serialization/SerializationTestTrait.phpnu Iw        PK         ! f    -             libs/serialization/SerializationTestUtils.phpnu Iw        PK         ! ܺ!  !  5             libs/filebackend/fsfile/TempFSFileIntegrationTest.phpnu Iw        PK         ! NU@R  R              a language/TimeAdjustTest.phpnu Iw        PK         ! [!7  7  "             language/LanguageConverterTest.phpnu Iw        PK         ! s    -             language/LanguageConverterIntegrationTest.phpnu Iw        PK         ! +)  )  '             language/LanguageConverterTestTrait.phpnu Iw        PK         ! ;t  t              ' language/MessageTest.phpnu Iw        PK         ! ؏    $            _ language/LanguageIntegrationTest.phpnu Iw        PK         ! 1  1              9 language/MessageCacheTest.phpnu Iw        PK         ! &^@gF  F  ,             language/LanguageFallbackIntegrationTest.phpnu Iw        PK         ! q?  ?  $             language/LanguageClassesTestCase.phpnu Iw        PK         ! "  "  '            E language/converters/UzConverterTest.phpnu Iw        PK         ! ͵3  3  (             language/converters/ZghConverterTest.phpnu Iw        PK         ! E
  
  (            I language/converters/BanConverterTest.phpnu Iw        PK         ! 'D    (             language/converters/GanConverterTest.phpnu Iw        PK         ! r    7             language/converters/LanguageConverterConversionTest.phpnu Iw        PK         ! `>    '             language/converters/IuConverterTest.phpnu Iw        PK         ! qV    (             language/converters/WuuConverterTest.phpnu Iw        PK         ! ˚    (             language/converters/ShiConverterTest.phpnu Iw        PK         ! ̴
  
  '            )  language/converters/ShConverterTest.phpnu Iw        PK         ! ZD
  
  (            8 language/converters/TlyConverterTest.phpnu Iw        PK         ! 8    (            Y language/converters/CrhConverterTest.phpnu Iw        PK         ! wZ;  ;  (            ~/ language/converters/MniConverterTest.phpnu Iw        PK         ! 3p  p  '            6 language/converters/TgConverterTest.phpnu Iw        PK         ! ׈X  X  '            8 language/converters/SrConverterTest.phpnu Iw        PK         ! 'mJ
  J
  '            Q language/converters/ZhConverterTest.phpnu Iw        PK         ! cCN7  7  '            (\ language/converters/KuConverterTest.phpnu Iw        PK         ! #T  T  "            a language/LocalisationCacheTest.phpnu Iw        PK         ! 7;  7;  )            \z language/LanguageConverterFactoryTest.phpnu Iw        PK         ! -f  f               language/ConverterRuleTest.phpnu Iw        PK         ! s`s7  7  *             registration/ExtensionRegistrationTest.phpnu Iw        PK         ! ڡ]Y   Y   '             registration/FooBar/templates/README.mdnu Iw        PK         ! 6                 parser/TagHooksTest.phpnu Iw        PK         ! ʷ@  @              y parser/CacheTimeTest.phpnu Iw        PK         ! Yfr&  r&               parser/ExtraParserTest.phpnu Iw        PK         ! ˢA                - parser/StripStateTest.phpnu Iw        PK         !     6            B parser/BeforeParserFetchTemplateRevisionRecordTest.phpnu Iw        PK         ! ).  .              JX parser/SanitizerTest.phpnu Iw        PK         ! XR	  R	              0 parser/MagicWordFactoryTest.phpnu Iw        PK         ! _'    3            ѐ parser/validateParserCacheSerializationTestData.phpnu Iw        PK         ! g    )            ; parser/LinkHolderArrayIntegrationTest.phpnu Iw        PK         ! @k|S  S               parser/PreprocessorTest.phpnu Iw        PK         ! z׬}1  }1  "              parser/RevisionOutputCacheTest.phpnu Iw        PK         ! Z`C  `C              2 parser/ParserOptionsTest.phpnu Iw        PK         ! WWs  Ws              Ev parser/ParserCacheTest.phpnu Iw        PK         ! x6  6               parser/ParserMethodsTest.phpnu Iw        PK         ! Hqq  q              6  parser/ParserTest.phpnu Iw        PK         ! &HQ  Q              5 parser/ParserOutputTest.phpnu Iw        PK         ! e[a  [a  ,             parser/ParserCacheSerializationTestCases.phpnu Iw        PK         ! 9                ?n parser/ParserPreloadTest.phpnu Iw        PK         ! &Q*  *              pw parser/MagicVariableTest.phpnu Iw        PK         ! PY  Y  "             parser/PageBundleJsonTraitTest.phpnu Iw        PK         ! ,OZ    '            [ parser/Parsoid/LintErrorCheckerTest.phpnu Iw        PK         ! @!T    "            1 parser/CoreParserFunctionsTest.phpnu Iw        PK         ! X    #             GlobalFunctions/WfExpandUrlTest.phpnu Iw        PK         ! g0  0               GlobalFunctions/GlobalTest.phpnu Iw        PK         ! 9*^   ^                GlobalFunctions/READMEnu Iw        PK         !  y  y  "             GlobalFunctions/WfParseUrlTest.phpnu Iw        PK         ! \=W    )             GlobalFunctions/WfThumbIsStandardTest.phpnu Iw        PK         ! <D    #             GlobalFunctions/WfShellExecTest.phpnu Iw        PK         ! (35  5               config/ConfigFactoryTest.phpnu Iw        PK         !                 j config/LoggedServiceOptions.phpnu Iw        PK         ! -     $             config/TestAllServiceOptionsUsed.phpnu Iw        PK         ! s  s              $ config/GlobalVarConfigTest.phpnu Iw        PK         ! b                v- http/MWHttpRequestTest.phpnu Iw        PK         ! T                ? http/GuzzleHttpRequestTest.phpnu Iw        PK         ! ֮9d  d  %            ] Permissions/PermissionManagerTest.phpnu Iw        PK         ! sE3  E3              " MediaWikiServicesTest.phpnu Iw        PK         ! GxY  Y              ;V media/ExifBitmapTest.phpnu Iw        PK         ! n                g media/FakeDimensionFile.phpnu Iw        PK         ! *~=J  J              k media/DjVuTest.phpnu Iw        PK         ! PAw	  w	              s media/JpegPixelFormatTest.phpnu Iw        PK         ! L$G                 ^} media/MediaWikiMediaTestCase.phpnu Iw        PK         ! 34                 media/XCFHandlerTest.phpnu Iw        PK         ! Ey  y               media/TiffTest.phpnu Iw        PK         ! m     %            p media/MediaHandlerIntegrationTest.phpnu Iw        PK         ! ki    #            Ġ media/BitmapMetadataHandlerTest.phpnu Iw        PK         ! itj|                ҳ media/JpegTest.phpnu Iw        PK         ! ZF~  ~               media/BitmapScalingTest.phpnu Iw        PK         ! 8!                 media/PNGHandlerTest.phpnu Iw        PK         ! rB`*  `*              5 media/WebPHandlerTest.phpnu Iw        PK         ! 7                 media/ExifTest.phpnu Iw        PK         ! B    #             media/JpegMetadataExtractorTest.phpnu Iw        PK         !                 ( media/FormatMetadataTest.phpnu Iw        PK         ! [\                $; media/GIFHandlerTest.phpnu Iw        PK         ! i                	Q media/ExifRotationTest.phpnu Iw        PK         !                 5p media/BmpHandlerTest.phpnu Iw        PK         ! x)7  )7              ey media/SvgHandlerTest.phpnu Iw        PK         ! AJ}  }  "            ְ media/PNGMetadataExtractorTest.phpnu Iw        PK         ! +	=                 media/Jpeg2000HandlerTest.phpnu Iw        PK         ! (d                 media/SVGReaderTest.phpnu Iw        PK         ! A  A              < media/ThumbnailImageTest.phpnu Iw        PK         ! _)    4             watchlist/WatchedItemQueryServiceIntegrationTest.phpnu Iw        PK         ! jAB  AB  -            1 watchlist/WatchedItemStoreIntegrationTest.phpnu Iw        PK         ! >    '            G watchlist/ClearUserWatchlistJobTest.phpnu Iw        PK         ! |A|  |               V search/SearchHighlighterTest.phpnu Iw        PK         ! P9  9  .            }[ search/ParserOutputSearchDataExtractorTest.phpnu Iw        PK         !  v                c search/SearchResultSetTest.phpnu Iw        PK         ! 9`w    '            vn search/SearchNearMatchResultSetTest.phpnu Iw        PK         ! \%  %  !            p search/SearchEnginePrefixTest.phpnu Iw        PK         ! 8!  8!              ޖ search/PrefixSearchTest.phpnu Iw        PK         ! Mq    ,            a search/SearchResultThumbnailProviderTest.phpnu Iw        PK         ! b2  2              w search/TitleMatcherTest.phpnu Iw        PK         ! F&                  search/SearchResultTraitTest.phpnu Iw        PK         ! H@  @               search/SearchEngineTest.phpnu Iw        PK         ! /:)  )  (            ) editpage/PreloadedContentBuilderTest.phpnu Iw        PK         ! ^%W  W              \: editpage/TextboxBuilderTest.phpnu Iw        PK         ! ,]v  v              L editpage/EditPageTest.phpnu Iw        PK         ! J Q   Q  $             editpage/EditPageConstraintsTest.phpnu Iw        PK         ! {-    $            & editpage/IntroMessageBuilderTest.phpnu Iw        PK         ! L    #            :1 externalstore/ExternalStoreTest.phpnu Iw        PK         ! E    *            6 externalstore/ExternalStoreFactoryTest.phpnu Iw        PK         !  %Tw  w  )            L externalstore/ExternalStoreAccessTest.phpnu Iw        PK         ! u'O  O  )            Z externalstore/ExternalStoreForTesting.phpnu Iw        PK         ! k<w  w              _ upload/UploadBaseTest.phpnu Iw        PK         ! .q  q              X upload/UploadStashTest.phpnu Iw        PK         ! %  %               upload/UploadFromUrlTest.phpnu Iw        PK         ! P%  %               import/ImportTest.phpnu Iw        PK         ! ,#  #  ,            ) import/ImportableOldRevisionImporterTest.phpnu Iw        PK         ! !%                1 import/ImportExportTest.phpnu Iw        PK         ! $xY  Y              L import/ImportFailureTest.phpnu Iw        PK         ! T	m  m  -            Z import/ImportTemporaryUserIntegrationTest.phpnu Iw        PK         ! ՙd	  d	  )            ab import/ImportLinkCacheIntegrationTest.phpnu Iw        PK         ! Ѿ                l Rest/RequestFromGlobalsTest.phpnu Iw        PK         ! 0Ny
  y
               Rest/EntryPointTest.phpnu Iw        PK         ! UGJ  J  %            H Storage/NameTableStoreFactoryTest.phpnu Iw        PK         ! ï-  -               Storage/PageUpdaterTest.phpnu Iw        PK         ! 
N  N              _< Storage/SqlBlobStoreTest.phpnu Iw        PK         ! ڬG    &            s Storage/DerivedPageDataUpdaterTest.phpnu Iw        PK         ! n2@  @  -            S  Storage/PageUpdaterFactoryIntegrationTest.phpnu Iw        PK         ! I[,  ,              {Y  Storage/NameTableStoreTest.phpnu Iw        PK         ! Tb."  "  #              Storage/RevisionSlotsUpdateTest.phpnu Iw        PK         ! I:dP  P  "            i  logging/UploadLogFormatterTest.phpnu Iw        PK         ! V&2NO  NO  "              logging/DeleteLogFormatterTest.phpnu Iw        PK         ! ="    "            ! logging/RightsLogFormatterTest.phpnu Iw        PK         ! ɞX  X              ! logging/LogFormatterTest.phpnu Iw        PK         ! Cy;2  2              w! logging/LogTests.i18n.phpnu Iw        PK         ! 2?  2?  !            Zy! logging/BlockLogFormatterTest.phpnu Iw        PK         ! V-"    $            ݸ! logging/NewUsersLogFormatterTest.phpnu Iw        PK         ! h%Ax  x  (            6! logging/ContentModelLogFormatterTest.phpnu Iw        PK         ! Z      $            ! logging/PageLangLogFormatterTest.phpnu Iw        PK         ! p
  
  "            z! logging/ImportLogFormatterTest.phpnu Iw        PK         ! O8                 ! logging/MoveLogFormatterTest.phpnu Iw        PK         ! 5(                 ! logging/DatabaseLogEntryTest.phpnu Iw        PK         ! Y(1  1  #            " logging/ProtectLogFormatterTest.phpnu Iw        PK         ! }U  U  !            =" logging/MergeLogFormatterTest.phpnu Iw        PK         ! 4kW	  	  "            F" logging/PatrolLogFormatterTest.phpnu Iw        PK         ! 7g	  	               P" logging/LogFormatterTestCase.phpnu Iw        PK         ! g2B?  B?              `i" user/ActorMigrationTest.phpnu Iw        PK         ! 2۳5\  \              " user/ActorMigrationTest.sqlnu Iw        PK         ! Z1"  "  (            " user/TalkPageNotificationManagerTest.phpnu Iw        PK         ! gI  I               |" user/UserGroupMembershipTest.phpnu Iw        PK         ! lķ  ķ              " user/UserGroupManagerTest.phpnu Iw        PK         ! @[<B  <B              &# user/BotPasswordTest.phpnu Iw        PK         ! YZ                # user/ExternalUserNamesTest.phpnu Iw        PK         ! i9  9              # user/LocalIdLookupTest.phpnu Iw        PK         ! v  v              )# user/UserTest.phpnu Iw        PK         ! !>pU  U              $ user/UserEditTrackerTest.phpnu Iw        PK         ! nR&S  &S              $ user/PasswordResetTest.phpnu Iw        PK         ! w@9   9               :% MockServiceWiring.phpnu Iw        PK         ! Df                o;% actions/RollbackActionTest.phpnu Iw        PK         ! L%                ZK% actions/ActionTest.phpnu Iw        PK         ! n?h9  9              'g% actions/WatchActionTest.phpnu Iw        PK         ! #Y3  Y3               % actions/ActionEntryPointTest.phpnu Iw        PK         ! A"    (            % actions/ActionFactoryIntegrationTest.phpnu Iw        PK         ! 7q
  q
              % SampleTest.phpnu Iw        PK         ! R?M M             Y% Output/OutputPageTest.phpnu Iw        PK         !                 ' content/TextContentTest.phpnu Iw        PK         ! MS  S              7' content/ContentHandlerTest.phpnu Iw        PK         ! O6  6  5            o( content/Transform/PreSaveTransformParamsValueTest.phpnu Iw        PK         ! U,h    ,            
( content/Transform/ContentTransformerTest.phpnu Iw        PK         !     (            3( content/JavaScriptContentHandlerTest.phpnu Iw        PK         ! Uik	  k	  D            
( content/RegistrationContentHandlerFactoryToMediaWikiServicesTest.phpnu Iw        PK         ! 6~F   F               '( content/WikitextContentTest.phpnu Iw        PK         ! N&8  8  -            ~H( content/JsonContentHandlerIntegrationTest.phpnu Iw        PK         ! 1  1              Y( content/CssContentTest.phpnu Iw        PK         ! /t    -            f( content/TextContentHandlerIntegrationTest.phpnu Iw        PK         ! h:H%  H%  "            o( content/ContentModelChangeTest.phpnu Iw        PK         ! F'  F'  1            ,( content/WikitextContentHandlerIntegrationTest.phpnu Iw        PK         ! ?    !            Ӽ( content/CssContentHandlerTest.phpnu Iw        PK         ! nED!  !  &            ( content/WikitextContentHandlerTest.phpnu Iw        PK         ! ̌Q?    3            ( content/JavaScriptContentHandlerIntegrationTest.phpnu Iw        PK         ! h  h              +( content/FallbackContentTest.phpnu Iw        PK         ! w    !            ( content/WikitextStructureTest.phpnu Iw        PK         ! V=    !            E	) content/JavaScriptContentTest.phpnu Iw        PK         ! d];/	  	  &            _) content/FallbackContentHandlerTest.phpnu Iw        PK         ! q    ,            ') content/CssContentHandlerIntegrationTest.phpnu Iw        PK         ! q)  )              #-) TestUser.phpnu Iw        PK         ! gn    0            A) OutputTransform/OutputTransformStageTestBase.phpnu Iw        PK         ! ,;U  U  4            J) OutputTransform/DefaultOutputPipelineFactoryTest.phpnu Iw        PK         ! q                Tf) OutputTransform/TestUtils.phpnu Iw        PK         ! ӂ    0            ) OutputTransform/ContentDOMTransformStageTest.phpnu Iw        PK         ! AGZ    8            ) OutputTransform/Stages/HandleParsoidSectionLinksTest.phpnu Iw        PK         ! 3I  I  3            1) OutputTransform/Stages/ExpandToAbsoluteUrlsTest.phpnu Iw        PK         ! )/[    *            ݣ) OutputTransform/Stages/ExtractBodyTest.phpnu Iw        PK         ! R    0            ) OutputTransform/Stages/DeduplicateStylesTest.phpnu Iw        PK         ! C    /            #) OutputTransform/Stages/HandleTOCMarkersTest.phpnu Iw        PK         ! .=    1            9) OutputTransform/Stages/AddWrapperDivClassTest.phpnu Iw        PK         ! UC  C  =            =) OutputTransform/Stages/ExecutePostCacheTransformHooksTest.phpnu Iw        PK         ! .I|  |  .            ) OutputTransform/Stages/RenderDebugInfoTest.phpnu Iw        PK         ! ,    8            ) OutputTransform/Stages/HydrateHeaderPlaceholdersTest.phpnu Iw        PK         ! <~,u  u  0            ) OutputTransform/Stages/AddRedirectHeaderTest.phpnu Iw        PK         !     1            ) OutputTransform/Stages/HandleSectionLinksTest.phpnu Iw        PK         ! g    (            * OutputTransform/Stages/HardenNFCTest.phpnu Iw        PK         ! n=    2            I* OutputTransform/Stages/ParsoidLocalizationTest.phpnu Iw        PK         ! vk    *            B(* OutputTransform/DummyDOMTransformStage.phpnu Iw        PK         ! hz  z  !            ** CommentStore/CommentStoreTest.sqlnu Iw        PK         ! $  $  !            ,* CommentStore/CommentStoreTest.phpnu Iw        PK         ! "    (            Q* CommentStore/CommentStoreCommentTest.phpnu Iw        PK         ! Ʉ                T* utils/BatchRowUpdateTest.phpnu Iw        PK         ! ЬsT  T              q* utils/GitInfoTest.phpnu Iw        PK         ! .=	  =	               ,* utils/ZipDirectoryReaderTest.phpnu Iw        PK         ! ݝ                 * utils/FileContentsHasherTest.phpnu Iw        PK         ! @%IMS
  S
              * utils/MWTimestampTest.phpnu Iw        PK         ! ?;&  ;&              B* linker/LinkRendererTest.phpnu Iw        PK         ! 2'  '              * linker/LinkTargetStoreTest.phpnu Iw        PK         ! @6!3  !3              =* linker/LinkerTest.phpnu Iw        PK         ! c^A"  "              * site/HashSiteStoreTest.phpnu Iw        PK         ! *Qs  s              + site/SiteImporterTest.xmlnu Iw        PK         ! f~                + site/SiteTest.phpnu Iw        PK         ! y,t
  
              -+ site/SiteImporterTest.phpnu Iw        PK         ! mE  E              "F+ site/DBSiteStoreTest.phpnu Iw        PK         ! #                X+ site/TestSites.phpnu Iw        PK         ! Bq.  .              f+ site/MediaWikiSiteTest.phpnu Iw        PK         ! `r                cu+ site/SiteListTest.phpnu Iw        PK         !                 + site/SiteExporterTest.phpnu Iw        PK         ! \                + site/CachingSiteStoreTest.phpnu Iw        PK         ! YR:h"  h"  #            %+ password/UserPasswordPolicyTest.phpnu Iw        PK         ! W2    *            + collation/CustomUppercaseCollationTest.phpnu Iw        PK         ! t'  '              + collation/CollationTest.phpnu Iw        PK         ! *
  
  $            v+ collation/RemoteIcuCollationTest.phpnu Iw        PK         ! 3&  3&              + WikiMap/WikiMapTest.phpnu Iw        PK         ! )  )  $            V, filebackend/SwiftFileBackendTest.phpnu Iw        PK         ! ez-  -  ;            1, filebackend/lockmanager/LockManagerGroupIntegrationTest.phpnu Iw        PK         ! ¶    )            k8, filebackend/FileBackendMultiWriteTest.phpnu Iw        PK         ! c    /            D, filebackend/FileBackendGroupIntegrationTest.phpnu Iw        PK         ! c    $            M, filebackend/FileBackendStoreTest.phpnu Iw        PK         ! wɤ:  :              W, cache/LinkCacheTest.phpnu Iw        PK         ! ՘                N, cache/GenderCacheTest.phpnu Iw        PK         ! L?z  z              , cache/BacklinkCacheTest.phpnu Iw        PK         ! vz<|  |              b, cache/LinkCacheTestTrait.phpnu Iw        PK         !  !   !              *, cache/LinkBatchTest.phpnu Iw        PK         ! F  F  -            q, preferences/DefaultPreferencesFactoryTest.phpnu Iw        PK         ! _X    &            "- preferences/SignatureValidatorTest.phpnu Iw        PK         ! ayT  T               ?- ExternalLinks/LinkFilterTest.phpnu Iw        PK         ! ?    #            /- Category/TrackingCategoriesTest.phpnu Iw        PK         ! )W                +- Category/CategoryTest.phpnu Iw        PK         ! 7                c- Request/FauxRequestTest.phpnu Iw        PK         ! 9X  X  %            T- Request/ContentSecurityPolicyTest.phpnu Iw        PK         ! Ig                {%. Request/WebResponseTest.phpnu Iw        PK         ! V  V              *. Request/WebRequestTest.phpnu Iw        PK         ! X"r  r  !            . title/MediaWikiTitleCodecTest.phpnu Iw        PK         ! _bG0 0             t. title/TitleTest.phpnu Iw        PK         ! 2Ҧ
  
  %            0 title/NaiveImportTitleFactoryTest.phpnu Iw        PK         ! zXy  y              0 title/TitleUrlTest.phpnu Iw        PK         ! 	Ԏ  Ԏ               0 title/NamespaceInfoTest.phpnu Iw        PK         !     )            1 title/NamespaceImportTitleFactoryTest.phpnu Iw        PK         ! arۑ	  	               1 title/TemplateCategoriesTest.phpnu Iw        PK         ! E"!z
  z
  '            4)1 title/SubpageImportTitleFactoryTest.phpnu Iw        PK         ! >N                41 Message/TextFormatterTest.phpnu Iw        PK         ! d
  d
  '            PD1 Message/MessageFormatterFactoryTest.phpnu Iw        PK         ! q+      *            O1 installer/patches/drop-table-updatelog.sqlnu Iw        PK         ! i    !            O1 installer/DatabaseUpdaterTest.phpnu Iw        PK         ! k    5            V1 installer/DatabaseUpdaterWhenUpdateLogMissingTest.phpnu Iw        PK         ! 
  
              \1 installer/WebInstallerTest.phpnu Iw        PK         ! 
R,  ,  $            W`1 installer/WebInstallerOutputTest.phpnu Iw        PK         ! uJ  J              b1 languages/LanguageBhoTest.phpnu Iw        PK         !  QI  I              nf1 languages/LanguageHiTest.phpnu Iw        PK         ! Oɽ                j1 languages/LanguageSkTest.phpnu Iw        PK         ! g=h                n1 languages/LanguageMtTest.phpnu Iw        PK         ! C                7u1 languages/LanguageCuTest.phpnu Iw        PK         ! 4                qy1 languages/LanguageMnTest.phpnu Iw        PK         ! K                r|1 languages/LanguageMkTest.phpnu Iw        PK         ! 2a
  
              1 languages/LanguageArTest.phpnu Iw        PK         ! yEP  P              1 languages/LanguageTlTest.phpnu Iw        PK         ! G                1 languages/LanguageSeTest.phpnu Iw        PK         ! H                Γ1 languages/LanguageTrTest.phpnu Iw        PK         ! ] \  \              1 languages/LanguageWaTest.phpnu Iw        PK         ! f                 C1 languages/LanguageTyvTest.phpnu Iw        PK         ! b                }1 languages/LanguageSmaTest.phpnu Iw        PK         ! 4~h                1 languages/LanguageGvTest.phpnu Iw        PK         ! 嫱o                2 languages/LanguageLvTest.phpnu Iw        PK         ! w                q2 languages/LanguageMlTest.phpnu Iw        PK         ! 7                y2 languages/LanguageBsTest.phpnu Iw        PK         ! :ʡL                2 languages/LanguageMoTest.phpnu Iw        PK         ! 6f  f              22 languages/LanguageHeTest.phpnu Iw        PK         ! gHI  I              &2 languages/LanguageFrTest.phpnu Iw        PK         ! y&                y*2 languages/LanguageRuTest.phpnu Iw        PK         ! y2  2              A2 languages/LanguageArqTest.phpnu Iw        PK         ! *A6)
  )
              )D2 languages/LanguageKaTest.phpnu Iw        PK         !                 N2 languages/LanguageSlTest.phpnu Iw        PK         ! -"t                R2 languages/LanguageBeTest.phpnu Iw        PK         ! jI  I              W2 languages/LanguageAmTest.phpnu Iw        PK         !                 Z2 languages/LanguageCyTest.phpnu Iw        PK         ! Ş                ^2 languages/LanguageHyTest.phpnu Iw        PK         ! t                b2 languages/LanguageNlTest.phpnu Iw        PK         ! EUI                f2 languages/LanguageHrTest.phpnu Iw        PK         ! !_sV
  V
              j2 languages/LanguagePlTest.phpnu Iw        PK         ! L_
  
              cu2 languages/LanguageUkTest.phpnu Iw        PK         ! VvQ  Q              2 languages/LanguageZhTest.phpnu Iw        PK         ! r~k  k              .2 languages/LanguageKshTest.phpnu Iw        PK         ! Q<                2 languages/LanguageKkTest.phpnu Iw        PK         ! _                2 languages/LanguageDsbTest.phpnu Iw        PK         ! UW$,  ,              -2 languages/LanguageTiTest.phpnu Iw        PK         ! /I  I              2 languages/LanguageLnTest.phpnu Iw        PK         ! 	  	  #            :2 languages/LanguageBe_taraskTest.phpnu Iw        PK         ! I                v2 languages/LanguageRoTest.phpnu Iw        PK         ! n~V  V              ͣ2 languages/LanguageApcTest.phpnu Iw        PK         ! +)                p2 languages/LanguageGaTest.phpnu Iw        PK         ! b                C2 languages/LanguageHsbTest.phpnu Iw        PK         ! _n=                q2 languages/LanguageLtTest.phpnu Iw        PK         ! TL:                2 languages/LanguageSrTest.phpnu Iw        PK         !                  z2 languages/LanguageCsTest.phpnu Iw        PK         ! $                2 languages/LanguageShTest.phpnu Iw        PK         ! N1e--  -              2 languages/LanguageNsoTest.phpnu Iw        PK         ! 6    "            i2 languages/LanguageIsv_latnTest.phpnu Iw        PK         !                 :2 languages/LanguageSgsTest.phpnu Iw        PK         ! nb  b              2 languages/LanguageHuTest.phpnu Iw        PK         ! zrc  c              2 languages/LanguageMgTest.phpnu Iw        PK         ! M]                t2 languages/LanguageGdTest.phpnu Iw        PK         ! Z                2 mail/MailAddressTest.phpnu Iw        PK         ! |5  5              2 mail/EmailNotificationTest.phpnu Iw        PK         ! Ԑ;9w  w              =2 db/LBFactoryTest.phpnu Iw        PK         ! 0Zl  Zl              )p3 db/LoadBalancerTest.phpnu Iw        PK         ! GV                3 db/DatabaseTestHelper.phpnu Iw        PK         ! z    )            3 linkeddata/PageDataRequestHandlerTest.phpnu Iw        PK         ! `ΏR  R  !            4 Revision/RenderedRevisionTest.phpnu Iw        PK         ! ʳ               h4 Revision/RevisionStoreDbTest.phpnu Iw        PK         ! &  &  "            h5 Revision/RevisionQueryInfoTest.phpnu Iw        PK         ! @7G    &            6 Revision/MutableRevisionRecordTest.phpnu Iw        PK         ! 2},r,  r,              $6 Revision/RevisionStoreTest.phpnu Iw        PK         ! ?l%    &            Q6 Revision/RevisionArchiveRecordTest.phpnu Iw        PK         ! &v&  v&  '            n6 Revision/ArchivedRevisionLookupTest.phpnu Iw        PK         ! 3ڎH  H  !            \6 Revision/RevisionRendererTest.phpnu Iw        PK         ! w1  1  $            ;6 Revision/RevisionStoreRecordTest.phpnu Iw        PK         ! rzW  W              6 diff/DifferenceEngineTest.phpnu Iw        PK         ! (|    !            T7 diff/TextSlotDiffRendererTest.phpnu Iw        PK         ! ΁E=E  E  *            .p7 diff/TextDiffer/ManifoldTextDifferTest.phpnu Iw        PK         ! [8    +            ~7 diff/TextDiffer/Wikidiff2TextDifferTest.phpnu Iw        PK         !     *            ؐ7 diff/TextDiffer/ExternalTextDifferTest.phpnu Iw        PK         ! Y    "            7 diff/TextDiffer/TextDifferData.phpnu Iw        PK         ! @To@   @   #            7 diff/TextDiffer/externalDiffTest.shnu ̗        PK         ! DԀ    %            s7 diff/TextDiffer/PhpTextDifferTest.phpnu Iw        PK         ! 1a                H7 diff/CustomDifferenceEngine.phpnu Iw        PK         ! {-M  M              7 diff/SlotDiffRendererTest.phpnu Iw        PK         ! O    -            -7 diff/DifferenceEngineSlotDiffRendererTest.phpnu Iw        PK         ! zx  x              7 deferred/LinksUpdateTest.phpnu Iw        PK         ! zs6  6               h58 deferred/DeferredUpdatesTest.phpnu Iw        PK         ! ^BV  V              k8 deferred/SearchUpdateTest.phpnu Iw        PK         ! `l	  	               cr8 deferred/SiteStatsUpdateTest.phpnu Iw        PK         ! ;                |8 deferred/CdnCacheUpdateTest.phpnu Iw        PK         ! !HO^  ^  +            8 deferred/RefreshSecondaryDataUpdateTest.phpnu Iw        PK         ! Lޝ	    $            j8 deferred/LinksDeletionUpdateTest.phpnu Iw        PK         ! 
                h8 api/ApiEntryPointTest.phpnu Iw        PK         ! ~r_~8  ~8              98 api/ApiStashEditTest.phpnu Iw        PK         ! a'  '  !            8 api/ApiChangeContentModelTest.phpnu Iw        PK         ! B(                9 api/ApiEditPageTest.phpnu Iw        PK         ! K&  &  0            9 api/Validator/ApiParamValidatorCallbacksTest.phpnu Iw        PK         ! f"  "  "            : api/Validator/SubmoduleDefTest.phpnu Iw        PK         ! `c#MS  S  '             /: api/Validator/ApiParamValidatorTest.phpnu Iw        PK         ! c                : api/ApiUsageExceptionTest.phpnu Iw        PK         ! R*:  :              : api/ApiOptionsTest.phpnu Iw        PK         ! 7މ                : api/generateRandomImages.phpnu Iw        PK         ! n[
  [
              .: api/ApiCheckTokenTest.phpnu Iw        PK         ! J!  !              : api/format/ApiFormatPhpTest.phpnu Iw        PK         ! }  }              B: api/format/ApiFormatXmlTest.phpnu Iw        PK         ! a'                 : api/format/ApiFormatNoneTest.phpnu Iw        PK         ! Z                 0; api/format/ApiFormatTestBase.phpnu Iw        PK         ! 68  8               p; api/format/ApiFormatBaseTest.phpnu Iw        PK         ! V                 H; api/format/ApiFormatJsonTest.phpnu Iw        PK         ! dJC  C              [; api/format/ApiFormatRawTest.phpnu Iw        PK         ! D۵    2            h; api/ApiSetNotificationTimestampIntegrationTest.phpnu Iw        PK         ! v    "            o; api/ApiAcquireTempUserNameTest.phpnu Iw        PK         ! m                Uw; api/ApiProtectTest.phpnu Iw        PK         ! |_8&  &              |; api/ApiBlockTest.phpnu Iw        PK         ! =1v  v              T; api/ApiParseTest.phpnu Iw        PK         ! 8`n(  (              < api/ApiUploadTest.phpnu Iw        PK         ! H+                A< api/ApiUploadTestCase.phpnu Iw        PK         ! ă                T< api/ApiDisabledTest.phpnu Iw        PK         ! s*ƃ)  )              U< api/ApiTestCase.phpnu Iw        PK         ! RB                < api/ApiCSPReportTest.phpnu Iw        PK         ! *  *              < api/ApiUnblockTest.phpnu Iw        PK         ! V:v    "            < api/ApiCreateTempUserTraitTest.phpnu Iw        PK         ! m2@  2@              < api/ApiMoveTest.phpnu Iw        PK         ! ڏ4!L  L              < api/ApiTestContext.phpnu Iw        PK         ! v9                < api/ApiBaseTest.phpnu Iw        PK         ! +w '  '  "            = api/ApiContinuationManagerTest.phpnu Iw        PK         ! s                = api/ApiOpenSearchTest.phpnu Iw        PK         !                  = api/ApiBlockInfoTraitTest.phpnu Iw        PK         ! \˼}  }              = api/ApiMessageTest.phpnu Iw        PK         ! 	+֥                = api/ApiClearHasMsgTest.phpnu Iw        PK         ! 9NG  G              = api/ApiRollbackTest.phpnu Iw        PK         ! x8                > api/RandomImageGenerator.phpnu Iw        PK         ! }                V> api/ApiPurgeTest.phpnu Iw        PK         ! 9C@  C@              o*> api/ApiPageSetTest.phpnu Iw        PK         ! FH                j> api/ApiResultTest.phpnu Iw        PK         !  H                ,? api/ApiMainTest.phpnu Iw        PK         ! E                [? api/ApiDeleteTest.phpnu Iw        PK         ! \;NF]  F]              }? api/ApiErrorFormatterTest.phpnu Iw        PK         ! [mæ  æ              6@ api/ApiComparePagesTest.phpnu Iw        PK         ! Qwf                @ api/ApiWatchTest.phpnu Iw        PK         ! M*"  "  #            O@ api/query/ApiQueryImageInfoTest.phpnu Iw        PK         ! D}&b  b               WA api/query/ApiQuerySearchTest.phpnu Iw        PK         ! 8    .            	(A api/query/ApiQueryWatchlistIntegrationTest.phpnu Iw        PK         ! P!"  !"              iA api/query/ApiQueryTest.phpnu Iw        PK         ! :  :  &            A api/query/ApiQueryPrefixSearchTest.phpnu Iw        PK         ! }&    &            dB api/query/ApiQueryContinueTestBase.phpnu Iw        PK         ! lG(  (              B api/query/ApiQueryInfoTest.phpnu Iw        PK         ! W
  
  (            ~CB api/query/ApiQueryBlockInfoTraitTest.phpnu Iw        PK         ! Si  i              MB api/query/ApiQueryTestBase.phpnu Iw        PK         ! ˴a$  a$              aB api/query/ApiQueryBasicTest.phpnu Iw        PK         ! Vq5  5  -            AB api/query/ApiQueryAllDeletedRevisionsTest.phpnu Iw        PK         ! J+  +  "            ӑB api/query/ApiQueryAllUsersTest.phpnu Iw        PK         ! 0~>  >  1            B api/query/ApiQueryWatchlistRawIntegrationTest.phpnu Iw        PK         ! b    "            B api/query/ApiQueryDisabledTest.phpnu Iw        PK         ! iN                 Q C api/query/ApiQueryTokensTest.phpnu Iw        PK         ! r  r  "            ]C api/query/ApiQuerySiteinfoTest.phpnu Iw        PK         ! \(    &            t{C api/query/ApiQueryLanguageinfoTest.phpnu Iw        PK         ! Z$    #            C api/query/ApiQueryLogEventsTest.phpnu Iw        PK         ! v.    "            C api/query/ApiQueryAllPagesTest.phpnu Iw        PK         ! `E{  {  &            5C api/query/ApiQueryUserContribsTest.phpnu Iw        PK         ! -f    &            C api/query/ApiQueryAllRevisionsTest.phpnu Iw        PK         ! Iǫ    #            wC api/query/ApiQueryRevisionsTest.phpnu Iw        PK         ! }B    "            C api/query/ApiQueryUserInfoTest.phpnu Iw        PK         ! 0f  0f  2            7C api/query/ApiQueryRecentChangesIntegrationTest.phpnu Iw        PK         !                  FD api/query/ApiQueryBlocksTest.phpnu Iw        PK         ! HV
  V
  #            ZD api/query/ApiQueryContinue2Test.phpnu Iw        PK         ! 2ղ.  .  "            weD api/query/ApiQueryContinueTest.phpnu Iw        PK         ! v=                {D api/MockApi.phpnu Iw        PK         ! km2  2              ȖD api/ApiRevisionDeleteTest.phpnu Iw        PK         ! &,                GD api/ApiUndeleteTest.phpnu Iw        PK         ! >'  '              SD api/ApiUserrightsTest.phpnu Iw        PK         ! 1ڇ                %D api/MockApiQueryBase.phpnu Iw        PK         ! ?                D api/ApiLogoutTest.phpnu Iw        PK         ! G.p  p               ;D api/ApiFeedRecentChangesTest.phpnu Iw        PK         ! \+  +              D api/ApiLoginTest.phpnu Iw        PK         ! jU,  ,              <E skins/SideBarTest.phpnu Iw        PK         ! ܛJ                -E skins/SkinMustacheTest.phpnu Iw        PK         ! ik                  
=E skins/test.mustachenu Iw        PK         ! "CQ  CQ              Y=E skins/SkinTest.phpnu Iw        PK         ! '                ގE skins/SkinTemplateTest.phpnu Iw        PK         ! +ʀo                E interwiki/InterwikiTest.phpnu Iw        PK         ! 2N    (            E interwiki/ClassicInterwikiLookupTest.phpnu Iw        PK         !     '            E poolcounter/PoolWorkArticleViewTest.phpnu Iw        PK         ! l    *            *E poolcounter/PoolWorkArticleViewOldTest.phpnu Iw        PK         ! f    .            6E poolcounter/PoolWorkArticleViewCurrentTest.phpnu Iw        PK         ! ?u##  #              	F sparql/SparqlClientTest.phpnu Iw        PK         ! 87  7  !            F profiler/ProfilingContextTest.phpnu Iw        PK         ! F    $            $F block/AutoblockExemptionListTest.phpnu Iw        PK         ! te  e              w)F block/BlockManagerTest.phpnu Iw        PK         !     !            F block/BlockErrorFormatterTest.phpnu Iw        PK         ! K]  K]              F block/DatabaseBlockTest.phpnu Iw        PK         ! ޫ!  !              ,G block/CompositeBlockTest.phpnu Iw        PK         ! D    )            a.G block/Restriction/PageRestrictionTest.phpnu Iw        PK         ! J    +            k7G block/Restriction/ActionRestrictionTest.phpnu Iw        PK         ! z/  /  )            :G block/Restriction/RestrictionTestCase.phpnu Iw        PK         ! esyW    .            fBG block/Restriction/NamespaceRestrictionTest.phpnu Iw        PK         ! ?	X@  X@  #            zFG block/BlockRestrictionStoreTest.phpnu Iw        PK         ! ber  r              %G Status/StatusTest.phpnu Iw        PK         ! J=  =              #G Status/StatusFormatterTest.phpnu Iw        PK         ! QS  S  .            8H recentchanges/CategoryMembershipChangeTest.phpnu Iw        PK         ! '$    )            PH recentchanges/TestRecentChangesHelper.phpnu Iw        PK         ! =    )            bH recentchanges/RCCacheEntryFactoryTest.phpnu Iw        PK         ! R    .            H recentchanges/rcfeed/RCFeedIntegrationTest.phpnu Iw        PK         ! ˣ<  <  ,            H recentchanges/RecentChangesUpdateJobTest.phpnu Iw        PK         ! ?    $             H recentchanges/OldChangesListTest.phpnu Iw        PK         ! #OO@  @  "            :H recentchanges/RecentChangeTest.phpnu Iw        PK         ! E'  '  )            CH recentchanges/EnhancedChangesListTest.phpnu Iw        PK         ! &!5  5              A#I xml/XmlTest.phpnu Iw        PK         ! f    %            YI ResourceLoader/ResourceLoaderTest.phpnu Iw        PK         ! W                OI ResourceLoader/ContextTest.phpnu Iw        PK         ! CU7  U7  !            J ResourceLoader/ClientHtmlTest.phpnu Iw        PK         ! a9R    (            9FJ ResourceLoader/UserOptionsModuleTest.phpnu Iw        PK         ! >4U,  U,              SJ ResourceLoader/ModuleTest.phpnu Iw        PK         ! %Gm  m  &            EJ ResourceLoader/OOUIImageModuleTest.phpnu Iw        PK         ! k?  ?  !            J ResourceLoader/SkinModuleTest.phpnu Iw        PK         ! Z_    (            KJ ResourceLoader/DerivativeContextTest.phpnu Iw        PK         ! (a?  ?  "            eJ ResourceLoader/CodexModuleTest.phpnu Iw        PK         ! )abBY  BY  $            K ResourceLoader/StartUpModuleTest.phpnu Iw        PK         ! I&q7  q7  !            dvK ResourceLoader/WikiModuleTest.phpnu Iw        PK         ! 0    (            &K ResourceLoader/LessVarFileModuleTest.phpnu Iw        PK         ! u      '            ;K ResourceLoader/templates/template2.htmlnu Iw        PK         ! ͌      4            K ResourceLoader/templates/template_awesome.handlebarsnu Iw        PK         ! H      &            K ResourceLoader/templates/template.htmlnu Iw        PK         ! {s+  +  '            zK ResourceLoader/MessageBlobStoreTest.phpnu Iw        PK         ! L_N  N  "            K ResourceLoader/ImageModuleTest.phpnu Iw        PK         ! XNu<m  <m  !            K ResourceLoader/FileModuleTest.phpnu Iw        PK         ! lgs  s  /            )_L ResourceLoader/ResourceLoaderEntryPointTest.phpnu Iw        PK         ! 3S  3S  $            fL filerepo/ThumbnailEntryPointTest.phpnu Iw        PK         ! (  (              L filerepo/LocalRepoTest.phpnu Iw        PK         ! L'=  =              L filerepo/file/FileTest.phpnu Iw        PK         ! 1}L|  L|               M filerepo/file/LocalFileTest.phpnu Iw        PK         ! otȔ    )            M filerepo/FileBackendDBRepoWrapperTest.phpnu Iw        PK         ! 1                   tM filerepo/FileRepoTest.phpnu Iw        PK         ! 
V  V  '            M filerepo/Thumbnail404EntryPointTest.phpnu Iw        PK         !     &            jM filerepo/MigrateFileRepoLayoutTest.phpnu Iw        PK         ! CQ                M filerepo/StoreBatchTest.phpnu Iw        PK         ! .u.  .  ,            M filerepo/AuthenticatedFileEntryPointTest.phpnu Iw        PK         ! 5y\  \              (N filerepo/RepoGroupTest.phpnu Iw        PK         ! /                K0N TestUserRegistry.phpnu Iw        PK         ! $                >N session/CsrfTokenSetTest.phpnu Iw        PK         ! ?4   4               EN session/UserInfoTest.phpnu Iw        PK         ! M`  `  %            cfN session/CookieSessionProviderTest.phpnu Iw        PK         ! Iɀߕ                N session/SessionTest.phpnu Iw        PK         ! %-  -  !            jN session/PHPSessionHandlerTest.phpnu Iw        PK         ! VHx:  :              O session/TestUtils.phpnu Iw        PK         ! .U                @O session/SessionManagerTest.phpnu Iw        PK         ! )JX(  (  2            fO session/ImmutableSessionProviderWithCookieTest.phpnu Iw        PK         ! |O1  O1              P session/SessionInfoTest.phpnu Iw        PK         ! .ྚ                5NP session/SessionBackendTest.phpnu Iw        PK         ! tfb                AP session/SessionProviderTest.phpnu Iw        PK         ! ǭV0  0  *            ,Q session/BotPasswordSessionProviderTest.phpnu Iw        PK         ! ѿ2  2              8Q session/TestBagOStuff.phpnu Iw        PK         ! VprL
  L
              AQ page/WikiFilePageTest.phpnu Iw        PK         ! V+!  +!              KQ page/ArticleTest.phpnu Iw        PK         ! 	.(~"  ~"              mQ page/MergeHistoryTest.phpnu Iw        PK         ! L                Q page/WikiPageDbTest.phpnu Iw        PK         ! R -Q}  }  #            A}R page/PageSelectQueryBuilderTest.phpnu Iw        PK         ! Li  i              R page/ArticleViewTest.phpnu Iw        PK         ! ;G$>O  O              HR page/ArticleTablesTest.phpnu Iw        PK         ! gb%  b%              S page/PagePropsTest.phpnu Iw        PK         !  ,                *S page/WikiCategoryPageTest.phpnu Iw        PK         ! b  b              6S page/PageStoreTest.phpnu Iw        PK         ! &@n  n              S page/ParserOutputAccessTest.phpnu Iw        PK         ! 1                P'T page/PageArchiveTest.phpnu Iw        PK         ! da@C  C              +BT page/ImagePageTest.phpnu Iw        PK         ! htL  L              OT page/MovePageTest.phpnu Iw        PK         ! O                T page/UndeletePageTest.phpnu Iw        PK         ! 'l3                T page/ImagePage404Test.phpnu Iw        PK         ! @F  F  %            5T pager/RangeChronologicalPagerTest.phpnu Iw        PK         ! RJӵ                 пT pager/ContributionsPagerTest.phpnu Iw        PK         ! e                T pager/HistoryPagerTest.phpnu Iw        PK         ! No
  
  '            T pager/ReverseChronologicalPagerTest.phpnu Iw        PK         ! X0/Gn  n              T changetags/ChangeTagsTest.phpnu Iw        PK         ! >ư    !            <gU changetags/ChangeTagsListTest.phpnu Iw        PK         ! Ap  p  #            ;uU ThrottleFilterPresentationModel.phpnu Iw        PK         ! ]>$\                |U SpecsFormatter.phpnu Iw        PK         ! _k!  !  )            ֖U VariableGenerator/RCVariableGenerator.phpnu Iw        PK         ! m:
  
  .            LU VariableGenerator/VariableGeneratorFactory.phpnu Iw        PK         ! K~!  ~!  '            :U VariableGenerator/VariableGenerator.phpnu Iw        PK         ! n,  ,  *            U VariableGenerator/RunVariableGenerator.phpnu Iw        PK         !  .0I  I              V EditBox/EditBoxBuilder.phpnu Iw        PK         !                 'V EditBox/PlainEditBoxBuilder.phpnu Iw        PK         !                 *V EditBox/EditBoxField.phpnu Iw        PK         ! l5W
  W
  !            -V EditBox/EditBoxBuilderFactory.phpnu Iw        PK         ! V                J8V EditBox/AceEditBoxBuilder.phpnu Iw        PK         ! a(	  	              @FV Api/UnblockAutopromote.phpnu Iw        PK         ! fС
  
              PV Api/CheckSyntax.phpnu Iw        PK         ! u                ZV Api/EvalExpression.phpnu Iw        PK         ! pR                hV Api/AbuseLogPrivateDetails.phpnu Iw        PK         ! >  >              EwV Api/QueryAbuseLog.phpnu Iw        PK         ! >b                -V Api/CheckMatch.phpnu Iw        PK         ! &s!  !              
V Api/QueryAbuseFilters.phpnu Iw        PK         !  Sr  r              XV FilterRunnerFactory.phpnu Iw        PK         ! RNS(  S(              W AbuseLogger.phpnu Iw        PK         ! Ov                +W FilterUtils.phpnu Iw        PK         ! ikӠ                e.W BlockAutopromoteStore.phpnu Iw        PK         ! 2  2              N>W FilterRunner.phpnu Iw        PK         ! B                pW FilterImporter.phpnu Iw        PK         ! Ӳ    !            ݁W ChangeTags/ChangeTagValidator.phpnu Iw        PK         ! 6i                 ȈW ChangeTags/ChangeTagsManager.phpnu Iw        PK         ! @
                W ChangeTags/ChangeTagger.phpnu Iw        PK         ! 00  0  "            YW Special/AbuseFilterSpecialPage.phpnu Iw        PK         ! ӑ  ӑ              ۷W Special/SpecialAbuseLog.phpnu Iw        PK         ! R*(  (  "            IX Special/BlockedExternalDomains.phpnu Iw        PK         ! aF                  ?sX Special/SpecialAbuseFilter.phpnu Iw        PK         ! w7                 zX AbuseFilterPermissionManager.phpnu Iw        PK         ! B                 X Variables/VariablesFormatter.phpnu Iw        PK         ! $\                 X Variables/LazyLoadedVariable.phpnu Iw        PK         ! Pr  r              qX Variables/VariablesManager.phpnu Iw        PK         ! h+EP  EP  "            1X Variables/LazyVariableComputer.phpnu Iw        PK         ! &ς                 ,Y Variables/VariablesBlobStore.phpnu Iw        PK         ! ;o  o              ;Y Variables/VariableHolder.phpnu Iw        PK         ! _@  @  $            UGY Variables/UnsetVariableException.phpnu Iw        PK         ! *ȗu	  	              HY EchoNotifier.phpnu Iw        PK         ! >      "            =RY CentralDBNotAvailableException.phpnu Iw        PK         ! xy  y              SY ActionSpecifier.phpnu Iw        PK         ! em                ZY GlobalNameUtils.phpnu Iw        PK         ! r2&                %bY RunnerData.phpnu Iw        PK         ! k%  %              oY FilterStore.phpnu Iw        PK         ! iy&b    (            Y AbuseFilterPreAuthenticationProvider.phpnu Iw        PK         ! ĤF  F              Y KeywordsManager.phpnu Iw        PK         ! ]?T  T              Y InvalidImportDataException.phpnu Iw        PK         ! ;U    .            hY LogFormatter/AbuseFilterModifyLogFormatter.phpnu Iw        PK         ! ۪<6  6  0            Y LogFormatter/ProtectedVarsAccessLogFormatter.phpnu Iw        PK         ! 9}    8            YY LogFormatter/AbuseFilterBlockedDomainHitLogFormatter.phpnu Iw        PK         ! mz  z  0            Y LogFormatter/AbuseFilterSuppressLogFormatter.phpnu Iw        PK         ! Hݹ    %            cY LogFormatter/AbuseLogHitFormatter.phpnu Iw        PK         ! @    .            qZ LogFormatter/AbuseFilterRightsLogFormatter.phpnu Iw        PK         ! G3                YZ AbuseFilterChangesList.phpnu Iw        PK         ! .D                IZ CentralDBManager.phpnu Iw        PK         ! $s[                "Z Watcher/EmergencyWatcher.phpnu Iw        PK         ! 0                 7Z Watcher/Watcher.phpnu Iw        PK         ! e    !            9Z Watcher/UpdateHitCountWatcher.phpnu Iw        PK         ! 3R  R              @Z AbuseLoggerFactory.phpnu Iw        PK         ! .                  7MZ FilterProfiler.phpnu Iw        PK         !                 |mZ FilterCompare.phpnu Iw        PK         ! x
  
              >vZ TextExtractor.phpnu Iw        PK         ! f$                jZ FilterUser.phpnu Iw        PK         ! >7                Z EmergencyCache.phpnu Iw        PK         ! f    !            Z TableDiffFormatterFullContext.phpnu Iw        PK         ! #8[  [              Z EditStashCache.phpnu Iw        PK         ! p4  4  %            7Z Consequences/ConsequencesExecutor.phpnu Iw        PK         ! 0*    #            jZ Consequences/ConsequencesLookup.phpnu Iw        PK         ! xw    '            jZ Consequences/Consequence/RangeBlock.phpnu Iw        PK         ! by    $            Z Consequences/Consequence/Degroup.phpnu Iw        PK         !     2            |[ Consequences/Consequence/ReversibleConsequence.phpnu Iw        PK         ! ^i    %            r[ Consequences/Consequence/Throttle.phpnu Iw        PK         ! rg    (            O*[ Consequences/Consequence/Consequence.phpnu Iw        PK         ! A    %            ,[ Consequences/Consequence/Disallow.phpnu Iw        PK         ! _  _  <            0[ Consequences/Consequence/ConsequencesDisablerConsequence.phpnu Iw        PK         ! 
  
  0            4[ Consequences/Consequence/BlockingConsequence.phpnu Iw        PK         ! G}  }  3            ?[ Consequences/Consequence/HookAborterConsequence.phpnu Iw        PK         ! \  \  "            A[ Consequences/Consequence/Block.phpnu Iw        PK         ! u?  ?               /O[ Consequences/Consequence/Tag.phpnu Iw        PK         ! 'L/  /  !            R[ Consequences/Consequence/Warn.phpnu Iw        PK         ! |K_^	  ^	  -            >^[ Consequences/Consequence/BlockAutopromote.phpnu Iw        PK         ! #                g[ Consequences/Parameters.phpnu Iw        PK         ! 7]0  0  %            ;n[ Consequences/ConsequencesRegistry.phpnu Iw        PK         ! ׮8  8  $            y[ Consequences/ConsequencesFactory.phpnu Iw        PK         ! t  t  ,            L[ Consequences/ConsequencesExecutorFactory.phpnu Iw        PK         ! zRw  w  2            [ Consequences/ConsequenceNotPrecheckedException.phpnu Iw        PK         ! ̘<                [ BlockedDomainFilter.phpnu Iw        PK         ! u    ,            [ Hooks/AbuseFilterGetDangerousActionsHook.phpnu Iw        PK         ! P    4            [ Hooks/AbuseFilterGenerateVarsForRecentChangeHook.phpnu Iw        PK         ! Eȭ    '            ^[ Hooks/AbuseFilterAlterVariablesHook.phpnu Iw        PK         ! Lm  m  +            4[ Hooks/AbuseFilterShouldFilterActionHook.phpnu Iw        PK         ! `E  E  ,            [ Hooks/AbuseFilterDeprecatedVariablesHook.phpnu Iw        PK         !     &            [ Hooks/AbuseFilterCustomActionsHook.phpnu Iw        PK         ! Tj7    (            [ Hooks/AbuseFilterContentToStringHook.phpnu Iw        PK         ! Qf                [ Hooks/AbuseFilterHookRunner.phpnu Iw        PK         ! Z`[I  I  %            T[ Hooks/AbuseFilterFilterActionHook.phpnu Iw        PK         ! '4  4  *            [ Hooks/AbuseFilterGenerateTitleVarsHook.phpnu Iw        PK         ! 4    )            [ Hooks/AbuseFilterGenerateUserVarsHook.phpnu Iw        PK         ! d>T  T  (            [ Hooks/AbuseFilterComputeVariableHook.phpnu Iw        PK         ! rg6  6  *            w[ Hooks/AbuseFilterInterceptVariableHook.phpnu Iw        PK         ! :.    ,            [ Hooks/AbuseFilterGenerateGenericVarsHook.phpnu Iw        PK         ! "Ztz                 ,[ Hooks/AbuseFilterBuilderHook.phpnu Iw        PK         ! r    $            P[ Hooks/Handlers/ChangeTagsHandler.phpnu Iw        PK         !     #            [ Hooks/Handlers/ToolLinksHandler.phpnu Iw        PK         ! gS    #            \ Hooks/Handlers/UserMergeHandler.phpnu Iw        PK         ! u2ڢ                	\ Hooks/Handlers/EchoHandler.phpnu Iw        PK         ! o}    *            \ Hooks/Handlers/RecentChangeSaveHandler.phpnu Iw        PK         ! )    #            Y\ Hooks/Handlers/CheckUserHandler.phpnu Iw        PK         ! Q+  Q+  )            \ Hooks/Handlers/FilteredActionsHandler.phpnu Iw        PK         ! 	    %            TF\ Hooks/Handlers/PreferencesHandler.phpnu Iw        PK         ! /      +            N\ Hooks/Handlers/AutoPromoteGroupsHandler.phpnu Iw        PK         ! 0Er    %             V\ Hooks/Handlers/ConfirmEditHandler.phpnu Iw        PK         ! n  n  "            0^\ Hooks/Handlers/PageSaveHandler.phpnu Iw        PK         ! ʞ2Դ    '            `\ Hooks/Handlers/SchemaChangesHandler.phpnu Iw        PK         ! 崄    '            |\ Hooks/Handlers/RegistrationCallback.phpnu Iw        PK         ! H    (            ۋ\ Hooks/Handlers/EditPermissionHandler.phpnu Iw        PK         ! nVA  A              H\ FilterLookup.phpnu Iw        PK         ! A%+e#  e#              \ BlockedDomainStorage.phpnu Iw        PK         ! Me&                D ] EditRevUpdater.phpnu Iw        PK         ! ڜ"1  1              n] FilterValidator.phpnu Iw        PK         ! ߯qI  I              zA] Parser/AFPTreeParser.phpnu Iw        PK         ! X=aLI  LI              S] Parser/SyntaxChecker.phpnu Iw        PK         ! =Is  s              ] Parser/RuleCheckerFactory.phpnu Iw        PK         ! D
  
  )            ] Parser/Exception/UserVisibleException.phpnu Iw        PK         ! h	  	  '            ] Parser/Exception/UserVisibleWarning.phpnu Iw        PK         ! Y.(  (  "            .] Parser/Exception/ExceptionBase.phpnu Iw        PK         ! E$/  /  ,            ] Parser/Exception/ConditionLimitException.phpnu Iw        PK         !  :    &            3] Parser/Exception/InternalException.phpnu Iw        PK         ! lr  r              ] Parser/AFPSyntaxTree.phpnu Iw        PK         ! `;  ;              Z] Parser/AFPParserState.phpnu Iw        PK         ! @z                ] Parser/ParserStatus.phpnu Iw        PK         ! (/                ] Parser/RuleCheckerStatus.phpnu Iw        PK         ! ܃_V  V              ^ Parser/AFPTreeNode.phpnu Iw        PK         ! :P                b^ Parser/AFPToken.phpnu Iw        PK         !  ֪  ֪              G^ Parser/FilterEvaluator.phpnu Iw        PK         ! 툄4  4              g^ Parser/AFPData.phpnu Iw        PK         ! _$                ^ Parser/AbuseFilterTokenizer.phpnu Iw        PK         ! K1BJm/  m/              _ AbuseFilterServices.phpnu Iw        PK         ! urHj  j               ;K_ Pager/GlobalAbuseFilterPager.phpnu Iw        PK         ! 6+GW4  4              V_ Pager/AbuseLogPager.phpnu Iw        PK         ! ?-"  "  !            R_ Pager/AbuseFilterHistoryPager.phpnu Iw        PK         ! 	2  	2              _ Pager/AbuseFilterPager.phpnu Iw        PK         ! 	H@T  T  !            _ Pager/AbuseFilterExaminePager.phpnu Iw        PK         ! 5d +  +              _ View/AbuseFilterViewTools.phpnu Iw        PK         ! Wz(  (  !            _ View/AbuseFilterViewTestBatch.phpnu Iw        PK         ! 7                ` View/AbuseFilterViewImport.phpnu Iw        PK         ! :d0+  0+              "` View/AbuseFilterViewDiff.phpnu Iw        PK         ! MGgh  h              M` View/AbuseFilterViewHistory.phpnu Iw        PK         ! 8:82  82              Da` View/AbuseFilterViewExamine.phpnu Iw        PK         ! ?R                ˓` View/HideAbuseLog.phpnu Iw        PK         ! %                ,` View/AbuseFilterView.phpnu Iw        PK         ! O                x` View/AbuseFilterViewEdit.phpnu Iw        PK         ! #/p,,  ,,              hsa View/AbuseFilterViewList.phpnu Iw        PK         ! >h.  h.              a View/AbuseFilterViewRevert.phpnu Iw        PK         ! Iq                a Filter/Filter.phpnu Iw        PK         ! ]]ͥ    "            sa Filter/FilterNotFoundException.phpnu Iw        PK         ! SC$                ja Filter/AbstractFilter.phpnu Iw        PK         ! <\4I  I  )            a Filter/FilterVersionNotFoundException.phpnu Iw        PK         ! Ku    0            ga Filter/ClosestFilterVersionNotFoundException.phpnu Iw        PK         ! ۏqH  H              Wa Filter/Specs.phpnu Iw        PK         ! $6U?  ?              a Filter/ExistingFilter.phpnu Iw        PK         ! 0iP  P              ga Filter/MutableFilter.phpnu Iw        PK         ! 1                b Filter/LastEditInfo.phpnu Iw        PK         ! 8D	  	              b Filter/Flags.phpnu Iw        PK         ! ^A  A              b Filter/HistoryFilter.phpnu Iw        PK         ! l                Vb AbuseFilter.phpnu Iw        PK         ! C  C              b ProtectedVarsAccessLogger.phpnu Iw        PK         ! y2  2              /b NoopGeoLocation.phpnu [        PK         ! K                0b HttpGeoLocation.phpnu [        PK         ! \s/  /              L7b GeoLocation.phpnu [        PK         ! Ѣy                8b Decisions.phpnu [        PK         ! TJM  M              Eb SpecialContact.phpnu [        PK         !                 b Hooks/ContactFormHook.phpnu [        PK         ! Tv l  l              b Hooks/HookRunner.phpnu [        PK         ! $;o;    !            b Hooks/ContactFromCompleteHook.phpnu [        PK         ! o ŀ    "            b specials/SpecialRandomPageTest.phpnu Iw        PK         ! d$  d$  &            Tb Html/TemplateParserIntegrationTest.phpnu Iw        PK         !     "            b export/WikiExporterFactoryTest.phpnu Iw        PK         ! 24                 Wb composer/LockFileCheckerTest.phpnu Iw        PK         ! 1o    3            b libs/lockmanager/LockManagerIntegrationTestBase.phpnu Iw        PK         ! .Tm
  
  3            b libs/lockmanager/MemcLockManagerIntegrationTest.phpnu Iw        PK         ! -    4            
b libs/lockmanager/RedisLockManagerIntegrationTest.phpnu Iw        PK         ! =  =  .            }b libs/rdbms/resultwrapper/ResultWrapperTest.phpnu Iw        PK         ! Ӂl   l   .            c libs/rdbms/resultwrapper/ResultWrapperTest.sqlnu Iw        PK         ! 5o    5            c libs/filebackend/MemoryFileBackendIntegrationTest.phpnu Iw        PK         ! FN?\  \  4            %c libs/filebackend/SwiftFileBackendIntegrationTest.phpnu Iw        PK         ! K  3            c libs/filebackend/FileBackendIntegrationTestBase.phpnu Iw        PK         ! tWn	  	  1            d libs/filebackend/FSFileBackendIntegrationTest.phpnu Iw        PK         ! {F맯    9            )d libs/filebackend/FileBackendMultiWriteIntegrationTest.phpnu Iw        PK         ! B  B  #            0d libs/uuid/GlobalIdGeneratorTest.phpnu Iw        PK         ! )    +            Hd language/LanguageFactoryIntegrationTest.phpnu Iw        PK         ! HãJ
  
  !            oQd language/SpecialPageAliasTest.phpnu Iw        PK         ! ;-d  d  (            o\d parser/ParserObserverIntegrationTest.phpnu Iw        PK         ! BA(                +ad parser/TidyTest.phpnu Iw        PK         ! B>N
  N
  %            gd parser/SanitizerValidateEmailTest.phpnu Iw        PK         !     $            rd parser/Parsoid/ParsoidParserTest.phpnu Iw        PK         ! Oi|!  !  /            ~d parser/Parsoid/LanguageVariantConverterTest.phpnu Iw        PK         ! gm    (            ޠd parser/Parsoid/Config/SiteConfigTest.phpnu Iw        PK         ! u8
  
  (            d parser/Parsoid/Config/DataAccessTest.phpnu Iw        PK         ! j}    +            Ӱd parser/Parsoid/HtmlTransformFactoryTest.phpnu Iw        PK         ! !G_	:  	:  -            (d parser/Parsoid/HtmlToContentTransformTest.phpnu Iw        PK         !     .            d parser/Parsoid/data/Transform/Minimal-999.htmlnu Iw        PK         ! 7      *            d parser/Parsoid/data/Transform/Minimal.htmlnu Iw        PK         !      -            Hd parser/Parsoid/data/Transform/JsonConfig.htmlnu Iw        PK         ! eVC  VC              Xd HTMLForm/HTMLFormFieldTest.phpnu Iw        PK         ! =
  
              :e HTMLForm/HTMLFormTest.phpnu Iw        PK         ! &    ,            OVe HTMLForm/Field/HTMLRestrictionsFieldTest.phpnu Iw        PK         ! I&:    2            de HTMLForm/Field/HTMLAutoCompleteSelectFieldTest.phpnu Iw        PK         ! V  V  )            le HTMLForm/Field/HTMLTitleTextFieldTest.phpnu Iw        PK         ! ̟\    (            bze HTMLForm/Field/HTMLUserTextFieldTest.phpnu Iw        PK         ! 
U[  [  *            e HTMLForm/Field/HTMLSelectNamespaceTest.phpnu Iw        PK         ! pS  S  &            Pe HTMLForm/Field/HTMLButtonFieldTest.phpnu Iw        PK         ! Nb    %            e HTMLForm/Field/HTMLRadioFieldTest.phpnu Iw        PK         ! ٫"
  
  %            +e HTMLForm/Field/HTMLCheckFieldTest.phpnu Iw        PK         ! ֪    "            ,e HTMLForm/HTMLFormFieldTestCase.phpnu Iw        PK         !  IV!  V!              (e http/HttpRequestFactoryTest.phpnu Iw        PK         ! y ߬    $             f Permissions/RestrictionStoreTest.phpnu Iw        PK         ! ߯OL  L              f Permissions/RateLimiterTest.phpnu Iw        PK         !       /            kf Permissions/PermissionStatusIntegrationTest.phpnu Iw        PK         ! c"r K
  K
  &            a|f Permissions/GrantsLocalizationTest.phpnu Iw        PK         ! 8b  b  !            f context/DerivativeContextTest.phpnu Iw        PK         ! wQ=v*  v*              f context/RequestContextTest.phpnu Iw        PK         ! UM  M  &            yf CommentFormatter/CommentParserTest.phpnu Iw        PK         ! u?4  4  ,            Xg CommentFormatter/RowCommentFormatterTest.phpnu Iw        PK         ! 2f}#  }#  )            g CommentFormatter/CommentFormatterTest.phpnu Iw        PK         ! 6@"  "  "            Cg watchlist/WatchlistManagerTest.phpnu Iw        PK         ! ?O	  O	  -            &fg search/SearchSuggestionSetIntegrationTest.phpnu Iw        PK         ! ʿ5~    0            og editpage/Constraint/ChangeTagsConstraintTest.phpnu Iw        PK         ! kq$    A            xg editpage/Constraint/EditFilterMergedContentHookConstraintTest.phpnu Iw        PK         ! x)y.  .               gg RenameUser/RenameuserSQLTest.phpnu Iw        PK         ! J&  &  )            g Rest/Handler/LanguageLinksHandlerTest.phpnu Iw        PK         ! ^    %            dg Rest/Handler/DiscoveryHandlerTest.phpnu Iw        PK         ! wFE  E  $            g Rest/Handler/PageHTMLHandlerTest.phpnu Iw        PK         ! ?c  #            g Rest/Handler/ParsoidHandlerTest.phpnu Iw        PK         ! Cd  d  &            i Rest/Handler/ModuleSpecHandlerTest.phpnu Iw        PK         !     &            i Rest/Handler/PageSourceHandlerTest.phpnu Iw        PK         ! tM    1            /2i Rest/Handler/OpenSearchDescriptionHandlerTest.phpnu Iw        PK         ! in    %            8i Rest/Handler/HTMLHandlerTestTrait.phpnu Iw        PK         ! vSi                 Di Rest/Handler/SpecTestModule.jsonnu Iw        PK         ! r      $            iFi Rest/Handler/SpecTestFlatRoutes.jsonnu Iw        PK         ! IG  G  (            =Gi Rest/Handler/PageRedirectHandlerTest.phpnu Iw        PK         ! Fɋ?  ?  $            [i Rest/Handler/CreationHandlerTest.phpnu Iw        PK         ! {
:  :  &            i Rest/Handler/MediaLinksHandlerTest.phpnu Iw        PK         ! c<                 Ki Rest/Handler/SpecTestRoutes.jsonnu Iw        PK         !     %            i Rest/Handler/MediaFileHandlerTest.phpnu Iw        PK         ! m2K]  K]  (            i Rest/Handler/ParsoidOutputAccessTest.phpnu Iw        PK         ! ǉ    %            j Rest/Handler/TransformHandlerTest.phpnu Iw        PK         ! 	;    *            1j Rest/Handler/RevisionSourceHandlerTest.phpnu Iw        PK         ! V4?  ?  (            Gj Rest/Handler/RevisionHTMLHandlerTest.phpnu Iw        PK         !  JOR  R  2            ćj Rest/Handler/data/Transform/MainPage-original.htmlnu Iw        PK         ! I    :            xj Rest/Handler/data/Transform/MainPage-original.data-parsoidnu Iw        PK         ! |	  	  6            sj Rest/Handler/data/Transform/MainPage-data-parsoid.htmlnu Iw        PK         !     ,            ȝj Rest/Handler/data/Transform/Minimal-999.htmlnu Iw        PK         ! 7      (            'j Rest/Handler/data/Transform/Minimal.htmlnu Iw        PK         ! uw@  @  '            ~j Rest/Handler/data/Transform/Selser.htmlnu Iw        PK         ! :Q  Q  5            j Rest/Handler/data/Transform/OriginalMainPage.wikitextnu Iw        PK         ! o;  ;  &            ˨j Rest/Handler/data/Transform/Image.htmlnu Iw        PK         !      +            \j Rest/Handler/data/Transform/JsonConfig.htmlnu Iw        PK         ! #H  H  .            jj Rest/Handler/data/Transform/Image-data-mw.htmlnu Iw        PK         ! Zs!
  !
  <            j Rest/Handler/data/Transform/MainPage-data-parsoid-1.1.1.htmlnu Iw        PK         !     -            j Rest/Handler/data/Transform/Minimal-2222.htmlnu Iw        PK         ! m  m  "            j Rest/Handler/data/OpenApi-3.0.jsonnu Iw        PK         ! h~m1  m1  -            9*k Rest/Handler/Helper/PageContentHelperTest.phpnu Iw        PK         ! +    4            \k Rest/Handler/Helper/HtmlOutputRendererHelperTest.phpnu Iw        PK         ! f[*f    .            k Rest/Handler/Helper/PageRedirectHelperTest.phpnu Iw        PK         !       1            l Rest/Handler/Helper/PageRestHelperFactoryTest.phpnu Iw        PK         ! a*(  *(  1            |l Rest/Handler/Helper/RevisionContentHelperTest.phpnu Iw        PK         ! qoÓ  Ó  4            Dl Rest/Handler/Helper/HtmlInputTransformHelperTest.phpnu Iw        PK         ! #~  ~  3            .l Rest/Handler/Helper/HtmlMessageOutputHelperTest.phpnu Iw        PK         ! "P  P  "            l Rest/Handler/UpdateHandlerTest.phpnu Iw        PK         ! $<z  z  $            ~1m Rest/Handler/RedirectHandlerTest.phpnu Iw        PK         ! Pހ8  8              L9m Storage/UndoIntegrationTest.phpnu Iw        PK         ! 
y`O$  O$  #            rm Storage/EditResultBuilderDbTest.phpnu Iw        PK         ! 8)  8)  ,            m Storage/RevertedTagUpdateIntegrationTest.phpnu Iw        PK         ! x*                 Qm logging/LogPageTest.phpnu Iw        PK         ! Vxi  i              _m user/ActorStoreTestBase.phpnu Iw        PK         !  j1    *            m user/Options/UserOptionsLookupTestBase.phpnu Iw        PK         ! BkAI  I  '            m user/Options/UserOptionsManagerTest.phpnu Iw        PK         ! .    )            ?n user/Options/DefaultOptionsLookupTest.phpnu Iw        PK         ! )j&  &  #            Gn user/UserSelectQueryBuilderTest.phpnu Iw        PK         ! WVl  l              nn user/ActorStoreTest.phpnu Iw        PK         ! I~M'  '  %            }n user/TempUser/TempUserCreatorTest.phpnu Iw        PK         ! kǣ    (            o user/TempUser/RealTempUserConfigTest.phpnu Iw        PK         ! pOc
  
  #            G"o user/TempUser/TempUserTestTrait.phpnu Iw        PK         ! ~4F,  ,              ,o user/UserFactoryTest.phpnu Iw        PK         ! w    ;            Jo user/Registration/UserRegistrationLookupIntegrationTest.phpnu Iw        PK         ! js    !            WRo StubObject/StubGlobalUserTest.phpnu Iw        PK         ! ;/  /              neo StubObject/StubObjectTest.phpnu Iw        PK         ! NhZv  v              xo utils/MWFilePropsTest.phpnu Iw        PK         ! LW    )            o cache/HtmlCacheUpdaterIntegrationTest.phpnu Iw        PK         ! }                o ExtensionServicesTestBase.phpnu Iw        PK         ! V&>	  	              o mail/EmailerTest.phpnu Iw        PK         ! [                 o db/DatabaseSqliteUpgradeTest.phpnu Iw        PK         ! (\  \              -o db/DatabaseMysqlTest.phpnu Iw        PK         ! rQ6  Q6              "p db/DatabasePostgresTest.phpnu Iw        PK         ! XH  H              Xp db/DatabaseSqliteTest.phpnu Iw        PK         ! *ݭ    8            p diff/DifferenceEngineSlotDiffRendererIntegrationTest.phpnu Iw        PK         ! x
  
  !            p revisionlist/RevisionListTest.phpnu Iw        PK         ! 2F    #             p poolcounter/PoolCounterWorkTest.phpnu Iw        PK         ! X    0            :p poolcounter/PoolCounterConnectionManagerTest.phpnu Iw        PK         ! 
  
  $            +p block/BlockPermissionCheckerTest.phpnu Iw        PK         ! pB                p block/BlockUserTest.phpnu Iw        PK         ! C7Y  Y               p block/DatabaseBlockStoreTest.phpnu Iw        PK         ! 0a                Rq block/UnblockUserTest.phpnu Iw        PK         ! Tq    /            Zq ResourceLoader/ForeignResourceStructureTest.phpnu Iw        PK         ! Bz
  z
  &            sbq filerepo/LocalAndForeignDBRepoTest.phpnu Iw        PK         ! <0  <0              Cmq ExtensionJsonTestBase.phpnu Iw        PK         ! f1  1              ȝq page/DeletePageTest.phpnu Iw        PK         ! 1 @n@  n@              q page/RollbackPageTest.phpnu Iw        PK         ! \	  	              qr MediaWikiEntryPointTest.phpnu Iw        PK    t r   